diff --git a/CLAUDE.md b/CLAUDE.md index feea2a2..a561c24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Save Queue `game-store.ts` serialises all DB writes through a module-level `_saveQueue` promise chain so concurrent calls never race on `gameId`. Any code that must read the final saved `gameId` (e.g. `endGame()` needs `gameId` to look up rank) must `await` the action that triggers the save before reading state. +### Board generation & order strategies +Boards are **solvable by construction** (`src/utils/init-gameboard.ts`): the fixed layout is peeled into a removal order of free pairs, then matched token pairs are laid onto it, so replaying the order always wins. Freeness is purely geometric and lives in one shared rule (`src/utils/board-rules.ts` `isSelectable`), reused by both the live game (`allowedforSelection`) and the generator. + +The peel order policy is a **ports & adapters seam** (`src/utils/order-strategies.ts`). The port is `OrderStrategy.peel(random)`; selectable adapters only express *which two free tiles to take next* via `peelWith`: `scatter` (**production default** — pairs the highest free tile with the lowest, spreading matches across layers), `topDownRandom`, `original`, `bottomUpRandom`. Note the layer-greedy strategies (`topDownRandom`/`bottomUpRandom`) always pair the top-of-pyramid tiles among themselves — a visible "ready-made" cluster of matching pairs at the top — which is why `scatter` is the default. Solvability holds for every adapter because the shared loop only ever removes currently-free tiles; a policy that strands just returns `null` and `dealSolvableBoard` falls back to `canonical` — a deterministic top-down order that never strands and is kept out of the selectable list (internal safety net only). + +To experiment locally, override the active strategy (resolution: runtime override → localStorage `eureka.orderStrategy` → `NEXT_PUBLIC_ORDER_STRATEGY` env → default). In dev, the `/play` screen shows a strategy badge (`src/components/strategy-switcher`): press `s` / `Shift+S` (or click) to cycle and re-deal; hover for descriptions. From the browser console (any build): `eurekaOrder.set('original')` then start a new game; `eurekaOrder.list()` to see options; `eurekaOrder.set(null)` to reset. Production play is unaffected unless one of those overrides is set. + ### Tile animations Tile animation state lives in `gameBoard[index].animating` (`'match' | 'mismatch' | null`). The `clicked()` action sets the flag and uses `setTimeout` to clear it after the CSS animation completes. CSS classes `tile.match` and `tile.mismatch` are defined in `global.css`. diff --git a/docs/gifs/construction.gif b/docs/gifs/construction.gif new file mode 100644 index 0000000..9b051fa Binary files /dev/null and b/docs/gifs/construction.gif differ diff --git a/docs/gifs/freeness.gif b/docs/gifs/freeness.gif new file mode 100644 index 0000000..fc45dea Binary files /dev/null and b/docs/gifs/freeness.gif differ diff --git a/docs/gifs/random.gif b/docs/gifs/random.gif new file mode 100644 index 0000000..9e671ff Binary files /dev/null and b/docs/gifs/random.gif differ diff --git a/docs/solvable-boards.html b/docs/solvable-boards.html new file mode 100644 index 0000000..098a5eb --- /dev/null +++ b/docs/solvable-boards.html @@ -0,0 +1,363 @@ + + + + + +Dealing solvable boards · Eureka + + + + + +
+
Eureka · board generation
+

Some shuffles deal an impossible game.

+

A mahjong board can look perfect and still have no solution. Here's how we stopped leaving that to chance — in three short clips.

+
+
The board
144 tiles · 5 layers · 36 symbols
+
The bug
random fill ⇒ deadlocks
+
The fix
record a solution first
+
+
↓ scroll
+
+ + +
+
+
+ the rule + Free tiles glow; matching two of a kind removes them + loops +
+
+
first, one rule
+

You can only take a free tile.

+

A tile is free when nothing covers it and a side is open. Match two free tiles of the same symbol and they leave the table.

+

The crucial part: freeness depends only on where tiles sit — never on their symbols. And taking tiles away only ever frees more. Hold onto those two facts.

+
+
+
+ +
+ + +
+
+
approach one
+

Deal & pray.

+

The obvious method: scatter the symbols onto the layout at random and hope. Played as well as possible, a random board still strands tiles that can never be matched.

+
+
+
+ live play → deadlock + A randomly dealt board played to a dead end with trapped tiles + loops +
+
+
no symbols left to match
+

The board was lost when it was dealt.

+

Every move shown is forced and optimal, yet the last tiles have their partners buried or hemmed in on opposite ends. An exhaustive search confirms no sequence wins it.

+
+
+
+
how often is it impossible?
+
0%
+
+
0%50%100%
+
Dealing random boards in your browser and checking each for any solution…
+
+

+ Computed live, on the same 16-tile board as the clips (small enough to follow every move). On the full 144-tile table the odds of a dead deal are far worse. The verdict holds: with random fill, winning is partly luck of the draw — which quietly erases the player's skill. +

+
+ +
+ + +
+
+
approach two
+

Record a solution, then deal it.

+

Flip the problem around. Instead of dealing tiles and searching for a solution, build the solution first and lay the tiles onto it.

+
+
+
+ peel → paint → replay + Peel the empty layout to record a removal order, paint matched pairs onto it, replay to clear the board + loops +
+
+
solvable by construction
+

Three acts, one guarantee.

+
    +
  1. Peel the empty layout — lift any two free slots and note the pair, until the table is clear. This always finishes, and the order is a guaranteed winning sequence.
  2. +
  3. Paint a matching pair of symbols onto each recorded step.
  4. +
  5. Replay the notes — every step is free and matches, so the board always clears.
  6. +
+
+
+

+ The symbols are still shuffled, so boards stay varied — only the possibility of an impossible deal is gone. +

+
+ +
+ + +
+
+
the difference
+

From a coin-flip to a craft.

+
+
+
+
deal & pray
+

Solvability is random.

+

Some boards can't be won no matter how you play. A loss might be the deal's fault, not yours — and nothing can tell the difference.

+
+
+
record & deal
+

Solvability is guaranteed.

+

Every board has a winning line by construction. If you lose, it's a move you made. Now the timer and the leaderboard measure skill — the whole point.

+
+
+
+ +
+ + +
+
+
why it's airtight
+

Two properties do all the work.

+

The whole guarantee rests on facts about freeness — the same rule the live game uses to decide what you can click.

+
+
+
+ ① Freeness is geometric. +

What can be taken depends only on layer / row / column and which tiles remain — never on the symbol. So any symbol assignment over a valid removal order is winnable.

