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