diff --git a/src/assets/i18n.json b/src/assets/i18n.json index 225c46465..92cddef62 100644 --- a/src/assets/i18n.json +++ b/src/assets/i18n.json @@ -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": { @@ -188,6 +189,7 @@ "addPlayerLabel": "プレイヤー名の管理", "tournamentLabelEdit": "大会ラウンドの管理", "shockArrows": "ショックアローを含む", + "noDuplicates": "ドロー間で譜面の重複を防ぐ", "invalid": "対象譜面が足りません!" }, "weights": { diff --git a/src/card-draw.ts b/src/card-draw.ts index b5e39a633..b01778e39 100644 --- a/src/card-draw.ts +++ b/src/card-draw.ts @@ -230,6 +230,23 @@ function bucketIndexForLvl(lvl: number, buckets: LvlRanges): number | null { export type DrawingMeta = Pick; 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; + /** 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(); /** @@ -242,7 +259,7 @@ export function draw( gameData: GameData, configData: ConfigState, startPoint: StartingPoint, -) { +): DrawResult { const { chartCount: numChartsToRandom, useWeights, @@ -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>(() => []); - const availableLvls = getAvailableLevels(gameData, useGranularLevels); const buckets = Array.from( getBuckets(configData, availableLvls, gameData.meta.granularTierResolution), @@ -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>(() => []); + 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); + } + } } /** @@ -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( @@ -455,7 +504,7 @@ export function draw( charts.unshift(...times(configData.playerPicks, newPlaceholder)); } - return charts; + return { charts, newlyUsedKeys, deckWasReset }; } export function newPlaceholder(): PlayerPickPlaceholder { diff --git a/src/controls/controls-drawer.tsx b/src/controls/controls-drawer.tsx index 7f298734f..45f7b3742 100644 --- a/src/controls/controls-drawer.tsx +++ b/src/controls/controls-drawer.tsx @@ -213,6 +213,7 @@ function GeneralSettings() { chartCount, sortByLevel, useGranularLevels, + noDuplicates, playerPicks, } = configState; const availableDifficulties = useMemo(() => { @@ -381,6 +382,15 @@ function GeneralSettings() { }} label={t("controls.sortByLevel")} /> + { + const noDuplicates = !!e.currentTarget.checked; + updateState({ noDuplicates }); + }} + label={t("controls.noDuplicates")} + /> ; /** if present, will draw an additional set of cards for each string id in `configs` */ multiDraws?: { /** if true, auto-merge the resulting draws */ @@ -52,16 +61,47 @@ export const defaultConfig: Omit = { sortByLevel: false, defaultPlayersPerDraw: 2, useGranularLevels: false, + noDuplicates: false, + usedCharts: [], }; const adapter = createEntityAdapter({}); +/** 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>) { + 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: { diff --git a/src/state/thunks.ts b/src/state/thunks.ts index 8b922a246..bfe3a6a97 100644 --- a/src/state/thunks.ts +++ b/src/state/thunks.ts @@ -1,5 +1,5 @@ import { AppThunk } from "./store"; -import { draw, DrawingMeta, newPlaceholder } from "../card-draw"; +import { draw, DrawResult, DrawingMeta, newPlaceholder } from "../card-draw"; import { getLastGameSelected, loadStockGamedataByName, @@ -30,6 +30,24 @@ function trackDraw(count: number | null, game?: string) { umami.track("cards-drawn", results); } +/** Update usedCharts on a config after a draw, if noDuplicates is enabled */ +function updateDeckState( + dispatch: (action: ReturnType) => void, + config: ConfigState, + result: DrawResult, +) { + if (!config.noDuplicates) return; + const usedCharts = result.deckWasReset + ? result.newlyUsedKeys + : (config.usedCharts || []).concat(result.newlyUsedKeys); + dispatch( + configSlice.actions.updateOne({ + id: config.id, + changes: { usedCharts }, + }), + ); +} + /** * Thunk creator for performing a new draw * @returns false if draw was unsuccessful @@ -52,12 +70,13 @@ export function createDraw( return "nok"; // no draw was possible } - const charts = draw(gameData, config, drawMeta); - if (!charts.length) { + const result = draw(gameData, config, drawMeta); + if (!result.charts.length) { showDrawErrorToast(); trackDraw(null); return "nok"; // could not draw the requested number of charts } + updateDeckState(dispatch, config, result); const players = drawMeta.meta.type === "simple" @@ -69,7 +88,7 @@ export function createDraw( const mainDraw: SubDrawing = { compoundId: [matchId, setId], configId, - charts, + charts: result.charts, }; const drawing: Drawing = { id: matchId, @@ -82,7 +101,7 @@ export function createDraw( configId, subDrawings: { [setId]: mainDraw }, }; - trackDraw(charts.length, gameData.i18n.en.name as string); + trackDraw(result.charts.length, gameData.i18n.en.name as string); if (config.multiDraws) { for (const otherConfigId of config.multiDraws.configs) { @@ -101,20 +120,24 @@ export function createDraw( console.error("couldnt perform extra draw, no game data"); continue; } - const otherCharts = draw(otherGameData, otherConfig, drawMeta); - if (!otherCharts.length) { + const otherResult = draw(otherGameData, otherConfig, drawMeta); + if (!otherResult.charts.length) { continue; // could not draw the requested number of charts } + updateDeckState(dispatch, otherConfig, otherResult); - trackDraw(otherCharts.length, otherGameData.i18n.en.name as string); + trackDraw( + otherResult.charts.length, + otherGameData.i18n.en.name as string, + ); if (config.multiDraws.merge) { - mainDraw.charts = mainDraw.charts.concat(otherCharts); + mainDraw.charts = mainDraw.charts.concat(otherResult.charts); } else { const otherSetId = `set-${nanoid(12)}`; drawing.subDrawings[otherSetId] = { compoundId: [drawing.id, otherSetId], configId: otherConfigId, - charts: otherCharts, + charts: otherResult.charts, }; } } @@ -149,18 +172,23 @@ export function createSubdraw( } const existingDraw = state.drawings.entities[parentDrawId]; - const charts = draw(gameData, config, { meta: existingDraw.meta }); - trackDraw(charts.length, gameData.i18n.en.name as string); - if (!charts.length) { + const result = draw(gameData, config, { meta: existingDraw.meta }); + trackDraw(result.charts.length, gameData.i18n.en.name as string); + if (!result.charts.length) { showDrawErrorToast(); return "nok"; // could not draw the requested number of charts } + updateDeckState(dispatch, config, result); const setId = `set-${nanoid(12)}`; dispatch( drawingsSlice.actions.addSubdraw({ existingDrawId: parentDrawId, - newSubdraw: { compoundId: [parentDrawId, setId], configId, charts }, + newSubdraw: { + compoundId: [parentDrawId, setId], + configId, + charts: result.charts, + }, }), ); return "ok"; @@ -195,14 +223,15 @@ export function createRedrawAll(drawingId: CompoundSetId): AppThunk { }; const gameData = await loadStockGamedataByName(originalConfig.gameKey); - const charts = draw(gameData!, drawConfig, { + const result = draw(gameData!, drawConfig, { meta: parent.meta, charts: chartsToKeep, }); + updateDeckState(dispatch, originalConfig, result); dispatch( drawingsSlice.actions.updateCharts({ drawId: drawingId, - newCharts: chartsToKeep.concat(charts), + newCharts: chartsToKeep.concat(result.charts), }), ); }; @@ -227,11 +256,11 @@ export function createRedrawChart( const gameData = await loadStockGamedataByName(customConfig.gameKey); if (!gameData) return; - const charts = draw(gameData, customConfig, { + const result = draw(gameData, customConfig, { meta: parent.meta, charts: target.charts.filter((chart) => chart.id !== chartId), }); - const chart = charts.pop(); + const chart = result.charts.pop(); if ( !chart || chart.type !== "DRAWN" || @@ -240,6 +269,7 @@ export function createRedrawChart( showDrawErrorToast(); return; // result didn't include a new chart } + updateDeckState(dispatch, customConfig, result); dispatch( drawingsSlice.actions.updateOneChart({ drawingId, @@ -286,11 +316,11 @@ export function createPlusOneChart( ), }; - const charts = draw(gameData, customConfig, { + const result = draw(gameData, customConfig, { meta: parent.meta, charts: target.charts, }); - const chart = charts.pop(); + const chart = result.charts.pop(); if ( !chart || chart.type !== "DRAWN" || @@ -299,6 +329,7 @@ export function createPlusOneChart( showDrawErrorToast(); return; // result didn't include a new chart } + updateDeckState(dispatch, originalConfig, result); return dispatch(drawingsSlice.actions.addOneChart({ drawingId, chart })); }; }