anvilsign in

collin/mahjong · eaeb7859

Give the table three computer players

Collin Richards · 2026-08-18 13:24 UTC · eaeb785937a809148a055a4675bc7f978b7769df · parent 5f763ae3 · browse files

modifiedREADME.md+112 −3
⋯ 5 unchanged lines
66 Every label is Chinese with English underneath; the tiles themselves stay
77 Chinese, because that is what the tiles say.
88
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+
913 ```
1014 npm install
1115 npm run dev # then open the browser full-screen (F11)
12-npm test # rules-engine tests, incl. 200 randomly-played hands
16+npm test # rules engine, the bots, and 200 randomly-played hands
1317 ```
1418
1519 ## Screen layout
⋯ 38 unchanged lines
5458 called — exactly like shouting 碰 before the next player picks up. A claim
5559 already declared still stands, so calling in time always wins the tile. If
5660 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.
5766 - **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal.
5867 加槓 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.
5979 - **Undo** — a 復原 button in the middle of the table takes back the last
6080 action, naming what it will undo (`復原 玩家 2 打 五萬`). It sits in the
6181 centre rather than on a seat because whoever *spots* the mis-tap should be
⋯ 20 unchanged lines
82102 Stakes are locked once a game is under way — they'd otherwise rewrite chips
83103 already won.
84104
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+
85188 ## Saving
86189
87190 The game state is plain data, so a save is just its JSON in `localStorage`,
88191 rewritten after every move. Close the lid, refresh, or run the battery flat and
89-nothing is lost — the lobby offers **繼續對局 Resume** alongside **開新局 New
90-game**, showing the round, hand number, chip counts and when it was saved. A
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
91196 save is validated before it is offered (four players, 144 tiles accounted for),
92197 so a truncated or hand-edited one is ignored rather than loaded into a broken
93198 table. `VERSION` in `save.ts` retires old saves if the state shape changes.
⋯ 100 unchanged lines
194299 public/tiles/ sprite sheet + tile back
195300 src/game/tiles.ts tile codes, wall, shuffle, sprite mapping
196301 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
197306 src/game/tai.ts 台 scoring
198307 src/game/engine.ts state machine: deal, turns, claim resolution, settlement
199308 src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help
⋯ 1 unchanged line
modifiedpublic/tiles/tiles.svg+51 −17
⋯ 104 unchanged lines
105105 <line fill="#99B9FF" stroke="#678ED3" x1="36" y1="63.6" x2="35" y2="65.33"/>
106106 <path fill="#678ED3" d="M44,0H0v69h45V0H44z M43,2c0,1.91,0,63.09,0,65c-1.86,0-39.14,0-41,0C2,65.09,2,3.91,2,2C3.86,2,41.14,2,43,2z"/>
107107 </g>
108- <!-- 一索 is a bird, not a stick. Drawn to sit in the same 62px-tall face
109- box the other bamboos use, in their green with the 五索 red for the
110- crest and tail. Origin is the glyph's top-left. -->
111- <g id="yitiao">
112- <path fill="#006C00" d="M4.6,44.4h24.8c1.1,0,1.9,0.9,1.9,1.9v1.3c0,1.1-0.9,1.9-1.9,1.9H4.6c-1.1,0-1.9-0.9-1.9-1.9v-1.3C2.6,45.3,3.5,44.4,4.6,44.4z"/>
113- <path fill="none" stroke="#004A00" stroke-width="1.1" d="M17,44.4v5.1"/>
114- <path fill="none" stroke="#C08A1E" stroke-width="1.9" stroke-linecap="round" d="M14.4,37.4v6.6M19.6,37.4v6.6"/>
115- <path fill="#9C0000" d="M21.8,26.4c5.2-5.9,9.1-13,11.2-20.4c0.5-1.9-1.7-2.8-2.5-1c-2.9,6.2-6.2,12.1-10.1,17.6L21.8,26.4z"/>
116- <path fill="#BF2A20" d="M19.4,29.2c6-4.2,10.9-9.9,14.2-16.4c0.9-1.7-1.1-3.1-2.2-1.5c-3.7,5.4-7.9,10.3-12.7,14.5L19.4,29.2z"/>
117- <path fill="#006C00" d="M17.6,38.6c-5.6,0-9.9-4.3-9.9-9.9c0-5.8,3.4-10.9,7.8-13.2l7.5,4.4c2.7,3.1,4.3,7,4.3,10.7C27.3,35.3,23.1,38.6,17.6,38.6z"/>
118- <path fill="#00530A" d="M16.8,21.6c4.7,0.7,8.1,4.6,8.7,9.7c0.4,3.4-1.1,5.7-3.1,5.7c-4.3,0-7.8-4.8-7.8-9.9C14.6,24.5,15.3,22.4,16.8,21.6z"/>
119- <circle fill="#006C00" cx="13.2" cy="15" r="6.5"/>
120- <path fill="none" stroke="#9C0000" stroke-width="1.4" stroke-linecap="round" d="M14.7,9.1C15.5,6.1,17.1,4.2,19.2,3.2M12.3,8.9C11.9,6,12.5,3.7,13.8,1.9"/>
121- <path fill="#C08A1E" d="M7.5,13.3L1.4,16.1l6.1,2.5V13.3z"/>
122- <circle fill="#1A1A1A" cx="11.3" cy="13.5" r="1.4"/>
108+ <!-- 一索 is a bird, not a stick: a sparrow on a perch, drawn line-art in
109+ the sheet's own green, red and blue. Origin is the glyph's top-left; it
110+ occupies a 44x56 box that centers in the tile face. -->
111+ <g id="yitiao" fill="none" stroke-linecap="round" stroke-linejoin="round">
112+ <!-- perch, with a knob at each end -->
113+ <g stroke="#006C00" fill="#006C00">
114+ <path stroke-width="1.4" d="M4.5,35H39.5"/>
115+ <circle cx="3.6" cy="35" r="1.9"/>
116+ <circle cx="40.4" cy="35" r="1.9"/>
117+ </g>
118+ <!-- tail plumes -->
119+ <g stroke="#006C00" stroke-width="0.9">
120+ <path d="M19.6,30.5C16,37,14.2,44,14,51.5"/>
121+ <path d="M20.8,31C18,38,17,45.5,17.5,53.5"/>
122+ <path d="M22,31.4C20.2,38.5,20.2,46.5,21.5,55"/>
123+ <path d="M23.2,31.4C22.6,38.5,23.6,46.5,25.8,55.5"/>
124+ <path d="M24.3,31C24.8,38,26.4,45.5,29,54"/>
125+ <path d="M25.2,30.3C26.4,36.5,28.6,43.5,31.6,51"/>
126+ </g>
127+ <!-- far wing -->
128+ <path stroke="#294D8E" stroke-width="1.2" d="M25.5,19.5C30,20.5,34.8,24.5,38.8,31C34.5,29.2,30,28.4,27,28.7"/>
129+ <!-- long flight feather, sweeping past the perch -->
130+ <path stroke="#294D8E" stroke-width="1.2" d="M23,23.5C17.5,27.5,10.8,34.5,5,44.5C11.5,38.5,17.8,32.5,22,29"/>
131+ <!-- near wing, folded over the breast -->
132+ <path stroke="#294D8E" stroke-width="1.2" d="M24,17.8C17.4,18.6,10.6,23.4,6.2,31.2C12,27.4,17.6,25.6,21.4,26.2C22.8,23.4,23.6,20.6,24,17.8Z"/>
133+ <g stroke="#294D8E" stroke-width="0.6">
134+ <path d="M19,19.6C18.2,21.8,17.4,24.2,16.6,26.4"/>
135+ <path d="M16.6,21C15.8,23.2,14.8,25.4,14,27.4"/>
136+ <path d="M14.2,22.6C13.4,24.6,12.4,26.6,11.6,28.6"/>
137+ <path d="M11.8,24.6C11,26.4,10.2,28.2,9.4,29.8"/>
138+ </g>
139+ <!-- head, beak, back and breast -->
140+ <g stroke="#9C0000" stroke-width="1.3">
141+ <path d="M20,8.6C17.2,7.8,14.4,9.4,13.2,11.8L7.4,13.9L13.2,15.9C12.6,17.6,12.4,19.4,12.7,21"/>
142+ <path d="M20.8,9.2C24.4,12,26.4,16.2,26.8,21.4C27.2,26.4,26.2,30,24.4,32"/>
143+ <path d="M13.4,17.2C14.2,22.4,16.4,27.4,19.8,31.2"/>
144+ </g>
145+ <g stroke="#9C0000" stroke-width="0.7">
146+ <path d="M15.4,18.4C16,22,17.2,25.4,19,28.6"/>
147+ <path d="M18.4,19.2C18.8,22.8,19.8,26.2,21.4,29.6"/>
148+ <path d="M21.6,20C21.8,23.6,22.6,26.8,24,30.2"/>
149+ </g>
150+ <!-- swept-back crest -->
151+ <g stroke="#006C00" stroke-width="1.2">
152+ <path d="M13.8,10.6C13.2,5.6,16.2,2.4,20.2,2.8C23.4,3.2,24.4,5.4,22.4,6.8"/>
153+ <path d="M16.6,8.8C17.8,7.2,20,6.2,22.4,6.8"/>
154+ </g>
155+ <path stroke="#9C0000" stroke-width="0.7" d="M8.8,14.6L13,14.2"/>
156+ <circle fill="#9C0000" stroke="none" cx="16.8" cy="11.9" r="1.4"/>
123157 </g>
124158 </defs>
125159 <use x="0" y="0" xlink:href="#bt1"/>
⋯ 204 unchanged lines
330364 <use transform="translate(556,102) scale(.72)" xlink:href="#dots"/>
331365 <use transform="translate(556,122) scale(.72)" xlink:href="#dots2"/>
332366 <use transform="translate(556,142) scale(.72)" xlink:href="#dots"/>
333- <use transform="translate(1553.7,14.9)" xlink:href="#yitiao"/>
334- <use transform="translate(1553.7,102.9)" xlink:href="#yitiao"/>
367+ <use transform="translate(1550.5,11.9)" xlink:href="#yitiao"/>
368+ <use transform="translate(1550.5,99.9)" xlink:href="#yitiao"/>
335369 <use transform="translate(1630,9.5)" xlink:href="#bams"/>
336370 <use transform="translate(1630,44.5)" xlink:href="#bams"/>
337371 <use transform="translate(1630,97.5)" xlink:href="#bams"/>
⋯ 192 unchanged lines
modifiedsrc/App.tsx+21 −4
11 import { useEffect, useState, useSyncExternalStore } from 'react';
2+import { AutoPlay } from './game/autoplay';
23 import { Game } from './game/engine';
34 import { loadPrefs, savePrefs, type Prefs } from './game/prefs';
45 import { loadSave } from './game/save';
⋯ 7 unchanged lines
1213 import './styles.css';
1314
1415 const SEATS: SeatId[] = [0, 1, 2, 3];
16+/** The human always takes the bottom seat; the other three are the computer's. */
17+const SOLO_SEATS = [false, true, true, true];
18+const HOTSEAT_SEATS = [false, false, false, false];
1519
1620 export default function App() {
1721 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
⋯ 28 unchanged lines
4650 onSave={commitSettings}
4751 onClose={cancelSettings}
4852 stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'}
53+ bots={game.state.bots}
4954 />
5055 );
5156
⋯ 5 unchanged lines
5762 setVolume(prefs.volume);
5863 }, [prefs]);
5964 useEffect(() => game.onSound(play), [game]);
65+ // Watches the table for the whole session and only stirs when a seat the
66+ // computer holds is the one being waited on.
67+ useEffect(() => new AutoPlay(game).start(), [game]);
6068
6169 if (phase === 'lobby') {
6270 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
71+ const savedSolo = saved?.state.bots.some(Boolean);
6372 return (
6473 <div className="lobby">
6574 <h1>台灣麻將</h1>
⋯ 7 unchanged lines
7382 {saved && (
7483 <div className="resume">
7584 <div className="resume-line">
76- {WIND_NAMES[round]}圈 · 第 {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand{' '}
77- {saved.state.handNumber}
85+ {savedSolo ? '單人 Solo' : '同桌 Hotseat'} · {WIND_NAMES[round]}圈 · 第{' '}
86+ {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand {saved.state.handNumber}
7887 </div>
7988 <div className="resume-scores">
8089 {saved.state.players.map((p, i) => (
⋯ 12 unchanged lines
93102 繼續對局 Resume
94103 </button>
95104 )}
96- <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={() => game.newGame()}>
97- {saved ? '開新局 New game' : '開始 Start'}
105+ <button
106+ className={`btn big-btn ${saved ? 'ghost' : ''}`}
107+ onClick={() => game.newGame(SOLO_SEATS, prefs.names)}
108+ >
109+ 單人對局 Single player
110+ <em>你坐東家,其餘三家由電腦代打 · you at 東, three computer players</em>
111+ </button>
112+ <button className="btn big-btn ghost" onClick={() => game.newGame(HOTSEAT_SEATS, prefs.names)}>
113+ 四人同桌 Hotseat
114+ <em>四個人共用一個螢幕 · four people, one screen</em>
98115 </button>
99116 </div>
100117
⋯ 31 unchanged lines
addedsrc/game/autoplay.ts+124 −0
1+import { botClaim, botDiscard, botKong, botView } from './bot';
2+import type { Game } from './engine';
3+import type { SeatId } from './types';
4+
5+const SEATS: SeatId[] = [0, 1, 2, 3];
6+
7+/**
8+ * When the computer players move.
9+ *
10+ * The engine has no idea any of this exists: this watches the same state
11+ * everyone else does and, when the position belongs to a bot, calls exactly
12+ * the method the corresponding button would have called. That is the whole
13+ * trick — a bot's move goes through `discard` / `respond` / `declare…` like
14+ * anyone else's, so scoring, sound, saving and undo all keep working.
15+ *
16+ * It never acts for a person and never closes a person's claim window: if the
17+ * table is waiting on you, it waits. What the bots actually decide is
18+ * `bot.ts`; what they are allowed to see is `danger.ts`.
19+ */
20+
21+/**
22+ * How long the computer appears to think. Not a difficulty setting — it is
23+ * there so a person can see what happened. A discard needs long enough to read
24+ * off the table; a 碰 or a 過 comes back quicker, because three seats answer
25+ * one after another and the wait is cumulative.
26+ */
27+const THINK_DISCARD = 750;
28+const THINK_CLAIM = 380;
29+/** A little unevenness, so the table doesn't tick like a metronome. */
30+const JITTER = 250;
31+
32+/** Whose move it is, if it is a bot's, and how long to sit on it. */
33+export function pendingBot(game: Game): { seat: SeatId; delay: number } | null {
34+ const s = game.state;
35+ if (s.phase === 'turn' && game.isBot(s.turn)) return { seat: s.turn, delay: THINK_DISCARD };
36+ if (s.phase === 'claims' || s.phase === 'robkong') {
37+ const seat = SEATS.find(
38+ (q) => game.isBot(q) && s.options[q] !== undefined && s.responses[q] === undefined,
39+ );
40+ if (seat !== undefined) return { seat, delay: THINK_CLAIM };
41+ }
42+ return null;
43+}
44+
45+/**
46+ * Play one computer move, if one is owed. Returns whether it did anything —
47+ * false means the table is waiting on a person, or on nothing at all.
48+ */
49+export function stepBots(game: Game): boolean {
50+ const s = game.state;
51+ const pending = pendingBot(game);
52+ if (!pending) return false;
53+ const seat = pending.seat;
54+
55+ if (s.phase === 'claims' || s.phase === 'robkong') {
56+ const tile = s.phase === 'robkong' ? s.robKongTile : s.lastDiscard?.tile;
57+ if (tile === null || tile === undefined) return false;
58+ game.respond(seat, botClaim(s, seat, tile, s.options[seat]!));
59+ return true;
60+ }
61+
62+ if (game.canSelfDrawWin(seat)) {
63+ game.declareSelfDraw(seat);
64+ return true;
65+ }
66+ // Only worth asking while there is a wall to draw the replacement from — the
67+ // engine turns a kong down flat once it is gone, and a refused move would
68+ // leave the table sitting here forever.
69+ if (game.wallLeft > 0) {
70+ const kong = botKong(s, seat, game.concealedKongs(seat), game.addedKongs(seat));
71+ if (kong?.kind === 'ankong') {
72+ game.declareConcealedKong(seat, kong.tile);
73+ return true;
74+ }
75+ if (kong?.kind === 'addkong') {
76+ game.declareAddedKong(seat, kong.tile);
77+ return true;
78+ }
79+ }
80+
81+ const p = s.players[seat];
82+ const view = botView(s, seat);
83+ game.discard(seat, botDiscard(p.hand, p.melds.length, view.unseen, view.danger));
84+ return true;
85+}
86+
87+export class AutoPlay {
88+ private timer: ReturnType<typeof setTimeout> | null = null;
89+ private game: Game;
90+
91+ constructor(game: Game) {
92+ this.game = game;
93+ }
94+
95+ /** Start watching. Returns the disposer. */
96+ start(): () => void {
97+ const off = this.game.subscribe(this.schedule);
98+ this.schedule();
99+ return () => {
100+ off();
101+ this.cancel();
102+ };
103+ }
104+
105+ private cancel() {
106+ if (this.timer !== null) clearTimeout(this.timer);
107+ this.timer = null;
108+ }
109+
110+ private schedule = () => {
111+ this.cancel();
112+ const pending = pendingBot(this.game);
113+ if (!pending) return;
114+ const wait = pending.delay + Math.floor(Math.random() * JITTER);
115+ // The state can move under us — a person taking an undo, say — so the move
116+ // is worked out again at the moment it is played, never here.
117+ const at = this.game.getSnapshot();
118+ this.timer = setTimeout(() => {
119+ this.timer = null;
120+ if (this.game.getSnapshot() !== at) return;
121+ stepBots(this.game);
122+ }, wait);
123+ };
124+}
addedsrc/game/bot.test.ts+329 −0
1+import { describe, expect, it } from 'vitest';
2+import { stepBots } from './autoplay';
3+import { botClaim, botDiscard, unseenFor } from './bot';
4+import { readTable } from './danger';
5+import { Game } from './engine';
6+import { isWinningHand, waits } from './hu';
7+import { shanten, ukeire } from './shanten';
8+import { buildWall, makeRng, NUM_BASIC, shuffle, type Tile } from './tiles';
9+import type { SeatId } from './types';
10+
11+/** Four copies of everything, minus nothing — the view from an empty table. */
12+const allUnseen = () => new Array<number>(NUM_BASIC).fill(4);
13+
14+describe('向聽 shanten', () => {
15+ it('calls a finished 17-tile hand -1', () => {
16+ // 123m 456m 789m 111s 東東東 + 白白
17+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 33];
18+ expect(shanten(h, 0)).toBe(-1);
19+ });
20+
21+ it('calls a 16-tile hand waiting on one tile 0', () => {
22+ // the same hand less the second 白 — 單釣白
23+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
24+ expect(shanten(h, 0)).toBe(0);
25+ });
26+
27+ it('agrees with the win check on every tile a ready hand could draw', () => {
28+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
29+ for (let t = 0; t < NUM_BASIC; t++) {
30+ expect(isWinningHand([...h, t], 0)).toBe(shanten([...h, t], 0) === -1);
31+ }
32+ });
33+
34+ it('counts exposed melds as sets already made', () => {
35+ // four melds down, 456m concealed, 單釣白
36+ const h: Tile[] = [3, 4, 5, 33];
37+ expect(shanten(h, 4)).toBe(0);
38+ expect(shanten([3, 4, 5, 33, 33], 4)).toBe(-1);
39+ });
40+
41+ it('sees a hand with nothing in it as far from home', () => {
42+ // one of everything, no pair, no run: 萬 1 3 5 7 9 with scattered honours
43+ const h: Tile[] = [0, 4, 8, 13, 22, 27, 28, 29, 30, 31, 32, 33, 0, 4, 8, 13];
44+ expect(shanten(h, 0)).toBeGreaterThan(2);
45+ });
46+
47+ it('never worsens when a tile is added', () => {
48+ const h: Tile[] = [0, 0, 1, 2, 9, 10, 11, 18, 18, 18, 27, 27, 31, 31, 5, 6];
49+ const before = shanten(h, 0);
50+ for (let t = 0; t < NUM_BASIC; t++) {
51+ expect(shanten([...h, t], 0)).toBeLessThanOrEqual(before);
52+ }
53+ });
54+
55+ it('names the tiles that would actually advance the hand', () => {
56+ // 123m 456m 789m 111s 東東東 白 — only 白 finishes it
57+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
58+ const u = ukeire(h, 0, allUnseen());
59+ expect(u.tiles).toEqual([33]);
60+ // `unseen` is the caller's own bookkeeping — this one has counted nothing.
61+ expect(u.count).toBe(4);
62+ });
63+});
64+
65+describe('bot discards', () => {
66+ it('throws the tile that is doing nothing', () => {
67+ // Everything is connected except the lone 北.
68+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 9, 10, 11, 18, 19, 20, 27, 27, 31, 31, 30];
69+ expect(botDiscard(h, 0, allUnseen())).toBe(30);
70+ });
71+
72+ it('never breaks a hand that is already ready', () => {
73+ // Five sets and two loose honours: whichever goes, the hand stays 聽牌.
74+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 30];
75+ const out = botDiscard(h, 0, allUnseen());
76+ expect([33, 30]).toContain(out);
77+ expect(shanten(h.filter((_, i) => i !== h.indexOf(out)), 0)).toBe(0);
78+ });
79+
80+ it('keeps the shape with more ways to improve when two discards tie', () => {
81+ // 45m (兩面, 8 tiles of 進張) against 13s (嵌張, 4) — the 嵌張 goes.
82+ const h: Tile[] = [3, 4, 9, 11, 18, 18, 18, 19, 19, 19, 20, 20, 20, 27, 27, 31, 31];
83+ const out = botDiscard(h, 0, allUnseen());
84+ expect([9, 11]).toContain(out);
85+ });
86+});
87+
88+describe('bot claims', () => {
89+ const table = () => {
90+ const g = new Game();
91+ g.newGame([false, true, true, true]);
92+ return g;
93+ };
94+
95+ it('always takes the win', () => {
96+ const g = table();
97+ const seat: SeatId = 1;
98+ const p = g.state.players[seat];
99+ p.melds = [];
100+ p.hand = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
101+ const claim = botClaim(g.state, seat, 33, [{ type: 'hu' }, { type: 'pung' }]);
102+ expect(claim).toEqual({ type: 'hu' });
103+ });
104+
105+ it('takes a 碰 that turns 一向聽 into 聽牌', () => {
106+ const g = table();
107+ const seat: SeatId = 1;
108+ const p = g.state.players[seat];
109+ p.melds = [];
110+ // 123m 456m 789m + 一二條 + 一筒對 + 中中 + a loose 北.
111+ p.hand = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 18, 18, 31, 31, 30];
112+ expect(shanten(p.hand, 0)).toBe(1);
113+ expect(botClaim(g.state, seat, 31, [{ type: 'pung' }])).toEqual({ type: 'pung' });
114+ });
115+
116+ it('passes on a 碰 that would only narrow its wait', () => {
117+ const g = table();
118+ const seat: SeatId = 1;
119+ const p = g.state.players[seat];
120+ p.melds = [];
121+ // Four sets and two pairs — already 聽牌 on either of them, and melding
122+ // one would throw the other wait away.
123+ p.hand = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 18, 18, 31, 31];
124+ expect(shanten(p.hand, 0)).toBe(0);
125+ expect(botClaim(g.state, seat, 31, [{ type: 'pung' }])).toBe('pass');
126+ });
127+
128+ it('passes on a 吃 that would only cost it flexibility', () => {
129+ const g = table();
130+ const seat: SeatId = 1;
131+ const p = g.state.players[seat];
132+ p.melds = [];
133+ // Five sets down and 單釣白; taking 四筒 off the table changes nothing.
134+ p.hand = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 18, 19, 20, 33];
135+ expect(botClaim(g.state, seat, 21, [{ type: 'chow', with: [19, 20] }])).toBe('pass');
136+ });
137+
138+ it('only counts tiles it can actually see', () => {
139+ const g = table();
140+ const seat: SeatId = 1;
141+ g.state.players[0].discards = [31, 31];
142+ const unseen = unseenFor(g.state, seat);
143+ // Two 中 on the table, plus however many this seat is holding itself.
144+ const own = g.state.players[seat].hand.filter((t) => t === 31).length;
145+ expect(unseen[31]).toBe(2 - own);
146+ // An opponent's concealed hand is none of the bot's business.
147+ g.state.players[2].hand = [31, 31];
148+ expect(unseenFor(g.state, seat)[31]).toBe(2 - own);
149+ });
150+});
151+
152+describe('a table of computer players', () => {
153+ /**
154+ * Nobody human at all: every seat a bot, driven flat out with the timers
155+ * taken away. If the bots can ever talk themselves into a position none of
156+ * them will move from, this is where it shows up.
157+ */
158+ const runHand = (seed: number) => {
159+ const g = new Game();
160+ g.newGame([true, true, true, true]);
161+ g.startHand(seed);
162+ let steps = 0;
163+ while (g.state.phase !== 'handEnd' && steps < 4000) {
164+ expect(stepBots(g)).toBe(true); // somebody always owes a move
165+ steps++;
166+ }
167+ return { game: g, steps };
168+ };
169+
170+ it('plays complete hands out, and wins a fair share of them', () => {
171+ let won = 0;
172+ for (let seed = 1; seed <= 30; seed++) {
173+ const { game, steps } = runHand(seed);
174+ const s = game.state;
175+ expect(s.phase).toBe('handEnd');
176+ expect(steps).toBeLessThan(4000);
177+
178+ let tiles = s.wall.length;
179+ for (const p of s.players) {
180+ tiles += p.hand.length + p.flowers.length + p.discards.length;
181+ for (const m of p.melds) tiles += m.tiles.length;
182+ }
183+ expect(tiles).toBe(144);
184+
185+ if (!s.result!.drawGame) won++;
186+ }
187+ // Real tables draw a fair few 16-tile hands, so this is a floor rather than
188+ // a target: a bot that never won one would mean the discard choice is not
189+ // actually taking the hand anywhere.
190+ expect(won).toBeGreaterThan(10);
191+ }, 30_000);
192+});
193+
194+describe('向聽 against the win checker', () => {
195+ /**
196+ * `hu.ts` is the authority on whether a hand is finished, and it works a
197+ * different way — an exhaustive split rather than a block count. Random
198+ * hands are dealt and the two are held against each other: a hand is 聽牌
199+ * exactly when `waits` finds something, and home exactly when `isWinningHand`
200+ * says so. Any disagreement is a bug in the fast one.
201+ */
202+ it('agrees on 聽牌 and on finished hands', () => {
203+ const rng = makeRng(20250817);
204+ for (let n = 0; n < 400; n++) {
205+ const melds = Math.floor(rng() * 5);
206+ const hand = shuffle(buildWall(), rng)
207+ .filter((t) => t < NUM_BASIC)
208+ .slice(0, (5 - melds) * 3 + 1);
209+ expect(shanten(hand, melds) <= 0).toBe(waits(hand, melds).length > 0);
210+ for (let t = 0; t < NUM_BASIC; t++) {
211+ const full = [...hand, t];
212+ expect(shanten(full, melds) === -1).toBe(isWinningHand(full, melds));
213+ }
214+ }
215+ }, 30_000);
216+});
217+
218+describe('reading the table', () => {
219+ /** A hand under way, with the seats posed by hand rather than dealt. */
220+ const table = () => {
221+ const g = new Game();
222+ g.newGame([false, true, true, true]);
223+ g.startHand(7);
224+ for (const p of g.state.players) {
225+ p.discards = [];
226+ p.melds = [];
227+ }
228+ return g;
229+ };
230+
231+ it('takes a seat more seriously the longer it has been playing', () => {
232+ const g = table();
233+ const early = readTable(g.state, 0, unseenFor(g.state, 0)).threat[1];
234+ g.state.players[1].discards = [0, 1, 2, 9, 10, 11, 18, 19, 20, 21];
235+ const late = readTable(g.state, 0, unseenFor(g.state, 0)).threat[1];
236+ expect(late).toBeGreaterThan(early);
237+ });
238+
239+ it('treats a tile an opponent threw themselves as safer than a fresh one', () => {
240+ const g = table();
241+ // Two middle tiles of the same rank; seat 1 has already let one of them go.
242+ g.state.players[1].discards = [4, 4, 4, 4, 4, 4];
243+ const { danger } = readTable(g.state, 0, unseenFor(g.state, 0));
244+ expect(danger[4]).toBeLessThan(danger[13]);
245+ });
246+
247+ it('treats 過水 as the confession it is', () => {
248+ const g = table();
249+ const plain = readTable(g.state, 0, unseenFor(g.state, 0));
250+ g.state.sacred[1] = [13];
251+ const read = readTable(g.state, 0, unseenFor(g.state, 0));
252+ // Locked out of winning on 五條, so it costs nothing to throw at that seat —
253+ // but the lock only exists because they were 聽牌 when it went past.
254+ expect(read.danger[13]).toBe(0);
255+ expect(read.threat[1]).toBeGreaterThan(plain.threat[1]);
256+ });
257+
258+ it('rates a lone honour below a middle tile, and a dead one below that', () => {
259+ const g = table();
260+ g.state.players[1].discards = [0, 1, 2, 9, 10, 11];
261+ const unseen = unseenFor(g.state, 0);
262+ const { danger } = readTable(g.state, 0, unseen);
263+ expect(danger[30]).toBeLessThan(danger[13]); // 北 against 五條
264+ // Every 白 accounted for: nobody can be waiting to pair or pung one.
265+ const allWhiteSeen = unseen.slice();
266+ allWhiteSeen[33] = 0;
267+ expect(readTable(g.state, 0, allWhiteSeen).danger[33]).toBeLessThan(danger[30]);
268+ });
269+
270+ it('gives the third dragon a very wide berth', () => {
271+ const g = table();
272+ g.state.players[1].discards = [0, 1, 2, 9, 10, 11];
273+ g.state.players[1].melds = [
274+ { kind: 'pung', tiles: [31, 31, 31], from: 0 },
275+ { kind: 'pung', tiles: [32, 32, 32], from: 0 },
276+ ];
277+ const { danger } = readTable(g.state, 0, unseenFor(g.state, 0));
278+ // 白 completes 大三元, and under 包牌 the feeder answers for the whole hand
279+ // — so it has to read as worse than the most dangerous ordinary tile going.
280+ expect(danger[33]).toBeGreaterThan(danger[13] * 2);
281+ expect(danger[33]).toBeGreaterThan(Math.max(...danger.filter((_, i) => i < 31)));
282+ });
283+
284+ it('will break its own shape to get out of the way, but not a ready hand', () => {
285+ const unseen = new Array<number>(NUM_BASIC).fill(4);
286+ const danger = new Array<number>(NUM_BASIC).fill(0.9);
287+ danger[30] = 0; // 北 is 現物
288+
289+ // Far from home: the safe tile is worth more than the shape.
290+ const scrappy: Tile[] = [0, 2, 4, 9, 11, 13, 18, 20, 22, 27, 28, 29, 31, 32, 33, 5, 30];
291+ expect(botDiscard(scrappy, 0, unseen, danger)).toBe(30);
292+
293+ // 聽牌 on 白: nothing on the table is worth giving that up for.
294+ const ready: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 30];
295+ const out = botDiscard(ready, 0, unseen, danger);
296+ expect(shanten(ready.filter((_, i) => i !== ready.indexOf(out)), 0)).toBe(0);
297+ });
298+});
299+
300+describe('流局', () => {
301+ /**
302+ * The draw panel turns the hands over: who was 聽牌 and on what. It reads
303+ * `tenpai`, which only answers for a hand at its resting size — so if a draw
304+ * ever left somebody holding an odd number of tiles the panel would quietly
305+ * report an empty table instead of the reveal.
306+ */
307+ it('leaves every hand readable, with waits to show', () => {
308+ let draws = 0;
309+ let ready = 0;
310+ for (let seed = 1; seed <= 200 && draws < 6; seed++) {
311+ const g = new Game();
312+ g.newGame([true, true, true, true]);
313+ g.startHand(seed);
314+ let steps = 0;
315+ while (g.state.phase !== 'handEnd' && steps++ < 4000) stepBots(g);
316+ if (!g.state.result!.drawGame) continue;
317+ draws++;
318+ for (const seat of [0, 1, 2, 3] as SeatId[]) {
319+ const p = g.state.players[seat];
320+ expect(p.hand.length).toBe((5 - p.melds.length) * 3 + 1);
321+ if (g.tenpai(seat).length > 0) ready++;
322+ }
323+ }
324+ expect(draws).toBeGreaterThan(0);
325+ // Hands that ran the wall out are usually close; an all-empty reveal would
326+ // mean the panel had nothing to say.
327+ expect(ready).toBeGreaterThan(0);
328+ }, 60_000);
329+});
addedsrc/game/bot.ts+231 −0
1+import { NO_DANGER, readTable } from './danger';
2+import type { Claim, ClaimOption, GameState } from './engine';
3+import { shanten, ukeire } from './shanten';
4+import { isHonor, isSuited, NUM_BASIC, rankOf, type Tile } from './tiles';
5+import type { SeatId } from './types';
6+
7+/**
8+ * The computer players.
9+ *
10+ * One idea, applied everywhere: a hand is judged at its resting size — the
11+ * (5 − melds) × 3 + 1 tiles you hold between a discard and your next draw —
12+ * by its 向聽 first and by how many tiles would advance it second. Every
13+ * decision below is the same comparison. Discard the tile that leaves the best
14+ * resting hand; take a 碰 / 吃 only when the hand it leaves is strictly closer
15+ * to home than the one you already have.
16+ *
17+ * The bot is scrupulous about what it knows. `unseenFor` counts only tiles
18+ * that are face up on the table or in the bot's own hand — it never reads an
19+ * opponent's tiles, and it never looks at the wall.
20+ *
21+ * All of it is pure: state in, decision out, no timers and no mutation. When
22+ * the bots should act, and how long they appear to think about it, is
23+ * `autoplay.ts`'s business.
24+ */
25+
26+/** How many of each basic tile this seat cannot account for yet. */
27+export function unseenFor(state: GameState, seat: SeatId): number[] {
28+ const unseen = new Array<number>(NUM_BASIC).fill(4);
29+ const drop = (t: Tile) => {
30+ if (t < NUM_BASIC) unseen[t]--;
31+ };
32+ for (const t of state.players[seat].hand) drop(t);
33+ for (const p of state.players) {
34+ for (const t of p.discards) drop(t);
35+ for (const m of p.melds) for (const t of m.tiles) drop(t);
36+ }
37+ // The tile currently on the table is in somebody's discard pile already.
38+ return unseen;
39+}
40+
41+/**
42+ * Everything a bot is allowed to know about the table, worked out once: what
43+ * is still out there, and what it would cost to throw each tile.
44+ */
45+export function botView(state: GameState, seat: SeatId): { unseen: number[]; danger: number[] } {
46+ const unseen = unseenFor(state, seat);
47+ return { unseen, danger: readTable(state, seat, unseen).danger };
48+}
49+
50+/** What a resting hand is worth: nearer home first, then more ways to improve. */
51+interface Value {
52+ shanten: number;
53+ ukeire: number;
54+}
55+
56+function valueOf(hand: Tile[], meldCount: number, unseen: number[]): Value {
57+ const sh = shanten(hand, meldCount);
58+ // A finished hand has nothing left to draw for, and counting it is wasted work.
59+ if (sh < 0) return { shanten: sh, ukeire: 0 };
60+ return { shanten: sh, ukeire: ukeire(hand, meldCount, unseen).count };
61+}
62+
63+const better = (a: Value, b: Value) =>
64+ a.shanten !== b.shanten ? a.shanten < b.shanten : a.ukeire > b.ukeire;
65+
66+/**
67+ * Separates tiles that are equally useless on paper. Prefers to let go of what
68+ * is hardest to build on: a lone honour whose copies are mostly gone is worth
69+ * less than a lone 五萬, which at least has neighbours it might meet.
70+ */
71+function dropRank(tile: Tile, hand: Tile[], unseen: number[]): number {
72+ const near = hand.filter((t) => t === tile || (isSuited(tile) && isSuited(t) && Math.abs(t - tile) <= 2 && Math.floor(t / 9) === Math.floor(tile / 9))).length - 1;
73+ let score = near === 0 ? 100 : 0; // nothing in hand relates to it
74+ if (isHonor(tile)) score += 20 + (4 - unseen[tile]) * 6; // and fewer left to pair with
75+ else if (rankOf(tile) === 1 || rankOf(tile) === 9) score += 10;
76+ else score += 5 - Math.abs(rankOf(tile) - 5);
77+ return score;
78+}
79+
80+/** One 向聽 step, in the same currency as everything else on the scoreboard. */
81+const SHANTEN_STEP = 100;
82+/** Past this much 進張 the extra hardly changes how the hand plays. */
83+const UKEIRE_CAP = 45;
84+/**
85+ * What a bot will pay to stay out of the way, by how close its own hand is.
86+ * At 聽牌 it pushes — no danger short of 包牌 is worth breaking a ready hand
87+ * for. Two or three away with somebody live across the table and it will give
88+ * up a whole 向聽 step to throw something safe, which is what folding is.
89+ */
90+const RISK_BY_SHANTEN = [40, 160, 320, 420];
91+
92+/** The tile to throw from a hand that is one over its resting size. */
93+export function botDiscard(
94+ hand: Tile[],
95+ meldCount: number,
96+ unseen: number[],
97+ danger: number[] = NO_DANGER,
98+): Tile {
99+ const choices = [...new Set(hand)];
100+ const scored = choices.map((t) => ({
101+ tile: t,
102+ value: valueOf(removeOne(hand, t), meldCount, unseen),
103+ rank: dropRank(t, hand, unseen),
104+ }));
105+
106+ // How much risk is worth taking is decided by the best the hand can do, not
107+ // by the tile under consideration — otherwise throwing the hand away would
108+ // make itself look cheap.
109+ const closest = Math.min(...scored.map((c) => c.value.shanten));
110+ const risk = RISK_BY_SHANTEN[Math.min(Math.max(closest, 0), RISK_BY_SHANTEN.length - 1)];
111+
112+ let best = scored[0];
113+ let bestScore = -Infinity;
114+ for (const c of scored) {
115+ const score =
116+ -c.value.shanten * SHANTEN_STEP +
117+ Math.min(c.value.ukeire, UKEIRE_CAP) -
118+ (danger[c.tile] ?? 0) * risk +
119+ c.rank * 0.05;
120+ if (score > bestScore) {
121+ bestScore = score;
122+ best = c;
123+ }
124+ }
125+ return best.tile;
126+}
127+
128+/**
129+ * Whether to take the tile on the table, and with what.
130+ *
131+ * 胡 is never turned down. Everything else has to earn itself: melding costs
132+ * the hand its concealment and its flexibility, so a 碰 or 吃 is only worth it
133+ * if the hand that comes out the other side is *strictly* closer to home. A 槓
134+ * is judged more kindly — it pays 台 and fetches a replacement tile, so
135+ * standing still is good enough.
136+ */
137+export function botClaim(
138+ state: GameState,
139+ seat: SeatId,
140+ tile: Tile,
141+ options: ClaimOption[],
142+): Claim | 'pass' {
143+ const p = state.players[seat];
144+ const melds = p.melds.length;
145+ const unseen = unseenFor(state, seat);
146+
147+ const win = options.find((o) => o.type === 'hu');
148+ if (win) return { type: 'hu' };
149+
150+ const now = valueOf(p.hand, melds, unseen);
151+ let best: { claim: Claim; value: Value } | null = null;
152+
153+ for (const o of options) {
154+ if (o.type === 'hu') continue;
155+ if (o.type === 'kong') {
156+ // Nothing left to discard afterwards — the replacement tile decides that.
157+ const rest = removeMany(p.hand, tile, 3);
158+ const v = valueOf(rest, melds + 1, unseen);
159+ if (v.shanten <= now.shanten && (best === null || better(v, best.value))) {
160+ best = { claim: { type: 'kong' }, value: v };
161+ }
162+ continue;
163+ }
164+ const used = o.type === 'pung' ? [tile, tile] : o.with!;
165+ let rest = p.hand;
166+ for (const t of used) rest = removeOne(rest, t);
167+ // A claim is followed by a discard, so judge the best hand it can leave.
168+ const after = bestAfterDiscard(rest, melds + 1, unseen);
169+ if (after.shanten < now.shanten && (best === null || better(after, best.value))) {
170+ best = { claim: { type: o.type, with: o.with }, value: after };
171+ }
172+ }
173+ return best ? best.claim : 'pass';
174+}
175+
176+/**
177+ * 暗槓 / 加槓 on the bot's own turn. Both leave the hand at its resting size
178+ * with one fewer set to find, so they are judged the same way a discard is,
179+ * and taken as long as the hand does not go backwards.
180+ */
181+export function botKong(
182+ state: GameState,
183+ seat: SeatId,
184+ concealed: Tile[],
185+ added: Tile[],
186+): { kind: 'ankong' | 'addkong'; tile: Tile } | null {
187+ if (concealed.length === 0 && added.length === 0) return null;
188+ const p = state.players[seat];
189+ const melds = p.melds.length;
190+ const unseen = unseenFor(state, seat);
191+ const keep = bestAfterDiscard(p.hand, melds, unseen);
192+
193+ let best: { kind: 'ankong' | 'addkong'; tile: Tile; value: Value } | null = null;
194+ for (const t of concealed) {
195+ const v = valueOf(removeMany(p.hand, t, 4), melds + 1, unseen);
196+ if (v.shanten <= keep.shanten && (best === null || better(v, best.value))) {
197+ best = { kind: 'ankong', tile: t, value: v };
198+ }
199+ }
200+ for (const t of added) {
201+ // The pung is already down; the fourth tile just moves out of hand.
202+ const v = valueOf(removeOne(p.hand, t), melds, unseen);
203+ if (v.shanten <= keep.shanten && (best === null || better(v, best.value))) {
204+ best = { kind: 'addkong', tile: t, value: v };
205+ }
206+ }
207+ return best ? { kind: best.kind, tile: best.tile } : null;
208+}
209+
210+/** The best resting hand reachable from `hand` by throwing one tile. */
211+function bestAfterDiscard(hand: Tile[], meldCount: number, unseen: number[]): Value {
212+ let best: Value | null = null;
213+ for (const t of new Set(hand)) {
214+ const v = valueOf(removeOne(hand, t), meldCount, unseen);
215+ if (best === null || better(v, best)) best = v;
216+ }
217+ return best ?? { shanten: 99, ukeire: 0 };
218+}
219+
220+function removeOne(tiles: Tile[], t: Tile): Tile[] {
221+ const out = tiles.slice();
222+ const i = out.indexOf(t);
223+ if (i >= 0) out.splice(i, 1);
224+ return out;
225+}
226+
227+function removeMany(tiles: Tile[], t: Tile, n: number): Tile[] {
228+ let out = tiles;
229+ for (let k = 0; k < n; k++) out = removeOne(out, t);
230+ return out;
231+}
addedsrc/game/danger.ts+135 −0
1+import type { GameState } from './engine';
2+import {
3+ EAST,
4+ isHonor,
5+ isSuited,
6+ NUM_BASIC,
7+ rankOf,
8+ RED,
9+ suitOf,
10+ WHITE,
11+ type Tile,
12+} from './tiles';
13+import type { Player, SeatId } from './types';
14+
15+const SEATS: SeatId[] = [0, 1, 2, 3];
16+
17+/**
18+ * Reading the table.
19+ *
20+ * Everything here comes off the face-up table — discard rows, exposed melds,
21+ * the 過水 locks the nameplates already show, and how much wall is left. No
22+ * opponent's concealed hand is ever touched; a bot that peeked would not be
23+ * playing the same game as the person across from it.
24+ *
25+ * Two questions, answered separately. `threat` asks how close each seat looks
26+ * to being home, which decides how much notice to take of them at all.
27+ * `danger` asks how likely a particular tile is to be the one they are sitting
28+ * on. What the bot does with the answer is `bot.ts`'s business — a hand that is
29+ * one tile from winning pushes through danger the same way a person would.
30+ */
31+
32+export interface TableRead {
33+ /** Per seat, roughly how close to home they look. 0..1, own seat always 0. */
34+ threat: number[];
35+ /** Per basic tile, how costly it looks to throw right now. 0 is 現物-safe. */
36+ danger: number[];
37+}
38+
39+/**
40+ * How many wait shapes a tile can complete at all. An honour can only ever be
41+ * caught by a pair or a triplet; a 五萬 sits in the middle of every run that
42+ * passes through it, which is why it is the last thing anyone throws late.
43+ */
44+function shapeRisk(t: Tile): number {
45+ if (isHonor(t)) return 0.28;
46+ const r = rankOf(t);
47+ if (r === 1 || r === 9) return 0.55;
48+ if (r === 2 || r === 8) return 0.8;
49+ return 1;
50+}
51+
52+/**
53+ * Late middle tiles are the tell. Nobody throws 五條 out of a hand that still
54+ * needs shaping — it comes out once the shape is finished and the tile has
55+ * nowhere left to go.
56+ */
57+function lateMiddleShare(p: Player): number {
58+ if (p.discards.length < 4) return 0;
59+ const recent = p.discards.slice(-4);
60+ const middle = recent.filter((t) => isSuited(t) && rankOf(t) >= 3 && rankOf(t) <= 7).length;
61+ return middle / recent.length;
62+}
63+
64+/** Pungs and kongs of a kind — the melds that say what a hand is going for. */
65+const tripletsOf = (p: Player) => p.melds.filter((m) => m.kind !== 'chow');
66+
67+/** The suit a seat has visibly committed to, if their melds all point one way. */
68+function flushSuit(p: Player): string | null {
69+ const suits = new Set(
70+ p.melds.flatMap((m) => m.tiles.filter(isSuited).map((t) => suitOf(t))),
71+ );
72+ if (p.melds.length < 2 || suits.size !== 1) return null;
73+ return [...suits][0];
74+}
75+
76+/**
77+ * Turns, not tiles. A hand is usually settled inside ten goes each, long before
78+ * the wall looks anywhere near finished, so counting how many times a seat has
79+ * actually played is a far better clock than how much wall is left.
80+ */
81+const TURNS_TO_READY = 10;
82+
83+export function readTable(state: GameState, seat: SeatId, unseen: number[]): TableRead {
84+ const threat = [0, 0, 0, 0];
85+ const danger = new Array<number>(NUM_BASIC).fill(0);
86+
87+ for (const o of SEATS) {
88+ if (o === seat) continue;
89+ const p = state.players[o];
90+
91+ const turns = Math.min(1, p.discards.length / TURNS_TO_READY);
92+ let t = 0.16 * p.melds.length + 0.5 * turns + 0.3 * lateMiddleShare(p);
93+ // 過水 is a confession: to be locked out of a tile they had to have been
94+ // able to win on it, which means they were 聽牌 when it went past.
95+ if (state.sacred[o].length > 0) t += 0.3;
96+ threat[o] = Math.min(1, t);
97+
98+ const flush = flushSuit(p);
99+ const trips = tripletsOf(p);
100+ const dragons = trips.filter((m) => m.tiles[0] >= RED && m.tiles[0] <= WHITE).length;
101+ const winds = trips.filter((m) => m.tiles[0] >= EAST && m.tiles[0] < RED).length;
102+ // 包牌 — hand over the third dragon or the fourth wind and the whole hand
103+ // is billed to you alone. Worth going a long way round to avoid.
104+ const baoDragon = state.rules.liability && dragons === 2;
105+ const baoWind = state.rules.liability && winds === 3;
106+
107+ for (let tile = 0; tile < NUM_BASIC; tile++) {
108+ // 過水 — they have already passed on winning with this one.
109+ if (state.rules.sacredDiscard && state.sacred[o].includes(tile)) continue;
110+
111+ let d = shapeRisk(tile);
112+ // They threw it themselves, so it was not their tile when it went down.
113+ // Their hand has moved on since, but it is still the best evidence there is.
114+ if (p.discards.includes(tile)) d *= 0.35;
115+
116+ // A pair or triplet wait needs live copies; without any, only a run can
117+ // catch this tile — and nothing at all can catch a lone honour.
118+ if (unseen[tile] <= 0) d *= isHonor(tile) ? 0.12 : 0.55;
119+ else if (unseen[tile] === 1) d *= 0.8;
120+
121+ if (flush !== null) d *= suitOf(tile) === flush ? 1.7 : isHonor(tile) ? 1.25 : 0.6;
122+ const isDragon = tile >= RED && tile <= WHITE;
123+ const isWind = tile >= EAST && tile < RED;
124+ // Honours are cheap to hold on to, and the bill for getting this one
125+ // wrong is the entire hand, so the multiplier is deliberately lopsided.
126+ if ((baoDragon && isDragon) || (baoWind && isWind)) d *= 10;
127+
128+ danger[tile] = Math.max(danger[tile], threat[o] * d);
129+ }
130+ }
131+ return { threat, danger };
132+}
133+
134+/** A table nobody is reading — the shape of `danger` when safety is switched off. */
135+export const NO_DANGER: number[] = new Array<number>(NUM_BASIC).fill(0);
modifiedsrc/game/engine.ts+76 −9
⋯ 14 unchanged lines
1515 type Tile,
1616 } from './tiles';
1717 import { clearSave, saveGame } from './save';
18-import { DEFAULT_NAMES, DEFAULT_RULES, type Meld, type Player, type Rules, type SeatId } from './types';
18+import {
19+ BOT_NAMES,
20+ DEFAULT_NAMES,
21+ DEFAULT_RULES,
22+ type Meld,
23+ type Player,
24+ type Rules,
25+ type SeatId,
26+} from './types';
1927
2028 export type Phase = 'lobby' | 'turn' | 'claims' | 'robkong' | 'handEnd' | 'gameEnd';
2129
⋯ 58 unchanged lines
8088 export interface GameState {
8189 rules: Rules;
8290 players: Player[];
91+ /**
92+ * Which seats the computer plays. All false is the hotseat game everyone
93+ * shares one screen for; seats 1-3 true is the single-player table. It lives
94+ * in the state rather than beside it so a saved game resumes as the kind of
95+ * game it was.
96+ */
97+ bots: boolean[];
8398 wall: Tile[];
8499 dealer: SeatId;
85100 dealerStreak: number;
⋯ 52 unchanged lines
138153 discards: [],
139154 score: rules.startingScore,
140155 })),
156+ bots: [false, false, false, false],
141157 wall: [],
142158 dealer: 0,
143159 dealerStreak: 0,
⋯ 47 unchanged lines
191207 if (this.undoStack.length > UNDO_DEPTH) this.undoStack.shift();
192208 }
193209
210+ /**
211+ * Which snapshot an undo would restore. At a hotseat table that is simply
212+ * the last one. With computer players it is the last point a *person* had a
213+ * decision to make: rewinding into the middle of the computer's turn would
214+ * only hand the move straight back to it.
215+ */
216+ private undoIndex(): number {
217+ if (this.undoStack.length === 0) return -1;
218+ if (!this.hasBots) return this.undoStack.length - 1;
219+ for (let i = this.undoStack.length - 1; i >= 0; i--) {
220+ if (humanCanAct(this.undoStack[i].state)) return i;
221+ }
222+ return -1;
223+ }
224+
194225 /** What undo would take back right now, or null if there is nothing. */
195226 undoable(): { zh: string; en: string } | null {
196- const top = this.undoStack[this.undoStack.length - 1];
197- return top ? { zh: top.zh, en: top.en } : null;
227+ const i = this.undoIndex();
228+ if (i < 0) return null;
229+ // Against the computer an undo swallows its replies too, so naming only
230+ // the most recent move would be a lie about what is coming back.
231+ if (this.hasBots) return { zh: '上一步', en: 'your last move' };
232+ const snap = this.undoStack[i];
233+ return { zh: snap.zh, en: snap.en };
198234 }
199235
200236 undo() {
201- const snap = this.undoStack.pop();
202- if (!snap) return;
203- this.state = snap.state;
237+ const i = this.undoIndex();
238+ if (i < 0) return;
239+ this.state = this.undoStack[i].state;
240+ this.undoStack.length = i;
204241 this.sfx('undo');
205242 this.emit();
206243 }
207244
245+ get hasBots() {
246+ return this.state.bots.some(Boolean);
247+ }
248+ isBot(seat: SeatId) {
249+ return this.state.bots[seat];
250+ }
251+
208252 private soundListeners = new Set<(cue: SoundCue) => void>();
209253 /** Sound is a side channel, not state: it is never saved and never replayed. */
210254 onSound = (fn: (cue: SoundCue) => void) => {
⋯ 15 unchanged lines
226270 const s = this.state;
227271 s.rules = rules;
228272 names.forEach((name, i) => {
229- s.players[i].name = name;
273+ // The computer keeps the name it was given; the settings screen is about
274+ // the people at the table.
275+ if (!s.bots[i]) s.players[i].name = name;
230276 });
231277 if (s.phase === 'lobby') for (const p of s.players) p.score = rules.startingScore;
232278 this.emit();
⋯ 644 unchanged lines
877923 this.emit();
878924 }
879925
880- /** Reset chips, dealer and round, then deal a fresh game. */
881- newGame() {
926+ /**
927+ * Reset chips, dealer and round, then deal a fresh game. `bots` says which
928+ * seats the computer takes — omitted, everyone at the table is a person.
929+ */
930+ newGame(bots: boolean[] = [false, false, false, false], names?: string[]) {
882931 const s = this.state;
883932 clearSave();
933+ s.bots = bots.slice();
934+ let botN = 0;
935+ for (const seat of SEATS) {
936+ // Seats change hands between games, so the nameplates are redrawn here:
937+ // a chair the computer has just vacated gets its person's name back.
938+ s.players[seat].name = s.bots[seat]
939+ ? BOT_NAMES[botN++ % BOT_NAMES.length]
940+ : names?.[seat] ?? s.players[seat].name;
941+ }
884942 for (const p of s.players) p.score = s.rules.startingScore;
885943 s.dealer = 0;
886944 s.dealerStreak = 0;
⋯ 26 unchanged lines
913971 }
914972 }
915973
974+/** Is there anything for a person to do in this position, or is it the computer's? */
975+function humanCanAct(s: GameState): boolean {
976+ if (s.phase === 'turn') return !s.bots[s.turn];
977+ if (s.phase === 'claims' || s.phase === 'robkong') {
978+ return SEATS.some((seat) => !s.bots[seat] && s.options[seat] && s.responses[seat] === undefined);
979+ }
980+ return false;
981+}
982+
916983 function removeOne(tiles: Tile[], t: Tile): Tile[] {
917984 const out = tiles.slice();
918985 const i = out.indexOf(t);
⋯ 20 unchanged lines
modifiedsrc/game/save.ts+2 −1
⋯ 2 unchanged lines
33
44 const KEY = 'taiwanese-mahjong/save';
55 /** Bump when GameState changes shape; older saves are then ignored rather than crashing. */
6-const VERSION = 2;
6+const VERSION = 3;
77
88 export interface SavedGame {
99 version: number;
⋯ 42 unchanged lines
5252 if (!Array.isArray(s.wall) || typeof s.phase !== 'string') return false;
5353 if (s.phase === 'lobby') return false;
5454 if (!Array.isArray(s.sacred) || s.sacred.length !== 4) return false;
55+ if (!Array.isArray(s.bots) || s.bots.length !== 4) return false;
5556 if (!s.rules || typeof s.rules.base !== 'number') return false;
5657
5758 let tiles = s.wall.length;
⋯ 8 unchanged lines
addedsrc/game/shanten.ts+151 −0
1+import { NUM_BASIC, toCounts, type Tile } from './tiles';
2+
3+/**
4+ * 向聽 — how many tiles a hand still has to exchange before it is 聽牌.
5+ * 0 is ready, -1 is already a winning hand.
6+ *
7+ * `hu.ts` answers "is this hand finished"; this answers "how far off is it",
8+ * which is the only question a computer player ever really asks. The two are
9+ * kept apart because the win check has to be exact and this one has to be
10+ * fast — it runs a few hundred times for every discard a bot considers.
11+ *
12+ * Everything here counts a hand at its *resting* size: (5 − melds) × 3 + 1
13+ * tiles, the shape you are in between a discard and your next draw. Comparing
14+ * two lines of play means comparing them at that size, never a 17-tile hand
15+ * against a 16-tile one.
16+ */
17+
18+/** One way of reading a group of tiles: complete sets, pairs, and other partials. */
19+interface Profile {
20+ sets: number;
21+ /** Incomplete runs — 兩面, 嵌張, 邊張. Pairs are counted separately. */
22+ partials: number;
23+ pairs: number;
24+}
25+
26+/**
27+ * Suits are independent, so each is read on its own and the readings combined.
28+ * The same nine-tile shape comes up constantly across a search, hence the cache.
29+ */
30+const profileCache = new Map<string, Profile[]>();
31+
32+function profilesOf(counts: number[], runs: boolean): Profile[] {
33+ const key = (runs ? 's' : 'h') + counts.join('');
34+ const hit = profileCache.get(key);
35+ if (hit) return hit;
36+
37+ const c = counts.slice();
38+ const out: Profile[] = [];
39+ const seen = new Set<number>();
40+
41+ const record = (sets: number, partials: number, pairs: number) => {
42+ const id = (sets * 8 + partials) * 8 + pairs;
43+ if (seen.has(id)) return;
44+ seen.add(id);
45+ out.push({ sets, partials, pairs });
46+ };
47+
48+ const walk = (i: number, sets: number, partials: number, pairs: number) => {
49+ while (i < c.length && c[i] === 0) i++;
50+ if (i === c.length) return record(sets, partials, pairs);
51+
52+ if (c[i] >= 3) {
53+ c[i] -= 3;
54+ walk(i, sets + 1, partials, pairs);
55+ c[i] += 3;
56+ }
57+ if (c[i] >= 2) {
58+ c[i] -= 2;
59+ walk(i, sets, partials, pairs + 1);
60+ c[i] += 2;
61+ }
62+ if (runs && i <= 6 && c[i + 1] > 0 && c[i + 2] > 0) {
63+ c[i]--; c[i + 1]--; c[i + 2]--;
64+ walk(i, sets + 1, partials, pairs);
65+ c[i]++; c[i + 1]++; c[i + 2]++;
66+ }
67+ if (runs && i <= 7 && c[i + 1] > 0) {
68+ c[i]--; c[i + 1]--;
69+ walk(i, sets, partials + 1, pairs);
70+ c[i]++; c[i + 1]++;
71+ }
72+ if (runs && i <= 6 && c[i + 2] > 0) {
73+ c[i]--; c[i + 2]--;
74+ walk(i, sets, partials + 1, pairs);
75+ c[i]++; c[i + 2]++;
76+ }
77+ // ...or the tile is doing nothing for us, in this reading at least.
78+ c[i]--;
79+ walk(i, sets, partials, pairs);
80+ c[i]++;
81+ };
82+
83+ walk(0, 0, 0, 0);
84+ profileCache.set(key, out);
85+ return out;
86+}
87+
88+/**
89+ * The classic count: every set is worth two tiles of progress and every
90+ * partial one, up to the `need + 1` blocks a finished hand has room for, and a
91+ * hand with no pair at all still owes one tile for it.
92+ */
93+function shantenOf(p: Profile, need: number): number {
94+ const sets = Math.min(p.sets, need);
95+ const blocks = Math.min(p.partials + p.pairs, need + 1 - sets);
96+ const noPair = p.pairs === 0 && sets + blocks === need + 1 ? 1 : 0;
97+ return 2 * need - 2 * sets - blocks + noPair;
98+}
99+
100+const GROUPS: [number, number, boolean][] = [
101+ [0, 9, true], // 萬
102+ [9, 18, true], // 條
103+ [18, 27, true], // 筒
104+ [27, NUM_BASIC, false], // 字牌 — no runs
105+];
106+
107+/** 向聽 of a concealed hand backed by `meldCount` exposed sets. */
108+export function shanten(tiles: Tile[], meldCount: number): number {
109+ const need = 5 - meldCount;
110+ const counts = toCounts(tiles);
111+
112+ // Fold the suits together, keeping one entry per distinct reading. The state
113+ // space is tiny — sets, partials and pairs are all bounded by six.
114+ let states = new Map<number, Profile>([[0, { sets: 0, partials: 0, pairs: 0 }]]);
115+ for (const [lo, hi, runs] of GROUPS) {
116+ const merged = new Map<number, Profile>();
117+ for (const a of states.values()) {
118+ for (const b of profilesOf(counts.slice(lo, hi), runs)) {
119+ const sets = Math.min(a.sets + b.sets, need);
120+ const partials = Math.min(a.partials + b.partials, need + 1);
121+ const pairs = Math.min(a.pairs + b.pairs, need + 1);
122+ const id = (sets * 8 + partials) * 8 + pairs;
123+ if (!merged.has(id)) merged.set(id, { sets, partials, pairs });
124+ }
125+ }
126+ states = merged;
127+ }
128+
129+ let best = 99;
130+ for (const st of states.values()) best = Math.min(best, shantenOf(st, need));
131+ return best;
132+}
133+
134+/**
135+ * 進張 — which tiles would bring the hand a step closer, and how many of each
136+ * are still out there. `unseen` is counted from the caller's own point of
137+ * view: the bot only ever subtracts what it can actually see.
138+ */
139+export function ukeire(tiles: Tile[], meldCount: number, unseen: number[]): { tiles: Tile[]; count: number } {
140+ const now = shanten(tiles, meldCount);
141+ const out: Tile[] = [];
142+ let count = 0;
143+ for (let t = 0; t < NUM_BASIC; t++) {
144+ if (unseen[t] <= 0) continue;
145+ if (shanten([...tiles, t], meldCount) < now) {
146+ out.push(t);
147+ count += unseen[t];
148+ }
149+ }
150+ return { tiles: out, count };
151+}
modifiedsrc/game/types.ts+3 −0
⋯ 4 unchanged lines
55 /** Placeholder names, until somebody types their own on the settings screen. */
66 export const DEFAULT_NAMES = ['玩家 1', '玩家 2', '玩家 3', '玩家 4'];
77
8+/** What the three computer players are called, in seat order after the human. */
9+export const BOT_NAMES = ['電腦 甲', '電腦 乙', '電腦 丙'];
10+
811 export type MeldKind =
912 | 'chow' // 吃
1013 | 'pung' // 碰
⋯ 62 unchanged lines
modifiedsrc/styles.css+105 −7
⋯ 5 unchanged lines
66 --depth: clamp(232px, 30vh, 330px);
77 --h-len: max(160px, calc(100vw - 2 * var(--depth)));
88 --v-len: 100vh;
9+ /* Height the action buttons occupy, held whether or not any are showing.
10+ The strip is built from its edge inwards, so a bar that grew when 打出
11+ appeared would shove the melds and discards along with it — the reservation
12+ is what keeps everything above it still. Sized for the tallest button: the
13+ ones that name a tile (打出 五萬, 吃 三萬 四萬) carry a third line. */
14+ --bar-h: 52px;
915 /* Largest tile a strip can hold. Edge inwards it has to fit one row of
10- discards (0.88 tiles tall), the melds (0.86), the buttons (56px, once one
11- is actually in there), the hand (1.71 + 6px) and the nameplate (28px),
12- plus its own padding, gaps and margins — so 3.45 tiles plus 112px.
16+ discards (0.88 tiles tall), the melds (0.86), the button bar, the hand
17+ (1.71 + 6px) and the nameplate (28px), plus its own padding, gaps and
18+ margins — so 3.45 tiles plus 124px.
1319
1420 Tile size otherwise follows the strip's *length*, which is why a window
1521 with less height than F11 kept full-size tiles and left the discards a
1622 sliver of a row to be cut off in. */
17- --tile-fit: calc((var(--depth) - 112px) / 3.45);
23+ --tile-fit: calc((var(--depth) - 124px) / 3.45);
1824
1925 --felt: #0f4534;
2026 --felt-2: #0a3126;
⋯ 320 unchanged lines
341347 justify-content: flex-end;
342348 padding-right: calc(var(--tile-w) * 0.4);
343349 align-self: stretch;
344- min-height: 40px;
350+ /* Reserved even when empty — see --bar-h. */
351+ min-height: var(--bar-h);
345352 flex-wrap: wrap;
346353 flex: none;
347354 }
355+/* Every button on the bar is the same height, so the row is the same height
356+ whichever ones are up: without this a 打出 五萬 stands taller than a bare 過
357+ and the strip shuffles itself every time you lift a tile. */
358+.actionbar .btn {
359+ height: var(--bar-h);
360+ min-height: 0;
361+ padding-top: 4px;
362+ padding-bottom: 4px;
363+}
348364 .waiting {
349365 font-size: 12px;
350366 opacity: 0.5;
⋯ 349 unchanged lines
700716 font-weight: 700;
701717 color: var(--gold);
702718 }
719+/* 胡牌 — the arrow that lands on the winner's edge of the table. Sits over the
720+ strip rather than in it, so the hand underneath keeps the height it had and
721+ nothing shifts when it appears. */
722+.win-arrow {
723+ position: absolute;
724+ top: 0;
725+ left: 50%;
726+ font-size: 52px;
727+ line-height: 0.9;
728+ color: var(--gold);
729+ text-shadow: 0 0 6px #0a3126, 0 0 20px rgba(217, 180, 92, 0.9);
730+ pointer-events: none;
731+ z-index: 4;
732+ animation: arrow-nudge 0.9s ease-in-out infinite;
733+}
734+@keyframes arrow-nudge {
735+ 0%,
736+ 100% {
737+ transform: translate(-50%, 0);
738+ }
739+ 50% {
740+ transform: translate(-50%, 9px);
741+ }
742+}
743+
744+/* 流局 — who was there and what they were sitting on. */
745+.ready-list {
746+ display: flex;
747+ flex-direction: column;
748+ gap: 6px;
749+ margin-top: 2px;
750+}
751+.ready-row {
752+ display: flex;
753+ align-items: center;
754+ gap: 8px;
755+ font-size: 14px;
756+}
757+.ready-row.none {
758+ opacity: 0.45;
759+}
760+.ready-name {
761+ min-width: 6.5em;
762+ text-align: right;
763+}
764+.ready-row .tag.ting {
765+ font-size: 11px;
766+ padding: 1px 6px;
767+ border-radius: 999px;
768+ background: rgba(46, 170, 120, 0.2);
769+ border: 1px solid rgba(46, 170, 120, 0.6);
770+ white-space: nowrap;
771+}
772+.ready-tiles {
773+ display: flex;
774+ flex-wrap: wrap;
775+ gap: 2px;
776+ /* Tiles size themselves off their strip's --tile-w, and the centre is not in
777+ one — without a size of its own here they come out 0 x 0 and vanish. */
778+ --tile-w: 46px;
779+}
780+.ready-no {
781+ font-size: 12px;
782+ letter-spacing: 0.04em;
783+}
784+
703785 .deltas {
704786 display: flex;
705787 gap: 16px;
⋯ 179 unchanged lines
885967 }
886968 .lobby-buttons {
887969 display: flex;
888- gap: 14px;
970+ flex-direction: column;
971+ gap: 12px;
889972 margin-top: 1.6rem;
890- align-items: center;
973+ align-items: stretch;
974+}
975+/* The two ways to play carry a line of explanation under the name. */
976+.lobby-buttons .big-btn {
977+ display: flex;
978+ flex-direction: column;
979+ gap: 3px;
980+ min-width: 360px;
981+ padding: 12px 28px;
982+}
983+.lobby-buttons .big-btn em {
984+ font-style: normal;
985+ font-size: 12px;
986+ font-weight: 400;
987+ letter-spacing: 0.03em;
988+ opacity: 0.72;
891989 }
892990
893991 /* ---------- sound ---------- */
⋯ 284 unchanged lines
modifiedsrc/ui/Center.tsx+33 −3
⋯ 21 unchanged lines
2222 const pending = (Object.keys(s.options) as unknown as number[])
2323 .map(Number)
2424 .filter((k) => s.responses[k as SeatId] === undefined) as SeatId[];
25- const facing = game.nextDrawer() ?? s.turn;
25+ // Against the computer there is only one person reading this, and they are
26+ // sitting at the bottom of the screen.
27+ const human = s.bots.indexOf(false);
28+ const facing = human >= 0 && game.hasBots ? (human as SeatId) : game.nextDrawer() ?? s.turn;
2629 // Deliberately not per-seat: whoever spots the mis-tap should be able to
2730 // reach it, not just the player who made it.
2831 const undoable = s.phase === 'gameEnd' ? null : game.undoable();
⋯ 21 unchanged lines
5053 {r && s.phase !== 'gameEnd' && (
5154 <div className="result">
5255 {r.drawGame ? (
53- <h2>流局 · Draw</h2>
56+ <>
57+ <h2>流局 · Draw</h2>
58+ {/* Nobody won, so the thing everyone wants to see is who was
59+ there and on what — the same reveal as turning the hands over
60+ at a real table. */}
61+ <div className="ready-list">
62+ {s.players.map((p, seat) => {
63+ const waiting = game.tenpai(seat as SeatId);
64+ return (
65+ <div className={`ready-row ${waiting.length ? '' : 'none'}`} key={seat}>
66+ <span className="ready-name">{p.name}</span>
67+ {waiting.length > 0 ? (
68+ <>
69+ <span className="tag ting">聽 Ready</span>
70+ <span className="ready-tiles">
71+ {waiting.map((t) => (
72+ <TileView key={t} tile={t} size="small" />
73+ ))}
74+ </span>
75+ </>
76+ ) : (
77+ <span className="ready-no">未聽 · not ready</span>
78+ )}
79+ </div>
80+ );
81+ })}
82+ </div>
83+ </>
5484 ) : (
5585 <>
5686 {/* One block per winner — more than one only under 一炮多響. */}
⋯ 53 unchanged lines
110140 ))}
111141 </div>
112142 <div className="over-buttons">
113- <button className="btn big-btn" onClick={() => game.newGame()}>
143+ <button className="btn big-btn" onClick={() => game.newGame(s.bots)}>
114144 <Bi zh="再來一將" en="New game" />
115145 </button>
116146 {/* The moment anyone is going to want the names or the stakes changed. */}
⋯ 24 unchanged lines
modifiedsrc/ui/Hand.tsx+15 −0
⋯ 3 unchanged lines
44
55 interface Props {
66 tiles: Tile[];
7+ /** Somebody else's hand: shown as backs, and not to be poked at. */
8+ faceDown?: boolean;
79 /** Index of the tile held apart from the rest, if any. */
810 drawnIndex: number | null;
911 selected: number | null;
⋯ 36 unchanged lines
4648
4749 export function Hand({
4850 tiles,
51+ faceDown,
4952 drawnIndex,
5053 selected,
5154 canDiscard,
⋯ 35 unchanged lines
8790 else onSelect(d.from);
8891 };
8992
93+ if (faceDown) {
94+ return (
95+ <div className="hand">
96+ {tiles.map((_, i) => (
97+ <div key={i} className="hand-cell">
98+ <TileView faceDown />
99+ </div>
100+ ))}
101+ </div>
102+ );
103+ }
104+
90105 return (
91106 <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
92107 {tiles.map((t, i) => (
⋯ 17 unchanged lines
modifiedsrc/ui/Seat.tsx+35 −12
⋯ 20 unchanged lines
2121 const s = game.state;
2222 const p = s.players[seat];
2323 const [sel, setSel] = useState<number | null>(null);
24- const myTurn = s.phase === 'turn' && s.turn === seat;
25- const opts = s.options[seat];
24+ // A computer seat has no buttons and no readable hand: its tiles go face down
25+ // and its 聽 / 過水 badges stay off, since both would give the hand away.
26+ // At the end of a hand everyone turns their tiles over, as they would.
27+ const bot = s.bots[seat];
28+ const concealed = bot && s.phase !== 'handEnd' && s.phase !== 'gameEnd';
29+ const myTurn = s.phase === 'turn' && s.turn === seat && !bot;
30+ const opts = bot ? undefined : s.options[seat];
2631 const responded = s.responses[seat] !== undefined;
2732 const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded;
2833 // Whoever draws next closes the claim window — nobody has to wait on a
2934 // player who hasn't noticed their buttons.
30- const canProceed = game.nextDrawer() === seat;
35+ const canProceed = game.nextDrawer() === seat && !bot;
3136
3237 useEffect(() => {
3338 setSel(null);
⋯ 2 unchanged lines
3641 const seatWind = (seat - s.dealer + 4) % 4;
3742 const isDealer = seat === s.dealer;
3843 const live = s.phase !== 'handEnd' && s.phase !== 'lobby';
39- const tenpai = live ? game.tenpai(seat) : [];
40- const sacred = live ? game.sacredTiles(seat) : [];
44+ const tenpai = live && !bot ? game.tenpai(seat) : [];
45+ const sacred = live && !bot ? game.sacredTiles(seat) : [];
4146 const concealedKongs = myTurn ? game.concealedKongs(seat) : [];
4247 const addedKongs = myTurn ? game.addedKongs(seat) : [];
4348 const canHu = myTurn && game.canSelfDrawWin(seat);
4449 const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0;
50+ // 一炮多響 can put more than one seat here, so this is a `some`, not a find.
51+ const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat);
4552
4653 const doDiscard = (i: number) => {
4754 game.discard(seat, p.hand[i]);
⋯ 11 unchanged lines
5966 className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`}
6067 style={{ transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }}
6168 >
69+ {/* Points down the strip at whoever won. It is inside the seat, so it
70+ turns with it and lands on their edge of the table wherever they
71+ are sitting — no working out which way is which. */}
72+ {won && (
73+ <div className="win-arrow" aria-hidden>
74+ ▼
75+ </div>
76+ )}
77+
6278 <div className="discards">
6379 {p.discards.map((t, i) => (
6480 <TileView
⋯ 38 unchanged lines
103119 />
104120 </button>
105121 ))}
106- <button className="btn pass" onClick={() => game.respond(seat, 'pass')}>
107- <Bi zh="過" en="Pass" />
108- </button>
122+ {/* The seat that draws next declines by picking up — see below. */}
123+ {!canProceed && (
124+ <button className="btn pass" onClick={() => game.respond(seat, 'pass')}>
125+ <Bi zh="過" en="Pass" />
126+ </button>
127+ )}
109128 </>
110129 )}
111130 {canProceed && (
112- <button className="btn proceed" onClick={() => game.resolveNow()}>
131+ <button
132+ className={`btn proceed ${claiming ? 'pass' : ''}`}
133+ onClick={() => game.resolveNow()}
134+ >
113135 <Bi
114- zh={s.phase === 'robkong' ? '繼續' : '摸牌'}
115- en={s.phase === 'robkong' ? 'Continue' : 'Draw'}
136+ zh={s.phase === 'robkong' ? '繼續' : claiming ? '過.摸牌' : '摸牌'}
137+ en={s.phase === 'robkong' ? 'Continue' : claiming ? 'Pass & draw' : 'Draw'}
116138 />
117139 </button>
118140 )}
⋯ 29 unchanged lines
148170
149171 <Hand
150172 tiles={p.hand}
173+ faceDown={concealed}
151174 drawnIndex={drawnIndex}
152175 selected={sel}
153176 canDiscard={myTurn}
⋯ 23 unchanged lines
177200 </span>
178201 )}
179202 <span className="chips">{p.score}</span>
180- {p.hand.length > 0 && s.phase !== 'handEnd' && (
203+ {p.hand.length > 0 && s.phase !== 'handEnd' && !bot && (
181204 <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
182205 理牌 Sort
183206 </button>
⋯ 9 unchanged lines
modifiedsrc/ui/Settings.tsx+5 −2
⋯ 8 unchanged lines
99 onClose: () => void;
1010 /** Stakes are only editable before the first hand of a game is dealt. */
1111 stakesLocked: boolean;
12+ /** Seats the computer is playing — there is nobody there to name. */
13+ bots: boolean[];
1214 }
1315
1416 interface Toggle {
⋯ 31 unchanged lines
4648 },
4749 ];
4850
49-export function Settings({ prefs, onSave, onClose, stakesLocked }: Props) {
51+export function Settings({ prefs, onSave, onClose, stakesLocked, bots }: Props) {
5052 // Edited locally and committed on 完成, so a half-typed name never reaches
5153 // the table and 取消 really does mean cancel.
5254 const [draft, setDraft] = useState<Prefs>(prefs);
⋯ 49 unchanged lines
102104 <label className="field" key={i}>
103105 <span className="f-label">{i + 1}</span>
104106 <input
105- value={n}
107+ value={bots[i] ? '電腦 Computer' : n}
108+ disabled={bots[i]}
106109 maxLength={12}
107110 placeholder={DEFAULT_NAMES[i]}
108111 onChange={(e) => {
⋯ 122 unchanged lines