feat(frontend): wire bot engine into solo-vs-bot split-view match
This commit is contained in:
parent
12423cec1d
commit
0d1de48841
3 changed files with 307 additions and 10 deletions
267
frontend/src/game/botMatch.ts
Normal file
267
frontend/src/game/botMatch.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
// Orchestrates one live solo-vs-bot round: drives the `OpponentView` split
|
||||||
|
// pane from a locally computed `computeBotSchedule` (see `engine/bot`)
|
||||||
|
// instead of network `round:progress` messages, then renders the same
|
||||||
|
// two-column scoreboard `MultiplayerMatch` uses once the round ends.
|
||||||
|
//
|
||||||
|
// Mirrors `multiplayerMatch.ts`'s shape and two-phase-init convention —
|
||||||
|
// `ThemeGuessGame` has no knowledge of this module either: `soloConfigFlow`
|
||||||
|
// supplies its `onCategoryAssigned` constructor callback and routes it into
|
||||||
|
// `BotMatch.handleLocalAssignment`, exactly like `MultiplayerMatch` docs
|
||||||
|
// describe. The one structural difference: there is no server, so this
|
||||||
|
// module also decides *when* the round ends (timer deadline, or — in
|
||||||
|
// no-limit mode, which has no deadline — once the bot's schedule has fully
|
||||||
|
// played out and the player has manually revealed via the existing Reveal
|
||||||
|
// button, matching how `ThemeGuessGame.reveal()` stays manually triggered
|
||||||
|
// for solo play in every time mode).
|
||||||
|
|
||||||
|
import { tokenize } from '../engine/tokenizer';
|
||||||
|
import { rgbToHex } from '../engine/colorUtils';
|
||||||
|
import { computeBotSchedule } from '../engine/bot';
|
||||||
|
import type { BotDifficulty, BotPick, BotTimeMode } from '../engine/bot';
|
||||||
|
import { CATEGORY_META, THEMES } from '../data/themes';
|
||||||
|
import { SNIPPETS } from '../data/snippets';
|
||||||
|
import type { CategoryId, CategoryStateMap, ThemeId } from '../types';
|
||||||
|
import { UNSET_BG_RGB, UNSET_FG_RGB } from './layoutConstants';
|
||||||
|
import { computeMatchResult } from './scoring';
|
||||||
|
import { renderMultiplayerResult } from './resultView';
|
||||||
|
import type { MultiplayerResultElements } from './resultView';
|
||||||
|
import { OpponentView } from './opponentView';
|
||||||
|
|
||||||
|
function requireEl<T extends HTMLElement>(id: string): T {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) throw new Error(`Missing required element #${id}`);
|
||||||
|
return el as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_BG_HEX = rgbToHex(UNSET_BG_RGB);
|
||||||
|
const FALLBACK_FG_HEX = rgbToHex(UNSET_FG_RGB);
|
||||||
|
|
||||||
|
/** Every category defaulted to its "never painted" placeholder — pads a
|
||||||
|
* partial submission (player or bot) at round end. Mirrors
|
||||||
|
* `multiplayerMatch.ts`'s identically named helper. */
|
||||||
|
function defaultColorMap(): Record<CategoryId, string> {
|
||||||
|
const result = {} as Record<CategoryId, string>;
|
||||||
|
for (const def of CATEGORY_META) {
|
||||||
|
result[def.id] = def.id === 'background' ? FALLBACK_BG_HEX : FALLBACK_FG_HEX;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the same per-category `weight`/`actualHex` derivation
|
||||||
|
* `ThemeGuessGame`'s private `build()` does from a snippet + theme, then
|
||||||
|
* layers a resolved color map (player's or bot's) over it so
|
||||||
|
* `computeMatchResult` can score it — the animation-only fields are
|
||||||
|
* filled with inert placeholders. Mirrors `multiplayerMatch.ts`'s
|
||||||
|
* identically named helper. */
|
||||||
|
function buildCategoryStateMap(
|
||||||
|
snippetIndex: number,
|
||||||
|
themeId: ThemeId,
|
||||||
|
colors: Record<CategoryId, string>,
|
||||||
|
): CategoryStateMap {
|
||||||
|
const tokens = tokenize(SNIPPETS[snippetIndex]);
|
||||||
|
const counts: Partial<Record<CategoryId, number>> = {};
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
|
||||||
|
const id = t.type as CategoryId;
|
||||||
|
counts[id] = (counts[id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
const maxCount = Math.max(1, ...Object.values(counts));
|
||||||
|
const theme = THEMES[themeId];
|
||||||
|
|
||||||
|
const categories = {} as CategoryStateMap;
|
||||||
|
for (const def of CATEGORY_META) {
|
||||||
|
const count = counts[def.id] ?? 0;
|
||||||
|
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
|
||||||
|
categories[def.id] = {
|
||||||
|
...def,
|
||||||
|
actualHex: theme.colors[def.id],
|
||||||
|
weight: def.id === 'background' ? maxCount : Math.max(count, 3),
|
||||||
|
count,
|
||||||
|
assignedHex: colors[def.id] ?? null,
|
||||||
|
currentRgb: { ...unset },
|
||||||
|
fromRgb: { ...unset },
|
||||||
|
toRgb: { ...unset },
|
||||||
|
transitionStart: 0,
|
||||||
|
pulsePhase: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deadline for the two clocked time modes, mirroring
|
||||||
|
* `soloConfigFlow.ts`'s (unexported) `TIME_MODE_DEADLINE_MS` — kept as a
|
||||||
|
* separate local copy since that module doesn't export it, matching this
|
||||||
|
* codebase's existing per-module duplication of the same small constant
|
||||||
|
* (see `multiplayerMatch.ts` vs `ThemeGuessGame.ts`'s category-map
|
||||||
|
* builders). No-limit mode has no deadline, so no entry here. */
|
||||||
|
const ROUND_DEADLINE_MS: Record<Exclude<BotTimeMode, 'none'>, number> = {
|
||||||
|
'1min': 60_000,
|
||||||
|
'2min': 120_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BotMatchConfig {
|
||||||
|
themeId: ThemeId;
|
||||||
|
snippetIndex: number;
|
||||||
|
timeMode: BotTimeMode;
|
||||||
|
difficulty: BotDifficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BotMatch {
|
||||||
|
private readonly splitView = requireEl<HTMLElement>('split-view');
|
||||||
|
private readonly resultModal = requireEl<HTMLElement>('result-modal');
|
||||||
|
private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard');
|
||||||
|
private readonly soloResultEls = [
|
||||||
|
requireEl<HTMLElement>('solo-score-meter-wrap'),
|
||||||
|
requireEl<HTMLElement>('score-verdict'),
|
||||||
|
requireEl<HTMLElement>('score-breakdown'),
|
||||||
|
requireEl<HTMLElement>('compare-toggle'),
|
||||||
|
];
|
||||||
|
private readonly mpResultElements: MultiplayerResultElements = {
|
||||||
|
banner: requireEl<HTMLElement>('mp-winner-banner'),
|
||||||
|
you: {
|
||||||
|
number: requireEl<HTMLElement>('mp-you-number'),
|
||||||
|
meter: requireEl<HTMLElement>('mp-you-meter'),
|
||||||
|
breakdown: requireEl<HTMLElement>('mp-you-breakdown'),
|
||||||
|
},
|
||||||
|
opponent: {
|
||||||
|
number: requireEl<HTMLElement>('mp-opponent-number'),
|
||||||
|
meter: requireEl<HTMLElement>('mp-opponent-meter'),
|
||||||
|
breakdown: requireEl<HTMLElement>('mp-opponent-breakdown'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
private readonly revealBtn = requireEl<HTMLButtonElement>('reveal-btn');
|
||||||
|
|
||||||
|
private opponentView: OpponentView | null = null;
|
||||||
|
private schedule: BotPick[] = [];
|
||||||
|
private pickTimeoutIds: number[] = [];
|
||||||
|
private deadlineTimeoutId: number | null = null;
|
||||||
|
private remainingPicks = 0;
|
||||||
|
|
||||||
|
private themeId: ThemeId | null = null;
|
||||||
|
private snippetIndex: number | null = null;
|
||||||
|
private timeMode: BotTimeMode = 'none';
|
||||||
|
private localColors: Partial<Record<CategoryId, string>> = {};
|
||||||
|
|
||||||
|
private ended = true;
|
||||||
|
private playerRevealed = false;
|
||||||
|
private botFinished = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.revealBtn.addEventListener('click', this.onRevealClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ThemeGuessGame`'s `onCategoryAssigned` callback target: tracks the
|
||||||
|
* local player's running color map for the eventual scoreboard. A
|
||||||
|
* no-op once the round has already ended (or before one has started),
|
||||||
|
* so it's safe to wire unconditionally regardless of the currently
|
||||||
|
* selected solo opponent. */
|
||||||
|
handleLocalAssignment(id: CategoryId, hex: string): void {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.localColors[id] = hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts one bot round: shows the split-view pane, (re)builds the
|
||||||
|
* `OpponentView` for `config.snippetIndex`, computes the bot's full
|
||||||
|
* pick schedule against the theme's real per-category colors, and
|
||||||
|
* schedules `opponentView.markAssigned` calls to play it out over
|
||||||
|
* time. Tears down any previous round's timers first. */
|
||||||
|
start(config: BotMatchConfig): void {
|
||||||
|
this.teardown();
|
||||||
|
this.ended = false;
|
||||||
|
this.playerRevealed = false;
|
||||||
|
this.botFinished = false;
|
||||||
|
this.localColors = {};
|
||||||
|
this.themeId = config.themeId;
|
||||||
|
this.snippetIndex = config.snippetIndex;
|
||||||
|
this.timeMode = config.timeMode;
|
||||||
|
|
||||||
|
this.splitView.classList.remove('hidden');
|
||||||
|
if (!this.opponentView) {
|
||||||
|
this.opponentView = new OpponentView('opponent-canvas', config.snippetIndex);
|
||||||
|
} else {
|
||||||
|
this.opponentView.setSnippet(config.snippetIndex);
|
||||||
|
}
|
||||||
|
this.opponentView.reset();
|
||||||
|
|
||||||
|
this.resultModal.classList.add('hidden');
|
||||||
|
this.mpScoreboard.classList.add('hidden');
|
||||||
|
|
||||||
|
const theme = THEMES[config.themeId];
|
||||||
|
const categories = CATEGORY_META.map((def) => ({ id: def.id, hex: theme.colors[def.id] }));
|
||||||
|
this.schedule = computeBotSchedule({
|
||||||
|
difficulty: config.difficulty,
|
||||||
|
timeMode: config.timeMode,
|
||||||
|
categories,
|
||||||
|
});
|
||||||
|
this.remainingPicks = this.schedule.length;
|
||||||
|
|
||||||
|
for (const pick of this.schedule) {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
this.opponentView?.markAssigned(pick.categoryId);
|
||||||
|
this.remainingPicks -= 1;
|
||||||
|
if (this.remainingPicks === 0) {
|
||||||
|
this.botFinished = true;
|
||||||
|
this.maybeEndNoLimitRound();
|
||||||
|
}
|
||||||
|
}, pick.atMs);
|
||||||
|
this.pickTimeoutIds.push(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.timeMode !== 'none') {
|
||||||
|
this.deadlineTimeoutId = window.setTimeout(() => this.endRound(), ROUND_DEADLINE_MS[config.timeMode]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides the split-view pane and cancels any pending bot timers —
|
||||||
|
* called when the solo player is on the 'Alone' path, so a previous
|
||||||
|
* bot round's schedule/pane never bleeds into a single-pane round. */
|
||||||
|
stop(): void {
|
||||||
|
this.teardown();
|
||||||
|
this.splitView.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onRevealClick = (): void => {
|
||||||
|
this.playerRevealed = true;
|
||||||
|
this.maybeEndNoLimitRound();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** No-limit mode has no deadline, so it ends only once both the bot's
|
||||||
|
* schedule has fully played out and the player has manually revealed
|
||||||
|
* (the existing Reveal button, same manual-trigger convention solo
|
||||||
|
* play already uses in every time mode). */
|
||||||
|
private maybeEndNoLimitRound(): void {
|
||||||
|
if (this.timeMode === 'none' && this.playerRevealed && this.botFinished) this.endRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private endRound(): void {
|
||||||
|
if (this.ended || this.snippetIndex === null || this.themeId === null) return;
|
||||||
|
this.ended = true;
|
||||||
|
this.clearTimers();
|
||||||
|
|
||||||
|
const botColors: Record<CategoryId, string> = { ...defaultColorMap() };
|
||||||
|
for (const pick of this.schedule) botColors[pick.categoryId] = pick.hex;
|
||||||
|
const youColors: Record<CategoryId, string> = { ...defaultColorMap(), ...this.localColors };
|
||||||
|
|
||||||
|
const youResult = computeMatchResult(buildCategoryStateMap(this.snippetIndex, this.themeId, youColors));
|
||||||
|
const opponentResult = computeMatchResult(buildCategoryStateMap(this.snippetIndex, this.themeId, botColors));
|
||||||
|
|
||||||
|
this.resultModal.classList.remove('hidden');
|
||||||
|
for (const el of this.soloResultEls) el.classList.add('hidden');
|
||||||
|
this.mpScoreboard.classList.remove('hidden');
|
||||||
|
renderMultiplayerResult(this.mpResultElements, youResult, opponentResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels every pending pick/deadline timeout. Idempotent — safe to
|
||||||
|
* call before a round has started. */
|
||||||
|
private clearTimers(): void {
|
||||||
|
for (const id of this.pickTimeoutIds) window.clearTimeout(id);
|
||||||
|
this.pickTimeoutIds = [];
|
||||||
|
if (this.deadlineTimeoutId !== null) window.clearTimeout(this.deadlineTimeoutId);
|
||||||
|
this.deadlineTimeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardown(): void {
|
||||||
|
this.ended = true;
|
||||||
|
this.clearTimers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,11 @@ const soloConfigFlow = createSoloConfigFlow(
|
||||||
game.setTheme(config.themeId);
|
game.setTheme(config.themeId);
|
||||||
game.setSnippet(config.snippetIndex);
|
game.setSnippet(config.snippetIndex);
|
||||||
} else {
|
} else {
|
||||||
game = new ThemeGuessGame(config.themeId, config.snippetIndex);
|
game = new ThemeGuessGame(
|
||||||
|
config.themeId,
|
||||||
|
config.snippetIndex,
|
||||||
|
(id, hex) => soloConfigFlow.handleLocalAssignment(id, hex),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
themeNameBadge.textContent = THEMES[config.themeId].name;
|
themeNameBadge.textContent = THEMES[config.themeId].name;
|
||||||
sound.playApply();
|
sound.playApply();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { THEMES } from '../data/themes';
|
import { THEMES } from '../data/themes';
|
||||||
import { pickRandomSnippetIndex } from '../data/snippets';
|
import { pickRandomSnippetIndex } from '../data/snippets';
|
||||||
import type { BotDifficulty, BotTimeMode } from '../engine/bot';
|
import type { BotDifficulty, BotTimeMode } from '../engine/bot';
|
||||||
import type { ThemeId } from '../types';
|
import { BotMatch } from '../game/botMatch';
|
||||||
|
import type { CategoryId, ThemeId } from '../types';
|
||||||
import type { ThemeGrid } from './themeGrid';
|
import type { ThemeGrid } from './themeGrid';
|
||||||
|
|
||||||
/** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be
|
/** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be
|
||||||
|
|
@ -37,8 +38,16 @@ export interface SoloConfigFlowElements {
|
||||||
export interface SoloConfigFlow {
|
export interface SoloConfigFlow {
|
||||||
/** Starts the inline countdown/elapsed-time HUD for a just-started
|
/** Starts the inline countdown/elapsed-time HUD for a just-started
|
||||||
* round. Call once the round is actually live (e.g. after the theme
|
* round. Call once the round is actually live (e.g. after the theme
|
||||||
* preview finishes), not at Play-click time. */
|
* preview finishes), not at Play-click time. Also the "round is live"
|
||||||
|
* signal this module uses internally to start (or, for 'Alone',
|
||||||
|
* tear down) the bot opponent's split-view pane — see `handleLocalAssignment`. */
|
||||||
startTimer(mode: SoloTimeMode): void;
|
startTimer(mode: SoloTimeMode): void;
|
||||||
|
/** `ThemeGuessGame`'s `onCategoryAssigned` callback target. Wire this
|
||||||
|
* once, at the game's first construction (see `MultiplayerMatch`'s
|
||||||
|
* two-phase-init doc for the pattern this mirrors) — it's a stable
|
||||||
|
* dispatch that forwards to whichever bot round `startTimer` most
|
||||||
|
* recently started, and is a no-op for 'Alone' rounds. */
|
||||||
|
handleLocalAssignment(id: CategoryId, hex: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIME_MODE_DEADLINE_MS: Record<Exclude<SoloTimeMode, 'none'>, number> = {
|
const TIME_MODE_DEADLINE_MS: Record<Exclude<SoloTimeMode, 'none'>, number> = {
|
||||||
|
|
@ -126,7 +135,9 @@ export function createSoloConfigFlow(
|
||||||
const opponentInputs = radios(elements.section, 'solo-opponent-mode');
|
const opponentInputs = radios(elements.section, 'solo-opponent-mode');
|
||||||
const timeModeInputs = radios(elements.section, 'solo-time-mode');
|
const timeModeInputs = radios(elements.section, 'solo-time-mode');
|
||||||
const botDifficultyInputs = radios(elements.section, 'solo-bot-difficulty');
|
const botDifficultyInputs = radios(elements.section, 'solo-bot-difficulty');
|
||||||
const startTimer = createTimerHud(elements.timerMount);
|
const startTimerHud = createTimerHud(elements.timerMount);
|
||||||
|
const botMatch = new BotMatch();
|
||||||
|
let lastConfig: SoloConfig | null = null;
|
||||||
|
|
||||||
function syncThemePicker(): void {
|
function syncThemePicker(): void {
|
||||||
const mode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
|
const mode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
|
||||||
|
|
@ -154,17 +165,32 @@ export function createSoloConfigFlow(
|
||||||
? themeGrid.selected
|
? themeGrid.selected
|
||||||
: themeIds[Math.floor(Math.random() * themeIds.length)];
|
: themeIds[Math.floor(Math.random() * themeIds.length)];
|
||||||
|
|
||||||
// TODO(#19): wire bot engine — opponent/botDifficulty are captured
|
lastConfig = {
|
||||||
// and handed off here, but nothing yet drives a live bot opponent
|
|
||||||
// from them; "vs Bot" currently plays identically to "Alone".
|
|
||||||
onStart({
|
|
||||||
themeId,
|
themeId,
|
||||||
snippetIndex: pickRandomSnippetIndex(),
|
snippetIndex: pickRandomSnippetIndex(),
|
||||||
timeMode,
|
timeMode,
|
||||||
opponent,
|
opponent,
|
||||||
botDifficulty,
|
botDifficulty,
|
||||||
});
|
};
|
||||||
|
onStart(lastConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { startTimer };
|
return {
|
||||||
|
startTimer(mode) {
|
||||||
|
startTimerHud(mode);
|
||||||
|
if (lastConfig?.opponent === 'bot') {
|
||||||
|
botMatch.start({
|
||||||
|
themeId: lastConfig.themeId,
|
||||||
|
snippetIndex: lastConfig.snippetIndex,
|
||||||
|
timeMode: lastConfig.timeMode,
|
||||||
|
difficulty: lastConfig.botDifficulty,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
botMatch.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleLocalAssignment(id, hex) {
|
||||||
|
botMatch.handleLocalAssignment(id, hex);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue