Compare commits

...

32 Commits

Author SHA1 Message Date
brianfertig 858dc9535d Merge pull request 'Game-Polish' (#10) from Game-Polish into main
Reviewed-on: #10
2026-09-02 05:10:11 +00:00
Brian Fertig 52eaff8b96 Add word-steered board generation and level-scaled special tiles to Book
- Introduce BookworkSteering with context-aware letter placement: class
  steering, vowel-band control, and a real-word completion boost so boards
  stay playable and avoid dead/vowel-clumped states across refills.
- Make gold/diamond spawn chances configurable per level via a linear ramp
  (specialTiles in bookwork.json), wiring the values through clearAndRefill
  and refillSteered instead of hard-coded constants.
- Add verifyBookwormBoard harness comparing baseline vs steered boards on
  word availability, dead-board rate, and simulated session survival, and
  extend verifyBookwork with special-tile schedule checks.
2026-09-01 23:09:12 -06:00
Brian Fertig 44de2fb049 Rename Bookwork to Bookworm and add dictionary-steered grid generation
- Update display name in registry, scene title, README, and tooling references
- Load ENABLE word list at runtime; fall back to unsteered random on failure
- Introduce makeSteeredGrid/refillSteering helpers for class/vowel-aware letter placement
- Export LETTER_WEIGHTS from BookworkLogic for reuse by the steering module
- Add `steer` tuning block (wordBoostK, classThreshold/damp/boost, vowelBand) to bookwork.json
2026-09-01 22:57:07 -06:00
brianfertig e3d2fdee34 Merge pull request 'Game-Polish' (#9) from Game-Polish into main
Reviewed-on: #9
2026-09-02 02:51:15 +00:00
Brian Fertig 3c2cf462ed Polish Mahjong layout: reposition portraits and reference panel
- Rename card back themes (Cthulhu, Cyberpunk, Nautical, etc.) and update
  artwork assets to match
- Move player portraits just left of each seat's tiles so they read as
  belonging to that player without covering any tile
- Reposition the "Hands" reference button clear of seat 2's melds/bonus
  tiles, and hide seat 1's portrait video while the panel covers it
- Auto-close the open reference panel whenever claim or win/kong buttons
  appear so nothing clickable is hidden
2026-09-01 20:49:47 -06:00
Brian Fertig 93db9ef9dc Fix ghost button hover state to use full opacity and correct text color 2026-09-01 20:03:10 -06:00
brianfertig ae30c66a3a Merge pull request 'Polish' (#8) from Polish into main
Reviewed-on: #8
2026-09-01 04:46:09 +00:00
Brian Fertig 429102105b Persist last selected category across menu restarts
- Track the most recently chosen category in module scope so it survives scene.start() reusing the same instance.
- Fall back to that value when entering the menu without explicit category data, restoring the user's previous selection instead of defaulting.
2026-08-31 22:45:22 -06:00
Brian Fertig 1298648e97 Polish opponent select scene and restore category on return
- Replace flat title/subtitle pills with brass Plaque headers in OpponentSelectScene for visual consistency with the game menu
- Shift opponent scroll area down (OPP_SCROLL_TOP 155 -> 180, height 440 -> 415) to clear the subtitle plaque band
- Pass selected category back to GameMenu via scene.start data on Back so the active grid is restored instead of resetting to default
- Add GameMenuScene.init() to capture incoming category and reset stale instance props (_currentCategory, _gridAnim) since scene.start reuses the existing instance
- Guard fadeIn target list with .filter(Boolean) to skip any uncreated elements
2026-08-31 22:21:03 -06:00
Brian Fertig 74cd4df85b Polish title plates with rounded brass-trimmed plaque and size animation
- Replace flat black rectangles behind the game-menu and landing titles with a new Plaque component that draws a rounded, accent-bordered plate with a soft drop shadow and warm top highlight.
- Add letter-spacing and text shadow to the menu title so it reads as raised against the plate.
- When the tab label swaps (e.g. "Loading…" → "Choose a game"), tween the plaque to its new width via a size proxy that redraws each frame, and pop the title text with a Back.easeOut scale so the change feels intentional.
2026-08-31 20:55:29 -06:00
Brian Fertig cbec3b3a44 Polish game menu and landing scene transitions
- Add slide-in/out grid animations when switching categories in the game menu, replacing the instant rebuild
- Rename "Choose a game" to "Choose a Game Category" and update title dynamically on category change
- Fade in menu controls (tabs, hint, back button) on entry to complete the landing handoff
- Animate logo zoom-out and fade buttons out when transitioning from landing to game menu via goToGameMenu()
- Guard against double-taps and stale avatar loads during scene transitions
2026-08-31 19:53:22 -06:00
Brian Fertig f2108e6258 Remove default category selection in favor of a hint prompt
- Drop the `_lastCategory` module-level state and the logic that pre-selected a tab on scene entry.
- Show a "Select a category above to see its games" hint instead, destroyed once the user picks a category.
2026-08-31 19:39:09 -06:00
brianfertig 4ecfcaee50 Merge pull request 'jigsaw-ui-improvements' (#7) from jigsaw-ui-improvements into main
Reviewed-on: #7
2026-08-31 14:47:28 +00:00
Brian Fertig e319f41ce8 Fix jigsaw tray and win-camera to account for the fixed HUD band
- Tray top now clears the HUD at full zoom-out so an entire piece
  (base cell + tabs + shadow headroom) stays below the bar.
- Win camera centers the board in the area below the HUD rather than
  the whole screen, giving the framed picture room under the bar.
- Add AGENTS.md noting the user handles their own commits/pushes.
2026-08-31 08:44:51 -06:00
Brian Fertig 97adfa642d Fix win-screen counter guard and add Playwright browser smoke test
- piecesBig counter guard used .parent (always undefined on container
  children in this Phaser build — they get .parentContainer), so the
  'PIECES' count-up never rendered; drop the dead guard.
- buttonHit() had the same .parent assumption; use .parentContainer.
- Add tools/smokeJigsaw.mjs: headless-Chromium smoke test driving
  menu -> play -> win showcase (camera settle, counter, confetti,
  frame/shine) -> Play Again -> Menu -> play again, asserting zero JS
  errors. Verified pass in browser.
2026-08-31 08:26:32 -06:00
Brian Fertig 18d89d4553 Redesign jigsaw win screen with zoomed board view and celebration effect
- Frame the completed puzzle large on the right while parking stats/actions in a left-hand panel, easing the camera into place
- Add staggered slide-in choreography for title, time, stats (with piece counter), and action buttons
- Fire confetti burst + falling curtain, a shine sweep, and a pulsing gold frame when the win camera lands
- Introduce exitWin() so restart/menu pull the camera back and slide the panel out before rebuilding state
- Simplify pinHUD() now that HUD/win overlay render through the fixed hudCam
2026-08-31 08:11:27 -06:00
brianfertig 0c59a292a3 Merge pull request 'Rework jigsaw UI with stitched mat playfield and two-column menu' (#6) from jigsaw-ui-improvements into main
Reviewed-on: #6
2026-08-31 05:30:00 +00:00
Brian Fertig 243d2e143c Rework jigsaw UI with stitched mat playfield and two-column menu
- Replace the flat felt background with a canvas-generated beige fabric mat (border band, dashed stitching, subtle weave tile, vignette) plus a deeper "pocket" panel behind the board so pieces read as resting on a puzzle mat.
- Add soft drop shadows to jigsaw pieces via an extra shadow pass before the art fill, and pad the piece canvas so the shadow isn't clipped.
- Rebuild the menu into two columns (image picker left, difficulty + start right) with tighter vertical rhythm; make the thumbnail box size a `thumbBox` property so preview/border scale together.
- Consolidate panel background+frame pairs (menu, dropdown, win layer) into single graphics objects to simplify tweening and reduce node count.
- Use a separate invisible zone for table pointerdown so the mat image can be swapped without dropping input, and guard `onTableDown` against double-firing while a piece grab is in progress.
2026-08-30 23:11:58 -06:00
brianfertig c29f33e2a3 Merge pull request 'Puzzle' (#5) from Puzzle into main
Reviewed-on: #5
2026-08-31 03:25:35 +00:00
Brian Fertig a7ebb67413 Extend jigsaw initial-image test coverage
Test now also asserts the menu is back to its original shape (no random-start button), the initial preview and thumb name match the random pick, Start Puzzle begins exactly that image, and the original 🎲 Random button still randomises the menu preview.
2026-08-30 21:19:11 -06:00
Brian Fertig 25f5e05a96 Start the jigsaw puzzle picker on a random image
The menu always opened on the first artwork; now imageIndex is randomised right after loadArtwork(), so each visit to the picker shows a different initial puzzle. The existing Random/prev/next buttons are unchanged (previous random-start button reverted in 4f2d299). Test page + CDP driver verify 8 fresh page loads all land on a valid in-range image with its preview loaded, with variety across loads.
2026-08-30 21:14:37 -06:00
Brian Fertig 4f2d29942d Revert "Add one-click random start to the jigsaw menu"
This reverts commit 0740c12ffa.
2026-08-30 21:12:44 -06:00
Brian Fertig 0740c12ffa Add one-click random start to the jigsaw menu
New '🎲 Start Random' button picks a random picture (never the current one when there's a choice) and starts the puzzle immediately, keeping the selected difficulty. Menu panel grows 820→890 to fit it. Headless-chromium test page + CDP driver verify: button placement, 8/8 random starts reach playing state, image variety, single-image guard, and difficulty preservation.
2026-08-30 21:11:15 -06:00
Brian Fertig 19dff633ce Add jigsaw test harness and local phaser.esm.js bundle
- __jig_test.html boots a Phaser game that loads JigsawGame with stubbed artwork/music caches and exposes window.__* helpers for driving piece grab/drop, scene inspection, and coordinate conversion.
- phaser.esm.js is a vendored webpack build of Phaser used as the "phaser" import map target so the harness can run without a package manager.
2026-08-30 21:03:23 -06:00
Brian Fertig 8dee798ae2 Fix jigsaw piece grab when click lands inside custom hit area
- Add pointerdown handler on each piece to forward the event to the central onTableDown resolver; without it, Phaser's topOnly=true consumes the click and the background never sees it, leaving pieces un-grabbable.
- Anchor the hit-area rectangle to the image's displayOrigin instead of (0,0) so the custom hit box tracks the piece's actual rendered position.
2026-08-30 21:02:22 -06:00
Brian Fertig 85fc7c16bf Add piece joining, board locking, and site soundtrack to jigsaw game
- Introduce group-based table assembly: grid-adjacent pieces snap together on the table, drag as one rigid unit, and lock onto the board as a block
- Extract join/lock rules into pure `resolveDrop` in JigsawLogic.js with a headless verification suite (tools/verifyJigsaw.js) covering fit guarantee, joining, locking, and win counting
- Replace the flat top-bar buttons with a "Menu ▾" dropdown panel to free the top-right corner for the shared site music HUD
- Integrate MusicPlayer soundtrack via getGameSoundtrack() and pin it to the fixed HUD camera
2026-08-30 19:49:00 -06:00
Brian Fertig b6299234cf Fix jigsaw camera bounds and piece tray to avoid overlapping the HUD
- Offset main camera scroll/zoom bounds by HUD_H so the play field
  never slides beneath the fixed top bar
- Shift the scattered-piece tray down by HUD_H to keep pieces clear of
  the target image and HUD
- Assign board panel, frame, and reference image to the main camera only
  so the fixed HUD camera doesn't draw their corners into the screen
2026-08-30 19:03:28 -06:00
Brian Fertig dca1cbef4d Add difficulty-based reference image and ghost slot visibility to Jigsaw
- Hide the faint full-picture reference on Hard and Legendary levels
- Show the target-slot ghost preview only on Easy while dragging a piece
- Store the selected difficulty on the scene for these checks
2026-08-30 18:40:56 -06:00
Brian Fertig 22e6b68cef Add camera pan/zoom and fixed HUD for jigsaw puzzle
- Expand the play field to a 3840x2160 world so pieces can scatter widely and the player can explore
- Introduce MIN_ZOOM (0.5) so the entire field fits on screen, with zoom anchored to the cursor
- Add a second fixed HUD camera so the menu, stats bar, and win overlay stay in screen space while the board camera pans/zooms
- Route world objects and UI containers to their respective cameras via cameraFilter
- Update background, scatter tray, and camera clamping to use the new world dimensions
2026-08-30 18:24:36 -06:00
Brian Fertig a8b4d15e8c Add Jigsaw puzzle game with drag-and-drop pieces and pan/zoom
- New Phaser scene (JigsawGame) supporting 4 difficulty tiers (25–144 pieces),
  image selection, timer, hint overlay, and win screen.
- Pure geometry module (JigsawLogic) generates seeded tab/blank outlines so
  adjacent pieces mesh exactly; also provides deterministic scatter positions.
- Pieces are drawn to canvas textures with depth shading; drag-to-snap with a
  ghost target, camera pan on empty table, and wheel/button zoom (1x–4x).
- Registered in gamesRegistry, main.js scene list, and GameRoomScene slug map;
  game-icons.png updated for the new icon frame.
2026-08-30 17:33:31 -06:00
brianfertig cb4ac7bd85 Merge pull request 'Small update to tutorial' (#4) from Master-of-Vega-Tutorial into main
Reviewed-on: #4
2026-08-29 17:15:37 +00:00
brianfertig d19fd1681b Merge pull request 'Master-of-Vega-Tutorial' (#3) from Master-of-Vega-Tutorial into main
Reviewed-on: #3
2026-08-29 17:13:03 +00:00
31 changed files with 251667 additions and 57 deletions

5
AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
# Agent notes
## Git
- **The user makes their own commits.** Leave changes in the working tree; do not
run `git commit` (or `git push`) unless explicitly asked.

View File

@ -88,7 +88,7 @@ menu categories:
| **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi | | **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi |
| **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle | | **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle |
| **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines | | **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines |
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble | | **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookworm, Kiitos, Tri-Ominoes, Jumble |
| **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter | | **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter |
| **Arcade, Console & PC** | 2 | Colorado Defense, Star Control | | **Arcade, Console & PC** | 2 | Colorado Defense, Star Control |

83
__jig_init_test.html Normal file
View File

@ -0,0 +1,83 @@
<!doctype html>
<html><head><meta charset="utf-8">
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
</head><body>
<div id="log"></div>
<script type="module">
import * as Phaser from 'phaser';
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
const log = (m) => { document.getElementById('log').textContent += m + '\n'; console.log('[t]', m); };
let failures = 0;
const check = (ok, msg) => { if (!ok) { failures++; log('FAIL: ' + msg); } else log('ok: ' + msg); };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const ART = [
{ name: 'Alien World', path: 'assets/images/shift/alien-world.png' },
{ name: 'Aquaroom', path: 'assets/images/shift/aquaroom.png' },
{ name: 'Aztec Warrior', path: 'assets/images/shift/aztec-warrior.png' },
{ name: 'Cat On Tiger', path: 'assets/images/shift/cat-on-tiger.png' },
{ name: 'Cockpit', path: 'assets/images/shift/cockpit.png' },
];
const config = {
type: Phaser.AUTO,
width: 1920, height: 1080,
parent: document.body,
backgroundColor: '#000',
scene: [ { key: 'boot', create() {
this.cache.json.add('shift-artwork', { artwork: ART });
this.cache.json.add('music', { tracks: [] });
this.scene.start('jigsaw-game');
} }, JigsawGame ],
};
const game = new Phaser.Game(config);
const s = () => game.scene.getScene('jigsaw-game');
(async () => {
for (let i = 0; i < 400 && !s(); i++) await wait(50); // boot jigsaw start is async
if (!s()) throw new Error('jigsaw scene never started');
for (let i = 0; i < 400 && !s().menu; i++) await wait(50);
check(!!s().menu, 'menu built');
// Menu is back to the original shape (random-start button reverted).
check(s().randomStartButton === undefined, 'randomStartButton is gone (menu as before)');
// The initial image is random but always a valid artwork entry, and the
// preview shown in the menu matches it.
const n = s().artwork.length;
const i = s().imageIndex;
check(Number.isInteger(i) && i >= 0 && i < n, `initial imageIndex ${i} is a valid index (0..${n - 1})`);
check(!!ART.find((a) => a.name === s().currentImage().name), 'initial currentImage() is a known artwork entry');
// Preview must be built for the initial image (async image load).
for (let k = 0; k < 200 && !s().previewImg; k++) await wait(25);
check(!!s().previewImg, 'initial preview image is displayed');
check(s().thumbName && s().thumbName.text === s().currentImage().name, `thumb name matches initial image (${s().currentImage().name})`);
// Start Puzzle must still start the initial (random) image.
s().selectedDiff = 'easy';
s().startPuzzle();
let playing = false;
for (let k = 0; k < 400 && !playing; k++) { playing = s().state === 'playing'; await wait(25); }
check(playing, 'Start Puzzle reaches playing state');
check(s().pieces.length === 25, '25 pieces built');
check(s().imageName === ART[i].name, `playing image matches the initial pick (${ART[i].name})`);
// The original 🎲 Random button still randomises the preview in the menu.
s().toMenu();
const before = s().imageIndex;
const seen = new Set([before]);
for (let k = 0; k < 10; k++) { s().randomImage(); seen.add(s().imageIndex); await wait(10); }
check(seen.size >= 2, '🎲 Random button still changes the selected image');
document.__initIndex = i;
log(failures === 0 ? 'ALL PASS' : failures + ' FAILURES');
document.title = failures === 0 ? 'PASS' : 'FAIL:' + failures;
})().catch((e) => {
log('ERROR: ' + ((e && e.stack) || e));
document.title = 'FAIL:error';
});
</script>
</body></html>

62
__jig_test.html Normal file
View File

@ -0,0 +1,62 @@
<!doctype html>
<html><head><meta charset="utf-8">
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
</head><body>
<div id="log"></div>
<script type="module">
import * as Phaser from 'phaser';
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
const log = (m) => { const el = document.getElementById('log'); el.textContent += m + '\n'; console.log('[harness]', m); };
const config = {
type: Phaser.WEBGL,
width: 1920, height: 1080,
parent: document.body,
backgroundColor: '#000',
scene: [ { key:'boot', create(){
// Provide the artwork cache so loadArtwork() has a real list.
this.cache.json.add('shift-artwork', { artwork: [ { name:'Alien World', path:'assets/images/shift/alien-world.png' } ] });
this.cache.json.add('music', { tracks: [] });
this.scene.start('jigsaw-game');
} }, JigsawGame ],
};
const game = new Phaser.Game(config);
window.__game = game;
// Helpers exposed for the driver.
window.__log = log;
window.__scene = () => game.scene.getScene('jigsaw-game');
window.__worldToScreen = (wx, wy) => {
const cam = window.__scene().cameras.main;
return { x: (wx - cam.scrollX) * cam.zoom + cam.x, y: (wy - cam.scrollY) * cam.zoom + cam.y };
};
window.__pieceAt = (i) => { const s = window.__scene(); const p = s.pieces[i]; return { i, x:p.img.x, y:p.img.y, placed:p.placed, groupLeader: p.group===p, depth:p.img.depth, hasInput: !!(p.img.input&&p.img.input.enabled) }; };
window.__pieces = () => window.__scene().pieces.map((p,i)=>({i, x:Math.round(p.img.x), y:Math.round(p.img.y), placed:p.placed, depth:p.img.depth}));
window.__startPuzzle = () => { const s = window.__scene(); s.selectedDiff='easy'; s.startPuzzle(); };
window.__grabAndDrop = async (pieceIdx, toWorld, steps=12, holdMs=40) => {
const s = window.__scene();
const p = s.pieces[pieceIdx];
const from = { x: p.img.x, y: p.img.y };
// mousedown at from
const f2s = window.__worldToScreen(from.x, from.y);
await __mouseDown(f2s.x, f2s.y);
await new Promise(r=>setTimeout(r,holdMs));
for (let k=1;k<=steps;k++){
const x = from.x + (toWorld.x-from.x)*k/steps, y = from.y + (toWorld.y-from.y)*k/steps;
const c = window.__worldToScreen(x,y);
await __mouseMove(c.x,c.y);
await new Promise(r=>setTimeout(r,8));
}
await __mouseUp();
return { from, to:{x:p.img.x,y:p.img.y}, moved: Math.hypot(p.img.x-from.x,p.img.y-from.y) };
};
// Low-level mouse dispatch in canvas (client) coordinates.
window.__mouseDown = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousedown',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
window.__mouseMove = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousemove',{clientX:cx,clientY:cy,bubbles:true})); };
window.__mouseUp = (cx=0,cy=0) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mouseup',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
log('harness ready');
</script>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 341 KiB

View File

@ -1,5 +1,18 @@
{ {
"playerBaseHp": 100, "playerBaseHp": 100,
"steer": {
"wordBoostK": 1.0,
"classThreshold": 2,
"classDamp": 0.35,
"classBoost": 1.8,
"vowelBand": [0.30, 0.45]
},
"specialTiles": {
"startLevel": 1,
"endLevel": 10,
"start": { "gold": 0.10, "diamond": 0.06 },
"end": { "gold": 0.03, "diamond": 0.02 }
},
"milestones": [ "milestones": [
{ "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" }, { "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" },
{ "afterLevel": 10, "maxHpBonus": 10 }, { "afterLevel": 10, "maxHpBonus": 10 },

View File

@ -1,13 +1,13 @@
{ {
"default": "back-0", "default": "back-0",
"cardBacks": [ "cardBacks": [
{ "id": "back-0", "name": "Card Back 1", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" }, { "id": "back-0", "name": "Cthulhu", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
{ "id": "back-1", "name": "Card Back 2", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" }, { "id": "back-1", "name": "Cyberpunk", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
{ "id": "back-2", "name": "Card Back 3", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" }, { "id": "back-2", "name": "Nautical", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
{ "id": "back-3", "name": "Card Back 4", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" }, { "id": "back-3", "name": "Steampunk", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
{ "id": "back-4", "name": "Card Back 5", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" }, { "id": "back-4", "name": "Jungle", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
{ "id": "back-5", "name": "Card Back 6", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" }, { "id": "back-5", "name": "Hi Tech", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
{ "id": "back-6", "name": "Card Back 7", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" }, { "id": "back-6", "name": "Fall Mountain", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
{ "id": "back-7", "name": "Card Back 8", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" } { "id": "back-7", "name": "Neon Miami", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
] ]
} }

62
package-lock.json generated Normal file
View File

@ -0,0 +1,62 @@
{
"name": "fertig-classic-games",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fertig-classic-games",
"version": "0.1.0",
"devDependencies": {
"playwright": "^1.62.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

View File

@ -9,5 +9,8 @@
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"devDependencies": {
"playwright": "^1.62.1"
} }
} }

248303
phaser.esm.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -99,7 +99,7 @@ registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame:
registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' }); registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' });
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 }); registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 }); registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 }); registerGame({ slug: 'bookwork', name: 'Bookworm', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 }); registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 });
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 }); registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' }); registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' });
@ -123,3 +123,4 @@ registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-con
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 }); registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 }); registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 }); registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });

