collin/mahjong · e2f4495f
Break the wall where the dice say
Collin Richards · 2026-08-25 07:30 UTC · e2f4495f95ecf4b029ca8e050ed565b9e7453f33 · parent 9737a8f7 · browse files
modifiedsrc/App.tsx+2 −1
| ⋯ 10 unchanged lines | |||
| 11 | 11 | import { NetSession } from './net/session'; | |
| 12 | 12 | import { Bi, BiText } from './ui/Bi'; | |
| 13 | 13 | import { Center } from './ui/Center'; | |
| 14 | - | import { showingColliders } from './ui/colliders'; | |
| 14 | + | import { exposeGame, showingColliders } from './ui/colliders'; | |
| 15 | 15 | import { useCompact } from './ui/compact'; | |
| 16 | 16 | import { Controller } from './ui/Controller'; | |
| 17 | 17 | import { FullscreenToggle } from './ui/FullscreenToggle'; | |
| ⋯ 34 unchanged lines | |||
| 52 | 52 | // settings screen edits the live game rather than replacing it. | |
| 53 | 53 | const [game] = useState(() => new Game(prefs.names, prefs.rules)); | |
| 54 | 54 | useSyncExternalStore(game.subscribe, game.getSnapshot); | |
| 55 | + | exposeGame(game); | |
| 55 | 56 | // The tiles in the middle. Owned here rather than inside <Pool> because the | |
| 56 | 57 | // seats need it too: a hand has to know whether it can be thrown from, and | |
| 57 | 58 | // which seats those are changes as the wall is eaten away. | |
| ⋯ 419 unchanged lines | |||
modifiedsrc/game/ctl.ts+7 −0
| ⋯ 44 unchanged lines | |||
| 45 | 45 | undoable(): { zh: string; en: string } | null; | |
| 46 | 46 | undo(): void; | |
| 47 | 47 | ||
| 48 | + | /** | |
| 49 | + | * Throw the dice again, moving where the wall is broken and nothing else. | |
| 50 | + | * Only the engine has it — an online table's wall is the host's business — | |
| 51 | + | * so it is what the dice tool checks for before it draws itself. | |
| 52 | + | */ | |
| 53 | + | reroll?(...dice: number[]): number[]; | |
| 54 | + | ||
| 48 | 55 | canSelfDrawWin(seat: SeatId): boolean; | |
| 49 | 56 | concealedKongs(seat: SeatId): Tile[]; | |
| 50 | 57 | addedKongs(seat: SeatId): Tile[]; | |
| ⋯ 21 unchanged lines | |||
modifiedsrc/game/engine.ts+31 −1
| ⋯ 23 unchanged lines | |||
| 24 | 24 | type Rules, | |
| 25 | 25 | type SeatId, | |
| 26 | 26 | } from './types'; | |
| 27 | + | import { rollDice, type Dice } from './wall'; | |
| 27 | 28 | ||
| 28 | 29 | export type Phase = 'lobby' | 'turn' | 'claims' | 'robkong' | 'handEnd' | 'gameEnd'; | |
| 29 | 30 | ||
| ⋯ 85 unchanged lines | |||
| 115 | 116 | sacred: Tile[][]; | |
| 116 | 117 | afterKong: boolean; | |
| 117 | 118 | robKongTile: Tile | null; | |
| 119 | + | /** | |
| 120 | + | * 擲骰 — what the dealer threw to open this hand. Where the wall is broken | |
| 121 | + | * and nothing else: the tiles were shuffled before the square was built, so | |
| 122 | + | * the dice move the gap round the table rather than change anybody's hand. | |
| 123 | + | * `breakAt` in game/wall.ts reads them. | |
| 124 | + | */ | |
| 125 | + | dice: Dice; | |
| 118 | 126 | /** How many of the original 144 wall positions have been taken off the front. */ | |
| 119 | 127 | drawnFront: number; | |
| 120 | 128 | /** ...and off the back (kong / flower replacements). */ | |
| ⋯ 54 unchanged lines | |||
| 175 | 183 | sacred: [[], [], [], []], | |
| 176 | 184 | afterKong: false, | |
| 177 | 185 | robKongTile: null, | |
| 186 | + | dice: [1, 1, 1], | |
| 178 | 187 | drawnFront: 0, | |
| 179 | 188 | drawnBack: 0, | |
| 180 | 189 | result: null, | |
| ⋯ 68 unchanged lines | |||
| 249 | 258 | this.emit(); | |
| 250 | 259 | } | |
| 251 | 260 | ||
| 261 | + | /** | |
| 262 | + | * Throw the dice again without touching the tiles — `game.reroll(6, 6, 6)` | |
| 263 | + | * from the console, or `game.reroll()` for a fresh throw. | |
| 264 | + | * | |
| 265 | + | * Only the break moves, which is the only thing the dice decide here: the | |
| 266 | + | * wall was shuffled before it was built, so nobody's hand changes and the | |
| 267 | + | * draw order is the draw order. It is how the square gets looked at broken | |
| 268 | + | * somewhere other than wherever this hand happened to open. | |
| 269 | + | */ | |
| 270 | + | reroll(...dice: number[]) { | |
| 271 | + | this.state.dice = | |
| 272 | + | dice.length === 3 ? (dice as Dice) : rollDice(makeRng(Math.floor(Math.random() * 2 ** 31))); | |
| 273 | + | this.emit(); | |
| 274 | + | return this.state.dice; | |
| 275 | + | } | |
| 276 | + | ||
| 252 | 277 | get hasBots() { | |
| 253 | 278 | return this.state.bots.some(Boolean); | |
| 254 | 279 | } | |
| ⋯ 71 unchanged lines | |||
| 326 | 351 | // Once the next hand is dealt the last one is settled and paid; there is | |
| 327 | 352 | // nothing left to take back, and the tiles are already shuffled away. | |
| 328 | 353 | this.undoStack = []; | |
| 329 | - | s.wall = shuffle(buildWall(), makeRng(seed)); | |
| 354 | + | // One rng for the whole hand's setup, so the same seed rebuilds the same | |
| 355 | + | // wall *and* throws the same dice — a hand replayed is the hand that was | |
| 356 | + | // played, gap and all. | |
| 357 | + | const rng = makeRng(seed); | |
| 358 | + | s.wall = shuffle(buildWall(), rng); | |
| 359 | + | s.dice = rollDice(rng); | |
| 330 | 360 | s.handNumber++; | |
| 331 | 361 | for (const p of s.players) { | |
| 332 | 362 | p.hand = []; | |
| ⋯ 698 unchanged lines | |||
modifiedsrc/game/save.ts+1 −1
| ⋯ 2 unchanged lines | |||
| 3 | 3 | ||
| 4 | 4 | const KEY = 'taiwanese-mahjong/save'; | |
| 5 | 5 | /** Bump when GameState changes shape; older saves are then ignored rather than crashing. */ | |
| 6 | - | const VERSION = 3; | |
| 6 | + | const VERSION = 4; | |
| 7 | 7 | ||
| 8 | 8 | export interface SavedGame { | |
| 9 | 9 | version: number; | |
| ⋯ 57 unchanged lines | |||
modifiedsrc/game/wall.ts+79 −7
| ⋯ 16 unchanged lines | |||
| 17 | 17 | drawnFront: number; | |
| 18 | 18 | drawnBack: number; | |
| 19 | 19 | rules: { wallReserve: number }; | |
| 20 | + | /** Who threw the dice, and what they came up. See `breakAt`. */ | |
| 21 | + | dealer: SeatId; | |
| 22 | + | dice: number[]; | |
| 20 | 23 | } | |
| 21 | 24 | ||
| 22 | 25 | export interface Stack { | |
| ⋯ 117 unchanged lines | |||
| 140 | 143 | return { cell, len, thick, open: len - thick, outer: len + thick }; | |
| 141 | 144 | } | |
| 142 | 145 | ||
| 146 | + | /** | |
| 147 | + | * Where the four walls actually stand, in tiles from where the square built | |
| 148 | + | * them: found by pushing them about on a real table, which is what the drag in | |
| 149 | + | * `ui/WallRing` is for. `x` is across the table and `y` down it, both in tiles | |
| 150 | + | * so the placement carries to any window. Nought is the wall left where it was | |
| 151 | + | * built. In `WALL_SIDES` order. | |
| 152 | + | */ | |
| 153 | + | export const WALL_PLACED: WallNudge[] = [ | |
| 154 | + | { x: 0, y: 0 }, | |
| 155 | + | { x: -1.98, y: -6.7 }, | |
| 156 | + | { x: 0.52, y: -4.91 }, | |
| 157 | + | { x: -1.11, y: 0.46 }, | |
| 158 | + | ]; | |
| 159 | + | ||
| 160 | + | /** How far a wall has been pushed from where it was built, in tiles. */ | |
| 161 | + | export interface WallNudge { | |
| 162 | + | x: number; | |
| 163 | + | y: number; | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /** 擲骰 — the three dice a Taiwanese dealer throws to open a hand. */ | |
| 167 | + | export type Dice = [number, number, number]; | |
| 168 | + | ||
| 169 | + | /** Three of them, from the same seeded rng that shuffled the wall. */ | |
| 170 | + | export function rollDice(rng: () => number): Dice { | |
| 171 | + | return [0, 0, 0].map(() => 1 + Math.floor(rng() * 6)) as Dice; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /** | |
| 175 | + | * Where the wall is broken, as a place in the square. | |
| 176 | + | * | |
| 177 | + | * The dealer throws, and the total does two jobs at once. It counts round the | |
| 178 | + | * table — the dealer themself is one, then on round to their right, which is | |
| 179 | + | * the way play goes — to pick *whose* wall is opened. Then the same number | |
| 180 | + | * counts stacks in from the right-hand end of that wall, and the break is | |
| 181 | + | * behind them: the tile drawn first is the next one along, and the drawing runs | |
| 182 | + | * away to the left from there, on round the square. | |
| 183 | + | * | |
| 184 | + | * The square makes that easy to say. Every wall is laid down from the corner | |
| 185 | + | * its own player's right hand falls on — that is what the pinwheel is — so the | |
| 186 | + | * run of 72 places already starts at the right end of each side, and the break | |
| 187 | + | * is simply so many places into the side the dice picked. | |
| 188 | + | * | |
| 189 | + | * Nothing about the *tiles* turns on it: the wall was shuffled before it was | |
| 190 | + | * built, so which stack is drawn first is decided either way. What it moves is | |
| 191 | + | * where in the middle of the table the gap opens, and which player has to reach | |
| 192 | + | * furthest for the next draw — which is the whole of what it does at a table | |
| 193 | + | * too, and is worth having because it is what a hand looks like. | |
| 194 | + | */ | |
| 195 | + | export function breakAt(dealer: SeatId, dice: number[]): number { | |
| 196 | + | const total = dice.reduce((n, d) => n + d, 0); | |
| 197 | + | if (total <= 0) return 0; | |
| 198 | + | // One is the dealer, and the count goes the way the turn does. | |
| 199 | + | const whose = ((dealer + total - 1) % 4) as SeatId; | |
| 200 | + | return (SEAT_WALL_SIDE[whose] * STACKS_PER_SIDE + total) % WALL_STACKS; | |
| 201 | + | } | |
| 202 | + | ||
| 143 | 203 | /** A stack and which of the 72 it is — the index is what the physics reads. */ | |
| 144 | 204 | export interface Placed { | |
| 145 | 205 | index: number; | |
| ⋯ 20 unchanged lines | |||
| 166 | 226 | * ends are where they would be on a table, and the square keeps the size it was | |
| 167 | 227 | * built at — a square that shrank every draw would close in on the discards | |
| 168 | 228 | * lying inside it. | |
| 229 | + | * | |
| 230 | + | * `from` is the break: which place in the square the first tile drawn comes | |
| 231 | + | * off, from `breakAt`. Every place is returned whether or not there is still a | |
| 232 | + | * stack standing on it, because with the break anywhere but the corner a wall | |
| 233 | + | * is eaten from its *middle* — a row that closed the gap up would drag the | |
| 234 | + | * whole tail of the wall along the table behind it. | |
| 169 | 235 | */ | |
| 170 | - | export function ringLayout(stacks: Stack[], cap: RingCapacity): Placed[][] { | |
| 236 | + | export function ringLayout(stacks: Stack[], cap: RingCapacity, from = 0): Placed[][] { | |
| 171 | 237 | const sides: Placed[][] = [[], [], [], []]; | |
| 172 | 238 | const lengths = [cap.h, cap.v, cap.h, cap.v]; | |
| 173 | 239 | const slots = 2 * (cap.h + cap.v); | |
| ⋯ 5 unchanged lines | |||
| 179 | 245 | // end, and no tile that is still standing ever has to move. | |
| 180 | 246 | const offset = WALL_STACKS - slots; | |
| 181 | 247 | ||
| 248 | + | const run: (Placed | null)[] = Array.from({ length: slots }, () => null); | |
| 182 | 249 | for (let index = 0; index < stacks.length; index++) { | |
| 183 | - | const stack = stacks[index]; | |
| 184 | 250 | // A wall too long for its square only happens before a hand is dealt, and | |
| 185 | 251 | // then only for the frame it takes to deal it. What falls off the start is | |
| 186 | 252 | // the part about to be drawn anyway. | |
| 187 | - | let at = index - offset; | |
| 188 | - | if (stack.count === 0 || at < 0 || at >= slots) continue; | |
| 189 | - | let side = 0; | |
| 190 | - | while (at >= lengths[side]) at -= lengths[side++]; | |
| 191 | - | sides[side].push({ index, stack }); | |
| 253 | + | const at = index - offset; | |
| 254 | + | if (at < 0 || at >= slots) continue; | |
| 255 | + | run[(at + from) % slots] = { index, stack: stacks[index] }; | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | let p = 0; | |
| 259 | + | for (let side = 0; side < 4; side++) { | |
| 260 | + | for (let i = 0; i < lengths[side]; i++, p++) { | |
| 261 | + | const place = run[p]; | |
| 262 | + | if (place) sides[side].push(place); | |
| 263 | + | } | |
| 192 | 264 | } | |
| 193 | 265 | return sides; | |
| 194 | 266 | } | |
modifiedsrc/styles.css+70 −3
| ⋯ 448 unchanged lines | |||
| 449 | 449 | .actionbar:empty { | |
| 450 | 450 | min-height: 0; | |
| 451 | 451 | } | |
| 452 | + | /* Except on the seat whose turn it is. There a button is one tap away — lift a | |
| 453 | + | tile and 打出 arrives — and a bar that springs into being under your hand | |
| 454 | + | shoves the sets and flowers above it up the strip while you are still | |
| 455 | + | deciding. The seat that is about to need the room keeps it. */ | |
| 456 | + | .seat.active .actionbar:empty { | |
| 457 | + | min-height: var(--bar-h); | |
| 458 | + | } | |
| 452 | 459 | /* Every button on the bar is the same height, so the row is the same height | |
| 453 | 460 | whichever ones are up: without this a 打出 五萬 stands taller than a bare 過 | |
| 454 | 461 | and the strip shuffles itself every time you lift a tile. */ | |
| ⋯ 247 unchanged lines | |||
| 702 | 709 | round each time, which is what makes four straight walls a pinwheel. Every | |
| 703 | 710 | corner of the square is covered by exactly one of them, and no two overlap. | |
| 704 | 711 | ||
| 705 | - | A wall that has been drawn from packs what is left towards its far end, so | |
| 706 | - | the gap opens at the break rather than at the corner. */ | |
| 712 | + | Every place in the wall is here whether or not there is still a stack on it — | |
| 713 | + | see `ringLayout` — so the row never closes up and the gap stays where the | |
| 714 | + | drawing actually got to. */ | |
| 707 | 715 | .wall-side { | |
| 708 | 716 | position: absolute; | |
| 709 | 717 | display: flex; | |
| 710 | - | justify-content: flex-end; | |
| 711 | 718 | /* Where it has been pushed to, in the table's own axes — a square wall goes | |
| 712 | 719 | where the finger goes. */ | |
| 713 | 720 | transform: translate(var(--dx, 0px), var(--dy, 0px)); | |
| ⋯ 1604 unchanged lines | |||
| 2318 | 2325 | opacity: 0.65; | |
| 2319 | 2326 | margin-top: 2px; | |
| 2320 | 2327 | } | |
| 2328 | + | ||
| 2329 | + | /* The dice tool. Dev builds only — `import.meta.env.DEV` in ui/Center — so it | |
| 2330 | + | is styled for being useful rather than for being lived with: bottom left, out | |
| 2331 | + | of the way of the undo button and of every seat's own strip. */ | |
| 2332 | + | .dice-debug { | |
| 2333 | + | position: absolute; | |
| 2334 | + | z-index: 2; | |
| 2335 | + | left: 10px; | |
| 2336 | + | bottom: 8px; | |
| 2337 | + | display: flex; | |
| 2338 | + | flex-direction: column; | |
| 2339 | + | align-items: center; | |
| 2340 | + | gap: 4px; | |
| 2341 | + | padding: 6px 10px 5px; | |
| 2342 | + | border-radius: 10px; | |
| 2343 | + | background: rgba(0, 0, 0, 0.32); | |
| 2344 | + | border: 1px solid rgba(217, 180, 92, 0.25); | |
| 2345 | + | font-size: 11px; | |
| 2346 | + | /* A wall gets pushed about through this corner, and the panel is only here to | |
| 2347 | + | be read — so the finger goes through it, and only the buttons take it. */ | |
| 2348 | + | pointer-events: none; | |
| 2349 | + | } | |
| 2350 | + | .dice-debug .btn { | |
| 2351 | + | pointer-events: auto; | |
| 2352 | + | } | |
| 2353 | + | .table.carrying .dice-debug { | |
| 2354 | + | pointer-events: none; | |
| 2355 | + | } | |
| 2356 | + | .dice-faces { | |
| 2357 | + | display: flex; | |
| 2358 | + | align-items: baseline; | |
| 2359 | + | gap: 4px; | |
| 2360 | + | line-height: 1; | |
| 2361 | + | } | |
| 2362 | + | .dice-faces b { | |
| 2363 | + | font-size: 26px; | |
| 2364 | + | font-weight: 400; | |
| 2365 | + | color: var(--gold); | |
| 2366 | + | } | |
| 2367 | + | .dice-faces em { | |
| 2368 | + | font-style: normal; | |
| 2369 | + | font-size: 13px; | |
| 2370 | + | font-weight: 700; | |
| 2371 | + | opacity: 0.75; | |
| 2372 | + | margin-left: 4px; | |
| 2373 | + | } | |
| 2374 | + | .dice-row { | |
| 2375 | + | display: flex; | |
| 2376 | + | gap: 4px; | |
| 2377 | + | } | |
| 2378 | + | .dice-row .btn { | |
| 2379 | + | font-size: 12px; | |
| 2380 | + | min-height: 0; | |
| 2381 | + | padding: 3px 9px; | |
| 2382 | + | } | |
| 2383 | + | .dice-says { | |
| 2384 | + | opacity: 0.6; | |
| 2385 | + | letter-spacing: 0.02em; | |
| 2386 | + | white-space: nowrap; | |
| 2387 | + | } | |
modifiedsrc/table/physics.test.ts+92 −19
| 1 | 1 | import { describe, expect, it } from 'vitest'; | |
| 2 | 2 | import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles'; | |
| 3 | 3 | import { | |
| 4 | + | breakAt, | |
| 4 | 5 | isBarrier, | |
| 5 | 6 | wallSquare, | |
| 6 | 7 | RING, | |
| 7 | 8 | ringLayout, | |
| 9 | + | rollDice, | |
| 8 | 10 | SEAT_WALL_SIDE, | |
| 9 | 11 | sideStacks, | |
| 10 | 12 | wallStacks, | |
| ⋯ 539 unchanged lines | |||
| 550 | 552 | ||
| 551 | 553 | describe('wallStacks', () => { | |
| 552 | 554 | const at = (drawnFront: number, drawnBack = 0) => | |
| 553 | - | wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } }); | |
| 555 | + | wallStacks({ drawnFront, drawnBack, dealer: 0, dice: [1, 1, 1], rules: { wallReserve: 16 } }); | |
| 554 | 556 | ||
| 555 | 557 | it('starts as 72 full stacks', () => { | |
| 556 | 558 | const s = at(0); | |
| ⋯ 86 unchanged lines | |||
| 643 | 645 | ||
| 644 | 646 | describe('ringLayout', () => { | |
| 645 | 647 | const at = (drawnFront: number, drawnBack = 0) => | |
| 646 | - | wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } }); | |
| 648 | + | wallStacks({ drawnFront, drawnBack, dealer: 0, dice: [1, 1, 1], rules: { wallReserve: 16 } }); | |
| 647 | 649 | const flat = (sides: Placed[][]) => sides.flat(); | |
| 650 | + | const standing = (sides: Placed[][]) => flat(sides).filter((p) => p.stack.count > 0); | |
| 648 | 651 | // A middle much wider than it is deep, which is what an ordinary window has. | |
| 649 | 652 | const wide = { h: 30, v: 10 }; | |
| 650 | 653 | ||
| 651 | - | it('draws what is still standing and nothing else', () => { | |
| 652 | - | const sides = ringLayout(at(65), wide); | |
| 654 | + | it('keeps every place in the square, standing or spent', () => { | |
| 655 | + | const sides = ringLayout(at(65), RING); | |
| 653 | 656 | const placed = flat(sides); | |
| 654 | - | expect(placed).toHaveLength(40); | |
| 655 | - | expect(placed.every((p) => p.stack.count > 0)).toBe(true); | |
| 656 | - | // In order all the way round: the break point first, the 底牌 tail last. | |
| 657 | - | expect(placed[0].index).toBe(32); | |
| 658 | - | expect(placed[0].stack.next).toBe(true); | |
| 659 | - | expect(placed[placed.length - 1].index).toBe(71); | |
| 660 | - | expect(placed.every((p, i) => i === 0 || p.index > placed[i - 1].index)).toBe(true); | |
| 657 | + | expect(placed).toHaveLength(WALL_STACKS); | |
| 658 | + | // In order all the way round from the break: the first drawn first, the | |
| 659 | + | // 底牌 tail last. | |
| 660 | + | expect(placed.map((p) => p.index)).toEqual(placed.map((_, i) => i)); | |
| 661 | + | expect(standing(sides)).toHaveLength(40); | |
| 662 | + | expect(standing(sides)[0].stack.next).toBe(true); | |
| 663 | + | expect(standing(sides)[39].index).toBe(71); | |
| 661 | 664 | }); | |
| 662 | 665 | ||
| 663 | 666 | it('fills the square it was built for, then is eaten from the break point', () => { | |
| 664 | 667 | // Built to the wall as dealt, so at the deal it comes out full and square. | |
| 665 | 668 | const square = { h: 10, v: 10 }; | |
| 666 | - | expect(ringLayout(at(64), square).map((s) => s.length)).toEqual([10, 10, 10, 10]); | |
| 669 | + | const perSide = (sides: Placed[][]) => | |
| 670 | + | sides.map((s) => s.filter((p) => p.stack.count > 0).length); | |
| 671 | + | expect(perSide(ringLayout(at(64), square))).toEqual([10, 10, 10, 10]); | |
| 667 | 672 | // From there the gap opens where the drawing is happening and nothing else | |
| 668 | 673 | // moves: the far end stays pinned to the end of the square. | |
| 669 | - | const eaten = ringLayout(at(78), square); | |
| 670 | - | expect(eaten.map((s) => s.length)).toEqual([3, 10, 10, 10]); | |
| 671 | - | expect(eaten[0][0].stack.next).toBe(true); | |
| 674 | + | expect(perSide(ringLayout(at(78), square))).toEqual([3, 10, 10, 10]); | |
| 672 | 675 | // And the other end shortens when a kong or a flower takes from the tail. | |
| 673 | - | expect(ringLayout(at(64, 6), square).map((s) => s.length)).toEqual([10, 10, 10, 7]); | |
| 676 | + | expect(perSide(ringLayout(at(64, 6), square))).toEqual([10, 10, 10, 7]); | |
| 674 | 677 | }); | |
| 675 | 678 | ||
| 676 | 679 | it('never puts more on a side than fits, however little ring there is', () => { | |
| ⋯ 8 unchanged lines | |||
| 685 | 688 | const lengths = [cap.h, cap.v, cap.h, cap.v]; | |
| 686 | 689 | sides.forEach((side, i) => expect(side.length).toBeLessThanOrEqual(lengths[i])); | |
| 687 | 690 | const live = at(front).filter((s) => s.count > 0).length; | |
| 688 | - | expect(flat(sides)).toHaveLength(Math.min(live, 2 * (cap.h + cap.v))); | |
| 691 | + | expect(standing(sides).length).toBeLessThanOrEqual(Math.min(live, 2 * (cap.h + cap.v))); | |
| 689 | 692 | } | |
| 690 | 693 | } | |
| 691 | 694 | }); | |
| ⋯ 1 unchanged line | |||
| 693 | 696 | it('keeps the far end of the wall when there is not room for all of it', () => { | |
| 694 | 697 | // Overflow only happens before a hand is dealt. What has to go is the front, | |
| 695 | 698 | // since that is the part about to be drawn anyway. | |
| 696 | - | const sides = ringLayout(at(0), { h: 6, v: 2 }); | |
| 697 | - | const placed = flat(sides); | |
| 699 | + | const placed = flat(ringLayout(at(0), { h: 6, v: 2 })); | |
| 698 | 700 | expect(placed).toHaveLength(16); | |
| 699 | 701 | expect(placed[placed.length - 1].index).toBe(71); | |
| 700 | 702 | expect(placed[0].index).toBe(56); | |
| 701 | 703 | }); | |
| 704 | + | ||
| 705 | + | it('a wide middle still lays every one of its places out in order', () => { | |
| 706 | + | const placed = flat(ringLayout(at(0), wide)); | |
| 707 | + | const first = placed[0].index; | |
| 708 | + | expect(placed.map((p) => p.index)).toEqual(placed.map((_, i) => first + i)); | |
| 709 | + | }); | |
| 710 | + | }); | |
| 711 | + | ||
| 712 | + | describe('擲骰 — where the dice say to break the wall', () => { | |
| 713 | + | const fresh = (dealer: SeatId, dice: number[]) => | |
| 714 | + | ringLayout( | |
| 715 | + | wallStacks({ drawnFront: 0, drawnBack: 0, dealer, dice, rules: { wallReserve: 16 } }), | |
| 716 | + | RING, | |
| 717 | + | breakAt(dealer, dice), | |
| 718 | + | ).flat(); | |
| 719 | + | /** Three dice that come to `n`, for every total three of them can make. */ | |
| 720 | + | const totalling = (n: number) => [n - 2, 1, 1]; | |
| 721 | + | ||
| 722 | + | it('is three dice, and the same seed throws them the same way', () => { | |
| 723 | + | const one = rollDice(makeRng(7)); | |
| 724 | + | expect(one).toHaveLength(3); | |
| 725 | + | expect(one.every((d) => d >= 1 && d <= 6)).toBe(true); | |
| 726 | + | expect(rollDice(makeRng(7))).toEqual(one); | |
| 727 | + | expect(rollDice(makeRng(8))).not.toEqual([0, 0, 0]); | |
| 728 | + | }); | |
| 729 | + | ||
| 730 | + | it('opens the wall of the seat the total counts to, the dealer being one', () => { | |
| 731 | + | for (const dealer of [0, 1, 2, 3] as SeatId[]) { | |
| 732 | + | for (let total = 3; total <= 18; total++) { | |
| 733 | + | const run = fresh(dealer, totalling(total)); | |
| 734 | + | // Where the first tile drawn is standing, as a place in the square. | |
| 735 | + | const head = run.findIndex((p) => p.index === 0); | |
| 736 | + | // The count goes round the table the way the turn does, and every wall | |
| 737 | + | // is laid down from its own player's right-hand corner — so the break | |
| 738 | + | // is that many places into the side the dice picked. | |
| 739 | + | const whose = ((dealer + total - 1) % 4) as SeatId; | |
| 740 | + | expect(head).toBe((SEAT_WALL_SIDE[whose] * STACKS_PER_SIDE + total) % WALL_STACKS); | |
| 741 | + | } | |
| 742 | + | } | |
| 743 | + | }); | |
| 744 | + | ||
| 745 | + | it('walks the break all the way round the square, and never off it', () => { | |
| 746 | + | const seen = new Set<number>(); | |
| 747 | + | for (const dealer of [0, 1, 2, 3] as SeatId[]) | |
| 748 | + | for (let total = 3; total <= 18; total++) { | |
| 749 | + | const head = fresh(dealer, totalling(total)).findIndex((p) => p.index === 0); | |
| 750 | + | expect(head).toBeGreaterThanOrEqual(0); | |
| 751 | + | expect(head).toBeLessThan(WALL_STACKS); | |
| 752 | + | seen.add(head); | |
| 753 | + | } | |
| 754 | + | // Four seats' worth of walls, sixteen totals: every side gets opened. | |
| 755 | + | expect(seen.size).toBeGreaterThan(STACKS_PER_SIDE); | |
| 756 | + | expect(new Set([...seen].map((p) => Math.floor(p / STACKS_PER_SIDE))).size).toBe(4); | |
| 757 | + | }); | |
| 758 | + | ||
| 759 | + | it('leaves the 底牌 tail standing just behind the break, wherever it is', () => { | |
| 760 | + | for (const total of [3, 7, 12, 18]) { | |
| 761 | + | const run = fresh(2, totalling(total)); | |
| 762 | + | const head = run.findIndex((p) => p.index === 0); | |
| 763 | + | expect(run[(head + WALL_STACKS - 1) % WALL_STACKS].index).toBe(71); | |
| 764 | + | } | |
| 765 | + | }); | |
| 766 | + | ||
| 767 | + | it('moves the gap and nothing else: the same wall, drawn in the same order', () => { | |
| 768 | + | const order = (dice: number[]) => | |
| 769 | + | fresh(0, dice) | |
| 770 | + | .slice() | |
| 771 | + | .sort((a, b) => a.index - b.index) | |
| 772 | + | .map((p) => p.index); | |
| 773 | + | expect(order(totalling(3))).toEqual(order(totalling(11))); | |
| 774 | + | }); | |
| 702 | 775 | }); | |
modifiedsrc/ui/Center.tsx+6 −0
| ⋯ 2 unchanged lines | |||
| 3 | 3 | import type { SeatId } from '../game/types'; | |
| 4 | 4 | import { labelOf } from '../game/tiles'; | |
| 5 | 5 | import { Bi, BiEm } from './Bi'; | |
| 6 | + | import { DiceDebug } from './DiceDebug'; | |
| 6 | 7 | import { useLang, useT } from './lang'; | |
| 7 | 8 | import { FullscreenToggle } from './FullscreenToggle'; | |
| 8 | 9 | import { SoundToggle } from './SoundToggle'; | |
| ⋯ 182 unchanged lines | |||
| 191 | 192 | </div> | |
| 192 | 193 | )} | |
| 193 | 194 | ||
| 195 | + | {/* Dev only, and only where there is a square to look at: the dice decide | |
| 196 | + | where the wall is broken, and that is a thing you want to be able to | |
| 197 | + | step through rather than wait for. */} | |
| 198 | + | {import.meta.env.DEV && !compact && s.phase !== 'gameEnd' && <DiceDebug game={game} />} | |
| 199 | + | ||
| 194 | 200 | <div className="table-controls"> | |
| 195 | 201 | {undoable && ( | |
| 196 | 202 | <button className="btn ghost undo" onClick={() => game.undo()}> | |
| ⋯ 24 unchanged lines | |||
addedsrc/ui/DiceDebug.tsx+64 −0
| 1 | + | import type { TableCtl } from '../game/ctl'; | |
| 2 | + | import type { SeatId } from '../game/types'; | |
| 3 | + | import { breakAt, SEAT_WALL_SIDE, WALL_SIDES } from '../game/wall'; | |
| 4 | + | import { STACKS_PER_SIDE } from '../game/tiles'; | |
| 5 | + | import { useT } from './lang'; | |
| 6 | + | ||
| 7 | + | /** | |
| 8 | + | * The dealer's dice, with a hand on them. Dev builds only. | |
| 9 | + | * | |
| 10 | + | * Where the wall is broken is thrown for once at the start of a hand and then | |
| 11 | + | * lived with, which is right at a table and useless for looking at: half the | |
| 12 | + | * question about the square is what it looks like opened at each of the | |
| 13 | + | * seventy-two places it can be opened at, and waiting for the dice to walk you | |
| 14 | + | * round is not a way to find out. So the total is a thing you can step through | |
| 15 | + | * here — every one three dice can make, 3 to 18 — and the square is redrawn on | |
| 16 | + | * each with the gap somewhere else. | |
| 17 | + | * | |
| 18 | + | * It moves the gap and nothing else: the tiles were shuffled before the wall | |
| 19 | + | * was built, so nobody's hand changes and the draw order is the draw order. | |
| 20 | + | * See `reroll` in game/engine.ts and `breakAt` in game/wall.ts. | |
| 21 | + | */ | |
| 22 | + | export function DiceDebug({ game }: { game: TableCtl }) { | |
| 23 | + | const t = useT(); | |
| 24 | + | const s = game.state; | |
| 25 | + | if (!game.reroll) return null; | |
| 26 | + | ||
| 27 | + | const total = s.dice.reduce((n, d) => n + d, 0); | |
| 28 | + | // Who the count landed on, and how far into their wall — the two things the | |
| 29 | + | // total decides, said out loud so the square can be checked against them. | |
| 30 | + | const whose = ((s.dealer + total - 1) % 4) as SeatId; | |
| 31 | + | const into = total % STACKS_PER_SIDE; | |
| 32 | + | const step = (to: number) => game.reroll?.(to - 2, 1, 1); | |
| 33 | + | ||
| 34 | + | return ( | |
| 35 | + | <div className="dice-debug"> | |
| 36 | + | <div className="dice-faces"> | |
| 37 | + | {s.dice.map((d, i) => ( | |
| 38 | + | <b key={i}>{d}</b> | |
| 39 | + | ))} | |
| 40 | + | <em>{total}</em> | |
| 41 | + | </div> | |
| 42 | + | <div className="dice-row"> | |
| 43 | + | <button className="btn ghost" onClick={() => step(Math.max(3, total - 1))} disabled={total <= 3}> | |
| 44 | + | − | |
| 45 | + | </button> | |
| 46 | + | <button className="btn ghost" onClick={() => game.reroll?.()}> | |
| 47 | + | {t('擲骰', 'Throw')} | |
| 48 | + | </button> | |
| 49 | + | <button | |
| 50 | + | className="btn ghost" | |
| 51 | + | onClick={() => step(Math.min(18, total + 1))} | |
| 52 | + | disabled={total >= 18} | |
| 53 | + | > | |
| 54 | + | + | |
| 55 | + | </button> | |
| 56 | + | </div> | |
| 57 | + | <div className="dice-says"> | |
| 58 | + | {s.players[whose].name} · {WALL_SIDES[SEAT_WALL_SIDE[whose]]} ·{' '} | |
| 59 | + | {t(`第 ${into || STACKS_PER_SIDE} 疊`, `${into || STACKS_PER_SIDE} in`)} ·{' '} | |
| 60 | + | {t('斷點', 'break')} {breakAt(s.dealer, s.dice)} | |
| 61 | + | </div> | |
| 62 | + | </div> | |
| 63 | + | ); | |
| 64 | + | } |
modifiedsrc/ui/WallRing.tsx+32 −24
| 1 | 1 | import { useLayoutEffect, useRef, useState } from 'react'; | |
| 2 | 2 | import { STACKS_PER_SIDE } from '../game/tiles'; | |
| 3 | 3 | import { | |
| 4 | + | breakAt, | |
| 4 | 5 | ringLayout, | |
| 5 | 6 | RING, | |
| 6 | 7 | wallSquare, | |
| 8 | + | WALL_PLACED, | |
| 7 | 9 | WALL_SIDES, | |
| 8 | 10 | wallStacks, | |
| 11 | + | type WallNudge, | |
| 9 | 12 | type WallProgress, | |
| 10 | 13 | type WallSquare, | |
| 11 | 14 | } from '../game/wall'; | |
| ⋯ 25 unchanged lines | |||
| 37 | 40 | const t = useT(); | |
| 38 | 41 | const ring = useRef<HTMLDivElement>(null); | |
| 39 | 42 | const square = useSquare(ring); | |
| 40 | - | const sides = ringLayout(wallStacks(state), RING); | |
| 43 | + | const sides = ringLayout(wallStacks(state), RING, breakAt(state.dealer, state.dice)); | |
| 41 | 44 | const { slide, take } = useSliding(ring, square); | |
| 42 | - | // Where they have been pushed to, for the console and anything reading it | |
| 43 | - | // back off the DOM rather than have it threaded through as a prop nothing | |
| 44 | - | // else wants. | |
| 45 | - | const nudged = slide.map((n) => `${Math.round(n.x)},${Math.round(n.y)}`).join(' '); | |
| 45 | + | // Where they have been pushed to, in tiles — the form `WALL_PLACED` is | |
| 46 | + | // written in, so what a push found can be pasted straight back into it. For | |
| 47 | + | // the console and anything reading it off the DOM rather than have it | |
| 48 | + | // threaded through as a prop nothing else wants. | |
| 49 | + | const nudged = slide.map(say).join(' '); | |
| 46 | 50 | ||
| 47 | 51 | return ( | |
| 48 | 52 | <div | |
| ⋯ 26 unchanged lines | |||
| 75 | 79 | onPointerDown={take(side)} | |
| 76 | 80 | style={ | |
| 77 | 81 | { | |
| 78 | - | '--dx': `${slide[side].x}px`, | |
| 79 | - | '--dy': `${slide[side].y}px`, | |
| 82 | + | '--dx': `${slide[side].x * (square?.cell ?? 0)}px`, | |
| 83 | + | '--dy': `${slide[side].y * (square?.cell ?? 0)}px`, | |
| 80 | 84 | } as React.CSSProperties | |
| 81 | 85 | } | |
| 82 | 86 | > | |
| ⋯ 6 unchanged lines | |||
| 89 | 93 | // wall on a table is one wall, not a wall with the end greyed off. | |
| 90 | 94 | className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`} | |
| 91 | 95 | > | |
| 92 | - | <i /> | |
| 96 | + | {/* A spent place keeps its room in the row and shows nothing: | |
| 97 | + | that hole is where the drawing has got to. */} | |
| 98 | + | {stack.count > 0 && <i />} | |
| 93 | 99 | </div> | |
| 94 | 100 | ))} | |
| 95 | 101 | </div> | |
| ⋯ 22 unchanged lines | |||
| 118 | 124 | * to — `invalidate` in table/pool.ts, and the listener in ui/Pool.tsx. | |
| 119 | 125 | */ | |
| 120 | 126 | function useSliding(ring: React.RefObject<HTMLDivElement | null>, square: WallSquare | null) { | |
| 121 | - | const [slide, setSlide] = useState<Nudge[]>(() => [0, 1, 2, 3].map(() => ({ x: 0, y: 0 }))); | |
| 127 | + | // Starting from where the last person to push them about left them, so a | |
| 128 | + | // push carries on from the placement rather than from the built square, and | |
| 129 | + | // what comes back is the number to write down. | |
| 130 | + | const [slide, setSlide] = useState<WallNudge[]>(() => WALL_PLACED.map((n) => ({ ...n }))); | |
| 122 | 131 | // Where the walls are, as the drag knows it. React state is what draws them, | |
| 123 | 132 | // and it commits when it commits — which is one push behind if the pointer | |
| 124 | 133 | // has already gone up. This is the same thing, now. | |
| 125 | - | const live = useRef<Nudge[]>(slide); | |
| 126 | - | const drag = useRef<{ side: number; fromX: number; fromY: number; at: Nudge } | null>(null); | |
| 134 | + | const live = useRef<WallNudge[]>(slide); | |
| 135 | + | const drag = useRef<{ side: number; fromX: number; fromY: number; at: WallNudge } | null>(null); | |
| 127 | 136 | // What the drag needs to know, kept current without the listeners below | |
| 128 | 137 | // having to be torn down and put back on every render to see it. | |
| 129 | 138 | const now = useRef(square); | |
| ⋯ 19 unchanged lines | |||
| 149 | 158 | useLayoutEffect(() => { | |
| 150 | 159 | const move = (e: PointerEvent) => { | |
| 151 | 160 | const d = drag.current; | |
| 152 | - | if (!d) return; | |
| 153 | - | // Nowhere near off the table, however far the pointer has run. | |
| 154 | - | const bound = STACKS_PER_SIDE * (now.current?.cell ?? 0) * 2; | |
| 161 | + | const cell = now.current?.cell; | |
| 162 | + | if (!d || !cell) return; | |
| 163 | + | // In tiles, which is what the placement is written in: the pointer moves | |
| 164 | + | // in pixels and a tile is however many of them the window came out at. | |
| 165 | + | const bound = STACKS_PER_SIDE * 2; | |
| 155 | 166 | const clamp = (v: number) => Math.min(bound, Math.max(-bound, v)); | |
| 156 | - | const to: Nudge = { | |
| 157 | - | x: clamp(d.at.x + e.clientX - d.fromX), | |
| 158 | - | y: clamp(d.at.y + e.clientY - d.fromY), | |
| 167 | + | const to: WallNudge = { | |
| 168 | + | x: clamp(d.at.x + (e.clientX - d.fromX) / cell), | |
| 169 | + | y: clamp(d.at.y + (e.clientY - d.fromY) / cell), | |
| 159 | 170 | }; | |
| 160 | 171 | const had = live.current[d.side]; | |
| 161 | 172 | if (had.x === to.x && had.y === to.y) return; | |
| ⋯ 7 unchanged lines | |||
| 169 | 180 | drag.current = null; | |
| 170 | 181 | moved(); | |
| 171 | 182 | // Said out loud at the end of the push, not during it: what the fingers | |
| 172 | - | // found, in the form it would be written down in. | |
| 183 | + | // found, in tiles, which is the form `WALL_PLACED` is written in. | |
| 173 | 184 | const at = live.current; | |
| 174 | - | const say = (n: Nudge) => `${Math.round(n.x)},${Math.round(n.y)}`; | |
| 175 | 185 | console.log( | |
| 176 | 186 | `[wall] ${WALL_SIDES[d.side]} pushed to ${say(at[d.side])} — ` + | |
| 177 | 187 | WALL_SIDES.map((cls, i) => `${cls} ${say(at[i])}`).join(' · '), | |
| ⋯ 13 unchanged lines | |||
| 191 | 201 | return { slide, take }; | |
| 192 | 202 | } | |
| 193 | 203 | ||
| 194 | - | /** How far a wall has been pushed from where it was built, in table pixels. */ | |
| 195 | - | export interface Nudge { | |
| 196 | - | x: number; | |
| 197 | - | y: number; | |
| 198 | - | } | |
| 204 | + | /** A push, in the two decimal places `WALL_PLACED` is written to. */ | |
| 205 | + | const say = (n: WallNudge) => `${round(n.x)},${round(n.y)}`; | |
| 206 | + | const round = (v: number) => Math.round(v * 100) / 100; | |
| 199 | 207 | ||
| 200 | 208 | /** | |
| 201 | 209 | * How big to build the square, measured rather than worked out twice: how big a | |
| ⋯ 52 unchanged lines | |||
modifiedsrc/ui/colliders.ts+12 −0
| ⋯ 160 unchanged lines | |||
| 161 | 161 | stepOn?: (n?: number) => void; | |
| 162 | 162 | /** The live pool, for poking at from the console. Dev builds only. */ | |
| 163 | 163 | pool?: unknown; | |
| 164 | + | /** The live game, likewise — `game.reroll(6, 6, 6)` breaks the wall | |
| 165 | + | * somewhere else without touching a tile. */ | |
| 166 | + | game?: unknown; | |
| 164 | 167 | } | |
| 165 | 168 | } | |
| 166 | 169 | ||
| ⋯ 13 unchanged lines | |||
| 180 | 183 | export function exposePool(pool: unknown) { | |
| 181 | 184 | if (import.meta.env.DEV && typeof window !== 'undefined') window.pool = pool; | |
| 182 | 185 | } | |
| 186 | + | ||
| 187 | + | /** | |
| 188 | + | * And the game, for the same reason. `game.reroll(1, 1, 1)` through | |
| 189 | + | * `game.reroll(6, 6, 6)` walks the break all the way round the square, which is | |
| 190 | + | * the only way to see a wall opened anywhere but where this hand opened it. | |
| 191 | + | */ | |
| 192 | + | export function exposeGame(game: unknown) { | |
| 193 | + | if (import.meta.env.DEV && typeof window !== 'undefined') window.game = game; | |
| 194 | + | } | |