Map list generation via weighted random (#2615)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2025-11-03 22:01:46 +02:00 committed by GitHub
parent 9fc30a7624
commit 0f810d615a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 105 additions and 131 deletions

View File

@ -310,26 +310,6 @@ describe("MapList.generate()", () => {
}
});
// TODO: fix flaky
it("replenishes the stage id pool when exhausted", { retry: 10 }, () => {
const gen = initGenerator(
new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
TC: [],
RM: [],
CB: [],
}),
);
const first = gen.next({ amount: 5 }).value.map((m) => m.stageId);
gen.next({ amount: 5 });
const third = gen.next({ amount: 5 }).value.map((m) => m.stageId);
for (const stageId of third) {
expect(first).toContainEqual(stageId);
}
});
// TODO: fix flaky
it(
"replenishes the stage id pool with different order",
@ -360,7 +340,7 @@ describe("MapList.generate()", () => {
},
);
it("replenishes accordingly if considerGuaranteed is true (Bo3)", () => {
it("considerGuaranteed affects the order maps are reused", () => {
for (let i = 0; i < 10; i++) {
const gen = MapList.generate({
mapPool: new MapPool({
@ -385,68 +365,33 @@ describe("MapList.generate()", () => {
}
});
it("replenishes accordingly if considerGuaranteed is true (Bo5)", () => {
for (let i = 0; i < 10; i++) {
it("should find unique maps when possible (All 4 One #50 bug)", () => {
const mapPool = new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5, 6, 7],
TC: [],
RM: [],
CB: [],
});
for (let i = 0; i < 50; i++) {
const gen = MapList.generate({
mapPool: new MapPool({
TW: [1, 2, 3, 4, 5],
SZ: [],
TC: [],
RM: [],
CB: [],
}),
mapPool,
considerGuaranteed: true,
});
gen.next();
const maps1 = gen.next({ amount: 5 }).value;
const notGuaranteedToBePlayed = [maps1[3].stageId, maps1[4].stageId];
gen.next({ amount: 5 });
const maps2 = gen.next({ amount: 5 }).value;
const maps = gen.next({ amount: 5 }).value;
expect([
maps2[0].stageId,
maps2[1].stageId,
maps2[2].stageId,
]).toContain(notGuaranteedToBePlayed[0]);
expect([
maps2[0].stageId,
maps2[1].stageId,
maps2[2].stageId,
]).toContain(notGuaranteedToBePlayed[1]);
const stageIds = maps.map((m) => m.stageId);
const uniqueStageIds = new Set(stageIds);
expect(uniqueStageIds.size).toBe(5);
}
});
it.fails(
"should find unique maps when possible (All 4 One #50 bug)",
() => {
const mapPool = new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5, 6, 7],
TC: [],
RM: [],
CB: [],
});
for (let i = 0; i < 50; i++) {
const gen = MapList.generate({
mapPool,
considerGuaranteed: true,
});
gen.next();
gen.next({ amount: 5 });
const maps = gen.next({ amount: 5 }).value;
const stageIds = maps.map((m) => m.stageId);
const uniqueStageIds = new Set(stageIds);
expect(uniqueStageIds.size).toBe(5);
}
},
);
// also add test about Bo7 not having repeat maps
});
});

View File

@ -46,9 +46,11 @@ export function* generate(args: {
const modes = args.mapPool.modes;
const stageCounts = initializeStageCounts(modes, args.mapPool.parsed);
const { stageWeights, stageModeWeights } = initializeWeights(
modes,
args.mapPool.parsed,
);
const orderedModes = modeOrders(modes);
const stageUsageTracker = new Map<StageId, number>();
let currentOrderIndex = 0;
const firstArgs = yield [];
@ -72,20 +74,32 @@ export function* generate(args: {
for (let i = 0; i < amount; i++) {
const mode = currentModeOrder[i % currentModeOrder.length];
const possibleStages = R.shuffle(args.mapPool.parsed[mode]);
const possibleStages = args.mapPool.parsed[mode];
const isNotGuaranteedToBePlayed = args.considerGuaranteed
? Math.ceil(amount / 2) <= i
: false;
replenishStageIds({ possibleStages, stageCounts, stageUsageTracker });
const stageId = mostRarelySeenStage(possibleStages, stageCounts);
const stageId = selectStageWeighted({
possibleStages,
mode,
stageWeights,
stageModeWeights,
});
result.push({ mode, stageId });
stageCounts.set(
stageId,
stageCounts.get(stageId)! + (isNotGuaranteedToBePlayed ? 0.5 : 1),
);
stageUsageTracker.set(stageId, currentOrderIndex);
for (const [key, value] of stageWeights.entries()) {
stageWeights.set(key, value + 1);
}
for (const [key, value] of stageModeWeights.entries()) {
stageModeWeights.set(key, value + 1);
}
const stageWeightPenalty = isNotGuaranteedToBePlayed ? -2 : -5;
const stageModeWeightPenalty = args.mapPool.modes.length > 1 ? -10 : 0;
stageWeights.set(stageId, stageWeightPenalty);
stageModeWeights.set(`${stageId}-${mode}`, stageModeWeightPenalty);
}
currentOrderIndex++;
@ -97,65 +111,80 @@ export function* generate(args: {
}
}
function initializeStageCounts(
modes: ModeShort[],
mapPool: ReadonlyMapPoolObject,
) {
const counts = new Map<StageId, number>();
const stageIds = modes.flatMap((mode) => mapPool[mode]);
function initializeWeights(modes: ModeShort[], mapPool: ReadonlyMapPoolObject) {
const stageWeights = new Map<StageId, number>();
const stageModeWeights = new Map<string, number>();
for (const stageId of stageIds) {
counts.set(stageId, 0);
for (const mode of modes) {
const stageIds = mapPool[mode];
for (const stageId of stageIds) {
stageWeights.set(stageId, 0);
stageModeWeights.set(`${stageId}-${mode}`, 0);
}
}
return counts;
return { stageWeights, stageModeWeights };
}
/** This function is used for controlling in which order we start reusing the stage ids */
function replenishStageIds({
function weightedRandomSelect<T>(
candidates: T[],
getWeight: (candidate: T) => number,
): T {
const totalWeight = candidates.reduce(
(sum, candidate) => sum + Math.max(0, getWeight(candidate)),
0,
);
invariant(totalWeight > 0, "Expected at least one candidate with weight > 0");
let random = Math.random() * totalWeight;
for (const candidate of candidates) {
const weight = Math.max(0, getWeight(candidate));
random -= weight;
if (random <= 0) {
return candidate;
}
}
return candidates[candidates.length - 1];
}
function selectStageWeighted({
possibleStages,
stageCounts,
stageUsageTracker,
mode,
stageWeights,
stageModeWeights,
}: {
possibleStages: StageId[];
stageCounts: Map<StageId, number>;
stageUsageTracker: Map<StageId, number>;
}) {
const allOptionsEqual = possibleStages.every(
(stageId) =>
stageCounts.get(stageId) === stageCounts.get(possibleStages[0]),
);
if (!allOptionsEqual) return;
possibleStages: readonly StageId[];
mode: ModeShort;
stageWeights: Map<StageId, number>;
stageModeWeights: Map<string, number>;
}): StageId {
const getCandidates = () =>
possibleStages.filter((stageId) => {
const stageWeight = stageWeights.get(stageId) ?? 0;
const stageModeWeight = stageModeWeights.get(`${stageId}-${mode}`) ?? 0;
return stageWeight >= 0 && stageModeWeight >= 0;
});
const relevantStageUsage = Array.from(stageUsageTracker.entries())
.filter(([stageId]) => possibleStages.includes(stageId))
.sort((a, b) => a[1] - b[1]);
let candidates = getCandidates();
const stagesToReplenish: StageId[] = [];
for (const [stageId] of relevantStageUsage) {
stagesToReplenish.push(stageId);
if (stagesToReplenish.length >= possibleStages.length / 2) break;
while (candidates.length === 0) {
for (const [key, value] of stageWeights.entries()) {
stageWeights.set(key, value + 1);
}
for (const [key, value] of stageModeWeights.entries()) {
stageModeWeights.set(key, value + 1);
}
candidates = getCandidates();
}
for (const stageId of stagesToReplenish) {
stageCounts.set(stageId, (stageCounts.get(stageId) ?? 0) - 0.5);
}
}
function mostRarelySeenStage(
possibleStages: StageId[],
stageCounts: Map<StageId, number>,
) {
const result = possibleStages.toSorted(
(a, b) => stageCounts.get(a)! - stageCounts.get(b)!,
)[0];
invariant(
typeof result === "number",
"Expected to find at least one stage ID",
);
return result;
return weightedRandomSelect(candidates, (stageId) => {
const stageWeight = stageWeights.get(stageId) ?? 0;
const stageModeWeight = stageModeWeights.get(`${stageId}-${mode}`) ?? 0;
return stageWeight + stageModeWeight + 1;
});
}
const MAX_MODE_ORDERS_ITERATIONS = 100;