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 /** Who threw the dice, and what they came up. See `breakAt`. */
21 dealer: SeatId;
22 dice: number[];
23}
24
25export interface Stack {
26 /** Tiles still in this stack: 2 full, 1 half, 0 spent. */
27 count: 0 | 1 | 2;
28 /** Part of the 16-tile 底牌 tail that ends the hand. */
29 dead: boolean;
30 /** The break point — the stack the next draw comes off. */
31 next: boolean;
32}
33
34/**
35 * Position 0 is the break point. Stack `i` holds positions `2i` and `2i+1`, and
36 * a position is still there if it is past the front and short of the back.
37 */
38export function wallStacks(s: WallProgress): Stack[] {
39 const front = s.drawnFront;
40 const back = WALL_SIZE - s.drawnBack;
41 const deadFrom = back - s.rules.wallReserve;
42
43 return Array.from({ length: WALL_STACKS }, (_, i) => {
44 const a = i * 2;
45 const b = a + 1;
46 const live = (p: number) => p >= front && p < back;
47 const count = ((live(a) ? 1 : 0) + (live(b) ? 1 : 0)) as 0 | 1 | 2;
48 return { count, dead: b >= deadFrom, next: front === a || front === b };
49 });
50}
51
52/**
53 * Whether a stack is something a thrown tile has to get past.
54 *
55 * A stack of two stands as tall as the tile being thrown at it. One of one is
56 * low enough to sail over, and a spent one is not there at all — so only a full
57 * stack is a barrier. This is the rule behind both halves of the throw: the
58 * colliders the pile bounces off, and whether a seat may flick at all.
59 */
60export const isBarrier = (s: Stack) => s.count === 2;
61
62/** The four sides in the order WallRing lays them out, clockwise from the top. */
63export const WALL_SIDES = ['top', 'right', 'bottom', 'left'] as const;
64export type WallSide = (typeof WALL_SIDES)[number];
65
66/**
67 * Which side of the square sits in front of each seat — the one a seat has to
68 * throw over. Seats run bottom, right, top, left (see ui/rotation.ts) and the
69 * sides are drawn top, right, bottom, left, so the two orders are not the same.
70 */
71export const SEAT_WALL_SIDE: Record<SeatId, number> = { 0: 2, 1: 1, 2: 0, 3: 3 };
72
73/** The stacks making up one quarter of the square as it was built. */
74export function sideStacks(stacks: Stack[], side: number): Stack[] {
75 return stacks.slice(side * STACKS_PER_SIDE, (side + 1) * STACKS_PER_SIDE);
76}
77
78/** How many stacks a side of the square holds, once the middle is measured. */
79export interface RingCapacity {
80 /** Along the top and the bottom. */
81 h: number;
82 /** Down the left and the right. */
83 v: number;
84}
85
86/** Dealt out before anybody looks at the table: sixteen tiles, four ways. */
87const DEALT = 64;
88/**
89 * How much wall is left once a hand has been dealt. The square is built to the
90 * whole hundred and forty-four now rather than to this — see `RING` — so this
91 * is only a fact about the wall, and what the tests measure a fresh deal
92 * against.
93 */
94export const DEALT_STACKS = WALL_STACKS - DEALT / 2;
95
96/**
97 * The square is the whole wall: eighteen stacks of two a side, four sides, the
98 * hundred and forty-four tiles, exactly as it is built on a table.
99 *
100 * It used to be rebuilt to what was *left* after a hand had been dealt — barely
101 * half of it — because eighteen full-size stacks a side wants about 560px and no
102 * ordinary window has that between the top and bottom strips. That bought a
103 * square, at the price of it not being the wall: a side ran out of stacks before
104 * it reached its corner, so the four of them never met.
105 *
106 * What gives instead is the tile. A wall tile is not a hand tile — on a table it
107 * is the same tile, but on a screen the wall has to fit the middle and the hand
108 * has to be readable, and those are two different jobs. See `wallSquare`.
109 */
110export const RING: RingCapacity = { h: STACKS_PER_SIDE, v: STACKS_PER_SIDE };
111
112/** The square, in pixels, once the room it has to fit has been measured. */
113export interface WallSquare {
114 /** A stack's short side — the wall's own tile width. */
115 cell: number;
116 /** One wall, end to end: eighteen stacks. */
117 len: number;
118 /** How deep a wall stands, which is a stack's long side. */
119 thick: number;
120 /** The opening left in the middle: where the discards go. */
121 open: number;
122 /** What the whole thing takes up, walls included. */
123 outer: number;
124}
125
126/**
127 * How big to build the square, given the room across the table it has to fit.
128 *
129 * Four walls of eighteen stacks, laid down square and nothing turned. Each is
130 * pinned at the corner it is built from and runs its whole length from there,
131 * which carries it one wall-depth past the far corner and over the end of the
132 * next wall along — the same way round each time. That is the pinwheel, and it
133 * is why the corners are covered rather than mitred.
134 *
135 * So the whole thing measures a wall's length *plus* one depth across, and
136 * leaves that length *less* one depth in the middle. Solved the other way here:
137 * the room is what there is, and the tile is what gives.
138 */
139export function wallSquare(room: number, ratio: number): WallSquare {
140 const cell = Math.max(1, room / (STACKS_PER_SIDE + ratio));
141 const len = cell * STACKS_PER_SIDE;
142 const thick = cell * ratio;
143 return { cell, len, thick, open: len - thick, outer: len + thick };
144}
145
146/**
147 * Where the four walls actually stand, in tiles from where the square built
148 * them: found by pushing them about on a real table, which is what the drag in
149 * `ui/WallRing` is for. `x` is across the table and `y` down it, both in tiles
150 * so the placement carries to any window. Nought is the wall left where it was
151 * built. In `WALL_SIDES` order.
152 */
153export const WALL_PLACED: WallNudge[] = [
154 { x: 0, y: 0 },
155 { x: -1.98, y: -6.7 },
156 { x: 0.52, y: -4.91 },
157 { x: -1.11, y: 0.46 },
158];
159
160/** How far a wall has been pushed from where it was built, in tiles. */
161export interface WallNudge {
162 x: number;
163 y: number;
164}
165
166/** 擲骰 — the three dice a Taiwanese dealer throws to open a hand. */
167export type Dice = [number, number, number];
168
169/** Three of them, from the same seeded rng that shuffled the wall. */
170export function rollDice(rng: () => number): Dice {
171 return [0, 0, 0].map(() => 1 + Math.floor(rng() * 6)) as Dice;
172}
173
174/**
175 * Where the wall is broken, as a place in the square.
176 *
177 * The dealer throws, and the total does two jobs at once. It counts round the
178 * table — the dealer themself is one, then on round to their right, which is
179 * the way play goes — to pick *whose* wall is opened. Then the same number
180 * counts stacks in from the right-hand end of that wall, and the break is
181 * behind them: the tile drawn first is the next one along, and the drawing runs
182 * away to the left from there, on round the square.
183 *
184 * The square makes that easy to say. Every wall is laid down from the corner
185 * its own player's right hand falls on — that is what the pinwheel is — so the
186 * run of 72 places already starts at the right end of each side, and the break
187 * is simply so many places into the side the dice picked.
188 *
189 * Nothing about the *tiles* turns on it: the wall was shuffled before it was
190 * built, so which stack is drawn first is decided either way. What it moves is
191 * where in the middle of the table the gap opens, and which player has to reach
192 * furthest for the next draw — which is the whole of what it does at a table
193 * too, and is worth having because it is what a hand looks like.
194 */
195export function breakAt(dealer: SeatId, dice: number[]): number {
196 const total = dice.reduce((n, d) => n + d, 0);
197 if (total <= 0) return 0;
198 // One is the dealer, and the count goes the way the turn does.
199 const whose = ((dealer + total - 1) % 4) as SeatId;
200 return (SEAT_WALL_SIDE[whose] * STACKS_PER_SIDE + total) % WALL_STACKS;
201}
202
203/** A stack and which of the 72 it is — the index is what the physics reads. */
204export interface Placed {
205 index: number;
206 stack: Stack;
207}
208
209/**
210 * Where what is left of the wall actually goes.
211 *
212 * Four walls of eighteen full-size stacks want about 560px a side, and the
213 * middle of the table is nothing like that in both directions at once — so a
214 * square built to the tile is a square that hangs out under the players. What
215 * saves it is that by the time anyone is looking, four hands have been dealt
216 * off the front and there is nothing like a whole wall left: only what is still
217 * standing is drawn, and it is laid out around a ring cut to the middle rather
218 * than to the tile. Players push the remaining stacks about to keep them tidy
219 * for exactly this reason.
220 *
221 * The rebuilt square is the size of the wall as it is dealt, so at the start
222 * the run fills it exactly, and from then on it is simply *eaten*: the tail is
223 * pinned to the end of the square and normal draws take stacks off the front,
224 * which leaves a growing gap where they were and moves nothing else. Kong and
225 * flower replacements come off the other end and shorten it from there. Both
226 * ends are where they would be on a table, and the square keeps the size it was
227 * built at — a square that shrank every draw would close in on the discards
228 * lying inside it.
229 *
230 * `from` is the break: which place in the square the first tile drawn comes
231 * off, from `breakAt`. Every place is returned whether or not there is still a
232 * stack standing on it, because with the break anywhere but the corner a wall
233 * is eaten from its *middle* — a row that closed the gap up would drag the
234 * whole tail of the wall along the table behind it.
235 */
236export function ringLayout(stacks: Stack[], cap: RingCapacity, from = 0): Placed[][] {
237 const sides: Placed[][] = [[], [], [], []];
238 const lengths = [cap.h, cap.v, cap.h, cap.v];
239 const slots = 2 * (cap.h + cap.v);
240 if (slots <= 0) return sides;
241
242 // Each stack has its own place in the square and keeps it: the far end is the
243 // far end of the square, and everything counts back from there. Draws off the
244 // front open a gap at the break point, draws off the tail shorten the other
245 // end, and no tile that is still standing ever has to move.
246 const offset = WALL_STACKS - slots;
247
248 const run: (Placed | null)[] = Array.from({ length: slots }, () => null);
249 for (let index = 0; index < stacks.length; index++) {
250 // A wall too long for its square only happens before a hand is dealt, and
251 // then only for the frame it takes to deal it. What falls off the start is
252 // the part about to be drawn anyway.
253 const at = index - offset;
254 if (at < 0 || at >= slots) continue;
255 run[(at + from) % slots] = { index, stack: stacks[index] };
256 }
257
258 let p = 0;
259 for (let side = 0; side < 4; side++) {
260 for (let i = 0; i < lengths[side]; i++, p++) {
261 const place = run[p];
262 if (place) sides[side].push(place);
263 }
264 }
265 return sides;
266}