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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 9 additions & 5 deletions src/utils/init-gameboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
12 changes: 6 additions & 6 deletions src/utils/init-gameboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/utils/order-strategies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
35 changes: 30 additions & 5 deletions src/utils/order-strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, OrderStrategy> = {
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;
Expand Down
2 changes: 1 addition & 1 deletion src/utils/solvable-board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading