| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { makeRng, STACKS_PER_SIDE } from '../game/tiles'; |
| 3 | import { |
| 4 | DEALT_STACKS, |
| 5 | isBarrier, |
| 6 | ringFor, |
| 7 | ringLayout, |
| 8 | ringOpening, |
| 9 | SEAT_WALL_SIDE, |
| 10 | sideStacks, |
| 11 | wallStacks, |
| 12 | type Placed, |
| 13 | } from '../game/wall'; |
| 14 | import type { SeatId } from '../game/types'; |
| 15 | import { |
| 16 | addBody, |
| 17 | createWorld, |
| 18 | faceOn, |
| 19 | liftFor, |
| 20 | outside, |
| 21 | overlapping, |
| 22 | settled, |
| 23 | step, |
| 24 | type Rect, |
| 25 | type Spawn, |
| 26 | } from './physics'; |
| 27 | import { canThrow, segmentHitsRect, type TableGeometry } from './geometry'; |
| 28 | |
| 29 | const POOL: Rect = { x: 200, y: 200, w: 220, h: 220 }; |
| 30 | /** Same aspect as a real tile, roughly pool-tile sized. */ |
| 31 | const TILE = { w: 26, h: 36 }; |
| 32 | /** Generous: moving, and then untangling itself, is done well inside this. */ |
| 33 | const SETTLE_CAP = 700; |
| 34 | |
| 35 | function 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 | |
| 61 | function 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 | |
| 70 | describe('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('always comes to rest face up, however it tumbles', () => { |
| 184 | // A tile lying face down in the discards is a tile nobody can read, so this |
| 185 | // is a guarantee rather than something the animation happens to get right. |
| 186 | const rng = makeRng(31337); |
| 187 | const world = createWorld(POOL); |
| 188 | for (let i = 0; i < 20; i++) { |
| 189 | const spawn = throwAt(rng); |
| 190 | addBody(world, { |
| 191 | ...spawn, |
| 192 | // Every awkward number of turns, over every awkward flight time. |
| 193 | flipTurns: 1 + Math.floor(rng() * 3), |
| 194 | flipOver: 0.2 + rng() * 0.5, |
| 195 | flipAxis: rng() * Math.PI * 2, |
| 196 | }); |
| 197 | for (let s = 0; s < 40; s++) step(world); |
| 198 | } |
| 199 | runToRest(world); |
| 200 | |
| 201 | for (const b of world.bodies) { |
| 202 | expect(b.resting).toBe(true); |
| 203 | // Square on to the table, not edge on and not face down. |
| 204 | expect(faceOn(b)).toBeCloseTo(1, 6); |
| 205 | } |
| 206 | }); |
| 207 | |
| 208 | it('turns a lobbed tile over in the air and lands it flat', () => { |
| 209 | const world = createWorld(POOL); |
| 210 | const b = addBody(world, { |
| 211 | tile: 0, |
| 212 | seat: 0, |
| 213 | x: POOL.x + POOL.w / 2, |
| 214 | y: POOL.y + POOL.h + 90, |
| 215 | vx: 0, |
| 216 | vy: -320, |
| 217 | z: 0, |
| 218 | vz: liftFor(0.6), |
| 219 | angle: 0, |
| 220 | spin: 0, |
| 221 | flipTurns: 2, |
| 222 | flipOver: 0.6, |
| 223 | flipAxis: Math.PI / 2, |
| 224 | w: TILE.w, |
| 225 | h: TILE.h, |
| 226 | }); |
| 227 | |
| 228 | // Somewhere in the air it must actually show its back, or it never turned. |
| 229 | let showedBack = false; |
| 230 | for (let i = 0; i < SETTLE_CAP && !settled(world); i++) { |
| 231 | step(world); |
| 232 | if (faceOn(b) < -0.2) showedBack = true; |
| 233 | } |
| 234 | expect(showedBack).toBe(true); |
| 235 | expect(faceOn(b)).toBeCloseTo(1, 6); |
| 236 | }); |
| 237 | |
| 238 | it('is deterministic — the same throws give the same pile', () => { |
| 239 | const pile = () => { |
| 240 | const rng = makeRng(99); |
| 241 | const world = createWorld(POOL); |
| 242 | for (let i = 0; i < 12; i++) { |
| 243 | addBody(world, throwAt(rng)); |
| 244 | for (let s = 0; s < 30; s++) step(world); |
| 245 | } |
| 246 | runToRest(world); |
| 247 | return world.bodies.map((b) => [b.x, b.y, b.angle]); |
| 248 | }; |
| 249 | // This is what lets a refresh mid-hand come back to the pile it had. |
| 250 | expect(pile()).toEqual(pile()); |
| 251 | }); |
| 252 | |
| 253 | it('gets a tile into the pool even when the wall is in the way', () => { |
| 254 | // A standing wall right across the throw, with the tile flicked flat at it: |
| 255 | // it cannot get in on its own, and must not be left outside. |
| 256 | const world = createWorld(POOL, [{ x: 150, y: 440, w: 320, h: 36 }]); |
| 257 | addBody(world, { |
| 258 | tile: 0, |
| 259 | seat: 0, |
| 260 | x: 310, |
| 261 | y: 520, |
| 262 | vx: 0, |
| 263 | vy: -400, |
| 264 | z: 0, |
| 265 | vz: 0, |
| 266 | angle: 0, |
| 267 | spin: 0, |
| 268 | w: TILE.w, |
| 269 | h: TILE.h, |
| 270 | }); |
| 271 | runToRest(world); |
| 272 | expect(settled(world)).toBe(true); |
| 273 | expect(outside(world.bodies[0], POOL)).toBeLessThan(0.5); |
| 274 | }); |
| 275 | |
| 276 | it('makes room for a tile dropped into a settled pile', () => { |
| 277 | // A rebuilt pile — every tile placed at rest on the same spot. |
| 278 | const world = createWorld(POOL); |
| 279 | const at = (n: number) => |
| 280 | addBody(world, { |
| 281 | tile: n, |
| 282 | seat: 0, |
| 283 | x: 310, |
| 284 | y: 310, |
| 285 | vx: 0, |
| 286 | vy: 0, |
| 287 | z: 0, |
| 288 | vz: 0, |
| 289 | angle: 0, |
| 290 | spin: 0, |
| 291 | w: TILE.w, |
| 292 | h: TILE.h, |
| 293 | atRest: true, |
| 294 | }); |
| 295 | for (let i = 0; i < 8; i++) at(i); |
| 296 | |
| 297 | // Placing them is a first shove; the world untangles the rest without |
| 298 | // anything appearing to move, and is not settled until it has. |
| 299 | runToRest(world); |
| 300 | expect(settled(world)).toBe(true); |
| 301 | for (const b of world.bodies) expect(outside(b, POOL)).toBeLessThan(0.5); |
| 302 | // Nothing sharing ground with anything else. |
| 303 | for (let i = 0; i < world.bodies.length; i++) { |
| 304 | for (let j = i + 1; j < world.bodies.length; j++) { |
| 305 | expect(overlapping(world.bodies[i], world.bodies[j])).toBeLessThan(1); |
| 306 | } |
| 307 | } |
| 308 | }); |
| 309 | }); |
| 310 | |
| 311 | // --------------------------------------------------------------------------- |
| 312 | |
| 313 | /** A seat-0 view: pool up the screen, a wall across it, hand below. */ |
| 314 | function geometry(walls: Rect[]): TableGeometry { |
| 315 | return { |
| 316 | size: { w: 620, h: 620 }, |
| 317 | pool: POOL, |
| 318 | walls, |
| 319 | tile: TILE, |
| 320 | felt: { x: 120, y: 120, w: 380, h: 380 }, |
| 321 | launch: { 0: { x: 310, y: 560 } }, |
| 322 | throwFrom: { 0: { x: 310, y: 560 } }, |
| 323 | }; |
| 324 | } |
| 325 | |
| 326 | /** The near side of the square, as 18 stacks. */ |
| 327 | const nearSide = (): Rect[] => |
| 328 | Array.from({ length: 18 }, (_, i) => ({ x: 180 + i * 14, y: 440, w: 14, h: 36 })); |
| 329 | |
| 330 | describe('the throw gate', () => { |
| 331 | it('is shut while the wall in front of the seat is standing', () => { |
| 332 | expect(canThrow(geometry(nearSide()), 0)).toBe(false); |
| 333 | }); |
| 334 | |
| 335 | it('opens once a run of stacks has been drawn', () => { |
| 336 | const walls = nearSide().filter((_, i) => i < 6 || i > 11); |
| 337 | expect(canThrow(geometry(walls), 0)).toBe(true); |
| 338 | }); |
| 339 | |
| 340 | it('opens on a gap off to one side, not just straight ahead', () => { |
| 341 | // A gap left of centre, with everything dead ahead still standing: the only |
| 342 | // way in is at an angle, towards the near corner of the pool. |
| 343 | const walls = nearSide().filter((_, i) => i < 2 || i > 7); |
| 344 | expect(canThrow(geometry(walls), 0)).toBe(true); |
| 345 | }); |
| 346 | |
| 347 | it('stays shut for a gap too far round to reach the pool in a line', () => { |
| 348 | // Stacks 0-3 are drawn, but that gap sits off the end of the pool: a tile |
| 349 | // aimed through it lands beside the square, not in it. Openness is about |
| 350 | // getting in, so this is genuinely still shut. |
| 351 | const walls = nearSide().filter((_, i) => i > 3); |
| 352 | expect(canThrow(geometry(walls), 0)).toBe(false); |
| 353 | }); |
| 354 | |
| 355 | it('is shut for a seat with no hand on screen', () => { |
| 356 | expect(canThrow(geometry([]), 2)).toBe(false); |
| 357 | }); |
| 358 | |
| 359 | it('is open when the square has been eaten away entirely', () => { |
| 360 | expect(canThrow(geometry([]), 0)).toBe(true); |
| 361 | }); |
| 362 | }); |
| 363 | |
| 364 | describe('segmentHitsRect', () => { |
| 365 | const r: Rect = { x: 10, y: 10, w: 20, h: 20 }; |
| 366 | |
| 367 | it('finds a crossing', () => { |
| 368 | expect(segmentHitsRect({ x: 0, y: 20 }, { x: 40, y: 20 }, r)).toBe(true); |
| 369 | }); |
| 370 | it('misses a segment that passes by', () => { |
| 371 | expect(segmentHitsRect({ x: 0, y: 40 }, { x: 40, y: 40 }, r)).toBe(false); |
| 372 | }); |
| 373 | it('misses a segment that stops short', () => { |
| 374 | expect(segmentHitsRect({ x: 0, y: 20 }, { x: 5, y: 20 }, r)).toBe(false); |
| 375 | }); |
| 376 | it('finds a diagonal through a corner', () => { |
| 377 | expect(segmentHitsRect({ x: 0, y: 0 }, { x: 20, y: 20 }, r)).toBe(true); |
| 378 | }); |
| 379 | }); |
| 380 | |
| 381 | // --------------------------------------------------------------------------- |
| 382 | |
| 383 | describe('wallStacks', () => { |
| 384 | const at = (drawnFront: number, drawnBack = 0) => |
| 385 | wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } }); |
| 386 | |
| 387 | it('starts as 72 full stacks', () => { |
| 388 | const s = at(0); |
| 389 | expect(s).toHaveLength(72); |
| 390 | expect(s.every((x) => x.count === 2)).toBe(true); |
| 391 | expect(s[0].next).toBe(true); |
| 392 | }); |
| 393 | |
| 394 | it('eats the square from the front, a stack at a time', () => { |
| 395 | // The deal takes 64 tiles plus the dealer's opener. |
| 396 | const s = at(65); |
| 397 | expect(s.slice(0, 32).every((x) => x.count === 0)).toBe(true); |
| 398 | expect(s[32].count).toBe(1); |
| 399 | expect(s[33].count).toBe(2); |
| 400 | expect(s[32].next).toBe(true); |
| 401 | }); |
| 402 | |
| 403 | it('eats it from the back too, for kong and flower replacements', () => { |
| 404 | const s = at(0, 3); |
| 405 | expect(s[71].count).toBe(0); |
| 406 | expect(s[70].count).toBe(1); |
| 407 | }); |
| 408 | |
| 409 | it('marks the 16-tile 底牌 tail dead', () => { |
| 410 | const s = at(0); |
| 411 | expect(s[71].dead).toBe(true); |
| 412 | expect(s[64].dead).toBe(true); |
| 413 | expect(s[63].dead).toBe(false); |
| 414 | }); |
| 415 | |
| 416 | it('counts only a full stack as something to throw over', () => { |
| 417 | // The rule both halves of the throw hang off: a stack of two is as tall as |
| 418 | // the tile coming at it, one of one is not, and a spent one is not there. |
| 419 | const s = at(65); |
| 420 | expect(isBarrier(s[33])).toBe(true); // two left |
| 421 | expect(isBarrier(s[32])).toBe(false); // one left — sail over it |
| 422 | expect(isBarrier(s[0])).toBe(false); // gone |
| 423 | }); |
| 424 | |
| 425 | it('gives each seat the side of the square in front of it', () => { |
| 426 | // Seats run bottom, right, top, left; sides are drawn top, right, bottom, |
| 427 | // left — so the two orders are genuinely different and worth pinning. |
| 428 | expect(SEAT_WALL_SIDE[0 as SeatId]).toBe(2); |
| 429 | expect(SEAT_WALL_SIDE[2 as SeatId]).toBe(0); |
| 430 | const s = at(65); |
| 431 | // At the deal the top side is gone, so the seat across the table is open |
| 432 | // while the near seat's own wall has not been touched. |
| 433 | expect(sideStacks(s, SEAT_WALL_SIDE[2 as SeatId]).every((x) => x.count === 0)).toBe(true); |
| 434 | expect(sideStacks(s, SEAT_WALL_SIDE[0 as SeatId]).every((x) => x.count === 2)).toBe(true); |
| 435 | expect(sideStacks(s, 0)).toHaveLength(STACKS_PER_SIDE); |
| 436 | }); |
| 437 | }); |
| 438 | |
| 439 | describe('ringFor', () => { |
| 440 | // A tile as it comes out on an ordinary window, and the stack it makes. |
| 441 | const cell = { s: 31, d: 43 }; |
| 442 | const holds = (cap: { h: number; v: number }) => 2 * (cap.h + cap.v); |
| 443 | |
| 444 | it('builds a square the size of the wall as it is dealt', () => { |
| 445 | const cap = ringFor({ w: 990, h: 360 }, cell); |
| 446 | expect(cap).toEqual({ h: 10, v: 10 }); |
| 447 | expect(holds(cap)).toBe(DEALT_STACKS); |
| 448 | // And it leaves the middle of itself for the discards. |
| 449 | expect(ringOpening(cap, cell).w).toBe(10 * 31 - 43); |
| 450 | }); |
| 451 | |
| 452 | it('gives up being square before it gives up a tile', () => { |
| 453 | // A middle too shallow for ten stacks down its side: the wall gets longer |
| 454 | // rather than the tiles getting smaller. |
| 455 | const cap = ringFor({ w: 990, h: 220 }, cell); |
| 456 | expect(cap.v).toBeLessThan(10); |
| 457 | expect(cap.h).toBeGreaterThan(10); |
| 458 | expect(holds(cap)).toBeGreaterThanOrEqual(DEALT_STACKS); |
| 459 | }); |
| 460 | |
| 461 | it('never asks for more room than it has, however small the middle', () => { |
| 462 | for (const space of [{ w: 990, h: 360 }, { w: 400, h: 400 }, { w: 300, h: 140 }, { w: 60, h: 40 }]) { |
| 463 | const cap = ringFor(space, cell); |
| 464 | expect(cap.h).toBeGreaterThanOrEqual(1); |
| 465 | expect(cap.v).toBeGreaterThanOrEqual(1); |
| 466 | expect(cap.h * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.w, cell.s + cell.d)); |
| 467 | expect(cap.v * cell.s + cell.d).toBeLessThanOrEqual(Math.max(space.h, cell.s + cell.d)); |
| 468 | } |
| 469 | }); |
| 470 | }); |
| 471 | |
| 472 | describe('ringLayout', () => { |
| 473 | const at = (drawnFront: number, drawnBack = 0) => |
| 474 | wallStacks({ drawnFront, drawnBack, rules: { wallReserve: 16 } }); |
| 475 | const flat = (sides: Placed[][]) => sides.flat(); |
| 476 | // A middle much wider than it is deep, which is what an ordinary window has. |
| 477 | const wide = { h: 30, v: 10 }; |
| 478 | |
| 479 | it('draws what is still standing and nothing else', () => { |
| 480 | const sides = ringLayout(at(65), wide); |
| 481 | const placed = flat(sides); |
| 482 | expect(placed).toHaveLength(40); |
| 483 | expect(placed.every((p) => p.stack.count > 0)).toBe(true); |
| 484 | // In order all the way round: the break point first, the 底牌 tail last. |
| 485 | expect(placed[0].index).toBe(32); |
| 486 | expect(placed[0].stack.next).toBe(true); |
| 487 | expect(placed[placed.length - 1].index).toBe(71); |
| 488 | expect(placed.every((p, i) => i === 0 || p.index > placed[i - 1].index)).toBe(true); |
| 489 | }); |
| 490 | |
| 491 | it('fills the square it was built for, then is eaten from the break point', () => { |
| 492 | // Built to the wall as dealt, so at the deal it comes out full and square. |
| 493 | const square = { h: 10, v: 10 }; |
| 494 | expect(ringLayout(at(64), square).map((s) => s.length)).toEqual([10, 10, 10, 10]); |
| 495 | // From there the gap opens where the drawing is happening and nothing else |
| 496 | // moves: the far end stays pinned to the end of the square. |
| 497 | const eaten = ringLayout(at(78), square); |
| 498 | expect(eaten.map((s) => s.length)).toEqual([3, 10, 10, 10]); |
| 499 | expect(eaten[0][0].stack.next).toBe(true); |
| 500 | // And the other end shortens when a kong or a flower takes from the tail. |
| 501 | expect(ringLayout(at(64, 6), square).map((s) => s.length)).toEqual([10, 10, 10, 7]); |
| 502 | }); |
| 503 | |
| 504 | it('never puts more on a side than fits, however little ring there is', () => { |
| 505 | for (const cap of [ |
| 506 | { h: 30, v: 10 }, |
| 507 | { h: 18, v: 18 }, |
| 508 | { h: 6, v: 2 }, |
| 509 | { h: 1, v: 1 }, |
| 510 | ]) { |
| 511 | for (const front of [0, 1, 65, 100, 128]) { |
| 512 | const sides = ringLayout(at(front), cap); |
| 513 | const lengths = [cap.h, cap.v, cap.h, cap.v]; |
| 514 | sides.forEach((side, i) => expect(side.length).toBeLessThanOrEqual(lengths[i])); |
| 515 | const live = at(front).filter((s) => s.count > 0).length; |
| 516 | expect(flat(sides)).toHaveLength(Math.min(live, 2 * (cap.h + cap.v))); |
| 517 | } |
| 518 | } |
| 519 | }); |
| 520 | |
| 521 | it('keeps the far end of the wall when there is not room for all of it', () => { |
| 522 | // Overflow only happens before a hand is dealt. What has to go is the front, |
| 523 | // since that is the part about to be drawn anyway. |
| 524 | const sides = ringLayout(at(0), { h: 6, v: 2 }); |
| 525 | const placed = flat(sides); |
| 526 | expect(placed).toHaveLength(16); |
| 527 | expect(placed[placed.length - 1].index).toBe(71); |
| 528 | expect(placed[0].index).toBe(56); |
| 529 | }); |
| 530 | }); |