+
+
+ ② Removal is monotone. +

Taking a tile away can only free more tiles, never cover one back up. So peeling the layout pair-by-pair always reaches the empty table — the recorded order can't get stuck.

+
+
+

The generator, in essence:

+
// 1. Peel the fixed layout into a valid removal order (geometry only).
+const pairs = peelLayout(random);        // 72 pairs, each free when removed
+
+// 2. Lay matched token pairs onto that order.
+const pairTokens = shuffle([...tokens, ...tokens]); // each symbol ×2 pairs
+pairs.forEach(([a, b], i) => {
+  board[a] = board[b] = { token: pairTokens[i] };  // the pair matches
+});
+
+// Replaying `pairs` wins ⇒ solvable by construction, for every shuffle.
+// Verified by re-solving 360 random deals in tests.
+
+ + + + + + diff --git a/docs/strategy-difficulty-report.html b/docs/strategy-difficulty-report.html new file mode 100644 index 0000000..98d668c --- /dev/null +++ b/docs/strategy-difficulty-report.html @@ -0,0 +1,326 @@ + + + + + +Board difficulty & order strategies — design review · Eureka + + + + +
+ +
+
Eureka · game design review
+

Board difficulty & the order-strategy knobs

+

Every board is now guaranteed solvable. This brief asks the next question: how hard should it be, and which lever do we pull to set that.

+
measured on the real 144-tile board · proxies, not playtests · figures reproducible from src/utils/order-strategies.ts
+
+ +
+
+
the short version
+

Recommendation

+

Keep scatter as the default, and add a difficulty dial via the symbol-labeling knob we haven't touched yet.

+

We shipped scatter because it removed a visible artifact (matching pairs pre-stacked at the top of the pyramid — 100% of old-default boards). The trade-off, now measured: scatter is ~28% more forgiving than the previous default (9.6 vs 7.5 legal moves per step). That matches playtest feedback ("plays well, but a bit easier").

+

Crucially, difficulty today is an accidental side-effect of board shape. The deliberate lever — how a symbol's copies are spread out — is still random and identical across every strategy. That's the dial to add.

+
+
+ +
+ +
+
background
+

One artifact we already fixed

+

Boards are built by recording a solution: peel the layout into a removal order, then lay matched symbol-pairs onto it. The peel policy used to cluster the top of the pyramid into ready-made matching pairs — "two matching pairs sitting right below the top tile," every game.

+ + + + + + + + +
strategytop-4 tiles self-pairedsame-layer pairs
topDownRandom old default100%96%
bottomUpRandom100%46%
original0%47%
scatter default0%21%
+

scatter pairs the highest free tile with the lowest, spreading matches across layers — so no layer becomes a self-contained giveaway. All strategies remain 100% solvable; scatter never falls back.

+
+ +
+ +
+
the model
+

Two independent knobs

+

Geometry is fixed and symbol frequencies are fixed, so a board is fully determined by two choices — and they do different jobs.

+
+
+

① Peel order

+

Which positions get paired. Controls the board's look and, as a side-effect, how many moves are open at once (branching). This is the knob we've been turning (topDown / bottomUp / scatter / original).

+ tuned +
+
+

② Symbol labeling

+

Which pairs get the same symbol. Controls how far apart a symbol's copies are in the solve — the deliberate difficulty lever. Currently pure-random and identical across all four strategies.

+ untouched ← the opportunity +
+
+
+ +
+ +
+
measurement
+

How hard is each strategy?

+

We walked each board's guaranteed solution and, at every step, counted the legal moves available (free tiles that share a symbol). More moves per step = more forgiving. We also tracked how often a step is forced (exactly one move).

+ +
+
Branching — mean legal moves available per step (higher = easier)
+
bottomUpRandom
4.5
+
topDownRandom
7.5
+
scatter ◂ default
9.6
+
original
9.7
+
+ +
+
Forced-move rate — % of steps with only one legal move (higher = harder / tenser)
+
bottomUpRandom
30.0%
+
topDownRandom
3.8%
+
scatter ◂ default
3.4%
+
original
3.8%
+
+ + + + + + + + + +
strategybranchingforced %opening movescopy-spread
bottomUpRandom4.530.0%11.90.34
topDownRandom old7.53.8%16.60.35
scatter default9.63.4%15.10.33
original9.73.8%18.40.34
+
+ +
+ +
+
learnings
+

What the numbers say

+ +
+ +
+ +
+
proposal
+

Where to go next

+
+
+
recommended
+

Difficulty dial

+

Keep scatter's look; turn the labeling knob. A 0→1 dial spreads a symbol's copies from clustered (easy) to far-apart in the solve (hard). Can scale with level to offset grace tiles, which currently make late levels easier.

+
effort: small · reversible · no look change
+
+
+
for consistency
+

Metric-driven deals

+

Generate ~20 candidate boards per deal (≈ms each), keep the one closest to a target difficulty score. Removes board-to-board variance — the cleanest way to stabilize highscores.

+
effort: medium · more compute/deal
+
+
+
optional
+

More peel flavors

+

balanced / ring / outward, for board character. Lower difficulty impact than labeling — mostly aesthetic variety.

+
effort: small · mostly look
+
+
+
+ +
+ +
+
decisions for the team
+

Open questions

+
Is "a bit easier" the goal?

More approachable for new players, or do we want to restore the old default's challenge — or sit somewhere between?

+
What's the target difficulty band?

Pick a branching range to aim for (e.g. hold ~6–8 moves/step), so the dial has a destination.

+
Should difficulty rise with level?

Today grace tiles make higher levels easier. A level-scaled dial would reverse that curve. Do we want a rising challenge?

+
Stabilize per-board difficulty, or keep variance?

Metric-driven deals make every board feel comparable; some randomness in difficulty can also feel more organic. Which do we want?

+
+ +
+ +
+
methodology
+
+

Figures are computed, not playtested — relative indicators of difficulty, not absolute. For each strategy we deal boards on the real 144-tile layout, then walk each board's guaranteed solution under the live selection rule (isSelectable), measuring at every step:

+
    +
  • branching — count of legal matchable free-tile pairs available;
  • +
  • forced-move rate — share of steps with exactly one such pair;
  • +
  • copy-spread — normalized distance between a symbol's two pairs in the removal order.
  • +
+

Artifact figures over 300 deals; difficulty figures over 20 deals each. Caveats: this walks one valid solution rather than modelling real player choices or running a full search, so treat the numbers as a consistent yardstick for comparing strategies, not as a calibrated difficulty score.

