anvilsign in

collin/mahjong · 70c6c51e

Initial commit: 台灣麻將 hotseat table

Collin Richards · 2026-08-07 09:12 UTC · 70c6c51e71b2e032d9fb0042e260d72b165fe1ff · browse files

added.gitignore+24 −0
1+# Logs
2+logs
3+*.log
4+npm-debug.log*
5+yarn-debug.log*
6+yarn-error.log*
7+pnpm-debug.log*
8+lerna-debug.log*
9+
10+node_modules
11+dist
12+dist-ssr
13+*.local
14+
15+# Editor directories and files
16+.vscode/*
17+!.vscode/extensions.json
18+.idea
19+.DS_Store
20+*.suo
21+*.ntvs*
22+*.njsproj
23+*.sln
24+*.sw?
added.oxlintrc.json+8 −0
1+{
2+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3+ "plugins": ["react", "typescript", "oxc"],
4+ "rules": {
5+ "react/rules-of-hooks": "error",
6+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
7+ }
8+}
addedREADME.md+162 −0
1+# 台灣麻將 — four-player hotseat on one touchscreen
2+
3+Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop.
4+All four hands are on screen at once, each rotated to face its own edge — put a
5+piece of card or plastic over your strip so the others can't see your tiles.
6+Every label is Chinese with English underneath; the tiles themselves stay
7+Chinese, because that is what the tiles say.
8+
9+```
10+npm install
11+npm run dev # then open the browser full-screen (F11)
12+npm test # rules-engine tests, incl. 200 randomly-played hands
13+```
14+
15+## Screen layout
16+
17+```
18+ ┌──── 對家 (rotated 180°) ────┐
19+ 上家 (rotated 90°) │ wall / discards │ 下家 (rotated −90°)
20+ └──── 自家 (upright, near you) ┘
21+```
22+
23+Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left),
24+which is the normal counter-clockwise 東南西北 order.
25+
26+Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊
27+marker, 聽 badge, chip count) → concealed hand → action buttons → melds and
28+flowers → that player's discard row.
29+
30+## Playing
31+
32+- **The tile you just drew** is held apart from the sorted hand with a gold ring
33+ and a 摸 label, instead of being sorted invisibly into it. It merges into the
34+ hand once you discard. 補花 replacements and kong replacements are marked the
35+ same way.
36+- **The wall** is drawn in the centre as a square: four staggered sides of 18
37+ stacks of two, the way it's built on a real table. The gold stack is the break
38+ point you draw from, stacks shrink to a single tile and then vanish as they're
39+ used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is
40+ also the end kong and flower replacements are taken from, so the square is
41+ eaten from both directions at once.
42+- **Discard** — tap a tile to lift it, tap again (or the 打出 button) to throw it.
43+ Action buttons sit on the right of your own strip, within thumb reach.
44+- **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back
45+ into suit order. Hands are never auto-sorted after the deal, so an arrangement
46+ you set up survives draws, claims and kongs.
47+- **Rules** — the `?` on your nameplate opens a bilingual rules panel anchored to
48+ your own edge of the table and rotated to face you.
49+- **Claiming** — after a discard, every seat that *can* claim gets 胡 / 槓 / 碰 /
50+ 吃 / 過 buttons on their own strip, and they resolve by priority (胡 > 槓/碰 >
51+ 吃; ties go to the player nearest the discarder in turn order).
52+ **The table never blocks on a claim.** The next player gets a 摸牌 button and
53+ may draw whenever they like, which shuts the window on anyone who hasn't
54+ called — exactly like shouting 碰 before the next player picks up. A claim
55+ already declared still stands, so calling in time always wins the tile. If
56+ everyone answers first, play advances on its own without the extra tap.
57+- **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal.
58+ 加槓 offers everyone else a 搶槓 chance.
59+- **Undo** — a 復原 button in the middle of the table takes back the last
60+ action, naming what it will undo (`復原 玩家 2 打 五萬`). It sits in the
61+ centre rather than on a seat because whoever *spots* the mis-tap should be
62+ able to reach it. Twenty actions deep, cleared when the next hand is dealt.
63+ Snapshots live outside the saved state, so an undo does not survive a
64+ refresh: what you can take back is what happened while everyone was still
65+ watching it happen.
66+- **Sound** — off by default. A dry clack as a tile goes down, and a distinct
67+ two-note chime when a claim window opens, which is how a slow player notices
68+ their 碰 is available before the next player draws it shut. Everything is
69+ synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
70+ so there is nothing to load and the cue lands on the same frame as the tile.
71+ Toggle it from the 🔊 button next to 復原, or from the settings screen.
72+- **Settings** — 設定 from the lobby, or from the game-over panel: player names,
73+ 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
74+ `localStorage` separately from the save, so they carry over to the next game.
75+ Stakes are locked once a game is under way — they'd otherwise rewrite chips
76+ already won.
77+
78+## Saving
79+
80+The game state is plain data, so a save is just its JSON in `localStorage`,
81+rewritten after every move. Close the lid, refresh, or run the battery flat and
82+nothing is lost — the lobby offers **繼續對局 Resume** alongside **開新局 New
83+game**, showing the round, hand number, chip counts and when it was saved. A
84+save is validated before it is offered (four players, 144 tiles accounted for),
85+so a truncated or hand-edited one is ignored rather than loaded into a broken
86+table. `VERSION` in `save.ts` retires old saves if the state shape changes.
87+
88+## Rules implemented
89+
90+- 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer
91+ draws the 17th.
92+- 補花 at the deal and on every drawn flower, replacements from the back of the
93+ wall.
94+- 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花.
95+- 流局 when 16 tiles remain (`Rules.wallReserve`); the dealer keeps the deal on a
96+ draw or on a dealer win (連莊), otherwise the deal passes and the round wind
97+ advances every four passes. A full 四圈 game is 16 dealer passes.
98+
99+### House rules (switches in `Rules`, all on the settings screen)
100+
101+- **過水 `sacredDiscard`** (on) — pass on a tile you could have won with and it
102+ is dead to you until your own next draw; the seat shows a 過水 badge listing
103+ what it is locked out of, so the missing 胡 button is never a mystery. A draw
104+ from either end of the wall lifts it. **`sacredClearedByClaim`** (off) decides
105+ whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ.
106+- **一炮多響 `multipleWinners`** (off) — one discard pays out to every seat that
107+ calls on it, each settled separately against the discarder. With it off the
108+ tile goes to the caller nearest the discarder. Either way the table now waits
109+ for other seats that *can* win before settling, so the nearest seat wins the
110+ tile rather than the quickest hand — and the 摸牌 button still closes the
111+ window on anyone dithering.
112+- **包牌 `liability`** (on) — feeding the pung that completes a visible 大三元
113+ or 大四喜 makes the feeder answer for the whole hand, in place of all three
114+ payers. Only the seat that fed the *last* of those sets is on the hook, and
115+ only if it came off a discard: a hand that assembled them itself, or closed
116+ the set with a 暗槓, has nobody to blame.
117+
118+### 台 scoring (`src/game/tai.ts`)
119+
120+自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each ·
121+花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 ·
122+小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 ·
123+三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 ·
124+海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1)
125+
126+Ambiguous hands are decomposed every legal way and scored at the best reading.
127+
128+**Payment** (`Game.settle`): one unit is `底 + 台 × 台值`
129+(`DEFAULT_RULES` = 底 3, 台 1, 100 chips each).
130+放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit
131+each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added
132+to the whole hand when the dealer wins.
133+
134+House rules vary a lot; the tai table and payments are plain data/functions in
135+`tai.ts` and `types.ts` if yours differ.
136+
137+## Tile art
138+
139+`public/tiles/tiles.svg` is the **postmodern** tileset from
140+[gnome-mahjongg](https://gitlab.gnome.org/GNOME/gnome-mahjongg), extracted from
141+the installed binary's GResource — a 43×2 sprite sheet (second row is the
142+highlighted variant, used for a lifted tile). `public/tiles/back.png` is the
143+blank tile from its **smooth** theme, tinted jade, since a solitaire game has no
144+face-down art of its own.
145+
146+That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever
147+distribute this app, either honour the GPL or swap the two files for art of your
148+own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating.
149+
150+Known gaps and house rules that aren't implemented yet are listed in
151+[TODO.md](TODO.md).
152+
153+## Layout
154+
155+```
156+public/tiles/ sprite sheet + tile back
157+src/game/tiles.ts tile codes, wall, shuffle, sprite mapping
158+src/game/hu.ts hand decomposition, 聽 detection, wait shapes
159+src/game/tai.ts 台 scoring
160+src/game/engine.ts state machine: deal, turns, claim resolution, settlement
161+src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help
162+```
addedTODO.md+23 −0
1+# TODO
2+
3+Ordered by how much they'd matter at a real table.
4+
5+## 1. Undo across a hand boundary
6+
7+`undo()` is cleared by `startHand`, so a 下一局 tapped by mistake — and that
8+button sits right under someone's thumb — cannot be taken back. The deal is
9+already reshuffled by then. Would need the snapshot from before `nextHand` kept,
10+and the seed recorded, so the same wall could be rebuilt.
11+
12+## 2. Settings mid-game
13+
14+設定 is reachable from the lobby and the game-over panel, but not during a 將, so
15+a player who wants to fix a typo'd name has to wait it out. The `?` panel is
16+already anchored per-seat and rotated to face its own edge; settings could share
17+that treatment.
18+
19+## 3. 花槓 and other flower house rules
20+
21+Flowers score, but some tables pay 花槓 out the moment it completes rather than
22+at settlement, and some deal 補花 differently at the start. Both are `Rules`
23+flags plus a payment hook.
addedindex.html+13 −0
1+<!doctype html>
2+<html lang="zh-TW">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
7+ <title>台灣麻將</title>
8+ </head>
9+ <body>
10+ <div id="root"></div>
11+ <script type="module" src="/src/main.tsx"></script>
12+ </body>
13+</html>
addedpackage-lock.json+1720 −0
1+{
2+ "name": "mahjong",
3+ "version": "0.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "mahjong",
9+ "version": "0.0.0",
10+ "dependencies": {
11+ "react": "^19.2.8",
12+ "react-dom": "^19.2.8"
13+ },
14+ "devDependencies": {
15+ "@types/node": "^24.13.3",
16+ "@types/react": "^19.2.17",
17+ "@types/react-dom": "^19.2.3",
18+ "@vitejs/plugin-react": "^6.0.4",
19+ "oxlint": "^1.75.0",
20+ "typescript": "~6.0.2",
21+ "vite": "^8.2.0",
22+ "vitest": "^4.1.10"
23+ }
24+ },
25+ "node_modules/@jridgewell/sourcemap-codec": {
26+ "version": "1.5.5",
27+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
28+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
29+ "dev": true,
30+ "license": "MIT"
31+ },
32+ "node_modules/@oxc-project/types": {
33+ "version": "0.143.0",
34+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
35+ "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
36+ "dev": true,
37+ "license": "MIT",
38+ "funding": {
39+ "url": "https://github.com/sponsors/Boshen"
40+ }
41+ },
42+ "node_modules/@oxlint/binding-android-arm-eabi": {
43+ "version": "1.77.0",
44+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz",
45+ "integrity": "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==",
46+ "cpu": [
47+ "arm"
48+ ],
49+ "dev": true,
50+ "license": "MIT",
51+ "optional": true,
52+ "os": [
53+ "android"
54+ ],
55+ "engines": {
56+ "node": "^20.19.0 || >=22.12.0"
57+ }
58+ },
59+ "node_modules/@oxlint/binding-android-arm64": {
60+ "version": "1.77.0",
61+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.77.0.tgz",
62+ "integrity": "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==",
63+ "cpu": [
64+ "arm64"
65+ ],
66+ "dev": true,
67+ "license": "MIT",
68+ "optional": true,
69+ "os": [
70+ "android"
71+ ],
72+ "engines": {
73+ "node": "^20.19.0 || >=22.12.0"
74+ }
75+ },
76+ "node_modules/@oxlint/binding-darwin-arm64": {
77+ "version": "1.77.0",
78+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.77.0.tgz",
79+ "integrity": "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==",
80+ "cpu": [
81+ "arm64"
82+ ],
83+ "dev": true,
84+ "license": "MIT",
85+ "optional": true,
86+ "os": [
87+ "darwin"
88+ ],
89+ "engines": {
90+ "node": "^20.19.0 || >=22.12.0"
91+ }
92+ },
93+ "node_modules/@oxlint/binding-darwin-x64": {
94+ "version": "1.77.0",
95+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.77.0.tgz",
96+ "integrity": "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==",
97+ "cpu": [
98+ "x64"
99+ ],
100+ "dev": true,
101+ "license": "MIT",
102+ "optional": true,
103+ "os": [
104+ "darwin"
105+ ],
106+ "engines": {
107+ "node": "^20.19.0 || >=22.12.0"
108+ }
109+ },
110+ "node_modules/@oxlint/binding-freebsd-x64": {
111+ "version": "1.77.0",
112+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.77.0.tgz",
113+ "integrity": "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==",
114+ "cpu": [
115+ "x64"
116+ ],
117+ "dev": true,
118+ "license": "MIT",
119+ "optional": true,
120+ "os": [
121+ "freebsd"
122+ ],
123+ "engines": {
124+ "node": "^20.19.0 || >=22.12.0"
125+ }
126+ },
127+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
128+ "version": "1.77.0",
129+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.77.0.tgz",
130+ "integrity": "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==",
131+ "cpu": [
132+ "arm"
133+ ],
134+ "dev": true,
135+ "license": "MIT",
136+ "optional": true,
137+ "os": [
138+ "linux"
139+ ],
140+ "engines": {
141+ "node": "^20.19.0 || >=22.12.0"
142+ }
143+ },
144+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
145+ "version": "1.77.0",
146+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.77.0.tgz",
147+ "integrity": "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==",
148+ "cpu": [
149+ "arm"
150+ ],
151+ "dev": true,
152+ "license": "MIT",
153+ "optional": true,
154+ "os": [
155+ "linux"
156+ ],
157+ "engines": {
158+ "node": "^20.19.0 || >=22.12.0"
159+ }
160+ },
161+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
162+ "version": "1.77.0",
163+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.77.0.tgz",
164+ "integrity": "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==",
165+ "cpu": [
166+ "arm64"
167+ ],
168+ "dev": true,
169+ "libc": [
170+ "glibc"
171+ ],
172+ "license": "MIT",
173+ "optional": true,
174+ "os": [
175+ "linux"
176+ ],
177+ "engines": {
178+ "node": "^20.19.0 || >=22.12.0"
179+ }
180+ },
181+ "node_modules/@oxlint/binding-linux-arm64-musl": {
182+ "version": "1.77.0",
183+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.77.0.tgz",
184+ "integrity": "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==",
185+ "cpu": [
186+ "arm64"
187+ ],
188+ "dev": true,
189+ "libc": [
190+ "musl"
191+ ],
192+ "license": "MIT",
193+ "optional": true,
194+ "os": [
195+ "linux"
196+ ],
197+ "engines": {
198+ "node": "^20.19.0 || >=22.12.0"
199+ }
200+ },
201+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
202+ "version": "1.77.0",
203+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.77.0.tgz",
204+ "integrity": "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==",
205+ "cpu": [
206+ "ppc64"
207+ ],
208+ "dev": true,
209+ "libc": [
210+ "glibc"
211+ ],
212+ "license": "MIT",
213+ "optional": true,
214+ "os": [
215+ "linux"
216+ ],
217+ "engines": {
218+ "node": "^20.19.0 || >=22.12.0"
219+ }
220+ },
221+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
222+ "version": "1.77.0",
223+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.77.0.tgz",
224+ "integrity": "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==",
225+ "cpu": [
226+ "riscv64"
227+ ],
228+ "dev": true,
229+ "libc": [
230+ "glibc"
231+ ],
232+ "license": "MIT",
233+ "optional": true,
234+ "os": [
235+ "linux"
236+ ],
237+ "engines": {
238+ "node": "^20.19.0 || >=22.12.0"
239+ }
240+ },
241+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
242+ "version": "1.77.0",
243+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.77.0.tgz",
244+ "integrity": "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==",
245+ "cpu": [
246+ "riscv64"
247+ ],
248+ "dev": true,
249+ "libc": [
250+ "musl"
251+ ],
252+ "license": "MIT",
253+ "optional": true,
254+ "os": [
255+ "linux"
256+ ],
257+ "engines": {
258+ "node": "^20.19.0 || >=22.12.0"
259+ }
260+ },
261+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
262+ "version": "1.77.0",
263+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.77.0.tgz",
264+ "integrity": "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==",
265+ "cpu": [
266+ "s390x"
267+ ],
268+ "dev": true,
269+ "libc": [
270+ "glibc"
271+ ],
272+ "license": "MIT",
273+ "optional": true,
274+ "os": [
275+ "linux"
276+ ],
277+ "engines": {
278+ "node": "^20.19.0 || >=22.12.0"
279+ }
280+ },
281+ "node_modules/@oxlint/binding-linux-x64-gnu": {
282+ "version": "1.77.0",
283+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.77.0.tgz",
284+ "integrity": "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==",
285+ "cpu": [
286+ "x64"
287+ ],
288+ "dev": true,
289+ "libc": [
290+ "glibc"
291+ ],
292+ "license": "MIT",
293+ "optional": true,
294+ "os": [
295+ "linux"
296+ ],
297+ "engines": {
298+ "node": "^20.19.0 || >=22.12.0"
299+ }
300+ },
301+ "node_modules/@oxlint/binding-linux-x64-musl": {
302+ "version": "1.77.0",
303+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.77.0.tgz",
304+ "integrity": "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==",
305+ "cpu": [
306+ "x64"
307+ ],
308+ "dev": true,
309+ "libc": [
310+ "musl"
311+ ],
312+ "license": "MIT",
313+ "optional": true,
314+ "os": [
315+ "linux"
316+ ],
317+ "engines": {
318+ "node": "^20.19.0 || >=22.12.0"
319+ }
320+ },
321+ "node_modules/@oxlint/binding-openharmony-arm64": {
322+ "version": "1.77.0",
323+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.77.0.tgz",
324+ "integrity": "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==",
325+ "cpu": [
326+ "arm64"
327+ ],
328+ "dev": true,
329+ "license": "MIT",
330+ "optional": true,
331+ "os": [
332+ "openharmony"
333+ ],
334+ "engines": {
335+ "node": "^20.19.0 || >=22.12.0"
336+ }
337+ },
338+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
339+ "version": "1.77.0",
340+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.77.0.tgz",
341+ "integrity": "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==",
342+ "cpu": [
343+ "arm64"
344+ ],
345+ "dev": true,
346+ "license": "MIT",
347+ "optional": true,
348+ "os": [
349+ "win32"
350+ ],
351+ "engines": {
352+ "node": "^20.19.0 || >=22.12.0"
353+ }
354+ },
355+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
356+ "version": "1.77.0",
357+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.77.0.tgz",
358+ "integrity": "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==",
359+ "cpu": [
360+ "ia32"
361+ ],
362+ "dev": true,
363+ "license": "MIT",
364+ "optional": true,
365+ "os": [
366+ "win32"
367+ ],
368+ "engines": {
369+ "node": "^20.19.0 || >=22.12.0"
370+ }
371+ },
372+ "node_modules/@oxlint/binding-win32-x64-msvc": {
373+ "version": "1.77.0",
374+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.77.0.tgz",
375+ "integrity": "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==",
376+ "cpu": [
377+ "x64"
378+ ],
379+ "dev": true,
380+ "license": "MIT",
381+ "optional": true,
382+ "os": [
383+ "win32"
384+ ],
385+ "engines": {
386+ "node": "^20.19.0 || >=22.12.0"
387+ }
388+ },
389+ "node_modules/@rolldown/binding-android-arm64": {
390+ "version": "1.2.3",
391+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
392+ "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
393+ "cpu": [
394+ "arm64"
395+ ],
396+ "dev": true,
397+ "license": "MIT",
398+ "optional": true,
399+ "os": [
400+ "android"
401+ ],
402+ "engines": {
403+ "node": "^20.19.0 || >=22.12.0"
404+ }
405+ },
406+ "node_modules/@rolldown/binding-darwin-arm64": {
407+ "version": "1.2.3",
408+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
409+ "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
410+ "cpu": [
411+ "arm64"
412+ ],
413+ "dev": true,
414+ "license": "MIT",
415+ "optional": true,
416+ "os": [
417+ "darwin"
418+ ],
419+ "engines": {
420+ "node": "^20.19.0 || >=22.12.0"
421+ }
422+ },
423+ "node_modules/@rolldown/binding-darwin-x64": {
424+ "version": "1.2.3",
425+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
426+ "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
427+ "cpu": [
428+ "x64"
429+ ],
430+ "dev": true,
431+ "license": "MIT",
432+ "optional": true,
433+ "os": [
434+ "darwin"
435+ ],
436+ "engines": {
437+ "node": "^20.19.0 || >=22.12.0"
438+ }
439+ },
440+ "node_modules/@rolldown/binding-freebsd-x64": {
441+ "version": "1.2.3",
442+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
443+ "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
444+ "cpu": [
445+ "x64"
446+ ],
447+ "dev": true,
448+ "license": "MIT",
449+ "optional": true,
450+ "os": [
451+ "freebsd"
452+ ],
453+ "engines": {
454+ "node": "^20.19.0 || >=22.12.0"
455+ }
456+ },
457+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
458+ "version": "1.2.3",
459+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
460+ "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
461+ "cpu": [
462+ "arm"
463+ ],
464+ "dev": true,
465+ "license": "MIT",
466+ "optional": true,
467+ "os": [
468+ "linux"
469+ ],
470+ "engines": {
471+ "node": "^20.19.0 || >=22.12.0"
472+ }
473+ },
474+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
475+ "version": "1.2.3",
476+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
477+ "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
478+ "cpu": [
479+ "arm64"
480+ ],
481+ "dev": true,
482+ "libc": [
483+ "glibc"
484+ ],
485+ "license": "MIT",
486+ "optional": true,
487+ "os": [
488+ "linux"
489+ ],
490+ "engines": {
491+ "node": "^20.19.0 || >=22.12.0"
492+ }
493+ },
494+ "node_modules/@rolldown/binding-linux-arm64-musl": {
495+ "version": "1.2.3",
496+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
497+ "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
498+ "cpu": [
499+ "arm64"
500+ ],
501+ "dev": true,
502+ "libc": [
503+ "musl"
504+ ],
505+ "license": "MIT",
506+ "optional": true,
507+ "os": [
508+ "linux"
509+ ],
510+ "engines": {
511+ "node": "^20.19.0 || >=22.12.0"
512+ }
513+ },
514+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
515+ "version": "1.2.3",
516+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
517+ "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
518+ "cpu": [
519+ "ppc64"
520+ ],
521+ "dev": true,
522+ "libc": [
523+ "glibc"
524+ ],
525+ "license": "MIT",
526+ "optional": true,
527+ "os": [
528+ "linux"
529+ ],
530+ "engines": {
531+ "node": "^20.19.0 || >=22.12.0"
532+ }
533+ },
534+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
535+ "version": "1.2.3",
536+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
537+ "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
538+ "cpu": [
539+ "s390x"
540+ ],
541+ "dev": true,
542+ "libc": [
543+ "glibc"
544+ ],
545+ "license": "MIT",
546+ "optional": true,
547+ "os": [
548+ "linux"
549+ ],
550+ "engines": {
551+ "node": "^20.19.0 || >=22.12.0"
552+ }
553+ },
554+ "node_modules/@rolldown/binding-linux-x64-gnu": {
555+ "version": "1.2.3",
556+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
557+ "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
558+ "cpu": [
559+ "x64"
560+ ],
561+ "dev": true,
562+ "libc": [
563+ "glibc"
564+ ],
565+ "license": "MIT",
566+ "optional": true,
567+ "os": [
568+ "linux"
569+ ],
570+ "engines": {
571+ "node": "^20.19.0 || >=22.12.0"
572+ }
573+ },
574+ "node_modules/@rolldown/binding-linux-x64-musl": {
575+ "version": "1.2.3",
576+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
577+ "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
578+ "cpu": [
579+ "x64"
580+ ],
581+ "dev": true,
582+ "libc": [
583+ "musl"
584+ ],
585+ "license": "MIT",
586+ "optional": true,
587+ "os": [
588+ "linux"
589+ ],
590+ "engines": {
591+ "node": "^20.19.0 || >=22.12.0"
592+ }
593+ },
594+ "node_modules/@rolldown/binding-openharmony-arm64": {
595+ "version": "1.2.3",
596+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
597+ "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
598+ "cpu": [
599+ "arm64"
600+ ],
601+ "dev": true,
602+ "license": "MIT",
603+ "optional": true,
604+ "os": [
605+ "openharmony"
606+ ],
607+ "engines": {
608+ "node": "^20.19.0 || >=22.12.0"
609+ }
610+ },
611+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
612+ "version": "1.2.3",
613+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
614+ "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
615+ "cpu": [
616+ "arm64"
617+ ],
618+ "dev": true,
619+ "license": "MIT",
620+ "optional": true,
621+ "os": [
622+ "win32"
623+ ],
624+ "engines": {
625+ "node": "^20.19.0 || >=22.12.0"
626+ }
627+ },
628+ "node_modules/@rolldown/binding-win32-x64-msvc": {
629+ "version": "1.2.3",
630+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
631+ "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
632+ "cpu": [
633+ "x64"
634+ ],
635+ "dev": true,
636+ "license": "MIT",
637+ "optional": true,
638+ "os": [
639+ "win32"
640+ ],
641+ "engines": {
642+ "node": "^20.19.0 || >=22.12.0"
643+ }
644+ },
645+ "node_modules/@rolldown/pluginutils": {
646+ "version": "1.0.1",
647+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
648+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
649+ "dev": true,
650+ "license": "MIT"
651+ },
652+ "node_modules/@standard-schema/spec": {
653+ "version": "1.1.0",
654+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
655+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
656+ "dev": true,
657+ "license": "MIT"
658+ },
659+ "node_modules/@types/chai": {
660+ "version": "5.2.3",
661+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
662+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
663+ "dev": true,
664+ "license": "MIT",
665+ "dependencies": {
666+ "@types/deep-eql": "*",
667+ "assertion-error": "^2.0.1"
668+ }
669+ },
670+ "node_modules/@types/deep-eql": {
671+ "version": "4.0.2",
672+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
673+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
674+ "dev": true,
675+ "license": "MIT"
676+ },
677+ "node_modules/@types/estree": {
678+ "version": "1.0.9",
679+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
680+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
681+ "dev": true,
682+ "license": "MIT"
683+ },
684+ "node_modules/@types/node": {
685+ "version": "24.13.3",
686+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
687+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
688+ "dev": true,
689+ "license": "MIT",
690+ "dependencies": {
691+ "undici-types": "~7.18.0"
692+ }
693+ },
694+ "node_modules/@types/react": {
695+ "version": "19.2.18",
696+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
697+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
698+ "dev": true,
699+ "license": "MIT",
700+ "dependencies": {
701+ "csstype": "^3.2.2"
702+ }
703+ },
704+ "node_modules/@types/react-dom": {
705+ "version": "19.2.4",
706+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
707+ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
708+ "dev": true,
709+ "license": "MIT",
710+ "peerDependencies": {
711+ "@types/react": "^19.2.0"
712+ }
713+ },
714+ "node_modules/@vitejs/plugin-react": {
715+ "version": "6.0.5",
716+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
717+ "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
718+ "dev": true,
719+ "license": "MIT",
720+ "dependencies": {
721+ "@rolldown/pluginutils": "^1.0.1"
722+ },
723+ "engines": {
724+ "node": "^20.19.0 || >=22.12.0"
725+ },
726+ "peerDependencies": {
727+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
728+ "babel-plugin-react-compiler": "^1.0.0",
729+ "vite": "^8.0.0"
730+ },
731+ "peerDependenciesMeta": {
732+ "@rolldown/plugin-babel": {
733+ "optional": true
734+ },
735+ "babel-plugin-react-compiler": {
736+ "optional": true
737+ }
738+ }
739+ },
740+ "node_modules/@vitest/expect": {
741+ "version": "4.1.10",
742+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
743+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
744+ "dev": true,
745+ "license": "MIT",
746+ "dependencies": {
747+ "@standard-schema/spec": "^1.1.0",
748+ "@types/chai": "^5.2.2",
749+ "@vitest/spy": "4.1.10",
750+ "@vitest/utils": "4.1.10",
751+ "chai": "^6.2.2",
752+ "tinyrainbow": "^3.1.0"
753+ },
754+ "funding": {
755+ "url": "https://opencollective.com/vitest"
756+ }
757+ },
758+ "node_modules/@vitest/mocker": {
759+ "version": "4.1.10",
760+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
761+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
762+ "dev": true,
763+ "license": "MIT",
764+ "dependencies": {
765+ "@vitest/spy": "4.1.10",
766+ "estree-walker": "^3.0.3",
767+ "magic-string": "^0.30.21"
768+ },
769+ "funding": {
770+ "url": "https://opencollective.com/vitest"
771+ },
772+ "peerDependencies": {
773+ "msw": "^2.4.9",
774+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
775+ },
776+ "peerDependenciesMeta": {
777+ "msw": {
778+ "optional": true
779+ },
780+ "vite": {
781+ "optional": true
782+ }
783+ }
784+ },
785+ "node_modules/@vitest/pretty-format": {
786+ "version": "4.1.10",
787+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
788+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
789+ "dev": true,
790+ "license": "MIT",
791+ "dependencies": {
792+ "tinyrainbow": "^3.1.0"
793+ },
794+ "funding": {
795+ "url": "https://opencollective.com/vitest"
796+ }
797+ },
798+ "node_modules/@vitest/runner": {
799+ "version": "4.1.10",
800+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
801+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
802+ "dev": true,
803+ "license": "MIT",
804+ "dependencies": {
805+ "@vitest/utils": "4.1.10",
806+ "pathe": "^2.0.3"
807+ },
808+ "funding": {
809+ "url": "https://opencollective.com/vitest"
810+ }
811+ },
812+ "node_modules/@vitest/snapshot": {
813+ "version": "4.1.10",
814+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
815+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
816+ "dev": true,
817+ "license": "MIT",
818+ "dependencies": {
819+ "@vitest/pretty-format": "4.1.10",
820+ "@vitest/utils": "4.1.10",
821+ "magic-string": "^0.30.21",
822+ "pathe": "^2.0.3"
823+ },
824+ "funding": {
825+ "url": "https://opencollective.com/vitest"
826+ }
827+ },
828+ "node_modules/@vitest/spy": {
829+ "version": "4.1.10",
830+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
831+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
832+ "dev": true,
833+ "license": "MIT",
834+ "funding": {
835+ "url": "https://opencollective.com/vitest"
836+ }
837+ },
838+ "node_modules/@vitest/utils": {
839+ "version": "4.1.10",
840+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
841+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
842+ "dev": true,
843+ "license": "MIT",
844+ "dependencies": {
845+ "@vitest/pretty-format": "4.1.10",
846+ "convert-source-map": "^2.0.0",
847+ "tinyrainbow": "^3.1.0"
848+ },
849+ "funding": {
850+ "url": "https://opencollective.com/vitest"
851+ }
852+ },
853+ "node_modules/assertion-error": {
854+ "version": "2.0.1",
855+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
856+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
857+ "dev": true,
858+ "license": "MIT",
859+ "engines": {
860+ "node": ">=12"
861+ }
862+ },
863+ "node_modules/chai": {
864+ "version": "6.2.2",
865+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
866+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
867+ "dev": true,
868+ "license": "MIT",
869+ "engines": {
870+ "node": ">=18"
871+ }
872+ },
873+ "node_modules/convert-source-map": {
874+ "version": "2.0.0",
875+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
876+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
877+ "dev": true,
878+ "license": "MIT"
879+ },
880+ "node_modules/csstype": {
881+ "version": "3.2.3",
882+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
883+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
884+ "dev": true,
885+ "license": "MIT"
886+ },
887+ "node_modules/detect-libc": {
888+ "version": "2.1.2",
889+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
890+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
891+ "dev": true,
892+ "license": "Apache-2.0",
893+ "engines": {
894+ "node": ">=8"
895+ }
896+ },
897+ "node_modules/es-module-lexer": {
898+ "version": "2.3.1",
899+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
900+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
901+ "dev": true,
902+ "license": "MIT"
903+ },
904+ "node_modules/estree-walker": {
905+ "version": "3.0.3",
906+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
907+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
908+ "dev": true,
909+ "license": "MIT",
910+ "dependencies": {
911+ "@types/estree": "^1.0.0"
912+ }
913+ },
914+ "node_modules/expect-type": {
915+ "version": "1.4.0",
916+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
917+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
918+ "dev": true,
919+ "license": "Apache-2.0",
920+ "engines": {
921+ "node": ">=12.0.0"
922+ }
923+ },
924+ "node_modules/fdir": {
925+ "version": "6.5.0",
926+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
927+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
928+ "dev": true,
929+ "license": "MIT",
930+ "engines": {
931+ "node": ">=12.0.0"
932+ },
933+ "peerDependencies": {
934+ "picomatch": "^3 || ^4"
935+ },
936+ "peerDependenciesMeta": {
937+ "picomatch": {
938+ "optional": true
939+ }
940+ }
941+ },
942+ "node_modules/fsevents": {
943+ "version": "2.3.3",
944+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
945+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
946+ "dev": true,
947+ "hasInstallScript": true,
948+ "license": "MIT",
949+ "optional": true,
950+ "os": [
951+ "darwin"
952+ ],
953+ "engines": {
954+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
955+ }
956+ },
957+ "node_modules/lightningcss": {
958+ "version": "1.33.0",
959+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
960+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
961+ "dev": true,
962+ "license": "MPL-2.0",
963+ "dependencies": {
964+ "detect-libc": "^2.0.3"
965+ },
966+ "engines": {
967+ "node": ">= 12.0.0"
968+ },
969+ "funding": {
970+ "type": "opencollective",
971+ "url": "https://opencollective.com/parcel"
972+ },
973+ "optionalDependencies": {
974+ "lightningcss-android-arm64": "1.33.0",
975+ "lightningcss-darwin-arm64": "1.33.0",
976+ "lightningcss-darwin-x64": "1.33.0",
977+ "lightningcss-freebsd-x64": "1.33.0",
978+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
979+ "lightningcss-linux-arm64-gnu": "1.33.0",
980+ "lightningcss-linux-arm64-musl": "1.33.0",
981+ "lightningcss-linux-x64-gnu": "1.33.0",
982+ "lightningcss-linux-x64-musl": "1.33.0",
983+ "lightningcss-win32-arm64-msvc": "1.33.0",
984+ "lightningcss-win32-x64-msvc": "1.33.0"
985+ }
986+ },
987+ "node_modules/lightningcss-android-arm64": {
988+ "version": "1.33.0",
989+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
990+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
991+ "cpu": [
992+ "arm64"
993+ ],
994+ "dev": true,
995+ "license": "MPL-2.0",
996+ "optional": true,
997+ "os": [
998+ "android"
999+ ],
1000+ "engines": {
1001+ "node": ">= 12.0.0"
1002+ },
1003+ "funding": {
1004+ "type": "opencollective",
1005+ "url": "https://opencollective.com/parcel"
1006+ }
1007+ },
1008+ "node_modules/lightningcss-darwin-arm64": {
1009+ "version": "1.33.0",
1010+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
1011+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
1012+ "cpu": [
1013+ "arm64"
1014+ ],
1015+ "dev": true,
1016+ "license": "MPL-2.0",
1017+ "optional": true,
1018+ "os": [
1019+ "darwin"
1020+ ],
1021+ "engines": {
1022+ "node": ">= 12.0.0"
1023+ },
1024+ "funding": {
1025+ "type": "opencollective",
1026+ "url": "https://opencollective.com/parcel"
1027+ }
1028+ },
1029+ "node_modules/lightningcss-darwin-x64": {
1030+ "version": "1.33.0",
1031+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
1032+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
1033+ "cpu": [
1034+ "x64"
1035+ ],
1036+ "dev": true,
1037+ "license": "MPL-2.0",
1038+ "optional": true,
1039+ "os": [
1040+ "darwin"
1041+ ],
1042+ "engines": {
1043+ "node": ">= 12.0.0"
1044+ },
1045+ "funding": {
1046+ "type": "opencollective",
1047+ "url": "https://opencollective.com/parcel"
1048+ }
1049+ },
1050+ "node_modules/lightningcss-freebsd-x64": {
1051+ "version": "1.33.0",
1052+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
1053+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
1054+ "cpu": [
1055+ "x64"
1056+ ],
1057+ "dev": true,
1058+ "license": "MPL-2.0",
1059+ "optional": true,
1060+ "os": [
1061+ "freebsd"
1062+ ],
1063+ "engines": {
1064+ "node": ">= 12.0.0"
1065+ },
1066+ "funding": {
1067+ "type": "opencollective",
1068+ "url": "https://opencollective.com/parcel"
1069+ }
1070+ },
1071+ "node_modules/lightningcss-linux-arm-gnueabihf": {
1072+ "version": "1.33.0",
1073+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
1074+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
1075+ "cpu": [
1076+ "arm"
1077+ ],
1078+ "dev": true,
1079+ "license": "MPL-2.0",
1080+ "optional": true,
1081+ "os": [
1082+ "linux"
1083+ ],
1084+ "engines": {
1085+ "node": ">= 12.0.0"
1086+ },
1087+ "funding": {
1088+ "type": "opencollective",
1089+ "url": "https://opencollective.com/parcel"
1090+ }
1091+ },
1092+ "node_modules/lightningcss-linux-arm64-gnu": {
1093+ "version": "1.33.0",
1094+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
1095+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
1096+ "cpu": [
1097+ "arm64"
1098+ ],
1099+ "dev": true,
1100+ "libc": [
1101+ "glibc"
1102+ ],
1103+ "license": "MPL-2.0",
1104+ "optional": true,
1105+ "os": [
1106+ "linux"
1107+ ],
1108+ "engines": {
1109+ "node": ">= 12.0.0"
1110+ },
1111+ "funding": {
1112+ "type": "opencollective",
1113+ "url": "https://opencollective.com/parcel"
1114+ }
1115+ },
1116+ "node_modules/lightningcss-linux-arm64-musl": {
1117+ "version": "1.33.0",
1118+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
1119+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
1120+ "cpu": [
1121+ "arm64"
1122+ ],
1123+ "dev": true,
1124+ "libc": [
1125+ "musl"
1126+ ],
1127+ "license": "MPL-2.0",
1128+ "optional": true,
1129+ "os": [
1130+ "linux"
1131+ ],
1132+ "engines": {
1133+ "node": ">= 12.0.0"
1134+ },
1135+ "funding": {
1136+ "type": "opencollective",
1137+ "url": "https://opencollective.com/parcel"
1138+ }
1139+ },
1140+ "node_modules/lightningcss-linux-x64-gnu": {
1141+ "version": "1.33.0",
1142+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
1143+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
1144+ "cpu": [
1145+ "x64"
1146+ ],
1147+ "dev": true,
1148+ "libc": [
1149+ "glibc"
1150+ ],
1151+ "license": "MPL-2.0",
1152+ "optional": true,
1153+ "os": [
1154+ "linux"
1155+ ],
1156+ "engines": {
1157+ "node": ">= 12.0.0"
1158+ },
1159+ "funding": {
1160+ "type": "opencollective",
1161+ "url": "https://opencollective.com/parcel"
1162+ }
1163+ },
1164+ "node_modules/lightningcss-linux-x64-musl": {
1165+ "version": "1.33.0",
1166+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
1167+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
1168+ "cpu": [
1169+ "x64"
1170+ ],
1171+ "dev": true,
1172+ "libc": [
1173+ "musl"
1174+ ],
1175+ "license": "MPL-2.0",
1176+ "optional": true,
1177+ "os": [
1178+ "linux"
1179+ ],
1180+ "engines": {
1181+ "node": ">= 12.0.0"
1182+ },
1183+ "funding": {
1184+ "type": "opencollective",
1185+ "url": "https://opencollective.com/parcel"
1186+ }
1187+ },
1188+ "node_modules/lightningcss-win32-arm64-msvc": {
1189+ "version": "1.33.0",
1190+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
1191+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
1192+ "cpu": [
1193+ "arm64"
1194+ ],
1195+ "dev": true,
1196+ "license": "MPL-2.0",
1197+ "optional": true,
1198+ "os": [
1199+ "win32"
1200+ ],
1201+ "engines": {
1202+ "node": ">= 12.0.0"
1203+ },
1204+ "funding": {
1205+ "type": "opencollective",
1206+ "url": "https://opencollective.com/parcel"
1207+ }
1208+ },
1209+ "node_modules/lightningcss-win32-x64-msvc": {
1210+ "version": "1.33.0",
1211+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
1212+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
1213+ "cpu": [
1214+ "x64"
1215+ ],
1216+ "dev": true,
1217+ "license": "MPL-2.0",
1218+ "optional": true,
1219+ "os": [
1220+ "win32"
1221+ ],
1222+ "engines": {
1223+ "node": ">= 12.0.0"
1224+ },
1225+ "funding": {
1226+ "type": "opencollective",
1227+ "url": "https://opencollective.com/parcel"
1228+ }
1229+ },
1230+ "node_modules/magic-string": {
1231+ "version": "0.30.21",
1232+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1233+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1234+ "dev": true,
1235+ "license": "MIT",
1236+ "dependencies": {
1237+ "@jridgewell/sourcemap-codec": "^1.5.5"
1238+ }
1239+ },
1240+ "node_modules/nanoid": {
1241+ "version": "3.3.17",
1242+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
1243+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
1244+ "dev": true,
1245+ "funding": [
1246+ {
1247+ "type": "github",
1248+ "url": "https://github.com/sponsors/ai"
1249+ }
1250+ ],
1251+ "license": "MIT",
1252+ "bin": {
1253+ "nanoid": "bin/nanoid.cjs"
1254+ },
1255+ "engines": {
1256+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1257+ }
1258+ },
1259+ "node_modules/obug": {
1260+ "version": "2.1.4",
1261+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
1262+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
1263+ "dev": true,
1264+ "funding": [
1265+ "https://github.com/sponsors/sxzz",
1266+ "https://opencollective.com/debug"
1267+ ],
1268+ "license": "MIT",
1269+ "engines": {
1270+ "node": ">=12.20.0"
1271+ }
1272+ },
1273+ "node_modules/oxlint": {
1274+ "version": "1.77.0",
1275+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz",
1276+ "integrity": "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==",
1277+ "dev": true,
1278+ "license": "MIT",
1279+ "bin": {
1280+ "oxlint": "bin/oxlint"
1281+ },
1282+ "engines": {
1283+ "node": "^20.19.0 || >=22.12.0"
1284+ },
1285+ "funding": {
1286+ "url": "https://github.com/sponsors/Boshen"
1287+ },
1288+ "optionalDependencies": {
1289+ "@oxlint/binding-android-arm-eabi": "1.77.0",
1290+ "@oxlint/binding-android-arm64": "1.77.0",
1291+ "@oxlint/binding-darwin-arm64": "1.77.0",
1292+ "@oxlint/binding-darwin-x64": "1.77.0",
1293+ "@oxlint/binding-freebsd-x64": "1.77.0",
1294+ "@oxlint/binding-linux-arm-gnueabihf": "1.77.0",
1295+ "@oxlint/binding-linux-arm-musleabihf": "1.77.0",
1296+ "@oxlint/binding-linux-arm64-gnu": "1.77.0",
1297+ "@oxlint/binding-linux-arm64-musl": "1.77.0",
1298+ "@oxlint/binding-linux-ppc64-gnu": "1.77.0",
1299+ "@oxlint/binding-linux-riscv64-gnu": "1.77.0",
1300+ "@oxlint/binding-linux-riscv64-musl": "1.77.0",
1301+ "@oxlint/binding-linux-s390x-gnu": "1.77.0",
1302+ "@oxlint/binding-linux-x64-gnu": "1.77.0",
1303+ "@oxlint/binding-linux-x64-musl": "1.77.0",
1304+ "@oxlint/binding-openharmony-arm64": "1.77.0",
1305+ "@oxlint/binding-win32-arm64-msvc": "1.77.0",
1306+ "@oxlint/binding-win32-ia32-msvc": "1.77.0",
1307+ "@oxlint/binding-win32-x64-msvc": "1.77.0"
1308+ },
1309+ "peerDependencies": {
1310+ "oxlint-tsgolint": ">=7.0.2001",
1311+ "vite-plus": "*"
1312+ },
1313+ "peerDependenciesMeta": {
1314+ "oxlint-tsgolint": {
1315+ "optional": true
1316+ },
1317+ "vite-plus": {
1318+ "optional": true
1319+ }
1320+ }
1321+ },
1322+ "node_modules/pathe": {
1323+ "version": "2.0.3",
1324+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1325+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1326+ "dev": true,
1327+ "license": "MIT"
1328+ },
1329+ "node_modules/picocolors": {
1330+ "version": "1.1.1",
1331+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1332+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1333+ "dev": true,
1334+ "license": "ISC"
1335+ },
1336+ "node_modules/picomatch": {
1337+ "version": "4.0.5",
1338+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
1339+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
1340+ "dev": true,
1341+ "license": "MIT",
1342+ "engines": {
1343+ "node": ">=12"
1344+ },
1345+ "funding": {
1346+ "url": "https://github.com/sponsors/jonschlinkert"
1347+ }
1348+ },
1349+ "node_modules/postcss": {
1350+ "version": "8.5.26",
1351+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1352+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1353+ "dev": true,
1354+ "funding": [
1355+ {
1356+ "type": "opencollective",
1357+ "url": "https://opencollective.com/postcss/"
1358+ },
1359+ {
1360+ "type": "tidelift",
1361+ "url": "https://tidelift.com/funding/github/npm/postcss"
1362+ },
1363+ {
1364+ "type": "github",
1365+ "url": "https://github.com/sponsors/ai"
1366+ }
1367+ ],
1368+ "license": "MIT",
1369+ "dependencies": {
1370+ "nanoid": "^3.3.17",
1371+ "picocolors": "^1.1.1",
1372+ "source-map-js": "^1.2.1"
1373+ },
1374+ "engines": {
1375+ "node": "^10 || ^12 || >=14"
1376+ }
1377+ },
1378+ "node_modules/react": {
1379+ "version": "19.2.8",
1380+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
1381+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
1382+ "license": "MIT",
1383+ "engines": {
1384+ "node": ">=0.10.0"
1385+ }
1386+ },
1387+ "node_modules/react-dom": {
1388+ "version": "19.2.8",
1389+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
1390+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
1391+ "license": "MIT",
1392+ "dependencies": {
1393+ "scheduler": "^0.27.0"
1394+ },
1395+ "peerDependencies": {
1396+ "react": "^19.2.8"
1397+ }
1398+ },
1399+ "node_modules/rolldown": {
1400+ "version": "1.2.3",
1401+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
1402+ "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
1403+ "dev": true,
1404+ "license": "MIT",
1405+ "dependencies": {
1406+ "@oxc-project/types": "=0.143.0",
1407+ "@rolldown/pluginutils": "^1.0.0"
1408+ },
1409+ "bin": {
1410+ "rolldown": "bin/cli.mjs"
1411+ },
1412+ "engines": {
1413+ "node": "^20.19.0 || >=22.12.0"
1414+ },
1415+ "optionalDependencies": {
1416+ "@rolldown/binding-android-arm64": "1.2.3",
1417+ "@rolldown/binding-darwin-arm64": "1.2.3",
1418+ "@rolldown/binding-darwin-x64": "1.2.3",
1419+ "@rolldown/binding-freebsd-x64": "1.2.3",
1420+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
1421+ "@rolldown/binding-linux-arm64-gnu": "1.2.3",
1422+ "@rolldown/binding-linux-arm64-musl": "1.2.3",
1423+ "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
1424+ "@rolldown/binding-linux-s390x-gnu": "1.2.3",
1425+ "@rolldown/binding-linux-x64-gnu": "1.2.3",
1426+ "@rolldown/binding-linux-x64-musl": "1.2.3",
1427+ "@rolldown/binding-openharmony-arm64": "1.2.3",
1428+ "@rolldown/binding-win32-arm64-msvc": "1.2.3",
1429+ "@rolldown/binding-win32-x64-msvc": "1.2.3"
1430+ }
1431+ },
1432+ "node_modules/scheduler": {
1433+ "version": "0.27.0",
1434+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
1435+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
1436+ "license": "MIT"
1437+ },
1438+ "node_modules/siginfo": {
1439+ "version": "2.0.0",
1440+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
1441+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
1442+ "dev": true,
1443+ "license": "ISC"
1444+ },
1445+ "node_modules/source-map-js": {
1446+ "version": "1.2.1",
1447+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1448+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1449+ "dev": true,
1450+ "license": "BSD-3-Clause",
1451+ "engines": {
1452+ "node": ">=0.10.0"
1453+ }
1454+ },
1455+ "node_modules/stackback": {
1456+ "version": "0.0.2",
1457+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
1458+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
1459+ "dev": true,
1460+ "license": "MIT"
1461+ },
1462+ "node_modules/std-env": {
1463+ "version": "4.2.0",
1464+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
1465+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
1466+ "dev": true,
1467+ "license": "MIT"
1468+ },
1469+ "node_modules/tinybench": {
1470+ "version": "2.9.0",
1471+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
1472+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
1473+ "dev": true,
1474+ "license": "MIT"
1475+ },
1476+ "node_modules/tinyexec": {
1477+ "version": "1.3.0",
1478+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
1479+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
1480+ "dev": true,
1481+ "license": "MIT",
1482+ "engines": {
1483+ "node": ">=18"
1484+ }
1485+ },
1486+ "node_modules/tinyglobby": {
1487+ "version": "0.2.17",
1488+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1489+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1490+ "dev": true,
1491+ "license": "MIT",
1492+ "dependencies": {
1493+ "fdir": "^6.5.0",
1494+ "picomatch": "^4.0.4"
1495+ },
1496+ "engines": {
1497+ "node": ">=12.0.0"
1498+ },
1499+ "funding": {
1500+ "url": "https://github.com/sponsors/SuperchupuDev"
1501+ }
1502+ },
1503+ "node_modules/tinyrainbow": {
1504+ "version": "3.1.1",
1505+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
1506+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
1507+ "dev": true,
1508+ "license": "MIT",
1509+ "engines": {
1510+ "node": ">=14.0.0"
1511+ }
1512+ },
1513+ "node_modules/typescript": {
1514+ "version": "6.0.3",
1515+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
1516+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
1517+ "dev": true,
1518+ "license": "Apache-2.0",
1519+ "bin": {
1520+ "tsc": "bin/tsc",
1521+ "tsserver": "bin/tsserver"
1522+ },
1523+ "engines": {
1524+ "node": ">=14.17"
1525+ }
1526+ },
1527+ "node_modules/undici-types": {
1528+ "version": "7.18.2",
1529+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
1530+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1531+ "dev": true,
1532+ "license": "MIT"
1533+ },
1534+ "node_modules/vite": {
1535+ "version": "8.2.0",
1536+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
1537+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
1538+ "dev": true,
1539+ "license": "MIT",
1540+ "dependencies": {
1541+ "lightningcss": "^1.33.0",
1542+ "picomatch": "^4.0.5",
1543+ "postcss": "^8.5.23",
1544+ "rolldown": "~1.2.0",
1545+ "tinyglobby": "^0.2.17"
1546+ },
1547+ "bin": {
1548+ "vite": "bin/vite.js"
1549+ },
1550+ "engines": {
1551+ "node": "^20.19.0 || >=22.12.0"
1552+ },
1553+ "funding": {
1554+ "url": "https://github.com/vitejs/vite?sponsor=1"
1555+ },
1556+ "optionalDependencies": {
1557+ "fsevents": "~2.3.3"
1558+ },
1559+ "peerDependencies": {
1560+ "@types/node": "^20.19.0 || >=22.12.0",
1561+ "@vitejs/devtools": "^0.4.0",
1562+ "esbuild": "^0.27.0 || ^0.28.0",
1563+ "jiti": ">=1.21.0",
1564+ "less": "^4.0.0",
1565+ "sass": "^1.70.0",
1566+ "sass-embedded": "^1.70.0",
1567+ "stylus": ">=0.54.8",
1568+ "sugarss": "^5.0.0",
1569+ "terser": "^5.16.0",
1570+ "tsx": "^4.8.1",
1571+ "yaml": "^2.4.2"
1572+ },
1573+ "peerDependenciesMeta": {
1574+ "@types/node": {
1575+ "optional": true
1576+ },
1577+ "@vitejs/devtools": {
1578+ "optional": true
1579+ },
1580+ "esbuild": {
1581+ "optional": true
1582+ },
1583+ "jiti": {
1584+ "optional": true
1585+ },
1586+ "less": {
1587+ "optional": true
1588+ },
1589+ "sass": {
1590+ "optional": true
1591+ },
1592+ "sass-embedded": {
1593+ "optional": true
1594+ },
1595+ "stylus": {
1596+ "optional": true
1597+ },
1598+ "sugarss": {
1599+ "optional": true
1600+ },
1601+ "terser": {
1602+ "optional": true
1603+ },
1604+ "tsx": {
1605+ "optional": true
1606+ },
1607+ "yaml": {
1608+ "optional": true
1609+ }
1610+ }
1611+ },
1612+ "node_modules/vitest": {
1613+ "version": "4.1.10",
1614+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
1615+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
1616+ "dev": true,
1617+ "license": "MIT",
1618+ "dependencies": {
1619+ "@vitest/expect": "4.1.10",
1620+ "@vitest/mocker": "4.1.10",
1621+ "@vitest/pretty-format": "4.1.10",
1622+ "@vitest/runner": "4.1.10",
1623+ "@vitest/snapshot": "4.1.10",
1624+ "@vitest/spy": "4.1.10",
1625+ "@vitest/utils": "4.1.10",
1626+ "es-module-lexer": "^2.0.0",
1627+ "expect-type": "^1.3.0",
1628+ "magic-string": "^0.30.21",
1629+ "obug": "^2.1.1",
1630+ "pathe": "^2.0.3",
1631+ "picomatch": "^4.0.3",
1632+ "std-env": "^4.0.0-rc.1",
1633+ "tinybench": "^2.9.0",
1634+ "tinyexec": "^1.0.2",
1635+ "tinyglobby": "^0.2.15",
1636+ "tinyrainbow": "^3.1.0",
1637+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
1638+ "why-is-node-running": "^2.3.0"
1639+ },
1640+ "bin": {
1641+ "vitest": "vitest.mjs"
1642+ },
1643+ "engines": {
1644+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
1645+ },
1646+ "funding": {
1647+ "url": "https://opencollective.com/vitest"
1648+ },
1649+ "peerDependencies": {
1650+ "@edge-runtime/vm": "*",
1651+ "@opentelemetry/api": "^1.9.0",
1652+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
1653+ "@vitest/browser-playwright": "4.1.10",
1654+ "@vitest/browser-preview": "4.1.10",
1655+ "@vitest/browser-webdriverio": "4.1.10",
1656+ "@vitest/coverage-istanbul": "4.1.10",
1657+ "@vitest/coverage-v8": "4.1.10",
1658+ "@vitest/ui": "4.1.10",
1659+ "happy-dom": "*",
1660+ "jsdom": "*",
1661+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
1662+ },
1663+ "peerDependenciesMeta": {
1664+ "@edge-runtime/vm": {
1665+ "optional": true
1666+ },
1667+ "@opentelemetry/api": {
1668+ "optional": true
1669+ },
1670+ "@types/node": {
1671+ "optional": true
1672+ },
1673+ "@vitest/browser-playwright": {
1674+ "optional": true
1675+ },
1676+ "@vitest/browser-preview": {
1677+ "optional": true
1678+ },
1679+ "@vitest/browser-webdriverio": {
1680+ "optional": true
1681+ },
1682+ "@vitest/coverage-istanbul": {
1683+ "optional": true
1684+ },
1685+ "@vitest/coverage-v8": {
1686+ "optional": true
1687+ },
1688+ "@vitest/ui": {
1689+ "optional": true
1690+ },
1691+ "happy-dom": {
1692+ "optional": true
1693+ },
1694+ "jsdom": {
1695+ "optional": true
1696+ },
1697+ "vite": {
1698+ "optional": false
1699+ }
1700+ }
1701+ },
1702+ "node_modules/why-is-node-running": {
1703+ "version": "2.3.0",
1704+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
1705+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
1706+ "dev": true,
1707+ "license": "MIT",
1708+ "dependencies": {
1709+ "siginfo": "^2.0.0",
1710+ "stackback": "0.0.2"
1711+ },
1712+ "bin": {
1713+ "why-is-node-running": "cli.js"
1714+ },
1715+ "engines": {
1716+ "node": ">=8"
1717+ }
1718+ }
1719+ }
1720+}
addedpackage.json+27 −0
1+{
2+ "name": "mahjong",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "lint": "oxlint",
10+ "preview": "vite preview",
11+ "test": "vitest run"
12+ },
13+ "dependencies": {
14+ "react": "^19.2.8",
15+ "react-dom": "^19.2.8"
16+ },
17+ "devDependencies": {
18+ "@types/node": "^24.13.3",
19+ "@types/react": "^19.2.17",
20+ "@types/react-dom": "^19.2.3",
21+ "@vitejs/plugin-react": "^6.0.4",
22+ "oxlint": "^1.75.0",
23+ "typescript": "~6.0.2",
24+ "vite": "^8.2.0",
25+ "vitest": "^4.1.10"
26+ }
27+}
addedpublic/favicon.svg+1 −0
1+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
addedpublic/icons.svg+24 −0
1+<svg xmlns="http://www.w3.org/2000/svg">
2+ <symbol id="bluesky-icon" viewBox="0 0 16 17">
3+ <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
4+ <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
5+ </symbol>
6+ <symbol id="discord-icon" viewBox="0 0 20 19">
7+ <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
8+ </symbol>
9+ <symbol id="documentation-icon" viewBox="0 0 21 20">
10+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
11+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
12+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
13+ </symbol>
14+ <symbol id="github-icon" viewBox="0 0 19 19">
15+ <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
16+ </symbol>
17+ <symbol id="social-icon" viewBox="0 0 20 20">
18+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
19+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
20+ </symbol>
21+ <symbol id="x-icon" viewBox="0 0 19 19">
22+ <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
23+ </symbol>
24+</svg>
addedpublic/tiles/back.pngbinary
Binary file
addedpublic/tiles/tiles.svg+513 −0
1+<?xml version="1.0" encoding="utf-8"?>
2+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="5504" height="352" viewBox="0 0 2752 176" version="1.1">
4+ <defs>
5+ <g id="bt1">
6+ <path d="M9,0C8.47,0,7.96,0.21,7.59,0.59l-7,7C0.21,7.96,0,8.47,0,9v77c0,1.1,0.9,2,2,2h53c0.53,0,1.04-0.21,1.41-0.59l7-7C63.79,80.04,64,79.53,64,79V2c0-1.1-0.9-2-2-2H9z"/>
7+ <path fill="#9F9F9F" d="M11.75,78.25l-9,9h9H55c0.33,0,0.65-0.13,0.88-0.37l7-7c0.23-0.23,0.37-1.3,0.37-1.63H11.75z"/>
8+ <path fill="#D7D7D7" d="M8.12,1.12l-7,7C0.88,8.35,0.75,8.67,0.75,9v66.25v1v9l2,2l7-7v-4v-1V0.75C9.42,0.75,8.35,0.88,8.12,1.12z"/>
9+ <path fill="#BFBFBF" d="M1.61,86.89l10.14-10.14v-0.5L1.34,86.66C1.42,86.75,1.51,86.82,1.61,86.89z"/>
10+ <path fill="#B7B7B7" d="M1.93,87.07l9.82-9.82v-0.5L1.61,86.89C1.71,86.96,1.82,87.02,1.93,87.07z"/>
11+ <path fill="#AFAFAF" d="M2.3,87.2l9.45-9.45v-0.5l-9.82,9.82C2.05,87.12,2.17,87.17,2.3,87.2z"/>
12+ <path fill="#A7A7A7" d="M11.75,77.75L2.3,87.2c0.14,0.03,0.29,0.05,0.45,0.05l9-9V77.75z"/>
13+ <path fill="#C7C7C7" d="M1.34,86.66l10.41-10.41h-0.5L1.11,86.39C1.18,86.49,1.25,86.58,1.34,86.66z"/>
14+ <path fill="#CDCDCD" d="M1.11,86.39l10.14-10.14h-0.5l-9.82,9.82C0.98,86.18,1.04,86.29,1.11,86.39z"/>
15+ <path fill="#D2D2D2" d="M0.93,86.07l9.82-9.82h-0.5L0.8,85.7C0.83,85.83,0.88,85.95,0.93,86.07z"/>
16+ <path fill="#D7D7D7" d="M10.25,76.25h-0.5l-9,9c0,0.15,0.02,0.3,0.05,0.45L10.25,76.25z"/>
17+ <path fill="#F6F6F6" d="M63.25,78.25V2.75c0-1.1-0.9-2-2-2h-50.5c-1.1,0-2,0.9-2,2v75.5c0,1.1,0.9,2,2,2h50.5C62.35,80.25,63.25,79.35,63.25,78.25z"/>
18+ <path fill="#FFFFFF" d="M8.75,78.25V2.75c0-1.1,0.9-2,2-2h-1c-1.1,0-2,0.9-2,2v75.5c0,1.1,0.9,2,2,2h1C9.65,80.25,8.75,79.35,8.75,78.25z"/>
19+ </g>
20+ <g id="bt2">
21+ <path fill="#0000FF" d="M9,0C8.47,0,7.96,0.21,7.59,0.59l-7,7C0.21,7.96,0,8.47,0,9v77c0,1.1,0.9,2,2,2h53c0.53,0,1.04-0.21,1.41-0.59l7-7C63.79,80.04,64,79.53,64,79V2c0-1.1-0.9-2-2-2H9z"/>
22+ <path fill="#8F8FFF" d="M11.75,78.25l-9,9h9H55c0.33,0,0.65-0.13,0.88-0.37l7-7c0.23-0.23,0.37-1.3,0.37-1.63H11.75z"/>
23+ <path fill="#C7C7FF" d="M8.12,1.12l-7,7C0.88,8.35,0.75,8.67,0.75,9v66.25v1v9l2,2l7-7v-4v-1V0.75C9.42,0.75,8.35,0.88,8.12,1.12z"/>
24+ <path fill="#AFAFFF" d="M1.61,86.89l10.14-10.14v-0.5L1.34,86.66C1.42,86.75,1.51,86.82,1.61,86.89z"/>
25+ <path fill="#A7A7FF" d="M1.93,87.07l9.82-9.82v-0.5L1.61,86.89C1.71,86.96,1.82,87.02,1.93,87.07z"/>
26+ <path fill="#9F9FFF" d="M2.3,87.2l9.45-9.45v-0.5l-9.82,9.82C2.05,87.12,2.17,87.17,2.3,87.2z"/>
27+ <path fill="#9797FF" d="M11.75,77.75L2.3,87.2c0.14,0.03,0.29,0.05,0.45,0.05l9-9V77.75z"/>
28+ <path fill="#B7B7FF" d="M1.34,86.66l10.41-10.41h-0.5L1.11,86.39C1.18,86.49,1.25,86.58,1.34,86.66z"/>
29+ <path fill="#BDBDFF" d="M1.11,86.39l10.14-10.14h-0.5l-9.82,9.82C0.98,86.18,1.04,86.29,1.11,86.39z"/>
30+ <path fill="#C2C2FF" d="M0.93,86.07l9.82-9.82h-0.5L0.8,85.7C0.83,85.83,0.88,85.95,0.93,86.07z"/>
31+ <path fill="#C7C7FF" d="M10.25,76.25h-0.5l-9,9c0,0.15,0.02,0.3,0.05,0.45L10.25,76.25z"/>
32+ <path fill="#E3E3FF" d="M61.25,0.75H9.75c-1.1,0-2,0.9-2,2v75.5c0,1.1,0.9,2,2,2h51.5c1.1,0,2-0.9,2-2V2.75C63.25,1.65,62.35,0.75,61.25,0.75z"/>
33+ <path fill="#FFFFFF" stroke="#0000FF" stroke-width="3" d="M60,74.19c0,1.55-1.22,2.81-2.72,2.81H13.72c-1.5,0-2.72-1.26-2.72-2.81V6.81C11,5.26,12.22,4,13.72,4h43.56C58.78,4,60,5.26,60,6.81V74.19z"/>
34+ </g>
35+ <path id="bams" fill="#006C00" d="M11,13.5c0-0.92-1.25-1.71-3.06-2.09V4.44C9.75,4.06,11,3.27,11,2.35C11,1.05,8.54,0,5.5,0S0,1.05,0,2.35c0,0.92,1.25,1.71,3.06,2.09v6.97C1.25,11.79,0,12.58,0,13.5c0,0.92,1.25,1.71,3.06,2.09v6.96C1.25,22.94,0,23.73,0,24.65C0,25.95,2.46,27,5.5,27s5.5-1.05,5.5-2.35c0-0.92-1.25-1.71-3.06-2.09v-6.96C9.75,15.21,11,14.42,11,13.5z M5.5,1.47c1.35,0,2.44,0.39,2.44,0.88c0,0.41-0.78,0.75-1.83,0.85v5.94c0,0.46-0.28,0.84-0.61,0.84S4.89,9.6,4.89,9.14V3.2C3.84,3.1,3.06,2.76,3.06,2.35C3.06,1.86,4.15,1.47,5.5,1.47z M5.5,25.53c-1.35,0-2.44-0.39-2.44-0.88c0-0.41,0.78-0.75,1.83-0.85v-5.94c0-0.46,0.28-0.84,0.61-0.84s0.61,0.38,0.61,0.84v5.94c1.05,0.1,1.83,0.44,1.83,0.85C7.94,25.14,6.85,25.53,5.5,25.53z M5.5,14.38c-1.86,0-3.36-0.39-3.36-0.88c0-0.49,1.5-0.88,3.36-0.88s3.36,0.39,3.36,0.88C8.86,13.99,7.36,14.38,5.5,14.38z"/>
36+ <path id="bams2" fill="#9C0000" d="M11,13.5c0-0.92-1.25-1.71-3.06-2.09V4.44C9.75,4.06,11,3.27,11,2.35C11,1.05,8.54,0,5.5,0S0,1.05,0,2.35c0,0.92,1.25,1.71,3.06,2.09v6.97C1.25,11.79,0,12.58,0,13.5c0,0.92,1.25,1.71,3.06,2.09v6.96C1.25,22.94,0,23.73,0,24.65C0,25.95,2.46,27,5.5,27s5.5-1.05,5.5-2.35c0-0.92-1.25-1.71-3.06-2.09v-6.96C9.75,15.21,11,14.42,11,13.5z M5.5,1.47c1.35,0,2.44,0.39,2.44,0.88c0,0.41-0.78,0.75-1.83,0.85v5.94c0,0.46-0.28,0.84-0.61,0.84S4.89,9.6,4.89,9.14V3.2C3.84,3.1,3.06,2.76,3.06,2.35C3.06,1.86,4.15,1.47,5.5,1.47z M5.5,25.53c-1.35,0-2.44-0.39-2.44-0.88c0-0.41,0.78-0.75,1.83-0.85v-5.94c0-0.46,0.28-0.84,0.61-0.84s0.61,0.38,0.61,0.84v5.94c1.05,0.1,1.83,0.44,1.83,0.85C7.94,25.14,6.85,25.53,5.5,25.53z M5.5,14.38c-1.86,0-3.36-0.39-3.36-0.88c0-0.49,1.5-0.88,3.36-0.88s3.36,0.39,3.36,0.88C8.86,13.99,7.36,14.38,5.5,14.38z"/>
37+ <g id="dots">
38+ <path fill="#10106C" d="M9,1.42c-4.18,0-7.58,3.4-7.58,7.58s3.4,7.58,7.58,7.58c4.18,0,7.58-3.4,7.58-7.58S13.18,1.42,9,1.42zM9,15.11c-1.36,0-2.49-0.97-2.75-2.25c0.78,0.56,1.72,0.89,2.75,0.89c1.03,0,1.97-0.34,2.75-0.89C11.49,14.14,10.36,15.11,9,15.11z M9,2.89c1.36,0,2.49,0.97,2.75,2.25C10.97,4.59,10.03,4.25,9,4.25c-1.03,0-1.97,0.34-2.75,0.89C6.51,3.86,7.64,2.89,9,2.89z M12.75,9c0,0.57-0.14,1.11-0.37,1.59C12.04,9.92,11.51,9.37,10.85,9c0.66-0.37,1.19-0.92,1.53-1.59C12.61,7.89,12.75,8.43,12.75,9z M5.25,9c0-0.57,0.14-1.11,0.37-1.59C5.96,8.08,6.49,8.63,7.15,9c-0.66,0.37-1.19,0.92-1.53,1.59C5.39,10.11,5.25,9.57,5.25,9z M11.59,11.7c-1.02-0.27-1.82-1.1-2.03-2.14c1.04,0.21,1.87,1.01,2.14,2.03C11.66,11.63,11.63,11.66,11.59,11.7z M6.3,11.59c0.27-1.02,1.1-1.82,2.15-2.03c-0.21,1.04-1.01,1.87-2.03,2.14C6.37,11.66,6.34,11.63,6.3,11.59z M6.41,6.3c1.02,0.27,1.82,1.1,2.03,2.15C7.4,8.23,6.57,7.43,6.3,6.41C6.34,6.37,6.37,6.34,6.41,6.3z M11.7,6.41c-0.27,1.02-1.1,1.82-2.14,2.03c0.21-1.05,1.01-1.87,2.03-2.15C11.63,6.34,11.66,6.37,11.7,6.41z M9,7.15C8.63,6.49,8.08,5.96,7.41,5.62C7.89,5.39,8.43,5.25,9,5.25s1.11,0.14,1.59,0.37C9.92,5.96,9.37,6.49,9,7.15z M9,10.85c0.37,0.66,0.92,1.19,1.59,1.53c-0.49,0.23-1.02,0.37-1.59,0.37s-1.11-0.14-1.59-0.37C8.08,12.04,8.63,11.51,9,10.85z M5.14,11.75C3.86,11.49,2.89,10.36,2.89,9s0.97-2.49,2.25-2.75C4.59,7.03,4.25,7.97,4.25,9C4.25,10.03,4.59,10.97,5.14,11.75z M12.86,6.25c1.28,0.26,2.25,1.39,2.25,2.75s-0.97,2.49-2.25,2.75c0.56-0.78,0.89-1.72,0.89-2.75C13.75,7.97,13.41,7.03,12.86,6.25z M15.01,6.33c-0.59-0.6-1.38-0.99-2.25-1.09c-0.11-0.88-0.49-1.66-1.09-2.25C13.15,3.65,14.35,4.85,15.01,6.33z M6.33,2.99c-0.6,0.59-0.98,1.38-1.09,2.25C4.36,5.35,3.58,5.73,2.99,6.33C3.65,4.85,4.85,3.65,6.33,2.99z M2.99,11.67c0.59,0.6,1.38,0.99,2.25,1.09c0.11,0.88,0.49,1.66,1.09,2.25C4.85,14.35,3.65,13.15,2.99,11.67z M11.67,15.01c0.6-0.59,0.99-1.38,1.09-2.25c0.88-0.11,1.66-0.49,2.25-1.09C14.35,13.15,13.15,14.35,11.67,15.01z"/>
39+ <path fill="#10106C" d="M9,0C4.04,0,0,4.04,0,9c0,4.96,4.04,9,9,9c4.96,0,9-4.04,9-9C18,4.04,13.96,0,9,0z M9,17c-4.41,0-8-3.59-8-8s3.59-8,8-8s8,3.59,8,8S13.41,17,9,17z"/>
40+ </g>
41+ <g id="dots2">
42+ <path fill="#9C0000" d="M9,1.42c-4.18,0-7.58,3.4-7.58,7.58s3.4,7.58,7.58,7.58c4.18,0,7.58-3.4,7.58-7.58S13.18,1.42,9,1.42zM9,15.11c-1.36,0-2.49-0.97-2.75-2.25c0.78,0.56,1.72,0.89,2.75,0.89c1.03,0,1.97-0.34,2.75-0.89C11.49,14.14,10.36,15.11,9,15.11z M9,2.89c1.36,0,2.49,0.97,2.75,2.25C10.97,4.59,10.03,4.25,9,4.25c-1.03,0-1.97,0.34-2.75,0.89C6.51,3.86,7.64,2.89,9,2.89z M12.75,9c0,0.57-0.14,1.11-0.37,1.59C12.04,9.92,11.51,9.37,10.85,9c0.66-0.37,1.19-0.92,1.53-1.59C12.61,7.89,12.75,8.43,12.75,9z M5.25,9c0-0.57,0.14-1.11,0.37-1.59C5.96,8.08,6.49,8.63,7.15,9c-0.66,0.37-1.19,0.92-1.53,1.59C5.39,10.11,5.25,9.57,5.25,9z M11.59,11.7c-1.02-0.27-1.82-1.1-2.03-2.14c1.04,0.21,1.87,1.01,2.14,2.03C11.66,11.63,11.63,11.66,11.59,11.7z M6.3,11.59c0.27-1.02,1.1-1.82,2.15-2.03c-0.21,1.04-1.01,1.87-2.03,2.14C6.37,11.66,6.34,11.63,6.3,11.59z M6.41,6.3c1.02,0.27,1.82,1.1,2.03,2.15C7.4,8.23,6.57,7.43,6.3,6.41C6.34,6.37,6.37,6.34,6.41,6.3z M11.7,6.41c-0.27,1.02-1.1,1.82-2.14,2.03c0.21-1.05,1.01-1.87,2.03-2.15C11.63,6.34,11.66,6.37,11.7,6.41z M9,7.15C8.63,6.49,8.08,5.96,7.41,5.62C7.89,5.39,8.43,5.25,9,5.25s1.11,0.14,1.59,0.37C9.92,5.96,9.37,6.49,9,7.15z M9,10.85c0.37,0.66,0.92,1.19,1.59,1.53c-0.49,0.23-1.02,0.37-1.59,0.37s-1.11-0.14-1.59-0.37C8.08,12.04,8.63,11.51,9,10.85z M5.14,11.75C3.86,11.49,2.89,10.36,2.89,9s0.97-2.49,2.25-2.75C4.59,7.03,4.25,7.97,4.25,9C4.25,10.03,4.59,10.97,5.14,11.75z M12.86,6.25c1.28,0.26,2.25,1.39,2.25,2.75s-0.97,2.49-2.25,2.75c0.56-0.78,0.89-1.72,0.89-2.75C13.75,7.97,13.41,7.03,12.86,6.25z M15.01,6.33c-0.59-0.6-1.38-0.99-2.25-1.09c-0.11-0.88-0.49-1.66-1.09-2.25C13.15,3.65,14.35,4.85,15.01,6.33z M6.33,2.99c-0.6,0.59-0.98,1.38-1.09,2.25C4.36,5.35,3.58,5.73,2.99,6.33C3.65,4.85,4.85,3.65,6.33,2.99z M2.99,11.67c0.59,0.6,1.38,0.99,2.25,1.09c0.11,0.88,0.49,1.66,1.09,2.25C4.85,14.35,3.65,13.15,2.99,11.67z M11.67,15.01c0.6-0.59,0.99-1.38,1.09-2.25c0.88-0.11,1.66-0.49,2.25-1.09C14.35,13.15,13.15,14.35,11.67,15.01z"/>
43+ <path fill="#9C0000" d="M9,0C4.04,0,0,4.04,0,9c0,4.96,4.04,9,9,9c4.96,0,9-4.04,9-9C18,4.04,13.96,0,9,0z M9,17c-4.41,0-8-3.59-8-8s3.59-8,8-8s8,3.59,8,8S13.41,17,9,17z"/>
44+ </g>
45+ <g id="nums">
46+ <path fill="#BA0000" d="M18.01,0.14c0,0-4.39,3.33-4.78,3.63c-0.32-0.33-3.2-3.27-3.2-3.27l2.22,8.92l0.6-0.03c0.75-4.76,4.35-6.63,5.33-7.05c0.14,0.2,0.26,0.42,0.26,0.72c0,0.09-0.01,0.18-0.02,0.28c-0.21,1.67-2.39,4.64-6.68,7.21l-0.31,0.18l0.81,1.01l0.2-0.06c0.06-0.02,6.59-2.06,10.36-1.88l1.14,0.05l-0.96-0.63c-0.08-0.05-1.95-1.25-3.93-1.03c0.28-0.63,2.26-2.56,4.15-4.16l0.31-0.26L18.19,0L18.01,0.14z"/>
47+ <path fill="#BA0000" d="M33.78,24.64c-0.17-0.17-4.21-4.28-5.63-5.01c-0.53-0.27-1.71-0.18-5.75,0.16c0,0-0.17,0.01-0.43,0.04c1.19-2.79,2.72-5.86,3.57-6.25l0.55-0.25l-2.13-2.09l-0.21,0.03c-5.11,0.87-13.08,2.09-14.11,1.9l-0.35-0.07l-0.12,0.33c-0.2,0.56,0.32,4.93,0.86,7.37C5.45,21.2,0,21.66,0,21.66l0.3,0.56l0.68,1.27l0.32-0.09c1.72-0.51,2.52-0.57,4.6-0.54c0.19,0.49,2.6,6.77,2.6,6.77l0.79-0.2c0,0-0.69-5.69-0.8-6.52c0.78,0.02,7.99,0.22,9.67,0.26c-1.09,0.92-3.56,3.02-3.56,3.02l0.4,0.71c0,0,5.15-1.76,5.41-1.85c0.25,0.1,4.3,1.65,4.3,1.65s-2.13-2.57-2.81-3.38c3.78,0.2,4.75,0.42,5.01,0.74c0.33,0.39,0.5,0.96,0.5,1.6c0,1.16-0.58,2.57-1.8,3.73c-1.82-0.46-5.17-2.34-5.21-2.36l-0.52,0.62L24.96,34l0.24-0.05c3.82-0.78,8.44-8.48,8.63-8.81L34,24.87L33.78,24.64z M21.02,16.65c-1.22,0.16-2.77,0.37-3.21,0.43c0-0.33,0-1.17,0-2c1.62-0.13,3.11-0.25,3.49-0.29C21.25,15.06,21.15,15.77,21.02,16.65z M16.09,15.22c0.02,0.84,0.04,1.75,0.05,2.06c-1.41,0.16-2.6,0.26-3.55,0.3c0.1-0.62,0.19-1.31,0.3-2.1C13.13,15.46,14.51,15.35,16.09,15.22z M12.03,20.65c0.08-0.35,0.17-0.76,0.26-1.24c1.3-0.24,2.61-0.46,3.89-0.63c0.01,0.48,0.03,1.1,0.04,1.52C15.97,20.31,14.26,20.46,12.03,20.65zM17.8,20.17c0-0.48,0-1.18,0-1.6c0.98-0.1,1.36-0.08,1.96-0.02l0.96,0.06c-0.07,0.46-0.14,0.92-0.2,1.35C19.43,20.04,18.19,20.14,17.8,20.17z"/>
48+ </g>
49+ <g id="flower">
50+ <rect x="1" y="1" fill="#F8F6D8" stroke="#F8F6D8" width="43" height="67"/>
51+ <path fill="#D3BB54" d="M11.27,58.16l-0.71-8.35c1.76-0.67,2.92-1.76,2.83-2.86c-0.09-1.1-1.42-1.91-3.26-2.16l-0.71-8.36c1.76-0.67,2.92-1.76,2.83-2.86c-0.13-1.56-2.69-2.54-5.71-2.19c-3.02,0.35-5.37,1.89-5.23,3.44c0.09,1.1,1.42,1.91,3.25,2.16l0.71,8.36c-1.76,0.67-2.92,1.76-2.83,2.86c0.09,1.1,1.42,1.91,3.25,2.16l0.71,8.36c-1.76,0.67-2.92,1.76-2.83,2.86c0.13,1.55,2.69,2.53,5.71,2.19c3.02-0.35,5.37-1.89,5.23-3.44C14.43,59.22,13.11,58.41,11.27,58.16z M4.34,34.47c-0.05-0.58,1-1.18,2.34-1.34c1.34-0.15,2.47,0.2,2.52,0.78c0.04,0.49-0.7,0.99-1.74,1.23l0.61,7.13c0.05,0.56-0.19,1.04-0.52,1.08c-0.34,0.04-0.65-0.38-0.7-0.94l-0.61-7.13C5.19,35.28,4.38,34.96,4.34,34.47z M4.57,47.95c-0.05-0.58,1.41-1.23,3.25-1.44c1.85-0.21,3.39,0.09,3.43,0.67c0.05,0.58-1.41,1.23-3.25,1.44C6.15,48.84,4.62,48.54,4.57,47.95z M9.15,62c-1.34,0.15-2.47-0.19-2.52-0.78c-0.04-0.49,0.7-0.99,1.74-1.23l-0.61-7.13c-0.05-0.56,0.19-1.04,0.52-1.08c0.33-0.04,0.65,0.38,0.69,0.94l0.61,7.13c1.06,0,1.87,0.32,1.91,0.81C11.54,61.25,10.49,61.85,9.15,62z"/>
52+ <path fill="none" stroke="#7C5A2D" stroke-width="0.25" d="M11.27,58.16l-0.71-8.35c1.76-0.67,2.92-1.76,2.83-2.86c-0.09-1.1-1.42-1.91-3.26-2.16l-0.71-8.36c1.76-0.67,2.92-1.76,2.83-2.86c-0.13-1.56-2.69-2.54-5.71-2.19c-3.02,0.35-5.37,1.89-5.23,3.44c0.09,1.1,1.42,1.91,3.25,2.16l0.71,8.36c-1.76,0.67-2.92,1.76-2.83,2.86c0.09,1.1,1.42,1.91,3.25,2.16l0.71,8.36c-1.76,0.67-2.92,1.76-2.83,2.86c0.13,1.55,2.69,2.53,5.71,2.19c3.02-0.35,5.37-1.89,5.23-3.44C14.43,59.22,13.11,58.41,11.27,58.16z M4.34,34.47c-0.05-0.58,1-1.18,2.34-1.34c1.34-0.15,2.47,0.2,2.52,0.78c0.04,0.49-0.7,0.99-1.74,1.23l0.61,7.13c0.05,0.56-0.19,1.04-0.52,1.08c-0.34,0.04-0.65-0.38-0.7-0.94l-0.61-7.13C5.19,35.28,4.38,34.96,4.34,34.47z M4.57,47.95c-0.05-0.58,1.41-1.23,3.25-1.44c1.85-0.21,3.39,0.09,3.43,0.67c0.05,0.58-1.41,1.23-3.25,1.44C6.15,48.84,4.62,48.54,4.57,47.95z M9.15,62c-1.34,0.15-2.47-0.19-2.52-0.78c-0.04-0.49,0.7-0.99,1.74-1.23l-0.61-7.13c-0.05-0.56,0.19-1.04,0.52-1.08c0.33-0.04,0.65,0.38,0.69,0.94l0.61,7.13c1.06,0,1.87,0.32,1.91,0.81C11.54,61.25,10.49,61.85,9.15,62z"/>
53+ <path fill="#EADDA7" stroke="#7C5A2D" stroke-width="0.25" d="M5.23,67.99c2.49-8.18-3.33-18.78-3.33-18.78s10.4,10.59,7.48,18.78H5.23z"/>
54+ <path fill="#DBC879" d="M19.02,36.17c-2.92-0.98-5.59-0.56-5.97,0.94c-0.27,1.06,0.7,2.38,2.33,3.41l-2.03,8.06c-1.85-0.14-3.28,0.37-3.54,1.43c-0.27,1.06,0.7,2.38,2.33,3.41l-2.03,8.06c-1.84-0.14-3.27,0.37-3.54,1.43c-0.38,1.5,1.68,3.51,4.6,4.5c2.92,0.98,5.59,0.56,5.96-0.94c0.27-1.06-0.7-2.38-2.32-3.41l2.03-8.06c1.85,0.14,3.28-0.37,3.55-1.43c0.27-1.06-0.71-2.38-2.33-3.41l2.03-8.06c1.84,0.14,3.28-0.37,3.54-1.43C23.99,39.16,21.94,37.15,19.02,36.17z M14.41,57.04l-1.73,6.88c0.98,0.45,1.63,1.1,1.51,1.58c-0.14,0.56-1.31,0.66-2.6,0.23c-1.3-0.44-2.23-1.25-2.09-1.81c0.12-0.47,0.97-0.62,2.01-0.39l1.73-6.88c0.13-0.54,0.51-0.88,0.83-0.77C14.39,55.98,14.54,56.5,14.41,57.04z M18.32,52.88c-0.14,0.56-1.7,0.53-3.48-0.07c-1.78-0.6-3.11-1.54-2.97-2.11c0.14-0.56,1.7-0.53,3.48,0.07C17.13,51.37,18.46,52.32,18.32,52.88z M20.68,39.67c-0.12,0.47-0.97,0.62-2.01,0.39l-1.73,6.88c-0.13,0.54-0.51,0.88-0.83,0.77c-0.32-0.11-0.48-0.63-0.34-1.17l1.73-6.88c-0.98-0.45-1.63-1.1-1.51-1.58c0.14-0.56,1.31-0.67,2.6-0.23C19.89,38.3,20.82,39.11,20.68,39.67z"/>
55+ <path fill="none" stroke="#7C5A2D" stroke-width="0.25" d="M19.02,36.17c-2.92-0.98-5.59-0.56-5.97,0.94c-0.27,1.06,0.7,2.38,2.33,3.41l-2.03,8.06c-1.85-0.14-3.28,0.37-3.54,1.43c-0.27,1.06,0.7,2.38,2.33,3.41l-2.03,8.06c-1.84-0.14-3.27,0.37-3.54,1.43c-0.38,1.5,1.68,3.51,4.6,4.5c2.92,0.98,5.59,0.56,5.96-0.94c0.27-1.06-0.7-2.38-2.32-3.41l2.03-8.06c1.85,0.14,3.28-0.37,3.55-1.43c0.27-1.06-0.71-2.38-2.33-3.41l2.03-8.06c1.84,0.14,3.28-0.37,3.54-1.43C23.99,39.16,21.94,37.15,19.02,36.17z M14.41,57.04l-1.73,6.88c0.98,0.45,1.63,1.1,1.51,1.58c-0.14,0.56-1.31,0.66-2.6,0.23c-1.3-0.44-2.23-1.25-2.09-1.81c0.12-0.47,0.97-0.62,2.01-0.39l1.73-6.88c0.13-0.54,0.51-0.88,0.83-0.77C14.39,55.98,14.54,56.5,14.41,57.04z M18.32,52.88c-0.14,0.56-1.7,0.53-3.48-0.07c-1.78-0.6-3.11-1.54-2.97-2.11c0.14-0.56,1.7-0.53,3.48,0.07C17.13,51.37,18.46,52.32,18.32,52.88z M20.68,39.67c-0.12,0.47-0.97,0.62-2.01,0.39l-1.73,6.88c-0.13,0.54-0.51,0.88-0.83,0.77c-0.32-0.11-0.48-0.63-0.34-1.17l1.73-6.88c-0.98-0.45-1.63-1.1-1.51-1.58c0.14-0.56,1.31-0.67,2.6-0.23C19.89,38.3,20.82,39.11,20.68,39.67z"/>
56+ <path fill="#EADA9B" stroke="#7C5A2D" stroke-width="0.25" d="M19.85,67.7c-1.77-5.79,2.35-13.28,2.35-13.28s-7.35,7.49-5.29,13.28H19.85z"/>
57+ <path fill="#BFAA52" stroke="#7C5A2D" d="M30.33,52.2c-10.89,0-12.13-1.84-15.85-5.97c4.7-3.44,10.9-3.9,16.34,4.82L30.33,52.2z"/>
58+ <path fill="#DBC775" d="M27.52,50.68c-6.89,0-7.68-1.16-10.03-3.78c2.98-2.18,6.9-2.47,10.34,3.05L27.52,50.68z"/>
59+ <path fill="#BFAA52" stroke="#7C5A2D" d="M42.27,47.51c-0.36,2.52-6.85,4.67-9.62,5.14c-2.77,0.47,1.52-5.63,6.18-9.5C41.78,42.64,42.5,45.94,42.27,47.51z"/>
60+ <path fill="#DBC775" d="M37.59,49.31c-0.15,1.08-2.95,2.01-4.14,2.21c-1.19,0.2,0.66-2.42,2.66-4.09C37.38,47.21,37.69,48.63,37.59,49.31z"/>
61+ <path fill="#E5D389" stroke="#7C5A2D" stroke-width="0.75" d="M38.25,67.92c-7.82-3.7-5.44-11.92-5.44-18.72l-2.23-0.29c0,0-2.8,17.48,4.83,18.76L38.25,67.92z"/>
62+ <path fill="#BFAA52" stroke="#7C5A2D" d="M43.33,52.91v7.75c0,0-2.47,2.07-4.21,2.07c-1.73,0-6.93-9.55-6.44-10.59C33.19,51.1,42.84,50.85,43.33,52.91z"/>
63+ <path fill="#DBC775" d="M40.06,53.5c1.02,0.72,0,3.43,0,3.43s-1.33,0.91-2.27,0.91c-0.93,0-3.73-4.22-3.47-4.68S39.04,52.79,40.06,53.5z"/>
64+ <path fill="#BFAA52" stroke="#7C5A2D" d="M31.45,52.39c-12.87-1.29-10.89,4.39-14.11,10.07C27.49,65.3,28.23,57.04,31.45,52.39z"/>
65+ <path fill="#E2CF7F" d="M30.26,53.1c-8.21-0.82-6.95,2.8-9,6.42C27.74,61.34,28.21,56.07,30.26,53.1z"/>
66+ <path fill="#BFAA52" stroke="#7C5A2D" d="M32.93,50.42c0,0,3.69-9.06-0.25-11.11c-4.04-2.11-6.64-1.29-7.13,0c-0.5,1.29,3.91,12.41,5.65,12.66C32.93,52.23,32.93,50.42,32.93,50.42z"/>
67+ <path fill="#DBC775" d="M32.14,50.27c0,0,2.05-5.67-0.14-6.95c-2.24-1.32-3.68-0.81-3.96,0c-0.28,0.81,2.17,7.76,3.13,7.92C32.14,51.4,32.14,50.27,32.14,50.27z"/>
68+ <path fill="#EADDA7" stroke="#7C5A2D" stroke-width="0.25" d="M31.45,51.12c0,0-1.48-2.54-1.23-4.87"/>
69+ <path fill="#BFAA52" stroke="#7C5A2D" d="M30.33,52.6c0,0-4.13,5.57-2.01,5.48c2.41-0.11,2.6,0,5.1,0.46c2.57,0.47,0.2-5.55-1.6-6.32L30.33,52.6z"/>
70+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M27.27,48.13c0,0,2.02,2.93,3.35,2.93c1.33,0,0.61,2.18,0,1.56C30,51.99,28.15,50.19,27.05,50C26.23,50,26.04,47.66,27.27,48.13z"/>
71+ <path fill="#D3BB54" stroke="#AF9A41" d="M44,12.51c-4.46,0-8.54,2.85-8.54,6.37c0,3.52,4.08,6.37,8.54,6.37V12.51z"/>
72+ <path fill="#D3BB54" stroke="#AF9A41" d="M33.33,7.64c0-3.52-3.62-6.23-8.08-6.23c-4.46,0-8.08,2.71-8.08,6.23s3.62,6.37,8.08,6.37C29.71,14.01,33.33,11.16,33.33,7.64z"/>
73+ <ellipse fill="#D3BB54" stroke="#AF9A41" cx="29.56" cy="17.54" rx="8.08" ry="6.37"/>
74+ <path fill="#DBC879" stroke="#AF9A41" d="M44,16.35V1.42H30.9c-2.93,1.56-3.79,4.49-3.79,7.49c0,4.79,4.93,8.68,11.02,8.68C40.21,17.58,42.34,17.13,44,16.35z"/>
75+ <path fill="#D3BB54" stroke="#AF9A41" d="M31.94,15.47c0,0-0.72,1.82-3.03,1.82"/>
76+ <path fill="#D3BB54" stroke="#AF9A41" d="M27.19,6.62c0,0-1.73,0.57-3.17-1.02"/>
77+ <path fill="#663333" 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"/>
78+ <path fill="#E2CF7F" d="M31.13,52.9c0,0-1.71,2.31-0.83,2.27c1-0.04,1.08,0,2.11,0.19c1.06,0.2,0.08-2.3-0.66-2.62L31.13,52.9z"/>
79+ </g>
80+ <g id="season">
81+ <rect x="1" y="1" fill="#E3E4FF" width="43" height="67"/>
82+ <polygon fill="#CAE0FF" stroke="#99B9FF" points="4.69,42.38 9.02,39.76 10.2,44.33 6.26,48.26 8.62,49.89 10.2,48.26 10.98,51.52 14.91,51.19 14.52,47.93 17.27,49.24 18.85,47.28 14.13,44.01 13.34,39.44 19.24,41.4 20.81,45.97 23.56,44.99 22.77,42.7 26.71,44.33 28.28,41.07 23.96,39.44 27.11,38.45 25.92,36.17 20.42,37.8 14.91,35.84 20.03,32.9 25.92,33.88 26.31,31.27 23.17,30.94 26.31,28.98 23.96,26.37 20.81,28 20.81,26.04 18.06,25.71 17.27,30.61 12.55,33.23 11.77,28.66 15.7,24.41 12.95,23.1 11.38,25.06 10.58,21.47 7.05,22.12 7.45,25.39 4.69,24.08 3.12,25.71 7.45,28.66 8.23,33.55 3.12,31.92 1,27.81 1,34.92 1.16,34.86 6.26,36.82 1.55,39.76 1,39.66 1,45.06 1.16,44.99 1,45.91 1,47.31 3.51,47.6"/>
83+ <path fill="#99B9FF" stroke="#678ED3" d="M40.63,26.67c1.1,0,2.17-0.13,3.21-0.35V1.5h-14.3c-2.44,2.67-3.95,6.22-3.95,10.13C25.59,19.94,32.32,26.67,40.63,26.67z"/>
84+ <polygon fill="#C9D3F2" stroke="#9EB3E8" stroke-width="0.5" points="22.61,14.9 22.3,14.59 20.96,16.9 23.27,18.23 23.39,17.81"/>
85+ <polygon fill="#C9D3F2" stroke="#9EB3E8" stroke-width="0.5" points="34.45,28.87 34.03,28.98 35.36,31.29 37.67,29.96 37.35,29.65"/>
86+ <polygon fill="#798CD8" points="43.83,30.1 43.83,29.71 43.59,29.96"/>
87+ <polygon fill="#C9D3F2" stroke="#9EB3E8" stroke-width="0.5" points="23.39,5.45 23.27,5.03 20.96,6.36 22.3,8.67 22.61,8.36"/>
88+ <polygon fill="#99B9FF" stroke="#678ED3" points="23.31,15.6 23.31,7.66 22.61,8.36 22.3,8.67 19.34,11.63 22.3,14.59 22.61,14.9"/>
89+ <polygon fill="#C9D3F2" stroke="#9EB3E8" stroke-width="0.5" points="26.66,23.47 26.23,23.36 26.23,26.02 28.9,26.02 28.78,25.6"/>
90+ <polygon fill="#99B9FF" stroke="#678ED3" points="37.35,29.65 37.67,29.96 40.63,32.92 43.59,29.96 43.83,29.71 43.83,28.95 36.66,28.95"/>
91+ <polygon fill="#99B9FF" stroke="#678ED3" points="35.41,28.61 28.53,24.64 28.78,25.6 28.9,26.02 29.98,30.07 34.03,28.98 34.45,28.87"/>
92+ <polygon fill="#99B9FF" stroke="#678ED3" points="27.61,23.73 23.64,16.85 23.39,17.81 23.27,18.23 22.19,22.27 26.23,23.36 26.66,23.47"/>
93+ <polygon fill="#99B9FF" stroke="#678ED3" points="23.39,5.45 23.64,6.41 26.48,1.5 22.33,1.5 23.27,5.03 "/>
94+ <path fill="#99B9FF" stroke="#678ED3" d="M30.67,46.67c0,0,2.32-3,7-3c5.67,0,5.67,9.33,5.67,9.33H19.67c0,0-0.33-6.67,7.67-6.67c3.67,0,6,4,6,4"/>
95+ <line fill="#99B9FF" stroke="#678ED3" x1="29.5" y1="57.27" x2="24.83" y2="65.36"/>
96+ <line fill="#99B9FF" stroke="#678ED3" x1="34.22" y1="55.68" x2="28.68" y2="65.95"/>
97+ <line fill="#99B9FF" stroke="#678ED3" x1="23.33" y1="61" x2="20.5" y2="65.91"/>
98+ <line fill="#99B9FF" stroke="#678ED3" x1="37.46" y1="54.63" x2="33.25" y2="62.59"/>
99+ <line fill="#99B9FF" stroke="#678ED3" x1="40.46" y1="62.17" x2="38.13" y2="66.21"/>
100+ <line fill="#99B9FF" stroke="#678ED3" x1="27.11" y1="54.52" x2="24.44" y2="59.14"/>
101+ <line fill="#99B9FF" stroke="#678ED3" x1="31.67" y1="54" x2="30.67" y2="55.73"/>
102+ <line fill="#99B9FF" stroke="#678ED3" x1="40.98" y1="55.6" x2="37.81" y2="61.09"/>
103+ <line fill="#99B9FF" stroke="#678ED3" x1="23.84" y1="55.04" x2="22.18" y2="57.92"/>
104+ <line fill="#99B9FF" stroke="#678ED3" x1="21.13" y1="55.63" x2="16.91" y2="63.59"/>
105+ <line fill="#99B9FF" stroke="#678ED3" x1="36" y1="63.6" x2="35" y2="65.33"/>
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+ </g>
108+ </defs>
109+ <use x="0" y="0" xlink:href="#bt1"/>
110+ <use x="64" y="0" xlink:href="#bt1"/>
111+ <use x="128" y="0" xlink:href="#bt1"/>
112+ <use x="192" y="0" xlink:href="#bt1"/>
113+ <use x="256" y="0" xlink:href="#bt1"/>
114+ <use x="320" y="0" xlink:href="#bt1"/>
115+ <use x="384" y="0" xlink:href="#bt1"/>
116+ <use x="448" y="0" xlink:href="#bt1"/>
117+ <use x="512" y="0" xlink:href="#bt1"/>
118+ <use x="576" y="0" xlink:href="#bt1"/>
119+ <use x="640" y="0" xlink:href="#bt1"/>
120+ <use x="704" y="0" xlink:href="#bt1"/>
121+ <use x="768" y="0" xlink:href="#bt1"/>
122+ <use x="832" y="0" xlink:href="#bt1"/>
123+ <use x="896" y="0" xlink:href="#bt1"/>
124+ <use x="960" y="0" xlink:href="#bt1"/>
125+ <use x="1024" y="0" xlink:href="#bt1"/>
126+ <use x="1088" y="0" xlink:href="#bt1"/>
127+ <use x="1152" y="0" xlink:href="#bt1"/>
128+ <use x="1216" y="0" xlink:href="#bt1"/>
129+ <use x="1280" y="0" xlink:href="#bt1"/>
130+ <use x="1344" y="0" xlink:href="#bt1"/>
131+ <use x="1408" y="0" xlink:href="#bt1"/>
132+ <use x="1472" y="0" xlink:href="#bt1"/>
133+ <use x="1536" y="0" xlink:href="#bt1"/>
134+ <use x="1600" y="0" xlink:href="#bt1"/>
135+ <use x="1664" y="0" xlink:href="#bt1"/>
136+ <use x="1728" y="0" xlink:href="#bt1"/>
137+ <use x="1792" y="0" xlink:href="#bt1"/>
138+ <use x="1856" y="0" xlink:href="#bt1"/>
139+ <use x="1920" y="0" xlink:href="#bt1"/>
140+ <use x="1984" y="0" xlink:href="#bt1"/>
141+ <use x="2048" y="0" xlink:href="#bt1"/>
142+ <use x="2112" y="0" xlink:href="#bt1"/>
143+ <use x="2176" y="0" xlink:href="#bt1"/>
144+ <use x="2240" y="0" xlink:href="#bt1"/>
145+ <use x="2304" y="0" xlink:href="#bt1"/>
146+ <use x="2368" y="0" xlink:href="#bt1"/>
147+ <use x="2432" y="0" xlink:href="#bt1"/>
148+ <use x="2496" y="0" xlink:href="#bt1"/>
149+ <use x="2560" y="0" xlink:href="#bt1"/>
150+ <use x="2624" y="0" xlink:href="#bt1"/>
151+ <use x="2688" y="0" xlink:href="#bt1"/>
152+ <use x="0" y="88" xlink:href="#bt2"/>
153+ <use x="64" y="88" xlink:href="#bt2"/>
154+ <use x="128" y="88" xlink:href="#bt2"/>
155+ <use x="192" y="88" xlink:href="#bt2"/>
156+ <use x="256" y="88" xlink:href="#bt2"/>
157+ <use x="320" y="88" xlink:href="#bt2"/>
158+ <use x="384" y="88" xlink:href="#bt2"/>
159+ <use x="448" y="88" xlink:href="#bt2"/>
160+ <use x="512" y="88" xlink:href="#bt2"/>
161+ <use x="576" y="88" xlink:href="#bt2"/>
162+ <use x="640" y="88" xlink:href="#bt2"/>
163+ <use x="704" y="88" xlink:href="#bt2"/>
164+ <use x="768" y="88" xlink:href="#bt2"/>
165+ <use x="832" y="88" xlink:href="#bt2"/>
166+ <use x="896" y="88" xlink:href="#bt2"/>
167+ <use x="960" y="88" xlink:href="#bt2"/>
168+ <use x="1024" y="88" xlink:href="#bt2"/>
169+ <use x="1088" y="88" xlink:href="#bt2"/>
170+ <use x="1152" y="88" xlink:href="#bt2"/>
171+ <use x="1216" y="88" xlink:href="#bt2"/>
172+ <use x="1280" y="88" xlink:href="#bt2"/>
173+ <use x="1344" y="88" xlink:href="#bt2"/>
174+ <use x="1408" y="88" xlink:href="#bt2"/>
175+ <use x="1472" y="88" xlink:href="#bt2"/>
176+ <use x="1536" y="88" xlink:href="#bt2"/>
177+ <use x="1600" y="88" xlink:href="#bt2"/>
178+ <use x="1664" y="88" xlink:href="#bt2"/>
179+ <use x="1728" y="88" xlink:href="#bt2"/>
180+ <use x="1792" y="88" xlink:href="#bt2"/>
181+ <use x="1856" y="88" xlink:href="#bt2"/>
182+ <use x="1920" y="88" xlink:href="#bt2"/>
183+ <use x="1984" y="88" xlink:href="#bt2"/>
184+ <use x="2048" y="88" xlink:href="#bt2"/>
185+ <use x="2112" y="88" xlink:href="#bt2"/>
186+ <use x="2176" y="88" xlink:href="#bt2"/>
187+ <use x="2240" y="88" xlink:href="#bt2"/>
188+ <use x="2304" y="88" xlink:href="#bt2"/>
189+ <use x="2368" y="88" xlink:href="#bt2"/>
190+ <use x="2432" y="88" xlink:href="#bt2"/>
191+ <use x="2496" y="88" xlink:href="#bt2"/>
192+ <use x="2560" y="88" xlink:href="#bt2"/>
193+ <use x="2624" y="88" xlink:href="#bt2"/>
194+ <use x="2688" y="88" xlink:href="#bt2"/>
195+ <use x="979" y="41" xlink:href="#nums"/>
196+ <use x="1043" y="41" xlink:href="#nums"/>
197+ <use x="1107" y="41" xlink:href="#nums"/>
198+ <use x="1171" y="41" xlink:href="#nums"/>
199+ <use x="1235" y="41" xlink:href="#nums"/>
200+ <use x="1299" y="41" xlink:href="#nums"/>
201+ <use x="1363" y="41" xlink:href="#nums"/>
202+ <use x="1427" y="41" xlink:href="#nums"/>
203+ <use x="1491" y="41" xlink:href="#nums"/>
204+ <use x="979" y="129" xlink:href="#nums"/>
205+ <use x="1043" y="129" xlink:href="#nums"/>
206+ <use x="1107" y="129" xlink:href="#nums"/>
207+ <use x="1171" y="129" xlink:href="#nums"/>
208+ <use x="1235" y="129" xlink:href="#nums"/>
209+ <use x="1299" y="129" xlink:href="#nums"/>
210+ <use x="1363" y="129" xlink:href="#nums"/>
211+ <use x="1427" y="129" xlink:href="#nums"/>
212+ <use x="1491" y="129" xlink:href="#nums"/>
213+ <use x="2125" y="6" xlink:href="#season"/>
214+ <use x="2189" y="6" xlink:href="#season"/>
215+ <use x="2253" y="6" xlink:href="#season"/>
216+ <use x="2317" y="6" xlink:href="#season"/>
217+ <use x="2125" y="94" xlink:href="#season"/>
218+ <use x="2189" y="94" xlink:href="#season"/>
219+ <use x="2253" y="94" xlink:href="#season"/>
220+ <use x="2317" y="94" xlink:href="#season"/>
221+ <use x="2445" y="6" xlink:href="#flower"/>
222+ <use x="2509" y="6" xlink:href="#flower"/>
223+ <use x="2573" y="6" xlink:href="#flower"/>
224+ <use x="2637" y="6" xlink:href="#flower"/>
225+ <use x="2445" y="94" xlink:href="#flower"/>
226+ <use x="2509" y="94" xlink:href="#flower"/>
227+ <use x="2573" y="94" xlink:href="#flower"/>
228+ <use x="2637" y="94" xlink:href="#flower"/>
229+ <use transform="translate(88.5,16.5) scale(1.22)" xlink:href="#dots"/>
230+ <use transform="translate(88.5,42.5) scale(1.22)" xlink:href="#dots"/>
231+ <use transform="translate(88.5,104.5) scale(1.22)" xlink:href="#dots"/>
232+ <use transform="translate(88.5,130.5) scale(1.22)" xlink:href="#dots"/>
233+ <use transform="translate(144.5,12.5)" xlink:href="#dots"/>
234+ <use transform="translate(144.5,100.5)" xlink:href="#dots"/>
235+ <use transform="translate(154.5,31.5)" xlink:href="#dots2"/>
236+ <use transform="translate(154.5,119.5)" xlink:href="#dots2"/>
237+ <use transform="translate(164.5,50.5)" xlink:href="#dots"/>
238+ <use transform="translate(164.5,138.5)" xlink:href="#dots"/>
239+ <use transform="translate(209,15) scale(.94)" xlink:href="#dots"/>
240+ <use transform="translate(209,49) scale(.94)" xlink:href="#dots"/>
241+ <use transform="translate(209,103) scale(.94)" xlink:href="#dots"/>
242+ <use transform="translate(209,137) scale(.94)" xlink:href="#dots"/>
243+ <use transform="translate(229,15) scale(.94)" xlink:href="#dots"/>
244+ <use transform="translate(229,49) scale(.94)" xlink:href="#dots"/>
245+ <use transform="translate(229,103) scale(.94)" xlink:href="#dots"/>
246+ <use transform="translate(229,137) scale(.94)" xlink:href="#dots"/>
247+ <use transform="translate(273.5,13.5) scale(.88)" xlink:href="#dots"/>
248+ <use transform="translate(273.5,51.5) scale(.88)" xlink:href="#dots"/>
249+ <use transform="translate(273.5,101.5) scale(.88)" xlink:href="#dots"/>
250+ <use transform="translate(273.5,139.5) scale(.88)" xlink:href="#dots"/>
251+ <use transform="translate(283.5,32.5) scale(.88)" xlink:href="#dots2"/>
252+ <use transform="translate(283.5,120.5) scale(.88)" xlink:href="#dots2"/>
253+ <use transform="translate(293.5,13.5) scale(.88)" xlink:href="#dots"/>
254+ <use transform="translate(293.5,51.5) scale(.88)" xlink:href="#dots"/>
255+ <use transform="translate(293.5,101.5) scale(.88)" xlink:href="#dots"/>
256+ <use transform="translate(293.5,139.5) scale(.88)" xlink:href="#dots"/>
257+ <use transform="translate(340,14) scale(.72)" xlink:href="#dots"/>
258+ <use transform="translate(340,37) scale(.72)" xlink:href="#dots2"/>
259+ <use transform="translate(340,54) scale(.72)" xlink:href="#dots2"/>
260+ <use transform="translate(340,102) scale(.72)" xlink:href="#dots"/>
261+ <use transform="translate(340,125) scale(.72)" xlink:href="#dots2"/>
262+ <use transform="translate(340,142) scale(.72)" xlink:href="#dots2"/>
263+ <use transform="translate(358,14) scale(.72)" xlink:href="#dots"/>
264+ <use transform="translate(358,37) scale(.72)" xlink:href="#dots2"/>
265+ <use transform="translate(358,54) scale(.72)" xlink:href="#dots2"/>
266+ <use transform="translate(358,102) scale(.72)" xlink:href="#dots"/>
267+ <use transform="translate(358,125) scale(.72)" xlink:href="#dots2"/>
268+ <use transform="translate(358,142) scale(.72)" xlink:href="#dots2"/>
269+ <use transform="translate(399,10) scale(.72)" xlink:href="#dots"/>
270+ <use transform="translate(404,41) scale(.72)" xlink:href="#dots2"/>
271+ <use transform="translate(404,58) scale(.72)" xlink:href="#dots2"/>
272+ <use transform="translate(413,17) scale(.72)" xlink:href="#dots"/>
273+ <use transform="translate(427,24) scale(.72)" xlink:href="#dots"/>
274+ <use transform="translate(404,129) scale(.72)" xlink:href="#dots2"/>
275+ <use transform="translate(404,146) scale(.72)" xlink:href="#dots2"/>
276+ <use transform="translate(399,98) scale(.72)" xlink:href="#dots"/>
277+ <use transform="translate(422,41) scale(.72)" xlink:href="#dots2"/>
278+ <use transform="translate(422,58) scale(.72)" xlink:href="#dots2"/>
279+ <use transform="translate(413,105) scale(.72)" xlink:href="#dots"/>
280+ <use transform="translate(427,112) scale(.72)" xlink:href="#dots"/>
281+ <use transform="translate(422,129) scale(.72)" xlink:href="#dots2"/>
282+ <use transform="translate(422,146) scale(.72)" xlink:href="#dots2"/>
283+ <use transform="translate(469,10) scale(.72)" xlink:href="#dots"/>
284+ <use transform="translate(469,26) scale(.72)" xlink:href="#dots"/>
285+ <use transform="translate(469,42) scale(.72)" xlink:href="#dots"/>
286+ <use transform="translate(469,58) scale(.72)" xlink:href="#dots"/>
287+ <use transform="translate(469,98) scale(.72)" xlink:href="#dots"/>
288+ <use transform="translate(469,114) scale(.72)" xlink:href="#dots"/>
289+ <use transform="translate(469,130) scale(.72)" xlink:href="#dots"/>
290+ <use transform="translate(469,146) scale(.72)" xlink:href="#dots"/>
291+ <use transform="translate(485,10) scale(.72)" xlink:href="#dots"/>
292+ <use transform="translate(485,26) scale(.72)" xlink:href="#dots"/>
293+ <use transform="translate(485,42) scale(.72)" xlink:href="#dots"/>
294+ <use transform="translate(485,58) scale(.72)" xlink:href="#dots"/>
295+ <use transform="translate(485,98) scale(.72)" xlink:href="#dots"/>
296+ <use transform="translate(485,114) scale(.72)" xlink:href="#dots"/>
297+ <use transform="translate(485,130) scale(.72)" xlink:href="#dots"/>
298+ <use transform="translate(485,146) scale(.72)" xlink:href="#dots"/>
299+ <use transform="translate(526,14) scale(.72)" xlink:href="#dots"/>
300+ <use transform="translate(526,34) scale(.72)" xlink:href="#dots2"/>
301+ <use transform="translate(526,54) scale(.72)" xlink:href="#dots"/>
302+ <use transform="translate(526,102) scale(.72)" xlink:href="#dots"/>
303+ <use transform="translate(526,122) scale(.72)" xlink:href="#dots2"/>
304+ <use transform="translate(526,142) scale(.72)" xlink:href="#dots"/>
305+ <use transform="translate(541,14) scale(.72)" xlink:href="#dots"/>
306+ <use transform="translate(541,34) scale(.72)" xlink:href="#dots2"/>
307+ <use transform="translate(541,54) scale(.72)" xlink:href="#dots"/>
308+ <use transform="translate(541,102) scale(.72)" xlink:href="#dots"/>
309+ <use transform="translate(541,122) scale(.72)" xlink:href="#dots2"/>
310+ <use transform="translate(541,142) scale(.72)" xlink:href="#dots"/>
311+ <use transform="translate(556,14) scale(.72)" xlink:href="#dots"/>
312+ <use transform="translate(556,34) scale(.72)" xlink:href="#dots2"/>
313+ <use transform="translate(556,54) scale(.72)" xlink:href="#dots"/>
314+ <use transform="translate(556,102) scale(.72)" xlink:href="#dots"/>
315+ <use transform="translate(556,122) scale(.72)" xlink:href="#dots2"/>
316+ <use transform="translate(556,142) scale(.72)" xlink:href="#dots"/>
317+ <use transform="translate(1565,25) scale(1.18,1.15)" xlink:href="#bams"/>
318+ <use transform="translate(1565,113) scale(1.18,1.15)" xlink:href="#bams"/>
319+ <use transform="translate(1630,9.5)" xlink:href="#bams"/>
320+ <use transform="translate(1630,44.5)" xlink:href="#bams"/>
321+ <use transform="translate(1630,97.5)" xlink:href="#bams"/>
322+ <use transform="translate(1630,132.5)" xlink:href="#bams"/>
323+ <use transform="translate(1682,44.5)" xlink:href="#bams"/>
324+ <use transform="translate(1682,132.5)" xlink:href="#bams"/>
325+ <use transform="translate(1694,9.5)" xlink:href="#bams"/>
326+ <use transform="translate(1694,97.5)" xlink:href="#bams"/>
327+ <use transform="translate(1705,44.5)" xlink:href="#bams"/>
328+ <use transform="translate(1705,132.5)" xlink:href="#bams"/>
329+ <use transform="translate(1746,9.5)" xlink:href="#bams"/>
330+ <use transform="translate(1746,44.5)" xlink:href="#bams"/>
331+ <use transform="translate(1746,97.5)" xlink:href="#bams"/>
332+ <use transform="translate(1746,132.5)" xlink:href="#bams"/>
333+ <use transform="translate(1770,9.5)" xlink:href="#bams"/>
334+ <use transform="translate(1770,44.5)" xlink:href="#bams"/>
335+ <use transform="translate(1770,97.5)" xlink:href="#bams"/>
336+ <use transform="translate(1770,132.5)" xlink:href="#bams"/>
337+ <use transform="translate(1810,10.5) scale(.91,.925)" xlink:href="#bams"/>
338+ <use transform="translate(1810,45.5) scale(.91,.925)" xlink:href="#bams"/>
339+ <use transform="translate(1810,98.5) scale(.91,.925)" xlink:href="#bams"/>
340+ <use transform="translate(1810,133.5) scale(.91,.925)" xlink:href="#bams"/>
341+ <use transform="translate(1822.5,116) scale(.91,.925)" xlink:href="#bams2"/>
342+ <use transform="translate(1822.5,28) scale(.91,.925)" xlink:href="#bams2"/>
343+ <use transform="translate(1835,10.5) scale(.91,.925)" xlink:href="#bams"/>
344+ <use transform="translate(1835,45.5) scale(.91,.925)" xlink:href="#bams"/>
345+ <use transform="translate(1835,98.5) scale(.91,.925)" xlink:href="#bams"/>
346+ <use transform="translate(1835,133.5) scale(.91,.925)" xlink:href="#bams"/>
347+ <use transform="translate(1875,12.5) scale(.82,.85)" xlink:href="#bams"/>
348+ <use transform="translate(1875,45.5) scale(.82,.85)" xlink:href="#bams"/>
349+ <use transform="translate(1875,100.5) scale(.82,.85)" xlink:href="#bams"/>
350+ <use transform="translate(1875,133.5) scale(.82,.85)" xlink:href="#bams"/>
351+ <use transform="translate(1887,12.5) scale(.82,.85)" xlink:href="#bams"/>
352+ <use transform="translate(1887,45.5) scale(.82,.85)" xlink:href="#bams"/>
353+ <use transform="translate(1887,100.5) scale(.82,.85)" xlink:href="#bams"/>
354+ <use transform="translate(1887,133.5) scale(.82,.85)" xlink:href="#bams"/>
355+ <use transform="translate(1899,12.5) scale(.82,.85)" xlink:href="#bams"/>
356+ <use transform="translate(1899,45.5) scale(.82,.85)" xlink:href="#bams"/>
357+ <use transform="translate(1899,100.5) scale(.82,.85)" xlink:href="#bams"/>
358+ <use transform="translate(1899,133.5) scale(.82,.85)" xlink:href="#bams"/>
359+ <use transform="translate(1939.5,31.5) scale(.73,.66)" xlink:href="#bams"/>
360+ <use transform="translate(1951.5,31.5) scale(.73,.66)" xlink:href="#bams"/>
361+ <use transform="translate(1963.5,31.5) scale(.73,.66)" xlink:href="#bams"/>
362+ <use transform="translate(1939.5,50.5) scale(.73,.66)" xlink:href="#bams"/>
363+ <use transform="translate(1951.5,50.5) scale(.73,.66)" xlink:href="#bams"/>
364+ <use transform="translate(1963.5,50.5) scale(.73,.66)" xlink:href="#bams"/>
365+ <use transform="translate(1939.5,119.5) scale(.73,.66)" xlink:href="#bams"/>
366+ <use transform="translate(1951.5,119.5) scale(.73,.66)" xlink:href="#bams"/>
367+ <use transform="translate(1963.5,119.5) scale(.73,.66)" xlink:href="#bams"/>
368+ <use transform="translate(1939.5,138.5) scale(.73,.66)" xlink:href="#bams"/>
369+ <use transform="translate(1951.5,138.5) scale(.73,.66)" xlink:href="#bams"/>
370+ <use transform="translate(1963.5,138.5) scale(.73,.66)" xlink:href="#bams"/>
371+ <use transform="translate(1951.5,12.5) scale(.73,.66)" xlink:href="#bams2"/>
372+ <use transform="translate(1951.5,100.5) scale(.73,.66)" xlink:href="#bams2"/>
373+ <use transform="translate(2003.5,13.5) scale(.73,.77)" xlink:href="#bams"/>
374+ <use transform="translate(2003.5,46.5) scale(.73,.77)" xlink:href="#bams"/>
375+ <use transform="translate(2003.5,101.5) scale(.73,.77)" xlink:href="#bams"/>
376+ <use transform="translate(2003.5,134.5) scale(.73,.77)" xlink:href="#bams"/>
377+ <use transform="translate(2027.5,13.5) scale(.73,.77)" xlink:href="#bams"/>
378+ <use transform="translate(2027.5,46.5) scale(.73,.77)" xlink:href="#bams"/>
379+ <use transform="translate(2027.5,101.5) scale(.73,.77)" xlink:href="#bams"/>
380+ <use transform="translate(2027.5,134.5) scale(.73,.77)" xlink:href="#bams"/>
381+ <use transform="translate(2067.5,12.5) scale(.73,.66)" xlink:href="#bams"/>
382+ <use transform="translate(2067.5,31.5) scale(.73,.66)" xlink:href="#bams"/>
383+ <use transform="translate(2067.5,50.5) scale(.73,.66)" xlink:href="#bams"/>
384+ <use transform="translate(2067.5,100.5) scale(.73,.66)" xlink:href="#bams"/>
385+ <use transform="translate(2067.5,119.5) scale(.73,.66)" xlink:href="#bams"/>
386+ <use transform="translate(2067.5,138.5) scale(.73,.66)" xlink:href="#bams"/>
387+ <use transform="translate(2079.5,12.5) scale(.73,.66)" xlink:href="#bams2"/>
388+ <use transform="translate(2079.5,31.5) scale(.73,.66)" xlink:href="#bams2"/>
389+ <use transform="translate(2079.5,50.5) scale(.73,.66)" xlink:href="#bams2"/>
390+ <use transform="translate(2079.5,100.5) scale(.73,.66)" xlink:href="#bams2"/>
391+ <use transform="translate(2079.5,119.5) scale(.73,.66)" xlink:href="#bams2"/>
392+ <use transform="translate(2079.5,138.5) scale(.73,.66)" xlink:href="#bams2"/>
393+ <use transform="translate(2091.5,12.5) scale(.73,.66)" xlink:href="#bams"/>
394+ <use transform="translate(2091.5,31.5) scale(.73,.66)" xlink:href="#bams"/>
395+ <use transform="translate(2091.5,50.5) scale(.73,.66)" xlink:href="#bams"/>
396+ <use transform="translate(2091.5,100.5) scale(.73,.66)" xlink:href="#bams"/>
397+ <use transform="translate(2091.5,119.5) scale(.73,.66)" xlink:href="#bams"/>
398+ <use transform="translate(2091.5,138.5) scale(.73,.66)" xlink:href="#bams"/>
399+ <path fill="#006C00" d="M2031.75,29.81c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.41-0.58-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.58-0.83-2.51-0.47-4.32,0.8s-2.81,2.96-2.23,3.79c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.58,0.83,2.51,0.47,4.32-0.8S2032.33,30.64,2031.75,29.81z M2021.19,22.48l-2.65-3.79c-0.67,0.38-1.29,0.49-1.47,0.22c-0.22-0.31,0.26-1.02,1.06-1.58c0.8-0.56,1.63-0.77,1.85-0.46c0.18,0.26-0.13,0.8-0.71,1.3l2.65,3.79c0.21,0.3,0.21,0.65,0.01,0.79C2021.73,22.9,2021.4,22.77,2021.19,22.48z M2021.5,26.4c-0.22-0.31,0.5-1.19,1.61-1.96c1.11-0.78,2.18-1.15,2.4-0.84c0.22,0.31-0.5,1.19-1.61,1.96C2022.79,26.33,2021.71,26.71,2021.5,26.4z M2028.87,32.67c-0.8,0.56-1.63,0.77-1.85,0.46c-0.18-0.26,0.13-0.8,0.71-1.3l-2.65-3.79c-0.21-0.29-0.21-0.65-0.01-0.79c0.2-0.14,0.53-0.02,0.74,0.28l2.65,3.79c0.67-0.38,1.29-0.49,1.47-0.22C2030.15,31.39,2029.67,32.1,2028.87,32.67z"/>
400+ <path fill="#006C00" d="M2021.52,16.4c-1.81-1.27-3.75-1.62-4.32-0.8c-0.41,0.58-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.35-0.53-2.75,0.06c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.58,0.83,0.42,2.52,2.23,3.79c1.81,1.27,3.75,1.63,4.32,0.8c0.41-0.59,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06C2024.33,19.36,2023.33,17.67,2021.52,16.4z M2013.92,28.03l-2.65,3.79c0.58,0.5,0.9,1.04,0.71,1.31c-0.22,0.31-1.04,0.11-1.85-0.46s-1.28-1.27-1.06-1.58c0.18-0.26,0.8-0.15,1.47,0.22l2.65-3.79c0.21-0.3,0.54-0.42,0.74-0.28C2014.13,27.38,2014.13,27.74,2013.92,28.03z M2017.5,26.4c-0.22,0.31-1.29-0.07-2.4-0.84c-1.11-0.77-1.83-1.65-1.61-1.96c0.22-0.31,1.29,0.07,2.4,0.84C2017,25.21,2017.72,26.09,2017.5,26.4z M2021.93,18.91c-0.18,0.26-0.8,0.15-1.47-0.22l-2.65,3.79c-0.21,0.3-0.54,0.42-0.74,0.28c-0.2-0.14-0.2-0.49,0.01-0.79l2.65-3.79c-0.58-0.5-0.9-1.04-0.71-1.3c0.22-0.31,1.04-0.11,1.85,0.46S2022.15,18.6,2021.93,18.91z"/>
401+ <path fill="#006C00" d="M2031.75,117.81c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.41-0.58-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.58-0.83-2.51-0.47-4.32,0.8c-1.81,1.27-2.81,2.96-2.23,3.79c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.41,0.58,1.51,0.56,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.58,0.83,2.51,0.47,4.32-0.8C2031.33,120.33,2032.33,118.64,2031.75,117.81z M2021.19,110.48l-2.65-3.79c-0.67,0.38-1.29,0.49-1.47,0.22c-0.22-0.31,0.26-1.02,1.06-1.58s1.63-0.77,1.85-0.46c0.18,0.26-0.13,0.8-0.71,1.3l2.65,3.79c0.21,0.3,0.21,0.65,0.01,0.79C2021.73,110.9,2021.4,110.77,2021.19,110.48z M2021.5,114.4c-0.22-0.31,0.5-1.19,1.61-1.96c1.11-0.78,2.18-1.15,2.4-0.84c0.22,0.31-0.5,1.19-1.61,1.96C2022.79,114.34,2021.71,114.71,2021.5,114.4z M2028.87,120.67c-0.8,0.56-1.63,0.77-1.85,0.46c-0.18-0.26,0.13-0.8,0.71-1.31l-2.65-3.79c-0.21-0.3-0.21-0.65-0.01-0.79c0.2-0.14,0.53-0.02,0.74,0.28l2.65,3.79c0.67-0.38,1.29-0.49,1.47-0.22C2030.15,119.39,2029.67,120.1,2028.87,120.67z"/>
402+ <path fill="#006C00" d="M2021.52,104.4c-1.81-1.27-3.75-1.63-4.32-0.8c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.35-0.53-2.75,0.06c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.58,0.83,0.42,2.52,2.23,3.79c1.81,1.27,3.75,1.62,4.32,0.8c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06c0.41-0.59,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06C2024.33,107.36,2023.33,105.67,2021.52,104.4z M2013.92,116.03l-2.65,3.79c0.58,0.5,0.9,1.04,0.71,1.31c-0.22,0.31-1.04,0.11-1.85-0.46c-0.8-0.56-1.28-1.27-1.06-1.58c0.18-0.26,0.8-0.15,1.47,0.22l2.65-3.79c0.21-0.29,0.54-0.42,0.74-0.28C2014.13,115.38,2014.13,115.74,2013.92,116.03z M2017.5,114.4c-0.22,0.31-1.29-0.07-2.4-0.84c-1.11-0.77-1.83-1.65-1.61-1.96c0.22-0.31,1.29,0.07,2.4,0.84C2017,113.21,2017.72,114.09,2017.5,114.4z M2021.93,106.91c-0.18,0.26-0.8,0.15-1.47-0.22l-2.65,3.79c-0.21,0.3-0.54,0.42-0.74,0.28c-0.2-0.14-0.2-0.49,0.01-0.79l2.65-3.79c-0.58-0.5-0.9-1.04-0.71-1.3c0.22-0.31,1.04-0.11,1.85,0.46C2021.67,105.9,2022.15,106.61,2021.93,106.91z"/>
403+ <path fill="#006C00" d="M2023.75,148.81c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.58-0.83-2.51-0.47-4.32,0.8s-2.81,2.96-2.23,3.79c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.41,0.58,1.51,0.56,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.58,0.83,2.51,0.47,4.32-0.8C2023.33,151.33,2024.33,149.64,2023.75,148.81z M2013.19,141.48l-2.65-3.79c-0.67,0.38-1.29,0.49-1.47,0.22c-0.22-0.31,0.26-1.02,1.06-1.58s1.63-0.77,1.85-0.46c0.18,0.26-0.13,0.8-0.71,1.3l2.65,3.79c0.21,0.29,0.21,0.65,0.01,0.79C2013.73,141.9,2013.4,141.77,2013.19,141.48z M2013.5,145.4c-0.22-0.31,0.5-1.19,1.61-1.96c1.11-0.78,2.18-1.15,2.4-0.84c0.22,0.31-0.5,1.19-1.61,1.96S2013.71,145.71,2013.5,145.4zM2020.87,151.67c-0.8,0.56-1.63,0.77-1.85,0.46c-0.18-0.26,0.13-0.8,0.71-1.3l-2.65-3.79c-0.21-0.3-0.21-0.65-0.01-0.79c0.2-0.14,0.53-0.02,0.74,0.28l2.65,3.79c0.67-0.38,1.29-0.49,1.47-0.22C2022.15,150.4,2021.67,151.1,2020.87,151.67z"/>
404+ <path fill="#006C00" d="M2029.52,135.4c-1.81-1.27-3.75-1.63-4.32-0.8c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.58,0.83,0.42,2.52,2.23,3.79c1.81,1.27,3.75,1.62,4.32,0.8c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.35,0.53,2.75-0.06c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06C2032.33,138.36,2031.33,136.67,2029.52,135.4z M2021.92,147.03l-2.65,3.79c0.58,0.5,0.9,1.04,0.71,1.3c-0.22,0.31-1.04,0.11-1.85-0.46c-0.8-0.56-1.28-1.27-1.06-1.58c0.18-0.26,0.8-0.15,1.47,0.22l2.65-3.79c0.21-0.3,0.54-0.42,0.74-0.28C2022.13,146.39,2022.13,146.74,2021.92,147.03z M2025.5,145.4c-0.22,0.31-1.29-0.07-2.4-0.84c-1.11-0.77-1.83-1.65-1.61-1.96c0.22-0.31,1.29,0.07,2.4,0.84C2025,144.21,2025.72,145.09,2025.5,145.4z M2029.93,137.92c-0.18,0.26-0.8,0.15-1.47-0.22l-2.65,3.79c-0.21,0.29-0.54,0.42-0.74,0.28c-0.2-0.14-0.2-0.49,0.01-0.79l2.65-3.79c-0.58-0.5-0.9-1.04-0.71-1.3c0.22-0.31,1.04-0.11,1.85,0.46C2029.67,136.9,2030.15,137.61,2029.93,137.92z"/>
405+ <path fill="#006C00" d="M2023.75,60.81c-0.41-0.59-1.51-0.57-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.41-0.59-1.51-0.56-2.75-0.06l-3.11-4.44c0.9-1,1.3-2.02,0.89-2.61c-0.58-0.83-2.51-0.47-4.32,0.8c-1.81,1.27-2.81,2.96-2.23,3.79c0.41,0.59,1.51,0.57,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.41,0.58,1.51,0.56,2.75,0.06l3.11,4.44c-0.9,1-1.3,2.02-0.89,2.61c0.58,0.83,2.51,0.47,4.32-0.8C2023.33,63.33,2024.33,61.64,2023.75,60.81z M2013.19,53.48l-2.65-3.79c-0.67,0.38-1.29,0.49-1.47,0.22 c-0.22-0.31,0.26-1.02,1.06-1.58s1.63-0.77,1.85-0.46c0.18,0.26-0.13,0.8-0.71,1.3l2.65,3.79c0.21,0.29,0.21,0.65,0.01,0.79C2013.73,53.9,2013.4,53.77,2013.19,53.48z M2013.5,57.4c-0.22-0.31,0.5-1.19,1.61-1.96c1.11-0.78,2.18-1.15,2.4-0.84c0.22,0.31-0.5,1.19-1.61,1.96C2014.79,57.34,2013.71,57.71,2013.5,57.4z M2020.87,63.67c-0.8,0.56-1.63,0.77-1.85,0.46c-0.18-0.26,0.13-0.8,0.71-1.3l-2.65-3.79c-0.21-0.3-0.21-0.65-0.01-0.79c0.2-0.14,0.53-0.02,0.74,0.28l2.65,3.79c0.67-0.38,1.29-0.49,1.47-0.22C2022.15,62.4,2021.67,63.1,2020.87,63.67z"/>
406+ <path fill="#006C00" d="M2029.52,47.4c-1.81-1.27-3.75-1.63-4.32-0.8c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.41,0.59-0.02,1.61,0.89,2.61l-3.11,4.44c-1.25-0.51-2.34-0.53-2.75,0.06c-0.58,0.83,0.42,2.52,2.23,3.79c1.81,1.27,3.75,1.62,4.32,0.8c0.41-0.58,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.35,0.53,2.75-0.06c0.41-0.59,0.02-1.61-0.89-2.61l3.11-4.44c1.25,0.51,2.34,0.53,2.75-0.06C2032.33,50.36,2031.33,48.67,2029.52,47.4z M2021.92,59.03l-2.65,3.79c0.58,0.5,0.9,1.04,0.71,1.3c-0.22,0.31-1.04,0.11-1.85-0.46c-0.8-0.56-1.28-1.27-1.06-1.58c0.18-0.26,0.8-0.15,1.47,0.22l2.65-3.79c0.21-0.3,0.54-0.42,0.74-0.28C2022.13,58.39,2022.13,58.74,2021.92,59.03z M2025.5,57.4c-0.22,0.31-1.29-0.07-2.4-0.84c-1.11-0.77-1.83-1.65-1.61-1.96c0.22-0.31,1.29,0.07,2.4,0.84C2025,56.21,2025.72,57.09,2025.5,57.4z M2029.93,49.92c-0.18,0.26-0.8,0.15-1.47-0.22l-2.65,3.79c-0.21,0.29-0.54,0.42-0.74,0.28c-0.2-0.14-0.2-0.49,0.01-0.79l2.65-3.79c-0.58-0.5-0.9-1.04-0.71-1.3c0.22-0.31,1.04-0.11,1.85,0.46C2029.67,48.9,2030.15,49.61,2029.93,49.92z"/>
407+ <path fill="#20208C" d="M1171.2,12.1l6.8,19.4l0.3-0.1c3.6-1.2,13.3-4.1,15.3-2.6c0.2,0.2,0.3,0.4,0.3,0.7l-0.1,0.8l0.6-0.5c6.8-5.3,9.4-14,9.4-14.1l0-0.2l-0.1-0.1c-4.9-7.3-24.9-1.3-25.7-1l-0.2,0.1l0,0.2c0,0-0.2,1.7-0.2,2.4c-0.4-1.5-1.1-4.1-1.1-4.1l-5.6-1.3L1171.2,12.1z M1178.1,17.8c1.2-0.3,2.6-0.7,4-1c0,0,0,0,0,0s1.9,9.3,2,9.9c-0.5,0.1-3,0.8-3.6,1c-0.1-0.4-1.4-5.6-2.5-9.9C1178,17.9,1178.1,17.8,1178.1,17.8z M1189.8,15.5c0,0.1-0.4,9.6-0.4,10.2c-0.4,0.1-2.1,0.4-2.6,0.5c-0.1-0.6-1.8-9.6-1.8-9.9C1186.7,15.9,1188.3,15.7,1189.8,15.5z M1197.7,16.4c-0.2,2.3-1.1,8.1-2.6,9.9c-0.1-0.1,0-0.2-0.1-0.3c-0.5-0.5-1.6-0.7-3.3-0.6c0.3-4.5,1-7.5,1.9-7.9l0.7-0.3c0,0-3.5-1.7-3.8-1.8C1194.3,15,1197.3,15.1,1197.7,16.4z"/>
408+ <path fill="#20208C" d="M1003.8,18.1c-0.3,0.2-0.4,0.5-0.4,0.8c0,0.3,0.1,0.5,0.1,0.7c-2.6,0.2-19.7,1.8-23.9,1.4l-0.4,0l1.7,6l0.3-0.1c3.3-1.1,24.1-2.4,29-1.4c0.8,0.1,1.2,0,1.4-0.4c0.5-1-1.9-3.5-3.4-4.8C1007.3,19.4,1004.8,17.5,1003.8,18.1z"/>
409+ <path fill="#20208C" d="M1067.9,23.9c-0.3,0.2-0.4,0.5-0.4,0.9c0,0.2,0,0.5,0.1,0.7c-2.8,0.3-19.9,1.9-24.1,1.4l-0.5-0.1l1.7,6.2l0.4-0.1c3.3-1.2,24.3-2.4,29.3-1.4c0.8,0.2,1.3,0,1.5-0.4c0.7-1.3-3-4.6-3.4-4.9C1071.5,25.2,1069,23.2,1067.9,23.9z"/>
410+ <path fill="#20208C" d="M1060.1,12.9c-2.6,0.9-6.2,2.2-12.3,2.2l-0.7,0l2.9,4.2l16.7-2.2l0.5-3.1l-0.1-0.1C1064.7,11.2,1063,11.8,1060.1,12.9z"/>
411+ <path fill="#20208C" d="M1131.7,26.3c-0.3,0.2-0.4,0.5-0.4,0.9c0,0.3,0,0.5,0.1,0.7c-2.7,0.3-19.3,1.9-23.4,1.4l-0.5-0.1l1.7,6.2l0.3-0.1c3.2-1.2,23.6-2.4,28.4-1.4c0.8,0.2,1.3,0,1.5-0.4c0.6-1.3-2.9-4.6-3.3-5C1135.1,27.7,1132.7,25.6,1131.7,26.3z"/>
412+ <polygon fill="#20208C" points="1125.3,19 1114,20.4 1115.6,24.7 1127.6,23.3 1125.6,19 "/>
413+ <path fill="#20208C" d="M1122.9,8.6c-3,1.1-7,2.6-14,2.6l-0.6,0l3.2,4.9l18.9-2.6l0.6-3.6l-0.1-0.1C1128.2,6.6,1126.3,7.3,1122.9,8.6z"/>
414+ <path fill="#20208C" d="M1245.8,10.3l-10.3,10.5l0.4,0.5c0,0,5.6-3.2,7-4c-0.6,1.5-4.3,11.6-4.3,11.6l3.3,3.5c0,0,2.4-15.6,2.4-15.9c0.3-0.2,5.1-2.9,5.1-2.9l-3.4-3.7L1245.8,10.3z"/>
415+ <path fill="#20208C" d="M1258.6,10.2h-1.9c0,0-0.6,1.3-0.9,1.9c0,0-0.1-0.1-0.1-0.1l-5.9,3.5l0.2,0.7c0,0,3-0.2,3.9-0.2c-0.4,0.8-2.2,4.5-2.3,4.8c-0.4,0-6.3,0.5-6.3,0.5l-0.1,0.7l4,1.6c0,0,0.7-0.3,1.3-0.5c-0.6,1.2-2.5,5.2-2.7,5.5c-1.3,0.1-2.3,0.1-2.9,0l-0.4,0l0.5,3.7l0.3-0.1c1.4-0.2,12.2-1.6,14.1-1.7c1.8-0.1,7.3,2.1,7.4,2.1l0.7,0.3l-0.2-0.7c-0.1-0.4-1.3-3.9-2.2-4.8l-0.1-0.1l-0.1,0c-0.8-0.2-3.6,0-7.5,0.4c0.9-1,4.3-4.6,4.3-4.6l-3.2-3.4c0,0-2.9,7.8-3,8.2c-0.3,0-2.7,0.3-3.5,0.3c0.4-0.9,2.5-6.5,2.6-6.7c0.3-0.1,3.2-1.2,3.2-1.2l-0.2-0.7c0,0-1.7,0.3-2.5,0.4c0.3-0.9,1.6-4.1,1.7-4.4c0.2,0,1.2-0.1,1.2-0.1s-0.6-0.9-0.7-1.1c0.1-0.3,1.7-4.4,1.7-4.4H1258.6z"/>
416+ <path fill="#20208C" d="M1313.2,9.8c0,0.3,0,0.6,0,1c0,1.6-0.2,3.7-0.7,6.2c-5.9,0.7-11.1,1.5-11.2,1.5l-0.3,0V21c0,0,9.9-0.4,10.7-0.5c-0.2,0.7-1,4-1,4l0.6,0.3c0,0,2.9-4.1,3.1-4.4c11.7-0.3,14.4,0.2,15,0.5l0.6,0.4l-0.6-3.5c-0.3-1-2-2.1-12.2-1.3c0.6-0.9,3.2-4.6,3.2-4.6l-7.2-2.7L1313.2,9.8z"/>
417+ <path fill="#20208C" d="M1307.6,24.8c0,0,0.8,4.7-1.6,8.5l0.5,0.5c4.4-2.9,5.9-6.6,5.9-6.7l0.1-0.3l-5-2.6L1307.6,24.8z"/>
418+ <path fill="#20208C" d="M1317.1,23.7c0.2,0.3,5.9,7.7,7.9,9.6l0.1,0.1l0.2,0c0.4,0,0.8-0.3,1.1-0.7c0.8-1.1,1.3-3.2,1-4.3c-0.4-1.3-7.6-4.3-9.8-5.2l-1.2-0.5L1317.1,23.7z"/>
419+ <path fill="#20208C" d="M1387.2,11.7L1387.2,11.7c-0.2,0.4-1.5,1.9-8.3,7.6c-0.1-0.7-0.2-1.6-0.2-1.6c-0.2-1.9-0.4-4.1-0.8-6.5l-0.1-0.3l-3.2,0.3l0,0.3c0,0.6,0,1.1,0,1.7c0,3.3,0.3,6.2,0.9,8.9c-0.3,0.2-10,8.2-10,8.2l0.3,0.3l1.9,1.8c0,0,7.9-6.9,8.6-7.6c0.7,2.2,1.6,4,2.7,5c2,2,4.2,1.7,7,1.3c2-0.3,4.3-0.6,7-0.2l0.5,0.1l-0.6-2.8c0-0.6-0.3-1.1-0.7-1.5c-1.3-0.9-3.8-0.4-6.5,0.1c-1.3,0.3-2.7,0.5-3.9,0.6c-1.3-1.2-2.1-2.8-2.6-5.2c7.5-6.2,10.3-7.8,11.3-8.1l0.2-0.1l0-0.3c0-0.2,0-0.3,0-0.4c0-1.8-0.9-2.8-1.7-3C1388.5,10.4,1387.7,10.6,1387.2,11.7z"/>
420+ <path fill="#20208C" d="M1432.4,19c0,3.7-4.5,9.2-4.7,9.4l-0.7,0.9l1.1-0.3c5.2-1.4,8.2-5,8.3-5.1l0.2-0.2l-4.2-5.7V19z"/>
421+ <path fill="#20208C" d="M1437.9,11.3l-2,0.3l0.3,0.5c0.1,0.2,9.3,15.7,10.6,19.3l0.1,0.3l0.3-0.1c2.2-0.4,4-0.2,5.8,0c2.2,0.2,4.3,0.4,6.7-0.4l0.4-0.1l-0.2-0.4c-1.2-2-2.5-2.4-4.2-2.9c-3.2-0.9-8-2.3-17.3-16.3l-0.1-0.2L1437.9,11.3z"/>
422+ <path fill="#20208C" d="M1520.6,23.6c0,0-2.9,3.2-3.1,3.4c-3.2,0.7-8.4,1.2-8.8-0.4c-0.5-1.9,3.4-10.4,4.9-13.6l0.2-0.3l-5.6-2.8l-0.1,0.4c-0.1,0.2-0.4,1.3-0.9,3c-2,0-4.2,0-4.5,0c0-0.4-0.1-1.3-0.1-1.3l0,0c-1.4-2.3-7.4-0.7-8.1-0.6l-0.4,0.1l0.2,0.4c0.2,0.4,0.4,0.9,0.5,1.4c-0.8,0-3.8,0-3.8,0l1.9,3.6c0,0,1.8-0.3,2.4-0.4c0,0.2,0,0.5,0,0.7c0,6.2-2.7,14.1-2.7,14.2l0.6,0.3c0.3-0.4,7.9-9.5,9.3-16.4c0.2,0,2.4-0.4,4.4-0.7c-1.2,4.7-2.6,12.1-0.3,14.8c3.4,4,16.6,3.7,17.1,3.5l0.3-0.1L1520.6,23.6z"/>
423+ <path fill="#20208C" d="M1171.2,100.1l6.8,19.4l0.3-0.1c3.6-1.2,13.3-4.1,15.3-2.6c0.2,0.2,0.3,0.4,0.3,0.7l-0.1,0.8l0.6-0.5c6.8-5.3,9.4-14,9.4-14.1l0-0.2l-0.1-0.1c-4.9-7.3-24.9-1.3-25.7-1l-0.2,0.1l0,0.2c0,0-0.2,1.7-0.2,2.4c-0.4-1.5-1.1-4.1-1.1-4.1l-5.6-1.3L1171.2,100.1z M1178.1,105.8c1.2-0.3,2.6-0.7,4-1c0,0,0,0,0,0s1.9,9.3,2,9.9c-0.5,0.1-3,0.8-3.6,1c-0.1-0.4-1.4-5.6-2.5-9.9C1178,105.9,1178.1,105.8,1178.1,105.8z M1189.8,103.5c0,0.1-0.4,9.6-0.4,10.2c-0.4,0.1-2.1,0.4-2.6,0.5c-0.1-0.6-1.8-9.6-1.8-9.9C1186.7,103.9,1188.3,103.7,1189.8,103.5z M1197.7,104.4c-0.2,2.3-1.1,8.1-2.6,9.9c-0.1-0.1,0-0.2-0.1-0.3c-0.5-0.5-1.6-0.7-3.3-0.6c0.3-4.5,1-7.5,1.9-7.9l0.7-0.3c0,0-3.5-1.7-3.8-1.8C1194.3,103,1197.3,103.1,1197.7,104.4z"/>
424+ <path fill="#20208C" d="M1003.8,106.1c-0.3,0.2-0.4,0.5-0.4,0.8c0,0.3,0.1,0.5,0.1,0.7c-2.6,0.2-19.7,1.8-23.9,1.4l-0.4,0l1.7,6l0.3-0.1c3.3-1.1,24.1-2.4,29-1.4c0.8,0.1,1.2,0,1.4-0.4c0.5-1-1.9-3.5-3.4-4.8C1007.3,107.4,1004.8,105.5,1003.8,106.1z"/>
425+ <path fill="#20208C" d="M1067.9,111.9c-0.3,0.2-0.4,0.5-0.4,0.9c0,0.2,0,0.5,0.1,0.7c-2.8,0.3-19.9,1.9-24.1,1.4l-0.5-0.1l1.7,6.2l0.4-0.1c3.3-1.2,24.3-2.4,29.3-1.4c0.8,0.2,1.3,0,1.5-0.4c0.7-1.3-3-4.6-3.4-4.9C1071.5,113.2,1069,111.2,1067.9,111.9z"/>
426+ <path fill="#20208C" d="M1060.1,100.9c-2.6,0.9-6.2,2.2-12.3,2.2l-0.7,0l2.9,4.2l16.7-2.2l0.5-3.1l-0.1-0.1C1064.7,99.2,1063,99.8,1060.1,100.9z"/>
427+ <path fill="#20208C" d="M1131.7,114.3c-0.3,0.2-0.4,0.5-0.4,0.9c0,0.3,0,0.5,0.1,0.7c-2.7,0.3-19.3,1.9-23.4,1.4l-0.5-0.1l1.7,6.2l0.3-0.1c3.2-1.2,23.6-2.4,28.4-1.4c0.8,0.2,1.3,0,1.5-0.4c0.6-1.3-2.9-4.6-3.3-5C1135.1,115.7,1132.7,113.6,1131.7,114.3z"/>
428+ <polygon fill="#20208C" points="1125.3,107 1114,108.4 1115.6,112.7 1127.6,111.3 1125.6,107 "/>
429+ <path fill="#20208C" d="M1122.9,96.6c-3,1.1-7,2.6-14,2.6l-0.6,0l3.2,4.9l18.9-2.6l0.6-3.6l-0.1-0.1C1128.2,94.6,1126.3,95.3,1122.9,96.6z"/>
430+ <path fill="#20208C" d="M1245.8,98.3l-10.3,10.5l0.4,0.5c0,0,5.6-3.2,7-4c-0.6,1.5-4.3,11.6-4.3,11.6l3.3,3.5c0,0,2.4-15.6,2.4-15.9c0.3-0.2,5.1-2.9,5.1-2.9l-3.4-3.7L1245.8,98.3z"/>
431+ <path fill="#20208C" d="M1258.6,98.2h-1.9c0,0-0.6,1.3-0.9,1.9c0,0-0.1-0.1-0.1-0.1l-5.9,3.5l0.2,0.7c0,0,3-0.2,3.9-0.2c-0.4,0.8-2.2,4.5-2.3,4.8c-0.4,0-6.3,0.5-6.3,0.5l-0.1,0.7l4,1.6c0,0,0.7-0.3,1.3-0.5c-0.6,1.2-2.5,5.2-2.7,5.5c-1.3,0.1-2.3,0.1-2.9,0l-0.4,0l0.5,3.7l0.3-0.1c1.4-0.2,12.2-1.6,14.1-1.7c1.8-0.1,7.3,2.1,7.4,2.1l0.7,0.3l-0.2-0.7c-0.1-0.4-1.3-3.9-2.2-4.8l-0.1-0.1l-0.1,0c-0.8-0.2-3.6,0-7.5,0.4c0.9-1,4.3-4.6,4.3-4.6l-3.2-3.4c0,0-2.9,7.8-3,8.2c-0.3,0-2.7,0.3-3.5,0.3c0.4-0.9,2.5-6.5,2.6-6.7c0.3-0.1,3.2-1.2,3.2-1.2l-0.2-0.7c0,0-1.7,0.3-2.5,0.4c0.3-0.9,1.6-4.1,1.7-4.4c0.2,0,1.2-0.1,1.2-0.1s-0.6-0.9-0.7-1.1c0.1-0.3,1.7-4.4,1.7-4.4H1258.6z"/>
432+ <path fill="#20208C" d="M1313.2,97.8c0,0.3,0,0.6,0,1c0,1.6-0.2,3.7-0.7,6.2c-5.9,0.7-11.1,1.5-11.2,1.5l-0.3,0v2.5c0,0,9.9-0.4,10.7-0.5c-0.2,0.7-1,4-1,4l0.6,0.3c0,0,2.9-4.1,3.1-4.4c11.7-0.3,14.4,0.2,15,0.5l0.6,0.4l-0.6-3.5c-0.3-1-2-2.1-12.2-1.3c0.6-0.9,3.2-4.6,3.2-4.6l-7.2-2.7L1313.2,97.8z"/>
433+ <path fill="#20208C" d="M1307.6,112.8c0,0,0.8,4.7-1.6,8.5l0.5,0.5c4.4-2.9,5.9-6.6,5.9-6.7l0.1-0.3l-5-2.6L1307.6,112.8z"/>
434+ <path fill="#20208C" d="M1317.1,111.7c0.2,0.3,5.9,7.7,7.9,9.6l0.1,0.1l0.2,0c0.4,0,0.8-0.3,1.1-0.7c0.8-1.1,1.3-3.2,1-4.3c-0.4-1.3-7.6-4.3-9.8-5.2l-1.2-0.5L1317.1,111.7z"/>
435+ <path fill="#20208C" d="M1387.2,99.7L1387.2,99.7c-0.2,0.4-1.5,1.9-8.3,7.6c-0.1-0.7-0.2-1.6-0.2-1.6c-0.2-1.9-0.4-4.1-0.8-6.5l-0.1-0.3l-3.2,0.3l0,0.3c0,0.6,0,1.1,0,1.7c0,3.3,0.3,6.2,0.9,8.9c-0.3,0.2-10,8.2-10,8.2l0.3,0.3l1.9,1.8c0,0,7.9-6.9,8.6-7.6c0.7,2.2,1.6,4,2.7,5c2,2,4.2,1.7,7,1.3c2-0.3,4.3-0.6,7-0.2l0.5,0.1l-0.6-2.8c0-0.6-0.3-1.1-0.7-1.5c-1.3-0.9-3.8-0.4-6.5,0.1c-1.3,0.3-2.7,0.5-3.9,0.6c-1.3-1.2-2.1-2.8-2.6-5.2c7.5-6.2,10.3-7.8,11.3-8.1l0.2-0.1l0-0.3c0-0.2,0-0.3,0-0.4c0-1.8-0.9-2.8-1.7-3C1388.5,98.4,1387.7,98.6,1387.2,99.7z"/>
436+ <path fill="#20208C" d="M1432.4,107c0,3.7-4.5,9.2-4.7,9.4l-0.7,0.9l1.1-0.3c5.2-1.4,8.2-5,8.3-5.1l0.2-0.2l-4.2-5.7V107z"/>
437+ <path fill="#20208C" d="M1437.9,99.3l-2,0.3l0.3,0.5c0.1,0.2,9.3,15.7,10.6,19.3l0.1,0.3l0.3-0.1c2.2-0.4,4-0.2,5.8,0c2.2,0.2,4.3,0.4,6.7-0.4l0.4-0.1l-0.2-0.4c-1.2-2-2.5-2.4-4.2-2.9c-3.2-0.9-8-2.3-17.3-16.3l-0.1-0.2L1437.9,99.3z"/>
438+ <path fill="#20208C" d="M1520.6,111.6c0,0-2.9,3.2-3.1,3.4c-3.2,0.7-8.4,1.2-8.8-0.4c-0.5-1.9,3.4-10.4,4.9-13.6l0.2-0.3l-5.6-2.8l-0.1,0.4c-0.1,0.2-0.4,1.3-0.9,3c-2,0-4.2,0-4.5,0c0-0.4-0.1-1.3-0.1-1.3l0,0c-1.4-2.3-7.4-0.7-8.1-0.6l-0.4,0.1l0.2,0.4c0.2,0.4,0.4,0.9,0.5,1.4c-0.8,0-3.8,0-3.8,0l1.9,3.6c0,0,1.8-0.3,2.4-0.4c0,0.2,0,0.5,0,0.7c0,6.2-2.7,14.1-2.7,14.2l0.6,0.3c0.3-0.4,7.9-9.5,9.3-16.4c0.2,0,2.4-0.4,4.4-0.7c-1.2,4.7-2.6,12.1-0.3,14.8c3.4,4,16.6,3.7,17.1,3.5l0.3-0.1L1520.6,111.6z"/>
439+ <path fill="#101040" d="M632.3,142.7c-2-0.9-11.8,3.3-11.8,3.3l0.5-20.7c0,0,5.3-4.3,9.6-1.7l-1.2-5c-1.4-0.2-8.4,5.5-8.4,5.5s-0.6-18.8,4.8-19.9c0,0-8.9-2.6-9.7-2.2c-0.4,0.2-0.4,13.8-0.2,27.1c-3.2,3.1-5.9,6.2-7.1,7.3l0.4-28.5c0,0-4.7-2.6-5.8-1.4l0.2,14.8c-0.3-0.5-0.7-0.9-1-1.3c-1-1.3-6.1-4.2-6.1-4.2s1.7,5.6,2.5,6.6c0.7,0.9,3,2.9,4.6,2.2l0.2,16.5c-3.4,3-7.9,6.8-8.5,5.4c-1.5-3.2-4.7-3-5.2-1.5c-0.5,1.6,4.2,10.2,5.8,10.1c0.8-0.1,4.4-4.9,8-10l0.1,5.3c1.6,2.1,3.8,0.5,4.6-0.7l0.3-11c1.1-1.6,4-4.5,7.1-7.9c0.2,12.1,0.3,22.8,0.3,22.8c4.9-5.2,16.7-4,16.7-4L632.3,142.7z"/>
440+ <path fill="#101040" d="M688.4,14.3c-8.5,5.7-21.5,6.9-28.8,5.9l-0.9,2c11.7,3.8,26.4,1,31.2-3.2L688.4,14.3z"/>
441+ <path fill="#101040" d="M692.5,39.2c-3.6-0.9-7.3-1.4-10.8-1.7c0.8-2.9,1.2-6,1-9.2l-7.8,2c0.6,1.7,0.6,4.3,0.2,7c-1.6,0-3.1,0.1-4.5,0.3c0.2-2.7,0.4-3.3-0.5-6l-4.7,1.3c1,1.8,1.5,2.3,1.8,5.1c-3.4,1-5.7,1.8-6.9,3.2c-5.1,6.1,0,23.2,0,23.2s-0.2-15.4,2-17c2.2-1.6,4.7-1.8,4.7-1.8c0.1,1.5-1.6,6.7-1.6,6.7c2.2-2.7,3.2-3.6,4.3-7.2c1.6-0.5,2.5-0.8,4.1-1c-1.6,6.1-4.5,10.9-4.5,10.9l7.4,2c-4.1-0.1-13.3,5.1-13.3,5.1s16.2-4,20.8-1.1l1.2-2.9c-3.4-2.7-10.5-4.9-10.5-4.9s2.2-4.1,4.6-9.4c2.3,0.1,5.6,0.3,7.7,0.9c9.2,4.1-1.7,22.4-1.7,22.4S705.7,45.9,692.5,39.2z"/>
442+ <path fill="#101040" d="M735.3,14.2c0.4,0.5,0.9,2.3,0.9,8.6c0,0.9,0,1.8,0,2.8c-3.4,0.5-6.3,1-6.8,1.2l-0.6,0.2l2.8,2.7l0.2-0.1c1.1-0.3,2.6-0.5,4.2-0.7c0,0.7-0.4,7.3-0.4,7.9c-4.9,0.4-9.2,1.1-12.7,1.9c-3,0.8-4.8,2.6-5.2,5.4c-0.1,0.4-0.1,0.9-0.1,1.4c0,7.1,6.9,16.9,7,16.9l0.8,0.5l-0.3-0.9c0-0.1-2.8-9.4-2.8-15.6c0-1.8,0.2-3.3,0.8-4.3c0.4-0.6,0.8-0.9,1.4-1c1.3-0.2,2.7-0.4,4-0.6c0,0,3.1,4,3.7,4.7c-0.5,0.1-1.4,0.3-1.4,0.3l0,0.3l0.2,2.2l0.4-0.1c0.6-0.2,1.8-0.5,3.2-0.7c0,0.6,0.1,2.4,0.2,2.9c-1.7,0.5-3.2,1.1-3.8,1.4l-0.4,0.2l1.5,1.6l0.2-0.1c0.7-0.3,1.7-0.6,2.7-0.9c0,0.7,0.3,5.6,0.3,5.6l0.7,0c0,0,1.1-5.5,1.2-6c1.8-0.2,3.5-0.1,4.8,0.2l0.4,0.1v-3l-0.2-0.1c-0.8-0.4-2.3-0.4-4.5,0.1c0.1-0.7,0.4-2.1,0.5-2.5c1.6-0.1,2.9-0.2,3.8-0.1l0.5,0.1l-0.7-2.6c0,0-0.9,0-1.4,0.1c0.8-0.9,3.5-4.4,3.7-4.6c0.3,0,3.9,0.1,3.9,0.1c1.6,0.1,3,0.7,4.1,2c1.3,1.5,2,3.9,2,6.3c0,0.5,0,1.1-0.1,1.6l-0.1,0.7c-0.7,5.1-1.3,8.8-3.1,9.6c-1.2,0.5-3.1-0.3-5.9-2.4l-0.5,0.5l5.5,9.6l0.3-0.1c6.7-2.4,7.7-3.8,10.4-15.4c1.2-5.1,0.8-9.1-1.3-11.8c-2.5-3.3-6.3-3.7-6.5-3.7c-0.1,0-6.7-0.3-14.6,0.2c0.2-0.8,2-7.5,2.1-7.9c2.5,0,4.7,0.1,5.7,0.5l0.4,0.1l0.4-4.7l-0.4,0c-1.1,0-2.8,0.1-5.1,0.3c1.3-4.4,1.9-5.8,2.1-6.2l0.2-0.3l-8.1-4.7L735.3,14.2z M739.7,39.5c-0.1,0.7-0.7,4.3-0.7,4.7c0,0-0.1,0-0.1,0c0-0.1,0.1-0.4,0.1-0.4l-4.4,0.4c0,0,0,0.5,0,0.6c-0.4,0.1-1,0.2-1.4,0.2c0-0.7,0-5,0-5.1C735.4,39.8,737.5,39.6,739.7,39.5z"/>
443+ <path fill="#101040" d="M804.5,41.3l0.1-6.4l4.4-0.3l-0.3-2.4l-4.1,1.2l0-5.7c2.4,0.3,5,0.8,7.1,0.9c4.3,0.3-1.2,14.1-1.2,14.1s16.3-14.4,2.6-18.1c-1.4,0-4.9,0.2-8.4,0.4l0-3.9l7.5-1.9l-7.5-0.1l0-4l-4.6-3.4l0.5,8.7l-4,2.7l4.1-1l0.2,3.2c-1.8,0.2-3.4,0.3-4.3,0.5c-11.5,2.2,1,17.2,1,17.2s-4-14,0.5-15.3c0.9-0.3,1.8-0.3,2.9-0.3l0.4,7l-3,0.9l3-0.2l0.2,4.1c-9.2,12.1-19.7,16.6-19.7,16.6s10.2-1.1,20-11.9l1,18.7c-3.7-1.5-7-3.6-7-3.6s2.4,7.1,7.5,10.1l4.3-5.1c-1-0.2-2.1-0.5-3.2-0.9l0.2-18.9c2.7,3.2,9,10.5,11.7,11l9-3C821.9,51.6,807.7,43.3,804.5,41.3z"/>
444+ <path fill="#101040" d="M688.4,102.3c-8.5,5.7-21.5,6.9-28.8,5.9l-0.9,2c11.7,3.8,26.4,1,31.2-3.2L688.4,102.3z"/>
445+ <path fill="#101040" d="M692.5,127.2c-3.6-0.9-7.3-1.4-10.8-1.7c0.8-2.9,1.2-6,1-9.2l-7.8,2c0.6,1.7,0.6,4.3,0.2,7c-1.6,0-3.1,0.1-4.5,0.3c0.2-2.7,0.4-3.3-0.5-6l-4.7,1.3c1,1.8,1.5,2.3,1.8,5.1c-3.4,1-5.7,1.8-6.9,3.2c-5.1,6.1,0,23.2,0,23.2s-0.2-15.4,2-17c2.2-1.6,4.7-1.8,4.7-1.8c0.1,1.5-1.6,6.7-1.6,6.7c2.2-2.7,3.2-3.6,4.3-7.2c1.6-0.5,2.5-0.8,4.1-1c-1.6,6.1-4.5,10.9-4.5,10.9l7.4,2c-4.1-0.1-13.3,5.1-13.3,5.1s16.2-4,20.8-1.1l1.2-2.9c-3.4-2.7-10.5-4.9-10.5-4.9s2.2-4.1,4.6-9.4c2.3,0.1,5.6,0.3,7.7,0.9c9.2,4.1-1.7,22.4-1.7,22.4S705.7,133.9,692.5,127.2z"/>
446+ <path fill="#101040" d="M735.3,102.2c0.4,0.5,0.9,2.3,0.9,8.6c0,0.8,0,1.8,0,2.8c-3.4,0.5-6.3,1-6.8,1.2l-0.6,0.2l2.8,2.7l0.2-0.1c1.1-0.3,2.6-0.5,4.2-0.7c0,0.7-0.4,7.3-0.4,7.9c-4.9,0.4-9.2,1.1-12.7,1.9c-3,0.8-4.8,2.6-5.2,5.4c-0.1,0.4-0.1,0.9-0.1,1.4c0,7.1,6.9,16.9,7,16.9l0.8,0.5l-0.3-0.9c0-0.1-2.8-9.4-2.8-15.6c0-1.8,0.2-3.3,0.8-4.3c0.4-0.6,0.8-0.9,1.4-1c1.3-0.2,2.7-0.4,4-0.6c0,0,3.1,4,3.7,4.7c-0.5,0.1-1.4,0.3-1.4,0.3l0,0.3l0.2,2.2l0.4-0.1c0.6-0.2,1.8-0.5,3.2-0.7c0,0.6,0.1,2.4,0.2,2.9c-1.7,0.5-3.2,1.1-3.8,1.4l-0.4,0.2l1.5,1.6l0.2-0.1c0.7-0.3,1.7-0.6,2.7-0.9c0,0.7,0.3,5.6,0.3,5.6l0.7,0c0,0,1.1-5.5,1.2-6c1.8-0.2,3.5-0.1,4.8,0.2l0.4,0.1v-3l-0.2-0.1c-0.8-0.4-2.3-0.4-4.5,0.1c0.1-0.7,0.4-2.1,0.5-2.5c1.6-0.1,2.9-0.2,3.8-0.1l0.5,0.1l-0.7-2.6c0,0-0.9,0-1.4,0.1c0.8-0.9,3.5-4.4,3.7-4.6c0.3,0,3.9,0.1,3.9,0.1c1.6,0.1,3,0.7,4.1,2c1.3,1.5,2,3.9,2,6.3c0,0.5,0,1.1-0.1,1.6l-0.1,0.7c-0.7,5.1-1.3,8.8-3.1,9.6c-1.2,0.5-3.1-0.3-5.9-2.4l-0.5,0.5l5.5,9.6l0.3-0.1c6.7-2.4,7.7-3.8,10.4-15.4c1.2-5.1,0.8-9.1-1.3-11.8c-2.5-3.3-6.3-3.7-6.5-3.7c-0.1,0-6.7-0.3-14.6,0.2c0.2-0.8,2-7.5,2.1-7.9c2.5,0,4.7,0.1,5.7,0.5l0.4,0.1l0.4-4.7l-0.4,0c-1.1,0-2.8,0.1-5.1,0.3c1.3-4.4,1.9-5.8,2.1-6.2l0.2-0.3l-8.1-4.7L735.3,102.2z M739.7,127.5c-0.1,0.7-0.7,4.3-0.7,4.7c0,0-0.1,0-0.1,0c0-0.1,0.1-0.4,0.1-0.4l-4.4,0.4c0,0,0,0.5,0,0.6c-0.4,0.1-1,0.2-1.4,0.2c0-0.7,0-5,0-5.1C735.4,127.8,737.5,127.6,739.7,127.5z"/>
447+ <path fill="#101040" d="M804.5,129.3l0.1-6.4l4.4-0.3l-0.3-2.4l-4.1,1.2l0-5.7c2.4,0.3,5,0.8,7.1,0.9c4.3,0.3-1.2,14.1-1.2,14.1s16.3-14.4,2.6-18.1c-1.4,0-4.9,0.2-8.4,0.4l0-3.9l7.5-1.9l-7.5-0.1l0-4l-4.6-3.4l0.5,8.7l-4,2.7l4.1-1l0.2,3.2c-1.8,0.2-3.4,0.3-4.3,0.5c-11.5,2.2,1,17.2,1,17.2s-4-14,0.5-15.3c0.9-0.3,1.8-0.3,2.9-0.3l0.4,7l-3,0.9l3-0.2l0.2,4.1c-9.2,12.1-19.7,16.6-19.7,16.6s10.2-1.1,20-11.9l1,18.7c-3.7-1.5-7-3.6-7-3.6s2.4,7.1,7.5,10.1l4.3-5.1c-1-0.2-2.1-0.5-3.2-0.9l0.2-18.9c2.7,3.2,9,10.5,11.7,11l9-3C821.9,139.6,807.7,131.3,804.5,129.3z"/>
448+ <path fill="none" stroke="#10106C" stroke-width="0.75" d="M49.4,40.5c0,7.7-6.2,13.9-13.9,13.9c-7.7,0-13.9-6.2-13.9-13.9c0-7.7,6.2-13.9,13.9-13.9C43.2,26.6,49.4,32.8,49.4,40.5z"/>
449+ <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="42" cy="40.5" r="6.5"/>
450+ <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="29" cy="40.5" r="6.5"/>
451+ <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="35.5" cy="34" r="6.5"/>
452+ <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="35.5" cy="47" r="6.5"/>
453+ <path fill="none" stroke="#10106C" stroke-width="0.75" d="M44.8,40.5c0,5.1-4.2,9.3-9.3,9.3s-9.3-4.2-9.3-9.3c0-5.1,4.2-9.3,9.3-9.3S44.8,35.4,44.8,40.5z"/>
454+ <path fill="none" stroke="#BA0000" stroke-width="2" d="M52.2,40.5c0,9.2-7.5,16.7-16.7,16.7c-9.2,0-16.7-7.5-16.7-16.7c0-9.2,7.5-16.7,16.7-16.7C44.7,23.8,52.2,31.3,52.2,40.5z"/>
455+ <path fill="none" stroke="#10106C" stroke-width="0.75" d="M55,40.5C55,51.3,46.3,60,35.5,60C24.7,60,16,51.3,16,40.5C16,29.7,24.7,21,35.5,21C46.3,21,55,29.7,55,40.5z"/>
456+ <path fill="none" stroke="#10106C" stroke-width="0.75" d="M49.4,128.5c0,7.7-6.2,13.9-13.9,13.9c-7.7,0-13.9-6.2-13.9-13.9s6.2-13.9,13.9-13.9C43.2,114.6,49.4,120.8,49.4,128.5z"/>
457+ <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="42" cy="128.5" r="6.5"/>
458+ <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="29" cy="128.5" r="6.5"/>
459+ <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="35.5" cy="122" r="6.5"/>
460+ <circle fill="none" stroke="#10106C" stroke-width="1.6" cx="35.5" cy="135" r="6.5"/>
461+ <path fill="none" stroke="#10106C" stroke-width="0.75" d="M44.8,128.5c0,5.1-4.2,9.3-9.3,9.3s-9.3-4.2-9.3-9.3c0-5.1,4.2-9.3,9.3-9.3S44.8,123.4,44.8,128.5z"/>
462+ <path fill="none" stroke="#BA0000" stroke-width="2" d="M52.2,128.5c0,9.2-7.5,16.7-16.7,16.7c-9.2,0-16.7-7.5-16.7-16.7c0-9.2,7.5-16.7,16.7-16.7C44.7,111.8,52.2,119.3,52.2,128.5z"/>
463+ <path fill="none" stroke="#10106C" stroke-width="0.75" d="M55,128.5c0,10.8-8.7,19.5-19.5,19.5c-10.8,0-19.5-8.7-19.5-19.5c0-10.8,8.7-19.5,19.5-19.5C46.3,109,55,117.7,55,128.5z"/>
464+ <path fill="#BA0000" d="M861.8,10.2c-0.4,0.4-0.5,1-0.5,1.7c0,1.3,0.4,2.9,0.6,3.5c0,0,0.3,7,0.4,7.8c-0.9,0.1-2.5,0.2-3.1,0.4l-0.8,0c-1.7,0.1-3.3,0.2-4,0.9l-0.3,0.3l0.3,0.4c0.5,0.6,0.7,2.3,0.9,4.1c0.5,3.7,1.1,8.3,4.1,10.1l0.6,0.3L860,39c0,0,0.1-0.3,0.1-0.7c0-0.3-0.1-0.8-0.2-1.5c0.8,0.1,2.5,0.3,3.1,0.3c0.1,0.8,2.7,33.4,2.7,33.4l1,0c0,0,1.1-32.2,1.1-33.2c0.7,0,3.8,0.1,4.7,0.2c-0.2,0.6-1,3.1-1,3.1l1.4-1.3c0.4-0.3,9.1-8.2,7.9-13.4c-0.4-1.7-1.8-2.9-4.1-3.5l-0.1,0h-0.1c-1.3,0-5.3,0.1-8.3,0.3c0.1-2.6,0.1-4,0.1-4.7c0-0.6,0-0.7,0-0.8c0,0,0-0.1,0-0.1c0-0.5,0.5-1.2,0.9-1.7c0.4-0.6,0.9-1.1,0.9-1.7c0-0.2-0.1-0.4-0.2-0.6c-0.6-0.9-5-3.2-6.9-3.4C862.4,9.8,862,9.9,861.8,10.2z M861.9,26.6c0,0,0.2,0,0.5,0c0,0.9,0.3,7.2,0.4,8.1c-0.8-0.1-2.7-0.3-3.3-0.3c-0.3-1.3-0.4-2.7-0.5-4.1c-0.1-1.2-0.2-2.4-0.4-3.5C859.5,26.8,861.9,26.6,861.9,26.6z M868,29.7c0,0,0.1-2.4,0.1-3.2c1.3,0.2,3,0.3,4.6,0.3c0,0,1.2,0,1.8,0c0,0.4-0.1,0.9-0.1,0.9c-0.2,2.2-0.5,4.8-1.4,7.4c-0.6,0-4.3-0.2-5.1-0.3C867.9,34.1,868,29.7,868,29.7z"/>
465+ <path fill="#BA0000" d="M861.8,98.2c-0.4,0.4-0.5,1-0.5,1.7c0,1.3,0.4,2.9,0.6,3.5c0,0,0.3,7,0.4,7.8c-0.9,0.1-2.5,0.2-3.1,0.4l-0.8,0c-1.7,0.1-3.3,0.2-4,0.9l-0.3,0.3l0.3,0.4c0.5,0.6,0.7,2.3,0.9,4.1c0.5,3.7,1.1,8.3,4.1,10.1l0.6,0.3l0.2-0.6c0,0,0.1-0.3,0.1-0.7c0-0.3-0.1-0.8-0.2-1.5c0.8,0.1,2.5,0.3,3.1,0.3c0.1,0.8,2.7,33.4,2.7,33.4l1,0c0,0,1.1-32.2,1.1-33.2c0.7,0,3.8,0.1,4.7,0.2c-0.2,0.6-1,3.1-1,3.1l1.4-1.3c0.4-0.3,9.1-8.2,7.9-13.4c-0.4-1.7-1.8-2.9-4.1-3.5l-0.1,0h-0.1c-1.3,0-5.3,0.1-8.3,0.3c0.1-2.6,0.1-4,0.1-4.7c0-0.6,0-0.7,0-0.8c0,0,0-0.1,0-0.1c0-0.5,0.5-1.2,0.9-1.7c0.4-0.6,0.9-1.1,0.9-1.7c0-0.2-0.1-0.4-0.2-0.6c-0.6-0.9-5-3.2-6.9-3.4C862.4,97.8,862,97.9,861.8,98.2z M861.9,114.6c0,0,0.2,0,0.5,0c0,0.9,0.3,7.2,0.4,8.1c-0.8-0.1-2.7-0.3-3.3-0.3c-0.3-1.3-0.4-2.7-0.5-4.1c-0.1-1.2-0.2-2.4-0.4-3.5C859.5,114.8,861.9,114.6,861.9,114.6z M868,117.7c0,0,0.1-2.4,0.1-3.2c1.3,0.2,3,0.3,4.6,0.3c0,0,1.2,0,1.8,0c0,0.4-0.1,0.9-0.1,0.9c-0.2,2.2-0.5,4.8-1.4,7.4c-0.6,0-4.3-0.2-5.1-0.3C867.9,122.1,868,117.7,868,117.7z"/>
466+ <path fill="#004C00" d="M925,47.8c0,0.1-1.4,5.1-5.1,7.1c-0.5-0.6-3.1-4.2-3.1-4.2l-0.9,0.5l4.4,11.4l1-0.2c0-2.2,1.1-3.5,2.4-4.9c1.8-2.1,3.8-4.5,2.3-9.7l-0.5-1.7L925,47.8z"/>
467+ <path fill="#004C00" d="M929.5,37c0,0-0.5,4.9-1.9,7.8l-0.3,0.7l0.7,0.1c1.8,0.1,3.5-3.7,4.1-5.4c0.4,0,1.4,0.1,2,0.1c-0.3,1.2-0.7,2.8-0.7,3.8c0,0.4,0,0.7,0.2,0.9l0.5,0.3c0.8,0.1,5.2-0.2,6.1-0.3l0-1c-1.5-0.2-3.3-0.7-3.5-1.2c-0.2-0.6,0.6-3,1.5-5l0.3-0.7l-9-0.6L929.5,37z"/>
468+ <path fill="#004C00" d="M942,46.6L927.3,47c0,0,4.9,6.3,5.4,6.9c-2.6,2.4-4.9,4.2-7,5.4l-0.6,0.4l0.6,0.5c1.4,1.1,4.2-0.2,8.6-4.2c2.3,3.2,3.8,5.5,4.3,6.9l0.2,0.7l2.7-2.2l0-0.2l0-0.3c0-0.5,0-2.1-4.7-7.3c3-3.1,5.6-6,5.6-6.1l0.7-0.9L942,46.6z M937.3,49.4c-0.8,0.9-1.7,1.8-2.1,2.3c-0.4-0.4-1.2-1.2-1.9-1.9C934.4,49.6,936,49.5,937.3,49.4z"/>
469+ <path fill="#004C00" d="M931.1,25.2l-1.7,1.7l-4.5-5.2l-0.2,0c-0.7,0-4.3,0.1-5.2,1.4c-0.1,0.1-0.1,0.3-0.1,0.4c0,0.1,0,0.1,0,0.2c0.1,0.5,0.7,0.7,1.8,1.1c0.5,0.2,1.2,0.5,1.5,0.7c-1.1,2.3-2.3,4.4-3.7,6.4c-0.7-0.4-4.1-2.5-4.1-2.5l-0.6,0.8c0,0,2.8,3.1,3.3,3.6c-2.8,3.5-6.2,6.6-10.4,9.5l0.4,0.9c12.8-3.3,21.9-16.2,22.1-16.5l2.3,2.2c-3.2,2-6.2,3.8-6.2,3.8l0.5,0.9c2-0.8,4.8-1.9,7.6-3L945,42.4l10.8-3.8l-0.1-1c-5.1-0.2-12-4.4-17.1-7.9c2.9-1,5.3-1.7,6.4-1.6c0.7,0,0.9-0.3,1-0.5c0.4-1.1-3.5-4.4-11.4-9.7l-0.5-0.3l-0.2,0.6 M934.9,27c0.7-1.2,1.6-2.9,1.9-3.3c0.5,0.3,1.8,1.1,2.5,1.6c-0.4,0.2-1.9,1.1-3.7,2.3C935.4,27.4,935.2,27.2,934.9,27z"/>
470+ <path fill="#004C00" d="M926.9,35.4l-6.2,3.5l0.3,0.9c0,0,1.1-0.1,1.8-0.1c-0.2,0.7-0.6,1.8-1.1,3.2c-0.6,0-5-0.2-5-0.2l0.8,0.9c0.3,0.3,1.6,1.7,2.7,1.7c0,0,0.3-0.1,0.6-0.1c-0.8,2-1.7,3.7-2.6,5l0.7,0.7c1.5-1.2,3.2-3.4,4.8-6.4c0.3-0.1,1.2-0.4,1.2-0.4l-0.1-1c0,0-0.2,0-0.4,0c1.8-3.6,3.1-7.1,3.1-7.1l0.5-1.3L926.9,35.4z"/>
471+ <path fill="#004C00" d="M925,135.8c0,0.1-1.4,5.1-5.1,7.1c-0.5-0.6-3.1-4.2-3.1-4.2l-0.9,0.5l4.4,11.4l1-0.2c0-2.2,1.1-3.5,2.4-4.9c1.8-2.1,3.8-4.5,2.3-9.7l-0.5-1.7L925,135.8z"/>
472+ <path fill="#004C00" d="M929.5,125c0,0-0.5,4.9-1.9,7.8l-0.3,0.7l0.7,0.1c1.8,0.1,3.5-3.7,4.1-5.4c0.4,0,1.4,0.1,2,0.1c-0.3,1.2-0.7,2.8-0.7,3.8c0,0.4,0,0.7,0.2,0.9l0.5,0.3c0.8,0.1,5.2-0.2,6.1-0.3l0-1c-1.5-0.2-3.3-0.7-3.5-1.2c-0.2-0.6,0.6-3,1.5-5l0.3-0.7l-9-0.6L929.5,125z"/>
473+ <path fill="#004C00" d="M942,134.6l-14.7,0.4c0,0,4.9,6.3,5.4,6.9c-2.6,2.4-4.9,4.2-7,5.4l-0.6,0.4l0.6,0.5c1.4,1.1,4.2-0.2,8.6-4.2c2.3,3.2,3.8,5.5,4.3,6.9l0.2,0.7l2.7-2.3l0-0.2l0-0.3c0-0.5,0-2.1-4.7-7.3c3-3.1,5.6-6,5.6-6.1l0.7-0.9L942,134.6z M937.3,137.4c-0.8,0.9-1.7,1.8-2.1,2.3c-0.4-0.4-1.2-1.2-1.9-1.9C934.4,137.6,936,137.5,937.3,137.4z"/>
474+ <path fill="#004C00" d="M931.1,113.2l-1.7,1.7l-4.5-5.2l-0.2,0c-0.7,0-4.3,0.1-5.2,1.4c-0.1,0.1-0.1,0.3-0.1,0.4c0,0.1,0,0.1,0,0.2c0.1,0.5,0.7,0.7,1.8,1.1c0.5,0.2,1.2,0.5,1.5,0.7c-1.1,2.3-2.3,4.4-3.7,6.4c-0.7-0.4-4.1-2.5-4.1-2.5l-0.6,0.8c0,0,2.8,3.1,3.3,3.6c-2.8,3.5-6.2,6.6-10.4,9.5l0.4,0.9c12.8-3.3,21.9-16.2,22.1-16.5l2.3,2.2c-3.2,2-6.2,3.8-6.2,3.8l0.5,0.9c2-0.8,4.8-1.9,7.6-3l11.4,10.9l10.8-3.8l-0.1-1c-5.1-0.2-12-4.4-17.1-7.9c2.9-1,5.3-1.7,6.4-1.6c0.7,0,0.9-0.3,1-0.5c0.4-1.1-3.5-4.4-11.4-9.7l-0.5-0.3l-0.2,0.6 M934.9,115c0.7-1.2,1.6-2.9,1.9-3.3c0.5,0.3,1.8,1.1,2.5,1.6c-0.4,0.2-1.9,1.1-3.7,2.3C935.4,115.4,935.2,115.2,934.9,115z"/>
475+ <path fill="#004C00" d="M926.9,123.4l-6.2,3.5l0.3,0.9c0,0,1.1-0.1,1.8-0.1c-0.2,0.7-0.6,1.8-1.1,3.2c-0.6,0-5-0.2-5-0.2l0.8,0.9c0.3,0.3,1.6,1.7,2.7,1.7c0,0,0.3-0.1,0.6-0.1c-0.8,2-1.7,3.7-2.6,5l0.7,0.7c1.5-1.2,3.2-3.4,4.8-6.4c0.3-0.1,1.2-0.4,1.2-0.4l-0.1-1c0,0-0.2,0-0.4,0c1.8-3.6,3.1-7.1,3.1-7.1l0.5-1.3L926.9,123.4z"/>
476+ <rect x="2386" y="12" rx="5" fill="#294D8E" width="35" height="57"/>
477+ <rect x="2387" y="13" rx="4" fill="#F4F4F4" width="33" height="55"/>
478+ <rect x="2390" y="16" rx="3" fill="#294D8E" width="27" height="49"/>
479+ <rect x="2393" y="19" rx="7" fill="#F4F4F4" width="21" height="43"/>
480+ <rect x="2386" y="100" rx="5" fill="#294D8E" width="35" height="57"/>
481+ <rect x="2387" y="101" rx="4" fill="#FFFFFF" width="33" height="55"/>
482+ <rect x="2390" y="104" rx="3" fill="#294D8E" width="27" height="49"/>
483+ <rect x="2393" y="107" rx="7" fill="#FFFFFF" width="21" height="43"/>
484+ <path fill="#99B9FF" stroke="#678ED3" d="M2323.8,13.9l-0.1,1.3c0,0,8.9-2.4,9.4-0.6c0,0-0.4,4.1-1.4,4.9c0-0.5-0.7-0.6-1.7-0.5c0.1-1.1,0.3-3.7,1-4l-1.6-0.7l-0.2,4.8c-0.5,0.1-1,0.2-1.5,0.3l-0.9-4.8l-1,0.4l0.9,4.7c-1.1,0.3-2,0.5-2,0.5l-1.8-7l-2.2-0.5l3.1,8.9c0,0,7.7-2.7,7.5-0.7c3.2-2.5,4.3-6.5,4.3-6.5C2333.4,11,2323.8,13.9,2323.8,13.9z"/>
485+ <path fill="#99B9FF" stroke="#678ED3" d="M2267.6,19.9c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2259.5,20.7,2267.6,19.9,2267.6,19.9z"/>
486+ <path fill="#99B9FF" stroke="#678ED3" d="M2139.6,16.1c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2131.5,16.9,2139.6,16.1,2139.6,16.1z"/>
487+ <polygon fill="#99B9FF" stroke="#678ED3" points="2264.9,16.3 2265.7,17.8 2260.9,18.3 2260.4,16.8"/>
488+ <path fill="#99B9FF" stroke="#678ED3" d="M2267.1,12.5c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2267.1,12.5z"/>
489+ <path fill="#99B9FF" stroke="#678ED3" d="M2203.6,18.9c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2195.5,19.7,2203.6,18.9,2203.6,18.9z"/>
490+ <path fill="#99B9FF" stroke="#678ED3" d="M2203.1,14.5c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2203.1,14.5z"/>
491+ <path fill="#99B9FF" stroke="#678ED3" d="M2323.8,101.9l-0.1,1.3c0,0,8.9-2.4,9.4-0.6c0,0-0.4,4.1-1.4,4.9c0-0.5-0.7-0.6-1.7-0.5c0.1-1.1,0.3-3.7,1-4l-1.6-0.7l-0.2,4.8c-0.5,0.1-1,0.2-1.5,0.3l-0.9-4.8l-1,0.4l0.9,4.7c-1.1,0.3-2,0.5-2,0.5l-1.8-7l-2.2-0.5l3.1,8.9c0,0,7.7-2.7,7.5-0.7c3.2-2.5,4.3-6.5,4.3-6.5C2333.4,99,2323.8,101.9,2323.8,101.9z"/>
492+ <path fill="#99B9FF" stroke="#678ED3" d="M2267.6,107.9c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2259.5,108.7,2267.6,107.9,2267.6,107.9z"/>
493+ <path fill="#99B9FF" stroke="#678ED3" d="M2139.6,104.1c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2131.5,104.9,2139.6,104.1,2139.6,104.1z"/>
494+ <polygon fill="#99B9FF" stroke="#678ED3" points="2264.9,104.3 2265.7,105.8 2260.9,106.3 2260.4,104.8 "/>
495+ <path fill="#99B9FF" stroke="#678ED3" d="M2267.1,100.5c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2267.1,100.5z"/>
496+ <path fill="#99B9FF" stroke="#678ED3" d="M2203.6,106.9c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2195.5,107.7,2203.6,106.9,2203.6,106.9z"/>
497+ <path fill="#99B9FF" stroke="#678ED3" d="M2203.1,102.5c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2203.1,102.5z"/>
498+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2643.1,20.3l-0.1,1.3c0,0,8.9-2.4,9.4-0.6c0,0-0.4,4.1-1.4,4.9c0-0.5-0.7-0.6-1.7-0.5c0.1-1.1,0.3-3.7,1-4l-1.6-0.7l-0.2,4.8c-0.5,0.1-1,0.2-1.5,0.3l-0.9-4.8l-1,0.4l0.9,4.7c-1.1,0.3-2,0.5-2,0.5l-1.8-7L2640,19l3.1,8.9c0,0,7.7-2.7,7.5-0.7c3.2-2.5,4.3-6.5,4.3-6.5C2652.7,17.4,2643.1,20.3,2643.1,20.3z"/>
499+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2586.9,26.3c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2578.9,27.1,2586.9,26.3,2586.9,26.3z"/>
500+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2458.9,22.5c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2450.9,23.3,2458.9,22.5,2458.9,22.5z"/>
501+ <polygon fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" points="2584.3,22.6 2585,24.1 2580.3,24.7 2579.7,23.2 "/>
502+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2586.5,18.8c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2586.5,18.8z"/>
503+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2522.9,25.3c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2514.9,26.1,2522.9,25.3,2522.9,25.3z"/>
504+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2522.5,20.8c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2522.5,20.8z"/>
505+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2643.1,108.3l-0.1,1.3c0,0,8.9-2.4,9.4-0.6c0,0-0.4,4.1-1.4,4.9c0-0.5-0.7-0.6-1.7-0.5c0.1-1.1,0.3-3.7,1-4l-1.6-0.7l-0.2,4.8c-0.5,0.1-1,0.2-1.5,0.3l-0.9-4.8l-1,0.4l0.9,4.7c-1.1,0.3-2,0.5-2,0.5l-1.8-7l-2.2-0.5l3.1,8.9c0,0,7.7-2.7,7.5-0.7c3.2-2.5,4.3-6.5,4.3-6.5C2652.7,105.4,2643.1,108.3,2643.1,108.3z"/>
506+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2586.9,114.3c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2578.9,115.1,2586.9,114.3,2586.9,114.3z"/>
507+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2458.9,110.5c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2450.9,111.3,2458.9,110.5,2458.9,110.5z"/>
508+ <polygon fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" points="2584.3,110.6 2585,112.1 2580.3,112.7 2579.7,111.2 "/>
509+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2586.5,106.8c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2586.5,106.8z"/>
510+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2522.9,113.3c-0.8-2.5,4.7,2.6,2.6,2.2c-2.1-0.4-10.6,0.1-11.9,0.6l-0.6-2.2C2514.9,114.1,2522.9,113.3,2522.9,113.3z"/>
511+ <path fill="#DBC879" stroke="#7C5A2D" stroke-width="0.5" d="M2522.5,108.8c-2-2.3-2.8,0.6-9,0.6l1.1,1.7l7.7-1L2522.5,108.8z"/>
512+ <path fill="#101040" d="M632.3,54.7c-2-0.9-11.8,3.3-11.8,3.3l0.5-20.7c0,0,5.3-4.3,9.6-1.7l-1.2-5c-1.4-0.2-8.4,5.5-8.4,5.5s-0.6-18.8,4.8-19.9c0,0-8.9-2.6-9.7-2.2c-0.4,0.2-0.4,13.8-0.2,27.1c-3.2,3.1-5.9,6.2-7.1,7.3l0.4-28.5c0,0-4.7-2.6-5.8-1.4l0.2,14.8c-0.3-0.5-0.7-0.9-1-1.3c-1-1.3-6.1-4.2-6.1-4.2s1.7,5.6,2.5,6.6c0.7,0.9,3,2.9,4.6,2.2l0.2,16.5c-3.4,3-7.9,6.8-8.5,5.4c-1.5-3.2-4.7-3-5.2-1.5c-0.5,1.6,4.2,10.2,5.8,10.1c0.8-0.1,4.4-4.9,8-10l0.1,5.3c1.6,2.1,3.8,0.5,4.6-0.7l0.3-11c1.1-1.6,4-4.5,7.1-7.9c0.2,12.1,0.3,22.8,0.3,22.8c4.9-5.2,16.7-4,16.7-4L632.3,54.7z"/>
513+</svg>
addedsrc/App.tsx+131 −0
1+import { useEffect, useState, useSyncExternalStore } from 'react';
2+import { Game } from './game/engine';
3+import { loadPrefs, savePrefs, type Prefs } from './game/prefs';
4+import { loadSave } from './game/save';
5+import { play, setSoundEnabled, setVolume } from './game/sound';
6+import { WIND_EN, WIND_NAMES } from './game/tiles';
7+import type { SeatId } from './game/types';
8+import { Center } from './ui/Center';
9+import { Help } from './ui/Help';
10+import { Seat } from './ui/Seat';
11+import { Settings } from './ui/Settings';
12+import { SoundToggle } from './ui/SoundToggle';
13+import './styles.css';
14+
15+const SEATS: SeatId[] = [0, 1, 2, 3];
16+
17+export default function App() {
18+ const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
19+ // Built once, from the preferences as they stood at startup; after that the
20+ // settings screen edits the live game rather than replacing it.
21+ const [game] = useState(() => new Game(prefs.names, prefs.rules));
22+ useSyncExternalStore(game.subscribe, game.getSnapshot);
23+ const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
24+ const [settingsOpen, setSettingsOpen] = useState(false);
25+ // Read once at startup; after that the live game is the source of truth.
26+ const [saved] = useState(() => loadSave());
27+ const phase = game.state.phase;
28+
29+ // Names and stakes live in the game state as well as in the preferences —
30+ // saved games keep the settings they were played under.
31+ const commitSettings = (p: Prefs) => {
32+ setPrefs(p);
33+ game.applySettings(p.names, p.rules);
34+ setSettingsOpen(false);
35+ };
36+ // The panel auditions sound changes as you make them, so cancelling has to
37+ // put the audio back the way it was.
38+ const cancelSettings = () => {
39+ setSoundEnabled(prefs.sound);
40+ setVolume(prefs.volume);
41+ setSettingsOpen(false);
42+ };
43+ const settingsPanel = settingsOpen && (
44+ <Settings
45+ prefs={prefs}
46+ onSave={commitSettings}
47+ onClose={cancelSettings}
48+ stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'}
49+ />
50+ );
51+
52+ // Preferences are written straight through, so a mute survives a refresh.
53+ useEffect(() => {
54+ savePrefs(prefs);
55+ setSoundEnabled(prefs.sound);
56+ setVolume(prefs.volume);
57+ }, [prefs]);
58+ useEffect(() => game.onSound(play), [game]);
59+
60+ if (phase === 'lobby') {
61+ const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
62+ return (
63+ <div className="lobby">
64+ <h1>台灣麻將</h1>
65+ <p className="sub">Taiwanese Mahjong · 16 tiles · four players, one screen</p>
66+ <p className="hint">
67+ 把擋板放在螢幕四邊,各自遮住自己的牌。
68+ <br />
69+ Stand a card over your edge of the screen to hide your hand.
70+ </p>
71+
72+ {saved && (
73+ <div className="resume">
74+ <div className="resume-line">
75+ {WIND_NAMES[round]}圈 · 第 {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand{' '}
76+ {saved.state.handNumber}
77+ </div>
78+ <div className="resume-scores">
79+ {saved.state.players.map((p, i) => (
80+ <span key={i}>
81+ {p.name} <b>{p.score}</b>
82+ </span>
83+ ))}
84+ </div>
85+ <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div>
86+ </div>
87+ )}
88+
89+ <div className="lobby-buttons">
90+ {saved && (
91+ <button className="btn big-btn" onClick={() => game.restore(saved.state)}>
92+ 繼續對局 Resume
93+ </button>
94+ )}
95+ <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={() => game.newGame()}>
96+ {saved ? '開新局 New game' : '開始 Start'}
97+ </button>
98+ </div>
99+
100+ <div className="lobby-extras">
101+ <button className="btn ghost sound-toggle" onClick={() => setSettingsOpen(true)}>
102+ ⚙ 設定 Settings
103+ </button>
104+ <SoundToggle prefs={prefs} onPrefs={setPrefs} />
105+ </div>
106+ {settingsPanel}
107+ </div>
108+ );
109+ }
110+
111+ return (
112+ <div className="table">
113+ {SEATS.map((s) => (
114+ <Seat key={s} game={game} seat={s} onHelp={() => setHelpSeat(s)} />
115+ ))}
116+ <Center game={game} prefs={prefs} onPrefs={setPrefs} onSettings={() => setSettingsOpen(true)} />
117+ {helpSeat !== null && <Help seat={helpSeat} onClose={() => setHelpSeat(null)} />}
118+ {settingsPanel}
119+ </div>
120+ );
121+}
122+
123+function relativeTime(ts: number): string {
124+ const mins = Math.round((Date.now() - ts) / 60000);
125+ if (mins < 1) return '剛剛 just now';
126+ if (mins < 60) return `${mins} 分鐘前 · ${mins} min ago`;
127+ const hours = Math.round(mins / 60);
128+ if (hours < 24) return `${hours} 小時前 · ${hours} h ago`;
129+ const days = Math.round(hours / 24);
130+ return `${days} 天前 · ${days} d ago`;
131+}
addedsrc/assets/hero.pngbinary
Binary file
addedsrc/assets/vite.svg+1 −0
1+<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="29.961" y="-17.13" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-13.43" y="-22.082" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="34.321" y="-37.644" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="38.847" y="-10.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="22.45" y="-1.645" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="32.919" y="11.36" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter></defs></svg>
addedsrc/game/engine.test.ts+520 −0
1+import { describe, expect, it } from 'vitest';
2+import { Game } from './engine';
3+import { decompose, isWinningHand, waits } from './hu';
4+import { scoreHand } from './tai';
5+import { buildWall, NUM_BASIC, type Tile } from './tiles';
6+import { DEFAULT_RULES, type Meld, type SeatId } from './types';
7+
8+const SEATS: SeatId[] = [0, 1, 2, 3];
9+
10+/**
11+ * Force a seat one tile from home: four pungs down and `hand` concealed, so
12+ * the hand turns on a single tile. Tile conservation goes out of the window,
13+ * which is fine — the tests that care about it deal properly.
14+ */
15+function setUpReadyHand(game: Game, seat: SeatId, hand: Tile[]) {
16+ const p = game.state.players[seat];
17+ const from = ((seat + 1) % 4) as SeatId;
18+ p.melds = [9, 12, 15, 18].map(
19+ (t): Meld => ({ kind: 'pung', tiles: [t, t, t], from }),
20+ );
21+ p.hand = [...hand];
22+}
23+
24+describe('wall', () => {
25+ it('has 144 tiles with the right multiplicities', () => {
26+ const w = buildWall();
27+ expect(w.length).toBe(144);
28+ for (let t = 0; t < NUM_BASIC; t++) expect(w.filter((x) => x === t).length).toBe(4);
29+ for (let t = NUM_BASIC; t < 42; t++) expect(w.filter((x) => x === t).length).toBe(1);
30+ });
31+});
32+
33+describe('hu detection', () => {
34+ it('accepts a 17-tile hand of five sets and a pair', () => {
35+ // 123m 456m 789m 111s 東東東 + 白白 → 5 sets + pair
36+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33, 33];
37+ expect(isWinningHand(h, 0)).toBe(true);
38+ });
39+
40+ it('rejects a hand one tile short of a set', () => {
41+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 28, 33, 33];
42+ expect(isWinningHand(h, 0)).toBe(false);
43+ });
44+
45+ it('works with melds already exposed', () => {
46+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 33, 33]; // 2 sets + pair, 3 melds down
47+ expect(isWinningHand(h, 3)).toBe(true);
48+ });
49+
50+ it('finds both readings of 111222333', () => {
51+ const d = decompose([0, 0, 0, 1, 1, 1, 2, 2, 2, 33, 33], 3);
52+ const kinds = d.map((x) => x.sets.map((s) => s.type).sort().join(''));
53+ expect(kinds).toContain('pungpungpung');
54+ expect(kinds).toContain('chowchowchow');
55+ });
56+
57+ it('computes waits', () => {
58+ const h: Tile[] = [0, 1, 2, 3, 4, 5, 33]; // 2 melds down, waiting to pair 白
59+ expect(waits(h, 3)).toEqual([33]);
60+ });
61+});
62+
63+describe('scoring', () => {
64+ const baseCtx = {
65+ winner: 0 as SeatId,
66+ dealer: 1 as SeatId,
67+ dealerStreak: 0,
68+ roundWind: 0,
69+ melds: [],
70+ concealed: [] as Tile[],
71+ winTile: 0,
72+ selfDraw: false,
73+ loser: 1 as SeatId,
74+ flowers: [] as Tile[],
75+ robKong: false,
76+ afterKong: false,
77+ lastTile: false,
78+ lastDiscard: false,
79+ heavenly: false,
80+ earthly: false,
81+ humanly: false,
82+ };
83+
84+ it('scores 清一色 + 碰碰胡', () => {
85+ // 111m 222m 333m 444m 555m + 66m, all concealed, won on a discard
86+ const concealed: Tile[] = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 5, 5];
87+ const r = scoreHand({ ...baseCtx, concealed, winTile: 4 });
88+ const names = r.items.map((i) => i.name);
89+ expect(names).toContain('清一色');
90+ expect(names).toContain('碰碰胡');
91+ expect(names).toContain('門清');
92+ });
93+
94+ it('scores 大三元', () => {
95+ const concealed: Tile[] = [31, 31, 31, 32, 32, 32, 33, 33, 0, 1, 2, 3, 4, 5, 9, 9];
96+ const r = scoreHand({ ...baseCtx, concealed, winTile: 33 });
97+ expect(r.items.map((i) => i.name)).toContain('大三元');
98+ });
99+
100+ it('counts seat flowers', () => {
101+ const concealed: Tile[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 27, 27, 27, 33];
102+ // winner is seat 0, dealer is seat 1 → seat wind index 3 → 冬(37) and 竹(41)
103+ const r = scoreHand({ ...baseCtx, concealed, winTile: 33, flowers: [37, 41] });
104+ expect(r.items.find((i) => i.name.startsWith('正花'))?.tai).toBe(2);
105+ });
106+});
107+
108+/**
109+ * Plays whole hands with a simple bot to shake out state-machine bugs.
110+ * `hurry` exercises the non-blocking path: at most one seat answers a discard
111+ * before the next player draws and closes the window on everyone else.
112+ */
113+function playRandomHand(game: Game, rng: () => number, maxSteps = 4000, hurry = false) {
114+ let steps = 0;
115+ while (game.state.phase !== 'handEnd' && steps++ < maxSteps) {
116+ const s = game.state;
117+ if (s.phase === 'turn') {
118+ const seat = s.turn;
119+ if (game.canSelfDrawWin(seat)) {
120+ game.declareSelfDraw(seat);
121+ continue;
122+ }
123+ const ak = game.concealedKongs(seat);
124+ if (ak.length && rng() < 0.5) {
125+ game.declareConcealedKong(seat, ak[0]);
126+ continue;
127+ }
128+ const gk = game.addedKongs(seat);
129+ if (gk.length && rng() < 0.5) {
130+ game.declareAddedKong(seat, gk[0]);
131+ continue;
132+ }
133+ const hand = s.players[seat].hand;
134+ game.discard(seat, hand[Math.floor(rng() * hand.length)]);
135+ } else if (s.phase === 'claims' || s.phase === 'robkong') {
136+ const seat = (Object.keys(s.options) as unknown as number[])
137+ .map(Number)
138+ .find((k) => s.responses[k as SeatId] === undefined) as SeatId | undefined;
139+ if (seat === undefined) throw new Error('claims phase with no pending seat');
140+ const opts = s.options[seat]!;
141+ const hu = opts.find((o) => o.type === 'hu');
142+ if (hu) game.respond(seat, { type: 'hu' });
143+ else if (rng() < 0.35) game.respond(seat, { type: opts[0].type, with: opts[0].with });
144+ else if (hurry) game.resolveNow();
145+ else game.respond(seat, 'pass');
146+ } else {
147+ break;
148+ }
149+ }
150+ if (steps >= maxSteps) throw new Error('hand did not terminate');
151+}
152+
153+describe('saving', () => {
154+ it('round-trips the whole game through JSON', () => {
155+ let seed = 555;
156+ const rng = () => {
157+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
158+ return seed / 0x7fffffff;
159+ };
160+ const game = new Game();
161+ game.startHand(99);
162+ // Play part of a hand so melds, flowers and discards are all populated.
163+ for (let i = 0; i < 40 && game.state.phase !== 'handEnd'; i++) {
164+ const s = game.state;
165+ if (s.phase === 'turn') {
166+ const hand = s.players[s.turn].hand;
167+ game.discard(s.turn, hand[Math.floor(rng() * hand.length)]);
168+ } else {
169+ game.resolveNow();
170+ }
171+ }
172+
173+ const clone = JSON.parse(JSON.stringify(game.state));
174+ expect(clone).toEqual(game.state);
175+
176+ // A restored game keeps playing from exactly where it left off.
177+ const resumed = new Game();
178+ resumed.restore(clone);
179+ expect(resumed.state.turn).toBe(game.state.turn);
180+ expect(resumed.state.players[0].hand).toEqual(game.state.players[0].hand);
181+ if (resumed.state.phase === 'turn') {
182+ const hand = resumed.state.players[resumed.state.turn].hand;
183+ const before = hand.length;
184+ resumed.discard(resumed.state.turn, hand[0]);
185+ expect(resumed.state.players.some((p) => p.discards.length > 0)).toBe(true);
186+ expect(before).toBeGreaterThan(0);
187+ }
188+ });
189+});
190+
191+describe('full hands', () => {
192+ it('lets the next player close the claim window without waiting', () => {
193+ let seed = 4242;
194+ const rng = () => {
195+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
196+ return seed / 0x7fffffff;
197+ };
198+ const game = new Game();
199+ for (let h = 0; h < 60; h++) {
200+ game.startHand(h * 104729 + 11);
201+ playRandomHand(game, rng, 4000, true);
202+ const s = game.state;
203+ expect(s.phase).toBe('handEnd');
204+ expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144);
205+ expect(s.players.reduce((a, p) => a + p.score, 0)).toBe(400);
206+ game.nextHand();
207+ if (game.state.phase === 'gameEnd') break;
208+ }
209+ });
210+
211+ it('honours a claim already declared when the window is closed', () => {
212+ const game = new Game();
213+ game.startHand(7);
214+ const s = game.state;
215+ // Give the seat to the dealer's right a pair to pung with.
216+ const victim = ((s.dealer + 2) % 4) as SeatId;
217+ const tile = s.players[s.dealer].hand[0];
218+ s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)];
219+ game.discard(s.dealer, tile);
220+ expect(s.phase).toBe('claims');
221+ game.respond(victim, { type: 'pung' });
222+ // The other seats never answer; the next player draws anyway.
223+ game.resolveNow();
224+ expect(s.players[victim].melds.some((m) => m.kind === 'pung')).toBe(true);
225+ expect(s.turn).toBe(victim);
226+ });
227+
228+ it('takes back a mis-tapped discard', () => {
229+ const game = new Game();
230+ game.startHand(11);
231+ const s = game.state;
232+ const before = JSON.parse(JSON.stringify(s));
233+ expect(game.undoable()).toBeNull();
234+
235+ game.discard(s.dealer, s.players[s.dealer].hand[0]);
236+ expect(game.undoable()).not.toBeNull();
237+ game.undo();
238+
239+ expect(game.state).toEqual(before);
240+ expect(game.undoable()).toBeNull();
241+ });
242+
243+ it('takes back a pass, handing the claim buttons back', () => {
244+ const game = new Game();
245+ game.startHand(7);
246+ const s = game.state;
247+ const victim = ((s.dealer + 2) % 4) as SeatId;
248+ const tile = s.players[s.dealer].hand[0];
249+ s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)];
250+ game.discard(s.dealer, tile);
251+
252+ game.respond(victim, 'pass');
253+ expect(game.state.responses[victim]).toBe('pass');
254+ game.undo();
255+ expect(game.state.responses[victim]).toBeUndefined();
256+ expect(game.state.options[victim]).toBeTruthy();
257+
258+ // ...and the claim can then be made for real.
259+ game.respond(victim, { type: 'pung' });
260+ game.resolveNow();
261+ expect(game.state.players[victim].melds.some((m) => m.kind === 'pung')).toBe(true);
262+ });
263+
264+ it('caps the undo stack and forgets the previous hand', () => {
265+ const game = new Game();
266+ game.startHand(3);
267+ for (let i = 0; i < 30; i++) {
268+ const s = game.state;
269+ if (s.phase === 'turn') game.discard(s.turn, s.players[s.turn].hand[0]);
270+ else game.resolveNow();
271+ }
272+ let depth = 0;
273+ while (game.undoable()) {
274+ game.undo();
275+ depth++;
276+ }
277+ expect(depth).toBe(20);
278+
279+ game.startHand(4);
280+ expect(game.undoable()).toBeNull();
281+ });
282+
283+ it('過水 locks a declined win out until the next draw', () => {
284+ const game = new Game();
285+ game.startHand(21);
286+ const s = game.state;
287+ // Put a seat one tile from home: four melds down, 123m + 白白 in hand,
288+ // so it wins on 一萬 or 四萬.
289+ const victim = ((s.dealer + 2) % 4) as SeatId;
290+ setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬
291+ const tile = 3; // 四萬
292+
293+ game.state.players[s.dealer].hand = [tile, ...game.state.players[s.dealer].hand.slice(1)];
294+ game.discard(s.dealer, tile);
295+ expect(s.options[victim]?.some((o) => o.type === 'hu')).toBe(true);
296+ game.respond(victim, 'pass');
297+ game.resolveNow();
298+ expect(game.sacredTiles(victim)).toContain(tile);
299+
300+ // Same tile again from the next seat round: no 胡 on offer this time.
301+ const other = s.turn;
302+ expect(other).not.toBe(victim);
303+ game.state.players[other].hand = [tile, ...game.state.players[other].hand.slice(1)];
304+ game.discard(other, tile);
305+ expect(s.options[victim]?.some((o) => o.type === 'hu')).toBeFalsy();
306+ });
307+
308+ it('過水 lifts once the locked seat draws again', () => {
309+ const game = new Game();
310+ game.startHand(21);
311+ const s = game.state;
312+ const victim = ((s.dealer + 2) % 4) as SeatId;
313+ setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬
314+ s.sacred[victim] = [3];
315+
316+ // Walk the table round until the locked seat draws.
317+ for (let i = 0; i < 40 && !(s.phase === 'turn' && s.turn === victim); i++) {
318+ if (s.phase === 'turn') game.discard(s.turn, s.players[s.turn].hand[0]);
319+ else game.resolveNow();
320+ }
321+ expect(s.turn).toBe(victim);
322+ expect(game.sacredTiles(victim)).toEqual([]);
323+ });
324+
325+ it('過水 can be switched off, or lifted by a claim', () => {
326+ for (const clearedByClaim of [false, true]) {
327+ const game = new Game(undefined, { ...DEFAULT_RULES, sacredClearedByClaim: clearedByClaim });
328+ game.startHand(21);
329+ const s = game.state;
330+ const victim = ((s.dealer + 1) % 4) as SeatId; // to the discarder's left, so it can chow
331+ setUpReadyHand(game, victim, [1, 2, 33, 33]); // 聽 一萬 / 四萬
332+ s.sacred[victim] = [3];
333+ game.state.players[s.dealer].hand = [3, ...game.state.players[s.dealer].hand.slice(1)];
334+ game.discard(s.dealer, 3);
335+ game.respond(victim, { type: 'chow', with: [1, 2] });
336+ game.resolveNow();
337+ expect(game.sacredTiles(victim)).toEqual(clearedByClaim ? [] : [3]);
338+ }
339+
340+ const off = new Game(undefined, { ...DEFAULT_RULES, sacredDiscard: false });
341+ off.startHand(21);
342+ const seat = ((off.state.dealer + 2) % 4) as SeatId;
343+ off.state.sacred[seat] = [3];
344+ expect(off.sacredTiles(seat)).toEqual([]);
345+ });
346+
347+ it('一炮多響 pays every seat that calls on the tile', () => {
348+ const game = new Game(undefined, { ...DEFAULT_RULES, multipleWinners: true });
349+ game.startHand(41);
350+ const s = game.state;
351+ const a = ((s.dealer + 1) % 4) as SeatId;
352+ const b = ((s.dealer + 2) % 4) as SeatId;
353+ setUpReadyHand(game, a, [1, 2, 33, 33]);
354+ setUpReadyHand(game, b, [1, 2, 32, 32]);
355+ s.players[s.dealer].hand = [3, ...s.players[s.dealer].hand.slice(1)];
356+
357+ game.discard(s.dealer, 3);
358+ game.respond(a, { type: 'hu' });
359+ // The nearer seat does not get to close the door on the further one.
360+ expect(s.phase).toBe('claims');
361+ game.respond(b, { type: 'hu' });
362+
363+ const r = s.result!;
364+ expect(r.winners.map((w) => w.seat).sort()).toEqual([a, b].sort());
365+ expect(r.loser).toBe(s.dealer);
366+ expect(r.deltas[a]).toBeGreaterThan(0);
367+ expect(r.deltas[b]).toBeGreaterThan(0);
368+ expect(r.deltas[s.dealer]).toBe(-(r.deltas[a] + r.deltas[b]));
369+ expect(r.deltas.reduce((x, y) => x + y, 0)).toBe(0);
370+ });
371+
372+ it('awards the discard to the nearest caller when 一炮多響 is off', () => {
373+ const game = new Game();
374+ game.startHand(41);
375+ const s = game.state;
376+ const a = ((s.dealer + 1) % 4) as SeatId;
377+ const b = ((s.dealer + 2) % 4) as SeatId;
378+ setUpReadyHand(game, a, [1, 2, 33, 33]);
379+ setUpReadyHand(game, b, [1, 2, 32, 32]);
380+ s.players[s.dealer].hand = [3, ...s.players[s.dealer].hand.slice(1)];
381+
382+ game.discard(s.dealer, 3);
383+ game.respond(b, { type: 'hu' });
384+ game.respond(a, { type: 'hu' });
385+ expect(s.result!.winners.map((w) => w.seat)).toEqual([a]);
386+ });
387+
388+ it('包牌 — whoever fed the third dragon answers for the whole hand', () => {
389+ for (const liability of [true, false]) {
390+ const game = new Game(undefined, { ...DEFAULT_RULES, liability });
391+ game.startHand(31);
392+ const s = game.state;
393+ const winner = ((s.dealer + 1) % 4) as SeatId;
394+ const feeder = ((s.dealer + 2) % 4) as SeatId;
395+ const other = ((s.dealer + 3) % 4) as SeatId;
396+
397+ const p = s.players[winner];
398+ p.melds = [
399+ { kind: 'pung', tiles: [31, 31, 31], from: other }, // 中
400+ { kind: 'pung', tiles: [9, 9, 9], from: other },
401+ { kind: 'pung', tiles: [32, 32, 32], from: other }, // 發
402+ { kind: 'pung', tiles: [33, 33, 33], from: feeder }, // 白 — the third dragon
403+ ];
404+ p.hand = [1, 2, 5, 5, 3];
405+ s.turn = winner;
406+ s.phase = 'turn';
407+ s.drawnTile = 3;
408+
409+ game.declareSelfDraw(winner);
410+ const r = s.result!;
411+ expect(r.winners[0].items.map((i) => i.name)).toContain('大三元');
412+ expect(r.winners[0].liable).toBe(liability ? feeder : null);
413+ if (liability) {
414+ // Everyone else is out of it; the feeder covers all three shares.
415+ expect(r.deltas[other]).toBe(0);
416+ expect(r.deltas[s.dealer]).toBe(0);
417+ expect(r.deltas[feeder]).toBe(-r.deltas[winner]);
418+ } else {
419+ expect(r.deltas[other]).toBeLessThan(0);
420+ expect(r.deltas[feeder]).toBeLessThan(0);
421+ }
422+ expect(r.deltas.reduce((x, y) => x + y, 0)).toBe(0);
423+ }
424+ });
425+
426+ it('lets 包牌 go when the third dragon came from the winner’s own hand', () => {
427+ const game = new Game();
428+ game.startHand(31);
429+ const s = game.state;
430+ const winner = ((s.dealer + 1) % 4) as SeatId;
431+ const other = ((s.dealer + 3) % 4) as SeatId;
432+ const p = s.players[winner];
433+ p.melds = [
434+ { kind: 'pung', tiles: [31, 31, 31], from: other },
435+ { kind: 'pung', tiles: [9, 9, 9], from: other },
436+ { kind: 'pung', tiles: [32, 32, 32], from: other },
437+ { kind: 'ankong', tiles: [33, 33, 33, 33] }, // 暗槓, nobody handed it over
438+ ];
439+ p.hand = [1, 2, 5, 5, 3];
440+ s.turn = winner;
441+ s.phase = 'turn';
442+ s.drawnTile = 3;
443+
444+ game.declareSelfDraw(winner);
445+ expect(s.result!.winners[0].items.map((i) => i.name)).toContain('大三元');
446+ expect(s.result!.winners[0].liable).toBeNull();
447+ });
448+
449+ it('keeps the chips zero-sum under every house rule', () => {
450+ const variants = [
451+ { multipleWinners: true },
452+ { multipleWinners: true, sacredDiscard: false },
453+ { liability: false, sacredClearedByClaim: true },
454+ ];
455+ for (const [v, patch] of variants.entries()) {
456+ let seed = 900 + v;
457+ const rng = () => {
458+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
459+ return seed / 0x7fffffff;
460+ };
461+ const game = new Game(undefined, { ...DEFAULT_RULES, ...patch });
462+ for (let h = 0; h < 30; h++) {
463+ game.startHand(h * 7919 + v);
464+ playRandomHand(game, rng);
465+ const s = game.state;
466+ expect(s.phase).toBe('handEnd');
467+ expect(s.players.reduce((a, p) => a + p.score, 0)).toBe(400);
468+ expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144);
469+ game.nextHand();
470+ if (game.state.phase === 'gameEnd') break;
471+ }
472+ }
473+ });
474+
475+ it('plays 200 random hands without corrupting state', () => {
476+ let seed = 12345;
477+ const rng = () => {
478+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
479+ return seed / 0x7fffffff;
480+ };
481+ const game = new Game();
482+ let wins = 0;
483+ let draws = 0;
484+
485+ for (let h = 0; h < 200; h++) {
486+ game.startHand(h * 7919 + 3);
487+ playRandomHand(game, rng);
488+ const s = game.state;
489+ expect(s.phase).toBe('handEnd');
490+ if (s.result!.drawGame) draws++;
491+ else wins++;
492+
493+ // the wall square's bookkeeping matches the array it draws from
494+ expect(s.drawnFront + s.drawnBack + s.wall.length).toBe(144);
495+ expect(s.drawnFront).toBeGreaterThanOrEqual(65); // the deal
496+
497+ // tile conservation: every tile is accounted for exactly once
498+ const all: Tile[] = [...s.wall];
499+ for (const p of s.players) all.push(...p.hand, ...p.flowers, ...p.discards, ...p.melds.flatMap((m) => m.tiles));
500+ expect(all.length).toBe(144);
501+ for (let t = 0; t < NUM_BASIC; t++) expect(all.filter((x) => x === t).length).toBe(4);
502+
503+ // hand sizes are legal
504+ for (const seat of SEATS) {
505+ const p = s.players[seat];
506+ const expected = 16 - p.melds.length * 3;
507+ expect(p.hand.length).toBeGreaterThanOrEqual(expected);
508+ expect(p.hand.length).toBeLessThanOrEqual(expected + 1);
509+ }
510+
511+ // chips are zero-sum
512+ const total = s.players.reduce((a, p) => a + p.score, 0);
513+ expect(total).toBe(400);
514+
515+ game.nextHand();
516+ if (game.state.phase === 'gameEnd') break;
517+ }
518+ expect(wins + draws).toBeGreaterThan(0);
519+ });
520+});
addedsrc/game/engine.ts+928 −0
1+import { isWinningHand, waits } from './hu';
2+import { dealerTai, scoreHand, type TaiItem } from './tai';
3+import {
4+ buildWall,
5+ EAST,
6+ isFlower,
7+ isSuited,
8+ labelOf,
9+ makeRng,
10+ RED,
11+ shuffle,
12+ sortTiles,
13+ WHITE,
14+ WIND_NAMES,
15+ type Tile,
16+} from './tiles';
17+import { clearSave, saveGame } from './save';
18+import { DEFAULT_NAMES, DEFAULT_RULES, type Meld, type Player, type Rules, type SeatId } from './types';
19+
20+export type Phase = 'lobby' | 'turn' | 'claims' | 'robkong' | 'handEnd' | 'gameEnd';
21+
22+/**
23+ * Things worth hearing. The engine only names them; what they sound like, and
24+ * whether anything is audible at all, is the UI's business (`sound.ts`).
25+ */
26+export type SoundEvent =
27+ | 'deal'
28+ | 'discard'
29+ | 'claimWindow'
30+ | 'claim'
31+ | 'kong'
32+ | 'flower'
33+ | 'win'
34+ | 'drawGame'
35+ | 'undo';
36+
37+export type ClaimType = 'hu' | 'kong' | 'pung' | 'chow';
38+
39+export interface ClaimOption {
40+ type: ClaimType;
41+ /** For a chow: the two tiles from hand that complete it. */
42+ with?: Tile[];
43+}
44+
45+export interface Claim {
46+ type: ClaimType;
47+ with?: Tile[];
48+}
49+
50+/** One seat's win. A hand has several of these only under 一炮多響. */
51+export interface WinnerResult {
52+ seat: SeatId;
53+ selfDraw: boolean;
54+ tai: number;
55+ items: TaiItem[];
56+ winTile: Tile;
57+ /** 包牌 — the seat answering for this hand on its own, if any. */
58+ liable: SeatId | null;
59+}
60+
61+export interface HandResult {
62+ drawGame: boolean;
63+ /** Empty on a 流局. */
64+ winners: WinnerResult[];
65+ /** The discarder, when the hand was won off a discard. */
66+ loser: SeatId | null;
67+ deltas: number[];
68+}
69+
70+export interface GameState {
71+ rules: Rules;
72+ players: Player[];
73+ wall: Tile[];
74+ dealer: SeatId;
75+ dealerStreak: number;
76+ rotations: number; // completed dealer passes; roundWind = floor(rotations / 4)
77+ turn: SeatId;
78+ phase: Phase;
79+ drawnTile: Tile | null;
80+ /** True while the drawn tile is still held apart at the end of the hand. */
81+ drawnSeparate: boolean;
82+ lastDiscard: { tile: Tile; from: SeatId } | null;
83+ /** Seats that may respond to the tile on the table, and with what. */
84+ options: Partial<Record<SeatId, ClaimOption[]>>;
85+ responses: Partial<Record<SeatId, Claim | 'pass'>>;
86+ /**
87+ * 過水 — per seat, the tiles they have already declined to win on since
88+ * their last draw. Those tiles are dead to them until the lock lifts.
89+ */
90+ sacred: Tile[][];
91+ afterKong: boolean;
92+ robKongTile: Tile | null;
93+ /** How many of the original 144 wall positions have been taken off the front. */
94+ drawnFront: number;
95+ /** ...and off the back (kong / flower replacements). */
96+ drawnBack: number;
97+ result: HandResult | null;
98+ log: string[];
99+ handNumber: number;
100+}
101+
102+const SEATS: SeatId[] = [0, 1, 2, 3];
103+const next = (s: SeatId): SeatId => (((s + 1) % 4) as SeatId);
104+
105+/** How many actions back you can walk. Deeper than anyone will remember. */
106+const UNDO_DEPTH = 20;
107+
108+interface Snapshot {
109+ state: GameState;
110+ /** What pressing undo will take back, in the players' own words. */
111+ zh: string;
112+ en: string;
113+}
114+
115+export class Game {
116+ state: GameState;
117+ private listeners = new Set<() => void>();
118+ private version = 0;
119+
120+ constructor(names: string[] = DEFAULT_NAMES, rules: Rules = DEFAULT_RULES) {
121+ this.state = {
122+ rules,
123+ players: names.map((name) => ({
124+ name,
125+ hand: [],
126+ melds: [],
127+ flowers: [],
128+ discards: [],
129+ score: rules.startingScore,
130+ })),
131+ wall: [],
132+ dealer: 0,
133+ dealerStreak: 0,
134+ rotations: 0,
135+ turn: 0,
136+ phase: 'lobby',
137+ drawnTile: null,
138+ drawnSeparate: false,
139+ lastDiscard: null,
140+ options: {},
141+ responses: {},
142+ sacred: [[], [], [], []],
143+ afterKong: false,
144+ robKongTile: null,
145+ drawnFront: 0,
146+ drawnBack: 0,
147+ result: null,
148+ log: [],
149+ handNumber: 0,
150+ };
151+ }
152+
153+ // ---- store plumbing ----------------------------------------------------
154+ subscribe = (fn: () => void) => {
155+ this.listeners.add(fn);
156+ return () => this.listeners.delete(fn);
157+ };
158+ getSnapshot = () => this.version;
159+ private emit() {
160+ this.version++;
161+ saveGame(this.state);
162+ for (const l of this.listeners) l();
163+ }
164+
165+ // ---- undo --------------------------------------------------------------
166+ /**
167+ * Four people share one touchscreen, so a mis-tap is the likeliest complaint
168+ * at the table. The whole state is plain JSON, so taking one back is just a
169+ * matter of having kept a copy.
170+ *
171+ * Snapshots are pushed by the methods a *button* calls — discard, respond,
172+ * the kong and win declarations — not by the internal steps they run, so one
173+ * press is one undo. They live outside the state, which is why an undo does
174+ * not survive a reload: what you can take back is what happened while
175+ * everyone was still sitting there watching it happen.
176+ */
177+ private undoStack: Snapshot[] = [];
178+
179+ private push(zh: string, en: string) {
180+ this.undoStack.push({ state: JSON.parse(JSON.stringify(this.state)) as GameState, zh, en });
181+ if (this.undoStack.length > UNDO_DEPTH) this.undoStack.shift();
182+ }
183+
184+ /** What undo would take back right now, or null if there is nothing. */
185+ undoable(): { zh: string; en: string } | null {
186+ const top = this.undoStack[this.undoStack.length - 1];
187+ return top ? { zh: top.zh, en: top.en } : null;
188+ }
189+
190+ undo() {
191+ const snap = this.undoStack.pop();
192+ if (!snap) return;
193+ this.state = snap.state;
194+ this.sfx('undo');
195+ this.emit();
196+ }
197+
198+ private soundListeners = new Set<(e: SoundEvent) => void>();
199+ /** Sound is a side channel, not state: it is never saved and never replayed. */
200+ onSound = (fn: (e: SoundEvent) => void) => {
201+ this.soundListeners.add(fn);
202+ return () => {
203+ this.soundListeners.delete(fn);
204+ };
205+ };
206+ private sfx(e: SoundEvent) {
207+ for (const l of this.soundListeners) l(e);
208+ }
209+
210+ /**
211+ * Take the settings screen's word for the names and the stakes. Chips are
212+ * only re-dealt before the first hand — changing 底 mid-game shouldn't
213+ * rewrite what everyone has already won.
214+ */
215+ applySettings(names: string[], rules: Rules) {
216+ const s = this.state;
217+ s.rules = rules;
218+ names.forEach((name, i) => {
219+ s.players[i].name = name;
220+ });
221+ if (s.phase === 'lobby') for (const p of s.players) p.score = rules.startingScore;
222+ this.emit();
223+ }
224+
225+ /** Pick up a game saved earlier. */
226+ restore(state: GameState) {
227+ this.state = state;
228+ this.emit();
229+ }
230+ private say(msg: string) {
231+ this.state.log.unshift(msg);
232+ if (this.state.log.length > 40) this.state.log.pop();
233+ }
234+
235+ get roundWind() {
236+ return Math.floor(this.state.rotations / 4) % 4;
237+ }
238+ get wallLeft() {
239+ return this.state.wall.length - this.state.rules.wallReserve;
240+ }
241+
242+ // ---- hand setup --------------------------------------------------------
243+ startHand(seed = Math.floor(Math.random() * 2 ** 31)) {
244+ const s = this.state;
245+ // Once the next hand is dealt the last one is settled and paid; there is
246+ // nothing left to take back, and the tiles are already shuffled away.
247+ this.undoStack = [];
248+ s.wall = shuffle(buildWall(), makeRng(seed));
249+ s.handNumber++;
250+ for (const p of s.players) {
251+ p.hand = [];
252+ p.melds = [];
253+ p.flowers = [];
254+ p.discards = [];
255+ }
256+ s.result = null;
257+ s.lastDiscard = null;
258+ s.options = {};
259+ s.responses = {};
260+ s.sacred = [[], [], [], []];
261+ s.afterKong = false;
262+ s.robKongTile = null;
263+ s.drawnFront = 0;
264+ s.drawnBack = 0;
265+ s.log = [];
266+
267+ for (let round = 0; round < 4; round++) {
268+ for (let i = 0; i < 4; i++) {
269+ const seat = ((s.dealer + i) % 4) as SeatId;
270+ for (let k = 0; k < 4; k++) s.players[seat].hand.push(this.takeFront());
271+ }
272+ }
273+ // The dealer's 17th tile is the opening draw.
274+ const opener = this.takeFront();
275+ s.players[s.dealer].hand.push(opener);
276+
277+ // 補花 — replace flowers in seat order, repeating until nobody holds one.
278+ let dealerLast: Tile | null = null;
279+ let more = true;
280+ while (more) {
281+ more = false;
282+ for (let i = 0; i < 4; i++) {
283+ const seat = ((s.dealer + i) % 4) as SeatId;
284+ const r = this.replaceFlowers(seat);
285+ if (r.moved) more = true;
286+ if (seat === s.dealer && r.last !== null) dealerLast = r.last;
287+ }
288+ }
289+ for (const p of s.players) p.hand = sortTiles(p.hand);
290+
291+ s.turn = s.dealer;
292+ this.setDrawn(s.dealer, isFlower(opener) ? dealerLast : opener);
293+ s.phase = 'turn';
294+ this.say(`${WIND_NAMES[this.roundWind]}圈 — 莊家 dealer: ${s.players[s.dealer].name}`);
295+ this.sfx('deal');
296+ this.emit();
297+ }
298+
299+ /**
300+ * Moves flowers out of a hand, drawing replacements from the back of the wall.
301+ * Returns the last replacement tile so the caller can keep marking it as the
302+ * tile the player just drew.
303+ */
304+ private replaceFlowers(seat: SeatId): { moved: boolean; last: Tile | null } {
305+ const s = this.state;
306+ const p = s.players[seat];
307+ let moved = false;
308+ let last: Tile | null = null;
309+ for (;;) {
310+ const i = p.hand.findIndex(isFlower);
311+ if (i < 0) break;
312+ const f = p.hand.splice(i, 1)[0];
313+ p.flowers.push(f);
314+ p.flowers.sort((a, b) => a - b);
315+ moved = true;
316+ last = null;
317+ if (s.wall.length === 0) break;
318+ last = this.takeBack();
319+ p.hand.push(last);
320+ }
321+ return { moved, last };
322+ }
323+
324+ /**
325+ * Park the tile a player just drew at the end of their hand, held apart from
326+ * the rest. Hands are never auto-sorted after the deal — the arrangement is
327+ * the player's, so we only ever append.
328+ */
329+ private setDrawn(seat: SeatId, tile: Tile | null) {
330+ const s = this.state;
331+ s.drawnTile = tile;
332+ s.drawnSeparate = tile !== null;
333+ if (tile === null) return;
334+ const hand = s.players[seat].hand;
335+ const i = hand.lastIndexOf(tile);
336+ if (i >= 0) hand.splice(i, 1);
337+ hand.push(tile);
338+ }
339+
340+ /** Move a tile within a hand. Players arrange their own tiles however they like. */
341+ reorderHand(seat: SeatId, from: number, to: number) {
342+ const s = this.state;
343+ const hand = s.players[seat].hand;
344+ if (from === to || from < 0 || to < 0 || from >= hand.length || to >= hand.length) return;
345+ const last = hand.length - 1;
346+ // Sliding the drawn tile into the hand (or something past it) merges it in.
347+ if (s.drawnSeparate && s.turn === seat && (from === last || to === last)) s.drawnSeparate = false;
348+ const [t] = hand.splice(from, 1);
349+ hand.splice(to, 0, t);
350+ this.emit();
351+ }
352+
353+ /** 理牌 — sort a hand back into suit order. */
354+ sortHand(seat: SeatId) {
355+ const s = this.state;
356+ s.players[seat].hand = sortTiles(s.players[seat].hand);
357+ if (s.turn === seat) s.drawnSeparate = false;
358+ this.emit();
359+ }
360+
361+ /** Next tile off the front of the wall (the normal draw). */
362+ private takeFront(): Tile {
363+ this.state.drawnFront++;
364+ return this.state.wall.shift()!;
365+ }
366+
367+ /** Next tile off the back of the wall (kong and flower replacements). */
368+ private takeBack(): Tile {
369+ this.state.drawnBack++;
370+ return this.state.wall.pop()!;
371+ }
372+
373+ // ---- turn flow ---------------------------------------------------------
374+ private beginTurn(seat: SeatId) {
375+ const s = this.state;
376+ if (this.wallLeft <= 0) return this.endInDraw();
377+ const t = this.takeFront();
378+ s.players[seat].hand.push(t);
379+ let drawn: Tile | null = t;
380+ if (isFlower(t)) {
381+ drawn = this.replaceFlowers(seat).last;
382+ this.say(`${s.players[seat].name} 補花 flower`);
383+ this.sfx('flower');
384+ }
385+ s.turn = seat;
386+ this.setDrawn(seat, drawn);
387+ s.phase = 'turn';
388+ s.afterKong = false;
389+ s.lastDiscard = null;
390+ s.options = {};
391+ s.responses = {};
392+ // Your own draw always lifts 過水, whatever the table thinks about claims.
393+ s.sacred[seat] = [];
394+ }
395+
396+ // ---- 過水 ---------------------------------------------------------------
397+ /** Has this seat already passed on winning with this tile? */
398+ private isSacred(seat: SeatId, tile: Tile): boolean {
399+ const s = this.state;
400+ return s.rules.sacredDiscard && s.sacred[seat].includes(tile);
401+ }
402+
403+ /**
404+ * Everyone who was offered 胡 on the tile now on the table and did not take
405+ * it has declined it — including anyone who took a 碰 or 吃 instead. Called
406+ * once the claims are settled, so the seat that actually won is exempt.
407+ */
408+ private recordDeclinedWins(winners: SeatId[]) {
409+ const s = this.state;
410+ if (!s.rules.sacredDiscard) return;
411+ const tile = s.phase === 'robkong' ? s.robKongTile : s.lastDiscard?.tile;
412+ if (tile === undefined || tile === null) return;
413+ for (const seat of SEATS) {
414+ if (winners.includes(seat)) continue;
415+ if (!s.options[seat]?.some((o) => o.type === 'hu')) continue;
416+ if (!s.sacred[seat].includes(tile)) s.sacred[seat].push(tile);
417+ }
418+ }
419+
420+ /** Replacement tile after a kong, taken from the back of the wall. */
421+ private kongReplacement(seat: SeatId) {
422+ const s = this.state;
423+ if (s.wall.length === 0) return this.endInDraw();
424+ const t = this.takeBack();
425+ s.players[seat].hand.push(t);
426+ const drawn = isFlower(t) ? this.replaceFlowers(seat).last : t;
427+ this.setDrawn(seat, drawn);
428+ s.turn = seat;
429+ s.phase = 'turn';
430+ s.afterKong = true;
431+ s.sacred[seat] = []; // a replacement off the back is still a draw
432+ }
433+
434+ /** 過水 — tiles this seat has passed on winning with, for the badge. */
435+ sacredTiles(seat: SeatId): Tile[] {
436+ return this.state.rules.sacredDiscard ? this.state.sacred[seat] : [];
437+ }
438+
439+ discard(seat: SeatId, tile: Tile) {
440+ const s = this.state;
441+ if (s.phase !== 'turn' || s.turn !== seat) return;
442+ const p = s.players[seat];
443+ const i = p.hand.indexOf(tile);
444+ if (i < 0) return;
445+ this.push(`${p.name} 打 ${labelOf(tile)}`, `${p.name} discarded ${labelOf(tile)}`);
446+ p.hand.splice(i, 1);
447+ p.discards.push(tile);
448+ s.drawnTile = null;
449+ s.drawnSeparate = false;
450+ s.lastDiscard = { tile, from: seat };
451+ s.responses = {};
452+ s.options = this.claimOptionsFor(tile, seat);
453+ this.sfx('discard');
454+
455+ if (Object.keys(s.options).length === 0) {
456+ this.afterDiscardResolved();
457+ } else {
458+ s.phase = 'claims';
459+ // The point of the chime: claims don't block, so somebody has to be told
460+ // their window is open before the next player draws it shut.
461+ this.sfx('claimWindow');
462+ }
463+ this.emit();
464+ }
465+
466+ /** Called once nobody wants the discarded tile. */
467+ private afterDiscardResolved() {
468+ const s = this.state;
469+ const from = s.lastDiscard!.from;
470+ if (this.wallLeft <= 0) return this.endInDraw();
471+ this.beginTurn(next(from));
472+ }
473+
474+ private claimOptionsFor(tile: Tile, from: SeatId): Partial<Record<SeatId, ClaimOption[]>> {
475+ const s = this.state;
476+ const out: Partial<Record<SeatId, ClaimOption[]>> = {};
477+ for (const seat of SEATS) {
478+ if (seat === from) continue;
479+ const p = s.players[seat];
480+ const opts: ClaimOption[] = [];
481+ if (isWinningHand([...p.hand, tile], p.melds.length) && !this.isSacred(seat, tile)) {
482+ opts.push({ type: 'hu' });
483+ }
484+ const same = p.hand.filter((t) => t === tile).length;
485+ if (same >= 3 && this.wallLeft > 0) opts.push({ type: 'kong' });
486+ if (same >= 2) opts.push({ type: 'pung' });
487+ if (seat === next(from) && isSuited(tile)) {
488+ for (const pair of chowPartners(p.hand, tile)) opts.push({ type: 'chow', with: pair });
489+ }
490+ if (opts.length) out[seat] = opts;
491+ }
492+ return out;
493+ }
494+
495+ /**
496+ * The seat that draws next if nobody takes the tile on the table — they are
497+ * the ones who can close the claim window.
498+ */
499+ nextDrawer(): SeatId | null {
500+ const s = this.state;
501+ if (s.phase === 'claims' && s.lastDiscard) return next(s.lastDiscard.from);
502+ if (s.phase === 'robkong') return s.turn;
503+ return null;
504+ }
505+
506+ /**
507+ * Carry on without waiting for anyone still thinking: everyone who hasn't
508+ * answered passes. Claims already declared still stand, so a player who
509+ * called in time keeps the tile. This is what stops one distracted player
510+ * from halting the table.
511+ */
512+ resolveNow() {
513+ const s = this.state;
514+ if (s.phase !== 'claims' && s.phase !== 'robkong') return;
515+ this.push('關閉叫牌', 'Closed the claim window');
516+ for (const key of Object.keys(s.options)) {
517+ const seat = Number(key) as SeatId;
518+ if (s.responses[seat] === undefined) s.responses[seat] = 'pass';
519+ }
520+ this.tryResolveClaims();
521+ this.emit();
522+ }
523+
524+ respond(seat: SeatId, claim: Claim | 'pass') {
525+ const s = this.state;
526+ if (s.phase !== 'claims' && s.phase !== 'robkong') return;
527+ if (!s.options[seat]) return;
528+ const name = s.players[seat].name;
529+ if (claim === 'pass') this.push(`${name} 過`, `${name} passed`);
530+ else this.push(`${name} ${CLAIM_ZH[claim.type]}`, `${name} called ${CLAIM_EN[claim.type].toLowerCase()}`);
531+ s.responses[seat] = claim;
532+ this.tryResolveClaims();
533+ this.emit();
534+ }
535+
536+ private tryResolveClaims() {
537+ const s = this.state;
538+ const rank = (c: ClaimType) => (c === 'hu' ? 3 : c === 'chow' ? 1 : 2);
539+ const pending = (Object.keys(s.options) as unknown as SeatId[])
540+ .map(Number)
541+ .filter((seat) => s.responses[seat as SeatId] === undefined) as SeatId[];
542+
543+ const declared = (Object.entries(s.responses) as [string, Claim | 'pass'][])
544+ .filter(([, c]) => c !== 'pass')
545+ .map(([seat, c]) => ({ seat: Number(seat) as SeatId, claim: c as Claim }));
546+
547+ if (declared.length === 0) {
548+ if (pending.length > 0) return;
549+ // everyone passed
550+ this.recordDeclinedWins([]);
551+ if (s.phase === 'robkong') return this.finishAddKong();
552+ return this.afterDiscardResolved();
553+ }
554+
555+ const bestPending = Math.max(
556+ 0,
557+ ...pending.map((seat) => Math.max(...s.options[seat]!.map((o) => rank(o.type)))),
558+ );
559+ const best = Math.max(...declared.map((d) => rank(d.claim.type)));
560+ if (best < bestPending && pending.length > 0) return; // someone might outrank
561+
562+ const from = s.lastDiscard?.from ?? s.turn;
563+ const winners = declared.filter((d) => rank(d.claim.type) === best);
564+ // Closest player after the discarder wins a tie.
565+ winners.sort(
566+ (a, b) => ((a.seat - from + 4) % 4) - ((b.seat - from + 4) % 4),
567+ );
568+
569+ if (best === rank('hu')) {
570+ // Anyone else who can win on this tile is worth waiting for: under
571+ // 一炮多響 they get paid too, and without it the tile still belongs to
572+ // the seat nearest the discarder rather than to the quickest hand. The
573+ // Draw button closes the window either way, so nobody is held up.
574+ if (pending.some((seat) => s.options[seat]!.some((o) => o.type === 'hu'))) return;
575+ const seats = s.rules.multipleWinners ? winners.map((w) => w.seat) : [winners[0].seat];
576+ this.recordDeclinedWins(seats);
577+ return this.winOnTile(seats);
578+ }
579+
580+ this.recordDeclinedWins([winners[0].seat]);
581+ this.executeClaim(winners[0].seat, winners[0].claim);
582+ }
583+
584+ /** Everyone who called 胡 on the tile currently on the table takes it. */
585+ private winOnTile(seats: SeatId[]) {
586+ const s = this.state;
587+ const robKong = s.phase === 'robkong';
588+ const tile = robKong ? s.robKongTile! : s.lastDiscard!.tile;
589+ const from = robKong ? s.turn : s.lastDiscard!.from;
590+ this.settle(
591+ seats.map((seat) => ({ seat, winTile: tile, selfDraw: false, robKong })),
592+ from,
593+ );
594+ }
595+
596+ /** Melds the tile on the table into a hand. 胡 goes through `winOnTile`. */
597+ private executeClaim(seat: SeatId, claim: Claim) {
598+ const s = this.state;
599+ if (claim.type === 'hu') return this.winOnTile([seat]);
600+ const tile = s.phase === 'robkong' ? s.robKongTile! : s.lastDiscard!.tile;
601+ const from = s.phase === 'robkong' ? s.turn : s.lastDiscard!.from;
602+ const p = s.players[seat];
603+
604+ // Take the tile off the discard pile.
605+ if (s.lastDiscard) {
606+ const dp = s.players[from].discards;
607+ if (dp[dp.length - 1] === tile) dp.pop();
608+ }
609+ const take = (t: Tile, n: number) => {
610+ for (let k = 0; k < n; k++) p.hand.splice(p.hand.indexOf(t), 1);
611+ };
612+
613+ let meld: Meld;
614+ if (claim.type === 'kong') {
615+ take(tile, 3);
616+ meld = { kind: 'kong', tiles: [tile, tile, tile, tile], from };
617+ } else if (claim.type === 'pung') {
618+ take(tile, 2);
619+ meld = { kind: 'pung', tiles: [tile, tile, tile], from };
620+ } else {
621+ for (const t of claim.with!) take(t, 1);
622+ meld = { kind: 'chow', tiles: sortTiles([tile, ...claim.with!]), from };
623+ }
624+ p.melds.push(meld);
625+ // Some tables treat taking a tile as enough of a fresh start to lift 過水;
626+ // stricter ones make you wait for your own draw.
627+ if (s.rules.sacredClearedByClaim) s.sacred[seat] = [];
628+ this.say(`${p.name} ${claimName(claim.type)} ${labelOf(tile)}`);
629+ this.sfx(claim.type === 'kong' ? 'kong' : 'claim');
630+
631+ s.lastDiscard = null;
632+ s.options = {};
633+ s.responses = {};
634+ if (claim.type === 'kong') {
635+ this.kongReplacement(seat);
636+ } else {
637+ s.turn = seat;
638+ s.drawnTile = null;
639+ s.drawnSeparate = false;
640+ s.phase = 'turn';
641+ s.afterKong = false;
642+ }
643+ }
644+
645+ // ---- own-turn declarations --------------------------------------------
646+ /** Tiles the seat may declare a concealed kong on. */
647+ concealedKongs(seat: SeatId): Tile[] {
648+ const p = this.state.players[seat];
649+ const counts = new Map<Tile, number>();
650+ for (const t of p.hand) counts.set(t, (counts.get(t) ?? 0) + 1);
651+ return [...counts.entries()].filter(([, n]) => n === 4).map(([t]) => t);
652+ }
653+
654+ /** Tiles that can be added to an existing exposed pung. */
655+ addedKongs(seat: SeatId): Tile[] {
656+ const p = this.state.players[seat];
657+ return p.melds
658+ .filter((m) => m.kind === 'pung')
659+ .map((m) => m.tiles[0])
660+ .filter((t) => p.hand.includes(t));
661+ }
662+
663+ declareConcealedKong(seat: SeatId, tile: Tile) {
664+ const s = this.state;
665+ if (s.phase !== 'turn' || s.turn !== seat || this.wallLeft <= 0) return;
666+ const p = s.players[seat];
667+ this.push(`${p.name} 暗槓 ${labelOf(tile)}`, `${p.name} declared a concealed kong`);
668+ for (let k = 0; k < 4; k++) p.hand.splice(p.hand.indexOf(tile), 1);
669+ p.melds.push({ kind: 'ankong', tiles: [tile, tile, tile, tile] });
670+ this.say(`${p.name} 暗槓 concealed kong`);
671+ this.sfx('kong');
672+ this.kongReplacement(seat);
673+ this.emit();
674+ }
675+
676+ declareAddedKong(seat: SeatId, tile: Tile) {
677+ const s = this.state;
678+ if (s.phase !== 'turn' || s.turn !== seat || this.wallLeft <= 0) return;
679+ const p = s.players[seat];
680+ const meld = p.melds.find((m) => m.kind === 'pung' && m.tiles[0] === tile);
681+ if (!meld) return;
682+ this.push(`${p.name} 加槓 ${labelOf(tile)}`, `${p.name} declared an added kong`);
683+ p.hand.splice(p.hand.indexOf(tile), 1);
684+ meld.kind = 'addkong';
685+ meld.tiles = [tile, tile, tile, tile];
686+ s.robKongTile = tile;
687+
688+ // 搶槓 — anyone who can win on this tile may take it.
689+ const opts: Partial<Record<SeatId, ClaimOption[]>> = {};
690+ for (const other of SEATS) {
691+ if (other === seat) continue;
692+ const op = s.players[other];
693+ if (isWinningHand([...op.hand, tile], op.melds.length) && !this.isSacred(other, tile)) {
694+ opts[other] = [{ type: 'hu' }];
695+ }
696+ }
697+ if (Object.keys(opts).length) {
698+ s.options = opts;
699+ s.responses = {};
700+ s.phase = 'robkong';
701+ this.say(`${p.name} 加槓 ${labelOf(tile)} — 可搶槓 robbable`);
702+ this.sfx('kong');
703+ this.sfx('claimWindow');
704+ } else {
705+ this.say(`${p.name} 加槓 added kong`);
706+ this.sfx('kong');
707+ this.finishAddKong();
708+ }
709+ this.emit();
710+ }
711+
712+ private finishAddKong() {
713+ const s = this.state;
714+ s.options = {};
715+ s.responses = {};
716+ s.robKongTile = null;
717+ this.kongReplacement(s.turn);
718+ }
719+
720+ canSelfDrawWin(seat: SeatId): boolean {
721+ const s = this.state;
722+ if (s.phase !== 'turn' || s.turn !== seat) return false;
723+ const p = s.players[seat];
724+ return p.hand.length === (5 - p.melds.length) * 3 + 2 && isWinningHand(p.hand, p.melds.length);
725+ }
726+
727+ declareSelfDraw(seat: SeatId) {
728+ if (!this.canSelfDrawWin(seat)) return;
729+ const s = this.state;
730+ const p = s.players[seat];
731+ const winTile = s.drawnTile ?? p.hand[p.hand.length - 1];
732+ this.push(`${p.name} 自摸`, `${p.name} declared a self-draw`);
733+ this.settle([{ seat, winTile, selfDraw: true, robKong: false }], null);
734+ }
735+
736+ /** Tiles a seat is waiting on right now (for the 聽 badge). */
737+ tenpai(seat: SeatId): Tile[] {
738+ const p = this.state.players[seat];
739+ if (p.hand.length !== (5 - p.melds.length) * 3 + 1) return [];
740+ return waits(p.hand, p.melds.length);
741+ }
742+
743+ // ---- settlement --------------------------------------------------------
744+ /**
745+ * 包牌 — feeding the pung that visibly completes a 大三元 or 大四喜 makes
746+ * the feeder answer for the whole hand, on the reasoning that by the third
747+ * dragon nobody could claim not to have seen it coming.
748+ *
749+ * Only the seat that fed the *last* of those sets is on the hook, and only
750+ * if it came off a discard: a hand that assembled them itself has nobody to
751+ * blame.
752+ */
753+ private liableFor(winner: SeatId, items: TaiItem[]): SeatId | null {
754+ const s = this.state;
755+ if (!s.rules.liability) return null;
756+ const dragons = items.some((i) => i.name === '大三元');
757+ const winds = items.some((i) => i.name === '大四喜');
758+ if (!dragons && !winds) return null;
759+
760+ const inSet = dragons
761+ ? (t: Tile) => t >= RED && t <= WHITE
762+ : (t: Tile) => t >= EAST && t < RED;
763+ const melds = s.players[winner].melds.filter((m) => m.kind !== 'chow' && inSet(m.tiles[0]));
764+ // One of the sets was still in hand, so it was not handed over.
765+ if (melds.length !== (dragons ? 3 : 4)) return null;
766+ // `from` is absent on a 暗槓 — that one they made themselves.
767+ return melds[melds.length - 1].from ?? null;
768+ }
769+
770+ /** A win waiting to be paid out. */
771+ private settle(
772+ wins: { seat: SeatId; winTile: Tile; selfDraw: boolean; robKong: boolean }[],
773+ loser: SeatId | null,
774+ ) {
775+ const s = this.state;
776+ const { base, taiValue } = s.rules;
777+ const extra = dealerTai(s.dealerStreak) * taiValue; // 拉莊, billed to the dealer alone
778+ const totalDiscards = s.players.reduce((a, q) => a + q.discards.length, 0);
779+ const anyMeld = s.players.some((q) => q.melds.length > 0);
780+ const firstGoAround = totalDiscards <= 4 && !anyMeld;
781+ const deltas = [0, 0, 0, 0];
782+ const results: WinnerResult[] = [];
783+
784+ for (const w of wins) {
785+ const p = s.players[w.seat];
786+ // For a self-draw the winning tile is already in hand; drop exactly one copy.
787+ const conc = w.selfDraw ? removeOne(p.hand, w.winTile) : p.hand.slice();
788+
789+ const score = scoreHand({
790+ winner: w.seat,
791+ dealer: s.dealer,
792+ dealerStreak: s.dealerStreak,
793+ roundWind: this.roundWind,
794+ melds: p.melds,
795+ concealed: conc,
796+ winTile: w.winTile,
797+ selfDraw: w.selfDraw,
798+ loser,
799+ flowers: p.flowers,
800+ robKong: w.robKong,
801+ afterKong: s.afterKong && w.selfDraw,
802+ lastTile: w.selfDraw && this.wallLeft <= 0,
803+ lastDiscard: !w.selfDraw && this.wallLeft <= 0,
804+ heavenly: w.selfDraw && w.seat === s.dealer && totalDiscards === 0,
805+ earthly: w.selfDraw && w.seat !== s.dealer && firstGoAround,
806+ humanly: !w.selfDraw && w.seat !== s.dealer && firstGoAround,
807+ });
808+
809+ const unit = base + score.tai * taiValue;
810+ // Who would normally be settling with this winner, and for how much.
811+ const bills: [SeatId, number][] = [];
812+ if (w.selfDraw) {
813+ for (const seat of SEATS) {
814+ if (seat === w.seat) continue;
815+ bills.push([seat, unit + (seat === s.dealer ? extra : 0)]);
816+ }
817+ } else if (loser !== null) {
818+ bills.push([loser, unit + (loser === s.dealer && w.seat !== s.dealer ? extra : 0)]);
819+ }
820+
821+ const liable = this.liableFor(w.seat, score.items);
822+ if (liable !== null && liable !== w.seat) {
823+ // 包家 settles the whole hand alone, in everyone else's place.
824+ const total = bills.reduce((a, [, n]) => a + n, 0);
825+ deltas[liable] -= total;
826+ deltas[w.seat] += total;
827+ } else {
828+ for (const [seat, n] of bills) {
829+ deltas[seat] -= n;
830+ deltas[w.seat] += n;
831+ }
832+ }
833+
834+ results.push({
835+ seat: w.seat,
836+ selfDraw: w.selfDraw,
837+ tai: score.tai,
838+ items: score.items,
839+ winTile: w.winTile,
840+ liable: liable !== null && liable !== w.seat ? liable : null,
841+ });
842+ }
843+
844+ for (const seat of SEATS) s.players[seat].score += deltas[seat];
845+
846+ s.result = { drawGame: false, winners: results, loser, deltas };
847+ s.phase = 'handEnd';
848+ for (const r of results) {
849+ this.say(`${s.players[r.seat].name} 胡牌 wins ${r.tai} 台`);
850+ if (r.liable !== null) this.say(`${s.players[r.liable].name} 包牌 pays it all`);
851+ }
852+ this.sfx('win');
853+ this.emit();
854+ }
855+
856+ private endInDraw() {
857+ const s = this.state;
858+ s.result = {
859+ drawGame: true,
860+ winners: [],
861+ loser: null,
862+ deltas: [0, 0, 0, 0],
863+ };
864+ s.phase = 'handEnd';
865+ this.say('流局 draw');
866+ this.sfx('drawGame');
867+ this.emit();
868+ }
869+
870+ /** Reset chips, dealer and round, then deal a fresh game. */
871+ newGame() {
872+ const s = this.state;
873+ clearSave();
874+ for (const p of s.players) p.score = s.rules.startingScore;
875+ s.dealer = 0;
876+ s.dealerStreak = 0;
877+ s.rotations = 0;
878+ s.handNumber = 0;
879+ s.result = null;
880+ this.startHand();
881+ }
882+
883+ /** Advance the dealer / round and deal the next hand. */
884+ nextHand() {
885+ const s = this.state;
886+ const r = s.result;
887+ if (!r) return;
888+ // 連莊 — a draw, or the dealer being among the winners, keeps the deal.
889+ const dealerKeeps = r.drawGame || r.winners.some((w) => w.seat === s.dealer);
890+ if (dealerKeeps) {
891+ s.dealerStreak++;
892+ } else {
893+ s.dealer = next(s.dealer);
894+ s.dealerStreak = 0;
895+ s.rotations++;
896+ }
897+ if (s.rotations >= 16) {
898+ s.phase = 'gameEnd';
899+ this.emit();
900+ return;
901+ }
902+ this.startHand();
903+ }
904+}
905+
906+function removeOne(tiles: Tile[], t: Tile): Tile[] {
907+ const out = tiles.slice();
908+ const i = out.indexOf(t);
909+ if (i >= 0) out.splice(i, 1);
910+ return out;
911+}
912+
913+function chowPartners(hand: Tile[], tile: Tile): Tile[][] {
914+ const has = (t: Tile) => hand.includes(t);
915+ const r = tile % 9;
916+ const out: Tile[][] = [];
917+ if (r >= 2 && has(tile - 2) && has(tile - 1)) out.push([tile - 2, tile - 1]);
918+ if (r >= 1 && r <= 7 && has(tile - 1) && has(tile + 1)) out.push([tile - 1, tile + 1]);
919+ if (r <= 6 && has(tile + 1) && has(tile + 2)) out.push([tile + 1, tile + 2]);
920+ return out;
921+}
922+
923+const CLAIM_ZH: Record<ClaimType, string> = { hu: '胡', kong: '槓', pung: '碰', chow: '吃' };
924+const CLAIM_EN: Record<ClaimType, string> = { hu: 'Win', kong: 'Kong', pung: 'Pung', chow: 'Chow' };
925+
926+export function claimName(c: ClaimType): string {
927+ return `${CLAIM_ZH[c]} ${CLAIM_EN[c]}`;
928+}
addedsrc/game/hu.ts+118 −0
1+import { NUM_BASIC, NUM_SUITED, toCounts, type Tile } from './tiles';
2+
3+export type SetType = 'chow' | 'pung';
4+
5+/** A completed set. `tile` is the pung tile, or the *lowest* tile of a chow. */
6+export interface TileSet {
7+ type: SetType;
8+ tile: Tile;
9+ concealed: boolean;
10+ kong?: boolean;
11+}
12+
13+export interface Decomposition {
14+ sets: TileSet[];
15+ pair: Tile;
16+}
17+
18+/** Every way the concealed tiles split into `needSets` sets + one pair. */
19+export function decompose(tiles: Tile[], needSets: number): Decomposition[] {
20+ const counts = toCounts(tiles);
21+ const total = counts.reduce((a, b) => a + b, 0);
22+ if (total !== needSets * 3 + 2) return [];
23+
24+ const out: Decomposition[] = [];
25+ const seen = new Set<string>();
26+
27+ for (let p = 0; p < NUM_BASIC; p++) {
28+ if (counts[p] < 2) continue;
29+ counts[p] -= 2;
30+ const found: TileSet[][] = [];
31+ splitSets(counts, needSets, [], found);
32+ counts[p] += 2;
33+ for (const sets of found) {
34+ const key = p + '|' + sets.map((s) => s.type + s.tile).sort().join(',');
35+ if (seen.has(key)) continue;
36+ seen.add(key);
37+ out.push({ sets, pair: p });
38+ }
39+ }
40+ return out;
41+}
42+
43+function splitSets(counts: number[], need: number, acc: TileSet[], out: TileSet[][]) {
44+ if (need === 0) {
45+ for (const c of counts) if (c !== 0) return;
46+ out.push(acc.slice());
47+ return;
48+ }
49+ let i = 0;
50+ while (i < NUM_BASIC && counts[i] === 0) i++;
51+ if (i === NUM_BASIC) return;
52+
53+ if (counts[i] >= 3) {
54+ counts[i] -= 3;
55+ acc.push({ type: 'pung', tile: i, concealed: true });
56+ splitSets(counts, need - 1, acc, out);
57+ acc.pop();
58+ counts[i] += 3;
59+ }
60+ if (i < NUM_SUITED && i % 9 <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) {
61+ counts[i]--;
62+ counts[i + 1]--;
63+ counts[i + 2]--;
64+ acc.push({ type: 'chow', tile: i, concealed: true });
65+ splitSets(counts, need - 1, acc, out);
66+ acc.pop();
67+ counts[i]++;
68+ counts[i + 1]++;
69+ counts[i + 2]++;
70+ }
71+}
72+
73+export function isWinningHand(tiles: Tile[], meldCount: number): boolean {
74+ return decompose(tiles, 5 - meldCount).length > 0;
75+}
76+
77+/** Tiles that would complete the hand if drawn/claimed. `tiles` is the concealed hand. */
78+export function waits(tiles: Tile[], meldCount: number): Tile[] {
79+ const need = 5 - meldCount;
80+ if (tiles.length !== need * 3 + 1) return [];
81+ const out: Tile[] = [];
82+ for (let t = 0; t < NUM_BASIC; t++) {
83+ if (decompose([...tiles, t], need).length > 0) out.push(t);
84+ }
85+ return out;
86+}
87+
88+export type WaitShape = 'pair' | 'edge' | 'closed' | 'open';
89+
90+/**
91+ * How the winning tile was waited on — used for 獨聽 / 單釣.
92+ * Only meaningful when the hand has a single wait.
93+ */
94+export function waitShape(concealedBefore: Tile[], winTile: Tile, meldCount: number): WaitShape {
95+ const need = 5 - meldCount;
96+ const decs = decompose([...concealedBefore, winTile], need);
97+ let best: WaitShape = 'open';
98+ const rank = { pair: 3, edge: 2, closed: 2, open: 1 } as const;
99+ for (const d of decs) {
100+ let shape: WaitShape | null = null;
101+ if (d.pair === winTile) {
102+ // could be 單釣 only if the pair really needed this tile
103+ const c = toCounts(concealedBefore);
104+ if (c[winTile] === 1) shape = 'pair';
105+ }
106+ if (!shape) {
107+ for (const s of d.sets) {
108+ if (s.type === 'chow') {
109+ if (s.tile === winTile && s.tile % 9 === 6) shape = 'edge'; // 7-8 waiting 9? (789 low=7)
110+ else if (s.tile + 2 === winTile && s.tile % 9 === 0) shape = 'edge'; // 1-2 waiting 3
111+ else if (s.tile + 1 === winTile) shape = 'closed';
112+ }
113+ }
114+ }
115+ if (shape && rank[shape] > rank[best]) best = shape;
116+ }
117+ return best;
118+}
addedsrc/game/prefs.ts+52 −0
1+import { DEFAULT_NAMES, DEFAULT_RULES, type Rules } from './types';
2+
3+const KEY = 'taiwanese-mahjong/prefs';
4+
5+/**
6+ * Preferences belong to the table, not to a hand: they outlive a save, survive
7+ * 開新局, and are what a fresh `Game` is built from.
8+ */
9+export interface Prefs {
10+ /** Master mute. Off by default — a shared screen should not surprise a room. */
11+ sound: boolean;
12+ /** 0..1 */
13+ volume: number;
14+ names: string[];
15+ rules: Rules;
16+}
17+
18+export const DEFAULT_PREFS: Prefs = {
19+ sound: false,
20+ volume: 0.7,
21+ names: DEFAULT_NAMES,
22+ rules: DEFAULT_RULES,
23+};
24+
25+export function loadPrefs(): Prefs {
26+ try {
27+ const raw = localStorage.getItem(KEY);
28+ if (!raw) return DEFAULT_PREFS;
29+ const p = JSON.parse(raw) as Partial<Prefs>;
30+ return {
31+ sound: typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound,
32+ volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1),
33+ names:
34+ Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string')
35+ ? p.names
36+ : DEFAULT_NAMES,
37+ rules: { ...DEFAULT_RULES, ...(p.rules ?? {}) },
38+ };
39+ } catch {
40+ return DEFAULT_PREFS;
41+ }
42+}
43+
44+export function savePrefs(p: Prefs) {
45+ try {
46+ localStorage.setItem(KEY, JSON.stringify(p));
47+ } catch {
48+ // Storage unavailable — the settings just don't outlive the tab.
49+ }
50+}
51+
52+export const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
addedsrc/game/save.ts+65 −0
1+import type { GameState } from './engine';
2+import { NUM_BASIC, WALL_SIZE } from './tiles';
3+
4+const KEY = 'taiwanese-mahjong/save';
5+/** Bump when GameState changes shape; older saves are then ignored rather than crashing. */
6+const VERSION = 2;
7+
8+export interface SavedGame {
9+ version: number;
10+ savedAt: number;
11+ state: GameState;
12+}
13+
14+/**
15+ * The game state is plain data, so a save is just its JSON. Written after every
16+ * move, so a closed lid or a stray refresh costs nothing.
17+ */
18+export function saveGame(state: GameState) {
19+ if (state.phase === 'lobby') return;
20+ try {
21+ const payload: SavedGame = { version: VERSION, savedAt: Date.now(), state };
22+ localStorage.setItem(KEY, JSON.stringify(payload));
23+ } catch {
24+ // Storage unavailable or full — play carries on unsaved rather than dying.
25+ }
26+}
27+
28+export function loadSave(): SavedGame | null {
29+ try {
30+ const raw = localStorage.getItem(KEY);
31+ if (!raw) return null;
32+ const parsed = JSON.parse(raw) as SavedGame;
33+ if (parsed?.version !== VERSION) return null;
34+ if (!isPlausible(parsed.state)) return null;
35+ return parsed;
36+ } catch {
37+ return null;
38+ }
39+}
40+
41+export function clearSave() {
42+ try {
43+ localStorage.removeItem(KEY);
44+ } catch {
45+ // nothing to do
46+ }
47+}
48+
49+/** Guards against a truncated or hand-edited save putting the table in a bad state. */
50+function isPlausible(s: GameState | undefined): boolean {
51+ if (!s || !Array.isArray(s.players) || s.players.length !== 4) return false;
52+ if (!Array.isArray(s.wall) || typeof s.phase !== 'string') return false;
53+ if (s.phase === 'lobby') return false;
54+ if (!Array.isArray(s.sacred) || s.sacred.length !== 4) return false;
55+ if (!s.rules || typeof s.rules.base !== 'number') return false;
56+
57+ let tiles = s.wall.length;
58+ for (const p of s.players) {
59+ if (!p || !Array.isArray(p.hand) || !Array.isArray(p.melds)) return false;
60+ tiles += p.hand.length + p.flowers.length + p.discards.length;
61+ for (const m of p.melds) tiles += m.tiles.length;
62+ }
63+ if (tiles !== WALL_SIZE) return false;
64+ return s.players.every((p) => p.hand.every((t) => t >= 0 && t < NUM_BASIC + 8));
65+}
addedsrc/game/sound.ts+168 −0
1+import type { SoundEvent } from './engine';
2+
3+/**
4+ * Table noise, synthesised rather than sampled — a handful of oscillators is
5+ * smaller than one .wav and needs no loading, which matters when the whole
6+ * point is that the cue lands the instant the tile hits the table.
7+ *
8+ * Nothing here touches the audio hardware until `play` is first called, so the
9+ * module is safe to import in tests and safe to load before the user has
10+ * gestured at the page (browsers refuse to start audio before that).
11+ */
12+
13+let ctx: AudioContext | null = null;
14+let master: GainNode | null = null;
15+let noise: AudioBuffer | null = null;
16+let enabled = false;
17+let volume = 0.7;
18+
19+function audio(): AudioContext | null {
20+ if (typeof window === 'undefined') return null;
21+ if (!ctx) {
22+ const Ctor = window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
23+ if (!Ctor) return null;
24+ try {
25+ ctx = new Ctor();
26+ } catch {
27+ return null;
28+ }
29+ master = ctx.createGain();
30+ master.gain.value = volume;
31+ master.connect(ctx.destination);
32+ }
33+ // Chrome parks the context until a gesture, and again when the tab sleeps.
34+ if (ctx.state === 'suspended') void ctx.resume();
35+ return ctx;
36+}
37+
38+/** One second of white noise, reused by every clack. */
39+function noiseBuffer(c: AudioContext): AudioBuffer {
40+ if (!noise) {
41+ noise = c.createBuffer(1, c.sampleRate, c.sampleRate);
42+ const d = noise.getChannelData(0);
43+ for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
44+ }
45+ return noise;
46+}
47+
48+export function setSoundEnabled(on: boolean) {
49+ enabled = on;
50+ if (on) audio(); // unlock now, while we are still inside the click that toggled it
51+}
52+
53+export function setVolume(v: number) {
54+ volume = v;
55+ if (master && ctx) master.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
56+}
57+
58+/** Envelope helper: a tone that starts at `gain` and decays away over `dur`. */
59+function tone(
60+ c: AudioContext,
61+ opts: {
62+ freq: number;
63+ at: number;
64+ dur: number;
65+ gain: number;
66+ type?: OscillatorType;
67+ /** Slide to this frequency over the life of the note. */
68+ to?: number;
69+ },
70+) {
71+ const o = c.createOscillator();
72+ const g = c.createGain();
73+ o.type = opts.type ?? 'sine';
74+ const t = c.currentTime + opts.at;
75+ o.frequency.setValueAtTime(opts.freq, t);
76+ if (opts.to !== undefined) o.frequency.exponentialRampToValueAtTime(opts.to, t + opts.dur);
77+ g.gain.setValueAtTime(0.0001, t);
78+ g.gain.exponentialRampToValueAtTime(opts.gain, t + 0.004);
79+ g.gain.exponentialRampToValueAtTime(0.0001, t + opts.dur);
80+ o.connect(g).connect(master!);
81+ o.start(t);
82+ o.stop(t + opts.dur + 0.02);
83+}
84+
85+/** Bamboo-on-bamboo: a filtered noise burst with a short woody body under it. */
86+function clack(c: AudioContext, at: number, gain = 1) {
87+ const t = c.currentTime + at;
88+ const src = c.createBufferSource();
89+ src.buffer = noiseBuffer(c);
90+ src.playbackRate.value = 1;
91+ const bp = c.createBiquadFilter();
92+ bp.type = 'bandpass';
93+ bp.frequency.value = 2100;
94+ bp.Q.value = 1.1;
95+ const g = c.createGain();
96+ g.gain.setValueAtTime(0.55 * gain, t);
97+ g.gain.exponentialRampToValueAtTime(0.0001, t + 0.075);
98+ src.connect(bp).connect(g).connect(master!);
99+ src.start(t, Math.random() * 0.5);
100+ src.stop(t + 0.1);
101+ tone(c, { freq: 320, to: 160, at, dur: 0.09, gain: 0.28 * gain, type: 'triangle' });
102+}
103+
104+/** Struck-metal chime: a fundamental with two inharmonic partials over it. */
105+function bell(c: AudioContext, freq: number, at: number, gain = 1, dur = 0.55) {
106+ tone(c, { freq, at, dur, gain: 0.3 * gain, type: 'sine' });
107+ tone(c, { freq: freq * 2.02, at, dur: dur * 0.7, gain: 0.13 * gain, type: 'sine' });
108+ tone(c, { freq: freq * 3.01, at, dur: dur * 0.4, gain: 0.06 * gain, type: 'sine' });
109+}
110+
111+/**
112+ * Each cue has its own shape so it is identifiable without looking up — the
113+ * whole reason for the feature is a player noticing across the table.
114+ */
115+export function play(e: SoundEvent) {
116+ if (!enabled) return;
117+ const c = audio();
118+ if (!c || !master) return;
119+
120+ switch (e) {
121+ // A tile laid down. Dry and short so it never masks the chime after it.
122+ case 'discard':
123+ clack(c, 0);
124+ break;
125+ // Somebody's claim window is open. Rising two-note bell, easy to hear over
126+ // talk, and deliberately unlike the clack that precedes it.
127+ case 'claimWindow':
128+ bell(c, 784, 0.06, 1, 0.4); // G5
129+ bell(c, 1047, 0.19, 0.9, 0.55); // C6
130+ break;
131+ case 'claim':
132+ clack(c, 0, 0.8);
133+ clack(c, 0.055, 0.9);
134+ bell(c, 660, 0.02, 0.5, 0.3);
135+ break;
136+ // Four tiles going down, plus a replacement off the back of the wall.
137+ case 'kong':
138+ clack(c, 0, 0.7);
139+ clack(c, 0.05, 0.8);
140+ clack(c, 0.1, 0.9);
141+ clack(c, 0.15, 1);
142+ bell(c, 523, 0.16, 0.55, 0.5);
143+ break;
144+ case 'flower':
145+ bell(c, 1319, 0, 0.45, 0.35);
146+ break;
147+ // 胡牌 — a rising figure, the only cue that takes its time.
148+ case 'win':
149+ bell(c, 523, 0, 1, 0.5);
150+ bell(c, 659, 0.11, 1, 0.5);
151+ bell(c, 784, 0.22, 1, 0.6);
152+ bell(c, 1047, 0.33, 1.1, 1.1);
153+ break;
154+ // 流局 — the same figure, falling and dulled.
155+ case 'drawGame':
156+ bell(c, 523, 0, 0.7, 0.5);
157+ bell(c, 415, 0.13, 0.7, 0.6);
158+ bell(c, 330, 0.26, 0.7, 0.9);
159+ break;
160+ // Sixteen tiles apiece, so a scatter rather than a beat.
161+ case 'deal':
162+ for (let i = 0; i < 9; i++) clack(c, i * 0.045 + Math.random() * 0.02, 0.4);
163+ break;
164+ case 'undo':
165+ tone(c, { freq: 700, to: 330, at: 0, dur: 0.22, gain: 0.18, type: 'triangle' });
166+ break;
167+ }
168+}
addedsrc/game/tai.ts+209 −0
1+import { decompose, waits, waitShape, type Decomposition, type TileSet } from './hu';
2+import {
3+ EAST,
4+ NUM_SUITED,
5+ RED,
6+ WHITE,
7+ flowerSeat,
8+ flowerSet,
9+ isHonor,
10+ isSuited,
11+ suitOf,
12+ type Tile,
13+} from './tiles';
14+import { meldIsConcealed, meldIsKong, type Meld, type SeatId } from './types';
15+
16+export interface WinContext {
17+ winner: SeatId;
18+ dealer: SeatId;
19+ /** 連莊 count: 0 on a fresh dealer, 1 after one repeat, ... */
20+ dealerStreak: number;
21+ /** 圈風 0=東 1=南 2=西 3=北 */
22+ roundWind: number;
23+ melds: Meld[];
24+ /** Concealed tiles, NOT including the winning tile. */
25+ concealed: Tile[];
26+ winTile: Tile;
27+ selfDraw: boolean;
28+ /** Discarder, when won off a discard. */
29+ loser: SeatId | null;
30+ flowers: Tile[];
31+ robKong: boolean;
32+ afterKong: boolean;
33+ lastTile: boolean;
34+ lastDiscard: boolean;
35+ heavenly: boolean;
36+ earthly: boolean;
37+ humanly: boolean;
38+}
39+
40+export interface TaiItem {
41+ /** The term as it is called at the table. */
42+ name: string;
43+ /** Plain-English gloss for players still learning the terms. */
44+ en: string;
45+ tai: number;
46+}
47+
48+export interface TaiResult {
49+ tai: number;
50+ items: TaiItem[];
51+}
52+
53+/** Score the hand, taking the highest-value interpretation of ambiguous hands. */
54+export function scoreHand(ctx: WinContext): TaiResult {
55+ const need = 5 - ctx.melds.length;
56+ const decs = decompose([...ctx.concealed, ctx.winTile], need);
57+ if (decs.length === 0) return { tai: 0, items: [] };
58+
59+ let best: TaiResult = { tai: -1, items: [] };
60+ for (const d of decs) {
61+ const r = scoreDecomposition(ctx, d);
62+ if (r.tai > best.tai) best = r;
63+ }
64+ return best;
65+}
66+
67+function seatWindOf(seat: SeatId, dealer: SeatId): number {
68+ return (seat - dealer + 4) % 4;
69+}
70+
71+function scoreDecomposition(ctx: WinContext, d: Decomposition): TaiResult {
72+ const items: TaiItem[] = [];
73+ const add = (name: string, en: string, tai: number) => {
74+ if (tai !== 0) items.push({ name, en, tai });
75+ };
76+
77+ // ---- assemble the five sets -------------------------------------------
78+ const sets: TileSet[] = ctx.melds.map((m) => ({
79+ type: m.kind === 'chow' ? 'chow' : 'pung',
80+ tile: m.kind === 'chow' ? Math.min(...m.tiles) : m.tiles[0],
81+ concealed: meldIsConcealed(m),
82+ kong: meldIsKong(m),
83+ }));
84+ for (const s of d.sets) sets.push({ ...s });
85+
86+ // A set completed by someone else's discard is not concealed.
87+ if (!ctx.selfDraw) {
88+ const i = sets.findIndex(
89+ (s) =>
90+ s.concealed &&
91+ !s.kong &&
92+ (s.type === 'pung'
93+ ? s.tile === ctx.winTile
94+ : ctx.winTile >= s.tile && ctx.winTile <= s.tile + 2),
95+ );
96+ if (i >= 0) sets[i].concealed = false;
97+ }
98+
99+ const exposedCount = ctx.melds.filter((m) => !meldIsConcealed(m)).length;
100+ const fullyConcealed = exposedCount === 0; // 門清 (暗槓 still counts)
101+ const seatWind = seatWindOf(ctx.winner, ctx.dealer);
102+
103+ const allTiles: Tile[] = [];
104+ for (const m of ctx.melds) allTiles.push(...m.tiles);
105+ allTiles.push(...ctx.concealed, ctx.winTile);
106+
107+ // ---- flowers ----------------------------------------------------------
108+ if (ctx.flowers.length === 8) {
109+ add('八仙過海', 'All eight flowers', 8);
110+ } else {
111+ const own = ctx.flowers.filter((f) => flowerSeat(f) === seatWind).length;
112+ if (own) add(`正花 ×${own}`, `Seat flowers ×${own}`, own);
113+ for (const set of [0, 1]) {
114+ if (ctx.flowers.filter((f) => flowerSet(f) === set).length === 4) {
115+ add(set === 0 ? '花槓 (四季)' : '花槓 (四花)', set === 0 ? 'Four seasons' : 'Four flowers', 2);
116+ }
117+ }
118+ }
119+
120+ // ---- honor sets -------------------------------------------------------
121+ const pungs = sets.filter((s) => s.type === 'pung');
122+ const dragonPungs = pungs.filter((s) => s.tile >= RED && s.tile <= WHITE).length;
123+ const windPungs = pungs.filter((s) => s.tile >= EAST && s.tile < RED);
124+ const dragonPair = d.pair >= RED && d.pair <= WHITE;
125+ const windPair = d.pair >= EAST && d.pair < RED;
126+
127+ if (dragonPungs === 3) add('大三元', 'Big three dragons', 8);
128+ else if (dragonPungs === 2 && dragonPair) add('小三元', 'Little three dragons', 4);
129+ else if (dragonPungs > 0) add(`三元牌 ×${dragonPungs}`, `Dragon pung ×${dragonPungs}`, dragonPungs);
130+
131+ const windPungCount = windPungs.length;
132+ if (windPungCount === 4) add('大四喜', 'Big four winds', 16);
133+ else if (windPungCount === 3 && windPair) add('小四喜', 'Little four winds', 8);
134+ else {
135+ if (windPungs.some((s) => s.tile - EAST === ctx.roundWind)) add('圈風', 'Round wind', 1);
136+ if (windPungs.some((s) => s.tile - EAST === seatWind)) add('門風', 'Seat wind', 1);
137+ }
138+
139+ // ---- shape ------------------------------------------------------------
140+ const allPungs = sets.every((s) => s.type === 'pung');
141+ const allChows = sets.every((s) => s.type === 'chow');
142+ if (allPungs) add('碰碰胡', 'All pungs', 4);
143+
144+ const suits = new Set(allTiles.filter(isSuited).map(suitOf));
145+ const hasHonor = allTiles.some(isHonor);
146+ if (!allTiles.some(isSuited)) add('字一色', 'All honors', 16);
147+ else if (suits.size === 1)
148+ add(hasHonor ? '混一色' : '清一色', hasHonor ? 'Half flush' : 'Full flush', hasHonor ? 4 : 8);
149+
150+ const kinds = new Set(allTiles.map(suitOf));
151+ if (kinds.has('m') && kinds.has('s') && kinds.has('p') && kinds.has('wind') && kinds.has('dragon')) {
152+ add('五門齊', 'All five types', 2);
153+ }
154+
155+ const concealedPungs = sets.filter((s) => s.type === 'pung' && s.concealed).length;
156+ if (concealedPungs === 5) add('五暗刻', 'Five concealed pungs', 8);
157+ else if (concealedPungs === 4) add('四暗刻', 'Four concealed pungs', 5);
158+ else if (concealedPungs === 3) add('三暗刻', 'Three concealed pungs', 2);
159+
160+ const pairIsValuable =
161+ dragonPair || (windPair && (d.pair - EAST === ctx.roundWind || d.pair - EAST === seatWind));
162+ if (allChows && !pairIsValuable && !ctx.selfDraw && !hasHonorSet(sets)) add('平胡', 'All chows', 2);
163+
164+ // ---- how it was won ---------------------------------------------------
165+ if (ctx.heavenly) add('天胡', 'Blessing of heaven', 16);
166+ else if (ctx.earthly) add('地胡', 'Blessing of earth', 16);
167+ else if (ctx.humanly) add('人胡', 'Blessing of man', 8);
168+
169+ if (ctx.selfDraw) add('自摸', 'Self-draw', 1);
170+ if (fullyConcealed) add('門清', 'Fully concealed', 1);
171+ if (fullyConcealed && ctx.selfDraw) add('門清自摸', 'Concealed self-draw', 1);
172+ if (!ctx.selfDraw && exposedCount === 5) add('全求人', 'All melded', 2);
173+
174+ if (ctx.robKong) add('搶槓', 'Robbing the kong', 1);
175+ if (ctx.afterKong) add('槓上開花', 'Win off a kong', 1);
176+ if (ctx.lastTile) add('海底撈月', 'Last tile of the wall', 1);
177+ if (ctx.lastDiscard) add('河底撈魚', 'Last discard', 1);
178+
179+ const w = waits(ctx.concealed, ctx.melds.length);
180+ if (w.length === 1) {
181+ const shape = waitShape(ctx.concealed, ctx.winTile, ctx.melds.length);
182+ add(shape === 'pair' ? '單釣' : '獨聽', shape === 'pair' ? 'Single pair wait' : 'Sole wait', 1);
183+ }
184+
185+ // ---- dealer -----------------------------------------------------------
186+ // 莊家 tai only applies when the dealer is the winner; when the dealer is a
187+ // payer the same amount is billed to the dealer alone (see settleHand).
188+ if (ctx.winner === ctx.dealer)
189+ add(dealerLabel(ctx.dealerStreak), dealerLabelEn(ctx.dealerStreak), dealerTai(ctx.dealerStreak));
190+
191+ return { tai: items.reduce((a, i) => a + i.tai, 0), items };
192+}
193+
194+function hasHonorSet(sets: TileSet[]): boolean {
195+ return sets.some((s) => s.tile >= NUM_SUITED);
196+}
197+
198+/** 莊 1台, 連N拉N → 2N+1台. */
199+export function dealerTai(streak: number): number {
200+ return 1 + 2 * streak;
201+}
202+
203+export function dealerLabel(streak: number): string {
204+ return streak > 0 ? `莊家 連${streak}拉${streak}` : '莊家';
205+}
206+
207+export function dealerLabelEn(streak: number): string {
208+ return streak > 0 ? `Dealer, ${streak} in a row` : 'Dealer';
209+}
addedsrc/game/tiles.ts+130 −0
1+// Tile codes 0..41
2+// 0..8 萬 (characters) 1-9
3+// 9..17 條 (bamboo) 1-9
4+// 18..26 筒 (dots) 1-9
5+// 27..30 winds 東 南 西 北
6+// 31..33 dragons 中 發 白
7+// 34..37 seasons 春 夏 秋 冬 (seat 0..3)
8+// 38..41 flowers 梅 蘭 菊 竹 (seat 0..3)
9+
10+export type Tile = number;
11+
12+export const NUM_SUITED = 27;
13+export const NUM_BASIC = 34; // everything except flowers
14+export const FIRST_FLOWER = 34;
15+export const NUM_TILES = 42;
16+/** Tiles in a full wall: 34 basic × 4 + 8 flowers. */
17+export const WALL_SIZE = 144;
18+/** Stacks of two, 18 to a side of the square. */
19+export const WALL_STACKS = WALL_SIZE / 2;
20+export const STACKS_PER_SIDE = WALL_STACKS / 4;
21+
22+export const EAST = 27;
23+export const SOUTH = 28;
24+export const WEST = 29;
25+export const NORTH = 30;
26+export const RED = 31;
27+export const GREEN = 32;
28+export const WHITE = 33;
29+
30+export type SuitKind = 'm' | 's' | 'p' | 'wind' | 'dragon' | 'flower';
31+
32+export function suitOf(t: Tile): SuitKind {
33+ if (t < 9) return 'm';
34+ if (t < 18) return 's';
35+ if (t < 27) return 'p';
36+ if (t < 31) return 'wind';
37+ if (t < 34) return 'dragon';
38+ return 'flower';
39+}
40+
41+export const isFlower = (t: Tile) => t >= FIRST_FLOWER;
42+export const isHonor = (t: Tile) => t >= NUM_SUITED && t < NUM_BASIC;
43+export const isSuited = (t: Tile) => t < NUM_SUITED;
44+/** 1..9 for suited tiles, 0 otherwise */
45+export const rankOf = (t: Tile) => (isSuited(t) ? (t % 9) + 1 : 0);
46+export const isTerminal = (t: Tile) => isSuited(t) && (rankOf(t) === 1 || rankOf(t) === 9);
47+
48+/** The seat (0..3) a flower belongs to. */
49+export const flowerSeat = (t: Tile) => (t - FIRST_FLOWER) % 4;
50+/** 0 = 春夏秋冬 set, 1 = 梅蘭菊竹 set */
51+export const flowerSet = (t: Tile) => (t - FIRST_FLOWER < 4 ? 0 : 1);
52+
53+const DIGITS = ['一', '二', '三', '四', '五', '六', '七', '八', '九'];
54+const HONORS = ['東', '南', '西', '北', '中', '發', '白'];
55+const FLOWERS = ['春', '夏', '秋', '冬', '梅', '蘭', '菊', '竹'];
56+const SUIT_MARK = { m: '萬', s: '條', p: '筒' } as const;
57+
58+/** Big glyph shown on the tile face. */
59+export function faceOf(t: Tile): { main: string; sub?: string } {
60+ const s = suitOf(t);
61+ if (s === 'm' || s === 's' || s === 'p') {
62+ return { main: DIGITS[rankOf(t) - 1], sub: SUIT_MARK[s] };
63+ }
64+ if (s === 'flower') return { main: FLOWERS[t - FIRST_FLOWER] };
65+ return { main: HONORS[t - NUM_SUITED] };
66+}
67+
68+/** Short text label, e.g. "五萬", "東", "春". */
69+export function labelOf(t: Tile): string {
70+ const f = faceOf(t);
71+ return f.main + (f.sub ?? '');
72+}
73+
74+export const WIND_NAMES = ['東', '南', '西', '北'];
75+export const WIND_EN = ['East', 'South', 'West', 'North'];
76+
77+/**
78+ * Column of each tile in the sprite sheet (gnome-mahjongg's "postmodern"
79+ * theme, 43 columns × 2 rows; the second row is the highlighted variant).
80+ * Its order is dots, winds, dragons, characters, bamboo, seasons, flowers —
81+ * not ours, so this is the lookup between the two.
82+ */
83+export const SPRITE_COL: number[] = [
84+ 15, 16, 17, 18, 19, 20, 21, 22, 23, // 萬 1-9
85+ 24, 25, 26, 27, 28, 29, 30, 31, 32, // 條 1-9
86+ 0, 1, 2, 3, 4, 5, 6, 7, 8, // 筒 1-9
87+ 12, 11, 10, 9, // 東 南 西 北
88+ 13, 14, 37, // 中 發 白
89+ 33, 34, 35, 36, // 春 夏 秋 冬
90+ 38, 39, 40, 41, // 梅 蘭 菊 竹
91+];
92+export const SPRITE_COLS = 43;
93+
94+/** A full Taiwanese wall: 4× each basic tile + 1× each flower = 144. */
95+export function buildWall(): Tile[] {
96+ const w: Tile[] = [];
97+ for (let t = 0; t < NUM_BASIC; t++) for (let i = 0; i < 4; i++) w.push(t);
98+ for (let t = FIRST_FLOWER; t < NUM_TILES; t++) w.push(t);
99+ return w;
100+}
101+
102+/** mulberry32 — small deterministic PRNG so hands can be replayed from a seed. */
103+export function makeRng(seed: number) {
104+ let a = seed >>> 0;
105+ return () => {
106+ a = (a + 0x6d2b79f5) | 0;
107+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
108+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
109+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
110+ };
111+}
112+
113+export function shuffle<T>(arr: T[], rng: () => number): T[] {
114+ const a = arr.slice();
115+ for (let i = a.length - 1; i > 0; i--) {
116+ const j = Math.floor(rng() * (i + 1));
117+ [a[i], a[j]] = [a[j], a[i]];
118+ }
119+ return a;
120+}
121+
122+export function sortTiles(tiles: Tile[]): Tile[] {
123+ return tiles.slice().sort((a, b) => a - b);
124+}
125+
126+export function toCounts(tiles: Tile[]): number[] {
127+ const c = new Array(NUM_BASIC).fill(0);
128+ for (const t of tiles) if (t < NUM_BASIC) c[t]++;
129+ return c;
130+}
addedsrc/game/types.ts+72 −0
1+import type { Tile } from './tiles';
2+
3+export type SeatId = 0 | 1 | 2 | 3;
4+
5+/** Placeholder names, until somebody types their own on the settings screen. */
6+export const DEFAULT_NAMES = ['玩家 1', '玩家 2', '玩家 3', '玩家 4'];
7+
8+export type MeldKind =
9+ | 'chow' // 吃
10+ | 'pung' // 碰
11+ | 'kong' // 明槓 (claimed from a discard)
12+ | 'ankong' // 暗槓 (four from hand)
13+ | 'addkong'; // 加槓 (fourth tile added to an exposed pung)
14+
15+export interface Meld {
16+ kind: MeldKind;
17+ /** Sorted tiles making up the meld. */
18+ tiles: Tile[];
19+ /** Whose discard it came from, if any. */
20+ from?: SeatId;
21+}
22+
23+export const meldIsConcealed = (m: Meld) => m.kind === 'ankong';
24+export const meldIsKong = (m: Meld) => m.kind === 'kong' || m.kind === 'ankong' || m.kind === 'addkong';
25+
26+export interface Player {
27+ name: string;
28+ hand: Tile[]; // concealed, sorted
29+ melds: Meld[];
30+ flowers: Tile[];
31+ discards: Tile[];
32+ score: number;
33+}
34+
35+export interface Rules {
36+ /** 底 — flat amount every payer contributes. */
37+ base: number;
38+ /** 台 — value of one tai. */
39+ taiValue: number;
40+ /** Tiles left in the wall when the hand is declared a draw (流局). */
41+ wallReserve: number;
42+ /** Starting chips per player. */
43+ startingScore: number;
44+ /**
45+ * 過水 — pass on a tile you could have won on and it is dead to you until
46+ * your own next draw.
47+ */
48+ sacredDiscard: boolean;
49+ /**
50+ * Whether taking a 碰/吃/槓 also lifts a 過水 lock, or only drawing does.
51+ * Tables genuinely differ, hence the flag.
52+ */
53+ sacredClearedByClaim: boolean;
54+ /** 一炮多響 — one discard pays out to every seat that declares on it. */
55+ multipleWinners: boolean;
56+ /**
57+ * 包牌 — feed the third dragon or wind pung to an obvious 大三元 / 大四喜
58+ * and you answer for the whole hand yourself.
59+ */
60+ liability: boolean;
61+}
62+
63+export const DEFAULT_RULES: Rules = {
64+ base: 3,
65+ taiValue: 1,
66+ wallReserve: 16,
67+ startingScore: 100,
68+ sacredDiscard: true,
69+ sacredClearedByClaim: false,
70+ multipleWinners: false,
71+ liability: true,
72+};
addedsrc/main.tsx+9 −0
1+import { StrictMode } from 'react'
2+import { createRoot } from 'react-dom/client'
3+import App from './App.tsx'
4+
5+createRoot(document.getElementById('root')!).render(
6+ <StrictMode>
7+ <App />
8+ </StrictMode>,
9+)
addedsrc/styles.css+1164 −0
1+@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;600;900&display=swap');
2+
3+:root {
4+ --depth: clamp(232px, 30vh, 330px);
5+ --h-len: calc(100vw - 2 * var(--depth));
6+ --v-len: 100vh;
7+
8+ --felt: #0f4534;
9+ --felt-2: #0a3126;
10+ --ivory: #f6f0e2;
11+ --ivory-edge: #d9cfb6;
12+ --ink: #21262b;
13+ --gold: #d9b45c;
14+ --red: #b3261e;
15+ --green: #1c7a4b;
16+ --blue: #1d5aa8;
17+}
18+
19+* {
20+ box-sizing: border-box;
21+ -webkit-tap-highlight-color: transparent;
22+}
23+
24+html,
25+body,
26+#root {
27+ height: 100%;
28+ margin: 0;
29+ overflow: hidden;
30+ overscroll-behavior: none;
31+ touch-action: manipulation;
32+ user-select: none;
33+}
34+
35+body {
36+ font-family: 'Noto Sans TC', system-ui, sans-serif;
37+ color: var(--ivory);
38+ background: var(--felt-2);
39+}
40+
41+/* ---------- lobby ---------- */
42+.lobby {
43+ height: 100%;
44+ display: flex;
45+ flex-direction: column;
46+ align-items: center;
47+ justify-content: center;
48+ gap: 0.5rem;
49+ background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-2) 75%);
50+}
51+.lobby h1 {
52+ font-size: clamp(3rem, 9vw, 7rem);
53+ font-weight: 900;
54+ letter-spacing: 0.1em;
55+ margin: 0 0 0.5rem;
56+ color: var(--gold);
57+}
58+.lobby p {
59+ margin: 0;
60+ opacity: 0.75;
61+ letter-spacing: 0.15em;
62+}
63+.lobby .hint {
64+ opacity: 0.45;
65+ font-size: 0.9rem;
66+ margin-bottom: 1.5rem;
67+}
68+
69+/* ---------- table ---------- */
70+.table {
71+ position: relative;
72+ height: 100%;
73+ width: 100%;
74+ background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-2) 80%);
75+}
76+
77+.slot {
78+ position: absolute;
79+ overflow: hidden;
80+}
81+.slot-bottom {
82+ bottom: 0;
83+ left: var(--depth);
84+ right: var(--depth);
85+ height: var(--depth);
86+}
87+.slot-top {
88+ top: 0;
89+ left: var(--depth);
90+ right: var(--depth);
91+ height: var(--depth);
92+}
93+.slot-left {
94+ left: 0;
95+ top: 0;
96+ width: var(--depth);
97+ height: 100%;
98+}
99+.slot-right {
100+ right: 0;
101+ top: 0;
102+ width: var(--depth);
103+ height: 100%;
104+}
105+
106+.seat {
107+ position: absolute;
108+ top: 50%;
109+ left: 50%;
110+ height: var(--depth);
111+ display: flex;
112+ flex-direction: column;
113+ justify-content: flex-end;
114+ align-items: center;
115+ gap: 2px;
116+ padding: 2px 8px 4px;
117+ border-radius: 10px;
118+ overflow: hidden;
119+ transition: background 0.2s;
120+}
121+.slot-bottom .seat,
122+.slot-top .seat {
123+ width: var(--h-len);
124+ --tile-w: min(calc((var(--h-len) - 40px) / 18), 72px);
125+}
126+.slot-left .seat,
127+.slot-right .seat {
128+ width: var(--v-len);
129+ --tile-w: min(calc((var(--v-len) - 40px) / 18), 72px);
130+}
131+.seat.active {
132+ background: linear-gradient(to top, rgba(217, 180, 92, 0.22), transparent 70%);
133+}
134+.seat.claiming {
135+ background: linear-gradient(to top, rgba(179, 38, 30, 0.25), transparent 70%);
136+ animation: pulse 1.1s ease-in-out infinite;
137+}
138+@keyframes pulse {
139+ 50% {
140+ background: linear-gradient(to top, rgba(179, 38, 30, 0.45), transparent 70%);
141+ }
142+}
143+
144+/* ---------- tiles ---------- */
145+/* Faces come from a 43x2 sprite sheet; the second row is the highlighted
146+ variant, which we use for the tile a player has lifted to discard. */
147+.tile {
148+ position: relative;
149+ flex: none;
150+ --tw: var(--tile-w);
151+ width: var(--tw);
152+ height: calc(var(--tw) * 1.375);
153+ border-radius: calc(var(--tw) * 0.1);
154+ transition: transform 0.12s ease, filter 0.12s ease;
155+}
156+.tile.t-meld {
157+ --tw: calc(var(--tile-w) * 0.62);
158+}
159+.tile.t-small {
160+ --tw: calc(var(--tile-w) * 0.42);
161+}
162+.tile.face {
163+ background-image: url('/tiles/tiles.svg');
164+ background-repeat: no-repeat;
165+ background-size: calc(var(--tw) * 43) calc(var(--tw) * 2.75);
166+ background-position: calc(var(--col) * var(--tw) * -1) 0;
167+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45));
168+}
169+.tile.back {
170+ background-image: url('/tiles/back.png');
171+ background-size: 100% 100%;
172+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45));
173+}
174+.tile.tappable {
175+ cursor: pointer;
176+}
177+.tile.raised {
178+ transform: translateY(calc(var(--tw) * -0.28));
179+ filter: drop-shadow(0 10px 12px rgba(0, 0, 0, 0.55));
180+}
181+/* Lifted tiles switch to the sheet's highlighted row. */
182+.tile.face.raised {
183+ background-position: calc(var(--col) * var(--tw) * -1) calc(var(--tw) * -1.375);
184+}
185+.tile.hl {
186+ outline: 2px solid var(--gold);
187+ outline-offset: 1px;
188+ border-radius: calc(var(--tw) * 0.1);
189+}
190+.tile.dim {
191+ opacity: 0.45;
192+}
193+
194+/* ---------- seat sections ---------- */
195+.hand {
196+ display: flex;
197+ gap: 2px;
198+ /* the extra top room is where the 摸 tag sits */
199+ height: calc(var(--tile-w) * 1.71 + 6px);
200+ padding-top: calc(var(--tile-w) * 0.34);
201+ align-items: flex-end;
202+ flex: none;
203+}
204+/* Every hand tile sits in a draggable cell. */
205+.hand-cell {
206+ position: relative;
207+ display: flex;
208+ align-items: flex-end;
209+ touch-action: none;
210+ cursor: grab;
211+}
212+.hand-cell.dragging {
213+ z-index: 5;
214+ cursor: grabbing;
215+}
216+.hand-cell.dragging .tile {
217+ transform: translateY(calc(var(--tile-w) * -0.2)) scale(1.06);
218+ filter: drop-shadow(0 14px 18px rgba(0, 0, 0, 0.65));
219+}
220+.drawn-slot {
221+ margin-left: calc(var(--tile-w) * 0.45);
222+}
223+.drawn-slot .tile {
224+ outline: 2px solid var(--gold);
225+ outline-offset: 1px;
226+ border-radius: calc(var(--tw) * 0.1);
227+}
228+.drawn-tag {
229+ position: absolute;
230+ top: calc(var(--tile-w) * -0.3);
231+ left: 0;
232+ right: 0;
233+ text-align: center;
234+ font-size: calc(var(--tile-w) * 0.24);
235+ font-weight: 600;
236+ letter-spacing: 0.1em;
237+ color: var(--gold);
238+ white-space: nowrap;
239+ pointer-events: none;
240+}
241+
242+.melds {
243+ display: flex;
244+ gap: 10px;
245+ flex-wrap: wrap;
246+ justify-content: center;
247+ min-height: calc(var(--tile-w) * 0.86);
248+ flex: none;
249+}
250+.meld {
251+ display: flex;
252+ gap: 1px;
253+}
254+.meld.flowers {
255+ margin-left: 12px;
256+ padding-left: 12px;
257+ border-left: 1px dashed rgba(246, 240, 226, 0.3);
258+}
259+.discards {
260+ display: flex;
261+ flex-wrap: wrap;
262+ gap: 2px;
263+ justify-content: center;
264+ align-content: flex-start;
265+ max-width: 94%;
266+ min-height: 0;
267+ margin-bottom: 2px;
268+ flex: 1 1 0;
269+ overflow-y: auto;
270+ scrollbar-width: none;
271+}
272+
273+.nameplate {
274+ display: flex;
275+ align-items: center;
276+ gap: 8px;
277+ font-size: 13px;
278+ letter-spacing: 0.08em;
279+ opacity: 0.9;
280+ padding-top: 2px;
281+ flex: none;
282+}
283+.nameplate .wind {
284+ display: flex;
285+ flex-direction: column;
286+ align-items: center;
287+ line-height: 1;
288+ font-size: 18px;
289+ font-weight: 900;
290+ color: var(--gold);
291+}
292+.nameplate .wind em {
293+ font-style: normal;
294+ font-size: 8px;
295+ font-weight: 600;
296+ letter-spacing: 0.1em;
297+ text-transform: uppercase;
298+ opacity: 0.6;
299+}
300+.nameplate .wind.dealer {
301+ color: #ff7a68;
302+}
303+.nameplate .tag {
304+ font-size: 11px;
305+ padding: 1px 6px;
306+ border-radius: 999px;
307+ background: rgba(217, 180, 92, 0.2);
308+ border: 1px solid rgba(217, 180, 92, 0.5);
309+}
310+.nameplate .tag.ting {
311+ background: rgba(46, 170, 120, 0.2);
312+ border-color: rgba(46, 170, 120, 0.6);
313+}
314+.nameplate .tag.sacred {
315+ background: rgba(179, 38, 30, 0.22);
316+ border-color: rgba(224, 108, 96, 0.6);
317+ color: #ffc9c2;
318+}
319+.nameplate .chips {
320+ margin-left: 4px;
321+ font-weight: 900;
322+ font-size: 16px;
323+ color: var(--gold);
324+}
325+
326+.actionbar {
327+ display: flex;
328+ gap: 8px;
329+ align-items: center;
330+ justify-content: flex-end;
331+ padding-right: calc(var(--tile-w) * 0.4);
332+ align-self: stretch;
333+ min-height: 40px;
334+ flex-wrap: wrap;
335+ flex: none;
336+}
337+.waiting {
338+ font-size: 12px;
339+ opacity: 0.5;
340+}
341+.delta {
342+ font-size: 20px;
343+ font-weight: 900;
344+}
345+.delta.up {
346+ color: #5fd39a;
347+}
348+.delta.down {
349+ color: #ff8a80;
350+}
351+
352+/* ---------- buttons ---------- */
353+.btn {
354+ font-family: inherit;
355+ font-size: 17px;
356+ font-weight: 400;
357+ color: #1a1a1a;
358+ background: linear-gradient(180deg, #f3e6c4, #d9b45c);
359+ border: none;
360+ border-radius: 8px;
361+ padding: 6px 15px;
362+ min-height: 46px;
363+ box-shadow: 0 2px 0 #9a7d38;
364+ cursor: pointer;
365+ display: inline-flex;
366+ align-items: center;
367+ gap: 6px;
368+}
369+.btn:active {
370+ transform: translateY(2px);
371+ box-shadow: none;
372+}
373+.btn-sub {
374+ font-size: 12px;
375+ opacity: 0.7;
376+}
377+
378+/* Bilingual label: the called term above, English cue below. */
379+.bi {
380+ display: flex;
381+ flex-direction: column;
382+ align-items: center;
383+ justify-content: center;
384+ line-height: 1.05;
385+ gap: 1px;
386+}
387+.bi .zh {
388+ font-size: 19px;
389+ font-weight: 400;
390+ letter-spacing: 0.06em;
391+}
392+.bi .en {
393+ font-size: 9.5px;
394+ font-weight: 600;
395+ letter-spacing: 0.11em;
396+ text-transform: uppercase;
397+ opacity: 0.68;
398+ white-space: nowrap;
399+}
400+.bi .bi-sub {
401+ font-size: 11px;
402+ opacity: 0.72;
403+ white-space: nowrap;
404+}
405+.big-btn .bi .zh {
406+ font-size: 24px;
407+}
408+.big-btn .bi .en {
409+ font-size: 11px;
410+}
411+.btn.claim.hu {
412+ background: linear-gradient(180deg, #ff8a7a, #c0392b);
413+ color: #fff;
414+ box-shadow: 0 2px 0 #7c231a;
415+}
416+.btn.claim.kong,
417+.btn.claim.pung {
418+ background: linear-gradient(180deg, #8fd3ff, #2b7ec0);
419+ color: #fff;
420+ box-shadow: 0 2px 0 #1c527d;
421+}
422+.btn.claim.chow {
423+ background: linear-gradient(180deg, #9be3b7, #2e9e63);
424+ color: #fff;
425+ box-shadow: 0 2px 0 #1d6640;
426+}
427+.btn.sort {
428+ min-height: 20px;
429+ padding: 1px 8px;
430+ font-size: 11px;
431+ letter-spacing: 0.06em;
432+ border-radius: 999px;
433+}
434+.btn.proceed {
435+ background: linear-gradient(180deg, #f3e6c4, #d9b45c);
436+}
437+.btn.pass,
438+.btn.ghost {
439+ background: transparent;
440+ color: rgba(246, 240, 226, 0.75);
441+ border: 1px solid rgba(246, 240, 226, 0.35);
442+ box-shadow: none;
443+}
444+.btn.discard {
445+ background: linear-gradient(180deg, #fff2cf, #e0c070);
446+}
447+.big-btn {
448+ font-size: 22px;
449+ padding: 12px 34px;
450+ min-height: 56px;
451+}
452+
453+/* ---------- center ---------- */
454+.center {
455+ position: absolute;
456+ inset: var(--depth);
457+ display: flex;
458+ flex-direction: column;
459+ align-items: center;
460+ justify-content: center;
461+ gap: 10px;
462+ pointer-events: none;
463+}
464+.center > * {
465+ pointer-events: auto;
466+}
467+/* Everything in the centre reads on top of the wall square. */
468+.info,
469+.last-discard,
470+.prompt,
471+.result,
472+.log,
473+.log,
474+.info {
475+ display: flex;
476+ flex-direction: column;
477+ align-items: center;
478+ gap: 2px;
479+ opacity: 0.9;
480+ text-align: center;
481+}
482+.info .round,
483+.info .wall {
484+ display: flex;
485+ flex-direction: column;
486+ align-items: center;
487+ line-height: 1.05;
488+}
489+.info .wall {
490+ margin-top: 10px;
491+ padding-top: 10px;
492+ border-top: 1px solid rgba(246, 240, 226, 0.14);
493+}
494+.info .big {
495+ font-size: 40px;
496+ font-weight: 400;
497+ color: var(--gold);
498+}
499+.info .lbl {
500+ font-size: 11px;
501+ letter-spacing: 0.06em;
502+ opacity: 0.6;
503+}
504+.info .hand-no {
505+ margin-top: 8px;
506+ font-size: 11px;
507+ opacity: 0.5;
508+ letter-spacing: 0.1em;
509+}
510+/* The wall: a square of four staggered sides, 18 stacks of two each, drawn
511+ with the tileset's own blank tile. Sits behind the centre readout. */
512+.wall-ring {
513+ position: absolute;
514+ top: 50%;
515+ left: 50%;
516+ --ring: min(calc(100vh - 2 * var(--depth) - 16px), calc(100vw - 2 * var(--depth) - 16px), 560px);
517+ --ws: calc(var(--ring) / 19.375);
518+ --wd: calc(var(--ws) * 1.375);
519+ width: var(--ring);
520+ height: var(--ring);
521+ transform: translate(-50%, -50%);
522+ pointer-events: none;
523+ z-index: 0;
524+}
525+.wall-side {
526+ position: absolute;
527+ display: flex;
528+}
529+/* Pinwheel: each side starts where the previous one ended. */
530+.wall-side.top {
531+ top: 0;
532+ left: var(--wd);
533+ right: 0;
534+ height: var(--wd);
535+}
536+.wall-side.right {
537+ top: var(--wd);
538+ right: 0;
539+ bottom: 0;
540+ width: var(--wd);
541+ flex-direction: column;
542+}
543+.wall-side.bottom {
544+ bottom: 0;
545+ left: 0;
546+ right: var(--wd);
547+ height: var(--wd);
548+ flex-direction: row-reverse;
549+}
550+.wall-side.left {
551+ top: 0;
552+ left: 0;
553+ bottom: var(--wd);
554+ width: var(--wd);
555+ flex-direction: column-reverse;
556+}
557+.wall-stack {
558+ position: relative;
559+ flex: none;
560+ width: var(--ws);
561+ height: var(--wd);
562+}
563+.wall-side.left .wall-stack,
564+.wall-side.right .wall-stack {
565+ width: var(--wd);
566+ height: var(--ws);
567+}
568+.wall-stack i {
569+ position: absolute;
570+ top: 50%;
571+ left: 50%;
572+ width: var(--ws);
573+ height: var(--wd);
574+ margin: calc(var(--wd) / -2) 0 0 calc(var(--ws) / -2);
575+ background-image: url('/tiles/back.png');
576+ background-size: 100% 100%;
577+ filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45));
578+}
579+.wall-side.left .wall-stack i,
580+.wall-side.right .wall-stack i {
581+ transform: rotate(90deg);
582+}
583+/* One tile left in the stack. */
584+.wall-stack.s1 i {
585+ opacity: 0.8;
586+ transform: scale(0.86);
587+}
588+.wall-side.left .wall-stack.s1 i,
589+.wall-side.right .wall-stack.s1 i {
590+ transform: rotate(90deg) scale(0.86);
591+}
592+/* Spent stacks stay as ghosts so the square keeps its shape. */
593+.wall-stack.s0 i {
594+ background: none;
595+ filter: none;
596+ border: 1px dashed rgba(246, 240, 226, 0.11);
597+ border-radius: 2px;
598+}
599+.wall-stack.dead i {
600+ opacity: 0.4;
601+ filter: grayscale(0.6);
602+}
603+.wall-stack.next::after {
604+ content: '';
605+ position: absolute;
606+ inset: -2px;
607+ border: 2px solid var(--gold);
608+ border-radius: 3px;
609+ box-shadow: 0 0 12px rgba(217, 180, 92, 0.7);
610+}
611+
612+.facing {
613+ position: relative;
614+ z-index: 1;
615+ display: flex;
616+ flex-direction: column;
617+ align-items: center;
618+ gap: 8px;
619+ transition: transform 0.35s ease;
620+}
621+
622+.last-discard {
623+ display: flex;
624+ flex-direction: column;
625+ align-items: center;
626+ gap: 6px;
627+ --tile-w: 64px;
628+}
629+.ld-label {
630+ font-size: 12px;
631+ opacity: 0.6;
632+}
633+.prompt {
634+ font-size: 15px;
635+ letter-spacing: 0.1em;
636+ opacity: 0.8;
637+}
638+.prompt.warn {
639+ color: #ffb4a2;
640+ font-weight: 600;
641+}
642+.log {
643+ position: absolute;
644+ bottom: 8px;
645+ left: 12px;
646+ font-size: 12px;
647+ line-height: 1.5;
648+ opacity: 0.55;
649+ pointer-events: none;
650+}
651+
652+.result {
653+ background: rgba(10, 30, 24, 0.94);
654+ border: 1px solid rgba(217, 180, 92, 0.45);
655+ border-radius: 14px;
656+ padding: 18px 26px;
657+ display: flex;
658+ flex-direction: column;
659+ align-items: center;
660+ gap: 10px;
661+ max-height: 90%;
662+ overflow: auto;
663+}
664+.result h2 {
665+ margin: 0;
666+ font-size: 24px;
667+ color: var(--gold);
668+}
669+.result .wt {
670+ color: var(--ivory);
671+ opacity: 0.8;
672+}
673+.tai-list {
674+ display: grid;
675+ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
676+ gap: 2px 18px;
677+ font-size: 14px;
678+ width: 100%;
679+}
680+.tai-item {
681+ display: flex;
682+ align-items: baseline;
683+ justify-content: space-between;
684+ gap: 12px;
685+ padding: 2px 0;
686+ border-bottom: 1px dotted rgba(246, 240, 226, 0.2);
687+}
688+.tai-name {
689+ display: flex;
690+ flex-direction: column;
691+ line-height: 1.15;
692+}
693+.tai-name em {
694+ font-style: normal;
695+ font-size: 10px;
696+ letter-spacing: 0.08em;
697+ text-transform: uppercase;
698+ opacity: 0.55;
699+}
700+.tai-val {
701+ font-weight: 700;
702+ color: var(--gold);
703+}
704+.deltas {
705+ display: flex;
706+ gap: 16px;
707+ font-size: 14px;
708+ opacity: 0.9;
709+}
710+.deltas .up {
711+ color: #5fd39a;
712+}
713+.deltas .down {
714+ color: #ff8a80;
715+}
716+
717+/* ---------- help panel ---------- */
718+.helpbtn {
719+ margin-left: 2px;
720+ width: 22px;
721+ height: 22px;
722+ border-radius: 50%;
723+ border: 1px solid rgba(246, 240, 226, 0.35);
724+ background: transparent;
725+ color: rgba(246, 240, 226, 0.75);
726+ font-family: inherit;
727+ font-size: 12px;
728+ line-height: 1;
729+ cursor: pointer;
730+}
731+/* The rules panel gets its own deeper slot so it is comfortable to read. */
732+.help-slot {
733+ position: absolute;
734+ inset: 0;
735+ z-index: 30;
736+ background: rgba(4, 18, 14, 0.55);
737+}
738+.help-slot.slot-bottom,
739+.help-slot.slot-top,
740+.help-slot.slot-left,
741+.help-slot.slot-right {
742+ inset: 0;
743+ width: auto;
744+ height: auto;
745+}
746+.help {
747+ position: absolute;
748+ top: 50%;
749+ left: 50%;
750+ height: var(--hd);
751+ z-index: 31;
752+ display: flex;
753+ flex-direction: column;
754+ background: rgba(8, 32, 25, 0.985);
755+ border: 1px solid rgba(217, 180, 92, 0.4);
756+ border-radius: 12px;
757+ padding: 10px 18px 14px;
758+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
759+}
760+/* Anchored to the asking player's own edge, and rotated to face them. The
761+ leading translate is applied last, so it shifts the box in screen space. */
762+.help-slot.slot-bottom .help,
763+.help-slot.slot-top .help {
764+ width: calc(100vw - 60px);
765+ --hd: min(52vh, 560px);
766+ --off: calc(50vh - var(--hd) / 2 - 14px);
767+}
768+.help-slot.slot-left .help,
769+.help-slot.slot-right .help {
770+ width: calc(100vh - 60px);
771+ --hd: min(52vw, 560px);
772+ --off: calc(50vw - var(--hd) / 2 - 14px);
773+}
774+.help-slot.slot-bottom .help {
775+ translate: 0 var(--off);
776+}
777+.help-slot.slot-top .help {
778+ translate: 0 calc(var(--off) * -1);
779+}
780+.help-slot.slot-left .help {
781+ translate: calc(var(--off) * -1) 0;
782+}
783+.help-slot.slot-right .help {
784+ translate: var(--off) 0;
785+}
786+.help-head {
787+ display: flex;
788+ align-items: center;
789+ justify-content: space-between;
790+ gap: 16px;
791+ flex: none;
792+}
793+.help-head h3 {
794+ margin: 0;
795+ font-size: 15px;
796+ font-weight: 400;
797+ letter-spacing: 0.08em;
798+ color: var(--gold);
799+}
800+.help-head .btn {
801+ min-height: 30px;
802+ padding: 2px 12px;
803+ font-size: 13px;
804+}
805+.help-body {
806+ flex: 1 1 0;
807+ min-height: 0;
808+ overflow-y: auto;
809+ columns: 4;
810+ column-gap: 26px;
811+ column-fill: auto;
812+ padding-top: 8px;
813+ overflow-x: auto;
814+ scrollbar-width: thin;
815+}
816+.help-body section {
817+ break-inside: avoid;
818+ margin-bottom: 10px;
819+}
820+.help-body h4 {
821+ margin: 0 0 2px;
822+ font-size: 13px;
823+ font-weight: 400;
824+ color: var(--gold);
825+ letter-spacing: 0.06em;
826+}
827+.help-body h4 em {
828+ font-style: normal;
829+ font-size: 9px;
830+ letter-spacing: 0.12em;
831+ text-transform: uppercase;
832+ opacity: 0.55;
833+ margin-left: 4px;
834+}
835+.help-body p {
836+ margin: 0 0 4px;
837+ display: flex;
838+ flex-direction: column;
839+ line-height: 1.35;
840+ font-size: 11.5px;
841+}
842+.help-body p em {
843+ font-style: normal;
844+ font-size: 10.5px;
845+ opacity: 0.6;
846+}
847+.help-body .credit {
848+ opacity: 0.6;
849+}
850+
851+/* ---------- lobby resume ---------- */
852+.lobby .sub {
853+ font-size: 0.95rem;
854+}
855+.resume {
856+ margin-top: 1.4rem;
857+ padding: 12px 26px;
858+ border: 1px solid rgba(217, 180, 92, 0.35);
859+ border-radius: 12px;
860+ background: rgba(0, 0, 0, 0.2);
861+ text-align: center;
862+ display: flex;
863+ flex-direction: column;
864+ gap: 6px;
865+}
866+.resume-line {
867+ font-size: 14px;
868+ color: var(--gold);
869+ letter-spacing: 0.06em;
870+}
871+.resume-scores {
872+ display: flex;
873+ gap: 18px;
874+ justify-content: center;
875+ font-size: 13px;
876+ opacity: 0.8;
877+}
878+.resume-scores b {
879+ color: var(--gold);
880+ font-weight: 600;
881+}
882+.resume-when {
883+ font-size: 11px;
884+ opacity: 0.45;
885+ letter-spacing: 0.08em;
886+}
887+.lobby-buttons {
888+ display: flex;
889+ gap: 14px;
890+ margin-top: 1.6rem;
891+ align-items: center;
892+}
893+
894+/* ---------- sound ---------- */
895+.lobby-extras {
896+ margin-top: 1.4rem;
897+ display: flex;
898+ gap: 12px;
899+ align-items: center;
900+}
901+.sound-toggle {
902+ min-height: 34px;
903+ padding: 2px 14px;
904+ font-size: 13px;
905+ letter-spacing: 0.06em;
906+ border-radius: 999px;
907+ gap: 8px;
908+}
909+.sound-toggle.on {
910+ border-color: rgba(217, 180, 92, 0.7);
911+ color: var(--gold);
912+}
913+.sound-toggle.compact {
914+ min-height: 28px;
915+ padding: 2px 9px;
916+ font-size: 15px;
917+}
918+.sound-toggle .spk {
919+ font-size: 15px;
920+ line-height: 1;
921+}
922+
923+/* ---------- shared table controls ---------- */
924+/* Bottom-right of the centre square, opposite the log. Unrotated on purpose:
925+ it belongs to the table, not to a seat. */
926+.table-controls {
927+ position: absolute;
928+ right: 10px;
929+ bottom: 8px;
930+ display: flex;
931+ align-items: center;
932+ gap: 8px;
933+}
934+.btn.undo {
935+ min-height: 34px;
936+ padding: 3px 14px;
937+ border-radius: 999px;
938+ gap: 9px;
939+ max-width: 40vh;
940+}
941+.undo-arrow {
942+ font-size: 17px;
943+ line-height: 1;
944+ color: var(--gold);
945+}
946+.undo-what {
947+ display: flex;
948+ flex-direction: column;
949+ align-items: flex-start;
950+ line-height: 1.15;
951+ min-width: 0;
952+}
953+.undo-what .zh {
954+ font-size: 13px;
955+ letter-spacing: 0.05em;
956+}
957+.undo-what .en {
958+ font-size: 9px;
959+ letter-spacing: 0.09em;
960+ text-transform: uppercase;
961+ opacity: 0.55;
962+ white-space: nowrap;
963+ overflow: hidden;
964+ text-overflow: ellipsis;
965+ max-width: 100%;
966+}
967+
968+/* ---------- result: one block per winner ---------- */
969+.result .win {
970+ display: flex;
971+ flex-direction: column;
972+ align-items: center;
973+ gap: 8px;
974+ width: 100%;
975+}
976+.result .win + .win {
977+ padding-top: 10px;
978+ border-top: 1px solid rgba(217, 180, 92, 0.3);
979+}
980+.result .liable {
981+ font-size: 12px;
982+ letter-spacing: 0.08em;
983+ color: #ffb4a2;
984+}
985+.result .multi {
986+ font-size: 12px;
987+ letter-spacing: 0.1em;
988+ color: var(--gold);
989+ opacity: 0.85;
990+}
991+
992+/* ---------- settings ---------- */
993+.over-buttons {
994+ display: flex;
995+ gap: 12px;
996+ align-items: center;
997+}
998+.settings-scrim {
999+ position: absolute;
1000+ inset: 0;
1001+ z-index: 40;
1002+ background: rgba(4, 18, 14, 0.72);
1003+ display: flex;
1004+ align-items: center;
1005+ justify-content: center;
1006+ padding: 20px;
1007+}
1008+.settings {
1009+ width: min(880px, 100%);
1010+ max-height: 100%;
1011+ display: flex;
1012+ flex-direction: column;
1013+ background: rgba(8, 32, 25, 0.99);
1014+ border: 1px solid rgba(217, 180, 92, 0.4);
1015+ border-radius: 14px;
1016+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
1017+ padding: 12px 20px 16px;
1018+}
1019+.settings-head {
1020+ display: flex;
1021+ align-items: center;
1022+ justify-content: space-between;
1023+ gap: 16px;
1024+ flex: none;
1025+ padding-bottom: 8px;
1026+ border-bottom: 1px solid rgba(246, 240, 226, 0.12);
1027+}
1028+.settings-head h3 {
1029+ margin: 0;
1030+ font-size: 17px;
1031+ font-weight: 400;
1032+ letter-spacing: 0.08em;
1033+ color: var(--gold);
1034+}
1035+.settings-head-btns {
1036+ display: flex;
1037+ gap: 10px;
1038+}
1039+.settings-head .btn {
1040+ min-height: 36px;
1041+ padding: 2px 16px;
1042+ font-size: 14px;
1043+}
1044+.settings-body {
1045+ flex: 1 1 0;
1046+ min-height: 0;
1047+ overflow-y: auto;
1048+ scrollbar-width: thin;
1049+ padding-top: 10px;
1050+ display: grid;
1051+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
1052+ gap: 4px 28px;
1053+ align-content: start;
1054+}
1055+.settings-body section {
1056+ margin-bottom: 12px;
1057+}
1058+.settings-body h4 {
1059+ margin: 0 0 6px;
1060+ font-size: 13px;
1061+ font-weight: 400;
1062+ color: var(--gold);
1063+ letter-spacing: 0.06em;
1064+}
1065+.settings-body h4 em {
1066+ font-style: normal;
1067+ font-size: 9px;
1068+ letter-spacing: 0.12em;
1069+ text-transform: uppercase;
1070+ opacity: 0.55;
1071+ margin-left: 5px;
1072+}
1073+.settings .field {
1074+ display: flex;
1075+ align-items: center;
1076+ gap: 10px;
1077+ padding: 3px 0;
1078+}
1079+.settings .f-label {
1080+ display: flex;
1081+ align-items: baseline;
1082+ gap: 5px;
1083+ font-size: 13px;
1084+ min-width: 92px;
1085+ opacity: 0.85;
1086+}
1087+.settings .f-label em {
1088+ font-style: normal;
1089+ font-size: 9px;
1090+ letter-spacing: 0.1em;
1091+ text-transform: uppercase;
1092+ opacity: 0.55;
1093+}
1094+.settings input[type='text'],
1095+.settings input:not([type]),
1096+.settings input[type='number'] {
1097+ flex: 1 1 0;
1098+ min-width: 0;
1099+ font-family: inherit;
1100+ font-size: 15px;
1101+ color: var(--ivory);
1102+ background: rgba(0, 0, 0, 0.28);
1103+ border: 1px solid rgba(246, 240, 226, 0.25);
1104+ border-radius: 7px;
1105+ padding: 7px 10px;
1106+ user-select: text;
1107+}
1108+.settings input:focus {
1109+ outline: none;
1110+ border-color: rgba(217, 180, 92, 0.8);
1111+}
1112+.settings input[type='number'] {
1113+ max-width: 110px;
1114+}
1115+.settings input[type='range'] {
1116+ flex: 1 1 0;
1117+ accent-color: var(--gold);
1118+}
1119+.settings input:disabled {
1120+ opacity: 0.4;
1121+}
1122+.settings .switch {
1123+ display: grid;
1124+ grid-template-columns: auto 1fr;
1125+ gap: 2px 10px;
1126+ align-items: center;
1127+ padding: 5px 0;
1128+ cursor: pointer;
1129+}
1130+.settings .switch input {
1131+ width: 20px;
1132+ height: 20px;
1133+ accent-color: var(--gold);
1134+ cursor: pointer;
1135+}
1136+.settings .sw-name {
1137+ display: flex;
1138+ align-items: baseline;
1139+ gap: 6px;
1140+ font-size: 14px;
1141+}
1142+.settings .sw-name em {
1143+ font-style: normal;
1144+ font-size: 9px;
1145+ letter-spacing: 0.1em;
1146+ text-transform: uppercase;
1147+ opacity: 0.55;
1148+}
1149+.settings .note {
1150+ grid-column: 2;
1151+ font-size: 11px;
1152+ line-height: 1.35;
1153+ opacity: 0.55;
1154+}
1155+.settings .switch.dead {
1156+ opacity: 0.4;
1157+ cursor: default;
1158+}
1159+.settings .note.lock {
1160+ grid-column: auto;
1161+ margin: 4px 0 0;
1162+ color: #ffb4a2;
1163+ opacity: 0.75;
1164+}
addedsrc/ui/Bi.tsx+23 −0
1+import type { ClaimType } from '../game/engine';
2+
3+/**
4+ * Bilingual label: the Chinese term players call at the table, with the English
5+ * underneath for anyone still learning it. Tile faces stay pure Chinese — those
6+ * are the tiles themselves, not UI chrome.
7+ */
8+export function Bi({ zh, en, sub }: { zh: string; en: string; sub?: string }) {
9+ return (
10+ <span className="bi">
11+ <span className="zh">{zh}</span>
12+ <span className="en">{en}</span>
13+ {sub && <span className="bi-sub">{sub}</span>}
14+ </span>
15+ );
16+}
17+
18+export const CLAIM_LABEL: Record<ClaimType, [string, string]> = {
19+ hu: ['胡', 'Win'],
20+ kong: ['槓', 'Kong'],
21+ pung: ['碰', 'Pung'],
22+ chow: ['吃', 'Chow'],
23+};
addedsrc/ui/Center.tsx+148 −0
1+import type { Game } from '../game/engine';
2+import type { Prefs } from '../game/prefs';
3+import type { SeatId } from '../game/types';
4+import { labelOf } from '../game/tiles';
5+import { Bi } from './Bi';
6+import { SoundToggle } from './SoundToggle';
7+import { TileView } from './TileView';
8+import { SEAT_ROT } from './rotation';
9+import { WallRing } from './WallRing';
10+
11+interface Props {
12+ game: Game;
13+ prefs: Prefs;
14+ onPrefs: (p: Prefs) => void;
15+ onSettings: () => void;
16+}
17+
18+export function Center({ game, prefs, onPrefs, onSettings }: Props) {
19+ const s = game.state;
20+ const r = s.result;
21+ // Point the live readout at whoever can move the game along.
22+ const pending = (Object.keys(s.options) as unknown as number[])
23+ .map(Number)
24+ .filter((k) => s.responses[k as SeatId] === undefined) as SeatId[];
25+ const facing = game.nextDrawer() ?? s.turn;
26+ // Deliberately not per-seat: whoever spots the mis-tap should be able to
27+ // reach it, not just the player who made it.
28+ const undoable = s.phase === 'gameEnd' ? null : game.undoable();
29+
30+ return (
31+ <div className="center">
32+ {s.phase !== 'gameEnd' && <WallRing state={s} />}
33+
34+ <div className="facing" style={{ transform: `rotate(${SEAT_ROT[facing]}deg)` }}>
35+ {s.lastDiscard && s.phase !== 'handEnd' && (
36+ <div className="last-discard">
37+ <div className="ld-label">{s.players[s.lastDiscard.from].name} 打 discarded</div>
38+ <TileView tile={s.lastDiscard.tile} size="hand" highlight />
39+ </div>
40+ )}
41+
42+ {s.phase === 'claims' && (
43+ <div className="prompt">
44+ {pending.length} 家可吃碰 · {pending.length} may claim
45+ </div>
46+ )}
47+ {s.phase === 'robkong' && <div className="prompt warn">搶槓機會 · Rob the kong?</div>}
48+ </div>
49+
50+ {r && s.phase !== 'gameEnd' && (
51+ <div className="result">
52+ {r.drawGame ? (
53+ <h2>流局 · Draw</h2>
54+ ) : (
55+ <>
56+ {/* One block per winner — more than one only under 一炮多響. */}
57+ {r.winners.map((w) => (
58+ <div className="win" key={w.seat}>
59+ <h2>
60+ {s.players[w.seat].name} {w.selfDraw ? '自摸' : '胡牌'} · {w.tai} 台
61+ <span className="wt"> {labelOf(w.winTile)}</span>
62+ </h2>
63+ {w.liable !== null && (
64+ <div className="liable">
65+ 包牌 · {s.players[w.liable].name} pays the whole hand
66+ </div>
67+ )}
68+ <div className="tai-list">
69+ {w.items.map((it, i) => (
70+ <div key={i} className="tai-item">
71+ <span className="tai-name">
72+ {it.name}
73+ <em>{it.en}</em>
74+ </span>
75+ <span className="tai-val">{it.tai}</span>
76+ </div>
77+ ))}
78+ </div>
79+ </div>
80+ ))}
81+ {r.winners.length > 1 && (
82+ <div className="multi">一炮多響 · one discard, {r.winners.length} winners</div>
83+ )}
84+ <div className="deltas">
85+ {r.deltas.map((d, i) => (
86+ <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}>
87+ {s.players[i].name} {d > 0 ? `+${d}` : d}
88+ </span>
89+ ))}
90+ </div>
91+ </>
92+ )}
93+ <button className="btn big-btn" onClick={() => game.nextHand()}>
94+ <Bi zh="下一局" en="Next hand" />
95+ </button>
96+ </div>
97+ )}
98+
99+ {s.phase === 'gameEnd' && (
100+ <div className="result">
101+ <h2>一將終了 · Game over</h2>
102+ <div className="deltas">
103+ {[...s.players]
104+ .map((p, i) => ({ p, i }))
105+ .sort((a, b) => b.p.score - a.p.score)
106+ .map(({ p, i }) => (
107+ <span key={i} className={p.score > s.rules.startingScore ? 'up' : p.score < s.rules.startingScore ? 'down' : ''}>
108+ {p.name} {p.score}
109+ </span>
110+ ))}
111+ </div>
112+ <div className="over-buttons">
113+ <button className="btn big-btn" onClick={() => game.newGame()}>
114+ <Bi zh="再來一將" en="New game" />
115+ </button>
116+ {/* The moment anyone is going to want the names or the stakes changed. */}
117+ <button className="btn ghost big-btn" onClick={onSettings}>
118+ <Bi zh="設定" en="Settings" />
119+ </button>
120+ </div>
121+ </div>
122+ )}
123+
124+ <div className="table-controls">
125+ {undoable && (
126+ <button className="btn ghost undo" onClick={() => game.undo()}>
127+ <span className="undo-arrow" aria-hidden>
128+ ↺
129+ </span>
130+ <span className="undo-what">
131+ <span className="zh">復原 {undoable.zh}</span>
132+ <span className="en">Undo · {undoable.en}</span>
133+ </span>
134+ </button>
135+ )}
136+ <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
137+ </div>
138+
139+ <div className="log">
140+ {s.log.slice(0, 5).map((l, i) => (
141+ <div key={i} style={{ opacity: 1 - i * 0.18 }}>
142+ {l}
143+ </div>
144+ ))}
145+ </div>
146+ </div>
147+ );
148+}
addedsrc/ui/Hand.tsx+109 −0
1+import { useRef, useState } from 'react';
2+import type { Tile } from '../game/tiles';
3+import { TileView } from './TileView';
4+
5+interface Props {
6+ tiles: Tile[];
7+ /** Index of the tile held apart from the rest, if any. */
8+ drawnIndex: number | null;
9+ selected: number | null;
10+ canDiscard: boolean;
11+ onSelect: (i: number) => void;
12+ onDiscard: (i: number) => void;
13+ onMove: (from: number, to: number) => void;
14+}
15+
16+/** How far a pointer must travel before a tap becomes a drag. */
17+const DRAG_SLOP = 12;
18+
19+/**
20+ * Finds the tile nearest the pointer. Works whatever the seat's rotation:
21+ * bounding rects are always screen-axis-aligned, so we read the row's direction
22+ * off the first two tiles rather than assuming it runs left-to-right.
23+ */
24+function nearestIndex(container: HTMLElement, x: number, y: number): number | null {
25+ const els = Array.from(container.querySelectorAll<HTMLElement>('[data-idx]'));
26+ if (els.length < 2) return null;
27+ const centers = els.map((el) => {
28+ const r = el.getBoundingClientRect();
29+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
30+ });
31+ const horizontal = Math.abs(centers[1].x - centers[0].x) >= Math.abs(centers[1].y - centers[0].y);
32+ const along = (p: { x: number; y: number }) => (horizontal ? p.x : p.y);
33+ const p = along({ x, y });
34+
35+ let best = 0;
36+ let bestD = Infinity;
37+ centers.forEach((c, i) => {
38+ const d = Math.abs(along(c) - p);
39+ if (d < bestD) {
40+ bestD = d;
41+ best = i;
42+ }
43+ });
44+ return best;
45+}
46+
47+export function Hand({
48+ tiles,
49+ drawnIndex,
50+ selected,
51+ canDiscard,
52+ onSelect,
53+ onDiscard,
54+ onMove,
55+}: Props) {
56+ const ref = useRef<HTMLDivElement>(null);
57+ const drag = useRef<{ from: number; cur: number; x: number; y: number; moved: boolean } | null>(null);
58+ const [dragging, setDragging] = useState<number | null>(null);
59+
60+ const down = (e: React.PointerEvent, i: number) => {
61+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
62+ drag.current = { from: i, cur: i, x: e.clientX, y: e.clientY, moved: false };
63+ };
64+
65+ const move = (e: React.PointerEvent) => {
66+ const d = drag.current;
67+ if (!d || !ref.current) return;
68+ if (!d.moved && Math.hypot(e.clientX - d.x, e.clientY - d.y) < DRAG_SLOP) return;
69+ if (!d.moved) {
70+ d.moved = true;
71+ setDragging(d.cur);
72+ }
73+ const to = nearestIndex(ref.current, e.clientX, e.clientY);
74+ if (to !== null && to !== d.cur) {
75+ onMove(d.cur, to);
76+ d.cur = to;
77+ setDragging(to);
78+ }
79+ };
80+
81+ const up = () => {
82+ const d = drag.current;
83+ drag.current = null;
84+ setDragging(null);
85+ if (!d || d.moved || !canDiscard) return;
86+ if (selected === d.from) onDiscard(d.from);
87+ else onSelect(d.from);
88+ };
89+
90+ return (
91+ <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
92+ {tiles.map((t, i) => (
93+ <div
94+ key={i}
95+ data-idx={i}
96+ className={
97+ 'hand-cell' +
98+ (i === drawnIndex ? ' drawn-slot' : '') +
99+ (i === dragging ? ' dragging' : '')
100+ }
101+ onPointerDown={(e) => down(e, i)}
102+ >
103+ {i === drawnIndex && <span className="drawn-tag">摸 Drew</span>}
104+ <TileView tile={t} raised={selected === i} />
105+ </div>
106+ ))}
107+ </div>
108+ );
109+}
addedsrc/ui/Help.tsx+166 −0
1+import type { SeatId } from '../game/types';
2+import { SEAT_ROT, SEAT_SLOT } from './rotation';
3+
4+interface Section {
5+ zh: string;
6+ en: string;
7+ body: [string, string][];
8+}
9+
10+/** Rules reference, shown inside one player's own strip so only they read it. */
11+const SECTIONS: Section[] = [
12+ {
13+ zh: '目標',
14+ en: 'The goal',
15+ body: [
16+ ['十六張牌,湊成五組加一對眼。', 'Build five sets plus one pair from 16 tiles.'],
17+ ['一組=順子(三張同花色連號)或刻子(三張相同)。', 'A set is a run of three in one suit, or three of a kind.'],
18+ ['摸進第十七張後打出一張。', 'You draw a 17th tile each turn, then discard one.'],
19+ ],
20+ },
21+ {
22+ zh: '牌',
23+ en: 'The tiles',
24+ body: [
25+ ['萬 · 條 · 筒 一到九,各四張。', 'Characters, bamboo and dots, 1–9, four of each.'],
26+ ['字牌:東南西北 中發白,各四張。', 'Honours: four winds and three dragons, four of each.'],
27+ ['花牌八張,摸到自動補牌,還能算台。', 'Eight flowers: drawn ones replace themselves and score.'],
28+ ],
29+ },
30+ {
31+ zh: '輪到你',
32+ en: 'Your turn',
33+ body: [
34+ ['點一張牌抬起,再點一次打出。', 'Tap a tile to lift it, tap again to discard it.'],
35+ ['剛摸的牌會分開放在最右邊,標著「摸」。', 'The tile you just drew sits apart on the right, marked 摸.'],
36+ ['牌可以拖曳重排,或按「理牌」自動排序。', 'Drag tiles to rearrange, or press 理牌 to sort.'],
37+ ],
38+ },
39+ {
40+ zh: '吃碰槓',
41+ en: 'Claiming a discard',
42+ body: [
43+ ['碰:手上兩張相同,可碰任何人打的牌。', 'Pung: two matching tiles, from anyone’s discard.'],
44+ ['吃:只能吃上家(左手邊)打的牌。', 'Chow: a run, and only from the player to your left.'],
45+ ['優先序:胡 > 碰/槓 > 吃。', 'Priority: win beats pung/kong, which beat chow.'],
46+ ['吃碰之後不用摸牌,直接打一張。', 'After claiming you do not draw — just discard.'],
47+ [
48+ '要碰要快:下家一摸牌就來不及了,桌子不會等你。',
49+ 'Call fast — the window shuts when the next player draws. Nobody waits.',
50+ ],
51+ ],
52+ },
53+ {
54+ zh: '槓',
55+ en: 'Kongs',
56+ body: [
57+ ['四張相同:明槓、暗槓、或碰後加槓。', 'Four of a kind, claimed, concealed, or added to a pung.'],
58+ ['槓完從牌尾補一張,胡了算「槓上開花」。', 'You draw from the tail; winning on it scores 槓上開花.'],
59+ ['別人加槓時你剛好能胡,可以搶槓。', 'You may rob an added kong if that tile completes your hand.'],
60+ ],
61+ },
62+ {
63+ zh: '胡牌',
64+ en: 'Winning',
65+ body: [
66+ ['自摸:自己摸到,三家各付一份。', 'Self-draw: all three opponents pay you.'],
67+ ['放槍:胡別人打的牌,放槍者一家付。', 'Off a discard: the discarder alone pays.'],
68+ ['一份 = 底 + 台數 × 台值。', 'One share is the base plus your tai count.'],
69+ ['牌牆剩十六張還沒人胡就流局。', 'If 16 tiles remain undrawn, the hand is a draw.'],
70+ ],
71+ },
72+ {
73+ zh: '常見台數',
74+ en: 'Common tai',
75+ body: [
76+ ['自摸 1 · 門清 1 · 門清自摸 +1', 'Self-draw 1 · concealed 1 · both together +1'],
77+ ['正花每張 1 · 圈風 1 · 門風 1 · 三元牌每組 1', 'Own flower 1 · round wind 1 · seat wind 1 · dragon pung 1'],
78+ ['碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16', 'All pungs 4 · half flush 4 · full flush 8 · all honours 16'],
79+ ['大三元 8 · 小四喜 8 · 大四喜 16', 'Big three dragons 8 · little four winds 8 · big four winds 16'],
80+ ['莊家 1 台,連莊每次再加 2 台。', 'Dealer scores 1, plus 2 more for each repeat deal.'],
81+ ],
82+ },
83+ {
84+ zh: '房規',
85+ en: 'House rules',
86+ body: [
87+ [
88+ '過水:不胡的牌,到自己再摸牌前都不能胡。',
89+ 'Sacred discard: pass on a winning tile and it is dead to you until your next draw.',
90+ ],
91+ [
92+ '一炮多響:一張放槍牌,幾家胡就付幾家。',
93+ 'Multiple winners: one discard can pay out to every seat that calls on it.',
94+ ],
95+ [
96+ '包牌:放第三張三元/四喜刻子的人,一人付全部。',
97+ 'Liability: feed the third dragon or wind pung to a big hand and you pay for it alone.',
98+ ],
99+ ['以上都可以在設定裡開關。', 'All three are switches on the settings screen.'],
100+ ],
101+ },
102+ {
103+ zh: '拿錯了',
104+ en: 'Mis-taps',
105+ body: [
106+ ['桌子中央有「復原」,可以退回上一步。', 'The 復原 button in the middle takes back the last action.'],
107+ ['誰看到誰按,不限於按錯的那個人。', 'Anyone can press it — not just whoever made the mistake.'],
108+ ],
109+ },
110+ {
111+ zh: '莊家',
112+ en: 'The dealer',
113+ body: [
114+ ['莊家胡牌或流局就連莊。', 'The dealer keeps the deal on a win or a draw.'],
115+ ['否則換下家做莊,四家輪完換一圈風。', 'Otherwise it passes on; the round wind turns every four passes.'],
116+ ],
117+ },
118+];
119+
120+/**
121+ * Rules panel for one seat. Rendered at table level rather than inside the
122+ * seat strip so it can be deep enough to read, but still rotated to face only
123+ * that player.
124+ */
125+export function Help({ seat, onClose }: { seat: SeatId; onClose: () => void }) {
126+ return (
127+ <div className={`help-slot ${SEAT_SLOT[seat]}`} onClick={onClose}>
128+ <div
129+ className="help"
130+ style={{ transform: `translate(-50%, -50%) rotate(${SEAT_ROT[seat]}deg)` }}
131+ onClick={(e) => e.stopPropagation()}
132+ >
133+ <div className="help-head">
134+ <h3>台灣麻將規則 · How to play</h3>
135+ <button className="btn ghost" onClick={onClose}>
136+ 關閉 Close
137+ </button>
138+ </div>
139+ <div className="help-body">
140+ {SECTIONS.map((sec) => (
141+ <section key={sec.zh}>
142+ <h4>
143+ {sec.zh} <em>{sec.en}</em>
144+ </h4>
145+ {sec.body.map(([zh, en], i) => (
146+ <p key={i}>
147+ <span>{zh}</span>
148+ <em>{en}</em>
149+ </p>
150+ ))}
151+ </section>
152+ ))}
153+ <section className="credit">
154+ <h4>
155+ 牌面 <em>Tile art</em>
156+ </h4>
157+ <p>
158+ <span>gnome-mahjongg「postmodern」牌組</span>
159+ <em>From gnome-mahjongg’s postmodern theme, GPL-2.0-or-later.</em>
160+ </p>
161+ </section>
162+ </div>
163+ </div>
164+ </div>
165+ );
166+}
addedsrc/ui/Seat.tsx+192 −0
1+import { useEffect, useState } from 'react';
2+import type { Game } from '../game/engine';
3+import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles';
4+import type { SeatId } from '../game/types';
5+import { Bi, CLAIM_LABEL } from './Bi';
6+import { Hand } from './Hand';
7+import { TileView } from './TileView';
8+
9+const ROT: Record<SeatId, number> = { 0: 0, 1: -90, 2: 180, 3: 90 };
10+// seat 0 = bottom (near edge of the laptop), then counter-clockwise on screen:
11+// 1 = right, 2 = top, 3 = left — which is the natural 東南西北 seating order.
12+const SLOT: Record<SeatId, string> = { 0: 'slot-bottom', 1: 'slot-right', 2: 'slot-top', 3: 'slot-left' };
13+
14+interface Props {
15+ game: Game;
16+ seat: SeatId;
17+ onHelp: () => void;
18+}
19+
20+export function Seat({ game, seat, onHelp }: Props) {
21+ const s = game.state;
22+ const p = s.players[seat];
23+ const [sel, setSel] = useState<number | null>(null);
24+ const myTurn = s.phase === 'turn' && s.turn === seat;
25+ const opts = s.options[seat];
26+ const responded = s.responses[seat] !== undefined;
27+ const claiming = (s.phase === 'claims' || s.phase === 'robkong') && !!opts && !responded;
28+ // Whoever draws next closes the claim window — nobody has to wait on a
29+ // player who hasn't noticed their buttons.
30+ const canProceed = game.nextDrawer() === seat;
31+
32+ useEffect(() => {
33+ setSel(null);
34+ }, [myTurn, s.handNumber, p.hand.length, s.drawnTile]);
35+
36+ const seatWind = (seat - s.dealer + 4) % 4;
37+ const isDealer = seat === s.dealer;
38+ const live = s.phase !== 'handEnd' && s.phase !== 'lobby';
39+ const tenpai = live ? game.tenpai(seat) : [];
40+ const sacred = live ? game.sacredTiles(seat) : [];
41+ const concealedKongs = myTurn ? game.concealedKongs(seat) : [];
42+ const addedKongs = myTurn ? game.addedKongs(seat) : [];
43+ const canHu = myTurn && game.canSelfDrawWin(seat);
44+ const delta = s.phase === 'handEnd' ? s.result?.deltas[seat] ?? 0 : 0;
45+
46+ const doDiscard = (i: number) => {
47+ game.discard(seat, p.hand[i]);
48+ setSel(null);
49+ };
50+
51+ // The tile just drawn is held apart at the end of the hand until it is played
52+ // or the player slides it in, so it stays obvious which tile just arrived.
53+ const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null;
54+ const selTile = sel === null ? null : p.hand[sel] ?? null;
55+
56+ return (
57+ <div className={`slot ${SLOT[seat]}`}>
58+ <div
59+ className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`}
60+ style={{ transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }}
61+ >
62+ <div className="discards">
63+ {p.discards.map((t, i) => (
64+ <TileView
65+ key={i}
66+ tile={t}
67+ size="small"
68+ highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1}
69+ />
70+ ))}
71+ </div>
72+
73+ <div className="melds">
74+ {p.melds.map((m, i) => (
75+ <div className="meld" key={i}>
76+ {m.tiles.map((t, j) => (
77+ <TileView key={j} tile={t} size="meld" faceDown={m.kind === 'ankong' && j > 0 && j < 3} />
78+ ))}
79+ </div>
80+ ))}
81+ {p.flowers.length > 0 && (
82+ <div className="meld flowers">
83+ {p.flowers.map((t, i) => (
84+ <TileView key={i} tile={t} size="meld" />
85+ ))}
86+ </div>
87+ )}
88+ </div>
89+
90+ <div className="actionbar">
91+ {claiming && (
92+ <>
93+ {opts!.map((o, i) => (
94+ <button
95+ key={i}
96+ className={`btn claim ${o.type}`}
97+ onClick={() => game.respond(seat, { type: o.type, with: o.with })}
98+ >
99+ <Bi
100+ zh={CLAIM_LABEL[o.type][0]}
101+ en={CLAIM_LABEL[o.type][1]}
102+ sub={o.with && o.with.map(labelOf).join(' ')}
103+ />
104+ </button>
105+ ))}
106+ <button className="btn pass" onClick={() => game.respond(seat, 'pass')}>
107+ <Bi zh="過" en="Pass" />
108+ </button>
109+ </>
110+ )}
111+ {canProceed && (
112+ <button className="btn proceed" onClick={() => game.resolveNow()}>
113+ <Bi
114+ zh={s.phase === 'robkong' ? '繼續' : '摸牌'}
115+ en={s.phase === 'robkong' ? 'Continue' : 'Draw'}
116+ />
117+ </button>
118+ )}
119+ {!claiming && !canProceed && responded && s.phase !== 'turn' && (
120+ <div className="waiting">已回應 Responded</div>
121+ )}
122+ {myTurn && (
123+ <>
124+ {canHu && (
125+ <button className="btn claim hu" onClick={() => game.declareSelfDraw(seat)}>
126+ <Bi zh="自摸" en="Self-draw" />
127+ </button>
128+ )}
129+ {concealedKongs.map((t) => (
130+ <button key={`ak${t}`} className="btn claim kong" onClick={() => game.declareConcealedKong(seat, t)}>
131+ <Bi zh="暗槓" en="Concealed kong" sub={labelOf(t)} />
132+ </button>
133+ ))}
134+ {addedKongs.map((t) => (
135+ <button key={`gk${t}`} className="btn claim kong" onClick={() => game.declareAddedKong(seat, t)}>
136+ <Bi zh="加槓" en="Added kong" sub={labelOf(t)} />
137+ </button>
138+ ))}
139+ {selTile !== null && sel !== null && (
140+ <button className="btn discard" onClick={() => doDiscard(sel)}>
141+ <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
142+ </button>
143+ )}
144+ </>
145+ )}
146+ {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>}
147+ </div>
148+
149+ <Hand
150+ tiles={p.hand}
151+ drawnIndex={drawnIndex}
152+ selected={sel}
153+ canDiscard={myTurn}
154+ onSelect={setSel}
155+ onDiscard={doDiscard}
156+ onMove={(from, to) => game.reorderHand(seat, from, to)}
157+ />
158+
159+ <div className="nameplate">
160+ <span className={`wind ${isDealer ? 'dealer' : ''}`}>
161+ {WIND_NAMES[seatWind]}
162+ <em>{WIND_EN[seatWind]}</em>
163+ </span>
164+ <span className="pname">{p.name}</span>
165+ {isDealer && (
166+ <span className="tag">
167+ 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''}
168+ </span>
169+ )}
170+ {tenpai.length > 0 && (
171+ <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span>
172+ )}
173+ {/* Without this the 胡 button just quietly fails to appear. */}
174+ {sacred.length > 0 && (
175+ <span className="tag sacred" title="過水 — passed on winning with these until your next draw">
176+ 過水 {sacred.map(labelOf).join('·')}
177+ </span>
178+ )}
179+ <span className="chips">{p.score}</span>
180+ {p.hand.length > 0 && s.phase !== 'handEnd' && (
181+ <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
182+ 理牌 Sort
183+ </button>
184+ )}
185+ <button className="helpbtn" onClick={onHelp} aria-label="規則 rules">
186+ ?
187+ </button>
188+ </div>
189+ </div>
190+ </div>
191+ );
192+}
addedsrc/ui/Settings.tsx+209 −0
1+import { useState } from 'react';
2+import { clamp, type Prefs } from '../game/prefs';
3+import { play, setSoundEnabled, setVolume } from '../game/sound';
4+import { DEFAULT_NAMES, type Rules } from '../game/types';
5+
6+interface Props {
7+ prefs: Prefs;
8+ onSave: (p: Prefs) => void;
9+ onClose: () => void;
10+ /** Stakes are only editable before the first hand of a game is dealt. */
11+ stakesLocked: boolean;
12+}
13+
14+interface Toggle {
15+ key: keyof Rules;
16+ zh: string;
17+ en: string;
18+ note: string;
19+}
20+
21+/** The house rules tables actually argue about before they start. */
22+const TOGGLES: Toggle[] = [
23+ {
24+ key: 'sacredDiscard',
25+ zh: '過水',
26+ en: 'Sacred discard',
27+ note: 'Pass on a tile you could have won on and it is dead to you until your own next draw.',
28+ },
29+ {
30+ key: 'sacredClearedByClaim',
31+ zh: '吃碰解過水',
32+ en: 'A claim lifts it',
33+ note: 'Taking a 吃 / 碰 / 槓 counts as a fresh start, rather than making you wait to draw.',
34+ },
35+ {
36+ key: 'multipleWinners',
37+ zh: '一炮多響',
38+ en: 'Multiple winners',
39+ note: 'One discard pays out to every seat that calls on it, not just the nearest.',
40+ },
41+ {
42+ key: 'liability',
43+ zh: '包牌',
44+ en: 'Liability',
45+ note: 'Feed the third dragon or wind pung to a 大三元 / 大四喜 and you pay for the whole hand.',
46+ },
47+];
48+
49+export function Settings({ prefs, onSave, onClose, stakesLocked }: Props) {
50+ // Edited locally and committed on 完成, so a half-typed name never reaches
51+ // the table and 取消 really does mean cancel.
52+ const [draft, setDraft] = useState<Prefs>(prefs);
53+ const rules = draft.rules;
54+ const setRules = (patch: Partial<Rules>) => setDraft({ ...draft, rules: { ...rules, ...patch } });
55+
56+ const num = (
57+ key: 'base' | 'taiValue' | 'startingScore',
58+ zh: string,
59+ en: string,
60+ min: number,
61+ max: number,
62+ ) => (
63+ <label className="field">
64+ <span className="f-label">
65+ {zh}
66+ <em>{en}</em>
67+ </span>
68+ <input
69+ type="number"
70+ inputMode="numeric"
71+ min={min}
72+ max={max}
73+ disabled={stakesLocked}
74+ value={rules[key]}
75+ onChange={(e) => setRules({ [key]: clamp(Math.round(+e.target.value || 0), min, max) })}
76+ />
77+ </label>
78+ );
79+
80+ return (
81+ <div className="settings-scrim" onClick={onClose}>
82+ <div className="settings" onClick={(e) => e.stopPropagation()}>
83+ <div className="settings-head">
84+ <h3>設定 · Settings</h3>
85+ <div className="settings-head-btns">
86+ <button className="btn ghost" onClick={onClose}>
87+ 取消 Cancel
88+ </button>
89+ <button className="btn" onClick={() => onSave(draft)}>
90+ 完成 Done
91+ </button>
92+ </div>
93+ </div>
94+
95+ <div className="settings-body">
96+ <section>
97+ <h4>
98+ 玩家 <em>Players</em>
99+ </h4>
100+ <div className="names">
101+ {draft.names.map((n, i) => (
102+ <label className="field" key={i}>
103+ <span className="f-label">{i + 1}</span>
104+ <input
105+ value={n}
106+ maxLength={12}
107+ placeholder={DEFAULT_NAMES[i]}
108+ onChange={(e) => {
109+ const names = draft.names.slice();
110+ names[i] = e.target.value;
111+ setDraft({ ...draft, names });
112+ }}
113+ />
114+ </label>
115+ ))}
116+ </div>
117+ </section>
118+
119+ <section>
120+ <h4>
121+ 籌碼 <em>Stakes</em>
122+ </h4>
123+ {num('base', '底', 'Base', 0, 999)}
124+ {num('taiValue', '台', 'Per tai', 0, 999)}
125+ {num('startingScore', '起始籌碼', 'Starting chips', 0, 99999)}
126+ {stakesLocked && (
127+ <p className="note lock">
128+ 對局進行中不能改動籌碼。
129+ <br />
130+ Stakes are fixed once a game is under way — finish it, or start a new one.
131+ </p>
132+ )}
133+ </section>
134+
135+ <section>
136+ <h4>
137+ 規則 <em>House rules</em>
138+ </h4>
139+ {TOGGLES.map((t) => {
140+ const on = rules[t.key] as boolean;
141+ // 過水 has to be on for the rule about lifting it to mean anything.
142+ const dead = t.key === 'sacredClearedByClaim' && !rules.sacredDiscard;
143+ return (
144+ <label className={`switch ${dead ? 'dead' : ''}`} key={t.key}>
145+ <input
146+ type="checkbox"
147+ checked={on}
148+ disabled={dead}
149+ onChange={(e) => setRules({ [t.key]: e.target.checked })}
150+ />
151+ <span className="sw-name">
152+ {t.zh}
153+ <em>{t.en}</em>
154+ </span>
155+ <span className="note">{t.note}</span>
156+ </label>
157+ );
158+ })}
159+ </section>
160+
161+ <section>
162+ <h4>
163+ 音效 <em>Sound</em>
164+ </h4>
165+ <label className="switch">
166+ <input
167+ type="checkbox"
168+ checked={draft.sound}
169+ onChange={(e) => {
170+ // Start the audio hardware inside the click, not after it.
171+ setSoundEnabled(e.target.checked);
172+ setDraft({ ...draft, sound: e.target.checked });
173+ if (e.target.checked) play('claimWindow');
174+ }}
175+ />
176+ <span className="sw-name">
177+ 開啟音效
178+ <em>Sound on</em>
179+ </span>
180+ <span className="note">
181+ A click as a tile goes down, and a chime when someone's claim window opens.
182+ </span>
183+ </label>
184+ <label className="field">
185+ <span className="f-label">
186+ 音量
187+ <em>Volume</em>
188+ </span>
189+ <input
190+ type="range"
191+ min={0}
192+ max={100}
193+ value={Math.round(draft.volume * 100)}
194+ disabled={!draft.sound}
195+ onChange={(e) => {
196+ const v = +e.target.value / 100;
197+ setVolume(v);
198+ setDraft({ ...draft, volume: v });
199+ }}
200+ onMouseUp={() => draft.sound && play('discard')}
201+ onTouchEnd={() => draft.sound && play('discard')}
202+ />
203+ </label>
204+ </section>
205+ </div>
206+ </div>
207+ </div>
208+ );
209+}
addedsrc/ui/SoundToggle.tsx+36 −0
1+import type { Prefs } from '../game/prefs';
2+import { play, setSoundEnabled } from '../game/sound';
3+
4+interface Props {
5+ prefs: Prefs;
6+ onPrefs: (p: Prefs) => void;
7+ /** Icon only, for the cramped row of controls in the middle of the table. */
8+ compact?: boolean;
9+}
10+
11+/**
12+ * Mute. Turning sound *on* plays the chime straight away — partly so the room
13+ * hears how loud it is before a hand starts, partly because the browser will
14+ * only start the audio hardware from inside a click like this one.
15+ */
16+export function SoundToggle({ prefs, onPrefs, compact }: Props) {
17+ const on = prefs.sound;
18+ return (
19+ <button
20+ className={`btn ghost sound-toggle ${compact ? 'compact' : ''} ${on ? 'on' : ''}`}
21+ aria-pressed={on}
22+ onClick={() => {
23+ // Unlock the audio hardware here, inside the click, rather than
24+ // waiting for the effect that persists the preference.
25+ setSoundEnabled(!on);
26+ onPrefs({ ...prefs, sound: !on });
27+ if (!on) play('claimWindow');
28+ }}
29+ >
30+ <span className="spk" aria-hidden>
31+ {on ? '🔊' : '🔇'}
32+ </span>
33+ {!compact && <span>{on ? '音效開 Sound on' : '靜音 Muted'}</span>}
34+ </button>
35+ );
36+}
addedsrc/ui/TileView.tsx+35 −0
1+import { labelOf, SPRITE_COL, type Tile } from '../game/tiles';
2+
3+interface Props {
4+ tile?: Tile;
5+ faceDown?: boolean;
6+ size?: 'hand' | 'meld' | 'small';
7+ raised?: boolean;
8+ dim?: boolean;
9+ highlight?: boolean;
10+ onClick?: () => void;
11+}
12+
13+export function TileView({ tile, faceDown, size = 'hand', raised, dim, highlight, onClick }: Props) {
14+ const down = faceDown || tile === undefined;
15+ const cls = [
16+ 'tile',
17+ `t-${size}`,
18+ down ? 'back' : 'face',
19+ raised ? 'raised' : '',
20+ dim ? 'dim' : '',
21+ highlight ? 'hl' : '',
22+ onClick ? 'tappable' : '',
23+ ]
24+ .filter(Boolean)
25+ .join(' ');
26+
27+ return (
28+ <div
29+ className={cls}
30+ onClick={onClick}
31+ aria-label={down ? undefined : labelOf(tile!)}
32+ style={down ? undefined : ({ '--col': SPRITE_COL[tile!] } as React.CSSProperties)}
33+ />
34+ );
35+}
addedsrc/ui/WallRing.tsx+45 −0
1+import { STACKS_PER_SIDE, WALL_SIZE, WALL_STACKS } from '../game/tiles';
2+import type { GameState } from '../game/engine';
3+
4+/**
5+ * The wall as it sits on a real table: a square of four staggered sides, 18
6+ * stacks of two to a side. Position 0 is the break point — draws eat the square
7+ * from there, while kong and flower replacements come off the far end (the 底牌
8+ * tail), so it is consumed from both directions at once. Spent stacks stay as
9+ * faint ghosts so the square keeps its shape all game.
10+ */
11+export function WallRing({ state }: { state: GameState }) {
12+ const front = state.drawnFront;
13+ const back = WALL_SIZE - state.drawnBack;
14+ const deadFrom = back - state.rules.wallReserve;
15+
16+ const stacks = Array.from({ length: WALL_STACKS }, (_, i) => {
17+ const a = i * 2;
18+ const b = a + 1;
19+ const live = (p: number) => p >= front && p < back;
20+ const count = (live(a) ? 1 : 0) + (live(b) ? 1 : 0);
21+ return { count, dead: b >= deadFrom, next: front === a || front === b };
22+ });
23+
24+ const side = (from: number, cls: string) => (
25+ <div className={`wall-side ${cls}`} key={cls}>
26+ {stacks.slice(from, from + STACKS_PER_SIDE).map((st, i) => (
27+ <div
28+ key={i}
29+ className={`wall-stack s${st.count}${st.dead ? ' dead' : ''}${st.next ? ' next' : ''}`}
30+ >
31+ <i />
32+ </div>
33+ ))}
34+ </div>
35+ );
36+
37+ return (
38+ <div className="wall-ring" aria-label="牌牆">
39+ {side(0, 'top')}
40+ {side(STACKS_PER_SIDE, 'right')}
41+ {side(STACKS_PER_SIDE * 2, 'bottom')}
42+ {side(STACKS_PER_SIDE * 3, 'left')}
43+ </div>
44+ );
45+}
addedsrc/ui/rotation.ts+15 −0
1+import type { SeatId } from '../game/types';
2+
3+/**
4+ * Screen rotation that makes a seat's content face that player.
5+ * Seat 0 sits at the bottom edge and play runs counter-clockwise on screen:
6+ * 1 = right, 2 = top, 3 = left.
7+ */
8+export const SEAT_ROT: Record<SeatId, number> = { 0: 0, 1: -90, 2: 180, 3: 90 };
9+
10+export const SEAT_SLOT: Record<SeatId, string> = {
11+ 0: 'slot-bottom',
12+ 1: 'slot-right',
13+ 2: 'slot-top',
14+ 3: 'slot-left',
15+};
addedtsconfig.app.json+26 −0
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023", "DOM"],
6+ "module": "esnext",
7+ "types": ["vite/client"],
8+ "allowArbitraryExtensions": true,
9+ "skipLibCheck": true,
10+
11+ /* Bundler mode */
12+ "moduleResolution": "bundler",
13+ "allowImportingTsExtensions": true,
14+ "verbatimModuleSyntax": true,
15+ "moduleDetection": "force",
16+ "noEmit": true,
17+ "jsx": "react-jsx",
18+
19+ /* Linting */
20+ "noUnusedLocals": true,
21+ "noUnusedParameters": true,
22+ "erasableSyntaxOnly": true,
23+ "noFallthroughCasesInSwitch": true
24+ },
25+ "include": ["src"]
26+}
addedtsconfig.json+7 −0
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.app.json" },
5+ { "path": "./tsconfig.node.json" }
6+ ]
7+}
addedtsconfig.node.json+23 −0
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023"],
6+ "types": ["node"],
7+ "skipLibCheck": true,
8+
9+ /* Bundler mode */
10+ "module": "nodenext",
11+ "allowImportingTsExtensions": true,
12+ "verbatimModuleSyntax": true,
13+ "moduleDetection": "force",
14+ "noEmit": true,
15+
16+ /* Linting */
17+ "noUnusedLocals": true,
18+ "noUnusedParameters": true,
19+ "erasableSyntaxOnly": true,
20+ "noFallthroughCasesInSwitch": true
21+ },
22+ "include": ["vite.config.ts"]
23+}
addedvite.config.ts+7 −0
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// https://vite.dev/config/
5+export default defineConfig({
6+ plugins: [react()],
7+})