diff --git a/CLAUDE.md b/CLAUDE.md index a561c24..8079950 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### 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). +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`: `tangled` (**production default**), `scatter`, `topDownRandom`, `original`, `bottomUpRandom`. The layer-greedy strategies (`topDownRandom`/`bottomUpRandom`) always self-pair the top-of-pyramid tiles — a visible "ready-made" cluster of matching pairs at the top; `scatter` (highest+lowest) avoids that but is the most forgiving (most simultaneous matches). `tangled` is the default: it peels low tiles first (fewer matches open at once → harder) but disperses each exposed upper tile into the base, so it's ~20% harder than `scatter` with no top cluster. Solvability holds for every adapter because the shared loop only ever removes currently-free tiles; a policy that strands just returns `null` and `dealSolvableBoard` retries, falling back to `canonical` — a deterministic order kept out of the selectable list (internal safety net only). + +Difficulty also scales by level via grace tiles (takeable from anywhere): a slow step ramp of 1 from level 2, 2 from level 5, 3 from level 15+ (`init-gameboard.ts`). 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. diff --git a/src/utils/init-gameboard.test.ts b/src/utils/init-gameboard.test.ts index 1984224..838ed94 100644 --- a/src/utils/init-gameboard.test.ts +++ b/src/utils/init-gameboard.test.ts @@ -42,16 +42,20 @@ describe("grace tiles", () => { expect(Object.values(initializeGameBoard()).filter((t) => t.grace)).toHaveLength(0); }); - it("level 2 has exactly 1 grace tile", () => { + // Grace tiles ramp slowly: 1 from level 2, 2 from level 5, 3 from level 15+. + it("levels 2–4 have exactly 1 grace tile", () => { expect(graceCount(2)).toBe(1); + expect(graceCount(4)).toBe(1); }); - it("level 3 has exactly 2 grace tiles", () => { - expect(graceCount(3)).toBe(2); + it("levels 5–14 have exactly 2 grace tiles", () => { + expect(graceCount(5)).toBe(2); + expect(graceCount(14)).toBe(2); }); - it("level 5 has exactly 4 grace tiles", () => { - expect(graceCount(5)).toBe(4); + it("levels 15+ have exactly 3 grace tiles", () => { + expect(graceCount(15)).toBe(3); + expect(graceCount(30)).toBe(3); }); it("all non-grace tiles have grace: false", () => { diff --git a/src/utils/init-gameboard.ts b/src/utils/init-gameboard.ts index 76c6328..541c89e 100644 --- a/src/utils/init-gameboard.ts +++ b/src/utils/init-gameboard.ts @@ -88,11 +88,11 @@ export const dealSolvableBoard = ( 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); + // Grace tiles (takeable from anywhere) ramp slowly so higher levels don't keep + // getting easier: 1 from level 2, 2 from level 5, 3 from level 15 onward. Grace + // only *adds* selectability, so it can never turn a solvable board unsolvable. + const graceCount = level >= 15 ? 3 : level >= 5 ? 2 : level >= 2 ? 1 : 0; + if (graceCount > 0) { shuffleInPlace(Object.keys(board), random) .slice(0, graceCount) .forEach((idx) => { @@ -114,7 +114,7 @@ export const initializeTestGameBoard = (): GameBoard => { }; // Initializes a guaranteed-solvable game board, filled with matched token pairs. -// From level 2 onward, `level - 1` tiles are marked as grace tiles. +// Grace tiles ramp slowly with level: 1 from L2, 2 from L5, 3 from L15+. export const initializeGameBoard = (level = 1): GameBoard => dealSolvableBoard(level).board; diff --git a/src/utils/order-strategies.test.ts b/src/utils/order-strategies.test.ts index db6b97d..cd958d0 100644 --- a/src/utils/order-strategies.test.ts +++ b/src/utils/order-strategies.test.ts @@ -48,7 +48,7 @@ describe("order strategies (port/adapter)", () => { it("resolves to the production default unless overridden", () => { expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY); - expect(DEFAULT_STRATEGY).toBe("scatter"); + expect(DEFAULT_STRATEGY).toBe("tangled"); setOrderStrategy("original"); expect(getOrderStrategy().name).toBe("original"); diff --git a/src/utils/order-strategies.ts b/src/utils/order-strategies.ts index eec01c4..eef6a69 100644 --- a/src/utils/order-strategies.ts +++ b/src/utils/order-strategies.ts @@ -169,21 +169,46 @@ export const scatter = peelWith( } ); +/** + * Tangled: the harder default. Peels low tiles first (like bottomUp, so fewer + * matches are open at once — harder), but whenever an upper-layer tile is + * exposed it pulls that tile down to pair with a base tile. So the small upper + * layers are dispersed into the base as they surface and never cluster at the + * top — bottomUp's difficulty without its "ready-made" top artifact. + * Measured ~20% fewer simultaneous matches than scatter; 0% top-cluster. + */ +export const tangled = peelWith( + "tangled", + "Peel low tiles first (fewer matches open at once) but disperse exposed upper tiles into the base — harder than scatter, no top cluster.", + (free, board, random) => { + shuffleInPlace(free, random); + free.sort((a, b) => board[a].layer - board[b].layer); // lowest layer first + const topIdx = free.findIndex((i) => board[i].layer >= 2); + if (topIdx !== -1) { + const top = free[topIdx]; + return [top, free[0] === top ? free[1] : free[0]]; // disperse the upper tile into the base + } + return [free[0], free[1]]; // otherwise the two 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, + tangled, scatter, + topDownRandom, 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"; +// `tangled` is the default: it keeps matches scarce (harder) while dispersing the +// upper layers so no "ready-made" cluster forms at the top. The layer-greedy +// strategies (topDown/bottomUp) always self-pair the top-of-pyramid tiles; +// scatter avoids that but is the most forgiving. tangled is the middle ground. +export const DEFAULT_STRATEGY = "tangled"; /** Guaranteed to complete (never strands). Used when a selectable strategy strands. */ export const fallbackStrategy: OrderStrategy = canonical; diff --git a/src/utils/solvable-board.test.ts b/src/utils/solvable-board.test.ts index 0fe2234..7886b85 100644 --- a/src/utils/solvable-board.test.ts +++ b/src/utils/solvable-board.test.ts @@ -72,7 +72,7 @@ describe("solvable board generation", () => { it("grace tiles never break solvability", () => { const { board, solution } = dealSolvableBoard(5); - expect(Object.values(board).filter((t) => t.grace)).toHaveLength(4); + expect(Object.values(board).filter((t) => t.grace)).toHaveLength(2); // level 5 → 2 grace tiles expect(solutionWins(board, solution)).toBe(true); }); });