anvilsign in

collin/mahjong

1import { describe, expect, it } from 'vitest';
2import { makeRng, STACKS_PER_SIDE, WALL_STACKS } from '../game/tiles';
3import {
4 isBarrier,
5 rhombus,
6 RING,
7 ringLayout,
8 SEAT_WALL_SIDE,
9 sideStacks,
10 wallStacks,
11 type Placed,
12} from '../game/wall';
13import type { SeatId } from '../game/types';
14import {
15 addBody,
16 createWorld,
17 faceOn,
18 FIXED_DT,
19 liftFor,
20 outside,
21 overlapping,
22 settled,
23 step,
24 type Rect,
25 type Spawn,
26} from './physics';
27import { canThrow, segmentHitsRect, type TableGeometry } from './geometry';
28
29const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 };
30/** Same aspect as a real tile, roughly pool-tile sized. */
31const TILE = { w: 26, h: 36 };
32/** Generous: moving, and then untangling itself, is done well inside this. */
33const SETTLE_CAP = 700;
34
35function throwAt(rng: () => number): Spawn {
36 const centre = { x: POOL.x + POOL.w / 2, y: POOL.y + POOL.h / 2 };
37 // From below the pool, as seat 0 would, but aimed all over the place and
38 // hard enough to be a genuine test of the bounds.
39 const from = { x: 100 + rng() * 400, y: 560 };
40 const spread = (rng() - 0.5) * 260;
41 const dx = centre.x + spread - from.x;
42 const dy = centre.y - from.y;
43 const d = Math.hypot(dx, dy);
44 const speed = 900 + rng() * 1800;
45 return {
46 tile: Math.floor(rng() * 34),
47 seat: 0,
48 x: from.x,
49 y: from.y,
50 vx: (dx / d) * speed,
51 vy: (dy / d) * speed,
52 z: 12,
53 vz: 300 + rng() * 500,
54 angle: rng() * Math.PI * 2,
55 spin: (rng() - 0.5) * 14,
56 w: TILE.w,
57 h: TILE.h,
58 };
59}
60
61function runToRest(world: ReturnType<typeof createWorld>, cap = SETTLE_CAP): number {
62 let steps = 0;
63 while (!settled(world) && steps < cap) {
64 step(world);
65 steps++;
66 }
67 return steps;
68}
69
70describe('physics — the pile', () => {
71 it('never lets a tile out of the pool, however hard it is thrown', () => {
72 const rng = makeRng(20260819);
73 for (let trial = 0; trial < 40; trial++) {
74 const world = createWorld(POOL);
75 // A full hand's worth, thrown one after another into the same pool.
76 for (let i = 0; i < 16; i++) {
77 addBody(world, throwAt(rng));
78 for (let s = 0; s < 40; s++) step(world);
79 }
80 runToRest(world);
81 for (const b of world.bodies) {
82 expect(outside(b, POOL)).toBeLessThan(0.5);
83 expect(b.z).toBe(0);
84 }
85 }
86 });
87
88 it('settles everything, rather than jittering forever', () => {
89 const rng = makeRng(7);
90 const world = createWorld(POOL);
91 for (let i = 0; i < 30; i++) addBody(world, throwAt(rng));
92 const steps = runToRest(world);
93 expect(steps).toBeLessThan(SETTLE_CAP);
94 expect(settled(world)).toBe(true);
95 });
96
97 it('never lets two tiles share the same ground', () => {
98 // A whole hand's worth thrown into one pool, then checked pair by pair as
99 // rotated rectangles — not as circles, which would let two tiles at an angle
100 // sit across each other and call it clear.
101 const rng = makeRng(4242);
102 const world = createWorld(POOL);
103 for (let i = 0; i < 24; i++) {
104 addBody(world, throwAt(rng));
105 for (let s = 0; s < 45; s++) step(world);
106 }
107 runToRest(world);
108
109 for (let i = 0; i < world.bodies.length; i++) {
110 for (let j = i + 1; j < world.bodies.length; j++) {
111 // Sub-pixel contact is how a pile rests against itself; see CONTACT_SLOP.
112 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
113 }
114 }
115 });
116
117 it('carries a hard throw much further than a soft one', () => {
118 // The whole point of throwing it yourself: how hard you flick has to show.
119 // Friction heavy enough to eat the difference would make every discard land
120 // in the same place however it was let go of.
121 // Ground covered, not distance from where it started: a hard throw crosses
122 // the square and comes back off the far side, which is the point of it.
123 const travel = (speed: number) => {
124 const world = createWorld(POOL);
125 const b = addBody(world, {
126 tile: 0,
127 seat: 0,
128 x: POOL.x + 20,
129 y: POOL.y + POOL.h / 2,
130 vx: speed,
131 vy: 0,
132 z: 0,
133 vz: 0,
134 angle: 0,
135 spin: 0,
136 w: TILE.w,
137 h: TILE.h,
138 });
139 let covered = 0;
140 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
141 const x = b.x;
142 const y = b.y;
143 step(world);
144 covered += Math.hypot(b.x - x, b.y - y);
145 }
146 return covered;
147 };
148
149 const soft = travel(220);
150 const hard = travel(2200);
151 expect(hard).toBeGreaterThan(soft * 3);
152 // And a hard one gets right across the square rather than dying halfway.
153 expect(hard).toBeGreaterThan(POOL.w * 0.55);
154 });
155
156 it('bounces a hard throw off the far side instead of sticking to it', () => {
157 const world = createWorld(POOL);
158 const b = addBody(world, {
159 tile: 0,
160 seat: 0,
161 x: POOL.x + 20,
162 y: POOL.y + POOL.h / 2,
163 vx: 2600,
164 vy: 0,
165 z: 0,
166 vz: 0,
167 angle: 0,
168 spin: 0,
169 w: TILE.w,
170 h: TILE.h,
171 });
172 // Run until it has been turned around by the far wall.
173 let bounced = false;
174 for (let i = 0; i < SETTLE_CAP && !bounced; i++) {
175 step(world);
176 if (b.vx < -40) bounced = true;
177 }
178 expect(bounced).toBe(true);
179 runToRest(world);
180 expect(outside(b, POOL)).toBeLessThan(0.5);
181 });
182
183 it('does not jerk a tile inwards at the moment it crosses onto the felt', () => {
184 // Coming in from a hand, the tile is outside the bounds and passes through
185 // them — that is what lets it be thrown in at all. But the edge is only a
186 // fence once it is in, and which step that happens on must not be visible:
187 // a tile that gets snapped half its own length up the table, and loses half
188 // its speed doing it, has hit something that is not drawn anywhere.
189 const world = createWorld(POOL);
190 const b = addBody(world, {
191 tile: 0,
192 seat: 0,
193 // From below, as seat 0 throws, low enough to be inside the wall's height
194 // the whole way — a skid rather than a lob.
195 x: POOL.x + POOL.w / 2,
196 y: POOL.y + POOL.h + 120,
197 vx: 0,
198 vy: -1600,
199 z: 0,
200 vz: liftFor(0.18),
201 angle: 0,
202 spin: 0,
203 w: TILE.w,
204 h: TILE.h,
205 });
206
207 // The furthest it moves in any one step, against the furthest it should:
208 // a step of travel, and nothing else.
209 const perStep = 1600 * FIXED_DT;
210 let jump = 0;
211 let lost = 0;
212 let prev = { x: b.x, y: b.y, v: Math.hypot(b.vx, b.vy) };
213 for (let i = 0; i < 60; i++) {
214 step(world);
215 const v = Math.hypot(b.vx, b.vy);
216 jump = Math.max(jump, Math.hypot(b.x - prev.x, b.y - prev.y));
217 // Only while it is still crossing in — the far side is meant to stop it.
218 if (b.y > POOL.y + TILE.h) lost = Math.max(lost, prev.v - v);
219 prev = { x: b.x, y: b.y, v };
220 }
221 expect(jump).toBeLessThan(perStep * 1.2);
222 expect(lost).toBeLessThan(60);
223 });
224
225 it('says which edge a tile was thrown into, and whose tile it was', () => {
226 // The middle stops where a player's tiles start, so an edge taken at speed
227 // is a discard thrown into somebody's hand — which is a thing they answer.
228 const world = createWorld(POOL);
229 addBody(world, {
230 tile: 0,
231 seat: 2,
232 x: POOL.x + POOL.w - 30,
233 y: POOL.y + POOL.h / 2,
234 vx: 2400,
235 vy: 0,
236 z: 0,
237 vz: 0,
238 angle: 0,
239 spin: 0,
240 w: TILE.w,
241 h: TILE.h,
242 });
243
244 const edges: string[] = [];
245 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
246 for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`);
247 }
248 expect(edges[0]).toBe('right:2');
249
250 // A tile that merely rolls into the edge counts too — arriving in
251 // somebody's tiles is arriving in them however gently it happened.
252 const gentle = createWorld(POOL);
253 addBody(gentle, {
254 tile: 0,
255 seat: 1,
256 x: POOL.x + POOL.w - 20,
257 y: POOL.y + POOL.h / 2,
258 vx: 90,
259 vy: 0,
260 z: 0,
261 vz: 0,
262 angle: 0,
263 spin: 0,
264 w: TILE.w,
265 h: TILE.h,
266 });
267 const rolled: string[] = [];
268 for (let i = 0; i < SETTLE_CAP && !settled(gentle); i++) {
269 for (const hit of step(gentle)) if (hit.edge) rolled.push(`${hit.edge}:${hit.seat}`);
270 }
271 expect(rolled[0]).toBe('right:1');
272
273 // But a tile lying against the edge is not arriving anywhere. It is held
274 // there every single step, and every one of those would be a complaint.
275 const held = createWorld(POOL);
276 const still = addBody(held, {
277 tile: 0,
278 seat: 1,
279 x: POOL.x + POOL.w,
280 y: POOL.y + POOL.h / 2,
281 vx: 0,
282 vy: 0,
283 z: 0,
284 vz: 0,
285 angle: 0,
286 spin: 0,
287 w: TILE.w,
288 h: TILE.h,
289 atRest: true,
290 });
291 still.resting = false;
292 const pinned = [];
293 for (let i = 0; i < 120; i++) for (const hit of step(held)) if (hit.edge) pinned.push(hit);
294 expect(pinned).toHaveLength(0);
295 });
296
297 it('says whose side a tile that landed short came in from', () => {
298 // Nothing was struck on the way — it simply fell short, out by somebody's
299 // seat, and came onto the table past them. Still theirs to complain about.
300 const world = createWorld(POOL);
301 addBody(world, {
302 tile: 0,
303 seat: 2,
304 x: POOL.x - 120,
305 y: POOL.y + POOL.h / 2,
306 vx: 0,
307 vy: 0,
308 z: 0,
309 vz: 0,
310 angle: 0,
311 spin: 0,
312 w: TILE.w,
313 h: TILE.h,
314 });
315
316 const edges: string[] = [];
317 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
318 for (const hit of step(world)) if (hit.edge) edges.push(`${hit.edge}:${hit.seat}`);
319 }
320 expect(edges[0]).toBe('left:2');
321 });
322
323 it('always comes to rest face up, however it tumbles', () => {
324 // A tile lying face down in the discards is a tile nobody can read, so this
325 // is a guarantee rather than something the animation happens to get right.
326 const rng = makeRng(31337);
327 const world = createWorld(POOL);
328 for (let i = 0; i < 20; i++) {
329 const spawn = throwAt(rng);
330 addBody(world, {
331 ...spawn,
332 // Every awkward number of turns, over every awkward flight time.
333 flipTurns: 1 + Math.floor(rng() * 3),
334 flipOver: 0.2 + rng() * 0.5,
335 flipAxis: rng() * Math.PI * 2,
336 });
337 for (let s = 0; s < 40; s++) step(world);
338 }
339 runToRest(world);
340
341 for (const b of world.bodies) {
342 expect(b.resting).toBe(true);
343 // Square on to the table, not edge on and not face down.
344 expect(faceOn(b)).toBeCloseTo(1, 6);
345 }
346 });
347
348 it('turns a lobbed tile over in the air and lands it flat', () => {
349 const world = createWorld(POOL);
350 const b = addBody(world, {
351 tile: 0,
352 seat: 0,
353 x: POOL.x + POOL.w / 2,
354 y: POOL.y + POOL.h + 90,
355 vx: 0,
356 vy: -320,
357 z: 0,
358 vz: liftFor(0.6),
359 angle: 0,
360 spin: 0,
361 flipTurns: 2,
362 flipOver: 0.6,
363 flipAxis: Math.PI / 2,
364 w: TILE.w,
365 h: TILE.h,
366 });
367
368 // Somewhere in the air it must actually show its back, or it never turned.
369 let showedBack = false;
370 for (let i = 0; i < SETTLE_CAP && !settled(world); i++) {
371 step(world);
372 if (faceOn(b) < -0.2) showedBack = true;
373 }
374 expect(showedBack).toBe(true);
375 expect(faceOn(b)).toBeCloseTo(1, 6);
376 });
377
378 it('is deterministic — the same throws give the same pile', () => {
379 const pile = () => {
380 const rng = makeRng(99);
381 const world = createWorld(POOL);
382 for (let i = 0; i < 12; i++) {
383 addBody(world, throwAt(rng));
384 for (let s = 0; s < 30; s++) step(world);
385 }
386 runToRest(world);
387 return world.bodies.map((b) => [b.x, b.y, b.angle]);
388 };
389 // This is what lets a refresh mid-hand come back to the pile it had.
390 expect(pile()).toEqual(pile());
391 });
392
393 it('gets a tile into the pool even when the wall is in the way', () => {
394 // A standing wall right across the throw, with the tile flicked flat at it:
395 // it cannot get in on its own, and must not be left outside.
396 const world = createWorld(POOL, [{ rect: { x: 150, y: 440, w: 320, h: 36 } }]);
397 addBody(world, {
398 tile: 0,
399 seat: 0,
400 x: 310,
401 y: 520,
402 vx: 0,
403 vy: -400,
404 z: 0,
405 vz: 0,
406 angle: 0,
407 spin: 0,
408 w: TILE.w,
409 h: TILE.h,
410 });
411 runToRest(world);
412 expect(settled(world)).toBe(true);
413 expect(outside(world.bodies[0], POOL)).toBeLessThan(0.5);
414 });
415
416 it('makes room for a tile dropped into a settled pile', () => {
417 // A rebuilt pile — every tile placed at rest on the same spot.
418 const world = createWorld(POOL);
419 const at = (n: number) =>
420 addBody(world, {
421 tile: n,
422 seat: 0,
423 x: 310,
424 y: 310,
425 vx: 0,
426 vy: 0,
427 z: 0,
428 vz: 0,
429 angle: 0,
430 spin: 0,
431 w: TILE.w,
432 h: TILE.h,
433 atRest: true,
434 });
435 for (let i = 0; i < 8; i++) at(i);
436
437 // Placing them is a first shove; the world untangles the rest without
438 // anything appearing to move, and is not settled until it has.
439 runToRest(world);
440 expect(settled(world)).toBe(true);
441 for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5);
442 // Nothing sharing ground with anything else.
443 for (let i = 0; i < world.bodies.length; i++) {
444 for (let j = i + 1; j < world.bodies.length; j++) {
445 expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1);
446 }
447 }
448 });
449});
450
451// ---------------------------------------------------------------------------
452
453/** A seat-0 view: pool up the screen, a wall across it, hand below. */
454function geometry(walls: Rect[]): TableGeometry {
455 return {
456 size: { w: 620, h: 620 },
457 pool: POOL,
458 walls: walls.map((rect) => ({ rect })),
459 barriers: walls.map((rect) => ({ rect })),
460 tile: TILE,
461 felt: { x: 120, y: 120, w: 380, h: 380 },
462 launch: { 0: { x: 310, y: 560 } },
463 throwFrom: { 0: { x: 310, y: 560 } },
464 };
465}
466
467/** The near side of the square, as 18 stacks. */
468const nearSide = (): Rect[] =>
469 Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 }));
470
471describe('the throw gate', () => {
472 it('is shut while the wall in front of the seat is standing', () => {
473 expect(canThrow(geometry(nearSide()), 0)).toBe(false);
474 });
475
476 it('opens once a run of stacks has been drawn', () => {
477 const walls = nearSide().filter((_, i) => i < 6 || i > 11);
478 expect(canThrow(geometry(walls), 0)).toBe(true);
479 });
480
481 it('opens on a gap off to one side, not just straight ahead', () => {
482 // A gap left of centre, with everything dead ahead still standing: the only
483 // way in is at an angle, towards the near corner of the pool.
484 const walls = nearSide().filter((_, i) => i < 2 || i > 7);
485 expect(canThrow(geometry(walls), 0)).toBe(true);
486 });
487
488 it('stays shut for a gap too far round to reach the pool in a line', () => {
489 // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile
490 // aimed through it lands beside the square, not in it. Openness is about
491 // getting in, so this is genuinely still shut.
492 const walls = nearSide().filter((_, i) => i > 3);
493 expect(canThrow(geometry(walls), 0)).toBe(false);
494 });
495
496 it('is shut for a seat with no hand on screen', () => {
497 expect(canThrow(geometry([]), 2)).toBe(false);
498 });
499
500 it('is open when the square has been eaten away entirely', () => {
501 expect(canThrow(geometry([]), 0)).toBe(true);
502 });
503});
504
505describe('segmentHitsRect', () => {
506 const r: Rect = { x: 10, y: 10, w: 20, h: 20 };
507
508 it('finds a crossing', () => {
509 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true);
510 });
511 it('misses a segment that passes by', () => {
512 expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false);
513 });
514 it('misses a segment that stops short', () => {
515 expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false);
516 });
517 it('finds a diagonal through a corner', () => {
518 expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true);
519 });
520});
521
522// ---------------------------------------------------------------------------
523
524describe('wallStacks', () => {
525 const at = (drawnFront: number, drawnBack = 0) =>
526 wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
527
528 it('starts as 72 full stacks', () => {
529 const s = at(0);
530 expect(s).toHaveLength(72);
531 expect(s.every((x) => x.count === 2)).toBe(true);
532 expect(s[0].next).toBe(true);
533 });
534
535 it('eats the square from the front, a stack at a time', () => {
536 // The deal takes 64 tiles plus the dealer's opener.
537 const s = at(65);
538 expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true);
539 expect(s[32].count).toBe(1);
540 expect(s[33].count).toBe(2);
541 expect(s[32].next).toBe(true);
542 });
543
544 it('eats it from the back too, for kong and flower replacements', () => {
545 const s = at(0, 3);
546 expect(s[71].count).toBe(0);
547 expect(s[70].count).toBe(1);
548 });
549
550 it('marks the 16-tile 底牌 tail dead', () => {
551 const s = at(0);
552 expect(s[71].dead).toBe(true);
553 expect(s[64].dead).toBe(true);
554 expect(s[63].dead).toBe(false);
555 });
556
557 it('counts only a full stack as something to throw over', () => {
558 // The rule both halves of the throw hang off: a stack of two is as tall as
559 // the tile coming at it, one of one is not, and a spent one is not there.
560 const s = at(65);
561 expect(isBarrier(s[33])).toBe(true); // two left
562 expect(isBarrier(s[32])).toBe(false); // one left — sail over it
563 expect(isBarrier(s[0])).toBe(false); // gone
564 });
565
566 it('gives each seat the side of the square in front of it', () => {
567 // Seats run bottom, right, top, left; sides are drawn top, right, bottom,
568 // left — so the two orders are genuinely different and worth pinning.
569 expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2);
570 expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0);
571 const s = at(65);
572 // At the deal the top side is gone, so the seat across the table is open
573 // while the near seat's own wall has not been touched.
574 expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true);
575 expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true);
576 expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE);
577 });
578});
579
580describe('the square is the whole wall', () => {
581 // A stack is a tile wide and 1.375 tiles deep, whatever a tile comes out as.
582 const RATIO = 1.375;
583 const SIDE = STACKS_PER_SIDE;
584
585 /** Every corner of every wall, as the layout would lay them down. */
586 const walls = (r: ReturnType<typeof rhombus>, len: number, thick: number) =>
587 r.places.map((p) => {
588 const rad = (p.dir * Math.PI) / 180;
589 const ax = Math.cos(rad);
590 const ay = Math.sin(rad);
591 const nx = -Math.sin(rad);
592 const ny = Math.cos(rad);
593 return [
594 [0, 0],
595 [len, 0],
596 [len, thick],
597 [0, thick],
598 ].map(([l, t]) => ({ x: p.x + l * ax + t * nx, y: p.y + l * ay + t * ny }));
599 });
600
601 it('is always eighteen stacks of two a side, and so a hundred and forty-four tiles', () => {
602 expect(RING).toEqual({ h: SIDE, v: SIDE });
603 expect(2 * (RING.h + RING.v)).toBe(WALL_STACKS);
604 expect(2 * (RING.h + RING.v) * 2).toBe(144);
605 });
606
607 it('closes at every corner, at every angle, however much it laps', () => {
608 for (const angle of [90, 60, 120, 45, 135, 30, 150]) {
609 for (const lapping of [1, 1.5, 3]) {
610 const r = rhombus(SIDE, RATIO, angle, 0, lapping);
611 const quads = walls(r, SIDE, RATIO);
612 // Each wall's far end has to reach past where the next one starts,
613 // measured along the next wall's own direction: that overlap is the
614 // corner being covered, and it must never be negative.
615 for (let i = 0; i < 4; i++) {
616 const next = r.places[(i + 1) % 4];
617 const rad = (next.dir * Math.PI) / 180;
618 const along = (p: { x: number; y: number }) =>
619 (p.x - next.x) * Math.cos(rad) + (p.y - next.y) * Math.sin(rad);
620 // Zero is the tightest join there can be — the two exactly meeting,
621 // which is what the corner's own mitre comes to. Below zero is a gap.
622 const reach = Math.max(...quads[i].map(along));
623 expect(reach).toBeGreaterThanOrEqual(-1e-6);
624 }
625 }
626 }
627 });
628
629 it('is a rhombus: four equal sides, and the corner it was asked for', () => {
630 const r = rhombus(SIDE, RATIO, 60);
631 const at = (i: number) => r.places[i];
632 const turn = (i: number) => {
633 const d = ((at((i + 1) % 4).dir - at(i).dir) % 360 + 360) % 360;
634 return d;
635 };
636 // Turning by the corner and then by its supplement, twice, comes to a full
637 // circle — which is what makes it close.
638 expect(turn(0) + turn(1)).toBeCloseTo(180, 6);
639 expect(turn(0)).toBeCloseTo(60, 6);
640 });
641
642 it('is square at ninety, and laps by exactly one wall-depth there', () => {
643 const r = rhombus(SIDE, RATIO, 90);
644 // A square's corner needs t / tan(45) — one depth, which is what the
645 // hand-written layout used before any of this was worked out.
646 expect(r.open.w).toBeCloseTo(SIDE - RATIO, 6);
647 expect(r.open.h).toBeCloseTo(SIDE - RATIO, 6);
648 });
649
650 it('scales cleanly, so the tile can be solved for by asking once', () => {
651 const one = rhombus(SIDE, RATIO, 70);
652 const ten = rhombus(SIDE * 10, RATIO * 10, 70);
653 expect(ten.box.w).toBeCloseTo(one.box.w * 10, 6);
654 expect(ten.box.h).toBeCloseTo(one.box.h * 10, 6);
655 });
656});
657
658describe('ringLayout', () => {
659 const at = (drawnFront: number, drawnBack = 0) =>
660 wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } });
661 const flat = (sides: Placed[][]) => sides.flat();
662 // A middle much wider than it is deep, which is what an ordinary window has.
663 const wide = { h: 30, v: 10 };
664
665 it('draws what is still standing and nothing else', () => {
666 const sides = ringLayout(at(65), wide);
667 const placed = flat(sides);
668 expect(placed).toHaveLength(40);
669 expect(placed.every((p) => p.stack.count > 0)).toBe(true);
670 // In order all the way round: the break point first, the 底牌 tail last.
671 expect(placed[0].index).toBe(32);
672 expect(placed[0].stack.next).toBe(true);
673 expect(placed[placed.length - 1].index).toBe(71);
674 expect(placed.every((p, i) => i === 0 || p.index > placed[i - 1].index)).toBe(true);
675 });
676
677 it('fills the square it was built for, then is eaten from the break point', () => {
678 // Built to the wall as dealt, so at the deal it comes out full and square.
679 const square = { h: 10, v: 10 };
680 expect(ringLayout(at(64), square).map((s) => s.length)).toEqual([10, 10, 10, 10]);
681 // From there the gap opens where the drawing is happening and nothing else
682 // moves: the far end stays pinned to the end of the square.
683 const eaten = ringLayout(at(78), square);
684 expect(eaten.map((s) => s.length)).toEqual([3, 10, 10, 10]);
685 expect(eaten[0][0].stack.next).toBe(true);
686 // And the other end shortens when a kong or a flower takes from the tail.
687 expect(ringLayout(at(64, 6), square).map((s) => s.length)).toEqual([10, 10, 10, 7]);
688 });
689
690 it('never puts more on a side than fits, however little ring there is', () => {
691 for (const cap of [
692 { h: 30, v: 10 },
693 { h: 18, v: 18 },
694 { h: 6, v: 2 },
695 { h: 1, v: 1 },
696 ]) {
697 for (const front of [0, 1, 65, 100, 128]) {
698 const sides = ringLayout(at(front), cap);
699 const lengths = [cap.h, cap.v, cap.h, cap.v];
700 sides.forEach((side, i) => expect(side.length).toBeLessThanOrEqual(lengths[i]));
701 const live = at(front).filter((s) => s.count > 0).length;
702 expect(flat(sides)).toHaveLength(Math.min(live, 2 * (cap.h + cap.v)));
703 }
704 }
705 });
706
707 it('keeps the far end of the wall when there is not room for all of it', () => {
708 // Overflow only happens before a hand is dealt. What has to go is the front,
709 // since that is the part about to be drawn anyway.
710 const sides = ringLayout(at(0), { h: 6, v: 2 });
711 const placed = flat(sides);
712 expect(placed).toHaveLength(16);
713 expect(placed[placed.length - 1].index).toBe(71);
714 expect(placed[0].index).toBe(56);
715 });
716});