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 | |||
| 6 | 6 | Every label is Chinese with English underneath; the tiles themselves stay | |
| 7 | 7 | Chinese, because that is what the tiles say. | |
| 8 | 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 | + | ||
| 9 | 13 | ``` | |
| 10 | 14 | npm install | |
| 11 | 15 | 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 | |
| 13 | 17 | ``` | |
| 14 | 18 | ||
| 15 | 19 | ## Screen layout | |
| ⋯ 38 unchanged lines | |||
| 54 | 58 | called — exactly like shouting 碰 before the next player picks up. A claim | |
| 55 | 59 | already declared still stands, so calling in time always wins the tile. If | |
| 56 | 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. | |
| 57 | 66 | - **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal. | |
| 58 | 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. | |
| 59 | 79 | - **Undo** — a 復原 button in the middle of the table takes back the last | |
| 60 | 80 | action, naming what it will undo (`復原 玩家 2 打 五萬`). It sits in the | |
| 61 | 81 | centre rather than on a seat because whoever *spots* the mis-tap should be | |
| ⋯ 20 unchanged lines | |||
| 82 | 102 | Stakes are locked once a game is under way — they'd otherwise rewrite chips | |
| 83 | 103 | already won. | |
| 84 | 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 | + | ||
| 85 | 188 | ## Saving | |
| 86 | 189 | ||
| 87 | 190 | The game state is plain data, so a save is just its JSON in `localStorage`, | |
| 88 | 191 | 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 | |
| 91 | 196 | save is validated before it is offered (four players, 144 tiles accounted for), | |
| 92 | 197 | so a truncated or hand-edited one is ignored rather than loaded into a broken | |
| 93 | 198 | table. `VERSION` in `save.ts` retires old saves if the state shape changes. | |
| ⋯ 100 unchanged lines | |||
| 194 | 299 | public/tiles/ sprite sheet + tile back | |
| 195 | 300 | src/game/tiles.ts tile codes, wall, shuffle, sprite mapping | |
| 196 | 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 | |
| 197 | 306 | src/game/tai.ts 台 scoring | |
| 198 | 307 | src/game/engine.ts state machine: deal, turns, claim resolution, settlement | |
| 199 | 308 | src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help | |
| ⋯ 1 unchanged line | |||
modifiedpublic/tiles/tiles.svg+51 −17
| ⋯ 104 unchanged lines | |||
| 105 | 105 | <line fill="#99B9FF" stroke="#678ED3" x1="36" y1="63.6" x2="35" y2="65.33"/> | |
| 106 | 106 | <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"/> | |
| 107 | 107 | </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"/> | |
| 123 | 157 | </g> | |
| 124 | 158 | </defs> | |
| 125 | 159 | <use x="0" y="0" xlink:href="#bt1"/> | |
| ⋯ 204 unchanged lines | |||
| 330 | 364 | <use transform="translate(556,102) scale(.72)" xlink:href="#dots"/> | |
| 331 | 365 | <use transform="translate(556,122) scale(.72)" xlink:href="#dots2"/> | |
| 332 | 366 | <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"/> | |
| 335 | 369 | <use transform="translate(1630,9.5)" xlink:href="#bams"/> | |
| 336 | 370 | <use transform="translate(1630,44.5)" xlink:href="#bams"/> | |
| 337 | 371 | <use transform="translate(1630,97.5)" xlink:href="#bams"/> | |
| ⋯ 192 unchanged lines | |||
modifiedsrc/App.tsx+21 −4
| 1 | 1 | import { useEffect, useState, useSyncExternalStore } from 'react'; | |
| 2 | + | import { AutoPlay } from './game/autoplay'; | |
| 2 | 3 | import { Game } from './game/engine'; | |
| 3 | 4 | import { loadPrefs, savePrefs, type Prefs } from './game/prefs'; | |
| 4 | 5 | import { loadSave } from './game/save'; | |
| ⋯ 7 unchanged lines | |||
| 12 | 13 | import './styles.css'; | |
| 13 | 14 | ||
| 14 | 15 | 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]; | |
| 15 | 19 | ||
| 16 | 20 | export default function App() { | |
| 17 | 21 | const [prefs, setPrefs] = useState<Prefs>(loadPrefs); | |
| ⋯ 28 unchanged lines | |||
| 46 | 50 | onSave={commitSettings} | |
| 47 | 51 | onClose={cancelSettings} | |
| 48 | 52 | stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'} | |
| 53 | + | bots={game.state.bots} | |
| 49 | 54 | /> | |
| 50 | 55 | ); | |
| 51 | 56 | ||
| ⋯ 5 unchanged lines | |||
| 57 | 62 | setVolume(prefs.volume); | |
| 58 | 63 | }, [prefs]); | |
| 59 | 64 | 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]); | |
| 60 | 68 | ||
| 61 | 69 | if (phase === 'lobby') { | |
| 62 | 70 | const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0; | |
| 71 | + | const savedSolo = saved?.state.bots.some(Boolean); | |
| 63 | 72 | return ( | |
| 64 | 73 | <div className="lobby"> | |
| 65 | 74 | <h1>台灣麻將</h1> | |
| ⋯ 7 unchanged lines | |||
| 73 | 82 | {saved && ( | |
| 74 | 83 | <div className="resume"> | |
| 75 | 84 | <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} | |
| 78 | 87 | </div> | |
| 79 | 88 | <div className="resume-scores"> | |
| 80 | 89 | {saved.state.players.map((p, i) => ( | |
| ⋯ 12 unchanged lines | |||
| 93 | 102 | 繼續對局 Resume | |
| 94 | 103 | </button> | |
| 95 | 104 | )} | |
| 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> | |
| 98 | 115 | </button> | |
| 99 | 116 | </div> | |
| 100 | 117 | ||
| ⋯ 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 | |||
| 15 | 15 | type Tile, | |
| 16 | 16 | } from './tiles'; | |
| 17 | 17 | 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'; | |
| 19 | 27 | ||
| 20 | 28 | export type Phase = 'lobby' | 'turn' | 'claims' | 'robkong' | 'handEnd' | 'gameEnd'; | |
| 21 | 29 | ||
| ⋯ 58 unchanged lines | |||
| 80 | 88 | export interface GameState { | |
| 81 | 89 | rules: Rules; | |
| 82 | 90 | 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[]; | |
| 83 | 98 | wall: Tile[]; | |
| 84 | 99 | dealer: SeatId; | |
| 85 | 100 | dealerStreak: number; | |
| ⋯ 52 unchanged lines | |||
| 138 | 153 | discards: [], | |
| 139 | 154 | score: rules.startingScore, | |
| 140 | 155 | })), | |
| 156 | + | bots: [false, false, false, false], | |
| 141 | 157 | wall: [], | |
| 142 | 158 | dealer: 0, | |
| 143 | 159 | dealerStreak: 0, | |
| ⋯ 47 unchanged lines | |||
| 191 | 207 | if (this.undoStack.length > UNDO_DEPTH) this.undoStack.shift(); | |
| 192 | 208 | } | |
| 193 | 209 | ||
| 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 | + | ||
| 194 | 225 | /** What undo would take back right now, or null if there is nothing. */ | |
| 195 | 226 | 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 }; | |
| 198 | 234 | } | |
| 199 | 235 | ||
| 200 | 236 | 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; | |
| 204 | 241 | this.sfx('undo'); | |
| 205 | 242 | this.emit(); | |
| 206 | 243 | } | |
| 207 | 244 | ||
| 245 | + | get hasBots() { | |
| 246 | + | return this.state.bots.some(Boolean); | |
| 247 | + | } | |
| 248 | + | isBot(seat: SeatId) { | |
| 249 | + | return this.state.bots[seat]; | |
| 250 | + | } | |
| 251 | + | ||
| 208 | 252 | private soundListeners = new Set<(cue: SoundCue) => void>(); | |
| 209 | 253 | /** Sound is a side channel, not state: it is never saved and never replayed. */ | |
| 210 | 254 | onSound = (fn: (cue: SoundCue) => void) => { | |
| ⋯ 15 unchanged lines | |||
| 226 | 270 | const s = this.state; | |
| 227 | 271 | s.rules = rules; | |
| 228 | 272 | 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; | |
| 230 | 276 | }); | |
| 231 | 277 | if (s.phase === 'lobby') for (const p of s.players) p.score = rules.startingScore; | |
| 232 | 278 | this.emit(); | |
| ⋯ 644 unchanged lines | |||
| 877 | 923 | this.emit(); | |
| 878 | 924 | } | |
| 879 | 925 | ||
| 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[]) { | |
| 882 | 931 | const s = this.state; | |
| 883 | 932 | 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 | + | } | |
| 884 | 942 | for (const p of s.players) p.score = s.rules.startingScore; | |
| 885 | 943 | s.dealer = 0; | |
| 886 | 944 | s.dealerStreak = 0; | |
| ⋯ 26 unchanged lines | |||
| 913 | 971 | } | |
| 914 | 972 | } | |
| 915 | 973 | ||
| 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 | + | ||
| 916 | 983 | function removeOne(tiles: Tile[], t: Tile): Tile[] { | |
| 917 | 984 | const out = tiles.slice(); | |
| 918 | 985 | const i = out.indexOf(t); | |
| ⋯ 20 unchanged lines | |||
modifiedsrc/game/save.ts+2 −1
| ⋯ 2 unchanged lines | |||
| 3 | 3 | ||
| 4 | 4 | const KEY = 'taiwanese-mahjong/save'; | |
| 5 | 5 | /** Bump when GameState changes shape; older saves are then ignored rather than crashing. */ | |
| 6 | - | const VERSION = 2; | |
| 6 | + | const VERSION = 3; | |
| 7 | 7 | ||
| 8 | 8 | export interface SavedGame { | |
| 9 | 9 | version: number; | |
| ⋯ 42 unchanged lines | |||
| 52 | 52 | if (!Array.isArray(s.wall) || typeof s.phase !== 'string') return false; | |
| 53 | 53 | if (s.phase === 'lobby') return false; | |
| 54 | 54 | if (!Array.isArray(s.sacred) || s.sacred.length !== 4) return false; | |
| 55 | + | if (!Array.isArray(s.bots) || s.bots.length !== 4) return false; | |
| 55 | 56 | if (!s.rules || typeof s.rules.base !== 'number') return false; | |
| 56 | 57 | ||
| 57 | 58 | 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 | |||
| 5 | 5 | /** Placeholder names, until somebody types their own on the settings screen. */ | |
| 6 | 6 | export const DEFAULT_NAMES = ['玩家 1', '玩家 2', '玩家 3', '玩家 4']; | |
| 7 | 7 | ||
| 8 | + | /** What the three computer players are called, in seat order after the human. */ | |
| 9 | + | export const BOT_NAMES = ['電腦 甲', '電腦 乙', '電腦 丙']; | |
| 10 | + | ||
| 8 | 11 | export type MeldKind = | |
| 9 | 12 | | 'chow' // 吃 | |
| 10 | 13 | | 'pung' // 碰 | |
| ⋯ 62 unchanged lines | |||
modifiedsrc/styles.css+105 −7
| ⋯ 5 unchanged lines | |||
| 6 | 6 | --depth: clamp(232px, 30vh, 330px); | |
| 7 | 7 | --h-len: max(160px, calc(100vw - 2 * var(--depth))); | |
| 8 | 8 | --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; | |
| 9 | 15 | /* 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. | |
| 13 | 19 | ||
| 14 | 20 | Tile size otherwise follows the strip's *length*, which is why a window | |
| 15 | 21 | with less height than F11 kept full-size tiles and left the discards a | |
| 16 | 22 | 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); | |
| 18 | 24 | ||
| 19 | 25 | --felt: #0f4534; | |
| 20 | 26 | --felt-2: #0a3126; | |
| ⋯ 320 unchanged lines | |||
| 341 | 347 | justify-content: flex-end; | |
| 342 | 348 | padding-right: calc(var(--tile-w) * 0.4); | |
| 343 | 349 | align-self: stretch; | |
| 344 | - | min-height: 40px; | |
| 350 | + | /* Reserved even when empty — see --bar-h. */ | |
| 351 | + | min-height: var(--bar-h); | |
| 345 | 352 | flex-wrap: wrap; | |
| 346 | 353 | flex: none; | |
| 347 | 354 | } | |
| 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 | + | } | |
| 348 | 364 | .waiting { | |
| 349 | 365 | font-size: 12px; | |
| 350 | 366 | opacity: 0.5; | |
| ⋯ 349 unchanged lines | |||
| 700 | 716 | font-weight: 700; | |
| 701 | 717 | color: var(--gold); | |
| 702 | 718 | } | |
| 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 | + | ||
| 703 | 785 | .deltas { | |
| 704 | 786 | display: flex; | |
| 705 | 787 | gap: 16px; | |
| ⋯ 179 unchanged lines | |||
| 885 | 967 | } | |
| 886 | 968 | .lobby-buttons { | |
| 887 | 969 | display: flex; | |
| 888 | - | gap: 14px; | |
| 970 | + | flex-direction: column; | |
| 971 | + | gap: 12px; | |
| 889 | 972 | 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; | |
| 891 | 989 | } | |
| 892 | 990 | ||
| 893 | 991 | /* ---------- sound ---------- */ | |
| ⋯ 284 unchanged lines | |||
modifiedsrc/ui/Center.tsx+33 −3
| ⋯ 21 unchanged lines | |||
| 22 | 22 | const pending = (Object.keys(s.options) as unknown as number[]) | |
| 23 | 23 | .map(Number) | |
| 24 | 24 | .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; | |
| 26 | 29 | // Deliberately not per-seat: whoever spots the mis-tap should be able to | |
| 27 | 30 | // reach it, not just the player who made it. | |
| 28 | 31 | const undoable = s.phase === 'gameEnd' ? null : game.undoable(); | |
| ⋯ 21 unchanged lines | |||
| 50 | 53 | {r && s.phase !== 'gameEnd' && ( | |
| 51 | 54 | <div className="result"> | |
| 52 | 55 | {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 | + | </> | |
| 54 | 84 | ) : ( | |
| 55 | 85 | <> | |
| 56 | 86 | {/* One block per winner — more than one only under 一炮多響. */} | |
| ⋯ 53 unchanged lines | |||
| 110 | 140 | ))} | |
| 111 | 141 | </div> | |
| 112 | 142 | <div className="over-buttons"> | |
| 113 | - | <button className="btn big-btn" onClick={() => game.newGame()}> | |
| 143 | + | <button className="btn big-btn" onClick={() => game.newGame(s.bots)}> | |
| 114 | 144 | <Bi zh="再來一將" en="New game" /> | |
| 115 | 145 | </button> | |
| 116 | 146 | {/* 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 | |||
| 4 | 4 | ||
| 5 | 5 | interface Props { | |
| 6 | 6 | tiles: Tile[]; | |
| 7 | + | /** Somebody else's hand: shown as backs, and not to be poked at. */ | |
| 8 | + | faceDown?: boolean; | |
| 7 | 9 | /** Index of the tile held apart from the rest, if any. */ | |
| 8 | 10 | drawnIndex: number | null; | |
| 9 | 11 | selected: number | null; | |
| ⋯ 36 unchanged lines | |||
| 46 | 48 | ||
| 47 | 49 | export function Hand({ | |
| 48 | 50 | tiles, | |
| 51 | + | faceDown, | |
| 49 | 52 | drawnIndex, | |
| 50 | 53 | selected, | |
| 51 | 54 | canDiscard, | |
| ⋯ 35 unchanged lines | |||
| 87 | 90 | else onSelect(d.from); | |
| 88 | 91 | }; | |
| 89 | 92 | ||
| 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 | + | ||
| 90 | 105 | return ( | |
| 91 | 106 | <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}> | |
| 92 | 107 | {tiles.map((t, i) => ( | |
| ⋯ 17 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+35 −12
| ⋯ 20 unchanged lines | |||
| 21 | 21 | const s = game.state; | |
| 22 | 22 | const p = s.players[seat]; | |
| 23 | 23 | 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]; | |
| 26 | 31 | const responded = s.responses[seat] !== undefined; | |
| 27 | 32 | const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded; | |
| 28 | 33 | // Whoever draws next closes the claim window — nobody has to wait on a | |
| 29 | 34 | // player who hasn't noticed their buttons. | |
| 30 | - | const canProceed = game.nextDrawer() === seat; | |
| 35 | + | const canProceed = game.nextDrawer() === seat && !bot; | |
| 31 | 36 | ||
| 32 | 37 | useEffect(() => { | |
| 33 | 38 | setSel(null); | |
| ⋯ 2 unchanged lines | |||
| 36 | 41 | const seatWind = (seat - s.dealer + 4) % 4; | |
| 37 | 42 | const isDealer = seat === s.dealer; | |
| 38 | 43 | 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) : []; | |
| 41 | 46 | const concealedKongs = myTurn ? game.concealedKongs(seat) : []; | |
| 42 | 47 | const addedKongs = myTurn ? game.addedKongs(seat) : []; | |
| 43 | 48 | const canHu = myTurn && game.canSelfDrawWin(seat); | |
| 44 | 49 | 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); | |
| 45 | 52 | ||
| 46 | 53 | const doDiscard = (i: number) => { | |
| 47 | 54 | game.discard(seat, p.hand[i]); | |
| ⋯ 11 unchanged lines | |||
| 59 | 66 | className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`} | |
| 60 | 67 | style={{ transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }} | |
| 61 | 68 | > | |
| 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 | + | ||
| 62 | 78 | <div className="discards"> | |
| 63 | 79 | {p.discards.map((t, i) => ( | |
| 64 | 80 | <TileView | |
| ⋯ 38 unchanged lines | |||
| 103 | 119 | /> | |
| 104 | 120 | </button> | |
| 105 | 121 | ))} | |
| 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 | + | )} | |
| 109 | 128 | </> | |
| 110 | 129 | )} | |
| 111 | 130 | {canProceed && ( | |
| 112 | - | <button className="btn proceed" onClick={() => game.resolveNow()}> | |
| 131 | + | <button | |
| 132 | + | className={`btn proceed ${claiming ? 'pass' : ''}`} | |
| 133 | + | onClick={() => game.resolveNow()} | |
| 134 | + | > | |
| 113 | 135 | <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'} | |
| 116 | 138 | /> | |
| 117 | 139 | </button> | |
| 118 | 140 | )} | |
| ⋯ 29 unchanged lines | |||
| 148 | 170 | ||
| 149 | 171 | <Hand | |
| 150 | 172 | tiles={p.hand} | |
| 173 | + | faceDown={concealed} | |
| 151 | 174 | drawnIndex={drawnIndex} | |
| 152 | 175 | selected={sel} | |
| 153 | 176 | canDiscard={myTurn} | |
| ⋯ 23 unchanged lines | |||
| 177 | 200 | </span> | |
| 178 | 201 | )} | |
| 179 | 202 | <span className="chips">{p.score}</span> | |
| 180 | - | {p.hand.length > 0 && s.phase !== 'handEnd' && ( | |
| 203 | + | {p.hand.length > 0 && s.phase !== 'handEnd' && !bot && ( | |
| 181 | 204 | <button className="btn ghost sort" onClick={() => game.sortHand(seat)}> | |
| 182 | 205 | 理牌 Sort | |
| 183 | 206 | </button> | |
| ⋯ 9 unchanged lines | |||
modifiedsrc/ui/Settings.tsx+5 −2
| ⋯ 8 unchanged lines | |||
| 9 | 9 | onClose: () => void; | |
| 10 | 10 | /** Stakes are only editable before the first hand of a game is dealt. */ | |
| 11 | 11 | stakesLocked: boolean; | |
| 12 | + | /** Seats the computer is playing — there is nobody there to name. */ | |
| 13 | + | bots: boolean[]; | |
| 12 | 14 | } | |
| 13 | 15 | ||
| 14 | 16 | interface Toggle { | |
| ⋯ 31 unchanged lines | |||
| 46 | 48 | }, | |
| 47 | 49 | ]; | |
| 48 | 50 | ||
| 49 | - | export function Settings({ prefs, onSave, onClose, stakesLocked }: Props) { | |
| 51 | + | export function Settings({ prefs, onSave, onClose, stakesLocked, bots }: Props) { | |
| 50 | 52 | // Edited locally and committed on 完成, so a half-typed name never reaches | |
| 51 | 53 | // the table and 取消 really does mean cancel. | |
| 52 | 54 | const [draft, setDraft] = useState<Prefs>(prefs); | |
| ⋯ 49 unchanged lines | |||
| 102 | 104 | <label className="field" key={i}> | |
| 103 | 105 | <span className="f-label">{i + 1}</span> | |
| 104 | 106 | <input | |
| 105 | - | value={n} | |
| 107 | + | value={bots[i] ? '電腦 Computer' : n} | |
| 108 | + | disabled={bots[i]} | |
| 106 | 109 | maxLength={12} | |
| 107 | 110 | placeholder={DEFAULT_NAMES[i]} | |
| 108 | 111 | onChange={(e) => { | |
| ⋯ 122 unchanged lines | |||