View File

@ -9,9 +9,10 @@ import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent, GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage, wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles, clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, computeMaxHp, isPotionUnlocked, specialTileChances,
} from './BookworkLogic.js'; } from './BookworkLogic.js';
import { getAttackDamage, getSpecialTile } from './BookworkAI.js'; import { getAttackDamage, getSpecialTile } from './BookworkAI.js';
import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js';
const CELL = 96; const CELL = 96;
const GRID_W = CELL * GRID_SIZE; const GRID_W = CELL * GRID_SIZE;
@ -39,7 +40,7 @@ export default class BookworkGame extends Phaser.Scene {
constructor() { super('BookworkGame'); } constructor() { super('BookworkGame'); }
init(data) { init(data) {
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookwork' }; this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookworm' };
this.config = { playerBaseHp: 100, milestones: [], levels: [] }; this.config = { playerBaseHp: 100, milestones: [], levels: [] };
this.bank = []; this.bank = [];
this.roster = []; this.roster = [];
@ -56,6 +57,9 @@ export default class BookworkGame extends Phaser.Scene {
this.bgTex = null; this.bgTex = null;
// Battle state // Battle state
this.grid = null; this.grid = null;
this.wordSet = null; // steering dictionary (ENABLE words 315); null → unsteered
this.steerOpts = {};
this.specialSpawn = { goldChance: 0.03, diamondChance: 0.02 };
this.tileObjs = null; this.tileObjs = null;
this.selection = []; this.selection = [];
this.selGraphics = null; this.selGraphics = null;
@ -85,8 +89,16 @@ export default class BookworkGame extends Phaser.Scene {
const raw = this.cache.json.get('bookwork'); const raw = this.cache.json.get('bookwork');
if (raw) this.config = raw; if (raw) this.config = raw;
this.steerOpts = this.config.steer ?? {};
this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level); this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level);
// Load the steering dictionary (same ENABLE list the word validator uses).
// Non-fatal: if it fails, boards fall back to plain weighted-random.
try {
const res = await fetch('data/wordlists/enable1.txt');
if (res.ok) this.wordSet = parseWordList(await res.text());
} catch (_) { this.wordSet = null; }
try { try {
const res = await fetch('data/opponents.json'); const res = await fetch('data/opponents.json');
const json = await res.json(); const json = await res.json();
@ -279,7 +291,7 @@ export default class BookworkGame extends Phaser.Scene {
this.clearLayer(); this.clearLayer();
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 84, 'BOOKWORK', { const title = this.add.text(cx, 84, 'BOOKWORM', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', { const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', {
@ -514,6 +526,7 @@ export default class BookworkGame extends Phaser.Scene {
this.level = level; this.level = level;
this.levelDef = lv; this.levelDef = lv;
this.opponent = this.opponentFor(lv); this.opponent = this.opponentFor(lv);
this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {});
this.clearLayer(); this.clearLayer();
this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted); this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted);
@ -523,7 +536,7 @@ export default class BookworkGame extends Phaser.Scene {
this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted); this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted);
this.potionUsed = false; this.potionUsed = false;
this.turnPhase = 'player'; this.turnPhase = 'player';
this.grid = makeGrid(); this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid();
this.selection = []; this.selection = [];
this.drawBattleUI(); this.drawBattleUI();
@ -832,7 +845,9 @@ export default class BookworkGame extends Phaser.Scene {
} }
// Refill grid // Refill grid
this.grid = clearAndRefill(this.grid, cells); this.grid = this.wordSet
? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn })
: clearAndRefill(this.grid, cells, Math.random, this.specialSpawn);
await this.animateRefill(cells); await this.animateRefill(cells);
this.redrawAllTiles(); this.redrawAllTiles();

View File

@ -1,7 +1,7 @@
export const GRID_SIZE = 5; export const GRID_SIZE = 5;
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed) // Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed)
const LETTER_WEIGHTS = { export const LETTER_WEIGHTS = {
A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2, A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2,
L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2, L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2,
X:1, Y:3, Z:1, X:1, Y:3, Z:1,
@ -16,6 +16,29 @@ export function randomLetter(rng = Math.random) {
return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)]; return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)];
} }
// Default spawn chances for multiplier tiles on refills (late-game floor).
export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 };
// Level schedule for multiplier-tile spawn chances. Interpolates linearly
// from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then
// holds cfg.end. Returns { goldChance, diamondChance }.
// cfg = { startLevel: 1, endLevel: 10,
// start: { gold: 0.10, diamond: 0.06 },
// end: { gold: 0.03, diamond: 0.02 } }
export function specialTileChances(level, cfg = {}) {
const start = cfg.start ?? SPECIAL_TILE_CHANCES;
const end = cfg.end ?? SPECIAL_TILE_CHANCES;
const from = cfg.startLevel ?? 1;
const to = cfg.endLevel ?? from;
const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0;
if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond };
if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond };
return {
goldChance: start.gold + (end.gold - start.gold) * t,
diamondChance: start.diamond + (end.diamond - start.diamond) * t,
};
}
export function makeGrid(rng = Math.random) { export function makeGrid(rng = Math.random) {
const grid = []; const grid = [];
for (let r = 0; r < GRID_SIZE; r++) { for (let r = 0; r < GRID_SIZE; r++) {
@ -69,9 +92,12 @@ export function computeSelfDamage(cells, grid) {
return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5; return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
} }
// Cascade tiles down in each column, fill top with new random tiles // Cascade tiles down in each column, fill top with new random tiles.
export function clearAndRefill(grid, usedCells, rng = Math.random) { // opts may override spawn chances: { goldChance, diamondChance }.
export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`)); const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
const next = grid.map((row) => row.map((cell) => ({ ...cell }))); const next = grid.map((row) => row.map((cell) => ({ ...cell })));
for (let c = 0; c < GRID_SIZE; c++) { for (let c = 0; c < GRID_SIZE; c++) {
@ -82,8 +108,8 @@ export function clearAndRefill(grid, usedCells, rng = Math.random) {
} }
// Fill remainder with new normal tiles // Fill remainder with new normal tiles
while (survive.length < GRID_SIZE) { while (survive.length < GRID_SIZE) {
const gold = rng() < 0.03; const gold = rng() < goldChance;
const diamond = !gold && rng() < 0.02; const diamond = !gold && rng() < diamondChance;
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal'; const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
survive.push({ letter: randomLetter(rng), type }); survive.push({ letter: randomLetter(rng), type });
} }

View File

@ -0,0 +1,204 @@
// BookworkSteering.js — context-aware letter placement for Bookworm
//
// The base engine (BookworkLogic.js) draws every letter independently from a
// weighted pool. That works, but it occasionally produces "dead" boards (no
// 3+ letter word findable) and vowel/consonant clumps, and those clumps
// persist through refills.
//
// This module layers a steering policy on the SAME pool: when a letter is
// chosen for a specific cell, each candidate's weight is adjusted by:
// 1. class steering — damp the class (vowel/consonant) already
// over-represented among the cell's filled neighbours
// 2. vowel band — keep the board-wide vowel share inside a target band
// 3. word boost — boost letters that complete real 3-letter words given
// the letters already placed around this cell
//
// Design notes:
// • Sampling, never argmax — boards stay varied and the injected-rng
// testability of BookworkLogic is preserved.
// • No full words are pre-placed. Steering only biases a single letter by
// what is already on the board, so the "word-friendly" property is
// re-established inductively after every refill — the board stays good
// as the game is played, not just at deal time.
// • With `wordSet` null every helper degrades to the plain weighted pool.
import { GRID_SIZE, LETTER_WEIGHTS, randomLetter, SPECIAL_TILE_CHANCES, getAdjacent, isAdjacent } from './BookworkLogic.js';
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
export const DEFAULT_STEER = {
wordBoostK: 1.0, // weight multiplier per word completed: w *= (1 + K * count)
classThreshold: 2, // neighbour class imbalance (|vowels - consonants|) that triggers steering
classDamp: 0.35, // multiplier applied to the over-represented class
classBoost: 1.8, // multiplier applied to the under-represented class
vowelBand: [0.30, 0.45], // target band for board-wide vowel share
bandMinFilled: 10, // only apply the vowel band once this many cells are filled
};
// Parse an ENABLE-style word list into a Set of A-Z words within [minLen, maxLen].
// 315 matches the /words/scrabble/validate dictionary (ENABLE, 215 letters)
// and the game's minimum playable word length.
export function parseWordList(text, minLen = 3, maxLen = 15) {
const set = new Set();
for (const raw of text.split('\n')) {
const w = raw.trim().toUpperCase();
if (w.length >= minLen && w.length <= maxLen && /^[A-Z]+$/.test(w)) set.add(w);
}
return set;
}
// Context-adjusted weight for every candidate letter at grid[r][c].
// `grid` may contain unfilled cells ({letter: null}) — they are ignored.
export function letterWeights(grid, r, c, wordSet = null, opts = {}) {
const o = { ...DEFAULT_STEER, ...opts };
// ── class steering: neighbour balance ───────────────────────────────
let v = 0, k = 0;
for (const nb of getAdjacent(r, c)) {
const l = grid[nb.r][nb.c]?.letter;
if (!l) continue;
if (VOWELS.has(l)) v++; else k++;
}
let vowelMult = 1, consonantMult = 1;
if (o.classSteer !== false) {
if (v >= k + o.classThreshold) { vowelMult = o.classDamp; consonantMult = o.classBoost; }
else if (k >= v + o.classThreshold) { consonantMult = o.classDamp; vowelMult = o.classBoost; }
}
// ── vowel band: board-wide share ────────────────────────────────────
if (o.vowelBand) {
let filled = 0, vTotal = 0;
for (let rr = 0; rr < GRID_SIZE; rr++) {
for (let cc = 0; cc < GRID_SIZE; cc++) {
if (rr === r && cc === c) continue;
const l = grid[rr][cc]?.letter;
if (!l) continue;
filled++;
if (VOWELS.has(l)) vTotal++;
}
}
if (filled >= o.bandMinFilled) {
const share = vTotal / filled;
if (share > o.vowelBand[1]) { vowelMult *= o.classDamp; consonantMult *= o.classBoost; }
else if (share < o.vowelBand[0]) { consonantMult *= o.classDamp; vowelMult *= o.classBoost; }
}
}
// ── word boost: 3-letter words completed through this cell ─────────
// For candidate L, count real words of the forms a-L-b (a,b filled
// neighbours), L-a-b and a-b-L (a,b filled neighbours, b adjacent a).
let counts = null;
if (wordSet && o.wordBoostK > 0) {
const N = [];
for (const nb of getAdjacent(r, c)) {
const l = grid[nb.r][nb.c]?.letter;
if (l) N.push({ l, r: nb.r, c: nb.c });
}
if (N.length >= 2) {
counts = new Array(26).fill(0);
for (let li = 0; li < 26; li++) {
const L = String.fromCharCode(65 + li);
if (!LETTER_WEIGHTS[L]) continue;
const seen = new Set();
for (const a of N) {
for (const b of N) {
if (a === b) continue;
const ab = isAdjacent(a, b);
const w1 = a.l + L + b.l;
if (wordSet.has(w1) && !seen.has(w1)) { seen.add(w1); counts[li]++; }
if (ab) {
const w2 = L + a.l + b.l;
if (wordSet.has(w2) && !seen.has(w2)) { seen.add(w2); counts[li]++; }
const w3 = a.l + b.l + L;
if (wordSet.has(w3) && !seen.has(w3)) { seen.add(w3); counts[li]++; }
}
}
}
}
}
}
// ── final weights ───────────────────────────────────────────────────
const weights = new Array(26).fill(0);
for (let li = 0; li < 26; li++) {
const L = String.fromCharCode(65 + li);
const base = LETTER_WEIGHTS[L];
if (!base) continue;
let w = base * (VOWELS.has(L) ? vowelMult : consonantMult);
if (counts) w *= 1 + o.wordBoostK * counts[li];
weights[li] = w;
}
return weights;
}
// Sample a letter from a 26-entry weight array (A..Z).
export function pickWeighted(weights, rng) {
let total = 0;
for (const w of weights) total += w;
if (total <= 0) return String.fromCharCode(65 + Math.floor(rng() * 26));
let x = rng() * total;
for (let i = 0; i < 26; i++) {
x -= weights[i];
if (x <= 0) return String.fromCharCode(65 + i);
}
return 'Z';
}
// Build a full 5×5 board with context steering (cells placed in random order,
// each steered by the already-placed neighbours).
export function makeSteeredGrid(rng = Math.random, wordSet = null, opts = {}) {
const grid = Array.from({ length: GRID_SIZE }, () =>
Array.from({ length: GRID_SIZE }, () => ({ letter: null, type: 'normal' })));
if (!wordSet) {
// Degrade to the plain weighted pool.
for (const row of grid) for (const cell of row) cell.letter = randomLetter(rng);
return grid;
}
const cells = [];
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) cells.push({ r, c });
for (let i = cells.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[cells[i], cells[j]] = [cells[j], cells[i]];
}
for (const { r, c } of cells) {
grid[r][c].letter = pickWeighted(letterWeights(grid, r, c, wordSet, opts), rng);
}
return grid;
}
// Same cascade semantics as BookworkLogic.clearAndRefill (survivors fall to
// the bottom of each column, fresh tiles drop into the top rows), except the
// fresh letters are chosen with context steering against the partially
// rebuilt board. Fresh tiles land ABOVE their column's survivors, so this
// also completes vertical word fragments the survivors left behind.
export function refillSteered(grid, usedCells, rng = Math.random, wordSet = null, opts = {}) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
// Pass 1: cascade survivors down each column; mark fresh slots as empty.
const newRows = new Array(GRID_SIZE).fill(0);
for (let c = 0; c < GRID_SIZE; c++) {
const survive = [];
for (let r = GRID_SIZE - 1; r >= 0; r--) {
if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] });
}
newRows[c] = GRID_SIZE - survive.length;
for (let i = 0; i < survive.length; i++) next[GRID_SIZE - 1 - i][c] = survive[i];
for (let r = 0; r < newRows[c]; r++) next[r][c] = { letter: null, type: 'normal' };
}
// Pass 2: place fresh tiles — per column bottom-up, left→right across
// columns — so each new tile sees survivors + already-placed tiles.
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
for (let c = 0; c < GRID_SIZE; c++) {
for (let r = newRows[c] - 1; r >= 0; r--) {
const weights = wordSet ? letterWeights(next, r, c, wordSet, opts) : null;
const letter = weights ? pickWeighted(weights, rng) : randomLetter(rng);
const gold = rng() < goldChance;
const diamond = !gold && rng() < diamondChance;
next[r][c] = { letter, type: gold ? 'gold' : diamond ? 'diamond' : 'normal' };
}
}
return next;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,292 @@
// Pure jigsaw-puzzle geometry + grid model. No Phaser/DOM dependencies so the
// tab/blank math can be unit-checked in Node and reused verbatim by the scene.
//
// Model
// -----
// A cols×rows grid. Every internal edge between two cells carries exactly one
// knob (a protruding "tab") that belongs to one of the two cells; the other
// cell gets the matching "blank" (indent). Boundary edges are flat.
//
// H[r][c] ∈ {'L','R'} — the vertical boundary between (r,c) [left] and
// (r,c+1) [right]. 'L' → left cell owns the tab.
// V[r][c] ∈ {'U','D'} — the horizontal boundary between (r,c) [top] and
// (r+1,c) [bottom]. 'U' → top cell owns the tab.
//
// Because both neighbours compute the SAME knob (same line segment, same side)
// and merely traverse it in opposite directions, adjacent pieces mesh exactly.
export const DIFFICULTIES = {
// Piece counts roughly double per tier. Square grids so the (square) source
// image fills the board edge-to-edge with no letterboxing.
easy: { key: 'easy', label: 'Easy', cols: 5, rows: 5 }, // 25
medium: { key: 'medium', label: 'Medium', cols: 6, rows: 6 }, // 36
hard: { key: 'hard', label: 'Hard', cols: 9, rows: 9 }, // 81
legendary: { key: 'legendary', label: 'Legendary', cols: 12, rows: 12 }, // 144
};
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'legendary'];
// Knob shape as fractions of the edge length (see edgeFragment below).
// neckFrac: how far in from each end the neck (narrow waist) sits.
// ctrlFrac: how far the Bézier controls sit off the edge → peak ≈ 0.75*ctrlFrac.
export const DEFAULT_KNOB = { neckFrac: 0.22, ctrlFrac: 0.30 };
// ── Seeded RNG (mulberry32) so a given seed always yields the same knob layout ──
export function mulberry32(seed) {
let a = seed >>> 0;
return function rng() {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = Math.random;
// Build the knob assignment for every internal edge.
export function makeJigsaw(cols, rows, seed = null) {
const g = seed == null ? rand : mulberry32(seed);
const H = [];
const V = [];
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols - 1; c++) row.push(g() < 0.5 ? 'L' : 'R');
H.push(row);
}
for (let r = 0; r < rows - 1; r++) {
const row = [];
for (let c = 0; c < cols; c++) row.push(g() < 0.5 ? 'U' : 'D');
V.push(row);
}
return { cols, rows, H, V };
}
// Per-cell edge spec. Each entry: { kind:'flat'|'tab'|'blank', normal:{x,y} }
// `normal` is the direction the knob bulges (null for flat edges). This is the
// side the shared curve lies on, so it is identical for both adjacent cells.
export function cellEdgeSpec(jig, r, c) {
const { cols, rows, H, V } = jig;
const out = {};
// Top edge — boundary V[r-1][c] (this cell is the BOTTOM cell of that edge).
if (r === 0) out.top = { kind: 'flat', normal: null };
else {
const owner = V[r - 1][c];
const tab = owner === 'D'; // bottom cell owns the tab
out.top = tab
? { kind: 'tab', normal: { x: 0, y: -1 } }
: { kind: 'blank', normal: { x: 0, y: 1 } };
}
// Right edge — boundary H[r][c] (this cell is the LEFT cell of that edge).
if (c === cols - 1) out.right = { kind: 'flat', normal: null };
else {
const owner = H[r][c];
const tab = owner === 'L'; // left cell owns the tab
out.right = tab
? { kind: 'tab', normal: { x: 1, y: 0 } }
: { kind: 'blank', normal: { x: -1, y: 0 } };
}
// Bottom edge — boundary V[r][c] (this cell is the TOP cell of that edge).
if (r === rows - 1) out.bottom = { kind: 'flat', normal: null };
else {
const owner = V[r][c];
const tab = owner === 'U'; // top cell owns the tab
out.bottom = tab
? { kind: 'tab', normal: { x: 0, y: 1 } }
: { kind: 'blank', normal: { x: 0, y: -1 } };
}
// Left edge — boundary H[r][c-1] (this cell is the RIGHT cell of that edge).
if (c === 0) out.left = { kind: 'flat', normal: null };
else {
const owner = H[r][c - 1];
const tab = owner === 'R'; // right cell owns the tab
out.left = tab
? { kind: 'tab', normal: { x: -1, y: 0 } }
: { kind: 'blank', normal: { x: 1, y: 0 } };
}
return out;
}
// The 4-neighbour cells of (r,c) inside the grid. By construction every such
// pair shares an internal edge, and both pieces trace the *same* shared curve
// for it — so these are exactly the pieces that mesh with (r,c) when placed in
// their correct board slots. That is the legal set of pieces that may join
// (r,c) anywhere on the table; no other pair can ever fit together.
export function cellNeighbours(jig, r, c) {
const { cols, rows } = jig;
const out = [];
if (c > 0) out.push([r, c - 1]);
if (c < cols - 1) out.push([r, c + 1]);
if (r > 0) out.push([r - 1, c]);
if (r < rows - 1) out.push([r + 1, c]);
return out;
}
// ── Table assembly: joining pieces & locking groups (pure, Phaser-free) ─────
// Model the scene feeds in (plain data only — the math never touches Phaser):
// piece: { r, c, home:{x,y}, pos:{x,y}, placed, group }
// group: { pieces: [...] } (piece.group points back)
// cellAt(r, c) -> piece|null (the grid lookup)
//
// Group invariant: every member of a group sits at `anchor.pos + (member.home -
// anchor.home)` for any member `anchor` — i.e. the exact board-relative offset
// — so members always mesh while the group moves. resolveDrop preserves it.
//
// resolveDrop decides what happens when `group` (all members unplaced) is
// released on the table, using the same snap radius for both outcomes:
// 1. BOARD LOCK — if any member is within `snapR` of its home slot, the
// whole group locks onto the board. Only grid-adjacent pieces can share a
// group, and grid-adjacent pieces mesh exactly on the board, so the group
// always lands as a correctly assembled block (every member is then
// aligned too, by the invariant).
// 2. JOIN — otherwise, any unplaced grid-neighbour of any member sitting
// within `snapR` of its correct relative position is absorbed together
// with its WHOLE group; repeated to a fixpoint so a chain of correctly
// placed pieces latches on in a single drop. Non-adjacent pieces can
// never join, no matter where they sit — they wouldn't mesh on the board.
// 3. REST — otherwise the group just rests where it was dropped.
//
// Pure: no mutation. Returns { outcome, placements, absorbedGroups } where
// placements are the target positions the caller must apply and absorbedGroups
// are the (other) groups that merged into `group`.
export function resolveDrop(jig, group, cellAt, snapR) {
// 1) Board lock takes precedence: any member aligned ⇒ the group is placed.
for (const m of group.pieces) {
if (Math.hypot(m.pos.x - m.home.x, m.pos.y - m.home.y) < snapR) {
return {
outcome: 'locked',
placements: group.pieces.map((m) => ({ piece: m, x: m.home.x, y: m.home.y })),
absorbedGroups: [],
};
}
}
// 2) Join: absorb unplaced grid-neighbours at their correct relative spot.
// `frame` is the group's consistent position frame: the dropped group is
// already home-exact, and every absorbed piece is snapped INTO the frame,
// so a chain that latches on ends up fully consistent (invariant holds).
const frame = new Map();
for (const m of group.pieces) frame.set(m, m.pos);
const members = [...group.pieces]; // working set — `group` is not mutated
const inGroup = new Set(members);
const placements = [];
const absorbedGroups = new Set();
let changed = true;
while (changed) {
changed = false;
for (const m of [...members]) {
for (const [nr, nc] of cellNeighbours(jig, m.r, m.c)) {
const q = cellAt(nr, nc);
if (!q || q.placed || inGroup.has(q)) continue;
// Where q belongs in the assembled group relative to m's frame position.
const fm = frame.get(m);
const ex = fm.x + (q.home.x - m.home.x);
const ey = fm.y + (q.home.y - m.home.y);
if (Math.hypot(q.pos.x - ex, q.pos.y - ey) >= snapR) continue;
absorbedGroups.add(q.group);
for (const x of q.group.pieces) {
if (inGroup.has(x)) continue;
// Snap x into the frame (offsets from q are exact).
const px = ex + (x.home.x - q.home.x);
const py = ey + (x.home.y - q.home.y);
frame.set(x, { x: px, y: py });
placements.push({ piece: x, x: px, y: py });
members.push(x);
inGroup.add(x);
}
changed = true;
}
}
}
if (!placements.length) return { outcome: 'rested', placements: [], absorbedGroups: [] };
return { outcome: 'joined', placements, absorbedGroups: [...absorbedGroups].filter((g) => g !== group) };
}
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
// Path commands for one edge, assuming the current point is `p0`.
// Flat → a single line. Knob → line to the near neck, one cubic through the
// bulb to the far neck, line to `p1`. The curve is direction-independent:
// feeding the reversed (p0,p1) yields the same geometric curve (controls swap),
// which is what makes neighbouring pieces mesh.
export function edgeFragment(p0, p1, edge, knob = DEFAULT_KNOB) {
if (!edge || edge.kind === 'flat') {
return [{ t: 'line', x: p1.x, y: p1.y }];
}
const { neckFrac, ctrlFrac } = knob;
const nA = lerp(p0, p1, neckFrac);
const nB = lerp(p0, p1, 1 - neckFrac);
const L = Math.hypot(p1.x - p0.x, p1.y - p0.y);
const cA = { x: nA.x + edge.normal.x * ctrlFrac * L, y: nA.y + edge.normal.y * ctrlFrac * L };
const cB = { x: nB.x + edge.normal.x * ctrlFrac * L, y: nB.y + edge.normal.y * ctrlFrac * L };
return [
{ t: 'line', x: nA.x, y: nA.y },
{ t: 'bezier', c1: cA, c2: cB, x: nB.x, y: nB.y },
{ t: 'line', x: p1.x, y: p1.y },
];
}
// Full clockwise outline of cell (r,c). `W`,`H` are the cell size in local
// units; `ox`,`oy` the cell's top-left in local units. Returns
// { start:{x,y}, cmds:[...] } where cmds are relative to `start`.
export function cellOutline(jig, r, c, W, H, ox = 0, oy = 0, knob = DEFAULT_KNOB) {
const x0 = ox + c * W;
const y0 = oy + r * H;
const TL = { x: x0, y: y0 };
const TR = { x: x0 + W, y: y0 };
const BR = { x: x0 + W, y: y0 + H };
const BL = { x: x0, y: y0 + H };
const spec = cellEdgeSpec(jig, r, c);
const cmds = [
...edgeFragment(TL, TR, spec.top, knob),
...edgeFragment(TR, BR, spec.right, knob),
...edgeFragment(BR, BL, spec.bottom, knob),
...edgeFragment(BL, TL, spec.left, knob),
];
return { start: TL, cmds };
}
// Apply path commands to a 2D canvas context (builds the current path).
export function tracePath(ctx, outline) {
const { start, cmds } = outline;
ctx.moveTo(start.x, start.y);
for (const c of cmds) {
if (c.t === 'line') ctx.lineTo(c.x, c.y);
else ctx.bezierCurveTo(c.c1.x, c.c1.y, c.c2.x, c.c2.y, c.x, c.y);
}
ctx.closePath();
}
// ── Scramble: assign each piece a start position in the "tray" region ─────────
// tray = {x, y, w, h} rectangle (in the same local units as the board) where
// pieces are scattered. Returns an array aligned to the piece index
// (r*cols + c) of {x, y, rotation} (rotation in radians, optional).
export function scramblePieces(jig, tray, seed = null, { spread = 0.9, rotation = false } = {}) {
const g = seed == null ? rand : mulberry32(seed);
const { cols, rows } = jig;
const N = cols * rows;
const placed = [];
const margin = Math.max(tray.w, tray.h) * 0.06;
const x0 = tray.x + margin, x1 = tray.x + tray.w - margin;
const y0 = tray.y + margin, y1 = tray.y + tray.h - margin;
for (let i = 0; i < N; i++) {
// Rejection-sample a few tries so pieces don't pile in a single spot.
let px = x0 + (x1 - x0) * (0.5 + (g() - 0.5) * spread);
let py = y0 + (y1 - y0) * (0.5 + (g() - 0.5) * spread);
let tries = 0;
while (tries < 24 && placed.some((p) => Math.hypot(p.x - px, p.y - py) < Math.min(tray.w, tray.h) * 0.05)) {
px = x0 + (x1 - x0) * g();
py = y0 + (y1 - y0) * g();
tries++;
}
placed.push({ x: px, y: py, rotation: rotation ? (g() - 0.5) * 0.6 : 0 });
}
return placed;
}

View File

@ -81,7 +81,17 @@ export default class MahjongGame extends Phaser.Scene {
const { tracks, volume } = getGameSoundtrack(this); const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) new MusicPlayer(this, tracks, volume); if (tracks.length) new MusicPlayer(this, tracks, volume);
} catch { /* optional */ } } catch { /* optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(DEPTH.bg); // Backdrop: the playfield chosen on the opponent-select screen (image
// texture, or the generated canvas texture for the "colored" playfields),
// falling back to its flat color and finally to the classic deep green.
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
} else {
const color = pf?.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : FELT;
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color).setDepth(DEPTH.bg);
}
const names = [auth.user?.username ?? 'You']; const names = [auth.user?.username ?? 'You'];
const skills = { 0: 5 }; const skills = { 0: 5 };
@ -130,11 +140,16 @@ export default class MahjongGame extends Phaser.Scene {
} }
buildPortraits() { buildPortraits() {
// Each portrait sits just to the left of its own tiles, at the vertical
// centre of that area (rows/columns are all centred on their midpoints),
// so it reads as belonging to that player without ever covering a tile.
// Keep ~18-20px clearance: human hand left edge ≥ 391 (14 tiles), left
// column left edge 133, right column left edge 1836, top row ≥ 616.
const spots = [ const spots = [
{ x: 90, y: 870, r: 46 }, // human — bottom left { x: 325, y: 975, r: 46 }, // human — left of the bottom hand row
{ x: 1830, y: 170, r: 40 }, // seat 1 — right { x: 1778, y: 580, r: 40 }, // seat 1 — left of the right column
{ x: 560, y: 95, r: 40 }, // seat 2 — top { x: 558, y: 70, r: 40 }, // seat 2 — left of the top row
{ x: 90, y: 310, r: 40 }, // seat 3 — left { x: 75, y: 580, r: 40 }, // seat 3 — left of the left column
]; ];
for (let seat = 0; seat < 4; seat++) { for (let seat = 0; seat < 4; seat++) {
const { x, y, r } = spots[seat]; const { x, y, r } = spots[seat];
@ -219,7 +234,10 @@ export default class MahjongGame extends Phaser.Scene {
}).setOrigin(0.5)); }).setOrigin(0.5));
this.refPanel = panel; this.refPanel = panel;
this.refBtn = new Button(this, 1810, 44, 'Hands', () => this.toggleReference(), { // Parked just left of the reference panel's open position (1490) so it
// never ends up under it; y=210 clears seat 2's melds (y≤98) and bonus
// tiles (y≤157), which sit at x 1330+.
this.refBtn = new Button(this, 1399, 210, 'Hands', () => this.toggleReference(), {
width: 150, height: 52, fontSize: 22, variant: 'ghost', width: 150, height: 52, fontSize: 22, variant: 'ghost',
}).setDepth(DEPTH.ref + 1); }).setDepth(DEPTH.ref + 1);
} }
@ -233,6 +251,15 @@ export default class MahjongGame extends Phaser.Scene {
ease: 'Quad.Out', ease: 'Quad.Out',
}); });
this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands'); this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands');
// Seat 1's portrait sits under the open panel. Its <video> is a DOM node
// that always renders above canvas content, so hide it while covered.
this.portraitCtrls[1]?.controller?.setVideoVisible?.(!this.refOpen);
}
// The claim row and the self win/kong buttons sit under the open panel's
// footprint — close it whenever they appear so nothing clickable is covered.
closeReferenceIfOpen() {
if (this.refOpen) this.toggleReference();
} }
// ── tile drawing ────────────────────────────────────────────────────────────── // ── tile drawing ──────────────────────────────────────────────────────────────
@ -382,7 +409,7 @@ export default class MahjongGame extends Phaser.Scene {
bx += 40; bx += 40;
} }
} else { // sides — vertical column of rotated backs } else { // sides — vertical column of rotated backs
const x = seat === 1 ? 1858 : 62; const x = seat === 1 ? 1858 : 155; // columns stay right of their portraits (seat 1: 1778+40, seat 3: 75+40)
let y = 580 - ((n - 1) * step) / 2; let y = 580 - ((n - 1) * step) / 2;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const back = this.makeTileBack(SM_W, SM_H); const back = this.makeTileBack(SM_W, SM_H);
@ -390,7 +417,7 @@ export default class MahjongGame extends Phaser.Scene {
this.dyn.add(back); this.dyn.add(back);
y += step; y += step;
} }
const mx = seat === 1 ? 1745 : 175; const mx = seat === 1 ? 1745 : 270; // melds/bonus offset from their column
let my = 330; let my = 330;
for (const m of p.melds) { for (const m of p.melds) {
const row = this.makeMeldRow(m, 38, 52, false); const row = this.makeMeldRow(m, 38, 52, false);
@ -481,6 +508,7 @@ export default class MahjongGame extends Phaser.Scene {
btn.setVisible(!!spec); btn.setVisible(!!spec);
if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`); if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`);
}); });
if (show && (acts?.canWin || this.kongSpecs.length > 0)) this.closeReferenceIfOpen();
} }
// ── human input ─────────────────────────────────────────────────────────────── // ── human input ───────────────────────────────────────────────────────────────
@ -521,6 +549,7 @@ export default class MahjongGame extends Phaser.Scene {
return new Promise((resolve) => { return new Promise((resolve) => {
this.claimResolve = resolve; this.claimResolve = resolve;
this.chowOptions = opts.chows; this.chowOptions = opts.chows;
this.closeReferenceIfOpen();
const visible = []; const visible = [];
if (opts.win) visible.push(this.claimBtns.win); if (opts.win) visible.push(this.claimBtns.win);
if (opts.kong) visible.push(this.claimBtns.kong); if (opts.kong) visible.push(this.claimBtns.kong);

View File

@ -62,6 +62,7 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js'; import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js'; import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
import ShiftGame from './games/shift/ShiftGame.js'; import ShiftGame from './games/shift/ShiftGame.js';
import JigsawGame from './games/jigsaw/JigsawGame.js';
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js'; import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js'; import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
import MahjongGame from './games/mahjong/MahjongGame.js'; import MahjongGame from './games/mahjong/MahjongGame.js';
@ -184,6 +185,7 @@ const config = {
HexsweeperGame, HexsweeperGame,
PuddingMonstersGame, PuddingMonstersGame,
ShiftGame, ShiftGame,
JigsawGame,
BlockFighterGame, BlockFighterGame,
MahjongMatchGame, MahjongMatchGame,
MahjongGame, MahjongGame,

View File

@ -2,12 +2,11 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js'; import { api } from '../services/api.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
import { TutorialModal } from '../ui/TutorialModal.js'; import { TutorialModal } from '../ui/TutorialModal.js';
let _lastCategory = null;
const CATEGORIES = [ const CATEGORIES = [
{ key: 'tabletop', label: 'Tabletop' }, { key: 'tabletop', label: 'Tabletop' },
{ key: 'cards', label: 'Cards & Dice' }, { key: 'cards', label: 'Cards & Dice' },
@ -22,10 +21,29 @@ const ICON_INACTIVE = 56;
const ICON_ACTIVE = 72; const ICON_ACTIVE = 72;
const ICON_OVERSHOOT = 86; const ICON_OVERSHOOT = 86;
const ICON_X_OFFSET = -145; const ICON_X_OFFSET = -145;
// How far (px) a grid travels off screen when switching categories.
const GRID_TRAVEL = GAME_WIDTH + 800;
// The most recently selected category. Module scope so it survives scene
// restarts (scene.start() reuses the instance) and also covers paths that
// start the menu without category data — the menu always returns to where
// the user last was.
let lastCategory = null;
export default class GameMenuScene extends Phaser.Scene { export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); } constructor() { super('GameMenu'); }
init(data) {
// Category to restore on entry: the explicit one (coming back from the
// opponent picker) or the most recently selected one.
this._initialCategory = (data && data.category) || lastCategory;
// scene.start() reuses the existing scene instance, so instance props from
// the previous visit survive — reset them so this create() starts clean
// (otherwise showCategory()'s "same category" guard swallows the restore).
this._currentCategory = null;
this._gridAnim = null;
}
async create() { async create() {
playMenuMusic(); playMenuMusic();
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
@ -33,12 +51,18 @@ export default class GameMenuScene extends Phaser.Scene {
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT); this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
addFullscreenButton(this); addFullscreenButton(this);
const titleText = this.add.text(cx, 60, 'Choose a game', { const titleText = this.add.text(cx, 60, 'Choose a Game Category', {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '64px', fontSize: '64px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7); titleText.setLetterSpacing(2);
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
this._titleText = titleText;
const plateW = titleText.width + 64;
const plateH = titleText.height + 28;
this._titleBg = new Plaque(this, plateW, plateH).setPosition(cx, 60).setDepth(0.5);
this._titlePlateW = plateW;
const loadingText = this.add.text(cx, 540, 'Loading game list…', { const loadingText = this.add.text(cx, 540, 'Loading game list…', {
fontSize: '24px', color: COLORS.mutedHex, fontSize: '24px', color: COLORS.mutedHex,
@ -107,14 +131,49 @@ export default class GameMenuScene extends Phaser.Scene {
this._tabIcons[key] = icon; this._tabIcons[key] = icon;
}); });
const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key; // No category is selected by default — the user picks one to see games.
this.showCategory(startKey); this._hintText = this.add.text(cx, 540, 'Select a category above to see its games', {
fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5);
new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' }); this._backBtn = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
// Returning from the opponent picker: restore the category that was
// active when the game was chosen — its grid flies back in from the right.
if (this._initialCategory) {
this.showCategory(this._initialCategory);
}
// The landing scene zooms the logo out before starting us, so fade the
// menu controls in to complete the handoff.
const fadeIn = [...Object.values(this._tabs), ...Object.values(this._tabIcons), this._hintText, this._backBtn].filter(Boolean);
for (const obj of fadeIn) obj.setAlpha(0);
this.tweens.add({
targets: fadeIn,
alpha: 1,
duration: 450,
ease: 'Power2.easeOut',
});
} }
showCategory(key) { showCategory(key) {
_lastCategory = key; if (key === this._currentCategory) return;
this._currentCategory = key;
lastCategory = key;
if (this._hintText) { this._hintText.destroy(); this._hintText = null; }
if (this._titleText) {
this._titleText.setText('Choose a game');
const newW = this._titleText.width + 64;
const newH = this._titleText.height + 28;
if (this._titlePlateW && Math.abs(this._titlePlateW - newW) > 1) {
this._titleBg.animateSize(newW, newH, 240, 'Quad.easeOut');
this._titlePlateW = newW;
}
// Little pop so the label swap reads as intentional.
this._titleText.setScale(0.9);
this.tweens.add({ targets: this._titleText, scaleX: 1, scaleY: 1, duration: 240, ease: 'Back.easeOut' });
}
for (const [k, btn] of Object.entries(this._tabs)) { for (const [k, btn] of Object.entries(this._tabs)) {
btn.setActive(k === key); btn.setActive(k === key);
} }
@ -162,13 +221,41 @@ export default class GameMenuScene extends Phaser.Scene {
} }
} }
for (const obj of this._gameObjects) obj.destroy(); // Interrupt any grid animation still in flight (user changed their mind mid-flight).
if (this._gridAnim) {
for (const obj of this._gridAnim.objects) obj.destroy();
this._gridAnim = null;
this._gameObjects = []; this._gameObjects = [];
}
if (this._ptrMoveHandler) { if (this._ptrMoveHandler) {
this.input.off('pointermove', this._ptrMoveHandler, this); this.input.off('pointermove', this._ptrMoveHandler, this);
this._ptrMoveHandler = null; this._ptrMoveHandler = null;
} }
const oldObjects = this._gameObjects;
this._gameObjects = [];
if (oldObjects.length === 0) {
this.buildCategoryGrid(key);
return;
}
// Fly the outgoing grid off the left edge, then bring the new one in from the right.
for (const obj of oldObjects) obj.disableInteractive();
this._gridAnim = { objects: oldObjects };
this.tweens.add({
targets: oldObjects,
x: `-=${GRID_TRAVEL}`,
duration: 380,
ease: 'Power2.easeIn',
onComplete: () => {
this._gridAnim = null;
for (const obj of oldObjects) obj.destroy();
this.buildCategoryGrid(key);
},
});
}
buildCategoryGrid(key) {
const games = this._gamesByCategory[key]; const games = this._gamesByCategory[key];
if (!games || games.length === 0) return; if (!games || games.length === 0) return;
@ -267,6 +354,18 @@ export default class GameMenuScene extends Phaser.Scene {
this._gameObjects.push(qg, qLabel); this._gameObjects.push(qg, qLabel);
} }
}); });
// Fly the grid in from off screen right.
const objs = this._gameObjects;
for (const obj of objs) obj.x += GRID_TRAVEL;
this._gridAnim = { objects: objs };
this.tweens.add({
targets: objs,
x: `-=${GRID_TRAVEL}`,
duration: 420,
ease: 'Power2.easeOut',
onComplete: () => { this._gridAnim = null; },
});
} }
openGame(game) { openGame(game) {

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' }; const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug]; const sceneKey = slugDispatch[this.game.slug];
const startData = { const startData = {

View File

@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js'; import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic } from '../ui/MenuMusic.js';
@ -16,6 +17,8 @@ export default class LandingScene extends Phaser.Scene {
const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0); const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0);
logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75); logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75);
this._logo = logo;
this._transitioning = false;
this.tweens.add({ this.tweens.add({
targets: logo, targets: logo,
@ -53,6 +56,7 @@ export default class LandingScene extends Phaser.Scene {
renderButtons() { renderButtons() {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const user = auth.user; const user = auth.user;
this._avatar = null;
const avatarR = 28; const avatarR = 28;
const avatarGap = 18; const avatarGap = 18;
@ -65,6 +69,8 @@ export default class LandingScene extends Phaser.Scene {
fontSize: '36px', fontSize: '36px',
color: COLORS.accentHex, color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
this._welcomeText = welcomeText;
welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
const hasAvatar = !!user?.avatarPath; const hasAvatar = !!user?.avatarPath;
const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width; const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width;
@ -72,7 +78,8 @@ export default class LandingScene extends Phaser.Scene {
welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx); welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx);
this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45); this._welcomeBg = new Plaque(this, totalW + pad.x * 2, welcomeText.height + pad.y * 2, { radius: 16 })
.setPosition(cx, y);
if (hasAvatar) { if (hasAvatar) {
const avatarCx = groupLeft + avatarR; const avatarCx = groupLeft + avatarR;
@ -86,11 +93,11 @@ export default class LandingScene extends Phaser.Scene {
this.load.start(); this.load.start();
}); });
} }
if (!this.scene.isActive('Landing')) return; if (this._transitioning || !this.scene.isActive('Landing')) return;
const maskG = this.make.graphics({ x: 0, y: 0, add: false }); const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff); maskG.fillStyle(0xffffff);
maskG.fillCircle(avatarCx, y, avatarR); maskG.fillCircle(avatarCx, y, avatarR);
this.add.image(avatarCx, y, key) this._avatar = this.add.image(avatarCx, y, key)
.setDisplaySize(avatarR * 2, avatarR * 2) .setDisplaySize(avatarR * 2, avatarR * 2)
.setMask(maskG.createGeometryMask()) .setMask(maskG.createGeometryMask())
.setDepth(1); .setDepth(1);
@ -98,7 +105,39 @@ export default class LandingScene extends Phaser.Scene {
})(); })();
} }
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 }); this.playBtn = new Button(this, cx, 810, 'Play', () => this.goToGameMenu(), { width: 480 });
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 }); this.profileBtn = new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
}
goToGameMenu() {
if (this._transitioning) return;
this._transitioning = true;
this.playBtn.disableInteractive();
this.profileBtn.disableInteractive();
// Fade the landing buttons out while the logo zooms past the camera.
const fadeOut = [this.playBtn, this.profileBtn, this._welcomeText, this._welcomeBg].filter(Boolean);
if (this._avatar) fadeOut.push(this._avatar);
this.tweens.add({
targets: fadeOut,
alpha: 0,
duration: 350,
ease: 'Power2.easeOut',
});
if (!this._logo) { this.scene.start('GameMenu'); return; }
this.tweens.killTweensOf(this._logo);
this.tweens.add({
targets: this._logo,
x: GAME_WIDTH / 2,
y: GAME_HEIGHT / 2,
scaleX: 2.4,
scaleY: 2.4,
alpha: 0,
duration: 700,
ease: 'Cubic.easeIn',
onComplete: () => this.scene.start('GameMenu'),
});
} }
} }

View File

@ -1,6 +1,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js'; import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js'; import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js';
@ -17,8 +18,8 @@ const CARD_TILE_GAP = 14;
// Opponent grid scroll area // Opponent grid scroll area
const OPP_SCROLL_W = 1780; const OPP_SCROLL_W = 1780;
const OPP_SCROLL_H = 440; const OPP_SCROLL_H = 415;
const OPP_SCROLL_TOP = 155; // top edge of scroll area const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque)
export default class OpponentSelectScene extends Phaser.Scene { export default class OpponentSelectScene extends Phaser.Scene {
constructor() { super('OpponentSelect'); } constructor() { super('OpponentSelect'); }
@ -58,21 +59,32 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1); this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1); this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1);
const titleText = this.add.text(cx, 60, this.gameDef.name, { // Brass-plaque headers, same treatment as the game menu headings. The
// subtitle plaque sits in the band between the title plaque and the
// opponent list (OPP_SCROLL_TOP) — keep those in sync if you move either.
const TITLE_Y = 55;
const PLATE_GAP = 10;
const titleText = this.add.text(cx, TITLE_Y, this.gameDef.name, {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '52px', fontSize: '52px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72); titleText.setLetterSpacing(2);
this.children.moveBelow(titlePill, titleText); titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
const titlePlate = new Plaque(this, titleText.width + 64, titleText.height + 28).setPosition(cx, TITLE_Y);
this.children.moveBelow(titlePlate, titleText);
const subtitleText = this.add.text(cx, 122, 'Choose your opponent', { const subtitleText = this.add.text(cx, 0, 'Choose your opponent', {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '36px', fontSize: '36px',
color: COLORS.mutedHex, color: COLORS.mutedHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72); subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
this.children.moveBelow(subtitlePill, subtitleText); const subPlateH = subtitleText.height + 28;
const subtitleY = TITLE_Y + (titleText.height + 28) / 2 + PLATE_GAP + subPlateH / 2;
subtitleText.setY(subtitleY);
const subtitlePlate = new Plaque(this, subtitleText.width + 64, subPlateH, { radius: 16 }).setPosition(cx, subtitleY);
this.children.moveBelow(subtitlePlate, subtitleText);
let opponents = []; let opponents = [];
try { try {
@ -87,7 +99,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
} }
const min = this.gameDef.minOpponents ?? 1; const min = this.gameDef.minOpponents ?? 1;
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), { new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu', { category: this.gameDef.category }), {
variant: 'ghost', variant: 'ghost',
width: 280, width: 280,
}); });

View File

@ -55,8 +55,8 @@ export class Button extends Phaser.GameObjects.Container {
if (this._active) return; if (this._active) return;
const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options; const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options;
if (v === 'ghost') { if (v === 'ghost') {
this._drawBg(bgh, 0.18); this._drawBg(bgh, 0.9);
this.text.setColor(COLORS.goldHex); this.text.setColor(thc);
} else { } else {
this._drawBg(bgh, 1); this._drawBg(bgh, 1);
this.text.setColor(thc); this.text.setColor(thc);

62
src/ui/Plaque.js Normal file
View File

@ -0,0 +1,62 @@
import * as Phaser from 'phaser';
import { COLORS } from '../config.js';
// A rounded, brass-trimmed plaque in the same design language as the
// category tabs. Use instead of a flat black rectangle behind title text.
export class Plaque extends Phaser.GameObjects.Graphics {
constructor(scene, width = 300, height = 90, options = {}) {
super(scene, false);
const {
fill = 0x241c10,
fillAlpha = 0.92,
border = COLORS.accent,
radius = 18,
} = options;
this._opts = { fill, fillAlpha, border, radius };
this._w = width;
this._h = height;
this.postFX.addShadow(0, 3, 0.004, 1.5, 0x000000, 8, 0.65);
this.redraw(width, height);
scene.add.existing(this);
}
redraw(w, h) {
this._w = w;
this._h = h;
const { fill, fillAlpha, border, radius } = this._opts;
const r = Math.max(4, Math.min(radius, w / 2, h / 2));
this.clear();
this.fillStyle(fill, fillAlpha);
this.fillRoundedRect(-w / 2, -h / 2, w, h, r);
// Faint warm highlight just inside the top edge reads as a raised plate.
this.lineStyle(2, 0xfff6dd, 0.16);
this.lineBetween(-w / 2 + r + 4, -h / 2 + 4, w / 2 - r - 4, -h / 2 + 4);
this.lineStyle(2, border, 1);
this.strokeRoundedRect(-w / 2, -h / 2, w, h, r);
}
// Animate the plate to a new size. Graphics in this Phaser build exposes
// no setScaleX/setScaleY (and scaling a redrawn shape is unreliable), so
// we tween a size proxy and redraw every frame instead.
animateSize(w, h, duration = 240, ease = 'Quad.easeOut', onComplete) {
if (this._sizeTween) this._sizeTween.stop();
const proxy = { w: this._w, h: this._h };
this._sizeTween = this.scene.tweens.add({
targets: proxy,
w,
h,
duration,
ease,
onUpdate: () => {
if (!this.scene) return; // destroyed mid-animation
this.redraw(proxy.w, proxy.h);
},
onComplete: () => {
this._sizeTween = null;
if (!this.scene) return; // destroyed mid-animation
this.redraw(w, h);
if (onComplete) onComplete();
},
});
}
}

View File

@ -0,0 +1,81 @@
// Headless-chromium CDP driver for the jigsaw initial-image test page.
// Loads the page repeatedly; each load must end PASS, and the initial image
// index (document.__initIndex) must vary across fresh loads.
// Usage: node tools/__jig_init_driver.mjs <url> [loads=8]
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const url = process.argv[2];
const loads = Math.max(2, parseInt(process.argv[3] || '8', 10));
if (!url) { console.error('usage: driver <url> [loads]'); process.exit(2); }
const BIN = process.env.HOME + '/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell';
const PORT = 9333;
const chrome = spawn(BIN, [
'--headless', '--no-sandbox', '--disable-gpu',
`--remote-debugging-port=${PORT}`,
'about:blank',
], { stdio: ['ignore', 'pipe', 'pipe'] });
chrome.stderr.on('data', (d) => { const s = d.toString(); if (!/Fontconfig|dbus|DBus|ozone/i.test(s)) process.stderr.write(s); });
async function httpJson(path) {
const r = await fetch(`http://127.0.0.1:${PORT}${path}`);
return r.json();
}
let ws, idc = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++idc;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
try {
let targets = null;
for (let i = 0; i < 60; i++) {
try { targets = await httpJson('/json/list'); break; } catch { await sleep(250); }
}
if (!targets) throw new Error('chrome devtools endpoint never came up');
const page = targets.find((t) => t.type === 'page');
if (!page) throw new Error('no page target');
ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
}
};
await send('Runtime.enable');
await send('Page.enable');
const ev = (expression) => send('Runtime.evaluate', { expression, returnByValue: true }).then((r) => r.result.value);
const indexes = [];
for (let k = 0; k < loads; k++) {
await send('Page.navigate', { url });
const t0 = Date.now();
let title = '';
while (Date.now() - t0 < 60000) {
title = (await ev('document.title')) || '';
if (title === 'PASS' || title.startsWith('FAIL')) break;
await sleep(500);
}
if (title !== 'PASS') throw new Error(`load #${k}: ${title || 'TIMEOUT'}`);
indexes.push(await ev('document.__initIndex'));
}
const valid = indexes.every((i) => Number.isInteger(i) && i >= 0 && i < 5);
const distinct = new Set(indexes).size;
console.log('initial indexes across fresh loads:', indexes.join(', '));
console.log(`all valid: ${valid}, distinct images: ${distinct}/${loads}`);
if (!valid || distinct < 2) { console.log('FAIL'); process.exitCode = 1; }
else console.log('ALL PASS');
} finally {
try { ws && ws.close(); } catch {}
chrome.kill('SIGKILL');
}

162
tools/smokeJigsaw.mjs Normal file
View File

@ -0,0 +1,162 @@
// Browser smoke test for the Jigsaw game (Playwright + headless Chromium).
//
// Usage:
// npx playwright install chromium # one-time
// python3 -m http.server 8000 # serve the repo root (or pass a base URL)
// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000
// node tools/smokeJigsaw.mjs http://localhost:9000
//
// What it checks:
// - game loads, menu → Start Puzzle → playing, pieces spawn on the table
// - forced win (placed = total; onWin()) runs the full showcase:
// * camera sweep lands on WIN_ZOOM framing the board
// * stats counter animates up to the total
// * celebration emitters fire (burst + curtain) and confetti is alive
// * gold frame + shine sweep exist at the right depths
// - "Play Again" restarts a fresh board and tears the win layer down
// - "Menu" returns to the menu and destroys the win layer
// - menu → Start Puzzle works again
// - zero JS console/page errors across the whole flow
//
// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few
// FPS (SwiftShader), so the scene clock advances slowly in real time. All
// waits here are condition-based polling (generous timeouts), never fixed
// sleeps. A full run takes roughly 13 minutes of wall time.
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import os from 'node:os';
import path from 'node:path';
import { chromium } from 'playwright';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html';
let server = null;
if (!process.argv[2]) {
try {
await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) });
} catch {
server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true });
await new Promise((r) => setTimeout(r, 1200));
console.log('started local static server on :8000');
}
}
const errors = [];
let failed = false;
const ok = (m) => console.log(' ok ' + m);
const bad = (m) => { failed = true; console.log(' FAIL ' + m); };
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
const evalS = (fn, ...a) => page.evaluate(fn, ...a);
const waitFor = async (name, cond, timeoutMs) => {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
if (await evalS(cond)) return true;
await new Promise((r) => setTimeout(r, 500));
}
bad(`timeout waiting for: ${name}`);
return false;
};
const snap = async (name) => {
const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`);
await page.screenshot({ path: dest, fullPage: false });
ok(`screenshot ${dest}`);
};
const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]);
const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000);
try {
console.log('— load —');
await page.goto(BASE, { waitUntil: 'load' });
await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 });
ok('harness ready');
console.log('— start puzzle —');
await evalS(() => window.__startPuzzle());
if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing');
ok('state = playing');
const pieces = await evalS(() => window.__pieces());
ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`);
if (pieces.length === 0) bad('no pieces spawned');
await snap('1_playing');
console.log('— force win —');
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')');
console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —');
if (await waitFor('camera settled + counter=total', () => {
const s = window.__scene();
return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01
&& s.piecesBig && s.piecesBig.text === String(s.total);
}, settleTimeout)) {
const wi = await evalS(() => {
const s = window.__scene();
return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text };
});
ok('settled: ' + JSON.stringify(wi));
await snap('2_win_panel');
}
console.log('— wait for celebration (emitters created + particles alive) —');
if (await waitFor('emitters alive', () => {
const s = window.__scene();
return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0);
}, settleTimeout)) {
const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount()));
ok('particles alive: ' + JSON.stringify(alive));
await snap('3_confetti');
const fx = await evalS(() => {
const s = window.__scene();
return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth };
});
ok('frame/shine: ' + JSON.stringify(fx));
if (!fx.frame || !fx.shine) bad('winFrame or winShine missing');
}
console.log('— Play Again button —');
await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684)
if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) {
const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length);
const winGone = await evalS(() => !window.__scene().winLayer);
ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`);
if (placed !== 0) bad('pieces not reset after Play Again');
if (!winGone) bad('winLayer not destroyed after Play Again');
await snap('4_restarted');
}
console.log('— force win again, then Menu button —');
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) {
ok('second win showcase running');
await click(400, 720); // inside "Menu"
if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) {
const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible);
const winGone = await evalS(() => !window.__scene().winLayer);
ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`);
if (!menuVisible) bad('menu not visible');
if (!winGone) bad('winLayer not destroyed');
await snap('5_menu');
}
}
console.log('— Start Puzzle from menu works —');
await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); });
if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK');
} finally {
await browser.close();
if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } }
}
console.log('\n— JS errors (' + errors.length + ') —');
errors.forEach((e) => console.log(' ' + e));
if (errors.length) failed = true;
console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS');
process.exit(failed ? 1 : 0);

View File

@ -1,13 +1,14 @@
#!/usr/bin/env node #!/usr/bin/env node
// verifyBookwork.js — engine tests for Bookwork // verifyBookwork.js — engine tests for Bookworm
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent, GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage, wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles, clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES,
} from '../src/games/bookwork/BookworkLogic.js'; } from '../src/games/bookwork/BookworkLogic.js';
import { refillSteered } from '../src/games/bookwork/BookworkSteering.js';
import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js'; import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js';
let pass = 0, fail = 0; let pass = 0, fail = 0;
@ -92,6 +93,34 @@ const after = clearAndRefill(gridR, used);
ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE)); ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE));
ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type)); ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type));
// ── special tile schedule ────────────────────────────────────────────────────
console.log('\nspecial tile schedule');
const SCHED = {
startLevel: 1, endLevel: 10,
start: { gold: 0.10, diamond: 0.06 },
end: { gold: 0.03, diamond: 0.02 },
};
ok('no cfg → current defaults', specialTileChances(1) .goldChance === 0.03 && specialTileChances(1).diamondChance === 0.02);
const at = (lv) => specialTileChances(lv, SCHED);
ok('level 1 = boosted start', at(1).goldChance === 0.10 && at(1).diamondChance === 0.06);
ok('level 10 = current rates', at(10).goldChance === 0.03 && at(10).diamondChance === 0.02);
ok('level 20 holds at current rates', at(20).goldChance === 0.03 && at(20).diamondChance === 0.02);
ok('clamps below startLevel', at(0).goldChance === 0.10);
const ramp = Array.from({ length: 10 }, (_, i) => specialTileChances(i + 1, SCHED).goldChance);
ok('gold chance monotonically decreases L1→L10', ramp.every((v, i) => i === 0 || v <= ramp[i - 1]));
ok('ramp stays within [end, start]', ramp.every((v) => v >= 0.03 && v <= 0.10));
const forcedGold = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 1 });
const newCount = 3;
ok('goldChance:1 → every fresh tile is gold',
forcedGold.flat().filter((c) => c.type === 'gold').length === newCount);
const forcedDiamond = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 0, diamondChance: 1 });
ok('diamondChance:1 → every fresh tile is diamond',
forcedDiamond.flat().filter((c) => c.type === 'diamond').length === newCount);
const steeredGold = refillSteered(makeGrid(), used, Math.random, null, { goldChance: 1 });
ok('refillSteered honors goldChance:1',
steeredGold.flat().filter((c) => c.type === 'gold').length === newCount);
// ── dropSpecialTile ──────────────────────────────────────────────────────────── // ── dropSpecialTile ────────────────────────────────────────────────────────────
console.log('\ndropSpecialTile'); console.log('\ndropSpecialTile');
const gridS = makeGrid(() => 0.5); const gridS = makeGrid(() => 0.5);
@ -175,6 +204,10 @@ if (bwData) {
ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp); ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp);
ok('playerBaseHp present', bwData.playerBaseHp > 0); ok('playerBaseHp present', bwData.playerBaseHp > 0);
ok('milestones array present', Array.isArray(bwData.milestones)); ok('milestones array present', Array.isArray(bwData.milestones));
const st = bwData.specialTiles;
ok('specialTiles schedule well-formed', !!st && st.startLevel < st.endLevel
&& st.start.gold > st.end.gold > 0 && st.start.diamond > st.end.diamond > 0
&& st.end.gold === SPECIAL_TILE_CHANCES.gold && st.end.diamond === SPECIAL_TILE_CHANCES.diamond);
} }
// ── Summary ─────────────────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,210 @@
#!/usr/bin/env node
// verifyBookwormBoard.js — Bookworm board-quality harness
//
// Compares the plain weighted-random board (baseline) against the steering
// layer (class steering + vowel band + word-completion boost) on:
// • static boards — words available, dead boards, vowel share
// • simulated sessions — play the longest word each turn, refill, repeat:
// words available per turn, dead turns (no word to play), turn survival
//
// Deterministic (seeded RNG). The dictionary is the same ENABLE list
// (315 letters) that /words/scrabble/validate accepts, so "available word"
// means "word the player can actually submit".
//
// Run: node tools/verifyBookwormBoard.js
import { readFileSync } from 'node:fs';
import {
GRID_SIZE, makeGrid, clearAndRefill, getAdjacent,
} from '../src/games/bookwork/BookworkLogic.js';
import {
makeSteeredGrid, refillSteered, parseWordList, letterWeights, pickWeighted, DEFAULT_STEER,
} from '../src/games/bookwork/BookworkSteering.js';
const N_BOARDS = 300;
const N_SESSIONS = 60;
const MAX_TURNS = 30;
let pass = 0, fail = 0;
function ok(label, cond) {
if (cond) { console.log(`${label}`); pass++; }
else { console.error(`${label}`); fail++; }
}
// ── deterministic rng ─────────────────────────────────────────────────────────
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── word finding (boggle DFS with prefix pruning; one path per word) ─────────
function buildPrefixSet(wordSet) {
const pre = new Set();
for (const w of wordSet) for (let i = 1; i <= w.length; i++) pre.add(w.slice(0, i));
return pre;
}
function findWords(grid, wordSet, prefixSet) {
const found = new Map(); // word -> [cells]
const visited = Array.from({ length: GRID_SIZE }, () => new Array(GRID_SIZE).fill(false));
const dfs = (r, c, word, cells) => {
const w = word + grid[r][c].letter;
const isWord = w.length >= 3 && wordSet.has(w);
if (isWord && !found.has(w)) found.set(w, [{ r, c }, ...cells]);
if (!prefixSet.has(w)) return;
if (cells.length + 1 >= GRID_SIZE * GRID_SIZE) return;
for (const { r: nr, c: nc } of getAdjacent(r, c)) {
if (visited[nr][nc]) continue;
visited[nr][nc] = true;
dfs(nr, nc, w, [...cells, { r, c }]);
visited[nr][nc] = false;
}
};
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) {
visited[r][c] = true;
dfs(r, c, '', []);
visited[r][c] = false;
}
return found;
}
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
const vowelShare = (grid) =>
grid.flat().filter((c) => c.letter && VOWELS.has(c.letter)).length / (GRID_SIZE * GRID_SIZE);
const quantile = (arr, q) => {
const s = [...arr].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor(q * s.length))];
};
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / Math.max(1, arr.length);
// ── setup ─────────────────────────────────────────────────────────────────────
console.log('Loading dictionary…');
const wordSet = parseWordList(readFileSync('./data/wordlists/enable1.txt', 'utf8'));
const prefixSet = buildPrefixSet(wordSet);
console.log(` ${wordSet.size} words (315 letters), ${prefixSet.size} prefixes\n`);
const variants = {
baseline: {
make: (rng) => makeGrid(rng),
refill: (g, cells, rng) => clearAndRefill(g, cells, rng),
},
steered: {
make: (rng) => makeSteeredGrid(rng, wordSet, DEFAULT_STEER),
refill: (g, cells, rng) => refillSteered(g, cells, rng, wordSet, DEFAULT_STEER),
},
};
// ── steering sanity ───────────────────────────────────────────────────────────
console.log('Steering sanity');
{
const rng = mulberry32(1);
const g = makeSteeredGrid(rng, wordSet, DEFAULT_STEER);
ok('steered grid is 5×5', g.length === GRID_SIZE && g.every((r) => r.length === GRID_SIZE));
ok('all cells filled with A-Z', g.flat().every((c) => /^[A-Z]$/.test(c.letter)));
ok('no Q (absent from base pool)', !g.flat().some((c) => c.letter === 'Q'));
const w = letterWeights(g, 2, 2, wordSet, DEFAULT_STEER);
ok('letterWeights: Q weight is 0', w['Q'.charCodeAt(0) - 65] === 0);
ok('letterWeights: total weight > 0', w.reduce((a, b) => a + b, 0) > 0);
const letter = pickWeighted(w, mulberry32(7));
ok('pickWeighted returns a letter in the pool', /^[A-Z]$/.test(letter) && letter !== 'Q');
const ref = refillSteered(g, [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }], mulberry32(3), wordSet, DEFAULT_STEER);
ok('steered refill keeps 5×5', ref.length === GRID_SIZE && ref.flat().every((c) => /^[A-Z]$/.test(c.letter)));
}
// ── static board quality ──────────────────────────────────────────────────────
console.log(`\nStatic boards (N=${N_BOARDS} each)`);
const staticStats = {};
for (const [name, v] of Object.entries(variants)) {
const wordCounts = [], shares = [];
for (let i = 0; i < N_BOARDS; i++) {
const g = v.make(mulberry32(1000 + i));
wordCounts.push(findWords(g, wordSet, prefixSet).size);
shares.push(vowelShare(g));
}
staticStats[name] = {
wordCounts,
dead: wordCounts.filter((n) => n === 0).length,
min: Math.min(...wordCounts),
p5: quantile(wordCounts, 0.05),
median: quantile(wordCounts, 0.5),
mean: mean(wordCounts),
max: Math.max(...wordCounts),
vowelMin: Math.min(...shares),
vowelMax: Math.max(...shares),
vowelMean: mean(shares),
};
}
const row = (label, f) =>
console.log(` ${label.padEnd(28)} ${String(f('baseline')).padStart(14)} ${String(f('steered')).padStart(14)}`);
console.log(' ' + 'metric'.padEnd(28) + 'baseline'.padStart(14) + 'steered'.padStart(14));
row('words/board (min)', (n) => staticStats[n].min);
row('words/board (p5)', (n) => staticStats[n].p5);
row('words/board (median)', (n) => staticStats[n].median);
row('words/board (mean)', (n) => staticStats[n].mean.toFixed(1));
row('words/board (max)', (n) => staticStats[n].max);
row('dead boards (0 words)', (n) => `${staticStats[n].dead} (${(100 * staticStats[n].dead / N_BOARDS).toFixed(1)}%)`);
row('vowel share (minmax)', (n) => `${(100 * staticStats[n].vowelMin).toFixed(0)}${(100 * staticStats[n].vowelMax).toFixed(0)}%`);
row('vowel share (mean)', (n) => `${(100 * staticStats[n].vowelMean).toFixed(1)}%`);
// ── simulated sessions ────────────────────────────────────────────────────────
console.log(`\nSimulated sessions (N=${N_SESSIONS}, max ${MAX_TURNS} turns, greedy longest-word play)`);
const sessionStats = {};
for (const [name, v] of Object.entries(variants)) {
const totals = [], wpt = [], dead = [], initial = [], longest = [];
for (let i = 0; i < N_SESSIONS; i++) {
const rng = mulberry32(90000 + i);
let grid = v.make(rng);
let deadTurn = false, total = 0;
for (let t = 0; t < MAX_TURNS; t++) {
const words = findWords(grid, wordSet, prefixSet);
if (t === 0) initial.push(words.size);
if (words.size === 0) { deadTurn = true; break; }
total += words.size;
wpt.push(words.size);
let best = null;
for (const [w, cells] of words) if (!best || w.length > best.length) best = { w, cells };
longest.push(best.w.length);
grid = v.refill(grid, best.cells, rng);
}
totals.push(total);
if (deadTurn) dead.push(1); else dead.push(0);
}
sessionStats[name] = {
deadRate: mean(dead),
totalMedian: quantile(totals, 0.5),
totalMean: mean(totals),
wptMin: Math.min(...wpt),
wptP5: quantile(wpt, 0.05),
wptMedian: quantile(wpt, 0.5),
initialMedian: quantile(initial, 0.5),
longestMax: Math.max(...longest),
};
}
row('dead sessions (stuck)', (n) => `${Math.round(sessionStats[n].deadRate * N_SESSIONS)}/${N_SESSIONS} (${(100 * sessionStats[n].deadRate).toFixed(0)}%)`);
row('initial words (median)', (n) => sessionStats[n].initialMedian);
row('words/turn (min)', (n) => sessionStats[n].wptMin);
row('words/turn (p5)', (n) => sessionStats[n].wptP5);
row('words/turn (median)', (n) => sessionStats[n].wptMedian);
row('total words (median)', (n) => sessionStats[n].totalMedian);
row('total words (mean)', (n) => sessionStats[n].totalMean.toFixed(0));
row('longest word seen', (n) => sessionStats[n].longestMax);
// ── comparisons ───────────────────────────────────────────────────────────────
console.log('\nComparison');
ok('steered median words/board ≥ baseline', staticStats.steered.median >= staticStats.baseline.median);
ok('steered dead boards ≤ baseline', staticStats.steered.dead <= staticStats.baseline.dead);
ok('steered p5 words/board ≥ baseline', staticStats.steered.p5 >= staticStats.baseline.p5);
ok('steered dead-session rate ≤ baseline', sessionStats.steered.deadRate <= sessionStats.baseline.deadRate);
ok('steered median words/turn ≥ baseline', sessionStats.steered.wptMedian >= sessionStats.baseline.wptMedian);
ok('steered median total words ≥ baseline', sessionStats.steered.totalMedian >= sessionStats.baseline.totalMedian);
console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed\n`);
process.exit(fail > 0 ? 1 : 0);

354
tools/verifyJigsaw.js Normal file
View File

@ -0,0 +1,354 @@
// Headless verification for Jigsaw.
// node tools/verifyJigsaw.js
// Exits non-zero on any failure.
//
// 1. Grid model: seeded determinism, neighbour bounds/symmetry, tab/blank
// complementarity on every internal edge.
// 2. FIT guarantee: for every internal edge of every difficulty, the two
// adjacent pieces trace the *same* shared curve — adjacent pieces physically
// mesh when placed in their correct slots. This is the property the
// table-join rule ("pieces may only join if they fit on the board") relies
// on, so it is checked for ALL grids.
// 3. Table assembly (the production resolveDrop): only grid-adjacent pieces
// join, whole groups are absorbed, fixpoint chains, the rigid-drag
// invariant, board lock (and its precedence over join), and win counting.
import {
DIFFICULTIES, DIFFICULTY_ORDER,
makeJigsaw, cellEdgeSpec, cellNeighbours, edgeFragment, resolveDrop,
} from '../src/games/jigsaw/JigsawLogic.js';
let failures = 0;
function check(ok, msg) {
if (!ok) { failures++; console.error(`${msg}`); }
return ok;
}
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
// ── 1. Grid model ────────────────────────────────────────────────────────────
console.log('Grid model:');
{
const a = makeJigsaw(6, 5, 1234), b = makeJigsaw(6, 5, 1234), c = makeJigsaw(6, 5, 4321);
check(JSON.stringify(a.H) === JSON.stringify(b.H) && JSON.stringify(a.V) === JSON.stringify(b.V),
'same seed must give the same knob layout');
check(JSON.stringify(a.H) !== JSON.stringify(c.H) || JSON.stringify(a.V) !== JSON.stringify(c.V),
'different seeds should give different layouts');
const jig = makeJigsaw(5, 5, 7);
const inB = (r, cc) => r >= 0 && r < 5 && cc >= 0 && cc < 5;
let allInRange = true, symmetric = true;
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 5; cc++) {
for (const [nr, nc] of cellNeighbours(jig, r, cc)) {
if (!inB(nr, nc)) allInRange = false;
if (!cellNeighbours(jig, nr, nc).some(([pr, pc]) => pr === r && pc === cc)) symmetric = false;
}
}
check(allInRange, 'neighbours never leave the grid');
check(symmetric, 'neighbourhood is symmetric');
check(cellNeighbours(jig, 0, 0).length === 2, 'corner piece has 2 neighbours');
check(cellNeighbours(jig, 0, 2).length === 3, 'edge piece has 3 neighbours');
check(cellNeighbours(jig, 2, 2).length === 4, 'interior piece has 4 neighbours');
// Every internal edge is a tab on exactly one side, blank on the other, and
// both sides agree on which way the shared curve bulges.
let complement = true;
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 4; cc++) {
const aL = cellEdgeSpec(jig, r, cc).right, bL = cellEdgeSpec(jig, r, cc + 1).left;
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
}
for (let r = 0; r < 4; r++) for (let cc = 0; cc < 5; cc++) {
const aL = cellEdgeSpec(jig, r, cc).bottom, bL = cellEdgeSpec(jig, r + 1, cc).top;
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
}
check(complement, 'every internal edge: tab/blank pair with a common curve side');
console.log(' ok');
}
// ── 2. Fit guarantee: adjacent pieces trace the identical shared curve ───────
console.log('Fit guarantee (adjacent pieces mesh on the board):');
{
const sample = (p0, p1, edge, n = 128) => {
const pts = [];
let cur = { x: p0.x, y: p0.y };
for (const c of edgeFragment(p0, p1, edge)) {
for (let i = 1; i <= n; i++) {
const t = i / n;
let x, y;
if (c.t === 'line') { x = cur.x + (c.x - cur.x) * t; y = cur.y + (c.y - cur.y) * t; }
else {
const m = 1 - t;
x = m * m * m * cur.x + 3 * m * m * t * c.c1.x + 3 * m * t * t * c.c2.x + t * t * t * c.x;
y = m * m * m * cur.y + 3 * m * m * t * c.c1.y + 3 * m * t * t * c.c2.y + t * t * t * c.y;
}
pts.push({ x, y });
}
cur = { x: c.x, y: c.y };
}
return pts;
};
// Max distance from every sample of curve A to the closest sample of B (both
// ways). Identical curves → near zero (sampling gap only).
const maxGap = (A, B) => Math.max(
...A.map((p) => Math.min(...B.map((q) => Math.hypot(p.x - q.x, p.y - q.y)))),
...B.map((p) => Math.min(...A.map((q) => Math.hypot(p.x - q.x, p.y - q.y))))
);
let edgesChecked = 0, ok = true, worst = 0;
const W = 100, H = 100;
for (const key of DIFFICULTY_ORDER) {
const { cols, rows } = DIFFICULTIES[key];
const jig = makeJigsaw(cols, rows, 42);
for (let r = 0; r < rows; r++) for (let c = 0; c < cols - 1; c++) {
// Vertical internal edge between (r,c) [left] and (r,c+1) [right].
const xA = c * W, yA = r * H;
const p0A = { x: xA + W, y: yA }, p1A = { x: xA + W, y: yA + H };
const p0B = { x: xA + W, y: yA + H }, p1B = { x: xA + W, y: yA };
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).right);
const B = sample(p0B, p1B, cellEdgeSpec(jig, r, c + 1).left);
const gap = maxGap(A, B);
edgesChecked++; worst = Math.max(worst, gap);
if (gap > 2.5) ok = false;
}
for (let r = 0; r < rows - 1; r++) for (let c = 0; c < cols; c++) {
// Horizontal internal edge between (r,c) [top] and (r+1,c) [bottom].
const xA = c * W, yA = r * H;
const p0A = { x: xA + W, y: yA + H }, p1A = { x: xA, y: yA + H };
const p0B = { x: xA, y: yA + H }, p1B = { x: xA + W, y: yA + H };
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).bottom);
const B = sample(p0B, p1B, cellEdgeSpec(jig, r + 1, c).top);
const gap = maxGap(A, B);
edgesChecked++; worst = Math.max(worst, gap);
if (gap > 2.5) ok = false;
}
}
check(ok, `all ${edgesChecked} internal edges on all difficulties mesh exactly (worst gap ${worst.toFixed(3)}px)`);
console.log(` ok — ${edgesChecked} internal edges checked, worst deviation ${worst.toFixed(4)}px`);
}
// ── 3. Table assembly (production resolveDrop) ───────────────────────────────
console.log('Table assembly (piece joining / group lock):');
{
const SNAP = 0.42; // same SNAP_FRAC as the scene
const boardOrigin = { x: 1000, y: 1000 };
const cell = 100;
function makeBoard(cols = 5, rows = 5, seed = 7) {
const jig = makeJigsaw(cols, rows, seed);
const pieces = [];
const grid = [];
for (let r = 0; r < rows; r++) grid.push(new Array(cols));
let i = 0;
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++, i++) {
const p = {
r, c,
home: { x: boardOrigin.x + (c + 0.5) * cell, y: boardOrigin.y + (r + 0.5) * cell },
// Scattered on the table, well clear of the board and of each other.
pos: { x: 50 + i * 140, y: 5000 + (i % 6) * 120 },
placed: false, group: null,
};
const g = { pieces: [p] };
p.group = g;
pieces.push(p);
grid[r][c] = p;
}
return {
jig, pieces, grid,
groups: new Set(pieces.map((p) => p.group)),
placed: 0, total: pieces.length,
cellAt: (r, c) => (r >= 0 && r < rows && c >= 0 && c < cols) ? grid[r][c] : null,
};
}
const at = (bd, r, c) => bd.grid[r][c];
// Mirror of JigsawGame.handleDrop: apply the decided positions, then do the
// (sound/nudge-free) scene bookkeeping.
function handleDrop(bd, group, anchor) {
const res = resolveDrop(bd.jig, group, bd.cellAt, cell * SNAP);
for (const pl of res.placements) pl.piece.pos = { x: pl.x, y: pl.y };
if (res.outcome === 'locked') {
const locked = group.pieces.filter((m) => !m.placed);
locked.forEach((m) => { m.placed = true; });
bd.groups.delete(group);
bd.placed += locked.length;
} else if (res.outcome === 'joined') {
for (const g of res.absorbedGroups) {
bd.groups.delete(g);
for (const x of g.pieces) { if (x.group === group) continue; x.group = group; group.pieces.push(x); }
}
}
return res;
}
// Mirror of JigsawGame.onPointerMove: rigid group drag around the anchor.
function dragGroup(bd, group, anchor, to) {
for (const m of group.pieces) m.pos = { x: to.x + (m.home.x - anchor.home.x), y: to.y + (m.home.y - anchor.home.y) };
}
const invariantHolds = (group) => group.pieces.every((m) => group.pieces.every((a) =>
approx(m.pos.x, a.pos.x + m.home.x - a.home.x) && approx(m.pos.y, a.pos.y + m.home.y - a.home.y)));
// 3a. resolveDrop is pure: no mutation before the scene applies the result.
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1);
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 20, y: A.pos.y + (B.home.y - A.home.y) - 15 };
const g0 = [...A.group.pieces];
const res = resolveDrop(bd.jig, A.group, bd.cellAt, cell * SNAP);
check(res.outcome === 'joined', '3a: adjacent pair within snap radius joins');
check(res.placements.length === 1 && res.placements[0].piece === B, '3a: only the absorbed piece is placed');
check(A.group.pieces.length === 1 && A.group.pieces[0] === A, '3a: group not mutated by resolveDrop');
check(B.pos.x === 200 + (B.home.x - A.home.x) + 20, '3a: piece positions not mutated by resolveDrop');
const m = res.placements[0];
B.pos = { x: m.x, y: m.y }; // scene-side application
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(B.pos.y, A.pos.y + (B.home.y - A.home.y)),
'3a: absorbed piece snaps to the exact mesh offset');
}
// 3b. Pieces that cannot both sit on the board never join — even when
// dropped dead-on their (hypothetical) meshing offset.
{
const bd = makeBoard();
const A = at(bd, 0, 0), C = at(bd, 0, 2);
A.pos = { x: 300, y: 500 };
C.pos = { x: A.pos.x + 2 * cell, y: A.pos.y }; // exact 2-cell offset, zero error
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3b: non-adjacent pieces never join (rests instead)');
check(A.group.pieces.length === 1, '3b: group stays a singleton');
}
{
const bd = makeBoard();
const A = at(bd, 0, 0), D = at(bd, 1, 1);
A.pos = { x: 300, y: 500 };
D.pos = { x: A.pos.x + cell * 0.7, y: A.pos.y + cell * 0.7 }; // sitting on top, diagonally
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3b: diagonal pieces never join even when overlapping');
}
// 3c. A chain of correctly placed pieces latches on in ONE drop (fixpoint).
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1); // C neighbour of B only
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 10, y: A.pos.y + (B.home.y - A.home.y) };
C.pos = { x: B.pos.x + (C.home.x - B.home.x) - 8, y: B.pos.y + (C.home.y - B.home.y) };
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'joined', '3c: chain joins in one drop');
check(B.group === A.group && C.group === A.group && A.group.pieces.length === 3, '3c: all three in one group');
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(C.pos.x, A.pos.x + (C.home.x - A.home.x)),
'3c: every member snaps to the exact mesh offset');
check(invariantHolds(A.group), '3c: group invariant holds after join');
}
// 3d. A pre-joined group is absorbed WHOLE when a neighbour drops beside it.
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1);
// First: join B+C on the table.
B.pos = { x: 400, y: 600 };
C.pos = { x: B.pos.x + (C.home.x - B.home.x) + 5, y: B.pos.y + (C.home.y - B.home.y) };
const r1 = handleDrop(bd, B.group, B);
check(r1.outcome === 'joined' && B.group.pieces.length === 2, '3d: B+C joined first');
// Then: drop A next to B → the whole B+C group comes along.
const Bg = B.group;
A.pos = { x: B.pos.x - (B.home.x - A.home.x) - 12, y: B.pos.y + 9 };
const r2 = handleDrop(bd, A.group, A);
check(r2.outcome === 'joined', '3d: A dropped beside the pair joins it');
check(B.group === A.group && A.group.pieces.length === 3, '3d: the whole pre-joined group was absorbed');
check(invariantHolds(A.group), '3d: group invariant holds after whole-group absorption');
check(bd.groups.has(A.group) && !bd.groups.has(Bg), '3d: group registry stays consistent');
}
// 3e. Groups drag rigidly: every member keeps its exact home offset.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3), C = at(bd, 1, 2);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
C.pos = { x: A.pos.x + (C.home.x - A.home.x), y: A.pos.y + (C.home.y - A.home.y) };
handleDrop(bd, A.group, A); // B and C both within snap → 3-piece group
check(A.group.pieces.length === 3, '3e: three-piece group formed');
dragGroup(bd, A.group, B, { x: 700, y: 900 }); // grab a non-first member
check(approx(A.pos.x, 700 + (A.home.x - B.home.x)) && approx(C.pos.y, 900 + (C.home.y - B.home.y)),
'3e: dragging any member moves the whole group rigidly');
check(invariantHolds(A.group), '3e: invariant preserved by drag');
}
// 3f. Board lock: any member aligned ⇒ the whole group lands on the board.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
check(A.group.pieces.length === 2, '3f: pair formed');
// Drag the pair (grabbing B, the "far" member) so B lands within snap of home.
dragGroup(bd, A.group, B, { x: B.home.x - 14, y: B.home.y + 10 });
const res = handleDrop(bd, A.group, B);
check(res.outcome === 'locked', '3f: group locks when aligned with the board');
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x) && approx(B.pos.y, B.home.y),
'3f: every member lands exactly on its home slot');
check(A.placed && B.placed && bd.placed === 2, '3f: both members count as placed');
check(!bd.groups.has(A.group), '3f: locked group retired from the table');
}
// 3g. Lock takes precedence over join.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3), D = at(bd, 3, 2);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
// D sits exactly where it would mesh under A (joinable)…
D.pos = { x: A.pos.x + (D.home.x - A.home.x), y: A.pos.y + (D.home.y - A.home.y) };
// …but the A+B pair is also aligned with the board.
dragGroup(bd, A.group, A, { x: A.home.x + 8, y: A.home.y - 6 });
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'locked', '3g: board lock wins over a possible join');
check(D.group.pieces.length === 1 && !D.placed, '3g: the joinable piece was NOT absorbed');
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x), '3g: group landed on the board');
}
// 3h. Outside the snap radius: nothing joins, positions untouched.
{
const bd = makeBoard();
const A = at(bd, 1, 1), B = at(bd, 1, 2);
A.pos = { x: 300, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 60, y: A.pos.y }; // 60 > 42 snap
const Bdrop = { ...B.pos }; // where the drop LEFT it
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3h: beyond snap radius the drop rests');
check(approx(B.pos.x, Bdrop.x) && approx(B.pos.y, Bdrop.y), '3h: unjoined piece keeps its dropped position');
}
// 3i. Placed pieces are ignored by joining.
{
const bd = makeBoard();
const A = at(bd, 1, 1), B = at(bd, 1, 2), D = at(bd, 2, 1);
D.pos = { x: D.home.x, y: D.home.y }; D.placed = true; // already on the board
A.pos = { x: 300, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 4, y: A.pos.y + (B.home.y - A.home.y) };
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'joined' && A.group.pieces.length === 2, '3i: unplaced neighbour still joins');
check(!A.group.pieces.includes(D) && D.placed, '3i: placed piece is never absorbed');
}
// 3j. Win bookkeeping: locking the final pieces reaches the total.
{
const bd = makeBoard(2, 1, 11); // 2 pieces, one internal edge
const A = at(bd, 0, 0), B = at(bd, 0, 1);
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 6, y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
check(A.group.pieces.length === 2, '3j: pair formed');
dragGroup(bd, A.group, A, { x: A.home.x, y: A.home.y });
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'locked' && bd.placed === bd.total, '3j: locking the pair completes the board');
}
console.log(' ok');
}
if (failures) {
console.error(`\nFAILED: ${failures} check(s).`);
process.exit(1);
}
console.log('\nAll Jigsaw checks passed.');