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