anvilsign in

collin/mahjong

1import { describe, expect, it } from 'vitest';
2import { makeRng, STACKS_PER_SIDE } from '../game/tiles';
3import { isBarrier, SEAT_WALL_SIDE, sideStacks, wallStacks } from '../game/wall';
4import type { SeatId } from '../game/types';
5import {
6 addBody,
7 createWorld,
8 faceOn,
9 liftFor,
10 outside,
11 overlapping,
12 settled,
13 step,
14 type Rect,
15 type Spawn,
16} from './physics';
17import { canThrow, segmentHitsRect, type TableGeometry } from './geometry';
18
19const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
20/** Same aspect as a real tile, roughly pool-tile sized. */
21const TILE = { w: 26, h: 36 };
22/** Generous: moving, and then untangling itself, is done well inside this. */
23const SETTLE_CAP = 700;
24
25function throwAt(rng: () => number): Spawn {
26 const centre = { x: POOL.x + POOL.w / 2, y: POOL.y + POOL.h / 2 };
27 // From below the pool, as seat 0 would, but aimed all over the place and
28 // hard enough to be a genuine test of the bounds.
29 const from = { x: 100 + rng() * 400, y: 560 };
30 const spread = (rng() - 0.5) * 260;
31 const dx = centre.x + spread - from.x;
32 const dy = centre.y - from.y;
33 const d = Math.hypot(dx, dy);
34 const speed = 900 + rng() * 1800;
35 return {
36 tile: Math.floor(rng() * 34),
37 seat: 0,
38 x: from.x,
39 y: from.y,
40 vx: (dx / d) * speed,
41 vy: (dy / d) * speed,
42 z: 12,
43 vz: 300 + rng() * 500,
44 angle: rng() * Math.PI * 2,
45 spin: (rng() - 0.5) * 14,
46 w: TILE.w,
47 h: TILE.h,
48 };
49}
50
51function runToRest(world: ReturnType<typeof createWorld>, cap = SETTLE_CAP): number {
52 let steps = 0;
53 while (!settled(world) && steps < cap) {
54 step(world);
55 steps++;
56 }
57 return steps;
58}
59
60describe('physics — the pile', () => {
61 it('never lets a tile out of the pool, however hard it is thrown', () => {
62 const rng = makeRng(20260819);
63 for (let trial = 0; trial < 40; trial++) {
64 const world = createWorld(POOL);
65 // A full hand's worth, thrown one after another into the same pool.
66 for (let i = 0; i < 16; i++) {
67 addBody(world, throwAt(rng));
68 for (let s = 0; s < 40; s++) step(world);
69 }
70 runToRest(world);
71 for (const b of world.bodies) {
72 expect(outside(b, POOL)).toBeLessThan(0.5);
73 expect(b.z).toBe(0);
74 }
75 }
76 });
77
78 it('settles everything, rather than jittering forever', () => {
79 const rng = makeRng(7);
80 const world = createWorld(POOL);
81 for (let i = 0; i < 30; i++) addBody(world, throwAt(rng));
82 const steps = runToRest(world);
83 expect(steps).toBeLessThan(SETTLE_CAP);
84 expect(settled(world)).toBe(true);
85 });
86
87 it('never lets two tiles share the same ground', () => {
88 // A whole hand's worth thrown into one pool, then checked pair by pair as
89 // rotated rectangles — not as circles, which would let two tiles at an angle
90 // sit across each other and call it clear.
91 const rng = makeRng(4242);
92 const world = createWorld(POOL);
93 for (let i = 0; i < 24; i++) {
94 addBody(world, throwAt(rng));
95 for (let s = 0; s < 45; s++) step(world);
96 }
97 runToRest(world);
98
99 for (let i = 0; i < world.bodies.length; i++) {
100 for (let j = i + 1; j < world.bodies.length; j++) {
101 // Sub-pixel contact is how a pile rests against itself; see CONTACT_SLOP.
102 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
103 }
104 }
105 });
106
107 it('carries a hard throw much further than a soft one', () => {
108 // The whole point of throwing it yourself: how hard you flick has to show.
109 // Friction heavy enough to eat the difference would make every discard land
110 // in the same place however it was let go of.
111 // Ground covered, not distance from where it started: a hard throw crosses
112 // the square and comes back off the far side, which is the point of it.
113 const travel = (speed: number) => {
114 const world = createWorld(POOL);
115 const b = addBody(world, {
116 tile: 0,
117 seat: 0,
118 x: POOL.x + 20,
119 y: POOL.y + POOL.h / 2,
120 vx: speed,
121 vy: 0,
122 z: 0,
123 vz: 0,
124 angle: 0,
125 spin: 0,
126 w: TILE.w,
127 h: TILE.h,
128 });
129 let covered = 0;
130 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
131 const x = b.x;
132 const y = b.y;
133 step(world);
134 covered += Math.hypot(b.x - x, b.y - y);
135 }
136 return covered;
137 };
138
139 const soft = travel(220);
140 const hard = travel(2200);
141 expect(hard).toBeGreaterThan(soft * 3);
142 // And a hard one gets right across the square rather than dying halfway.
143 expect(hard).toBeGreaterThan(POOL.w * 0.55);
144 });
145
146 it('bounces a hard throw off the far side instead of sticking to it', () => {
147 const world = createWorld(POOL);
148 const b = addBody(world, {
149 tile: 0,
150 seat: 0,
151 x: POOL.x + 20,
152 y: POOL.y + POOL.h / 2,
153 vx: 2600,
154 vy: 0,
155 z: 0,
156 vz: 0,
157 angle: 0,
158 spin: 0,
159 w: TILE.w,
160 h: TILE.h,
161 });
162 // Run until it has been turned around by the far wall.
163 let bounced = false;
164 for (let i = 0; i < SETTLE_CAP && !bounced; i++) {
165 step(world);
166 if (b.vx < -40) bounced = true;
167 }
168 expect(bounced).toBe(true);
169 runToRest(world);
170 expect(outside(b, POOL)).toBeLessThan(0.5);
171 });
172
173 it('always comes to rest face up, however it tumbles', () => {
174 // A tile lying face down in the discards is a tile nobody can read, so this
175 // is a guarantee rather than something the animation happens to get right.
176 const rng = makeRng(31337);
177 const world = createWorld(POOL);
178 for (let i = 0; i < 20; i++) {
179 const spawn = throwAt(rng);
180 addBody(world, {
181 ...spawn,
182 // Every awkward number of turns, over every awkward flight time.
183 flipTurns: 1 + Math.floor(rng() * 3),
184 flipOver: 0.2 + rng() * 0.5,
185 flipAxis: rng() * Math.PI * 2,
186 });
187 for (let s = 0; s < 40; s++) step(world);
188 }
189 runToRest(world);
190
191 for (const b of world.bodies) {
192 expect(b.resting).toBe(true);
193 // Square on to the table, not edge on and not face down.
194 expect(faceOn(b)).toBeCloseTo(1, 6);
195 }
196 });
197
198 it('turns a lobbed tile over in the air and lands it flat', () => {
199 const world = createWorld(POOL);
200 const b = addBody(world, {
201 tile: 0,
202 seat: 0,
203 x: POOL.x + POOL.w / 2,
204 y: POOL.y + POOL.h + 90,
205 vx: 0,
206 vy: -320,
207 z: 0,
208 vz: liftFor(0.6),
209 angle: 0,
210 spin: 0,
211 flipTurns: 2,
212 flipOver: 0.6,
213 flipAxis: Math.PI / 2,
214 w: TILE.w,
215 h: TILE.h,
216 });
217
218 // Somewhere in the air it must actually show its back, or it never turned.
219 let showedBack = false;
220 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
221 step(world);
222 if (faceOn(b) < -0.2) showedBack = true;
223 }
224 expect(showedBack).toBe(true);
225 expect(faceOn(b)).toBeCloseTo(1, 6);
226 });
227
228 it('is deterministic — the same throws give the same pile', () => {
229 const pile = () => {
230 const rng = makeRng(99);
231 const world = createWorld(POOL);
232 for (let i = 0; i < 12; i++) {
233 addBody(world, throwAt(rng));
234 for (let s = 0; s < 30; s++) step(world);
235 }
236 runToRest(world);
237 return world.bodies.map((b) => [b.x, b.y, b.angle]);
238 };
239 // This is what lets a refresh mid-hand come back to the pile it had.
240 expect(pile()).toEqual(pile());
241 });
242
243 it('gets a tile into the pool even when the wall is in the way', () => {
244 // A standing wall right across the throw, with the tile flicked flat at it:
245 // it cannot get in on its own, and must not be left outside.
246 const world = createWorld(POOL, [{ x: 150, y: 440, w: 320, h: 36 }]);
247 addBody(world, {
248 tile: 0,
249 seat: 0,
250 x: 310,
251 y: 520,
252 vx: 0,
253 vy: -400,
254 z: 0,
255 vz: 0,
256 angle: 0,
257 spin: 0,
258 w: TILE.w,
259 h: TILE.h,
260 });
261 runToRest(world);
262 expect(settled(world)).toBe(true);
263 expect(outside(world.bodies[0], POOL)).toBeLessThan(0.5);
264 });
265
266 it('makes room for a tile dropped into a settled pile', () => {
267 // A rebuilt pile — every tile placed at rest on the same spot.
268 const world = createWorld(POOL);
269 const at = (n: number) =>
270 addBody(world, {
271 tile: n,
272 seat: 0,
273 x: 310,
274 y: 310,
275 vx: 0,
276 vy: 0,
277 z: 0,
278 vz: 0,
279 angle: 0,
280 spin: 0,
281 w: TILE.w,
282 h: TILE.h,
283 atRest: true,
284 });
285 for (let i = 0; i < 8; i++) at(i);
286
287 // Placing them is a first shove; the world untangles the rest without
288 // anything appearing to move, and is not settled until it has.
289 runToRest(world);
290 expect(settled(world)).toBe(true);
291 for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5);
292 // Nothing sharing ground with anything else.
293 for (let i = 0; i < world.bodies.length; i++) {
294 for (let j = i + 1; j < world.bodies.length; j++) {
295 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
296 }
297 }
298 });
299});
300
301// ---------------------------------------------------------------------------
302
303/** A seat-0 view: pool up the screen, a wall across it, hand below. */
304function geometry(walls: Rect[]): TableGeometry {
305 return {
306 size: { w: 620, h: 620 },
307 pool: POOL,
308 walls,
309 tile: TILE,
310 felt: { x: 120, y: 120, w: 380, h: 380 },
311 launch: { 0: { x: 310, y: 560 } },
312 throwFrom: { 0: { x: 310, y: 560 } },
313 };
314}
315
316/** The near side of the square, as 18 stacks. */
317const nearSide = (): Rect[] =>
318 Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 }));
319
320describe('the throw gate', () => {
321 it('is shut while the wall in front of the seat is standing', () => {
322 expect(canThrow(geometry(nearSide()), 0)).toBe(false);
323 });
324
325 it('opens once a run of stacks has been drawn', () => {
326 const walls = nearSide().filter((_, i) => i < 6 || i > 11);
327 expect(canThrow(geometry(walls), 0)).toBe(true);
328 });
329
330 it('opens on a gap off to one side, not just straight ahead', () => {
331 // A gap left of centre, with everything dead ahead still standing: the only
332 // way in is at an angle, towards the near corner of the pool.
333 const walls = nearSide().filter((_, i) => i < 2 || i > 7);
334 expect(canThrow(geometry(walls), 0)).toBe(true);
335 });
336
337 it('stays shut for a gap too far round to reach the pool in a line', () => {
338 // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile
339 // aimed through it lands beside the square, not in it. Openness is about
340 // getting in, so this is genuinely still shut.
341 const walls = nearSide().filter((_, i) => i > 3);
342 expect(canThrow(geometry(walls), 0)).toBe(false);
343 });
344
345 it('is shut for a seat with no hand on screen', () => {
346 expect(canThrow(geometry([]), 2)).toBe(false);
347 });
348
349 it('is open when the square has been eaten away entirely', () => {
350 expect(canThrow(geometry([]), 0)).toBe(true);
351 });
352});
353
354describe('segmentHitsRect', () => {
355 const r: Rect = { x: 10, y: 10, w: 20, h: 20 };
356
357 it('finds a crossing', () => {
358 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true);
359 });
360 it('misses a segment that passes by', () => {
361 expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false);
362 });
363 it('misses a segment that stops short', () => {
364 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false);
365 });
366 it('finds a diagonal through a corner', () => {
367 expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true);
368 });
369});
370
371// ---------------------------------------------------------------------------
372
373describe('wallStacks', () => {
374 const at = (drawnFront: number, drawnBack = 0) =>
375 wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
376
377 it('starts as 72 full stacks', () => {
378 const s = at(0);
379 expect(s).toHaveLength(72);
380 expect(s.every((x) => x.count === 2)).toBe(true);
381 expect(s[0].next).toBe(true);
382 });
383
384 it('eats the square from the front, a stack at a time', () => {
385 // The deal takes 64 tiles plus the dealer's opener.
386 const s = at(65);
387 expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true);
388 expect(s[32].count).toBe(1);
389 expect(s[33].count).toBe(2);
390 expect(s[32].next).toBe(true);
391 });
392
393 it('eats it from the back too, for kong and flower replacements', () => {
394 const s = at(0, 3);
395 expect(s[71].count).toBe(0);
396 expect(s[70].count).toBe(1);
397 });
398
399 it('marks the 16-tile 底牌 tail dead', () => {
400 const s = at(0);
401 expect(s[71].dead).toBe(true);
402 expect(s[64].dead).toBe(true);
403 expect(s[63].dead).toBe(false);
404 });
405
406 it('counts only a full stack as something to throw over', () => {
407 // The rule both halves of the throw hang off: a stack of two is as tall as
408 // the tile coming at it, one of one is not, and a spent one is not there.
409 const s = at(65);
410 expect(isBarrier(s[33])).toBe(true); // two left
411 expect(isBarrier(s[32])).toBe(false); // one left — sail over it
412 expect(isBarrier(s[0])).toBe(false); // gone
413 });
414
415 it('gives each seat the side of the square in front of it', () => {
416 // Seats run bottom, right, top, left; sides are drawn top, right, bottom,
417 // left — so the two orders are genuinely different and worth pinning.
418 expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2);
419 expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0);
420 const s = at(65);
421 // At the deal the top side is gone, so the seat across the table is open
422 // while the near seat's own wall has not been touched.
423 expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true);
424 expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true);
425 expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE);
426 });
427});