anvilsign in

collin/mahjong

1import { describe, expect, it } from 'vitest';
2import { makeRng, STACKS_PER_SIDE } from '../game/tiles';
3import {
4 DEALT_STACKS,
5 isBarrier,
6 ringFor,
7 ringLayout,
8 ringOpening,
9 SEAT_WALL_SIDE,
10 sideStacks,
11 wallStacks,
12 type Placed,
13} from '../game/wall';
14import type { SeatId } from '../game/types';
15import {
16 addBody,
17 createWorld,
18 faceOn,
19 liftFor,
20 outside,
21 overlapping,
22 settled,
23 step,
24 type Rect,
25 type Spawn,
26} from './physics';
27import { canThrow, segmentHitsRect, type TableGeometry } from './geometry';
28
29const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
30/** Same aspect as a real tile, roughly pool-tile sized. */
31const TILE = { w: 26, h: 36 };
32/** Generous: moving, and then untangling itself, is done well inside this. */
33const SETTLE_CAP = 700;
34
35function throwAt(rng: () => number): Spawn {
36 const centre = { x: POOL.x + POOL.w / 2, y: POOL.y + POOL.h / 2 };
37 // From below the pool, as seat 0 would, but aimed all over the place and
38 // hard enough to be a genuine test of the bounds.
39 const from = { x: 100 + rng() * 400, y: 560 };
40 const spread = (rng() - 0.5) * 260;
41 const dx = centre.x + spread - from.x;
42 const dy = centre.y - from.y;
43 const d = Math.hypot(dx, dy);
44 const speed = 900 + rng() * 1800;
45 return {
46 tile: Math.floor(rng() * 34),
47 seat: 0,
48 x: from.x,
49 y: from.y,
50 vx: (dx / d) * speed,
51 vy: (dy / d) * speed,
52 z: 12,
53 vz: 300 + rng() * 500,
54 angle: rng() * Math.PI * 2,
55 spin: (rng() - 0.5) * 14,
56 w: TILE.w,
57 h: TILE.h,
58 };
59}
60
61function runToRest(world: ReturnType<typeof createWorld>, cap = SETTLE_CAP): number {
62 let steps = 0;
63 while (!settled(world) && steps < cap) {
64 step(world);
65 steps++;
66 }
67 return steps;
68}
69
70describe('physics — the pile', () => {
71 it('never lets a tile out of the pool, however hard it is thrown', () => {
72 const rng = makeRng(20260819);
73 for (let trial = 0; trial < 40; trial++) {
74 const world = createWorld(POOL);
75 // A full hand's worth, thrown one after another into the same pool.
76 for (let i = 0; i < 16; i++) {
77 addBody(world, throwAt(rng));
78 for (let s = 0; s < 40; s++) step(world);
79 }
80 runToRest(world);
81 for (const b of world.bodies) {
82 expect(outside(b, POOL)).toBeLessThan(0.5);
83 expect(b.z).toBe(0);
84 }
85 }
86 });
87
88 it('settles everything, rather than jittering forever', () => {
89 const rng = makeRng(7);
90 const world = createWorld(POOL);
91 for (let i = 0; i < 30; i++) addBody(world, throwAt(rng));
92 const steps = runToRest(world);
93 expect(steps).toBeLessThan(SETTLE_CAP);
94 expect(settled(world)).toBe(true);
95 });
96
97 it('never lets two tiles share the same ground', () => {
98 // A whole hand's worth thrown into one pool, then checked pair by pair as
99 // rotated rectangles — not as circles, which would let two tiles at an angle
100 // sit across each other and call it clear.
101 const rng = makeRng(4242);
102 const world = createWorld(POOL);
103 for (let i = 0; i < 24; i++) {
104 addBody(world, throwAt(rng));
105 for (let s = 0; s < 45; s++) step(world);
106 }
107 runToRest(world);
108
109 for (let i = 0; i < world.bodies.length; i++) {
110 for (let j = i + 1; j < world.bodies.length; j++) {
111 // Sub-pixel contact is how a pile rests against itself; see CONTACT_SLOP.
112 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
113 }
114 }
115 });
116
117 it('carries a hard throw much further than a soft one', () => {
118 // The whole point of throwing it yourself: how hard you flick has to show.
119 // Friction heavy enough to eat the difference would make every discard land
120 // in the same place however it was let go of.
121 // Ground covered, not distance from where it started: a hard throw crosses
122 // the square and comes back off the far side, which is the point of it.
123 const travel = (speed: number) => {
124 const world = createWorld(POOL);
125 const b = addBody(world, {
126 tile: 0,
127 seat: 0,
128 x: POOL.x + 20,
129 y: POOL.y + POOL.h / 2,
130 vx: speed,
131 vy: 0,
132 z: 0,
133 vz: 0,
134 angle: 0,
135 spin: 0,
136 w: TILE.w,
137 h: TILE.h,
138 });
139 let covered = 0;
140 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
141 const x = b.x;
142 const y = b.y;
143 step(world);
144 covered += Math.hypot(b.x - x, b.y - y);
145 }
146 return covered;
147 };
148
149 const soft = travel(220);
150 const hard = travel(2200);
151 expect(hard).toBeGreaterThan(soft * 3);
152 // And a hard one gets right across the square rather than dying halfway.
153 expect(hard).toBeGreaterThan(POOL.w * 0.55);
154 });
155
156 it('bounces a hard throw off the far side instead of sticking to it', () => {
157 const world = createWorld(POOL);
158 const b = addBody(world, {
159 tile: 0,
160 seat: 0,
161 x: POOL.x + 20,
162 y: POOL.y + POOL.h / 2,
163 vx: 2600,
164 vy: 0,
165 z: 0,
166 vz: 0,
167 angle: 0,
168 spin: 0,
169 w: TILE.w,
170 h: TILE.h,
171 });
172 // Run until it has been turned around by the far wall.
173 let bounced = false;
174 for (let i = 0; i < SETTLE_CAP && !bounced; i++) {
175 step(world);
176 if (b.vx < -40) bounced = true;
177 }
178 expect(bounced).toBe(true);
179 runToRest(world);
180 expect(outside(b, POOL)).toBeLessThan(0.5);
181 });
182
183 it('says which edge a tile was thrown into, and whose tile it was', () => {
184 // The middle stops where a player's tiles start, so an edge taken at speed
185 // is a discard thrown into somebody's hand — which is a thing they answer.
186 const world = createWorld(POOL);
187 addBody(world, {
188 tile: 0,
189 seat: 2,
190 x: POOL.x + POOL.w - 30,
191 y: POOL.y + POOL.h / 2,
192 vx: 2400,
193 vy: 0,
194 z: 0,
195 vz: 0,
196 angle: 0,
197 spin: 0,
198 w: TILE.w,
199 h: TILE.h,
200 });
201
202 const edges: string[] = [];
203 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
204 for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`);
205 }
206 expect(edges[0]).toBe('right:2');
207
208 // A tile that merely rolls into the edge counts too — arriving in
209 // somebody's tiles is arriving in them however gently it happened.
210 const gentle = createWorld(POOL);
211 addBody(gentle, {
212 tile: 0,
213 seat: 1,
214 x: POOL.x + POOL.w - 20,
215 y: POOL.y + POOL.h / 2,
216 vx: 90,
217 vy: 0,
218 z: 0,
219 vz: 0,
220 angle: 0,
221 spin: 0,
222 w: TILE.w,
223 h: TILE.h,
224 });
225 const rolled: string[] = [];
226 for (let i = 0; i < SETTLE_CAP && !settled(gentle); i++) {
227 for (const hit of step(gentle)) if (hit.edge) rolled.push(`${hit.edge}:${hit.seat}`);
228 }
229 expect(rolled[0]).toBe('right:1');
230
231 // But a tile lying against the edge is not arriving anywhere. It is held
232 // there every single step, and every one of those would be a complaint.
233 const held = createWorld(POOL);
234 const still = addBody(held, {
235 tile: 0,
236 seat: 1,
237 x: POOL.x + POOL.w,
238 y: POOL.y + POOL.h / 2,
239 vx: 0,
240 vy: 0,
241 z: 0,
242 vz: 0,
243 angle: 0,
244 spin: 0,
245 w: TILE.w,
246 h: TILE.h,
247 atRest: true,
248 });
249 still.resting = false;
250 const pinned = [];
251 for (let i = 0; i < 120; i++) for (const hit of step(held)) if (hit.edge) pinned.push(hit);
252 expect(pinned).toHaveLength(0);
253 });
254
255 it('says whose side a tile that landed short came in from', () => {
256 // Nothing was struck on the way — it simply fell short, out by somebody's
257 // seat, and came onto the table past them. Still theirs to complain about.
258 const world = createWorld(POOL);
259 addBody(world, {
260 tile: 0,
261 seat: 2,
262 x: POOL.x - 120,
263 y: POOL.y + POOL.h / 2,
264 vx: 0,
265 vy: 0,
266 z: 0,
267 vz: 0,
268 angle: 0,
269 spin: 0,
270 w: TILE.w,
271 h: TILE.h,
272 });
273
274 const edges: string[] = [];
275 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
276 for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`);
277 }
278 expect(edges[0]).toBe('left:2');
279 });
280
281 it('always comes to rest face up, however it tumbles', () => {
282 // A tile lying face down in the discards is a tile nobody can read, so this
283 // is a guarantee rather than something the animation happens to get right.
284 const rng = makeRng(31337);
285 const world = createWorld(POOL);
286 for (let i = 0; i < 20; i++) {
287 const spawn = throwAt(rng);
288 addBody(world, {
289 ...spawn,
290 // Every awkward number of turns, over every awkward flight time.
291 flipTurns: 1 + Math.floor(rng() * 3),
292 flipOver: 0.2 + rng() * 0.5,
293 flipAxis: rng() * Math.PI * 2,
294 });
295 for (let s = 0; s < 40; s++) step(world);
296 }
297 runToRest(world);
298
299 for (const b of world.bodies) {
300 expect(b.resting).toBe(true);
301 // Square on to the table, not edge on and not face down.
302 expect(faceOn(b)).toBeCloseTo(1, 6);
303 }
304 });
305
306 it('turns a lobbed tile over in the air and lands it flat', () => {
307 const world = createWorld(POOL);
308 const b = addBody(world, {
309 tile: 0,
310 seat: 0,
311 x: POOL.x + POOL.w / 2,
312 y: POOL.y + POOL.h + 90,
313 vx: 0,
314 vy: -320,
315 z: 0,
316 vz: liftFor(0.6),
317 angle: 0,
318 spin: 0,
319 flipTurns: 2,
320 flipOver: 0.6,
321 flipAxis: Math.PI / 2,
322 w: TILE.w,
323 h: TILE.h,
324 });
325
326 // Somewhere in the air it must actually show its back, or it never turned.
327 let showedBack = false;
328 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
329 step(world);
330 if (faceOn(b) < -0.2) showedBack = true;
331 }
332 expect(showedBack).toBe(true);
333 expect(faceOn(b)).toBeCloseTo(1, 6);
334 });
335
336 it('is deterministic — the same throws give the same pile', () => {
337 const pile = () => {
338 const rng = makeRng(99);
339 const world = createWorld(POOL);
340 for (let i = 0; i < 12; i++) {
341 addBody(world, throwAt(rng));
342 for (let s = 0; s < 30; s++) step(world);
343 }
344 runToRest(world);
345 return world.bodies.map((b) => [b.x, b.y, b.angle]);
346 };
347 // This is what lets a refresh mid-hand come back to the pile it had.
348 expect(pile()).toEqual(pile());
349 });
350
351 it('gets a tile into the pool even when the wall is in the way', () => {
352 // A standing wall right across the throw, with the tile flicked flat at it:
353 // it cannot get in on its own, and must not be left outside.
354 const world = createWorld(POOL, [{ x: 150, y: 440, w: 320, h: 36 }]);
355 addBody(world, {
356 tile: 0,
357 seat: 0,
358 x: 310,
359 y: 520,
360 vx: 0,
361 vy: -400,
362 z: 0,
363 vz: 0,
364 angle: 0,
365 spin: 0,
366 w: TILE.w,
367 h: TILE.h,
368 });
369 runToRest(world);
370 expect(settled(world)).toBe(true);
371 expect(outside(world.bodies[0], POOL)).toBeLessThan(0.5);
372 });
373
374 it('makes room for a tile dropped into a settled pile', () => {
375 // A rebuilt pile — every tile placed at rest on the same spot.
376 const world = createWorld(POOL);
377 const at = (n: number) =>
378 addBody(world, {
379 tile: n,
380 seat: 0,
381 x: 310,
382 y: 310,
383 vx: 0,
384 vy: 0,
385 z: 0,
386 vz: 0,
387 angle: 0,
388 spin: 0,
389 w: TILE.w,
390 h: TILE.h,
391 atRest: true,
392 });
393 for (let i = 0; i < 8; i++) at(i);
394
395 // Placing them is a first shove; the world untangles the rest without
396 // anything appearing to move, and is not settled until it has.
397 runToRest(world);
398 expect(settled(world)).toBe(true);
399 for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5);
400 // Nothing sharing ground with anything else.
401 for (let i = 0; i < world.bodies.length; i++) {
402 for (let j = i + 1; j < world.bodies.length; j++) {
403 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
404 }
405 }
406 });
407});
408
409// ---------------------------------------------------------------------------
410
411/** A seat-0 view: pool up the screen, a wall across it, hand below. */
412function geometry(walls: Rect[]): TableGeometry {
413 return {
414 size: { w: 620, h: 620 },
415 pool: POOL,
416 walls,
417 tile: TILE,
418 felt: { x: 120, y: 120, w: 380, h: 380 },
419 launch: { 0: { x: 310, y: 560 } },
420 throwFrom: { 0: { x: 310, y: 560 } },
421 };
422}
423
424/** The near side of the square, as 18 stacks. */
425const nearSide = (): Rect[] =>
426 Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 }));
427
428describe('the throw gate', () => {
429 it('is shut while the wall in front of the seat is standing', () => {
430 expect(canThrow(geometry(nearSide()), 0)).toBe(false);
431 });
432
433 it('opens once a run of stacks has been drawn', () => {
434 const walls = nearSide().filter((_, i) => i < 6 || i > 11);
435 expect(canThrow(geometry(walls), 0)).toBe(true);
436 });
437
438 it('opens on a gap off to one side, not just straight ahead', () => {
439 // A gap left of centre, with everything dead ahead still standing: the only
440 // way in is at an angle, towards the near corner of the pool.
441 const walls = nearSide().filter((_, i) => i < 2 || i > 7);
442 expect(canThrow(geometry(walls), 0)).toBe(true);
443 });
444
445 it('stays shut for a gap too far round to reach the pool in a line', () => {
446 // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile
447 // aimed through it lands beside the square, not in it. Openness is about
448 // getting in, so this is genuinely still shut.
449 const walls = nearSide().filter((_, i) => i > 3);
450 expect(canThrow(geometry(walls), 0)).toBe(false);
451 });
452
453 it('is shut for a seat with no hand on screen', () => {
454 expect(canThrow(geometry([]), 2)).toBe(false);
455 });
456
457 it('is open when the square has been eaten away entirely', () => {
458 expect(canThrow(geometry([]), 0)).toBe(true);
459 });
460});
461
462describe('segmentHitsRect', () => {
463 const r: Rect = { x: 10, y: 10, w: 20, h: 20 };
464
465 it('finds a crossing', () => {
466 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true);
467 });
468 it('misses a segment that passes by', () => {
469 expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false);
470 });
471 it('misses a segment that stops short', () => {
472 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false);
473 });
474 it('finds a diagonal through a corner', () => {
475 expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true);
476 });
477});
478
479// ---------------------------------------------------------------------------
480
481describe('wallStacks', () => {
482 const at = (drawnFront: number, drawnBack = 0) =>
483 wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
484
485 it('starts as 72 full stacks', () => {
486 const s = at(0);
487 expect(s).toHaveLength(72);
488 expect(s.every((x) => x.count === 2)).toBe(true);
489 expect(s[0].next).toBe(true);
490 });
491
492 it('eats the square from the front, a stack at a time', () => {
493 // The deal takes 64 tiles plus the dealer's opener.
494 const s = at(65);
495 expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true);
496 expect(s[32].count).toBe(1);
497 expect(s[33].count).toBe(2);
498 expect(s[32].next).toBe(true);
499 });
500
501 it('eats it from the back too, for kong and flower replacements', () => {
502 const s = at(0, 3);
503 expect(s[71].count).toBe(0);
504 expect(s[70].count).toBe(1);
505 });
506
507 it('marks the 16-tile 底牌 tail dead', () => {
508 const s = at(0);
509 expect(s[71].dead).toBe(true);
510 expect(s[64].dead).toBe(true);
511 expect(s[63].dead).toBe(false);
512 });
513
514 it('counts only a full stack as something to throw over', () => {
515 // The rule both halves of the throw hang off: a stack of two is as tall as
516 // the tile coming at it, one of one is not, and a spent one is not there.
517 const s = at(65);
518 expect(isBarrier(s[33])).toBe(true); // two left
519 expect(isBarrier(s[32])).toBe(false); // one left — sail over it
520 expect(isBarrier(s[0])).toBe(false); // gone
521 });
522
523 it('gives each seat the side of the square in front of it', () => {
524 // Seats run bottom, right, top, left; sides are drawn top, right, bottom,
525 // left — so the two orders are genuinely different and worth pinning.
526 expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2);
527 expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0);
528 const s = at(65);
529 // At the deal the top side is gone, so the seat across the table is open
530 // while the near seat's own wall has not been touched.
531 expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true);
532 expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true);
533 expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE);
534 });
535});
536
537describe('ringFor', () => {
538 // A tile as it comes out on an ordinary window, and the stack it makes.
539 const cell = { s: 31, d: 43 };
540 const holds = (cap: { h: number; v: number }) => 2 * (cap.h + cap.v);
541
542 it('builds a square the size of the wall as it is dealt', () => {
543 const cap = ringFor({ w: 990, h: 360 }, cell);
544 expect(cap).toEqual({ h: 10, v: 10 });
545 expect(holds(cap)).toBe(DEALT_STACKS);
546 // And it leaves the middle of itself for the discards.
547 expect(ringOpening(cap, cell).w).toBe(10 * 31 - 43);
548 });
549
550 it('gives up being square before it gives up a tile', () => {
551 // A middle too shallow for ten stacks down its side: the wall gets longer
552 // rather than the tiles getting smaller.
553 const cap = ringFor({ w: 990, h: 220 }, cell);
554 expect(cap.v).toBeLessThan(10);
555 expect(cap.h).toBeGreaterThan(10);
556 expect(holds(cap)).toBeGreaterThanOrEqual(DEALT_STACKS);
557 });
558
559 it('never asks for more room than it has, however small the middle', () => {
560 for (const space of [{ w: 990, h: 360 }, { w: 400, h: 400 }, { w: 300, h: 140 }, { w: 60, h: 40 }]) {
561 const cap = ringFor(space, cell);
562 expect(cap.h).toBeGreaterThanOrEqual(1);
563 expect(cap.v).toBeGreaterThanOrEqual(1);
564 expect(cap.h * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.w, cell.s + cell.d));
565 expect(cap.v * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.h, cell.s + cell.d));
566 }
567 });
568});
569
570describe('ringLayout', () => {
571 const at = (drawnFront: number, drawnBack = 0) =>
572 wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
573 const flat = (sides: Placed[][]) => sides.flat();
574 // A middle much wider than it is deep, which is what an ordinary window has.
575 const wide = { h: 30, v: 10 };
576
577 it('draws what is still standing and nothing else', () => {
578 const sides = ringLayout(at(65), wide);
579 const placed = flat(sides);
580 expect(placed).toHaveLength(40);
581 expect(placed.every((p) => p.stack.count > 0)).toBe(true);
582 // In order all the way round: the break point first, the 底牌 tail last.
583 expect(placed[0].index).toBe(32);
584 expect(placed[0].stack.next).toBe(true);
585 expect(placed[placed.length - 1].index).toBe(71);
586 expect(placed.every((p, i) => i === 0 || p.index > placed[i - 1].index)).toBe(true);
587 });
588
589 it('fills the square it was built for, then is eaten from the break point', () => {
590 // Built to the wall as dealt, so at the deal it comes out full and square.
591 const square = { h: 10, v: 10 };
592 expect(ringLayout(at(64), square).map((s) => s.length)).toEqual([10, 10, 10, 10]);
593 // From there the gap opens where the drawing is happening and nothing else
594 // moves: the far end stays pinned to the end of the square.
595 const eaten = ringLayout(at(78), square);
596 expect(eaten.map((s) => s.length)).toEqual([3, 10, 10, 10]);
597 expect(eaten[0][0].stack.next).toBe(true);
598 // And the other end shortens when a kong or a flower takes from the tail.
599 expect(ringLayout(at(64, 6), square).map((s) => s.length)).toEqual([10, 10, 10, 7]);
600 });
601
602 it('never puts more on a side than fits, however little ring there is', () => {
603 for (const cap of [
604 { h: 30, v: 10 },
605 { h: 18, v: 18 },
606 { h: 6, v: 2 },
607 { h: 1, v: 1 },
608 ]) {
609 for (const front of [0, 1, 65, 100, 128]) {
610 const sides = ringLayout(at(front), cap);
611 const lengths = [cap.h, cap.v, cap.h, cap.v];
612 sides.forEach((side, i) => expect(side.length).toBeLessThanOrEqual(lengths[i]));
613 const live = at(front).filter((s) => s.count > 0).length;
614 expect(flat(sides)).toHaveLength(Math.min(live, 2 * (cap.h + cap.v)));
615 }
616 }
617 });
618
619 it('keeps the far end of the wall when there is not room for all of it', () => {
620 // Overflow only happens before a hand is dealt. What has to go is the front,
621 // since that is the part about to be drawn anyway.
622 const sides = ringLayout(at(0), { h: 6, v: 2 });
623 const placed = flat(sides);
624 expect(placed).toHaveLength(16);
625 expect(placed[placed.length - 1].index).toBe(71);
626 expect(placed[0].index).toBe(56);
627 });
628});