anvilsign in

collin/mahjong

master / src / table / physics.test.ts
1import { describe, expect, it } from 'vitest';
2import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles';
3import {
4 breakAt,
5 isBarrier,
6 wallSquare,
7 RING,
8 ringLayout,
9 rollDice,
10 SEAT_WALL_SIDE,
11 sideStacks,
12 wallStacks,
13 type Placed,
14} from '../game/wall';
15import type { SeatId } from '../game/types';
16import {
17 addBody,
18 createWorld,
19 reshape,
20 faceOn,
21 FIXED_DT,
22 liftFor,
23 outside,
24 overlapping,
25 settled,
26 step,
27 type Rect,
28 type Spawn,
29} from './physics';
30import { canThrow, segmentHitsRect, type TableGeometry } from './geometry';
31
32const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
33/** Same aspect as a real tile, roughly pool-tile sized. */
34const TILE = { w: 26, h: 36 };
35/** Generous: moving, and then untangling itself, is done well inside this. */
36const SETTLE_CAP = 700;
37
38function throwAt(rng: () => number): Spawn {
39 const centre = { x: POOL.x + POOL.w / 2, y: POOL.y + POOL.h / 2 };
40 // From below the pool, as seat 0 would, but aimed all over the place and
41 // hard enough to be a genuine test of the bounds.
42 const from = { x: 100 + rng() * 400, y: 560 };
43 const spread = (rng() - 0.5) * 260;
44 const dx = centre.x + spread - from.x;
45 const dy = centre.y - from.y;
46 const d = Math.hypot(dx, dy);
47 const speed = 900 + rng() * 1800;
48 return {
49 tile: Math.floor(rng() * 34),
50 seat: 0,
51 x: from.x,
52 y: from.y,
53 vx: (dx / d) * speed,
54 vy: (dy / d) * speed,
55 z: 12,
56 vz: 300 + rng() * 500,
57 angle: rng() * Math.PI * 2,
58 spin: (rng() - 0.5) * 14,
59 w: TILE.w,
60 h: TILE.h,
61 };
62}
63
64function runToRest(world: ReturnType<typeof createWorld>, cap = SETTLE_CAP): number {
65 let steps = 0;
66 while (!settled(world) && steps < cap) {
67 step(world);
68 steps++;
69 }
70 return steps;
71}
72
73describe('physics — the pile', () => {
74 it('never lets a tile out of the pool, however hard it is thrown', () => {
75 const rng = makeRng(20260819);
76 for (let trial = 0; trial < 40; trial++) {
77 const world = createWorld(POOL);
78 // A full hand's worth, thrown one after another into the same pool.
79 for (let i = 0; i < 16; i++) {
80 addBody(world, throwAt(rng));
81 for (let s = 0; s < 40; s++) step(world);
82 }
83 runToRest(world);
84 for (const b of world.bodies) {
85 expect(outside(b, POOL)).toBeLessThan(0.5);
86 expect(b.z).toBe(0);
87 }
88 }
89 });
90
91 it('settles everything, rather than jittering forever', () => {
92 const rng = makeRng(7);
93 const world = createWorld(POOL);
94 for (let i = 0; i < 30; i++) addBody(world, throwAt(rng));
95 const steps = runToRest(world);
96 expect(steps).toBeLessThan(SETTLE_CAP);
97 expect(settled(world)).toBe(true);
98 });
99
100 it('never lets two tiles share the same ground', () => {
101 // A whole hand's worth thrown into one pool, then checked pair by pair as
102 // rotated rectangles — not as circles, which would let two tiles at an angle
103 // sit across each other and call it clear.
104 const rng = makeRng(4242);
105 const world = createWorld(POOL);
106 for (let i = 0; i < 24; i++) {
107 addBody(world, throwAt(rng));
108 for (let s = 0; s < 45; s++) step(world);
109 }
110 runToRest(world);
111
112 for (let i = 0; i < world.bodies.length; i++) {
113 for (let j = i + 1; j < world.bodies.length; j++) {
114 // Sub-pixel contact is how a pile rests against itself; see CONTACT_SLOP.
115 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
116 }
117 }
118 });
119
120 it('carries a hard throw much further than a soft one', () => {
121 // The whole point of throwing it yourself: how hard you flick has to show.
122 // Friction heavy enough to eat the difference would make every discard land
123 // in the same place however it was let go of.
124 // Ground covered, not distance from where it started: a hard throw crosses
125 // the square and comes back off the far side, which is the point of it.
126 const travel = (speed: number) => {
127 const world = createWorld(POOL);
128 const b = addBody(world, {
129 tile: 0,
130 seat: 0,
131 x: POOL.x + 20,
132 y: POOL.y + POOL.h / 2,
133 vx: speed,
134 vy: 0,
135 z: 0,
136 vz: 0,
137 angle: 0,
138 spin: 0,
139 w: TILE.w,
140 h: TILE.h,
141 });
142 let covered = 0;
143 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
144 const x = b.x;
145 const y = b.y;
146 step(world);
147 covered += Math.hypot(b.x - x, b.y - y);
148 }
149 return covered;
150 };
151
152 const soft = travel(220);
153 const hard = travel(2200);
154 expect(hard).toBeGreaterThan(soft * 3);
155 // And a hard one gets right across the square rather than dying halfway.
156 expect(hard).toBeGreaterThan(POOL.w * 0.55);
157 });
158
159 it('bounces a hard throw off the far side instead of sticking to it', () => {
160 const world = createWorld(POOL);
161 const b = addBody(world, {
162 tile: 0,
163 seat: 0,
164 x: POOL.x + 20,
165 y: POOL.y + POOL.h / 2,
166 vx: 2600,
167 vy: 0,
168 z: 0,
169 vz: 0,
170 angle: 0,
171 spin: 0,
172 w: TILE.w,
173 h: TILE.h,
174 });
175 // Run until it has been turned around by the far wall.
176 let bounced = false;
177 for (let i = 0; i < SETTLE_CAP && !bounced; i++) {
178 step(world);
179 if (b.vx < -40) bounced = true;
180 }
181 expect(bounced).toBe(true);
182 runToRest(world);
183 expect(outside(b, POOL)).toBeLessThan(0.5);
184 });
185
186 it('does not jerk a tile inwards at the moment it crosses onto the felt', () => {
187 // Coming in from a hand, the tile is outside the bounds and passes through
188 // them — that is what lets it be thrown in at all. But the edge is only a
189 // fence once it is in, and which step that happens on must not be visible:
190 // a tile that gets snapped half its own length up the table, and loses half
191 // its speed doing it, has hit something that is not drawn anywhere.
192 const world = createWorld(POOL);
193 const b = addBody(world, {
194 tile: 0,
195 seat: 0,
196 // From below, as seat 0 throws, low enough to be inside the wall's height
197 // the whole way — a skid rather than a lob.
198 x: POOL.x + POOL.w / 2,
199 y: POOL.y + POOL.h + 120,
200 vx: 0,
201 vy: -1600,
202 z: 0,
203 vz: liftFor(0.18),
204 angle: 0,
205 spin: 0,
206 w: TILE.w,
207 h: TILE.h,
208 });
209
210 // The furthest it moves in any one step, against the furthest it should:
211 // a step of travel, and nothing else.
212 const perStep = 1600 * FIXED_DT;
213 let jump = 0;
214 let lost = 0;
215 let prev = { x: b.x, y: b.y, v: Math.hypot(b.vx, b.vy) };
216 for (let i = 0; i < 60; i++) {
217 step(world);
218 const v = Math.hypot(b.vx, b.vy);
219 jump = Math.max(jump, Math.hypot(b.x - prev.x, b.y - prev.y));
220 // Only while it is still crossing in — the far side is meant to stop it.
221 if (b.y > POOL.y + TILE.h) lost = Math.max(lost, prev.v - v);
222 prev = { x: b.x, y: b.y, v };
223 }
224 expect(jump).toBeLessThan(perStep * 1.2);
225 expect(lost).toBeLessThan(60);
226 });
227
228 it('says which edge a tile was thrown into, and whose tile it was', () => {
229 // The middle stops where a player's tiles start, so an edge taken at speed
230 // is a discard thrown into somebody's hand — which is a thing they answer.
231 const world = createWorld(POOL);
232 addBody(world, {
233 tile: 0,
234 seat: 2,
235 x: POOL.x + POOL.w - 30,
236 y: POOL.y + POOL.h / 2,
237 vx: 2400,
238 vy: 0,
239 z: 0,
240 vz: 0,
241 angle: 0,
242 spin: 0,
243 w: TILE.w,
244 h: TILE.h,
245 });
246
247 const edges: string[] = [];
248 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
249 for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`);
250 }
251 expect(edges[0]).toBe('right:2');
252
253 // A tile that merely rolls into the edge counts too — arriving in
254 // somebody's tiles is arriving in them however gently it happened.
255 const gentle = createWorld(POOL);
256 addBody(gentle, {
257 tile: 0,
258 seat: 1,
259 x: POOL.x + POOL.w - 20,
260 y: POOL.y + POOL.h / 2,
261 vx: 90,
262 vy: 0,
263 z: 0,
264 vz: 0,
265 angle: 0,
266 spin: 0,
267 w: TILE.w,
268 h: TILE.h,
269 });
270 const rolled: string[] = [];
271 for (let i = 0; i < SETTLE_CAP && !settled(gentle); i++) {
272 for (const hit of step(gentle)) if (hit.edge) rolled.push(`${hit.edge}:${hit.seat}`);
273 }
274 expect(rolled[0]).toBe('right:1');
275
276 // But a tile lying against the edge is not arriving anywhere. It is held
277 // there every single step, and every one of those would be a complaint.
278 const held = createWorld(POOL);
279 const still = addBody(held, {
280 tile: 0,
281 seat: 1,
282 x: POOL.x + POOL.w,
283 y: POOL.y + POOL.h / 2,
284 vx: 0,
285 vy: 0,
286 z: 0,
287 vz: 0,
288 angle: 0,
289 spin: 0,
290 w: TILE.w,
291 h: TILE.h,
292 atRest: true,
293 });
294 still.resting = false;
295 const pinned = [];
296 for (let i = 0; i < 120; i++) for (const hit of step(held)) if (hit.edge) pinned.push(hit);
297 expect(pinned).toHaveLength(0);
298 });
299
300 it('says whose side a tile that landed short came in from', () => {
301 // Nothing was struck on the way — it simply fell short, out by somebody's
302 // seat, and came onto the table past them. Still theirs to complain about.
303 const world = createWorld(POOL);
304 addBody(world, {
305 tile: 0,
306 seat: 2,
307 x: POOL.x - 120,
308 y: POOL.y + POOL.h / 2,
309 vx: 0,
310 vy: 0,
311 z: 0,
312 vz: 0,
313 angle: 0,
314 spin: 0,
315 w: TILE.w,
316 h: TILE.h,
317 });
318
319 const edges: string[] = [];
320 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
321 for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`);
322 }
323 expect(edges[0]).toBe('left:2');
324 });
325
326 it('always comes to rest face up, however it tumbles', () => {
327 // A tile lying face down in the discards is a tile nobody can read, so this
328 // is a guarantee rather than something the animation happens to get right.
329 const rng = makeRng(31337);
330 const world = createWorld(POOL);
331 for (let i = 0; i < 20; i++) {
332 const spawn = throwAt(rng);
333 addBody(world, {
334 ...spawn,
335 // Every awkward number of turns, over every awkward flight time.
336 flipTurns: 1 + Math.floor(rng() * 3),
337 flipOver: 0.2 + rng() * 0.5,
338 flipAxis: rng() * Math.PI * 2,
339 });
340 for (let s = 0; s < 40; s++) step(world);
341 }
342 runToRest(world);
343
344 for (const b of world.bodies) {
345 expect(b.resting).toBe(true);
346 // Square on to the table, not edge on and not face down.
347 expect(faceOn(b)).toBeCloseTo(1, 6);
348 }
349 });
350
351 it('turns a lobbed tile over in the air and lands it flat', () => {
352 const world = createWorld(POOL);
353 const b = addBody(world, {
354 tile: 0,
355 seat: 0,
356 x: POOL.x + POOL.w / 2,
357 y: POOL.y + POOL.h + 90,
358 vx: 0,
359 vy: -320,
360 z: 0,
361 vz: liftFor(0.6),
362 angle: 0,
363 spin: 0,
364 flipTurns: 2,
365 flipOver: 0.6,
366 flipAxis: Math.PI / 2,
367 w: TILE.w,
368 h: TILE.h,
369 });
370
371 // Somewhere in the air it must actually show its back, or it never turned.
372 let showedBack = false;
373 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
374 step(world);
375 if (faceOn(b) < -0.2) showedBack = true;
376 }
377 expect(showedBack).toBe(true);
378 expect(faceOn(b)).toBeCloseTo(1, 6);
379 });
380
381 it('is deterministic — the same throws give the same pile', () => {
382 const pile = () => {
383 const rng = makeRng(99);
384 const world = createWorld(POOL);
385 for (let i = 0; i < 12; i++) {
386 addBody(world, throwAt(rng));
387 for (let s = 0; s < 30; s++) step(world);
388 }
389 runToRest(world);
390 return world.bodies.map((b) => [b.x, b.y, b.angle]);
391 };
392 // This is what lets a refresh mid-hand come back to the pile it had.
393 expect(pile()).toEqual(pile());
394 });
395
396 it('gets a tile into the pool even when the wall is in the way', () => {
397 // A standing wall right across the throw, with the tile flicked flat at it:
398 // it cannot get in on its own, and must not be left outside.
399 const world = createWorld(POOL, [{ rect: { x: 150, y: 440, w: 320, h: 36 } }]);
400 addBody(world, {
401 tile: 0,
402 seat: 0,
403 x: 310,
404 y: 520,
405 vx: 0,
406 vy: -400,
407 z: 0,
408 vz: 0,
409 angle: 0,
410 spin: 0,
411 w: TILE.w,
412 h: TILE.h,
413 });
414 runToRest(world);
415 expect(settled(world)).toBe(true);
416 expect(outside(world.bodies[0], POOL)).toBeLessThan(0.5);
417 });
418
419 it('makes room for a tile dropped into a settled pile', () => {
420 // A rebuilt pile — every tile placed at rest on the same spot.
421 const world = createWorld(POOL);
422 const at = (n: number) =>
423 addBody(world, {
424 tile: n,
425 seat: 0,
426 x: 310,
427 y: 310,
428 vx: 0,
429 vy: 0,
430 z: 0,
431 vz: 0,
432 angle: 0,
433 spin: 0,
434 w: TILE.w,
435 h: TILE.h,
436 atRest: true,
437 });
438 for (let i = 0; i < 8; i++) at(i);
439
440 // Placing them is a first shove; the world untangles the rest without
441 // anything appearing to move, and is not settled until it has.
442 runToRest(world);
443 expect(settled(world)).toBe(true);
444 for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5);
445 // Nothing sharing ground with anything else.
446 for (let i = 0; i < world.bodies.length; i++) {
447 for (let j = i + 1; j < world.bodies.length; j++) {
448 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
449 }
450 }
451 });
452});
453
454// ---------------------------------------------------------------------------
455
456/**
457 * A tile is turned by *where* it is hit, and by nothing else.
458 *
459 * The whole of the spin model is that a contact happens somewhere in particular
460 * rather than between two middles: the arm from a tile's centre to the place it
461 * is being pushed is what a turn is made of, and a push straight through the
462 * centre has none. These pin that, because it is the thing that is easy to
463 * break by reaching for the centres because they are to hand.
464 */
465describe('what turns a tile', () => {
466 const lying = (over: Partial<Spawn> = {}): Spawn => ({
467 tile: 0,
468 seat: 0,
469 x: POOL.x + POOL.w / 2,
470 y: POOL.y + POOL.h / 2,
471 vx: 0,
472 vy: 0,
473 z: 0,
474 vz: 0,
475 angle: 0,
476 spin: 0,
477 w: TILE.w,
478 h: TILE.h,
479 ...over,
480 });
481
482 it('does not turn a tile shouldered square in the back', () => {
483 const world = createWorld(POOL);
484 // Directly behind, and lined up: nothing about this is off to one side.
485 const struck = addBody(world, lying());
486 const hitter = addBody(world, lying({ y: POOL.y + POOL.h / 2 + TILE.h, vy: -700 }));
487 for (let i = 0; i < 30; i++) step(world);
488 expect(struck.vy).toBeLessThan(-100);
489 expect(Math.abs(struck.spin)).toBeLessThan(0.05);
490 expect(Math.abs(hitter.spin)).toBeLessThan(0.05);
491 });
492
493 it('turns one caught off to the side, and the way the arm says', () => {
494 const world = createWorld(POOL);
495 const struck = addBody(world, lying());
496 // The same shove, moved along the tile until it is catching one end of it.
497 addBody(world, lying({ x: POOL.x + POOL.w / 2 + TILE.w * 0.4, y: POOL.y + POOL.h / 2 + TILE.h, vy: -700 }));
498 for (let i = 0; i < 30; i++) step(world);
499 expect(struck.vy).toBeLessThan(-100);
500 expect(Math.abs(struck.spin)).toBeGreaterThan(0.5);
501 // Pushed forward on its right-hand end, so it comes round anticlockwise on
502 // the screen, which is a falling angle, since y runs down.
503 expect(struck.spin).toBeLessThan(0);
504 });
505
506 it('slews a tile that catches the edge on a corner, and not one lying square to it', () => {
507 const run = (angle: number) => {
508 const world = createWorld(POOL);
509 const b = addBody(world, lying({ y: POOL.y + POOL.h - TILE.h, vy: -900, angle }));
510 for (let i = 0; i < 30; i++) step(world);
511 return Math.abs(b.spin);
512 };
513 // Lying square to the edge it runs into: it meets the whole of one side at
514 // once, and there is no arm anywhere in that.
515 expect(run(0)).toBeLessThan(0.05);
516 expect(run(Math.PI / 2)).toBeLessThan(0.05);
517 // At forty-five degrees it meets it on one corner, and swings on round it.
518 expect(run(Math.PI / 4)).toBeGreaterThan(1);
519 });
520
521 it('stops a slide and a turn together, not one long before the other', () => {
522 // One patch of cloth, doing one thing: what it spends stopping the turn it
523 // has not got left for the slide, and the other way about. Two brakes
524 // running side by side, which is what a spin damped on its own is, let a
525 // tile skate on for half a second after it has visibly stopped turning, or
526 // sit turning where it stopped, and both of those read as wrong.
527 const run = (vx: number, spin: number) => {
528 const world = createWorld(POOL);
529 const b = addBody(world, lying({ x: POOL.x + TILE.w, vx, spin }));
530 const from = b.x;
531 let stopped = -1;
532 let stilled = -1;
533 for (let i = 0; i < 400; i++) {
534 step(world);
535 if (stopped < 0 && Math.abs(b.vx) < 1) stopped = i;
536 if (stilled < 0 && Math.abs(b.spin) < 0.05) stilled = i;
537 }
538 return { stopped, stilled, went: b.x - from };
539 };
540 // Thrown hard with only a little turn on it, and flicked barely at all with
541 // a lot: both ways round, the two run out at about the same moment.
542 const hard = run(900, 2);
543 expect(Math.abs(hard.stopped - hard.stilled)).toBeLessThan(20);
544 const turned = run(90, 9);
545 expect(Math.abs(turned.stopped - turned.stilled)).toBeLessThan(30);
546 // And a tile doing both carries further than the same tile only sliding,
547 // because the turn is taking some of the friction that would have stopped it.
548 expect(run(300, 8).went).toBeGreaterThan(run(300, 0).went);
549 });
550
551 it('does not wind the pool up: a crowded pile turns hard and still comes to rest', () => {
552 const rng = makeRng(20260825);
553 let fastest = 0;
554 for (let trial = 0; trial < 8; trial++) {
555 const world = createWorld(POOL);
556 for (let i = 0; i < 16; i++) {
557 addBody(world, throwAt(rng));
558 for (let s = 0; s < 40; s++) {
559 step(world);
560 for (const b of world.bodies) fastest = Math.max(fastest, Math.abs(b.spin));
561 }
562 }
563 expect(runToRest(world)).toBeLessThan(SETTLE_CAP);
564 }
565 // Tiles thrown at each other do come off spinning, which is the point, but
566 // a contact that fed itself would run away, and this is where that shows.
567 expect(fastest).toBeGreaterThan(9);
568 expect(fastest).toBeLessThan(40);
569 });
570});
571
572// ---------------------------------------------------------------------------
573
574/** A seat-0 view: pool up the screen, a wall across it, hand below. */
575function geometry(walls: Rect[]): TableGeometry {
576 return {
577 size: { w: 620, h: 620 },
578 pool: POOL,
579 walls: walls.map((rect) => ({ rect })),
580 barriers: walls.map((rect) => ({ rect })),
581 tile: TILE,
582 felt: { x: 120, y: 120, w: 380, h: 380 },
583 launch: { 0: { x: 310, y: 560 } },
584 throwFrom: { 0: { x: 310, y: 560 } },
585 };
586}
587
588/** The near side of the square, as 18 stacks. */
589const nearSide = (): Rect[] =>
590 Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 }));
591
592describe('a wall pushed across the table', () => {
593 // Its own patch of table, well clear of the pool the other tests share.
594 const BOUNDS: Rect = { x: 0, y: 0, w: 800, h: 800 };
595
596 it('shoves the tiles lying in front of it, even asleep ones', () => {
597 const wall = { rect: { x: 100, y: 380, w: 300, h: 40 } };
598 const world = createWorld(BOUNDS, [wall]);
599 const b = addBody(world, {
600 tile: 0, seat: 0, x: 300, y: 460, vx: 0, vy: 0, z: 0, vz: 0,
601 angle: 0, spin: 0, w: TILE.w, h: TILE.h, atRest: true,
602 });
603 // Let it go properly to sleep first — that is the case that used to fail.
604 let n = 0;
605 while (!settled(world) && n < 2000) { step(world); n++; }
606 expect(b.resting).toBe(true);
607 const before = b.y;
608
609 // Now push the wall down the table, over where it is lying.
610 reshape(world, BOUNDS, [{ rect: { x: 100, y: 430, w: 300, h: 40 } }]);
611 for (let i = 0; i < 400 && !settled(world); i++) step(world);
612
613 // Shoved along in front of it, not left standing inside it.
614 expect(b.y).toBeGreaterThan(before + 20);
615 });
616});
617
618describe('the throw gate', () => {
619 it('is shut while the wall in front of the seat is standing', () => {
620 expect(canThrow(geometry(nearSide()), 0)).toBe(false);
621 });
622
623 it('opens once a run of stacks has been drawn', () => {
624 const walls = nearSide().filter((_, i) => i < 6 || i > 11);
625 expect(canThrow(geometry(walls), 0)).toBe(true);
626 });
627
628 it('opens on a gap off to one side, not just straight ahead', () => {
629 // A gap left of centre, with everything dead ahead still standing: the only
630 // way in is at an angle, towards the near corner of the pool.
631 const walls = nearSide().filter((_, i) => i < 2 || i > 7);
632 expect(canThrow(geometry(walls), 0)).toBe(true);
633 });
634
635 it('stays shut for a gap too far round to reach the pool in a line', () => {
636 // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile
637 // aimed through it lands beside the square, not in it. Openness is about
638 // getting in, so this is genuinely still shut.
639 const walls = nearSide().filter((_, i) => i > 3);
640 expect(canThrow(geometry(walls), 0)).toBe(false);
641 });
642
643 it('is shut for a seat with no hand on screen', () => {
644 expect(canThrow(geometry([]), 2)).toBe(false);
645 });
646
647 it('is open when the square has been eaten away entirely', () => {
648 expect(canThrow(geometry([]), 0)).toBe(true);
649 });
650});
651
652describe('segmentHitsRect', () => {
653 const r: Rect = { x: 10, y: 10, w: 20, h: 20 };
654
655 it('finds a crossing', () => {
656 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true);
657 });
658 it('misses a segment that passes by', () => {
659 expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false);
660 });
661 it('misses a segment that stops short', () => {
662 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false);
663 });
664 it('finds a diagonal through a corner', () => {
665 expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true);
666 });
667});
668
669// ---------------------------------------------------------------------------
670
671describe('wallStacks', () => {
672 const at = (drawnFront: number, drawnBack = 0) =>
673 wallStacks({ drawnFront, drawnBack, dealer: 0, dice: [1, 1, 1], rules: { wallReserve: 16 } });
674
675 it('starts as 72 full stacks', () => {
676 const s = at(0);
677 expect(s).toHaveLength(72);
678 expect(s.every((x) => x.count === 2)).toBe(true);
679 expect(s[0].next).toBe(true);
680 });
681
682 it('eats the square from the front, a stack at a time', () => {
683 // The deal takes 64 tiles plus the dealer's opener.
684 const s = at(65);
685 expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true);
686 expect(s[32].count).toBe(1);
687 expect(s[33].count).toBe(2);
688 expect(s[32].next).toBe(true);
689 });
690
691 it('eats it from the back too, for kong and flower replacements', () => {
692 const s = at(0, 3);
693 expect(s[71].count).toBe(0);
694 expect(s[70].count).toBe(1);
695 });
696
697 it('marks the 16-tile 底牌 tail dead', () => {
698 const s = at(0);
699 expect(s[71].dead).toBe(true);
700 expect(s[64].dead).toBe(true);
701 expect(s[63].dead).toBe(false);
702 });
703
704 it('counts only a full stack as something to throw over', () => {
705 // The rule both halves of the throw hang off: a stack of two is as tall as
706 // the tile coming at it, one of one is not, and a spent one is not there.
707 const s = at(65);
708 expect(isBarrier(s[33])).toBe(true); // two left
709 expect(isBarrier(s[32])).toBe(false); // one left — sail over it
710 expect(isBarrier(s[0])).toBe(false); // gone
711 });
712
713 it('gives each seat the side of the square in front of it', () => {
714 // Seats run bottom, right, top, left; sides are drawn top, right, bottom,
715 // left — so the two orders are genuinely different and worth pinning.
716 expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2);
717 expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0);
718 const s = at(65);
719 // At the deal the top side is gone, so the seat across the table is open
720 // while the near seat's own wall has not been touched.
721 expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true);
722 expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true);
723 expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE);
724 });
725});
726
727describe('the square is the whole wall', () => {
728 // A stack is a tile wide and 1.375 tiles deep, whatever a tile comes out as.
729 const RATIO = 1.375;
730 const SIDE = STACKS_PER_SIDE;
731
732 it('is always eighteen stacks of two a side, and so a hundred and forty-four tiles', () => {
733 expect(RING).toEqual({ h: SIDE, v: SIDE });
734 expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS);
735 expect(2 * (RING.h + RING.v) * 2).toBe(144);
736 });
737
738 it('is built from the tile it was given, and nothing else decides it', () => {
739 const s = wallSquare(40, RATIO);
740 // The tile is the given. Eighteen stacks a side, a stack deep, and the
741 // whole thing is a wall's length plus one depth across.
742 expect(s.cell).toBe(40);
743 expect(s.len).toBeCloseTo(40 * SIDE, 6);
744 expect(s.thick).toBeCloseTo(40 * RATIO, 6);
745 expect(s.outer).toBeCloseTo(40 * (SIDE + RATIO), 6);
746 });
747
748 it('closes every corner with the overhang, and leaves the rest as the opening', () => {
749 const s = wallSquare(33, RATIO);
750 // A wall runs its whole length from the corner it is built from, which
751 // carries it one depth past the far one and over the end of the next.
752 expect(s.outer - s.open).toBeCloseTo(2 * s.thick, 6);
753 expect(s.open).toBeCloseTo(s.len - s.thick, 6);
754 // Four walls, each covering exactly one corner: the sum of what they cover
755 // is the outer square less the opening.
756 expect(s.outer * s.outer - s.open * s.open).toBeCloseTo(4 * s.len * s.thick, 6);
757 });
758
759 it('scales cleanly, so a bigger tile is the same square drawn bigger', () => {
760 const one = wallSquare(10, RATIO);
761 const ten = wallSquare(100, RATIO);
762 expect(ten.len).toBeCloseTo(one.len * 10, 6);
763 expect(ten.open).toBeCloseTo(one.open * 10, 6);
764 expect(ten.outer).toBeCloseTo(one.outer * 10, 6);
765 });
766});
767
768describe('ringLayout', () => {
769 const at = (drawnFront: number, drawnBack = 0) =>
770 wallStacks({ drawnFront, drawnBack, dealer: 0, dice: [1, 1, 1], rules: { wallReserve: 16 } });
771 const flat = (sides: Placed[][]) => sides.flat();
772 const standing = (sides: Placed[][]) => flat(sides).filter((p) => p.stack.count > 0);
773 // A middle much wider than it is deep, which is what an ordinary window has.
774 const wide = { h: 30, v: 10 };
775
776 it('keeps every place in the square, standing or spent', () => {
777 const sides = ringLayout(at(65), RING);
778 const placed = flat(sides);
779 expect(placed).toHaveLength(WALL_STACKS);
780 // In order all the way round from the break: the first drawn first, the
781 // 底牌 tail last.
782 expect(placed.map((p) => p.index)).toEqual(placed.map((_, i) => i));
783 expect(standing(sides)).toHaveLength(40);
784 expect(standing(sides)[0].stack.next).toBe(true);
785 expect(standing(sides)[39].index).toBe(71);
786 });
787
788 it('fills the square it was built for, then is eaten from the break point', () => {
789 // Built to the wall as dealt, so at the deal it comes out full and square.
790 const square = { h: 10, v: 10 };
791 const perSide = (sides: Placed[][]) =>
792 sides.map((s) => s.filter((p) => p.stack.count > 0).length);
793 expect(perSide(ringLayout(at(64), square))).toEqual([10, 10, 10, 10]);
794 // From there the gap opens where the drawing is happening and nothing else
795 // moves: the far end stays pinned to the end of the square.
796 expect(perSide(ringLayout(at(78), square))).toEqual([3, 10, 10, 10]);
797 // And the other end shortens when a kong or a flower takes from the tail.
798 expect(perSide(ringLayout(at(64, 6), square))).toEqual([10, 10, 10, 7]);
799 });
800
801 it('never puts more on a side than fits, however little ring there is', () => {
802 for (const cap of [
803 { h: 30, v: 10 },
804 { h: 18, v: 18 },
805 { h: 6, v: 2 },
806 { h: 1, v: 1 },
807 ]) {
808 for (const front of [0, 1, 65, 100, 128]) {
809 const sides = ringLayout(at(front), cap);
810 const lengths = [cap.h, cap.v, cap.h, cap.v];
811 sides.forEach((side, i) => expect(side.length).toBeLessThanOrEqual(lengths[i]));
812 const live = at(front).filter((s) => s.count > 0).length;
813 expect(standing(sides).length).toBeLessThanOrEqual(Math.min(live, 2 * (cap.h + cap.v)));
814 }
815 }
816 });
817
818 it('keeps the far end of the wall when there is not room for all of it', () => {
819 // Overflow only happens before a hand is dealt. What has to go is the front,
820 // since that is the part about to be drawn anyway.
821 const placed = flat(ringLayout(at(0), { h: 6, v: 2 }));
822 expect(placed).toHaveLength(16);
823 expect(placed[placed.length - 1].index).toBe(71);
824 expect(placed[0].index).toBe(56);
825 });
826
827 it('a wide middle still lays every one of its places out in order', () => {
828 const placed = flat(ringLayout(at(0), wide));
829 const first = placed[0].index;
830 expect(placed.map((p) => p.index)).toEqual(placed.map((_, i) => first + i));
831 });
832});
833
834describe('擲骰 — where the dice say to break the wall', () => {
835 const fresh = (dealer: SeatId, dice: number[]) =>
836 ringLayout(
837 wallStacks({ drawnFront: 0, drawnBack: 0, dealer, dice, rules: { wallReserve: 16 } }),
838 RING,
839 breakAt(dealer, dice),
840 ).flat();
841 /** Three dice that come to `n`, for every total three of them can make. */
842 const totalling = (n: number) => [n - 2, 1, 1];
843
844 it('is three dice, and the same seed throws them the same way', () => {
845 const one = rollDice(makeRng(7));
846 expect(one).toHaveLength(3);
847 expect(one.every((d) => d >= 1 && d <= 6)).toBe(true);
848 expect(rollDice(makeRng(7))).toEqual(one);
849 expect(rollDice(makeRng(8))).not.toEqual([0, 0, 0]);
850 });
851
852 it('opens the wall of the seat the total counts to, the dealer being one', () => {
853 for (const dealer of [0, 1, 2, 3] as SeatId[]) {
854 for (let total = 3; total <= 18; total++) {
855 const run = fresh(dealer, totalling(total));
856 // Where the first tile drawn is standing, as a place in the square.
857 const head = run.findIndex((p) => p.index === 0);
858 // The count goes round the table the way the turn does, and every wall
859 // is laid down from its own player's right-hand corner — so the break
860 // is that many places into the side the dice picked.
861 const whose = ((dealer + total - 1) % 4) as SeatId;
862 expect(head).toBe((SEAT_WALL_SIDE[whose] * STACKS_PER_SIDE + total) % WALL_STACKS);
863 }
864 }
865 });
866
867 it('walks the break all the way round the square, and never off it', () => {
868 const seen = new Set<number>();
869 for (const dealer of [0, 1, 2, 3] as SeatId[])
870 for (let total = 3; total <= 18; total++) {
871 const head = fresh(dealer, totalling(total)).findIndex((p) => p.index === 0);
872 expect(head).toBeGreaterThanOrEqual(0);
873 expect(head).toBeLessThan(WALL_STACKS);
874 seen.add(head);
875 }
876 // Four seats' worth of walls, sixteen totals: every side gets opened.
877 expect(seen.size).toBeGreaterThan(STACKS_PER_SIDE);
878 expect(new Set([...seen].map((p) => Math.floor(p / STACKS_PER_SIDE))).size).toBe(4);
879 });
880
881 it('leaves the 底牌 tail standing just behind the break, wherever it is', () => {
882 for (const total of [3, 7, 12, 18]) {
883 const run = fresh(2, totalling(total));
884 const head = run.findIndex((p) => p.index === 0);
885 expect(run[(head + WALL_STACKS - 1) % WALL_STACKS].index).toBe(71);
886 }
887 });
888
889 it('moves the gap and nothing else: the same wall, drawn in the same order', () => {
890 const order = (dice: number[]) =>
891 fresh(0, dice)
892 .slice()
893 .sort((a, b) => a.index - b.index)
894 .map((p) => p.index);
895 expect(order(totalling(3))).toEqual(order(totalling(11)));
896 });
897});