Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/assets/i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"addPlayerLabel": "Manage Player Names",
"tournamentLabelEdit": "Tournament round labels",
"shockArrows": "Shock Arrows",
"noDuplicates": "No duplicate charts between draws",
"invalid": "Couldn't draw enough charts with current settings!"
},
"weights": {
Expand Down Expand Up @@ -188,6 +189,7 @@
"addPlayerLabel": "プレイヤー名の管理",
"tournamentLabelEdit": "大会ラウンドの管理",
"shockArrows": "ショックアローを含む",
"noDuplicates": "ドロー間で譜面の重複を防ぐ",
"invalid": "対象譜面が足りません!"
},
"weights": {
Expand Down
69 changes: 59 additions & 10 deletions src/card-draw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,23 @@ function bucketIndexForLvl(lvl: number, buckets: LvlRanges): number | null {
export type DrawingMeta = Pick<Drawing, "meta">;
export type StartingPoint = DrawingMeta & { charts?: Drawing["charts"] };

/** Produces a stable string key that uniquely identifies a chart within a game */
export function chartKey(chart: {
name: string;
diffAbbr: string;
level: number;
}): string {
return `${chart.name}|${chart.diffAbbr}|${chart.level}`;
}

export interface DrawResult {
charts: Array<DrawnChart | PlayerPickPlaceholder>;
/** keys of charts newly drawn in this call (for deck tracking) */
newlyUsedKeys: string[];
/** true if the deck ran out and was reshuffled during this draw */
deckWasReset: boolean;
}

const artistDrawBlocklist = new Set();

/**
Expand All @@ -242,7 +259,7 @@ export function draw(
gameData: GameData,
configData: ConfigState,
startPoint: StartingPoint,
) {
): DrawResult {
const {
chartCount: numChartsToRandom,
useWeights,
Expand All @@ -251,9 +268,6 @@ export function draw(
useGranularLevels,
} = configData;

/** all charts we will consider to be valid for this draw, mapped by bucket index */
const validCharts = new DefaultingMap<number, Array<EligibleChart>>(() => []);

const availableLvls = getAvailableLevels(gameData, useGranularLevels);
const buckets = Array.from(
getBuckets(configData, availableLvls, gameData.meta.granularTierResolution),
Expand All @@ -265,11 +279,43 @@ export function draw(
: 0; // outside of weights mode we just put all songs into one shared bucket
}

for (const chart of eligibleCharts(configData, gameData)) {
if (artistDrawBlocklist.has(chart.artist)) continue;
const bucketIdx = bucketIndexForChart(chart);
if (bucketIdx === null) continue;
validCharts.get(bucketIdx).push(chart);
/** Collect all eligible charts into buckets */
function buildValidCharts() {
const result = new DefaultingMap<number, Array<EligibleChart>>(() => []);
for (const chart of eligibleCharts(configData, gameData)) {
if (artistDrawBlocklist.has(chart.artist)) continue;
const bucketIdx = bucketIndexForChart(chart);
if (bucketIdx === null) continue;
result.get(bucketIdx).push(chart);
}
return result;
}

/** all charts we will consider to be valid for this draw, mapped by bucket index */
const validCharts = buildValidCharts();

// noDuplicates: filter out previously used charts from the deck
let deckWasReset = false;
const usedKeySet =
configData.noDuplicates && configData.usedCharts?.length
? new Set(configData.usedCharts)
: null;

if (usedKeySet) {
let totalRemaining = 0;
for (const [bucketIdx, charts] of validCharts) {
const filtered = charts.filter((c) => !usedKeySet.has(chartKey(c)));
validCharts.set(bucketIdx, filtered);
totalRemaining += filtered.length;
}
// If not enough charts remain for the requested draw, reset the deck
if (totalRemaining < numChartsToRandom) {
deckWasReset = true;
// Rebuild from full pool (no filtering)
for (const [bucketIdx, charts] of buildValidCharts()) {
validCharts.set(bucketIdx, charts);
}
}
}

/**
Expand Down Expand Up @@ -440,6 +486,9 @@ export function draw(
}
} while (redraw);

// Collect keys of all newly drawn charts for deck tracking
const newlyUsedKeys = drawnCharts.map((c) => chartKey(c));

let charts: Drawing["charts"];
if (configData.sortByLevel) {
charts = drawnCharts.sort(
Expand All @@ -455,7 +504,7 @@ export function draw(
charts.unshift(...times(configData.playerPicks, newPlaceholder));
}

return charts;
return { charts, newlyUsedKeys, deckWasReset };
}

export function newPlaceholder(): PlayerPickPlaceholder {
Expand Down
10 changes: 10 additions & 0 deletions src/controls/controls-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ function GeneralSettings() {
chartCount,
sortByLevel,
useGranularLevels,
noDuplicates,
playerPicks,
} = configState;
const availableDifficulties = useMemo(() => {
Expand Down Expand Up @@ -381,6 +382,15 @@ function GeneralSettings() {
}}
label={t("controls.sortByLevel")}
/>
<Checkbox
id="noDuplicates"
checked={noDuplicates}
onChange={(e) => {
const noDuplicates = !!e.currentTarget.checked;
updateState({ noDuplicates });
}}
label={t("controls.noDuplicates")}
/>
<Checkbox
id="useGranularLevels"
disabled={!gameData.meta.granularTierResolution}
Expand Down
2 changes: 1 addition & 1 deletion src/controls/degrs-tester.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function* oneMillionDraws(
yield [
draw(gameData, configState!, {
meta: { players: [], title: "", type: "simple" },
}),
}).charts,
idx,
] as const;
}
Expand Down
44 changes: 42 additions & 2 deletions src/state/config.slice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
import {
createSlice,
createEntityAdapter,
type PayloadAction,
type Update,
} from "@reduxjs/toolkit";

export interface ConfigState {
id: string;
Expand All @@ -23,6 +28,10 @@ export interface ConfigState {
defaultPlayersPerDraw: number;
sortByLevel: boolean;
useGranularLevels: boolean;
/** when true, charts are not repeated between draws until all eligible charts have been seen */
noDuplicates: boolean;
/** chart keys already drawn from the deck (used when noDuplicates is enabled) */
usedCharts: Array<string>;
/** if present, will draw an additional set of cards for each string id in `configs` */
multiDraws?: {
/** if true, auto-merge the resulting draws */
Expand Down Expand Up @@ -52,16 +61,47 @@ export const defaultConfig: Omit<ConfigState, "id" | "name" | "gameKey"> = {
sortByLevel: false,
defaultPlayersPerDraw: 2,
useGranularLevels: false,
noDuplicates: false,
usedCharts: [],
};

const adapter = createEntityAdapter<ConfigState>({});

/** Config fields that affect which charts are eligible for drawing */
const ELIGIBILITY_FIELDS = new Set([
"gameKey",
"style",
"difficulties",
"lowerBound",
"upperBound",
"useGranularLevels",
"folders",
"flags",
"useWeights",
"weights",
"probabilityBucketCount",
"cutoffDate",
]);

export const configSlice = createSlice({
name: "config",
initialState: adapter.getInitialState(),
reducers: {
addOne: adapter.addOne,
updateOne: adapter.updateOne,
updateOne(state, action: PayloadAction<Update<ConfigState, string>>) {
const { changes } = action.payload;
// Auto-clear usedCharts when eligibility-affecting fields change
if (
!("usedCharts" in changes) &&
Object.keys(changes).some((k) => ELIGIBILITY_FIELDS.has(k))
) {
action.payload = {
...action.payload,
changes: { ...changes, usedCharts: [] },
};
}
adapter.updateOne(state, action);
},
removeOne: adapter.removeOne,
},
selectors: {
Expand Down
Loading