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 { addBody, createWorld, settled, step, type Body, type Rect, type Spawn } from './physics';
6import { canThrow, segmentHitsRect, type TableGeometry } from './geometry';
7
8const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
9/** Same aspect as a real tile, roughly pool-tile sized. */
10const TILE = { w: 26, h: 36 };
11/** Generous: nothing should still be moving after four seconds. */
12const SETTLE_CAP = 480;
13
14function 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
40function 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. */
50function 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
61describe('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. */
163function 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. */
174const nearSide = (): Rect[] =>
175 Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 }));
176
177describe('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
211describe('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
230describe('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});