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('is deterministic — the same throws give the same pile', () => {
106 const pile = () => {
107 const rng = makeRng(99);
108 const world = createWorld(POOL);
109 for (let i = 0; i < 12; i++) {
110 addBody(world, throwAt(rng));
111 for (let s = 0; s < 30; s++) step(world);
112 }
113 runToRest(world);
114 return world.bodies.map((b) => [b.x, b.y, b.angle]);
115 };
116 // This is what lets a refresh mid-hand come back to the pile it had.
117 expect(pile()).toEqual(pile());
118 });
119
120 it('gets a tile into the pool even when the wall is in the way', () => {
121 // A standing wall right across the throw, with the tile flicked flat at it:
122 // it cannot get in on its own, and must not be left outside.
123 const world = createWorld(POOL, [{ x: 150, y: 440, w: 320, h: 36 }]);
124 addBody(world, {
125 tile: 0,
126 seat: 0,
127 x: 310,
128 y: 520,
129 vx: 0,
130 vy: -400,
131 z: 0,
132 vz: 0,
133 angle: 0,
134 spin: 0,
135 w: TILE.w,
136 h: TILE.h,
137 });
138 runToRest(world);
139 expect(settled(world)).toBe(true);
140 expect(outside(world.bodies[0], POOL)).toBeLessThan(0.5);
141 });
142
143 it('makes room for a tile dropped into a settled pile', () => {
144 // A rebuilt pile — every tile placed at rest on the same spot.
145 const world = createWorld(POOL);
146 const at = (n: number) =>
147 addBody(world, {
148 tile: n,
149 seat: 0,
150 x: 310,
151 y: 310,
152 vx: 0,
153 vy: 0,
154 z: 0,
155 vz: 0,
156 angle: 0,
157 spin: 0,
158 w: TILE.w,
159 h: TILE.h,
160 atRest: true,
161 });
162 for (let i = 0; i < 8; i++) at(i);
163
164 expect(settled(world)).toBe(true);
165 for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5);
166 // Nothing sharing ground with anything else.
167 for (let i = 0; i < world.bodies.length; i++) {
168 for (let j = i + 1; j < world.bodies.length; j++) {
169 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
170 }
171 }
172 });
173});
174
175// ---------------------------------------------------------------------------
176
177/** A seat-0 view: pool up the screen, a wall across it, hand below. */
178function geometry(walls: Rect[]): TableGeometry {
179 return {
180 size: { w: 620, h: 620 },
181 pool: POOL,
182 walls,
183 tile: TILE,
184 launch: { 0: { x: 310, y: 560 } },
185 };
186}
187
188/** The near side of the square, as 18 stacks. */
189const nearSide = (): Rect[] =>
190 Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 }));
191
192describe('the throw gate', () => {
193 it('is shut while the wall in front of the seat is standing', () => {
194 expect(canThrow(geometry(nearSide()), 0)).toBe(false);
195 });
196
197 it('opens once a run of stacks has been drawn', () => {
198 const walls = nearSide().filter((_, i) => i < 6 || i > 11);
199 expect(canThrow(geometry(walls), 0)).toBe(true);
200 });
201
202 it('opens on a gap off to one side, not just straight ahead', () => {
203 // A gap left of centre, with everything dead ahead still standing: the only
204 // way in is at an angle, towards the near corner of the pool.
205 const walls = nearSide().filter((_, i) => i < 2 || i > 7);
206 expect(canThrow(geometry(walls), 0)).toBe(true);
207 });
208
209 it('stays shut for a gap too far round to reach the pool in a line', () => {
210 // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile
211 // aimed through it lands beside the square, not in it. Openness is about
212 // getting in, so this is genuinely still shut.
213 const walls = nearSide().filter((_, i) => i > 3);
214 expect(canThrow(geometry(walls), 0)).toBe(false);
215 });
216
217 it('is shut for a seat with no hand on screen', () => {
218 expect(canThrow(geometry([]), 2)).toBe(false);
219 });
220
221 it('is open when the square has been eaten away entirely', () => {
222 expect(canThrow(geometry([]), 0)).toBe(true);
223 });
224});
225
226describe('segmentHitsRect', () => {
227 const r: Rect = { x: 10, y: 10, w: 20, h: 20 };
228
229 it('finds a crossing', () => {
230 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true);
231 });
232 it('misses a segment that passes by', () => {
233 expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false);
234 });
235 it('misses a segment that stops short', () => {
236 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false);
237 });
238 it('finds a diagonal through a corner', () => {
239 expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true);
240 });
241});
242
243// ---------------------------------------------------------------------------
244
245describe('wallStacks', () => {
246 const at = (drawnFront: number, drawnBack = 0) =>
247 wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
248
249 it('starts as 72 full stacks', () => {
250 const s = at(0);
251 expect(s).toHaveLength(72);
252 expect(s.every((x) => x.count === 2)).toBe(true);
253 expect(s[0].next).toBe(true);
254 });
255
256 it('eats the square from the front, a stack at a time', () => {
257 // The deal takes 64 tiles plus the dealer's opener.
258 const s = at(65);
259 expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true);
260 expect(s[32].count).toBe(1);
261 expect(s[33].count).toBe(2);
262 expect(s[32].next).toBe(true);
263 });
264
265 it('eats it from the back too, for kong and flower replacements', () => {
266 const s = at(0, 3);
267 expect(s[71].count).toBe(0);
268 expect(s[70].count).toBe(1);
269 });
270
271 it('marks the 16-tile 底牌 tail dead', () => {
272 const s = at(0);
273 expect(s[71].dead).toBe(true);
274 expect(s[64].dead).toBe(true);
275 expect(s[63].dead).toBe(false);
276 });
277
278 it('counts only a full stack as something to throw over', () => {
279 // The rule both halves of the throw hang off: a stack of two is as tall as
280 // the tile coming at it, one of one is not, and a spent one is not there.
281 const s = at(65);
282 expect(isBarrier(s[33])).toBe(true); // two left
283 expect(isBarrier(s[32])).toBe(false); // one left — sail over it
284 expect(isBarrier(s[0])).toBe(false); // gone
285 });
286
287 it('gives each seat the side of the square in front of it', () => {
288 // Seats run bottom, right, top, left; sides are drawn top, right, bottom,
289 // left — so the two orders are genuinely different and worth pinning.
290 expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2);
291 expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0);
292 const s = at(65);
293 // At the deal the top side is gone, so the seat across the table is open
294 // while the near seat's own wall has not been touched.
295 expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true);
296 expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true);
297 expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE);
298 });
299});