anvilsign in

collin/mahjong

RenderedSource

1# 台灣麻將 — four-player hotseat on one touchscreen
2
3Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop.
4All four hands are on screen at once, each rotated to face its own edge — put a
5piece of card or plastic over your strip so the others can't see your tiles.
6Every label is Chinese with English underneath; the tiles themselves stay
7Chinese, because that is what the tiles say.
8
9Nobody else around? **單人對局 Single player** on the lobby gives you the
10bottom seat and hands the other three to the computer — same rules, same
11table, same scoring. See [computer players](#computer-players).
12
13```
14npm install
15npm run dev # then open the browser full-screen (F11)
16npm test # rules engine, the bots, and 200 randomly-played hands
17```
18
19## Screen layout
20
21```
22 ┌──── 對家 (rotated 180°) ────┐
23 上家 (rotated 90°) │ wall / discards │ 下家 (rotated −90°)
24 └──── 自家 (upright, near you) ┘
25```
26
27Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left),
28which is the normal counter-clockwise 東南西北 order.
29
30Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊
31marker, 聽 badge, chip count) → concealed hand → action buttons → melds and
32flowers → that player's discard row.
33
34## Playing
35
36- **The tile you just drew** is held apart from the sorted hand with a gold ring
37 and a 摸 label, instead of being sorted invisibly into it. It merges into the
38 hand once you discard. 補花 replacements and kong replacements are marked the
39 same way.
40- **The wall** is drawn in the centre as a square: four staggered sides of 18
41 stacks of two, the way it's built on a real table. The gold stack is the break
42 point you draw from, stacks shrink to a single tile and then vanish as they're
43 used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is
44 also the end kong and flower replacements are taken from, so the square is
45 eaten from both directions at once.
46- **Discard** — tap a tile to lift it, tap again (or the 打出 button) to throw it.
47 Action buttons sit on the right of your own strip, within thumb reach.
48- **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back
49 into suit order. Hands are never auto-sorted after the deal, so an arrangement
50 you set up survives draws, claims and kongs.
51- **Rules** — the `?` on your nameplate opens a bilingual rules panel anchored to
52 your own edge of the table and rotated to face you.
53- **Claiming** — after a discard, every seat that *can* claim gets 胡 / 槓 / 碰 /
54 吃 / 過 buttons on their own strip, and they resolve by priority (胡 > 槓/碰 >
55 吃; ties go to the player nearest the discarder in turn order).
56 **The table never blocks on a claim.** The next player gets a 摸牌 button and
57 may draw whenever they like, which shuts the window on anyone who hasn't
58 called — exactly like shouting 碰 before the next player picks up. A claim
59 already declared still stands, so calling in time always wins the tile. If
60 everyone answers first, play advances on its own without the extra tap.
61 The seat that draws next gets no separate 過 button, because for them the two
62 are the same move: picking up *is* how you decline. Their draw button reads
63 過.摸牌 while they have something they could have called instead. Wanting to
64 decline but leave the window open for everyone else is just waiting, which is
65 what not pressing anything already does.
66- **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal.
67 加槓 offers everyone else a 搶槓 chance.
68- **Where the buttons go** — the strip is built from its edge inwards, so the
69 action bar holds a fixed height whether or not anything is in it. Otherwise
70 the melds and discards above it shuffled along every time you lifted a tile
71 and 打出 五萬 appeared, since a button naming a tile stands taller than a bare
72 過. Every button on the bar is given that one height for the same reason.
73- **When a hand ends** — a big arrow drops onto the winner's edge of the table.
74 It lives inside their strip, so it turns with the seat and lands on the right
75 person wherever they are sitting, and 一炮多響 lights up every seat that
76 called. A 流局 has no winner to point at, so it turns the hands over instead:
77 who was 聽牌 and the tiles they were waiting on, the same reveal as at a real
78 table.
79- **Undo** — a 復原 button in the middle of the table takes back the last
80 action, naming what it will undo (`復原 玩家 2 打 五萬`). It sits in the
81 centre rather than on a seat because whoever *spots* the mis-tap should be
82 able to reach it. Twenty actions deep, cleared when the next hand is dealt.
83 Snapshots live outside the saved state, so an undo does not survive a
84 refresh: what you can take back is what happened while everyone was still
85 watching it happen.
86- **Sound** — on by default, muted from the 🔊 button in the middle of the
87 table or from settings. A dry clack as a tile goes down, and a distinct
88 two-note chime when a claim window opens, which is how a slow player notices
89 their 碰 is available before the next player draws it shut. Everything is
90 synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
91 so there is nothing to load and the cue lands on the same frame as the tile.
92 Browsers refuse to start audio before the page has been clicked, so the first
93 sound anyone hears is the deal — triggered by the button that starts it.
94- **報牌 (voice)** — a second switch under sound calls the game out loud: 碰,
95 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條,
96 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one
97 still being spoken; at table pace the newest is the only one that matters.
98 See [the voice pack](#the-voice-pack) for where the audio comes from.
99- **Settings** — 設定 from the lobby, or from the game-over panel: player names,
100 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
101 `localStorage` separately from the save, so they carry over to the next game.
102 Stakes are locked once a game is under way — they'd otherwise rewrite chips
103 already won.
104
105## Computer players
106
107Single player seats you at the bottom and gives seats 1-3 to the computer.
108Their tiles go face down and their 聽 / 過水 badges come off, since both would
109give the hand away; everything public — discards, melds, flowers, chips — stays
110exactly as it is, and everyone turns their hand over when the hand ends.
111
112**The engine does not know the bots exist.** `AutoPlay` (`src/game/autoplay.ts`)
113watches the same state the screen does, and when the seat being waited on
114belongs to the computer it calls the identical method the button would have
115called — `discard`, `respond`, `declareConcealedKong`. So scoring, sound,
116saving, 過水 and 包牌 all work without a special case, and a bot cannot make a
117move a person could not. It never acts for you and never closes your claim
118window: if the table is waiting on you, it waits.
119
120**How they play** (`src/game/bot.ts`) is one idea applied everywhere. A hand is
121judged at its *resting* size — the (5 − melds) × 3 + 1 tiles you hold between a
122discard and your next draw — by its 向聽 first and by its 進張 count second.
123
124- **Discard** — try every distinct tile, keep the one that leaves the best
125 resting hand. Ties go to whatever is hardest to build on: a lone honour with
126 most of its copies already gone, before a lone 五萬 that still has neighbours
127 to meet.
128- **碰 / 吃** — only when the hand that comes out the other side is *strictly*
129 closer to home. Melding costs concealment and flexibility, so a claim that
130 merely holds the 向聽 steady is declined — which is why the bots pass on
131 pungs that would narrow a two-sided wait to a single tile.
132- **槓** — judged more kindly: it pays 台 and fetches a replacement, so standing
133 still is good enough.
134- **胡** — always taken.
135
136**What they watch you do** (`src/game/danger.ts`) is the other half. Efficiency
137alone throws whatever is fastest and pays for it; these bots read the table
138first, off the face-up table only — discard rows, exposed melds, the 過水 locks
139the nameplates already show. Two separate questions:
140
141- **How close does each seat look?** Turns taken (a hand is usually settled
142 inside ten goes each, long before the wall looks finished, so counting the
143 wall is the wrong clock), melds exposed, and what they have been throwing
144 lately. Nobody discards 五條 out of a hand that still needs shaping — it comes
145 out once the shape is done, so late middle tiles are the tell. A 過水 lock is
146 a confession: to be locked out of a tile you had to have been able to win on
147 it.
148- **How likely is this tile the one they want?** An honour can only be caught by
149 a pair or a triplet; a 五萬 sits in the middle of every run through it. A tile
150 they discarded themselves was not their tile when it went down — not proof
151 now, but the best evidence there is. A tile with no copies left unseen cannot
152 be a pair or triplet wait at all. Two melds in one suit means stay out of that
153 suit. And two dragon pungs down means the third dragon is not going anywhere
154 near the table, because 包牌 bills the feeder for the entire hand.
155
156Whether any of that changes the discard depends on the bot's own hand. At 聽牌
157it pushes — nothing short of 包牌 is worth breaking a ready hand for. Two or
158three away with somebody live across the table and it will give up a whole 向聽
159step to throw something safe, which is what folding is.
160
161The estimate is checked against ground truth in the tests rather than assumed:
162over the tiles bots actually considered, the ones the model rated below 0.2 deal
163in 0% of the time and the ones above 0.8 deal in 10.5%, cleanly monotonic in
164between. Switching the read on cuts deal-ins by about 13% over a few hundred
165hands, takes hands from ~30 discards to ~38, and moves the draw rate from
166almost nothing to about one hand in ten — which is what a table where people
167stop feeding each other actually looks like.
168
169What they know is still only what they can see. `unseenFor` counts the four
170copies of each tile and subtracts the bot's own hand plus every discard and
171exposed meld on the table. Neither module ever reads an opponent's concealed
172tiles or looks at the wall.
173
174There is no difficulty setting, and the reading stops at the discard: claims are
175still judged purely on speed, and a bot will take a 碰 that walks it into
176trouble.
177
178向聽 lives in `src/game/shanten.ts`, apart from `hu.ts`, because the two want
179different things: the win check has to be exact, and this one has to be fast —
180it runs a few hundred times for every discard a bot considers. The tests hold
181them against each other over random hands, since they work in completely
182different ways and any disagreement is a bug in the fast one.
183
184Undo behaves differently against the computer: rewinding into the middle of its
185turn would only hand the move straight back to it, so one press goes back to
186the last point *you* had a decision to make, computer replies and all.
187
188## Saving
189
190The game state is plain data, so a save is just its JSON in `localStorage`,
191rewritten after every move. Close the lid, refresh, or run the battery flat and
192nothing is lost — the lobby offers **繼續對局 Resume** above the two ways to
193start a fresh one, showing whether it was a solo or a hotseat game, the round,
194hand number, chip counts and when it was saved. Which seats the computer holds
195is part of the state, so a save resumes as the kind of game it was. A
196save is validated before it is offered (four players, 144 tiles accounted for),
197so a truncated or hand-edited one is ignored rather than loaded into a broken
198table. `VERSION` in `save.ts` retires old saves if the state shape changes.
199
200## Rules implemented
201
202- 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer
203 draws the 17th.
204- 補花 at the deal and on every drawn flower, replacements from the back of the
205 wall.
206- 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花.
207- 流局 when 16 tiles remain (`Rules.wallReserve`); the dealer keeps the deal on a
208 draw or on a dealer win (連莊), otherwise the deal passes and the round wind
209 advances every four passes. A full 四圈 game is 16 dealer passes.
210
211### House rules (switches in `Rules`, all on the settings screen)
212
213- **過水 `sacredDiscard`** (on) — pass on a tile you could have won with and it
214 is dead to you until your own next draw; the seat shows a 過水 badge listing
215 what it is locked out of, so the missing 胡 button is never a mystery. A draw
216 from either end of the wall lifts it. **`sacredClearedByClaim`** (off) decides
217 whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ.
218- **一炮多響 `multipleWinners`** (off) — one discard pays out to every seat that
219 calls on it, each settled separately against the discarder. With it off the
220 tile goes to the caller nearest the discarder. Either way the table now waits
221 for other seats that *can* win before settling, so the nearest seat wins the
222 tile rather than the quickest hand — and the 摸牌 button still closes the
223 window on anyone dithering.
224- **包牌 `liability`** (on) — feeding the pung that completes a visible 大三元
225 or 大四喜 makes the feeder answer for the whole hand, in place of all three
226 payers. Only the seat that fed the *last* of those sets is on the hook, and
227 only if it came off a discard: a hand that assembled them itself, or closed
228 the set with a 暗槓, has nobody to blame.
229
230### 台 scoring (`src/game/tai.ts`)
231
232自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each ·
233花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 ·
234小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 ·
235三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 ·
236海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1)
237
238Ambiguous hands are decomposed every legal way and scored at the best reading.
239
240**Payment** (`Game.settle`): one unit is `底 + 台 × 台值`
241(`DEFAULT_RULES` = 底 3, 台 1, 100 chips each).
242放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit
243each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added
244to the whole hand when the dealer wins.
245
246House rules vary a lot; the tai table and payments are plain data/functions in
247`tai.ts` and `types.ts` if yours differ.
248
249## The voice pack
250
251A mahjong table only ever says about fifty things — 42 tile names and a handful
252of calls — so the whole vocabulary is rendered ahead of time into
253`public/voice/` (~330 kB of mp3) rather than left to whatever speech synthesis
254the browser happens to have. On Linux that is usually espeak-ng, which is
255intelligible but sounds like a modem; and on a machine with no Chinese voice at
256all the feature would silently do nothing. Shipping the audio makes playback
257instant, identical everywhere, and lets a call be cut off mid-word when the
258next one lands, all through the same Web Audio graph as the chimes.
259
260`scripts/voice.mjs` is the authoring step, not part of `npm run build`. It needs
261[piper](https://github.com/OHF-Voice/piper1-gpl) and ffmpeg on PATH:
262
263```
264node scripts/voice.mjs # → public/voice/*.mp3 + manifest.json
265PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs # a different voice
266```
267
268The wording lives in that script, and some of it is deliberately not the bare
269tile character: 東 alone is a direction where 東風 is the tile, and the dragons
270are 紅中 / 發財 / 白板 the way they are actually called — which also gives the
271phonemiser enough context to get the tone right, since 中 on its own is as
272likely to come out zhòng.
273
274The clips here were rendered with piper's `zh_CN-huayan-medium`. If you
275redistribute this app, check that voice's model card in
276[piper-voices](https://huggingface.co/rhasspy/piper-voices) for the terms
277attached to it, the same way you would the tile art below — or re-render the
278pack with a voice whose terms suit you, which is a single command.
279
280## Tile art
281
282`public/tiles/tiles.svg` is the **postmodern** tileset from
283[gnome-mahjongg](https://gitlab.gnome.org/GNOME/gnome-mahjongg), extracted from
284the installed binary's GResource — a 43×2 sprite sheet (second row is the
285highlighted variant, used for a lifted tile). `public/tiles/back.png` is the
286blank tile from its **smooth** theme, tinted jade, since a solitaire game has no
287face-down art of its own.
288
289That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever
290distribute this app, either honour the GPL or swap the two files for art of your
291own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating.
292
293Known gaps and house rules that aren't implemented yet are listed in
294[TODO.md](TODO.md).
295
296## Layout
297
298```
299public/tiles/ sprite sheet + tile back
300src/game/tiles.ts tile codes, wall, shuffle, sprite mapping
301src/game/hu.ts hand decomposition, 聽 detection, wait shapes
302src/game/shanten.ts 向聽 / 進張 counting, for the computer players
303src/game/bot.ts what a computer player discards and claims (pure)
304src/game/danger.ts reading the table: who looks ready, which tiles are hot
305src/game/autoplay.ts when it does it, and how long it appears to think
306src/game/tai.ts 台 scoring
307src/game/engine.ts state machine: deal, turns, claim resolution, settlement
308src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help
309```