anvilsign in

collin/mahjong

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/** A seat-0 view: pool up the screen, a wall across it, hand below. */
457function geometry(walls: Rect[]): TableGeometry {
458 return {
459 size: { w: 620, h: 620 },
460 pool: POOL,
461 walls: walls.map((rect) => ({ rect })),
462 barriers: walls.map((rect) => ({ rect })),
463 tile: TILE,
464 felt: { x: 120, y: 120, w: 380, h: 380 },
465 launch: { 0: { x: 310, y: 560 } },
466 throwFrom: { 0: { x: 310, y: 560 } },
467 };
468}
469
470/** The near side of the square, as 18 stacks. */
471const nearSide = (): Rect[] =>
472 Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 }));
473
474describe('a wall pushed across the table', () => {
475 // Its own patch of table, well clear of the pool the other tests share.
476 const BOUNDS: Rect = { x: 0, y: 0, w: 800, h: 800 };
477
478 it('shoves the tiles lying in front of it, even asleep ones', () => {
479 const wall = { rect: { x: 100, y: 380, w: 300, h: 40 } };
480 const world = createWorld(BOUNDS, [wall]);
481 const b = addBody(world, {
482 tile: 0, seat: 0, x: 300, y: 460, vx: 0, vy: 0, z: 0, vz: 0,
483 angle: 0, spin: 0, w: TILE.w, h: TILE.h, atRest: true,
484 });
485 // Let it go properly to sleep first — that is the case that used to fail.
486 let n = 0;
487 while (!settled(world) && n < 2000) { step(world); n++; }
488 expect(b.resting).toBe(true);
489 const before = b.y;
490
491 // Now push the wall down the table, over where it is lying.
492 reshape(world, BOUNDS, [{ rect: { x: 100, y: 430, w: 300, h: 40 } }]);
493 for (let i = 0; i < 400 && !settled(world); i++) step(world);
494
495 // Shoved along in front of it, not left standing inside it.
496 expect(b.y).toBeGreaterThan(before + 20);
497 });
498});
499
500describe('the throw gate', () => {
501 it('is shut while the wall in front of the seat is standing', () => {
502 expect(canThrow(geometry(nearSide()), 0)).toBe(false);
503 });
504
505 it('opens once a run of stacks has been drawn', () => {
506 const walls = nearSide().filter((_, i) => i < 6 || i > 11);
507 expect(canThrow(geometry(walls), 0)).toBe(true);
508 });
509
510 it('opens on a gap off to one side, not just straight ahead', () => {
511 // A gap left of centre, with everything dead ahead still standing: the only
512 // way in is at an angle, towards the near corner of the pool.
513 const walls = nearSide().filter((_, i) => i < 2 || i > 7);
514 expect(canThrow(geometry(walls), 0)).toBe(true);
515 });
516
517 it('stays shut for a gap too far round to reach the pool in a line', () => {
518 // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile
519 // aimed through it lands beside the square, not in it. Openness is about
520 // getting in, so this is genuinely still shut.
521 const walls = nearSide().filter((_, i) => i > 3);
522 expect(canThrow(geometry(walls), 0)).toBe(false);
523 });
524
525 it('is shut for a seat with no hand on screen', () => {
526 expect(canThrow(geometry([]), 2)).toBe(false);
527 });
528
529 it('is open when the square has been eaten away entirely', () => {
530 expect(canThrow(geometry([]), 0)).toBe(true);
531 });
532});
533
534describe('segmentHitsRect', () => {
535 const r: Rect = { x: 10, y: 10, w: 20, h: 20 };
536
537 it('finds a crossing', () => {
538 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true);
539 });
540 it('misses a segment that passes by', () => {
541 expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false);
542 });
543 it('misses a segment that stops short', () => {
544 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false);
545 });
546 it('finds a diagonal through a corner', () => {
547 expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true);
548 });
549});
550
551// ---------------------------------------------------------------------------
552
553describe('wallStacks', () => {
554 const at = (drawnFront: number, drawnBack = 0) =>
555 wallStacks({ drawnFront, drawnBack, dealer: 0, dice: [1, 1, 1], rules: { wallReserve: 16 } });
556
557 it('starts as 72 full stacks', () => {
558 const s = at(0);
559 expect(s).toHaveLength(72);
560 expect(s.every((x) => x.count === 2)).toBe(true);
561 expect(s[0].next).toBe(true);
562 });
563
564 it('eats the square from the front, a stack at a time', () => {
565 // The deal takes 64 tiles plus the dealer's opener.
566 const s = at(65);
567 expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true);
568 expect(s[32].count).toBe(1);
569 expect(s[33].count).toBe(2);
570 expect(s[32].next).toBe(true);
571 });
572
573 it('eats it from the back too, for kong and flower replacements', () => {
574 const s = at(0, 3);
575 expect(s[71].count).toBe(0);
576 expect(s[70].count).toBe(1);
577 });
578
579 it('marks the 16-tile 底牌 tail dead', () => {
580 const s = at(0);
581 expect(s[71].dead).toBe(true);
582 expect(s[64].dead).toBe(true);
583 expect(s[63].dead).toBe(false);
584 });
585
586 it('counts only a full stack as something to throw over', () => {
587 // The rule both halves of the throw hang off: a stack of two is as tall as
588 // the tile coming at it, one of one is not, and a spent one is not there.
589 const s = at(65);
590 expect(isBarrier(s[33])).toBe(true); // two left
591 expect(isBarrier(s[32])).toBe(false); // one left — sail over it
592 expect(isBarrier(s[0])).toBe(false); // gone
593 });
594
595 it('gives each seat the side of the square in front of it', () => {
596 // Seats run bottom, right, top, left; sides are drawn top, right, bottom,
597 // left — so the two orders are genuinely different and worth pinning.
598 expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2);
599 expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0);
600 const s = at(65);
601 // At the deal the top side is gone, so the seat across the table is open
602 // while the near seat's own wall has not been touched.
603 expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true);
604 expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true);
605 expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE);
606 });
607});
608
609describe('the square is the whole wall', () => {
610 // A stack is a tile wide and 1.375 tiles deep, whatever a tile comes out as.
611 const RATIO = 1.375;
612 const SIDE = STACKS_PER_SIDE;
613
614 it('is always eighteen stacks of two a side, and so a hundred and forty-four tiles', () => {
615 expect(RING).toEqual({ h: SIDE, v: SIDE });
616 expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS);
617 expect(2 * (RING.h + RING.v) * 2).toBe(144);
618 });
619
620 it('is built from the tile it was given, and nothing else decides it', () => {
621 const s = wallSquare(40, RATIO);
622 // The tile is the given. Eighteen stacks a side, a stack deep, and the
623 // whole thing is a wall's length plus one depth across.
624 expect(s.cell).toBe(40);
625 expect(s.len).toBeCloseTo(40 * SIDE, 6);
626 expect(s.thick).toBeCloseTo(40 * RATIO, 6);
627 expect(s.outer).toBeCloseTo(40 * (SIDE + RATIO), 6);
628 });
629
630 it('closes every corner with the overhang, and leaves the rest as the opening', () => {
631 const s = wallSquare(33, RATIO);
632 // A wall runs its whole length from the corner it is built from, which
633 // carries it one depth past the far one and over the end of the next.
634 expect(s.outer - s.open).toBeCloseTo(2 * s.thick, 6);
635 expect(s.open).toBeCloseTo(s.len - s.thick, 6);
636 // Four walls, each covering exactly one corner: the sum of what they cover
637 // is the outer square less the opening.
638 expect(s.outer * s.outer - s.open * s.open).toBeCloseTo(4 * s.len * s.thick, 6);
639 });
640
641 it('scales cleanly, so a bigger tile is the same square drawn bigger', () => {
642 const one = wallSquare(10, RATIO);
643 const ten = wallSquare(100, RATIO);
644 expect(ten.len).toBeCloseTo(one.len * 10, 6);
645 expect(ten.open).toBeCloseTo(one.open * 10, 6);
646 expect(ten.outer).toBeCloseTo(one.outer * 10, 6);
647 });
648});
649
650describe('ringLayout', () => {
651 const at = (drawnFront: number, drawnBack = 0) =>
652 wallStacks({ drawnFront, drawnBack, dealer: 0, dice: [1, 1, 1], rules: { wallReserve: 16 } });
653 const flat = (sides: Placed[][]) => sides.flat();
654 const standing = (sides: Placed[][]) => flat(sides).filter((p) => p.stack.count > 0);
655 // A middle much wider than it is deep, which is what an ordinary window has.
656 const wide = { h: 30, v: 10 };
657
658 it('keeps every place in the square, standing or spent', () => {
659 const sides = ringLayout(at(65), RING);
660 const placed = flat(sides);
661 expect(placed).toHaveLength(WALL_STACKS);
662 // In order all the way round from the break: the first drawn first, the
663 // 底牌 tail last.
664 expect(placed.map((p) => p.index)).toEqual(placed.map((_, i) => i));
665 expect(standing(sides)).toHaveLength(40);
666 expect(standing(sides)[0].stack.next).toBe(true);
667 expect(standing(sides)[39].index).toBe(71);
668 });
669
670 it('fills the square it was built for, then is eaten from the break point', () => {
671 // Built to the wall as dealt, so at the deal it comes out full and square.
672 const square = { h: 10, v: 10 };
673 const perSide = (sides: Placed[][]) =>
674 sides.map((s) => s.filter((p) => p.stack.count > 0).length);
675 expect(perSide(ringLayout(at(64), square))).toEqual([10, 10, 10, 10]);
676 // From there the gap opens where the drawing is happening and nothing else
677 // moves: the far end stays pinned to the end of the square.
678 expect(perSide(ringLayout(at(78), square))).toEqual([3, 10, 10, 10]);
679 // And the other end shortens when a kong or a flower takes from the tail.
680 expect(perSide(ringLayout(at(64, 6), square))).toEqual([10, 10, 10, 7]);
681 });
682
683 it('never puts more on a side than fits, however little ring there is', () => {
684 for (const cap of [
685 { h: 30, v: 10 },
686 { h: 18, v: 18 },
687 { h: 6, v: 2 },
688 { h: 1, v: 1 },
689 ]) {
690 for (const front of [0, 1, 65, 100, 128]) {
691 const sides = ringLayout(at(front), cap);
692 const lengths = [cap.h, cap.v, cap.h, cap.v];
693 sides.forEach((side, i) => expect(side.length).toBeLessThanOrEqual(lengths[i]));
694 const live = at(front).filter((s) => s.count > 0).length;
695 expect(standing(sides).length).toBeLessThanOrEqual(Math.min(live, 2 * (cap.h + cap.v)));
696 }
697 }
698 });
699
700 it('keeps the far end of the wall when there is not room for all of it', () => {
701 // Overflow only happens before a hand is dealt. What has to go is the front,
702 // since that is the part about to be drawn anyway.
703 const placed = flat(ringLayout(at(0), { h: 6, v: 2 }));
704 expect(placed).toHaveLength(16);
705 expect(placed[placed.length - 1].index).toBe(71);
706 expect(placed[0].index).toBe(56);
707 });
708
709 it('a wide middle still lays every one of its places out in order', () => {
710 const placed = flat(ringLayout(at(0), wide));
711 const first = placed[0].index;
712 expect(placed.map((p) => p.index)).toEqual(placed.map((_, i) => first + i));
713 });
714});
715
716describe('擲骰 — where the dice say to break the wall', () => {
717 const fresh = (dealer: SeatId, dice: number[]) =>
718 ringLayout(
719 wallStacks({ drawnFront: 0, drawnBack: 0, dealer, dice, rules: { wallReserve: 16 } }),
720 RING,
721 breakAt(dealer, dice),
722 ).flat();
723 /** Three dice that come to `n`, for every total three of them can make. */
724 const totalling = (n: number) => [n - 2, 1, 1];
725
726 it('is three dice, and the same seed throws them the same way', () => {
727 const one = rollDice(makeRng(7));
728 expect(one).toHaveLength(3);
729 expect(one.every((d) => d >= 1 && d <= 6)).toBe(true);
730 expect(rollDice(makeRng(7))).toEqual(one);
731 expect(rollDice(makeRng(8))).not.toEqual([0, 0, 0]);
732 });
733
734 it('opens the wall of the seat the total counts to, the dealer being one', () => {
735 for (const dealer of [0, 1, 2, 3] as SeatId[]) {
736 for (let total = 3; total <= 18; total++) {
737 const run = fresh(dealer, totalling(total));
738 // Where the first tile drawn is standing, as a place in the square.
739 const head = run.findIndex((p) => p.index === 0);
740 // The count goes round the table the way the turn does, and every wall
741 // is laid down from its own player's right-hand corner — so the break
742 // is that many places into the side the dice picked.
743 const whose = ((dealer + total - 1) % 4) as SeatId;
744 expect(head).toBe((SEAT_WALL_SIDE[whose] * STACKS_PER_SIDE + total) % WALL_STACKS);
745 }
746 }
747 });
748
749 it('walks the break all the way round the square, and never off it', () => {
750 const seen = new Set<number>();
751 for (const dealer of [0, 1, 2, 3] as SeatId[])
752 for (let total = 3; total <= 18; total++) {
753 const head = fresh(dealer, totalling(total)).findIndex((p) => p.index === 0);
754 expect(head).toBeGreaterThanOrEqual(0);
755 expect(head).toBeLessThan(WALL_STACKS);
756 seen.add(head);
757 }
758 // Four seats' worth of walls, sixteen totals: every side gets opened.
759 expect(seen.size).toBeGreaterThan(STACKS_PER_SIDE);
760 expect(new Set([...seen].map((p) => Math.floor(p / STACKS_PER_SIDE))).size).toBe(4);
761 });
762
763 it('leaves the 底牌 tail standing just behind the break, wherever it is', () => {
764 for (const total of [3, 7, 12, 18]) {
765 const run = fresh(2, totalling(total));
766 const head = run.findIndex((p) => p.index === 0);
767 expect(run[(head + WALL_STACKS - 1) % WALL_STACKS].index).toBe(71);
768 }
769 });
770
771 it('moves the gap and nothing else: the same wall, drawn in the same order', () => {
772 const order = (dice: number[]) =>
773 fresh(0, dice)
774 .slice()
775 .sort((a, b) => a.index - b.index)
776 .map((p) => p.index);
777 expect(order(totalling(3))).toEqual(order(totalling(11)));
778 });
779});