anvilsign in

collin/mahjong

1import { STACKS_PER_SIDE, WALL_SIZE, WALL_STACKS } from './tiles';
2import type { SeatId } from './types';
3
4/**
5 * What the wall square looks like right now.
6 *
7 * The square is eaten from both ends at once — normal draws come off the front,
8 * kong and flower replacements off the back — so how much of it is left is not
9 * one number but a per-stack question, and two things want the answer: the
10 * drawing of it (ui/WallRing) and the physics (table/geometry), since a stack
11 * still standing is something a thrown tile has to get past.
12 *
13 * Deliberately typed against the fields it reads rather than `GameState`, so it
14 * stays a function of the wall and nothing else.
15 */
16export interface WallProgress {
17 drawnFront: number;
18 drawnBack: number;
19 rules: { wallReserve: number };
20}
21
22export interface Stack {
23 /** Tiles still in this stack: 2 full, 1 half, 0 spent. */
24 count: 0 | 1 | 2;
25 /** Part of the 16-tile 底牌 tail that ends the hand. */
26 dead: boolean;
27 /** The break point — the stack the next draw comes off. */
28 next: boolean;
29}
30
31/**
32 * Position 0 is the break point. Stack `i` holds positions `2i` and `2i+1`, and
33 * a position is still there if it is past the front and short of the back.
34 */
35export function wallStacks(s: WallProgress): Stack[] {
36 const front = s.drawnFront;
37 const back = WALL_SIZE - s.drawnBack;
38 const deadFrom = back - s.rules.wallReserve;
39
40 return Array.from({ length: WALL_STACKS }, (_, i) => {
41 const a = i * 2;
42 const b = a + 1;
43 const live = (p: number) => p >= front && p < back;
44 const count = ((live(a) ? 1 : 0) + (live(b) ? 1 : 0)) as 0 | 1 | 2;
45 return { count, dead: b >= deadFrom, next: front === a || front === b };
46 });
47}
48
49/**
50 * Whether a stack is something a thrown tile has to get past.
51 *
52 * A stack of two stands as tall as the tile being thrown at it. One of one is
53 * low enough to sail over, and a spent one is not there at all — so only a full
54 * stack is a barrier. This is the rule behind both halves of the throw: the
55 * colliders the pile bounces off, and whether a seat may flick at all.
56 */
57export const isBarrier = (s: Stack) => s.count === 2;
58
59/** The four sides in the order WallRing lays them out, clockwise from the top. */
60export const WALL_SIDES = ['top', 'right', 'bottom', 'left'] as const;
61export type WallSide = (typeof WALL_SIDES)[number];
62
63/**
64 * Which side of the square sits in front of each seat — the one a seat has to
65 * throw over. Seats run bottom, right, top, left (see ui/rotation.ts) and the
66 * sides are drawn top, right, bottom, left, so the two orders are not the same.
67 */
68export const SEAT_WALL_SIDE: Record<SeatId, number> = { 0: 2, 1: 1, 2: 0, 3: 3 };
69
70/** The stacks making up one side, in the order that side is drawn. */
71export function sideStacks(stacks: Stack[], side: number): Stack[] {
72 return stacks.slice(side * STACKS_PER_SIDE, (side + 1) * STACKS_PER_SIDE);
73}