collin/mahjong
7c83cfe4d56ad03f5976a2d1f6232664dcd9518b / README.md
RenderedSource
| 1 | # 台灣麻將 — four-player hotseat on one touchscreen |
| 2 | |
| 3 | Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop. |
| 4 | All four hands are on screen at once, each rotated to face its own edge — put a |
| 5 | piece of card or plastic over your strip so the others can't see your tiles. |
| 6 | Every label is Chinese with English underneath; the tiles themselves stay |
| 7 | Chinese, because that is what the tiles say. |
| 8 | |
| 9 | Nobody else around? **單人對局 Single player** on the lobby gives you the |
| 10 | bottom seat and hands the other three to the computer — same rules, same |
| 11 | table, same scoring. See [computer players](#computer-players). |
| 12 | |
| 13 | ``` |
| 14 | npm install |
| 15 | npm run dev # then open the browser full-screen (F11) |
| 16 | npm 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 | |
| 27 | Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left), |
| 28 | which is the normal counter-clockwise 東南西北 order. |
| 29 | |
| 30 | Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊 |
| 31 | marker, 聽 badge, chip count) → concealed hand → action buttons → melds and |
| 32 | flowers → 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 | |
| 107 | Single player seats you at the bottom and gives seats 1-3 to the computer. |
| 108 | Their tiles go face down and their 聽 / 過水 badges come off, since both would |
| 109 | give the hand away; everything public — discards, melds, flowers, chips — stays |
| 110 | exactly 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`) |
| 113 | watches the same state the screen does, and when the seat being waited on |
| 114 | belongs to the computer it calls the identical method the button would have |
| 115 | called — `discard`, `respond`, `declareConcealedKong`. So scoring, sound, |
| 116 | saving, 過水 and 包牌 all work without a special case, and a bot cannot make a |
| 117 | move a person could not. It never acts for you and never closes your claim |
| 118 | window: 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 |
| 121 | judged at its *resting* size — the (5 − melds) × 3 + 1 tiles you hold between a |
| 122 | discard 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 |
| 137 | alone throws whatever is fastest and pays for it; these bots read the table |
| 138 | first, off the face-up table only — discard rows, exposed melds, the 過水 locks |
| 139 | the 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 | |
| 156 | Whether any of that changes the discard depends on the bot's own hand. At 聽牌 |
| 157 | it pushes — nothing short of 包牌 is worth breaking a ready hand for. Two or |
| 158 | three away with somebody live across the table and it will give up a whole 向聽 |
| 159 | step to throw something safe, which is what folding is. |
| 160 | |
| 161 | The estimate is checked against ground truth in the tests rather than assumed: |
| 162 | over the tiles bots actually considered, the ones the model rated below 0.2 deal |
| 163 | in 0% of the time and the ones above 0.8 deal in 10.5%, cleanly monotonic in |
| 164 | between. Switching the read on cuts deal-ins by about 13% over a few hundred |
| 165 | hands, takes hands from ~30 discards to ~38, and moves the draw rate from |
| 166 | almost nothing to about one hand in ten — which is what a table where people |
| 167 | stop feeding each other actually looks like. |
| 168 | |
| 169 | What they know is still only what they can see. `unseenFor` counts the four |
| 170 | copies of each tile and subtracts the bot's own hand plus every discard and |
| 171 | exposed meld on the table. Neither module ever reads an opponent's concealed |
| 172 | tiles or looks at the wall. |
| 173 | |
| 174 | There is no difficulty setting, and the reading stops at the discard: claims are |
| 175 | still judged purely on speed, and a bot will take a 碰 that walks it into |
| 176 | trouble. |
| 177 | |
| 178 | 向聽 lives in `src/game/shanten.ts`, apart from `hu.ts`, because the two want |
| 179 | different things: the win check has to be exact, and this one has to be fast — |
| 180 | it runs a few hundred times for every discard a bot considers. The tests hold |
| 181 | them against each other over random hands, since they work in completely |
| 182 | different ways and any disagreement is a bug in the fast one. |
| 183 | |
| 184 | Undo behaves differently against the computer: rewinding into the middle of its |
| 185 | turn would only hand the move straight back to it, so one press goes back to |
| 186 | the last point *you* had a decision to make, computer replies and all. |
| 187 | |
| 188 | ## Saving |
| 189 | |
| 190 | The game state is plain data, so a save is just its JSON in `localStorage`, |
| 191 | rewritten after every move. Close the lid, refresh, or run the battery flat and |
| 192 | nothing is lost — the lobby offers **繼續對局 Resume** above the two ways to |
| 193 | start a fresh one, showing whether it was a solo or a hotseat game, the round, |
| 194 | hand number, chip counts and when it was saved. Which seats the computer holds |
| 195 | is part of the state, so a save resumes as the kind of game it was. A |
| 196 | save is validated before it is offered (four players, 144 tiles accounted for), |
| 197 | so a truncated or hand-edited one is ignored rather than loaded into a broken |
| 198 | table. `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 | |
| 238 | Ambiguous 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 |
| 243 | each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added |
| 244 | to the whole hand when the dealer wins. |
| 245 | |
| 246 | House 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 | |
| 251 | A mahjong table only ever says about fifty things — 42 tile names and a handful |
| 252 | of 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 |
| 254 | the browser happens to have. On Linux that is usually espeak-ng, which is |
| 255 | intelligible but sounds like a modem; and on a machine with no Chinese voice at |
| 256 | all the feature would silently do nothing. Shipping the audio makes playback |
| 257 | instant, identical everywhere, and lets a call be cut off mid-word when the |
| 258 | next 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 | ``` |
| 264 | node scripts/voice.mjs # → public/voice/*.mp3 + manifest.json |
| 265 | PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs # a different voice |
| 266 | ``` |
| 267 | |
| 268 | The wording lives in that script, and some of it is deliberately not the bare |
| 269 | tile character: 東 alone is a direction where 東風 is the tile, and the dragons |
| 270 | are 紅中 / 發財 / 白板 the way they are actually called — which also gives the |
| 271 | phonemiser enough context to get the tone right, since 中 on its own is as |
| 272 | likely to come out zhòng. |
| 273 | |
| 274 | The clips here were rendered with piper's `zh_CN-huayan-medium`. If you |
| 275 | redistribute this app, check that voice's model card in |
| 276 | [piper-voices](https://huggingface.co/rhasspy/piper-voices) for the terms |
| 277 | attached to it, the same way you would the tile art below — or re-render the |
| 278 | pack 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 |
| 284 | the installed binary's GResource — a 43×2 sprite sheet (second row is the |
| 285 | highlighted variant, used for a lifted tile). `public/tiles/back.png` is the |
| 286 | blank tile from its **smooth** theme, tinted jade, since a solitaire game has no |
| 287 | face-down art of its own. |
| 288 | |
| 289 | That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever |
| 290 | distribute this app, either honour the GPL or swap the two files for art of your |
| 291 | own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating. |
| 292 | |
| 293 | Known gaps and house rules that aren't implemented yet are listed in |
| 294 | [TODO.md](TODO.md). |
| 295 | |
| 296 | ## Layout |
| 297 | |
| 298 | ``` |
| 299 | public/tiles/ sprite sheet + tile back |
| 300 | src/game/tiles.ts tile codes, wall, shuffle, sprite mapping |
| 301 | src/game/hu.ts hand decomposition, 聽 detection, wait shapes |
| 302 | src/game/shanten.ts 向聽 / 進張 counting, for the computer players |
| 303 | src/game/bot.ts what a computer player discards and claims (pure) |
| 304 | src/game/danger.ts reading the table: who looks ready, which tiles are hot |
| 305 | src/game/autoplay.ts when it does it, and how long it appears to think |
| 306 | src/game/tai.ts 台 scoring |
| 307 | src/game/engine.ts state machine: deal, turns, claim resolution, settlement |
| 308 | src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help |
| 309 | ``` |