+
+
+ + + +
+ + diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 2fb94d1..f2d1a79 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -5,6 +5,7 @@ import GameBoard from "@/components/game-board"; import { useGameStore } from "@/zustand/game-store"; import { useRouter } from "next/navigation"; import RankToast from "@/components/rank-toast"; +import StrategySwitcher from "@/components/strategy-switcher"; export default function Game() { const gameOver = useGameStore((state) => state.gameOver); @@ -28,6 +29,7 @@ export default function Game() { + ); } diff --git a/src/components/leaderboard/index.tsx b/src/components/leaderboard/index.tsx index 6d8f44f..1211241 100644 --- a/src/components/leaderboard/index.tsx +++ b/src/components/leaderboard/index.tsx @@ -10,6 +10,8 @@ export interface HighscoreRow { level: number; created_at: string; rank: number; + // Order strategy the board was dealt with (historical rows: 'random'). + strategy?: string; } const LIMIT = 10; @@ -35,6 +37,7 @@ const Leaderboard = ({ initial }: LeaderboardProps) => { const name = useGameStore((s) => s.name); const score = useGameStore((s) => s.score); const level = useGameStore((s) => s.level); + const strategy = useGameStore((s) => s.strategy); const { rows, youId } = useMemo(() => { const merged: Omit[] = initial.map((r) => ({ @@ -43,13 +46,14 @@ const Leaderboard = ({ initial }: LeaderboardProps) => { score: r.score, level: r.level, created_at: r.created_at, + strategy: r.strategy, })); let you: string | null = null; if (gameId && score > 0) { you = gameId; if (!merged.some((r) => r.id === gameId)) { - merged.push({ id: gameId, name, score, level, created_at: new Date().toISOString() }); + merged.push({ id: gameId, name, score, level, created_at: new Date().toISOString(), strategy }); } } @@ -63,7 +67,7 @@ const Leaderboard = ({ initial }: LeaderboardProps) => { if (you && !ranked.some((r) => r.id === you)) you = null; return { rows: ranked, youId: you }; - }, [initial, gameId, name, score, level]); + }, [initial, gameId, name, score, level, strategy]); return (
@@ -99,13 +103,20 @@ const Leaderboard = ({ initial }: LeaderboardProps) => { {entry.rank} )} - - - {entry.name || Anonymous} + + + + {entry.name || Anonymous} + + {isYou && ( + + You + + )} - {isYou && ( - - You + {entry.strategy && ( + + {entry.strategy} )} diff --git a/src/components/strategy-switcher/index.tsx b/src/components/strategy-switcher/index.tsx new file mode 100644 index 0000000..03a7e88 --- /dev/null +++ b/src/components/strategy-switcher/index.tsx @@ -0,0 +1,109 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import { useGameStore } from "@/zustand/game-store"; +import { + getOrderStrategy, + listOrderStrategies, + setOrderStrategy, +} from "@/utils/order-strategies"; + +/** + * Dev-only affordance to compare board order strategies in-game. + * Press `s` (next) or `Shift+S` (previous) — or click the badge — to switch the + * peel strategy and re-deal the current level with it. Hidden in production + * builds; the strategy choice (localStorage) still persists across reloads. + */ +const enabled = process.env.NODE_ENV !== "production"; + +const StrategySwitcher: React.FC = () => { + const redeal = useGameStore((state) => state.redealCurrentLevel); + const [current, setCurrent] = useState(() => + enabled ? getOrderStrategy().name : "" + ); + + const cycle = useCallback( + (dir: number) => { + const names = listOrderStrategies().map((s) => s.name); + const idx = names.indexOf(getOrderStrategy().name); + const next = names[(idx + dir + names.length) % names.length]; + setOrderStrategy(next); + setCurrent(next); + redeal(); + }, + [redeal] + ); + + useEffect(() => { + if (!enabled) return; + const isTyping = (el: EventTarget | null) => { + const node = el as HTMLElement | null; + if (!node) return false; + return ( + node.tagName === "INPUT" || + node.tagName === "TEXTAREA" || + node.tagName === "SELECT" || + node.isContentEditable + ); + }; + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return; + if (e.key === "s") { + e.preventDefault(); + cycle(1); + } else if (e.key === "S") { + e.preventDefault(); + cycle(-1); + } else if (e.key === "r" || e.key === "R") { + // re-deal the *same* strategy — to watch a single strategy vary + e.preventDefault(); + redeal(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [cycle, redeal]); + + if (!enabled) return null; + + const strategies = listOrderStrategies(); + + return ( +
+ {/* hover explanation — lists every strategy, current one highlighted */} +
+
+ board order strategy +
+
    + {strategies.map((s) => { + const active = s.name === current; + return ( +
  • + {active ? "▸ " : " "}{s.name} +
    {s.description}
    +
  • + ); + })} +
+
+ s next · ⇧s prev · r re-deal (same strategy) · or click +
+ note: strategies change the hidden solution, not the visible layout. +
+
+ + +
+ ); +}; + +export default StrategySwitcher; diff --git a/src/db/select-game.ts b/src/db/select-game.ts index 9cd1167..a0d22fc 100644 --- a/src/db/select-game.ts +++ b/src/db/select-game.ts @@ -5,6 +5,7 @@ interface RankedHighscore { name: string; score: number; level: number; + strategy: string; created_at: string; rank: number; } @@ -14,7 +15,7 @@ const LIMIT = 10; export const getHighscores = async (): Promise => { const { data, error } = await (getClient() as any) // untyped client .from("games") - .select("id, name, score, level, created_at") + .select("id, name, score, level, strategy, created_at") .order("score", { ascending: false }) .order("created_at", { ascending: false }) .limit(LIMIT); diff --git a/src/types/game.ts b/src/types/game.ts index 5ed4082..6c6cc1b 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -8,6 +8,8 @@ export type Game = { score: number; max_time: number; time_passed: number; + // Order strategy the board was dealt with. Historical rows default to 'random'. + strategy: string; created_at: string; }; diff --git a/src/utils/board-rules.ts b/src/utils/board-rules.ts new file mode 100644 index 0000000..4c5bbec --- /dev/null +++ b/src/utils/board-rules.ts @@ -0,0 +1,72 @@ +import type { GameBoard } from "@/types/game-board"; + +/** + * Whether a tile may currently be picked up. + * + * This is the single source of truth for "freeness". Both the live game + * (`allowedforSelection` in the store) and the solvable-board generator depend + * on it, so they can never disagree about which tiles are reachable. + * + * Crucially, freeness is *purely geometric*: it looks only at a tile's + * layer/row/column and which tiles are still on the board — never at the token + * value. It is also *monotone*: removing tiles can only ever free more tiles, + * never cover one back up. Those two properties are what make + * solvability-by-construction possible (see init-gameboard.ts). + * + * A tile is selectable when: + * 1. No higher-layer tile in the same column covers it (same `row + layer` + * "topFactor" ⇒ visual overlap), and + * 2. It is exposed on a side — either a special-cased edge tile of the + * crooked layer-0 rows / the apex, or the left- or right-most remaining + * tile of its (row, layer). + * + * (The special-cased indices mirror the original board's hand-tuned edge cases; + * see the Excalidraw diagram referenced in the store.) + */ +export const isSelectable = (board: GameBoard, index: string): boolean => { + const tile = board[index]; + if (!tile) return false; + const { row, layer, column } = tile; + + // 1. Covered by a higher tile in the same column with matching topFactor? + const isCovered = Object.keys(board).some( + (j) => + board[j].layer > layer && + board[j].column === column && + board[j].row + board[j].layer === row + layer + ); + if (isCovered) return false; + + // 2. Exposed on a side. + switch (true) { + // layer 0 hand-tuned edge cases + case index === "30": // left-side top-left + return board["42"] === undefined; + case index === "41": // right-side top-right + return board["55"] === undefined; + case index === "42" || index === "56": // outermost crooked tiles + return true; + case index === "43": // left-side top-right + return board["42"] === undefined; + case index === "54": // right-side bottom-right + return board["55"] === undefined; + case index === "55": // right-side left crooked + return board["56"] === undefined; + // layer 3 — covered by the apex (tile 143) which lives in a sentinel column + case index === "139" || + index === "140" || + index === "141" || + index === "142": + return board["143"] === undefined; + default: { + const rowItems = Object.keys(board).filter( + (i) => board[i].row === row && board[i].layer === layer + ); + return ( + index === rowItems[rowItems.length - 1] || index === rowItems[0] + ); + } + } +}; + +export default isSelectable; diff --git a/src/utils/init-gameboard.ts b/src/utils/init-gameboard.ts index 0aa36ec..76c6328 100644 --- a/src/utils/init-gameboard.ts +++ b/src/utils/init-gameboard.ts @@ -1,14 +1,106 @@ import type { Token, GameBoard } from "@/types/game-board"; -import { allTokens, gameBoardLayout } from "@/types/game-board"; - -// Shuffles `allTokens` in a flat array using the Fisher-Yates algorithm. -export const shuffleTiles = (): Token[] => { - const array = [...allTokens]; - for (let i = array.length - 1; i > 0; i--) { - const randomIndex = Math.floor(Math.random() * (i + 1)); - [array[i], array[randomIndex]] = [array[randomIndex], array[i]]; +import { tokens, allTokens, gameBoardLayout } from "@/types/game-board"; +import { shuffleInPlace } from "@/utils/shuffle"; +import { + getOrderStrategy, + fallbackStrategy, + type OrderStrategy, + type RemovalOrder, +} from "@/utils/order-strategies"; + +/** + * Why this file exists in this shape + * ---------------------------------- + * Dropping all 144 tokens onto the fixed turtle layout at random — the obvious + * approach — frequently produces *unsolvable* boards: the player runs into a + * deadlock where tiles remain but no two reachable tiles match. Whether a board + * can be won at all becomes luck of the draw, which is exactly what we don't + * want in a game meant to reward skill. + * + * The fix is "solvability by construction": instead of dealing tokens and + * *hoping* a solution exists, we generate the board by recording one. + * + * 1. Peel the layout into a valid removal order — repeatedly take two + * currently-free positions and remove them, until the board is empty. + * Because freeness is purely geometric and monotone (see board-rules.ts), + * this only depends on positions, not tokens, and always empties the board. + * 2. Lay matched token pairs onto that removal order. Replaying the order then + * wins the game, so the board is guaranteed solvable — for *every* token + * assignment, since tokens never affect freeness. + * + * Boards stay fully varied (the peel order and token assignment are randomised + * per deal); only the *guarantee* of solvability is new. + * + * Step 1's policy lives behind a port — see order-strategies.ts — so the peel + * order can be experimented with locally without touching this construction. + */ + +// A deterministic order of the (constant) layout, computed once via the +// fallback strategy. Used if a chosen strategy strands. If this ever throws, +// the layout itself is not fully clearable and the game is broken at the root. +let fallbackOrder: RemovalOrder | undefined; +const guaranteedOrder = (): RemovalOrder => { + if (fallbackOrder === undefined) { + const order = fallbackStrategy.peel(() => 0); + if (!order) { + throw new Error( + "gameBoardLayout is not fully clearable under the current selection rules" + ); + } + fallbackOrder = order; + } + return fallbackOrder; +}; + +export type SolvableDeal = { + board: GameBoard; + /** The removal order used to build the board — replaying it wins the game. */ + solution: RemovalOrder; +}; + +/** + * Deal a board that is guaranteed solvable, together with a winning solution. + * `random` is injectable for deterministic tests; production uses Math.random. + * `strategy` selects the peel order policy; it defaults to the resolved active + * strategy (production default unless overridden locally — see getOrderStrategy). + */ +export const dealSolvableBoard = ( + level = 1, + random: () => number = Math.random, + strategy: OrderStrategy = getOrderStrategy() +): SolvableDeal => { + let pairs: RemovalOrder | null = null; + for (let attempt = 0; attempt < 25 && !pairs; attempt++) { + pairs = strategy.peel(random); + } + if (!pairs) pairs = guaranteedOrder(); + + // 72 entries — each of the 36 tokens twice — so every token ends up on + // exactly 4 tiles (two pairs), matching `allTokens`. + const pairTokens = shuffleInPlace([...tokens, ...tokens], random); + + const board: GameBoard = {}; + const solution: Array<[string, string]> = []; + pairs.forEach(([a, b], i) => { + const token: Token = pairTokens[i]; + board[a] = { ...gameBoardLayout[a], token, active: false, animating: null, grace: false }; + board[b] = { ...gameBoardLayout[b], token, active: false, animating: null, grace: false }; + solution.push([a, b]); + }); + + // From level 2 onward, mark `level - 1` random tiles as grace tiles. Grace + // only *adds* selectability (it bypasses the position rules), so it can never + // turn a solvable board unsolvable. + if (level >= 2) { + const graceCount = Math.min(level - 1, Object.keys(board).length); + shuffleInPlace(Object.keys(board), random) + .slice(0, graceCount) + .forEach((idx) => { + board[idx] = { ...board[idx], grace: true }; + }); } - return array; + + return { board, solution }; }; // used for testing purposes, only initializes the first two tiles @@ -21,34 +113,9 @@ export const initializeTestGameBoard = (): GameBoard => { } as GameBoard; }; -// Initializes the game board by filling it with shuffled tokens. -// From level 2 onward, `level` random tiles are marked as grace tiles. -export const initializeGameBoard = (level = 1): GameBoard => { - const result: GameBoard = {}; - - shuffleTiles().forEach((token, index) => { - result[String(index)] = { - ...gameBoardLayout[index], - active: false, - animating: null, - grace: false, - token, - }; - }); - - if (level >= 2) { - const graceCount = level - 1; - const indices = Object.keys(result); - for (let i = indices.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [indices[i], indices[j]] = [indices[j], indices[i]]; - } - indices.slice(0, graceCount).forEach((idx) => { - result[idx] = { ...result[idx], grace: true }; - }); - } - - return result; -}; +// Initializes a guaranteed-solvable game board, filled with matched token pairs. +// From level 2 onward, `level - 1` tiles are marked as grace tiles. +export const initializeGameBoard = (level = 1): GameBoard => + dealSolvableBoard(level).board; export default initializeGameBoard; diff --git a/src/utils/order-strategies.test.ts b/src/utils/order-strategies.test.ts new file mode 100644 index 0000000..db6b97d --- /dev/null +++ b/src/utils/order-strategies.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, afterEach } from "vitest"; +import type { GameBoard } from "@/types/game-board"; +import { isSelectable } from "@/utils/board-rules"; +import { dealSolvableBoard } from "@/utils/init-gameboard"; +import { + STRATEGIES, + DEFAULT_STRATEGY, + canonical, + fallbackStrategy, + getOrderStrategy, + setOrderStrategy, + listOrderStrategies, +} from "@/utils/order-strategies"; + +const solutionWins = (board: GameBoard, solution: Array<[string, string]>) => { + const w: GameBoard = structuredClone(board); + for (const [a, b] of solution) { + if (!w[a] || !w[b]) return false; + if (w[a].token !== w[b].token) return false; + if (!isSelectable(w, a) || !isSelectable(w, b)) return false; + delete w[a]; + delete w[b]; + } + return Object.keys(w).length === 0; +}; + +describe("order strategies (port/adapter)", () => { + afterEach(() => setOrderStrategy(null)); // never leak an override between tests + + it("every selectable strategy (and the fallback) deals solvable boards", () => { + for (const strategy of [...Object.values(STRATEGIES), fallbackStrategy]) { + for (let i = 0; i < 12; i++) { + const { board, solution } = dealSolvableBoard(3, Math.random, strategy); + expect(solutionWins(board, solution), `${strategy.name} #${i}`).toBe(true); + } + } + }, 30000); + + it("canonical (fallback) is deterministic; topDownRandom varies", () => { + const c1 = canonical.peel(() => 0)!.map((p) => p.join("-")).join(","); + const c2 = canonical.peel(() => 0)!.map((p) => p.join("-")).join(","); + expect(c1).toEqual(c2); + + const r1 = STRATEGIES.topDownRandom.peel(Math.random)!.map((p) => p.join("-")).join(","); + const r2 = STRATEGIES.topDownRandom.peel(Math.random)!.map((p) => p.join("-")).join(","); + expect(r1).not.toEqual(r2); + }); + + it("resolves to the production default unless overridden", () => { + expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY); + expect(DEFAULT_STRATEGY).toBe("scatter"); + + setOrderStrategy("original"); + expect(getOrderStrategy().name).toBe("original"); + + setOrderStrategy("canonical"); // not selectable ⇒ ignored → default + expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY); + + setOrderStrategy("nonsense"); // unknown name is ignored → default + expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY); + + setOrderStrategy(null); + expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY); + }); + + it("exposes a strategy catalogue without the internal fallback", () => { + const names = listOrderStrategies().map((s) => s.name); + expect(names).toContain("topDownRandom"); + expect(names).toContain("original"); + expect(names).not.toContain("canonical"); + expect(listOrderStrategies().every((s) => s.description.length > 0)).toBe(true); + }); + + // Informational: how often each policy strands (and leans on the fallback). + // Not an assertion — just a lens for local experimentation. + it("reports strand rates per strategy", () => { + const N = 40; + for (const strategy of Object.values(STRATEGIES)) { + let stranded = 0; + for (let i = 0; i < N; i++) if (strategy.peel(Math.random) === null) stranded++; + console.log(` ${strategy.name.padEnd(16)} strands ${((100 * stranded) / N).toFixed(1)}% of peels`); + expect(stranded).toBeLessThanOrEqual(N); + } + }, 20000); +}); diff --git a/src/utils/order-strategies.ts b/src/utils/order-strategies.ts new file mode 100644 index 0000000..eec01c4 --- /dev/null +++ b/src/utils/order-strategies.ts @@ -0,0 +1,252 @@ +import type { GameBoard } from "@/types/game-board"; +import { gameBoardLayout } from "@/types/game-board"; +import { isSelectable } from "@/utils/board-rules"; +import { shuffleInPlace } from "@/utils/shuffle"; + +/** + * Order strategies — a ports & adapters seam for "how the layout is peeled + * into a removal order". + * + * The board generator (init-gameboard.ts) is solvable *by construction*: it + * peels the fixed layout into a sequence of free pairs, then lays matched + * tokens onto that sequence. Which pairs come out, and in what order, is the + * one interesting variable to experiment with — so it lives behind a port. + * + * THE PORT — `OrderStrategy.peel(random) => RemovalOrder | null`. + * AN ADAPTER — any policy for choosing the order. The default reproduces the + * current production behaviour exactly; others are for local + * experimentation (see `getOrderStrategy` resolution below). + * + * Solvability is preserved for *every* adapter, because the shared loop only + * ever removes tiles that are currently free (see `peelWith`). The only way a + * policy can misbehave is to *strand* (leave <2 free tiles with tiles still on + * the board); the loop returns `null` in that case and the caller falls back to + * the canonical adapter. So experiments can only change the *distribution* of + * boards, never whether a board is winnable. + */ + +/** An ordered list of position-pairs; replaying it (matching, in order) wins. */ +export type RemovalOrder = Array<[string, string]>; + +/** Chooses the next two free tiles to remove. The two returned indices must be + * members of `free` (both are guaranteed free at this step). */ +export type PairSelector = ( + free: string[], + board: GameBoard, + random: () => number +) => [string, string]; + +export type OrderStrategy = { + readonly name: string; + readonly description: string; + /** Produce a valid removal order, or `null` if this policy stranded. */ + peel: (random: () => number) => RemovalOrder | null; +}; + +// A throwaway board holding every layout position, used only while peeling. +const buildLayoutBoard = (): GameBoard => { + const board: GameBoard = {}; + for (const idx of Object.keys(gameBoardLayout)) { + board[idx] = { + ...gameBoardLayout[idx], + token: "0", + active: false, + animating: null, + grace: false, + }; + } + return board; +}; + +/** + * Build a strategy from a per-step `PairSelector`. The peel loop, the freeness + * check, and the stranding guard are shared here, so an adapter only has to + * express its *policy* — which two free tiles to take next. + */ +export const peelWith = ( + name: string, + description: string, + select: PairSelector +): OrderStrategy => ({ + name, + description, + peel(random) { + const board = buildLayoutBoard(); + const order: RemovalOrder = []; + while (Object.keys(board).length > 0) { + const free = Object.keys(board).filter((i) => isSelectable(board, i)); + if (free.length < 2) return null; // stranded — caller retries / falls back + const [a, b] = select(free, board, random); + order.push([a, b]); + delete board[a]; + delete board[b]; + } + return order; + }, +}); + +/* ------------------------------------------------------------------ adapters */ + +/** + * DEFAULT (production). Greedy: remove the two highest-layer free tiles, with a + * random tie-break within a layer. Clearing the top first keeps a lingering + * high tile from stranding the tiles beneath it; the random tie-break is what + * varies the pairing — and therefore the board — between deals. + */ +export const topDownRandom = peelWith( + "topDownRandom", + "Greedy top-down, random tie-break within a layer (production default).", + (free, board, random) => { + shuffleInPlace(free, random); + // Stable sort by layer (desc) keeps the random order within each layer. + free.sort((a, b) => board[b].layer - board[a].layer); + return [free[0], free[1]]; + } +); + +/** + * Original / loose: pick any two free tiles with no layer preference. Produces + * the most varied pairings but strands more often (the generator then leans on + * the internal fallback), so it's a good lens for comparing distributions. + */ +export const original = peelWith( + "original", + "Pick any two free tiles at random — no layer preference, the loosest pairing.", + (free, _board, random) => { + const i = Math.floor(random() * free.length); + let j = Math.floor(random() * (free.length - 1)); + if (j >= i) j++; + return [free[i], free[j]]; + } +); + +/** + * Deterministic top-down: highest layer, then lowest index. Ignores `random`, + * so it yields the *same* removal order every run and never strands. Not a + * selectable strategy — kept purely as the guaranteed fallback (see below). + */ +export const canonical = peelWith( + "canonical", + "Deterministic top-down (highest layer, lowest index). Internal fallback.", + (free, board) => { + const sorted = [...free].sort( + (a, b) => board[b].layer - board[a].layer || Number(a) - Number(b) + ); + return [sorted[0], sorted[1]]; + } +); + +/** + * Bottom-up: prefer the *lowest*-layer free tiles. The mirror image of the + * default — a useful contrast to see how peel direction shapes the boards (and + * how often a policy ends up leaning on the fallback). + */ +export const bottomUpRandom = peelWith( + "bottomUpRandom", + "Greedy bottom-up, random tie-break within a layer.", + (free, board, random) => { + shuffleInPlace(free, random); + free.sort((a, b) => board[a].layer - board[b].layer); + return [free[0], free[1]]; + } +); + +/** + * Scatter: pair the highest free tile with the lowest free tile each step. + * Removing a top tile every step keeps the peel from stranding (covers cleared + * promptly), while pairing it with a base tile spreads matches *across* layers. + * This avoids the "ready-made" look the layer-greedy strategies produce, where + * the small upper layers get paired within themselves and sit as obvious + * matched pairs at the top of the pyramid. + */ +export const scatter = peelWith( + "scatter", + "Pair the highest free tile with the lowest — matches spread across layers (no ready-made clusters).", + (free, board, random) => { + shuffleInPlace(free, random); // randomise ties within a layer + const byLayer = [...free].sort((a, b) => board[b].layer - board[a].layer); + return [byLayer[0], byLayer[byLayer.length - 1]]; // highest + lowest + } +); + +/* ------------------------------------------------------------------ registry */ + +// Selectable strategies (shown in the switcher / catalogue). `canonical` is +// deliberately excluded — it's the deterministic safety net, not a play option. +export const STRATEGIES: Record = { + topDownRandom, + scatter, + original, + bottomUpRandom, +}; + +// `scatter` is the default: layer-greedy strategies (topDown/bottomUp) always +// pair the top-of-pyramid tiles among themselves, which reads as a "ready-made" +// cluster of matching pairs at the top. scatter spreads matches across layers. +export const DEFAULT_STRATEGY = "scatter"; + +/** Guaranteed to complete (never strands). Used when a selectable strategy strands. */ +export const fallbackStrategy: OrderStrategy = canonical; + +export const listOrderStrategies = (): Array<{ name: string; description: string }> => + Object.values(STRATEGIES).map((s) => ({ name: s.name, description: s.description })); + +/* ----------------------------------------------------- local experimentation */ + +// Strategy switching is a development-only affordance. In production the +// strategy is operator-controlled (env or default) so that highscores stay +// comparable and players can't change the board distribution they're scored on. +const DEV = process.env.NODE_ENV !== "production"; + +let runtimeOverride: string | null = null; + +/** + * Override the active strategy for this session. Pass a name from `STRATEGIES`, + * or `null` to clear. Persists to localStorage (when available) so it survives + * a reload; the next board dealt uses it. + */ +export const setOrderStrategy = (name: string | null): void => { + runtimeOverride = name && STRATEGIES[name] ? name : null; + try { + if (typeof localStorage !== "undefined") { + if (runtimeOverride) localStorage.setItem("eureka.orderStrategy", runtimeOverride); + else localStorage.removeItem("eureka.orderStrategy"); + } + } catch { + /* localStorage unavailable (SSR / privacy mode) — in-memory override still applies */ + } +}; + +/** + * Resolve the active strategy. + * Production: `NEXT_PUBLIC_ORDER_STRATEGY` env → DEFAULT_STRATEGY. Player + * overrides (runtime / localStorage) are ignored. + * Development: runtime override → localStorage `eureka.orderStrategy` → env → + * DEFAULT_STRATEGY, re-read every deal so live switching works. + */ +export const getOrderStrategy = (): OrderStrategy => { + const fromEnv = + typeof process !== "undefined" ? process.env?.NEXT_PUBLIC_ORDER_STRATEGY : undefined; + let name = fromEnv ?? DEFAULT_STRATEGY; + if (DEV) { + let fromStorage: string | null = null; + try { + if (typeof localStorage !== "undefined") fromStorage = localStorage.getItem("eureka.orderStrategy"); + } catch { + /* ignore */ + } + name = runtimeOverride ?? fromStorage ?? fromEnv ?? DEFAULT_STRATEGY; + } + return STRATEGIES[name] ?? STRATEGIES[DEFAULT_STRATEGY]; +}; + +// Dev convenience: expose the knobs on window so you can experiment from the +// browser console, e.g. `eurekaOrder.set('original')` then start a new game. +// Not exposed in production — the strategy is locked there. +if (DEV && typeof window !== "undefined") { + (window as unknown as { eurekaOrder?: unknown }).eurekaOrder = { + set: setOrderStrategy, + list: listOrderStrategies, + current: () => getOrderStrategy().name, + }; +} diff --git a/src/utils/post-game-state.ts b/src/utils/post-game-state.ts index f1d9cd4..d9cf6d3 100644 --- a/src/utils/post-game-state.ts +++ b/src/utils/post-game-state.ts @@ -9,6 +9,7 @@ export interface GameStatePayload { score: number; maxTime: number; timePassed: number; + strategy: string; } export const postGameState = async ( @@ -25,6 +26,7 @@ export const postGameState = async ( score: gameState.score, max_time: gameState.maxTime, time_passed: gameState.timePassed, + strategy: gameState.strategy, }), }); diff --git a/src/utils/shuffle.ts b/src/utils/shuffle.ts new file mode 100644 index 0000000..e63004c --- /dev/null +++ b/src/utils/shuffle.ts @@ -0,0 +1,14 @@ +// Fisher-Yates, in place. Randomness is injected so callers can be made +// deterministic (e.g. tests, or the canonical fallback that passes a constant). +export const shuffleInPlace = ( + array: T[], + random: () => number = Math.random +): T[] => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +}; + +export default shuffleInPlace; diff --git a/src/utils/solvable-board.test.ts b/src/utils/solvable-board.test.ts new file mode 100644 index 0000000..0fe2234 --- /dev/null +++ b/src/utils/solvable-board.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { tokens, gameBoardLayout } from "@/types/game-board"; +import type { GameBoard } from "@/types/game-board"; +import { dealSolvableBoard } from "@/utils/init-gameboard"; +import { isSelectable } from "@/utils/board-rules"; + +/** + * Replays a claimed solution against the *real* selection rule. Returns true + * only if every pair is a legal match (both tiles present, both reachable in + * the same board state, same token) and the board ends up empty. This is the + * independent proof that a board is genuinely winnable. + */ +const solutionWins = ( + board: GameBoard, + solution: Array<[string, string]> +): boolean => { + const work: GameBoard = structuredClone(board); + for (const [a, b] of solution) { + if (!work[a] || !work[b]) return false; + if (work[a].token !== work[b].token) return false; + // Both must be reachable with both still on the board (you select one, then + // the other) — grace tiles aside, this is exactly what the game enforces. + const reachable = (idx: string) => work[idx].grace || isSelectable(work, idx); + if (!reachable(a) || !reachable(b)) return false; + delete work[a]; + delete work[b]; + } + return Object.keys(work).length === 0; +}; + +describe("solvable board generation", () => { + it("deals all 144 positions with the correct token multiset", () => { + const { board } = dealSolvableBoard(); + expect(Object.keys(board)).toHaveLength(Object.keys(gameBoardLayout).length); + + const counts = new Map(); + for (const tile of Object.values(board)) { + counts.set(tile.token, (counts.get(tile.token) ?? 0) + 1); + } + expect(counts.size).toBe(tokens.length); + for (const token of tokens) { + expect(counts.get(token)).toBe(4); // 4 copies of every token + } + }); + + it("every dealt board is solvable across many random deals and levels", () => { + for (let level = 1; level <= 6; level++) { + for (let i = 0; i < 60; i++) { + const { board, solution } = dealSolvableBoard(level); + expect(solutionWins(board, solution)).toBe(true); + } + } + }, 30000); + + it("is solvable even with a degenerate RNG (forces the fallback path)", () => { + // A constant RNG makes Fisher-Yates and tie-breaking trivial; the board must + // still be solvable, exercising the deterministic peel/fallback. + const { board, solution } = dealSolvableBoard(1, () => 0); + expect(solutionWins(board, solution)).toBe(true); + }); + + it("produces varied boards between deals", () => { + const fingerprint = (b: GameBoard) => + Object.keys(b) + .sort((a, c) => Number(a) - Number(c)) + .map((k) => b[k].token) + .join(""); + const a = fingerprint(dealSolvableBoard().board); + const b = fingerprint(dealSolvableBoard().board); + expect(a).not.toEqual(b); + }); + + it("grace tiles never break solvability", () => { + const { board, solution } = dealSolvableBoard(5); + expect(Object.values(board).filter((t) => t.grace)).toHaveLength(4); + expect(solutionWins(board, solution)).toBe(true); + }); +}); diff --git a/src/zustand/game-store.test.ts b/src/zustand/game-store.test.ts index bf1f2f8..ffe8220 100644 --- a/src/zustand/game-store.test.ts +++ b/src/zustand/game-store.test.ts @@ -9,6 +9,15 @@ vi.mock("@/utils/post-game-state", () => ({ postGameState: vi.fn().mockResolvedValue({ id: "mock-game-id" }), })); +// start() spins a real 1s interval on the singleton store. beforeEach resets +// state but never clears live intervals, so they leak across tests and keep +// firing step() on the shared store — which made the autosave test flaky on CI. +// Clear any live timer after every test in this file. +afterEach(() => { + const { timer } = useGameStore.getState(); + if (timer) clearInterval(timer); + useGameStore.setState({ timer: null }); +}); describe("useGameStore", () => { beforeEach(() => { @@ -836,30 +845,38 @@ describe("Game State Saving", () => { }); it("autosaves every 60 seconds", async () => { - const { result } = renderHook(() => useGameStore()); + // Fake timers so the live 1s interval that start() creates can't fire an + // extra step() on a slow machine — which would push timePassed to 60 and + // trip the autosave early. The test drives time by calling step() manually. + vi.useFakeTimers(); + try { + const { result } = renderHook(() => useGameStore()); - await act(async () => { - result.current.start(); - await Promise.resolve(); - }); + await act(async () => { + result.current.start(); + await Promise.resolve(); + }); - expect(postGameState).not.toHaveBeenCalled(); + expect(postGameState).not.toHaveBeenCalled(); - await act(async () => { - for (let i = 0; i < 59; i++) { - result.current.step(); - } - await Promise.resolve(); - }); + await act(async () => { + for (let i = 0; i < 59; i++) { + result.current.step(); + } + await Promise.resolve(); + }); - expect(postGameState).not.toHaveBeenCalled(); + expect(postGameState).not.toHaveBeenCalled(); - await act(async () => { - result.current.step(); - await Promise.resolve(); - }); + await act(async () => { + result.current.step(); + await Promise.resolve(); + }); - expect(postGameState).toHaveBeenCalled(); + expect(postGameState).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); it("resets gameId when starting a new game to null", async () => { diff --git a/src/zustand/game-store.ts b/src/zustand/game-store.ts index b974901..b230fe2 100644 --- a/src/zustand/game-store.ts +++ b/src/zustand/game-store.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; import type { GameBoard } from "@/types/game-board"; import { initializeGameBoard } from "@/utils/init-gameboard"; +import { isSelectable } from "@/utils/board-rules"; +import { getOrderStrategy, DEFAULT_STRATEGY } from "@/utils/order-strategies"; import { postGameState } from "@/utils/post-game-state"; export type Timer = ReturnType | null; @@ -37,6 +39,9 @@ export type State = { // True from the moment restart() clears the board until start() finishes // initialising the new board. Used to show a loading indicator in GameBoard. isRestarting: boolean; + // The order strategy the current board was dealt with. Saved with the game so + // highscores record which strategy produced the board (historical rows: 'random'). + strategy: string; }; export type Action = { @@ -55,6 +60,7 @@ export type Action = { scoredPair: () => Promise; levelCleared: () => Promise; continueNextLevel: () => void; + redealCurrentLevel: () => void; endGame: () => Promise; changeName: (name: string) => void; withdraw: () => Promise; @@ -86,6 +92,7 @@ const initialState: State = { boardGeneration: 0, // 0 = no game started yet; Date.now() once a game begins shouldAnimateOnMount: false, isRestarting: false, + strategy: DEFAULT_STRATEGY, }; // Serialises all saves so concurrent calls never race on gameId. @@ -151,6 +158,7 @@ export const useGameStore = create()( set((prev) => ({ ...initialState, gameBoard: initializeGameBoard(), + strategy: getOrderStrategy().name, name: prev.name, boardGeneration: Date.now(), shouldAnimateOnMount: true, @@ -208,68 +216,9 @@ export const useGameStore = create()( }, - allowedforSelection: (index: string) => { - const { gameBoard: board } = get(); - const { row, layer, column } = board[index]; - - // Check for a tile on a higher layer that visually covers the clicked tile. - // Two tiles overlap vertically when row_j + layer_j === row + layer - // (both map to the same topFactor). Only the same column can cover a tile - // because adjacent columns are spaced 1.02× tile-width apart — no visual overlap. - const coveringItem = (row: number, layer: number, column: number) => { - return Object.keys(board).filter((j) => { - return ( - board[j].layer > layer && - board[j].column === column && - board[j].row + board[j].layer === row + layer - ); - }); - }; - - const rowItems = (row: number, layer: number) => { - return Object.keys(board) - .filter((i) => { - return board[i].row === row && board[i].layer == layer; - }); - }; - - // Check if covering tile exists - if (coveringItem(row, layer, column).length !== 0) { - return false; - }; - - switch (true) { - // layer 0 - // see Excalidraw diagram for level 0 edge cases - // 30 == left-side top-left - case index === "30": - return board[42] === undefined; - // 41 == right-side top-right - case index === "41": - return board[55] === undefined; - // 42 == left-side left crooked - case index === "42" || index === "56": - // outermost crooked items, just for documentation sake - return true; - // 43 == left-side top-right - case index === "43": - return board[42] === undefined; - // 54 == right-side bottom-right - case index === "54": - return board[55] === undefined; - case index === "55": // right-side left crooked - return board[56] === undefined; - // layer 3 - case [139, 140, 141, 142].map(String).includes(index): - return board[143] === undefined; - default: - const items = rowItems(row, layer); - return ( - index === String(items[items.length - 1]) || - index === String(items[0]) - ); - } - }, + // Freeness is purely geometric, so it lives in a shared pure helper that + // the solvable-board generator reuses — keeping game and generator in sync. + allowedforSelection: (index: string) => isSelectable(get().gameBoard, index), clicked: async (index: string) => { set((state) => { @@ -376,12 +325,25 @@ export const useGameStore = create()( set(() => ({ levelClear: false, gameBoard: newBoard, + strategy: getOrderStrategy().name, boardGeneration: Date.now(), shouldAnimateOnMount: true, timer: globalThis.setInterval(() => get().step(), EVERY_SECOND), })); }, + // Re-deal the current level's board with the active order strategy, + // keeping level/score/time. Used by the dev strategy switcher so a + // strategy change takes effect on the board immediately. + redealCurrentLevel: () => { + set((state) => ({ + gameBoard: initializeGameBoard(state.level), + strategy: getOrderStrategy().name, + boardGeneration: Date.now(), + shouldAnimateOnMount: true, + })); + }, + endGame: async () => { // Save first, then look up rank, then set gameOver + both rank fields // atomically. This ensures the game-over page always mounts with the @@ -446,6 +408,7 @@ export const useGameStore = create()( score: state.score, maxTime: state.maxTime, timePassed: state.timePassed, + strategy: state.strategy, }); set({ gameId: savedGame.id }); } catch (error) { diff --git a/supabase/migrations/20260525_add_strategy_to_games.sql b/supabase/migrations/20260525_add_strategy_to_games.sql new file mode 100644 index 0000000..2bd1659 --- /dev/null +++ b/supabase/migrations/20260525_add_strategy_to_games.sql @@ -0,0 +1,8 @@ +-- Record the order strategy each game's board was dealt with. +-- +-- Rows created before solvability-by-construction shipped were dealt with a +-- plain random fill, so they backfill to 'random' via the column default. +-- New games record the actual strategy (e.g. 'scatter'). The default also keeps +-- inserts from the previously-deployed client (which omits the column) valid. +-- Idempotent so it is safe to re-run. +ALTER TABLE games ADD COLUMN IF NOT EXISTS strategy text NOT NULL DEFAULT 'random';