mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #78 from ZHANGTIANYAO1/feat/shuffle-bag-random-modes
feat(queue): 随机循环改为洗牌袋,每首歌播完一轮再重复 (优化随机循环逻辑)
This commit is contained in:
2 files changed
+106
-17
No files matched your search
@@ -484,4 +484,84 @@ describe("PlayQueue", () => {
|
|||||||
expect(promoted?.id).toBe("x");
|
expect(promoted?.id).toBe("x");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
|
||||||
|
// songs repeated often while others were starved. It should behave like a
|
||||||
|
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
|
||||||
|
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
|
||||||
|
describe("random-loop shuffle bag (issue #70)", () => {
|
||||||
|
it("plays every song exactly once per cycle before repeating", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 12;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
const cycle1 = [queue.current()!.id];
|
||||||
|
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
|
||||||
|
const cycle2: string[] = [];
|
||||||
|
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
|
||||||
|
|
||||||
|
// Each cycle is a full permutation of all N songs — zero repeats within
|
||||||
|
// a cycle, and both cycles cover the same complete set.
|
||||||
|
expect(new Set(cycle1).size).toBe(N);
|
||||||
|
expect(new Set(cycle2).size).toBe(N);
|
||||||
|
expect(new Set(cycle1)).toEqual(new Set(cycle2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 6;
|
||||||
|
const CYCLES = 20;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
counts.set(queue.current()!.id, 1);
|
||||||
|
for (let i = 0; i < CYCLES * N - 1; i++) {
|
||||||
|
const id = queue.next()!.id;
|
||||||
|
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle bag => each song plays exactly CYCLES times. True random
|
||||||
|
// would skew heavily.
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
expect(counts.get(`s${i}`)).toBe(CYCLES);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replay the same song across a cycle boundary", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 5;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
// Walk to the last song of cycle 1, then cross into cycle 2.
|
||||||
|
for (let i = 0; i < N - 1; i++) queue.next();
|
||||||
|
const lastOfCycle1 = queue.current()!.id;
|
||||||
|
const firstOfCycle2 = queue.next()!.id;
|
||||||
|
expect(firstOfCycle2).not.toBe(lastOfCycle1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a song added mid-cycle within the current cycle", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.play(); // A
|
||||||
|
queue.next(); // B — both originals now played this cycle
|
||||||
|
queue.add(makeSong("C")); // added mid-cycle, still unplayed
|
||||||
|
// C is the only unplayed song, so it must come next (not a reshuffle).
|
||||||
|
expect(queue.next()?.id).toBe("C");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps looping forever with multiple songs (never returns null)", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.add(makeSong("C"));
|
||||||
|
queue.play();
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
expect(queue.next()).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+26
-17
@@ -155,33 +155,42 @@ export class PlayQueue {
|
|||||||
return this.songs[target];
|
return this.songs[target];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 前进栈为空,走纯随机逻辑
|
|
||||||
if (this.mode === PlayMode.Random) {
|
// Shuffle bag: pick uniformly from the songs not yet played this
|
||||||
|
// cycle, so every song plays once before any repeats (NetEase/QQ
|
||||||
|
// style). Songs added mid-cycle aren't in playedIndices, so they're
|
||||||
|
// naturally eligible within the current cycle.
|
||||||
const unplayed: number[] = [];
|
const unplayed: number[] = [];
|
||||||
for (let i = 0; i < this.songs.length; i++) {
|
for (let i = 0; i < this.songs.length; i++) {
|
||||||
if (!this.playedIndices.has(i)) unplayed.push(i);
|
if (!this.playedIndices.has(i)) unplayed.push(i);
|
||||||
}
|
}
|
||||||
if (unplayed.length === 0) return null;
|
|
||||||
|
if (unplayed.length === 0) {
|
||||||
|
// Cycle complete.
|
||||||
|
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
|
||||||
|
// 随机循环:reshuffle and keep going forever.
|
||||||
|
if (this.songs.length === 1) {
|
||||||
|
this.pushHistory(this.currentIndex);
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.playedIndices = new Set([0]);
|
||||||
|
return this.songs[0];
|
||||||
|
}
|
||||||
|
// Start a fresh cycle: every song is eligible again, but exclude
|
||||||
|
// the song that just played from THIS pick only, so it doesn't
|
||||||
|
// repeat back-to-back across the boundary. It stays eligible for
|
||||||
|
// the rest of the new cycle, so every song still plays exactly once.
|
||||||
|
this.playedIndices = new Set();
|
||||||
|
for (let i = 0; i < this.songs.length; i++) {
|
||||||
|
if (i !== this.currentIndex) unplayed.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nextIndex =
|
const nextIndex =
|
||||||
unplayed[Math.floor(Math.random() * unplayed.length)];
|
unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||||
this.pushHistory(this.currentIndex);
|
this.pushHistory(this.currentIndex);
|
||||||
this.currentIndex = nextIndex;
|
this.currentIndex = nextIndex;
|
||||||
this.playedIndices.add(nextIndex);
|
this.playedIndices.add(nextIndex);
|
||||||
return this.songs[nextIndex];
|
return this.songs[nextIndex];
|
||||||
} else {
|
|
||||||
if (this.songs.length === 1) {
|
|
||||||
this.pushHistory(this.currentIndex);
|
|
||||||
this.currentIndex = 0;
|
|
||||||
return this.songs[0];
|
|
||||||
}
|
|
||||||
let idx: number;
|
|
||||||
do {
|
|
||||||
idx = Math.floor(Math.random() * this.songs.length);
|
|
||||||
} while (idx === this.currentIndex);
|
|
||||||
this.pushHistory(this.currentIndex);
|
|
||||||
this.currentIndex = idx;
|
|
||||||
return this.songs[idx];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user