| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { makeRng, STACKS_PER_SIDE } from '../game/tiles'; |
| 3 | import { isBarrier, SEAT_WALL_SIDE, sideStacks, wallStacks } from '../game/wall'; |
| 4 | import type { SeatId } from '../game/types'; |
| 5 | import { addBody, createWorld, settled, step, type Body, type Rect, type Spawn } from './physics'; |
| 6 | import { canThrow, segmentHitsRect, type TableGeometry } from './geometry'; |
| 7 | |
| 8 | const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 }; |
| 9 | /** Same aspect as a real tile, roughly pool-tile sized. */ |
| 10 | const TILE = { w: 26, h: 36 }; |
| 11 | /** Generous: nothing should still be moving after four seconds. */ |
| 12 | const SETTLE_CAP = 480; |
| 13 | |
| 14 | function throwAt(rng: () => number): Spawn { |
| 15 | const centre = { x: POOL.x + POOL.w / 2, y: POOL.y + POOL.h / 2 }; |
| 16 | // From below the pool, as seat 0 would, but aimed all over the place and |
| 17 | // hard enough to be a genuine test of the bounds. |
| 18 | const from = { x: 100 + rng() * 400, y: 560 }; |
| 19 | const spread = (rng() - 0.5) * 260; |
| 20 | const dx = centre.x + spread - from.x; |
| 21 | const dy = centre.y - from.y; |
| 22 | const d = Math.hypot(dx, dy); |
| 23 | const speed = 900 + rng() * 1800; |
| 24 | return { |
| 25 | tile: Math.floor(rng() * 34), |
| 26 | seat: 0, |
| 27 | x: from.x, |
| 28 | y: from.y, |
| 29 | vx: (dx / d) * speed, |
| 30 | vy: (dy / d) * speed, |
| 31 | z: 12, |
| 32 | vz: 300 + rng() * 500, |
| 33 | angle: rng() * Math.PI * 2, |
| 34 | spin: (rng() - 0.5) * 14, |
| 35 | w: TILE.w, |
| 36 | h: TILE.h, |
| 37 | }; |
| 38 | } |
| 39 | |
| 40 | function runToRest(world: ReturnType<typeof createWorld>, cap = SETTLE_CAP): number { |
| 41 | let steps = 0; |
| 42 | while (!settled(world) && steps < cap) { |
| 43 | step(world); |
| 44 | steps++; |
| 45 | } |
| 46 | return steps; |
| 47 | } |
| 48 | |
| 49 | /** How far outside the pool a body sits, allowing for its own radius. */ |
| 50 | function escape(b: Body, pool: Rect): number { |
| 51 | const r = (b.w + b.h) * 0.22; |
| 52 | return Math.max( |
| 53 | 0, |
| 54 | pool.x + r - b.x, |
| 55 | b.x - (pool.x + pool.w - r), |
| 56 | pool.y + r - b.y, |
| 57 | b.y - (pool.y + pool.h - r), |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | describe('physics — the pile', () => { |
| 62 | it('never lets a tile out of the pool, however hard it is thrown', () => { |
| 63 | const rng = makeRng(20260819); |
| 64 | for (let trial = 0; trial < 40; trial++) { |
| 65 | const world = createWorld(POOL); |
| 66 | // A full hand's worth, thrown one after another into the same pool. |
| 67 | for (let i = 0; i < 16; i++) { |
| 68 | addBody(world, throwAt(rng)); |
| 69 | for (let s = 0; s < 40; s++) step(world); |
| 70 | } |
| 71 | runToRest(world); |
| 72 | for (const b of world.bodies) { |
| 73 | expect(escape(b, POOL)).toBeLessThan(0.5); |
| 74 | expect(b.z).toBe(0); |
| 75 | } |
| 76 | } |
| 77 | }); |
| 78 | |
| 79 | it('settles everything, rather than jittering forever', () => { |
| 80 | const rng = makeRng(7); |
| 81 | const world = createWorld(POOL); |
| 82 | for (let i = 0; i < 30; i++) addBody(world, throwAt(rng)); |
| 83 | const steps = runToRest(world); |
| 84 | expect(steps).toBeLessThan(SETTLE_CAP); |
| 85 | expect(settled(world)).toBe(true); |
| 86 | }); |
| 87 | |
| 88 | it('is deterministic — the same throws give the same pile', () => { |
| 89 | const pile = () => { |
| 90 | const rng = makeRng(99); |
| 91 | const world = createWorld(POOL); |
| 92 | for (let i = 0; i < 12; i++) { |
| 93 | addBody(world, throwAt(rng)); |
| 94 | for (let s = 0; s < 30; s++) step(world); |
| 95 | } |
| 96 | runToRest(world); |
| 97 | return world.bodies.map((b) => [b.x, b.y, b.angle]); |
| 98 | }; |
| 99 | // This is what lets a refresh mid-hand come back to the pile it had. |
| 100 | expect(pile()).toEqual(pile()); |
| 101 | }); |
| 102 | |
| 103 | it('gets a tile into the pool even when the wall is in the way', () => { |
| 104 | // A standing wall right across the throw, with the tile flicked flat at it: |
| 105 | // it cannot get in on its own, and must not be left outside. |
| 106 | const world = createWorld(POOL, [{ x: 150, y: 440, w: 320, h: 36 }]); |
| 107 | addBody(world, { |
| 108 | tile: 0, |
| 109 | seat: 0, |
| 110 | x: 310, |
| 111 | y: 520, |
| 112 | vx: 0, |
| 113 | vy: -400, |
| 114 | z: 0, |
| 115 | vz: 0, |
| 116 | angle: 0, |
| 117 | spin: 0, |
| 118 | w: TILE.w, |
| 119 | h: TILE.h, |
| 120 | }); |
| 121 | runToRest(world); |
| 122 | expect(settled(world)).toBe(true); |
| 123 | expect(escape(world.bodies[0], POOL)).toBeLessThan(0.5); |
| 124 | }); |
| 125 | |
| 126 | it('makes room for a tile dropped into a settled pile', () => { |
| 127 | // A rebuilt pile — every tile placed at rest on the same spot. |
| 128 | const world = createWorld(POOL); |
| 129 | const at = (n: number) => |
| 130 | addBody(world, { |
| 131 | tile: n, |
| 132 | seat: 0, |
| 133 | x: 310, |
| 134 | y: 310, |
| 135 | vx: 0, |
| 136 | vy: 0, |
| 137 | z: 0, |
| 138 | vz: 0, |
| 139 | angle: 0, |
| 140 | spin: 0, |
| 141 | w: TILE.w, |
| 142 | h: TILE.h, |
| 143 | atRest: true, |
| 144 | }); |
| 145 | for (let i = 0; i < 8; i++) at(i); |
| 146 | |
| 147 | expect(settled(world)).toBe(true); |
| 148 | for (const b of world.bodies) expect(escape(b, POOL)).toBeLessThan(0.5); |
| 149 | // Nothing sitting exactly on top of anything else. |
| 150 | for (let i = 0; i < world.bodies.length; i++) { |
| 151 | for (let j = i + 1; j < world.bodies.length; j++) { |
| 152 | const a = world.bodies[i]; |
| 153 | const b = world.bodies[j]; |
| 154 | expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(1); |
| 155 | } |
| 156 | } |
| 157 | }); |
| 158 | }); |
| 159 | |
| 160 | // --------------------------------------------------------------------------- |
| 161 | |
| 162 | /** A seat-0 view: pool up the screen, a wall across it, hand below. */ |
| 163 | function geometry(walls: Rect[]): TableGeometry { |
| 164 | return { |
| 165 | size: { w: 620, h: 620 }, |
| 166 | pool: POOL, |
| 167 | walls, |
| 168 | tile: TILE, |
| 169 | launch: { 0: { x: 310, y: 560 } }, |
| 170 | }; |
| 171 | } |
| 172 | |
| 173 | /** The near side of the square, as 18 stacks. */ |
| 174 | const nearSide = (): Rect[] => |
| 175 | Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 })); |
| 176 | |
| 177 | describe('the throw gate', () => { |
| 178 | it('is shut while the wall in front of the seat is standing', () => { |
| 179 | expect(canThrow(geometry(nearSide()), 0)).toBe(false); |
| 180 | }); |
| 181 | |
| 182 | it('opens once a run of stacks has been drawn', () => { |
| 183 | const walls = nearSide().filter((_, i) => i < 6 || i > 11); |
| 184 | expect(canThrow(geometry(walls), 0)).toBe(true); |
| 185 | }); |
| 186 | |
| 187 | it('opens on a gap off to one side, not just straight ahead', () => { |
| 188 | // A gap left of centre, with everything dead ahead still standing: the only |
| 189 | // way in is at an angle, towards the near corner of the pool. |
| 190 | const walls = nearSide().filter((_, i) => i < 2 || i > 7); |
| 191 | expect(canThrow(geometry(walls), 0)).toBe(true); |
| 192 | }); |
| 193 | |
| 194 | it('stays shut for a gap too far round to reach the pool in a line', () => { |
| 195 | // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile |
| 196 | // aimed through it lands beside the square, not in it. Openness is about |
| 197 | // getting in, so this is genuinely still shut. |
| 198 | const walls = nearSide().filter((_, i) => i > 3); |
| 199 | expect(canThrow(geometry(walls), 0)).toBe(false); |
| 200 | }); |
| 201 | |
| 202 | it('is shut for a seat with no hand on screen', () => { |
| 203 | expect(canThrow(geometry([]), 2)).toBe(false); |
| 204 | }); |
| 205 | |
| 206 | it('is open when the square has been eaten away entirely', () => { |
| 207 | expect(canThrow(geometry([]), 0)).toBe(true); |
| 208 | }); |
| 209 | }); |
| 210 | |
| 211 | describe('segmentHitsRect', () => { |
| 212 | const r: Rect = { x: 10, y: 10, w: 20, h: 20 }; |
| 213 | |
| 214 | it('finds a crossing', () => { |
| 215 | expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true); |
| 216 | }); |
| 217 | it('misses a segment that passes by', () => { |
| 218 | expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false); |
| 219 | }); |
| 220 | it('misses a segment that stops short', () => { |
| 221 | expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false); |
| 222 | }); |
| 223 | it('finds a diagonal through a corner', () => { |
| 224 | expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true); |
| 225 | }); |
| 226 | }); |
| 227 | |
| 228 | // --------------------------------------------------------------------------- |
| 229 | |
| 230 | describe('wallStacks', () => { |
| 231 | const at = (drawnFront: number, drawnBack = 0) => |
| 232 | wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } }); |
| 233 | |
| 234 | it('starts as 72 full stacks', () => { |
| 235 | const s = at(0); |
| 236 | expect(s).toHaveLength(72); |
| 237 | expect(s.every((x) => x.count === 2)).toBe(true); |
| 238 | expect(s[0].next).toBe(true); |
| 239 | }); |
| 240 | |
| 241 | it('eats the square from the front, a stack at a time', () => { |
| 242 | // The deal takes 64 tiles plus the dealer's opener. |
| 243 | const s = at(65); |
| 244 | expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true); |
| 245 | expect(s[32].count).toBe(1); |
| 246 | expect(s[33].count).toBe(2); |
| 247 | expect(s[32].next).toBe(true); |
| 248 | }); |
| 249 | |
| 250 | it('eats it from the back too, for kong and flower replacements', () => { |
| 251 | const s = at(0, 3); |
| 252 | expect(s[71].count).toBe(0); |
| 253 | expect(s[70].count).toBe(1); |
| 254 | }); |
| 255 | |
| 256 | it('marks the 16-tile 底牌 tail dead', () => { |
| 257 | const s = at(0); |
| 258 | expect(s[71].dead).toBe(true); |
| 259 | expect(s[64].dead).toBe(true); |
| 260 | expect(s[63].dead).toBe(false); |
| 261 | }); |
| 262 | |
| 263 | it('counts only a full stack as something to throw over', () => { |
| 264 | // The rule both halves of the throw hang off: a stack of two is as tall as |
| 265 | // the tile coming at it, one of one is not, and a spent one is not there. |
| 266 | const s = at(65); |
| 267 | expect(isBarrier(s[33])).toBe(true); // two left |
| 268 | expect(isBarrier(s[32])).toBe(false); // one left — sail over it |
| 269 | expect(isBarrier(s[0])).toBe(false); // gone |
| 270 | }); |
| 271 | |
| 272 | it('gives each seat the side of the square in front of it', () => { |
| 273 | // Seats run bottom, right, top, left; sides are drawn top, right, bottom, |
| 274 | // left — so the two orders are genuinely different and worth pinning. |
| 275 | expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2); |
| 276 | expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0); |
| 277 | const s = at(65); |
| 278 | // At the deal the top side is gone, so the seat across the table is open |
| 279 | // while the near seat's own wall has not been touched. |
| 280 | expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true); |
| 281 | expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true); |
| 282 | expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE); |
| 283 | }); |
| 284 | }); |