mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 15:56:19 -05:00
Map list generation via weighted random (#2615)
This commit is contained in:
parent
9fc30a7624
commit
0f810d615a
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user