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