-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathChemGame.html
More file actions
371 lines (344 loc) · 82 KB
/
ChemGame.html
File metadata and controls
371 lines (344 loc) · 82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>离子风暴 - 网页版</title>
<style>
:root{--bg:#f2f6fb;--card:#fff;--line:#c7d4e2;--line2:#9ab0c7;--txt:#1f2e3d;--sub:#53667a;--acc:#2a7ecf;--accsoft:#dcedff}
*{box-sizing:border-box}
html,body{margin:0;height:100%;font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:var(--txt);background:linear-gradient(180deg,#f8fbff 0%,var(--bg) 100%)}
#app{height:100%;display:flex;flex-direction:column;gap:8px;padding:8px}
.panel{background:var(--card);border:1px solid var(--line);border-radius:8px;box-shadow:0 1px 3px rgba(19,34,48,.08)}
.title{font-weight:700;margin-bottom:6px}
#setup{padding:8px;display:flex;flex-direction:column;gap:6px}
#setup .row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
#setup .hint{color:var(--sub);font-size:13px}
.mode-btn{width:18em;max-width:100%;white-space:nowrap}
#status{flex:1;color:#0b4f6c;min-width:220px}
button,input,select,textarea{font-family:inherit;font-size:14px}
button{border:1px solid var(--line2);border-radius:6px;background:#f7fbff;color:#19324a;padding:6px 10px;cursor:pointer}
button:hover:not(:disabled){border-color:var(--acc);background:#eef6ff}
button:disabled{opacity:.55;cursor:not-allowed}
#main{flex:1;min-height:0;display:flex;gap:8px}
#left{width:min(360px,38vw);min-width:270px;min-height:0;display:flex;flex-direction:column;gap:8px}
#handPanel{flex:1;min-height:0;padding:8px;display:flex;flex-direction:column}
#handList{flex:1;min-height:0;overflow:auto;border:1px solid var(--line);border-radius:6px;padding:4px;background:#fbfdff}
.hand-item{display:block;width:100%;text-align:left;border:1px solid transparent;border-radius:5px;background:transparent;margin:0;padding:6px 8px;color:#1d3147}
.hand-item+.hand-item{margin-top:2px}
.hand-item:hover{background:#eef6ff;border-color:#d5e8ff}
.hand-item.sel{background:var(--accsoft);border-color:var(--acc)}
#actionPanel{padding:8px;display:flex;flex-direction:column;gap:6px}
#sel{min-height:22px;color:#2d4761;word-break:break-all}
#actionBtns{display:grid;grid-template-columns:1fr 1fr;gap:6px}
#right{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;gap:8px}
#info{padding:8px;min-height:0;display:flex;flex-direction:column;gap:6px}
#turn{font-weight:700;color:#223748;line-height:1.35}
#playersWrap{overflow-x:auto;border:1px solid var(--line);border-radius:6px;background:#fbfdff}
#players{border-collapse:collapse;width:100%;min-width:560px}
#players th,#players td{border:1px solid var(--line);padding:4px 6px;white-space:nowrap;text-align:center;font-weight:normal}
#players th:first-child,#players td:first-child{background:#eef4fb;font-weight:700;position:sticky;left:0;z-index:1}
.text{width:100%;border:1px solid var(--line);border-radius:6px;background:#fbfdff;color:var(--txt);padding:8px;resize:none;line-height:1.4;white-space:pre;overflow:auto;font-family:Consolas,"Microsoft YaHei",monospace}
#board{min-height:220px;height:38vh;max-height:55vh;flex:none}
#logPanel{flex:1;min-height:0;padding:8px;display:flex;flex-direction:column;gap:6px}
#log{flex:1;min-height:120px}
dialog.modal{border:none;border-radius:10px;padding:0;width:min(540px,calc(100vw - 28px));color:var(--txt);background:#fff;box-shadow:0 14px 36px rgba(24,40,60,.24)}
dialog.modal::backdrop{background:rgba(17,31,47,.45)}
.mhead{padding:10px 12px;border-bottom:1px solid var(--line);font-weight:700;background:#f6faff}
.mbody{padding:12px;display:flex;flex-direction:column;gap:8px}
.mline{display:grid;grid-template-columns:120px minmax(0,1fr);align-items:center;gap:8px}
.mline label{color:#294157;font-size:13px}
.mline input,.mline textarea,.mline select{border:1px solid var(--line2);border-radius:6px;padding:6px 8px;min-width:0;background:#fff;color:#1f2e3d}
.mline textarea{resize:none;height:34px;font-family:Consolas,"Microsoft YaHei",monospace}
.mnote{color:var(--sub);font-size:13px;line-height:1.45;white-space:pre-line}
.mbtns{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:0 12px 12px}
.mbtns.single{grid-template-columns:1fr}
@media (max-width:1100px){#main{flex-direction:column}#left{width:100%;min-width:0;max-height:48%}#board{height:240px;max-height:320px}}
</style>
</head>
<body>
<div id="app">
<section id="setup" class="panel">
<div class="row">
<button id="btnLocal" class="mode-btn">本地对局</button>
<button id="btnRemote" class="mode-btn">向服务器申请联机</button>
<button id="btnJoin" class="mode-btn">加入服务器房间</button>
</div>
<div class="row hint">点击上方按钮后会弹出详细设置窗口,再开始对应模式。</div>
<div class="row"><div id="status">就绪。</div><button id="btnStop">停止当前会话</button></div>
</section>
<section id="main">
<div id="left">
<section id="handPanel" class="panel">
<div class="title">我的手牌</div>
<div id="handList"></div>
</section>
<section id="actionPanel" class="panel">
<div class="title">出牌操作</div>
<div id="sel">当前选中: 无</div>
<div id="actionBtns">
<button id="btnPlay">出牌</button>
<button id="btnWangzha">王炸(强酸+强碱)</button>
</div>
</section>
</div>
<div id="right">
<section id="info" class="panel">
<div id="turn">尚未开始对局。</div>
<div id="playersWrap"><table id="players"></table></div>
<textarea id="board" class="text" readonly></textarea>
</section>
<section id="logPanel" class="panel">
<div class="title">对局日志</div>
<textarea id="log" class="text" readonly></textarea>
</section>
</div>
</section>
</div>
<script>
(()=>{
"use strict";
const TABLE={"Cation": ["H^+", "NH_4^+", "K^+", "Na^+", "Ba^{2+}", "Ca^{2+}", "Mg^{2+}", "Al^{3+}", "Zn^{2+}", "Fe^{2+}", "Fe^{3+}", "Pb^{2+}", "Cu^{2+}", "Ag^+"], "Anion": ["OH^-", "NO_3^-", "Cl^-", "SO_4^{2-}", "S^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}", "PO_4^{3-}", "Ac^-"], "Ion": ["H^+", "NH_4^+", "K^+", "Na^+", "Ba^{2+}", "Ca^{2+}", "Mg^{2+}", "Al^{3+}", "Zn^{2+}", "Fe^{2+}", "Fe^{3+}", "Pb^{2+}", "Cu^{2+}", "Ag^+", "OH^-", "NO_3^-", "Cl^-", "SO_4^{2-}", "S^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}", "PO_4^{3-}", "Ac^-"], "Special": ["Au", "U"], "Function": ["Acid", "Alkali", "Enough", "Impurity", "Filter", "Fade", "AirWashing", "Distill", "AddSodium", "Ban", "Reverse"], "Color": ["Fe^{2+}", "Fe^{3+}", "Cu^{2+}"], "initCard": {"H^+": 6, "NH_4^+": 6, "K^+": 2, "Na^+": 2, "Ba^{2+}": 4, "Ca^{2+}": 4, "Mg^{2+}": 3, "Al^{3+}": 2, "Zn^{2+}": 3, "Fe^{2+}": 3, "Fe^{3+}": 3, "Pb^{2+}": 3, "Cu^{2+}": 3, "Ag^+": 3, "OH^-": 6, "NO_3^-": 3, "Cl^-": 4, "SO_4^{2-}": 5, "S^{2-}": 3, "SO_3^{2-}": 3, "CO_3^{2-}": 5, "SiO_3^{2-}": 3, "PO_4^{3-}": 3, "Ac^-": 3, "Au": 3, "U": 3, "Acid": 4, "Alkali": 4, "Enough": 3, "Impurity": 3, "Filter": 5, "Fade": 3, "AirWashing": 3, "Distill": 4, "AddSodium": 2, "Ban": 4, "Reverse": 4}, "cardType": {"H^+": 1, "NH_4^+": 1, "K^+": 1, "Na^+": 1, "Ba^{2+}": 1, "Ca^{2+}": 1, "Mg^{2+}": 1, "Al^{3+}": 1, "Zn^{2+}": 1, "Fe^{2+}": 1, "Fe^{3+}": 1, "Pb^{2+}": 1, "Cu^{2+}": 1, "Ag^+": 1, "OH^-": 2, "NO_3^-": 2, "Cl^-": 2, "SO_4^{2-}": 2, "S^{2-}": 2, "SO_3^{2-}": 2, "CO_3^{2-}": 2, "SiO_3^{2-}": 2, "PO_4^{3-}": 2, "Ac^-": 2, "Au": 0, "U": 0, "Acid": 3, "Alkali": 3, "Enough": 3, "Impurity": 3, "Filter": 3, "Fade": 3, "AirWashing": 3, "Distill": 3, "AddSodium": 3, "Ban": 3, "Reverse": 3}, "toG": {"H^+": ["S^{2-}", "SO_3^{2-}", "CO_3^{2-}"], "NH_4^+": ["OH^-"], "K^+": [], "Na^+": [], "Ba^{2+}": [], "Ca^{2+}": [], "Mg^{2+}": [], "Al^{3+}": [], "Zn^{2+}": [], "Fe^{2+}": [], "Fe^{3+}": [], "Pb^{2+}": [], "Cu^{2+}": [], "Ag^+": [], "OH^-": ["NH_4^+"], "NO_3^-": [], "Cl^-": [], "SO_4^{2-}": [], "S^{2-}": ["H^+"], "SO_3^{2-}": ["H^+"], "CO_3^{2-}": ["H^+"], "SiO_3^{2-}": [], "PO_4^{3-}": [], "Ac^-": []}, "toS": {"H^+": ["SiO_3^{2-}"], "NH_4^+": [], "K^+": [], "Na^+": [], "Ba^{2+}": ["SO_4^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}", "PO_4^{3-}"], "Ca^{2+}": ["SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}", "PO_4^{3-}"], "Mg^{2+}": ["OH^-", "SiO_3^{2-}", "PO_4^{3-}"], "Al^{3+}": ["OH^-", "PO_4^{3-}"], "Zn^{2+}": ["OH^-", "S^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}", "PO_4^{3-}"], "Fe^{2+}": ["OH^-", "S^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}", "PO_4^{3-}"], "Fe^{3+}": ["OH^-", "PO_4^{3-}"], "Pb^{2+}": ["OH^-", "SO_4^{2-}", "S^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}", "PO_4^{3-}"], "Cu^{2+}": ["OH^-", "S^{2-}", "SO_3^{2-}", "PO_4^{3-}"], "Ag^+": ["Cl^-", "S^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}", "PO_4^{3-}"], "OH^-": ["Mg^{2+}", "Al^{3+}", "Zn^{2+}", "Fe^{2+}", "Fe^{3+}", "Pb^{2+}", "Cu^{2+}", "Ag^+"], "NO_3^-": [], "Cl^-": ["Ag^+"], "SO_4^{2-}": ["Ba^{2+}", "Pb^{2+}"], "S^{2-}": ["Zn^{2+}", "Fe^{2+}", "Pb^{2+}", "Cu^{2+}", "Ag^+"], "SO_3^{2-}": ["Ba^{2+}", "Ca^{2+}", "Zn^{2+}", "Fe^{2+}", "Pb^{2+}", "Cu^{2+}", "Ag^+"], "CO_3^{2-}": ["Ba^{2+}", "Ca^{2+}", "Zn^{2+}", "Fe^{2+}", "Pb^{2+}", "Ag^+"], "SiO_3^{2-}": ["H^+", "Ba^{2+}", "Ca^{2+}", "Mg^{2+}", "Zn^{2+}", "Fe^{2+}", "Pb^{2+}", "Ag^+"], "PO_4^{3-}": ["Ba^{2+}", "Ca^{2+}", "Mg^{2+}", "Al^{3+}", "Zn^{2+}", "Fe^{2+}", "Fe^{3+}", "Pb^{2+}", "Cu^{2+}", "Ag^+"], "Ac^-": []}, "toSS": {"H^+": [], "NH_4^+": [], "K^+": [], "Na^+": [], "Ba^{2+}": [], "Ca^{2+}": ["OH^-", "SO_4^{2-}", "S^{2-}"], "Mg^{2+}": ["SO_3^{2-}", "CO_3^{2-}"], "Al^{3+}": [], "Zn^{2+}": [], "Fe^{2+}": [], "Fe^{3+}": [], "Pb^{2+}": ["Cl^-"], "Cu^{2+}": [], "Ag^+": ["SO_4^{2-}"], "OH^-": ["Ca^{2+}"], "NO_3^-": [], "Cl^-": ["Pb^{2+}"], "SO_4^{2-}": ["Ca^{2+}", "Ag^+"], "S^{2-}": ["Ca^{2+}"], "SO_3^{2-}": ["Mg^{2+}"], "CO_3^{2-}": ["Mg^{2+}"], "SiO_3^{2-}": [], "PO_4^{3-}": [], "Ac^-": []}, "toWE": {"H^+": ["OH^-", "PO_4^{3-}", "Ac^-"], "NH_4^+": [], "K^+": [], "Na^+": [], "Ba^{2+}": [], "Ca^{2+}": [], "Mg^{2+}": [], "Al^{3+}": [], "Zn^{2+}": [], "Fe^{2+}": [], "Fe^{3+}": [], "Pb^{2+}": [], "Cu^{2+}": [], "Ag^+": [], "OH^-": ["H^+"], "NO_3^-": [], "Cl^-": [], "SO_4^{2-}": [], "S^{2-}": [], "SO_3^{2-}": [], "CO_3^{2-}": [], "SiO_3^{2-}": [], "PO_4^{3-}": ["H^+"], "Ac^-": ["H^+"]}, "toNE": {"H^+": [], "NH_4^+": ["SiO_3^{2-}"], "K^+": [], "Na^+": [], "Ba^{2+}": [], "Ca^{2+}": [], "Mg^{2+}": ["S^{2-}"], "Al^{3+}": ["S^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}"], "Zn^{2+}": [], "Fe^{2+}": [], "Fe^{3+}": ["S^{2-}", "SO_3^{2-}", "CO_3^{2-}", "SiO_3^{2-}"], "Pb^{2+}": [], "Cu^{2+}": ["CO_3^{2-}", "SiO_3^{2-}"], "Ag^+": ["OH^-"], "OH^-": ["Ag^+"], "NO_3^-": [], "Cl^-": [], "SO_4^{2-}": [], "S^{2-}": ["Mg^{2+}", "Al^{3+}", "Fe^{3+}"], "SO_3^{2-}": ["Al^{3+}", "Fe^{3+}"], "CO_3^{2-}": ["Al^{3+}", "Fe^{3+}", "Cu^{2+}"], "SiO_3^{2-}": ["NH_4^+", "Al^{3+}", "Fe^{3+}", "Cu^{2+}"], "PO_4^{3-}": [], "Ac^-": []}, "Ele": {"H^+": 1, "NH_4^+": 1, "K^+": 1, "Na^+": 1, "Ba^{2+}": 2, "Ca^{2+}": 2, "Mg^{2+}": 2, "Al^{3+}": 3, "Zn^{2+}": 2, "Fe^{2+}": 2, "Fe^{3+}": 3, "Pb^{2+}": 2, "Cu^{2+}": 2, "Ag^+": 1, "OH^-": -1, "NO_3^-": -1, "Cl^-": -1, "SO_4^{2-}": -2, "S^{2-}": -2, "SO_3^{2-}": -2, "CO_3^{2-}": -2, "SiO_3^{2-}": -2, "PO_4^{3-}": -3, "Ac^-": -1}}
;
const t={H:"H^+",OH:"OH^-",SiO3:"SiO_3^{2-}",Au:"Au",U:"U",Acid:"Acid",Alk:"Alkali",Enough:"Enough",Imp:"Impurity",Flt:"Filter",Fde:"Fade",AW:"AirWashing",Dtl:"Distill",AS:"AddSodium",Ban:"Ban",Rev:"Reverse"};
const HEARTBEAT_INTERVAL_SECONDS=1,HEARTBEAT_LAG_SECONDS=3,HEARTBEAT_TIMEOUT_SECONDS=8,HEARTBEAT_WATCHDOG_SECONDS=1;
const MAIN_PORT=5000;
const RESUME_CACHE_KEY="chemgame_web_resume_cache_v1";
const ION_SET=new Set(TABLE.Ion),SPECIAL_SET=new Set(TABLE.Special),FUNCTION_SET=new Set(TABLE.Function),CATION_SET=new Set(TABLE.Cation),ALL_CARDS_SORTED=Object.keys(TABLE.initCard).sort((a,b)=>b.length-a.length);
const NON_ION_DISPLAY={[t.Au]:"金",[t.U]:"铀",[t.Acid]:"强酸",[t.Alk]:"强碱",[t.Enough]:"足量",[t.Imp]:"杂质",[t.Flt]:"过滤",[t.Fde]:"褪色",[t.AW]:"洗气",[t.Dtl]:"蒸馏",[t.AS]:"加钠",[t.Ban]:"禁",[t.Rev]:"逆转"};
const ACID_REACTIVE_PRECIP_ANIONS=new Set([...(TABLE.toG?.[t.H]||[]),...(TABLE.toWE?.[t.H]||[]),...(TABLE.toS?.[t.H]||[])]);
const SUB_MAP={"0":"₀","1":"₁","2":"₂","3":"₃","4":"₄","5":"₅","6":"₆","7":"₇","8":"₈","9":"₉","+":"₊","-":"₋","(":"₍",")":"₎"};
const SUP_MAP={"0":"⁰","1":"¹","2":"²","3":"³","4":"⁴","5":"⁵","6":"⁶","7":"⁷","8":"⁸","9":"⁹","+":"⁺","-":"⁻","(":"⁽",")":"⁾"};
const rid=(p="id")=>`${p}_${Math.random().toString(36).slice(2,10)}_${Date.now().toString(36)}`;
const i32=(v,f=0)=>{const n=Number.parseInt(String(v),10);return Number.isFinite(n)?n:f};
const copy=(o)=>JSON.parse(JSON.stringify(o));
const isIpv6HostText=(h)=>{const s=String(h||"").trim();if(!s)return false;if(s.startsWith("[")&&s.endsWith("]"))return true;return s.includes(":")&&!/^[^:]+:\d+$/.test(s)};
const isLocalHost=(h)=>{const s=String(h||"").trim().toLowerCase();return s==="localhost"||s==="127.0.0.1"||s==="::1"||s==="[::1]"||s==="0:0:0:0:0:0:0:1"};
const pageIsHttps=(typeof location!=="undefined"&&location.protocol==="https:");
const defaultWsProtoForHost=(h)=>{if(!pageIsHttps)return"ws";return isLocalHost(h)?"ws":"wss"};
const hostSpec=(raw,fallbackProto="auto")=>{let s=String(raw??"").trim(),proto="",port=null;s=s.replace(/\uFF1A/g,":").replace(/\uFF0F/g,"/").replace(/\u3002/g,".").replace(/\uFF0E/g,".").replace(/\uFF0C/g,".").replace(/\u3000/g," ").trim();if(!s){const p=(fallbackProto==="auto")?"ws":fallbackProto;return{host:"127.0.0.1",proto:(String(p).toLowerCase()==="wss")?"wss":"ws",port:null}}const m=s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/(.*)$/);if(m){const sch=String(m[1]||"").toLowerCase();s=String(m[2]||"");if(sch==="ws"||sch==="wss")proto=sch;else if(sch==="http")proto="ws";else if(sch==="https")proto="wss"}const slash=s.indexOf("/");if(slash>=0)s=s.slice(0,slash);if(s.startsWith("[")&&s.includes("]")){const end=s.indexOf("]"),rest=s.slice(end+1);if(/^:\d+$/.test(rest))port=i32(rest.slice(1),null);s=s.slice(1,end)}else{const cc=(s.match(/:/g)||[]).length;if(cc===1&&/^.+:\d+$/.test(s)){const parts=s.split(":");port=i32(parts[parts.length-1],null);s=parts.slice(0,-1).join(":")}}s=s.trim().toLowerCase();if(!s)s="127.0.0.1";if(!proto){const fb=(fallbackProto==="auto")?defaultWsProtoForHost(s):fallbackProto;proto=(String(fb).toLowerCase()==="wss")?"wss":"ws"}if(!Number.isFinite(port)||port<=0)port=null;return{host:s,proto,port}};
const nh=(h)=>hostSpec(h).host;
const nproto=(h,fb="auto")=>hostSpec(h,fb).proto;
const hostPortFromInput=(raw)=>hostSpec(raw).port;
const hostForWsUrl=(h)=>{let s=String(h||"127.0.0.1").trim();if(!s)s="127.0.0.1";if(s.startsWith("[")&&s.endsWith("]"))return s;if(isIpv6HostText(s))return`[${s}]`;return s};
const ljson=(k,fb)=>{try{const raw=localStorage.getItem(k);if(!raw)return copy(fb);const v=JSON.parse(raw);if(v&&typeof v==="object")return v}catch(_){ }return copy(fb)};
const sjson=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch(_){ }};
const sum=(c)=>Object.values(c||{}).reduce((a,b)=>a+(Number.isFinite(+b)?+b:0),0);
const dsorted=(c)=>{const o={};for(const k of Object.keys(c||{}).sort()){const n=i32(c[k],0);if(n>0)o[k]=n}return o};
const padd=(c,k,d)=>{const n=i32(c[k],0)+d;if(n>0)c[k]=n;else delete c[k]};
const tr=(s,m)=>Array.from(String(s)).map(ch=>m[ch]||ch).join("");
const ionDisp=(ion)=>String(ion).replace(/_(\d+)/g,(_,p)=>tr(p,SUB_MAP)).replace(/\^\{([^}]+)\}/g,(_,p)=>tr(p,SUP_MAP)).replace(/\^([0-9+\-]+)/g,(_,p)=>tr(p,SUP_MAP));
const cname=(card)=>ION_SET.has(card)?ionDisp(card):(NON_ION_DISPLAY[card]||String(card));
const dtext=(txt)=>{let o=String(txt);for(const c of ALL_CARDS_SORTED)o=o.split(c).join(cname(c));return o};
const p2d=(pc)=>{const o={};for(const k of Object.keys(pc||{}).sort()){const n=i32(pc[k],0);if(n>0){const [a,b]=k.split("|");o[`${a} + ${b}`]=n}}return o};
const shuf=(arr,rf)=>{for(let i=arr.length-1;i>0;i--){const j=Math.floor(rf()*(i+1));[arr[i],arr[j]]=[arr[j],arr[i]]}};
const mkRnd=(seed=null)=>{if(seed===null||seed===undefined)return Math.random;let s=(Number(seed)>>>0)||1;return()=>{s=(1664525*s+1013904223)>>>0;return s/4294967296}};
const gcd=(a,b)=>{let x=Math.abs(i32(a,0)),y=Math.abs(i32(b,0));while(y){const t=x%y;x=y;y=t}return x||1};
const ensureBalance=(tb)=>{if(!tb||typeof tb!=="object")return;const cur=tb.Balance;if(cur&&typeof cur==="object"&&Object.keys(cur).length>0)return;const out={},ions=Array.isArray(tb.Ion)?tb.Ion:[],cats=Array.isArray(tb.Cation)?tb.Cation:[],ans=Array.isArray(tb.Anion)?tb.Anion:[],ele=(tb.Ele&&typeof tb.Ele==="object")?tb.Ele:{};for(const ion of ions)out[ion]={};for(const c of cats){for(const a of ans){const qc=Math.abs(i32(ele[c],0)),qa=Math.abs(i32(ele[a],0));if(qc<=0||qa<=0)continue;const g=gcd(qc,qa),cc=qa/g,ac=qc/g;(out[c]||(out[c]={}))[a]=[cc,ac];(out[a]||(out[a]={}))[c]=[ac,cc]}}tb.Balance=out};
ensureBalance(TABLE);
const rck=(h,p,n)=>`${nh(h)}:${i32(p,0)}:${String(n||"").trim()}`;
const getResume=(h,p,n)=>{const c=ljson(RESUME_CACHE_KEY,{}),r=c[rck(h,p,n)];if(!r||typeof r!=="object")return[null,""];const idx=(r.resume_index===null||r.resume_index===undefined)?null:i32(r.resume_index,null);return[idx,String(r.resume_key||"")]};
const setResume=(h,p,n,idx,key)=>{if(idx===null&&!key)return;const c=ljson(RESUME_CACHE_KEY,{});c[rck(h,p,n)]={resume_index:idx,resume_key:String(key||""),updated_at:Date.now()};sjson(RESUME_CACHE_KEY,c)};
class PlayerState{constructor(name){this.name=String(name);this.hand={}}get hand_size(){return sum(this.hand)}}
class GameEngine{
constructor(playerNames,initialHandSize=null,seed=null){
if(!Array.isArray(playerNames)||playerNames.length<2)throw new Error("至少需要 2 名玩家");
this.players=playerNames.map(n=>new PlayerState(n));this.deck=[];this.discard=[];this.solution={};this.precipitates={};this.gases={};this.weak_electrolytes={};this.gold_count=0;this.uranium_timers=[];this.skip_turns={};this.current_player=0;this.direction=1;this.actions_left=1;this.played_card_this_turn=false;this.winner=null;this.turn_number=1;this.log=[];this.pending_draw_effect=null;this._pending_draw_effect_seq=0;this._rng=mkRnd(seed);
this._build_deck();
let hs=(initialHandSize===null||initialHandSize===undefined)?this._default_hand_size(this.players.length):i32(initialHandSize,0);
if(hs<2)throw new Error("初始手牌数必须至少为2");hs=Math.min(10,hs);for(let i=0;i<this.players.length;i++)this._draw_cards(i,hs);
this._log(`对局开始。初始手牌数: ${hs}。`);this._log(`当前玩家: ${this.players[this.current_player].name}。`)
}
_default_hand_size(pc){if(pc===2)return 10;if(pc<=4)return 7;return 6}
_build_deck(){this.deck=[];for(const [c,nr] of Object.entries(TABLE.initCard)){const n=i32(nr,0);for(let i=0;i<n;i++)this.deck.push(c)}shuf(this.deck,this._rng)}
_log(t){this.log.push(String(t));if(this.log.length>200)this.log=this.log.slice(-200)}
_next_index(i=null){const b=(i===null||i===undefined)?this.current_player:i;return(b+this.direction+this.players.length)%this.players.length}
_prev_index(i){return(i-this.direction+this.players.length)%this.players.length}
_direction_hint_text(){if(!this.players.length)return"按座位顺序";let ord,p; if(this.direction===1){ord=Array.from({length:this.players.length},(_,i)=>i);p="按座位顺序"}else{ord=Array.from({length:this.players.length},(_,i)=>this.players.length-i-1);p="按座位逆序"}return `${p}(${[...ord.map(i=>String(i+1)),String(ord[0]+1)].join("→")})`}
_remove_from_hand(pid,card,count=1){const h=this.players[pid].hand;if(count<=0||i32(h[card],0)<count)return false;padd(h,card,-count);return true}
_draw_cards(pid,count){const out=[];for(let i=0;i<Math.max(0,i32(count,0));i++){if(!this.deck.length)break;const c=this.deck.pop();padd(this.players[pid].hand,c,1);out.push(c)}return out}
_draw_all_from(start,countEach){let idx=start;for(let i=0;i<this.players.length;i++){this._draw_cards(idx,countEach);idx=this._next_index(idx)}}
_draw_min_hand_players_once(){if(!this.players.length)return;const m=Math.min(...this.players.map(p=>p.hand_size));for(let i=0;i<this.players.length;i++)if(this.players[i].hand_size===m)this._draw_cards(i,1)}
_distribute_draws(start,total){if(total<=0)return[];const d=[];let rem=total,idx=start;while(rem>0&&this.deck.length){const lim=this.players.length===2?rem:Math.min(3,rem),got=this._draw_cards(idx,lim).length;if(got<=0)break;d.push([idx,got]);rem-=got;idx=this._next_index(idx)}return d}
_pair_key(a,b){return CATION_SET.has(a)?`${a}|${b}`:`${b}|${a}`}
_cleanup_solution(ion){if(i32(this.solution[ion],0)<=0)delete this.solution[ion]}
_pair_ion_coeff(pair,ion){const [c,a]=pair.split("|");const co=TABLE.Balance?.[c]?.[a];if(!co)return 0;if(ion===c)return i32(co[0],0);if(ion===a)return i32(co[1],0);return 0}
_is_silicic_acid_pair(pair){return pair===this._pair_key(t.H,t.SiO3)}
_pair_reaction_allowed(zoneName,pair,triggerIon,targetIon){if(this._pair_ion_coeff(pair,targetIon)<=0)return false;if(this._pair_ion_coeff(pair,triggerIon)>0)return false;if(zoneName!=="precipitates")return true;const [c,a]=pair.split("|");if(this._is_silicic_acid_pair(pair))return triggerIon===t.OH&&targetIon===t.H;if(triggerIon===t.H)return targetIon===a&&ACID_REACTIVE_PRECIP_ANIONS.has(a);if(triggerIon===t.OH)return false;return true}
_count_ion_in_pair_counter(zoneName,zone,ion,triggerIon=null){let t=0;for(const pair of Object.keys(zone||{})){const g=i32(zone[pair],0);if(g<=0)continue;if(triggerIon!==null&&!this._pair_reaction_allowed(zoneName,pair,triggerIon,ion))continue;const c=this._pair_ion_coeff(pair,ion);if(c>0)t+=c*g}return t}
_count_ion_in_products(ion,triggerIon=null){return this._count_ion_in_pair_counter("precipitates",this.precipitates,ion,triggerIon)+this._count_ion_in_pair_counter("weak_electrolytes",this.weak_electrolytes,ion,triggerIon)+this._count_ion_in_pair_counter("gases",this.gases,ion,triggerIon)}
_extract_ion_from_pair_counter(zoneName,zone,ion,need,triggerIon=null){if(need<=0)return[0,{}];let consumed=0;const rel={};for(const pair of Object.keys(zone||{}).sort()){if(consumed>=need)break;if(triggerIon!==null&&!this._pair_reaction_allowed(zoneName,pair,triggerIon,ion))continue;const [c,a]=pair.split("|");let src,other;if(ion===c){src=c;other=a}else if(ion===a){src=a;other=c}else continue;const sp=this._pair_ion_coeff(pair,src),op=this._pair_ion_coeff(pair,other);if(sp<=0)continue;let g=i32(zone[pair],0);while(g>0&&consumed<need){padd(zone,pair,-1);g--;const rem=need-consumed,take=Math.min(sp,rem);consumed+=take;if(sp>take)padd(rel,src,sp-take);if(op>0)padd(rel,other,op)}}return[consumed,rel]}
_extract_ion_from_products(ion,need,triggerIon=null){if(need<=0)return 0;let ct=0;const rel={};for(const [zn,z] of [["precipitates",this.precipitates],["weak_electrolytes",this.weak_electrolytes],["gases",this.gases]]){if(ct>=need)break;const [c,r]=this._extract_ion_from_pair_counter(zn,z,ion,need-ct,triggerIon);ct+=c;for(const [k,v] of Object.entries(r))padd(rel,k,i32(v,0))}for(const [k,v] of Object.entries(rel))if(v>0)padd(this.solution,k,v);return ct}
_resolve_reaction_with_ion(ion){if(!ION_SET.has(ion))return 0;let reacted=0;const consume=(other,into,ss=false)=>{const co=TABLE.Balance?.[ion]?.[other];if(!co)return 0;const ci=i32(co[0],0),cof=i32(co[1],0),th=ss?2:1;let gain=0;while(true){const ei=i32(this.solution[ion],0)>=th*ci,es=i32(this.solution[other],0)>=th*cof,ep=this._count_ion_in_products(other,ion)>=th*cof;if(!ei||(!es&&!ep))break;padd(this.solution,ion,-ci);this._cleanup_solution(ion);if(es){padd(this.solution,other,-cof);this._cleanup_solution(other)}else{const got=this._extract_ion_from_products(other,cof,ion);if(got<cof){padd(this.solution,ion,ci);break}}if(into)padd(into,this._pair_key(ion,other),1);reacted+=ci+cof;gain++}return gain};
for(const o of TABLE.toS[ion])consume(o,this.precipitates,false);for(const o of TABLE.toG[ion])consume(o,this.gases,false);for(const o of TABLE.toWE[ion])consume(o,this.weak_electrolytes,false);for(const o of TABLE.toSS[ion])consume(o,this.precipitates,true);for(const o of TABLE.toNE[ion])consume(o,null,false);return reacted}
_reactable_list(ion){const seq=[...(TABLE.toS[ion]||[]),...(TABLE.toG[ion]||[]),...(TABLE.toWE[ion]||[]),...(TABLE.toSS[ion]||[]),...(TABLE.toNE[ion]||[])],seen=new Set(),out=[];for(const x of seq)if(!seen.has(x)){seen.add(x);out.push(x)}return out}
_consume_reactable_ions_only(ion){if(!ION_SET.has(ion))return 0;let rp=0;for(const other of this._reactable_list(ion)){const co=TABLE.Balance?.[ion]?.[other];if(!co)continue;const cof=i32(co[1],0);if(cof<=0)continue;const th=(TABLE.toSS[ion]||[]).includes(other)?2:1;while(true){if(i32(this.solution[other],0)>=th*cof){padd(this.solution,other,-cof);this._cleanup_solution(other);rp+=cof;continue}if(this._count_ion_in_products(other,ion)>=th*cof){const got=this._extract_ion_from_products(other,cof,ion);if(got<cof)break;rp+=cof;continue}break}}return rp}
_check_winner(pid){if(this.players[pid].hand_size===0&&this.winner===null){this.winner=pid;this.pending_draw_effect=null;this._log(`${this.players[pid].name} 获胜。`)}}
_consume_action(){if(this.actions_left<=0)return false;this.actions_left-=1;return true}
_apply_radiation_for_player(pid){if(!this.uranium_timers.length)return;const rc=this.uranium_timers.length;if(rc>0){this._draw_cards(pid,rc);this._log(`辐射: ${this.players[pid].name} 摸${rc}张。`)}this.uranium_timers=this.uranium_timers.map(x=>x-1).filter(x=>x>0)}
_apply_radiation_for_current_player(){this._apply_radiation_for_player(this.current_player)}
_begin_turn_from(start){this.actions_left=1;this.played_card_this_turn=false;let cand=start;while(true){this.current_player=cand;this.turn_number+=1;const sc=i32(this.skip_turns[this.current_player],0);if(sc>0){padd(this.skip_turns,this.current_player,-1);this._log(`第${this.turn_number}回合: ${this.players[this.current_player].name} 被跳过。`);cand=this._next_index(this.current_player);continue}this._apply_radiation_for_current_player();this._log(`第${this.turn_number}回合: ${this.players[this.current_player].name}。`);return}}
_end_turn_internal(){this._begin_turn_from(this._next_index(this.current_player))}
_clear_board_cards(){const rg=this.gold_count,ru=this.uranium_timers.length;this.solution={};this.precipitates={};this.gases={};this.weak_electrolytes={};this.gold_count=0;this.uranium_timers=[];return[rg,ru]}
_apply_removed_special_effects(pid,rg,ru){if(rg>0){this._draw_all_from(pid,1);this._draw_min_hand_players_once();this._log("金被移除,触发共同富集。")}if(ru>0){this._draw_all_from(pid,1);this._log("铀被移除,触发核泄漏。")}}
_draw_limit_per_player(rem){if(rem<=0)return 0;return this.players.length===2?rem:Math.min(3,rem)}
_start_pending_draw_effect(source,effect,total,post=null){if(total<=0){if(Array.isArray(post)&&post.length===3)this._apply_removed_special_effects(post[0],post[1],post[2]);return}this._pending_draw_effect_seq+=1;this.pending_draw_effect={id:this._pending_draw_effect_seq,effect_card:effect,source_player:+source,remaining:+total,cursor:this._next_index(source),follow_open:true,awaiting_player:null,follow_allowed:false,counter_allowed:false,forced_next_player:null,post_removed_special:post,canceled_by_counter:false};this._advance_pending_draw_effect()}
_pending_waiting_player(){const p=this.pending_draw_effect;if(!p)return null;const w=p.awaiting_player;if(w===null||w===undefined)return null;const i=i32(w,-1);return(i>=0&&i<this.players.length)?i:null}
_advance_pending_draw_effect(){while(this.pending_draw_effect!==null&&this.winner===null){const p=this.pending_draw_effect,rem=i32(p.remaining,0);if(rem<=0){this._finalize_pending_draw_effect();return}const cur=i32(p.cursor,this.current_player);if(cur<0||cur>=this.players.length){p.remaining=0;this._finalize_pending_draw_effect();return}const eff=String(p.effect_card||"").trim(),src=i32(p.source_player,this.current_player),fo=!!p.follow_open,offline=i32(this.skip_turns[cur],0)>=1e8;const cf=!offline&&fo&&cur===this._next_index(src)&&i32(this.players[cur].hand[eff],0)>0,cc=!offline&&i32(this.players[cur].hand[t.AS],0)>0;if(cf||cc){p.awaiting_player=cur;p.follow_allowed=cf;p.counter_allowed=cc;return}const lim=this._draw_limit_per_player(rem);if(lim<=0){p.remaining=0;this._finalize_pending_draw_effect();return}const got=this._draw_cards(cur,lim).length;if(got<=0){p.remaining=0;this._finalize_pending_draw_effect();return}p.remaining=rem-got;p.cursor=this._next_index(cur);p.follow_open=false;this._log(`${this.players[cur].name} 受到 ${eff} 效果,摸 ${got} 张。`)}}
_finalize_pending_draw_effect(){const p=this.pending_draw_effect;if(!p)return;const forced=p.forced_next_player,rem=i32(p.remaining,0),eff=String(p.effect_card||"").trim();if(rem>0)this._log(`${eff} 剩余未执行摸牌 ${rem} 张,效果结束。`);const post=p.post_removed_special,cancel=!!p.canceled_by_counter;this.pending_draw_effect=null;if(!cancel&&Array.isArray(post)&&post.length===3)this._apply_removed_special_effects(post[0],post[1],post[2]);if(this.winner!==null)return;if(forced!==null&&forced!==undefined){this._begin_turn_from(i32(forced,this.current_player));return}if(this.actions_left<=0)this._end_turn_internal()}
_handle_pending_draw_response(pid,decision){const p=this.pending_draw_effect;if(!p)return[false,"当前没有待响应的加牌效果。"];const wp=this._pending_waiting_player();if(wp===null||pid!==wp)return[false,"当前不是你的加牌响应时机。"];const d=String(decision||"").trim().toLowerCase(),fa=!!p.follow_allowed,ca=!!p.counter_allowed,eff=String(p.effect_card||"").trim(),src=i32(p.source_player,this.current_player),pn=this.players[pid].name;p.awaiting_player=null;p.follow_allowed=false;p.counter_allowed=false;
if(d==="follow"){if(!fa)return[false,"当前不允许跟牌传递。"];if(!this._remove_from_hand(pid,eff,1))return[false,`你没有可跟牌的 ${eff}。`];this.discard.push(eff);p.source_player=pid;p.cursor=this._next_index(pid);p.follow_open=true;p.forced_next_player=this._next_index(pid);this._apply_radiation_for_player(pid);this._log(`${pn} 跟牌打出 ${eff},将效果传递给下家。`);this._check_winner(pid);if(this.winner!==null){this.pending_draw_effect=null;return[true,"已跟牌。"]}this._advance_pending_draw_effect();return[true,"已跟牌。"]}
if(d==="counter"){if(!ca)return[false,"当前不允许加钠反制。"];if(!this._remove_from_hand(pid,t.AS,1))return[false,"你没有可用于反制的加钠。"];this.discard.push(t.AS);this._apply_add_sodium_effect(pid,"加钠(反制)");const up=this._prev_index(pid);if(src===up){p.forced_next_player=this._next_index(pid);this._apply_radiation_for_player(pid);this._log(`${pn} 对上家的 ${eff} 使用加钠反制,视为耗尽本回合。`)}else{this._draw_cards(pid,1);p.forced_next_player=this._next_index(src);this._log(`${pn} 对 ${this.players[src].name} 的 ${eff} 使用加钠反制,额外摸1张,回合改到 ${this.players[p.forced_next_player].name}。`)}p.remaining=0;p.canceled_by_counter=true;this._check_winner(pid);this._finalize_pending_draw_effect();return[true,"已使用加钠反制。"]}
if(!["draw","accept","pass"].includes(d))return[false,"无效的响应指令。"];const rem=i32(p.remaining,0),lim=this._draw_limit_per_player(rem),got=this._draw_cards(pid,lim).length;p.remaining=Math.max(0,rem-got);p.cursor=this._next_index(pid);p.follow_open=false;this._log(`${pn} 选择执行摸牌,摸 ${got} 张。`);this._advance_pending_draw_effect();return[true,"已执行摸牌。"]}
on_player_disconnected(pid){const wp=this._pending_waiting_player();if(wp===null||wp!==pid)return;this._log(`${this.players[pid].name} 在响应阶段离线,按“摸牌”处理。`);this._handle_pending_draw_response(pid,"draw")}
_apply_add_sodium_effect(pid,label){const [rg,ru]=this._clear_board_cards();this._log(`${this.players[pid].name} 使用 ${label}。清空场面,全员摸1张。`);this._draw_all_from(pid,1);this._apply_removed_special_effects(pid,rg,ru)}
_play_wangzha(pid){if(!this._consume_action())return[false,"没有剩余行动次数。"];const h=this.players[pid].hand;if(i32(h[t.Acid],0)<1||i32(h[t.Alk],0)<1){this.actions_left+=1;return[false,"王炸需要手牌中同时有1张强酸和1张强碱。"]}this._remove_from_hand(pid,t.Acid,1);this._remove_from_hand(pid,t.Alk,1);this.discard.push(t.Acid,t.Alk);this._apply_add_sodium_effect(pid,"王炸(强酸+强碱)");this._check_winner(pid);return[true,"已打出王炸。"]}
_play_ion(pid,card,count){if(!ION_SET.has(card))return[false,`${card} 不是离子牌。`];if(count<=0)return[false,"数量必须为正整数。"];if(!this._consume_action())return[false,"没有剩余行动次数。"];if(!this._remove_from_hand(pid,card,count)){this.actions_left+=1;return[false,`手牌中 ${card} 数量不足。`]}padd(this.solution,card,count);const reacted=this._resolve_reaction_with_ion(card),extra=reacted>0?Math.floor((reacted+1)/2):0;this.actions_left+=extra;this._log(`${this.players[pid].name} 打出 ${card} ×${count}。参与反应牌数=${reacted},额外行动${extra}。`);this._check_winner(pid);return[true,"离子牌已打出。"]}
_play_special(pid,card){if(!SPECIAL_SET.has(card))return[false,`${card} 不是特殊牌。`];if(!this._consume_action())return[false,"没有剩余行动次数。"];if(!this._remove_from_hand(pid,card,1)){this.actions_left+=1;return[false,`手牌中 ${card} 数量不足。`]}if(card===t.Au)this.gold_count+=1;else if(card===t.U)this.uranium_timers.push(3);this._log(`${this.players[pid].name} 打出特殊牌 ${card}。`);this._check_winner(pid);return[true,"特殊牌已打出。"]}
_play_from_impurity(pid){while(true){const d=this._draw_cards(pid,1);if(!d.length){this._log("杂质触发:牌堆为空。");return}const card=d[0];this._log(`杂质摸到 ${card}。`);if(ION_SET.has(card)){this._remove_from_hand(pid,card,1);padd(this.solution,card,1);const reacted=this._resolve_reaction_with_ion(card),extra=reacted>0?Math.floor((reacted+1)/2):0;this.actions_left+=extra;this._log(`杂质自动打出离子 ${card}。参与反应牌数=${reacted},额外行动${extra}。`);return}if(SPECIAL_SET.has(card)){this._remove_from_hand(pid,card,1);if(card===t.Au)this.gold_count+=1;else this.uranium_timers.push(3);this._log(`杂质自动打出特殊牌 ${card}。`);return}this._remove_from_hand(pid,card,1);this.discard.push(card);this._log(`杂质弃置 ${card}(非离子/非特殊牌)。`)}}
_play_operation(pid,card,action){if(!FUNCTION_SET.has(card))return[false,`${card} 不是功能牌。`];if(!this._consume_action())return[false,"没有剩余行动次数。"];if(!this._remove_from_hand(pid,card,1)){this.actions_left+=1;return[false,`手牌中 ${card} 数量不足。`]}this.discard.push(card);const n=this.players[pid].name;let de=null,dt=0,post=null;
if(card===t.Acid){const r=this._consume_reactable_ions_only(t.H);de=card;dt=r;this._log(r>0?`${n} 使用 ${t.Acid}。可反应离子数=${r}。将按顺序结算摸牌(可跟牌/反制)。`:`${n} 使用 ${t.Acid},但无可反应离子。`)}
else if(card===t.Alk){const r=this._consume_reactable_ions_only(t.OH);de=card;dt=r;this._log(r>0?`${n} 使用 ${t.Alk}。可反应离子数=${r}。将按顺序结算摸牌(可跟牌/反制)。`:`${n} 使用 ${t.Alk},但无可反应离子。`)}
else if(card===t.Enough){const ion=String(action.target_ion||"").trim();let tc=Math.max(1,i32(action.target_count,1));this._draw_cards(pid,1);if(ION_SET.has(ion)&&i32(this.players[pid].hand[ion],0)>=tc&&this._remove_from_hand(pid,ion,tc)){padd(this.solution,ion,tc);const r=this._consume_reactable_ions_only(ion);this.actions_left+=r;this._log(`${n} 使用 ${t.Enough},打出 ${ion} ×${tc}。可反应离子数=${r},额外行动${r}。`)}else this._log(`${n} 使用 ${t.Enough},但未提供有效目标离子。`)}
else if(card===t.Imp){this._log(`${n} 使用 ${t.Imp}。`);this._play_from_impurity(pid)}
else if(card===t.Flt){const np=sum(this.precipitates);this.precipitates={};de=card;dt=np;this._log(np>0?`${n} 使用 ${t.Flt}。沉淀组数=${np}。将按顺序结算摸牌(可跟牌/反制)。`:`${n} 使用 ${t.Flt},场上无可计数沉淀。`);const rg=this.gold_count,ru=this.uranium_timers.length;this.gold_count=0;this.uranium_timers=[];post=[pid,rg,ru]}
else if(card===t.Fde){let c=0;for(const ion of TABLE.Color){c+=i32(this.solution[ion],0);if(i32(this.solution[ion],0)>0)delete this.solution[ion]}de=card;dt=2*c;this._log(dt>0?`${n} 使用 ${t.Fde}。有色离子数=${c}。将按顺序结算摸牌(可跟牌/反制)。`:`${n} 使用 ${t.Fde},但无有色离子。`)}
else if(card===t.AW){const g=sum(this.gases);this.gases={};de=card;dt=2*g;this._log(dt>0?`${n} 使用 ${t.AW}。气体组数=${g}。将按顺序结算摸牌(可跟牌/反制)。`:`${n} 使用 ${t.AW},但无气体。`)}
else if(card===t.Dtl){const blk=sum(this.precipitates)>0||sum(this.gases)>0||this.gold_count>0||this.uranium_timers.length>0;if(blk)this._log(`${n} 使用 ${t.Dtl},但当前无效。`);else{const ionNum=sum(this.solution);this.solution={};de=card;dt=ionNum;this._log(ionNum>0?`${n} 使用 ${t.Dtl}。移除离子数=${ionNum}。将按顺序结算摸牌(可跟牌/反制)。`:`${n} 使用 ${t.Dtl},溶液区无离子。`)}}
else if(card===t.AS)this._apply_add_sodium_effect(pid,t.AS)
else if(card===t.Ban){const tar=this._next_index(pid);padd(this.skip_turns,tar,1);this._log(`${n} 使用 ${t.Ban}。${this.players[tar].name} 的下回合将被跳过。`)}
else if(card===t.Rev){if(this.players.length===2){const tar=this._next_index(pid);padd(this.skip_turns,tar,1);this._log(`${n} 在双人模式下使用 ${t.Rev},按 ${t.Ban} 处理。跳过 ${this.players[tar].name}。`)}else{this.direction*=-1;this._log(`${n} 使用 ${t.Rev}。出牌方向改为 ${this._direction_hint_text()}。`)}}
else this._log(`${n} 打出功能牌 ${card}(无实际效果)。`);
if(de!==null)this._start_pending_draw_effect(pid,de,dt,post);this._check_winner(pid);return[true,"功能牌已打出。"]}
apply_action(pid,action){if(this.winner!==null)return[false,"对局已结束。"];if(!action||typeof action!=="object")return[false,"操作数据无效。"];const k=action.kind,wp=this._pending_waiting_player();if(wp!==null){if(pid!==wp)return[false,`当前等待 ${this.players[wp].name} 响应摸牌效果。`];if(k!=="respond_draw_effect")return[false,"当前必须先响应摸牌效果。"];return this._handle_pending_draw_response(pid,String(action.decision||"draw").trim().toLowerCase())}
if(k==="respond_draw_effect")return[false,"当前没有待响应的摸牌效果。"];if(pid!==this.current_player)return[false,"未轮到你行动。"];if(k==="end_turn"){if(!this.played_card_this_turn)return[false,"本回合尚未出牌,不能直接结束回合。"];this._end_turn_internal();return[true,"已结束回合。"]}
if(this.actions_left<=0)return[false,"没有剩余行动次数,请先结束回合。"];let ok=false,msg="";
if(k==="play_ion")[ok,msg]=this._play_ion(pid,String(action.card||"").trim(),i32(action.count,1));
else if(k==="play_special")[ok,msg]=this._play_special(pid,String(action.card||"").trim());
else if(k==="play_operation")[ok,msg]=this._play_operation(pid,String(action.card||"").trim(),action);
else if(k==="play_wangzha")[ok,msg]=this._play_wangzha(pid);
else return[false,`不支持的操作类型: ${k}`];
if(ok&&pid===this.current_player)this.played_card_this_turn=true;
if(ok&&this.winner===null&&this.pending_draw_effect===null&&pid===this.current_player&&this.actions_left<=0){this._end_turn_internal();msg=`${msg} 行动次数已耗尽,自动结束回合。`}
return[ok,msg]}
serialize_state(viewer=null,reveal=false,mode="local"){const pdata=[];for(let i=0;i<this.players.length;i++){const p=this.players[i],e={name:p.name,hand_size:p.hand_size};if(reveal||(viewer!==null&&i===viewer))e.hand=dsorted(p.hand);pdata.push(e)}
const wp=this._pending_waiting_player();let pp={active:false};if(this.pending_draw_effect!==null){const p=this.pending_draw_effect,src=i32(p.source_player,this.current_player),eff=String(p.effect_card||"").trim();pp={active:true,id:i32(p.id,0),source_player:src,source_name:(src>=0&&src<this.players.length)?this.players[src].name:"",effect_card:eff,remaining:i32(p.remaining,0),awaiting_player:wp,awaiting_name:(wp!==null&&wp>=0&&wp<this.players.length)?this.players[wp].name:"",follow_allowed:!!p.follow_allowed,counter_allowed:!!p.counter_allowed}}
let canAct=false,canEnd=false;if(viewer!==null&&this.winner===null){if(wp!==null){canAct=viewer===wp;canEnd=false}else{canAct=viewer===this.current_player&&this.actions_left>0;canEnd=viewer===this.current_player&&this.actions_left>0&&this.played_card_this_turn}}
return{mode,game_started:true,you_index:viewer,current_player:this.current_player,actions_left:this.actions_left,direction:this.direction,direction_hint:this._direction_hint_text(),turn_number:this.turn_number,winner:this.winner,deck_size:this.deck.length,discard_size:this.discard.length,can_act:canAct,can_end_turn:canEnd,played_card_this_turn:this.played_card_this_turn,pending_response:pp,players:pdata,board:{solution:dsorted(this.solution),precipitates:p2d(this.precipitates),gases:p2d(this.gases),weak_electrolytes:p2d(this.weak_electrolytes),gold:this.gold_count,uranium:this.uranium_timers.length,skip_turns:Object.fromEntries(Object.entries(this.skip_turns).filter(([,t])=>i32(t,0)>0).map(([idx,t])=>[this.players[+idx].name,t]))},log:[...this.log]}}
}
class LocalController{constructor(names,hs=null){this.engine=new GameEngine(names,hs)}get_state(){let v=this.engine.current_player;const w=this.engine._pending_waiting_player();if(w!==null)v=w;return this.engine.serialize_state(v,false,"local")}act(action){let p=this.engine.current_player;const w=this.engine._pending_waiting_player();if(w!==null)p=w;return this.engine.apply_action(p,action)}close(){}}
const wsUrl=(host,port,proto="ws")=>{const p=(String(proto||"").toLowerCase()==="wss")?"wss":"ws";return `${p}://${hostForWsUrl(host)}:${i32(port,0)}`};
class BrowserClientConnection{
constructor({host,port,name,resumeIndex=null,resumeKey="",wsProto="ws"}){this.host=nh(host);this.port=i32(port,0);this.name=String(name||"玩家");this.ws_proto=(String(wsProto||"").toLowerCase()==="wss")?"wss":"ws";this.ws=null;this.state=null;this.error=null;this.my_index=null;this.room_size=null;this.running=false;this.resume_index=resumeIndex;this.resume_key=String(resumeKey||"").trim();this._hbTimer=null}
_send_packet(p){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)throw new Error("尚未连接到房间。");this.ws.send(JSON.stringify(p))}
connect(){if(pageIsHttps&&this.ws_proto==="ws"&&!isLocalHost(this.host)){this.error="当前页面为 HTTPS,浏览器可能阻止连接 ws:// 非本机地址。请改用 wss:// 或通过 HTTP 打开网页。";return}let ws=null;try{ws=new WebSocket(wsUrl(this.host,this.port,this.ws_proto))}catch(exc){this.error=`连接失败: ${exc}`;return}this.ws=ws;this.running=true;
ws.onopen=()=>{const j={type:"join",name:this.name};if(this.resume_index!==null&&this.resume_index!==undefined)j.resume_index=this.resume_index;if(this.resume_key)j.resume_key=this.resume_key;try{this._send_packet(j)}catch(exc){this.error=`加入失败: ${exc}`}};
ws.onmessage=(ev)=>{if(!this.running)return;let msg=null;try{msg=JSON.parse(String(ev.data||""))}catch(_){this.error="收到无效JSON数据。";return}const t=String(msg?.type||"");
if(t==="welcome"){this.my_index=i32(msg.player_index,this.my_index);this.room_size=i32(msg.room_size,this.room_size||2);this.resume_index=this.my_index;const k=String(msg.reconnect_key||"").trim();if(k)this.resume_key=k}
else if(t==="state")this.state=msg.state;
else if(t==="error")this.error=String(msg.message||"未知错误")};
ws.onerror=()=>{if(this.running)this.error="网络连接异常。"};
ws.onclose=()=>{if(this.running)this.error=this.error||"与房间连接已断开。"};
this._hbTimer=setInterval(()=>{if(!this.running)return;if(this.ws&&this.ws.readyState===WebSocket.OPEN){try{this._send_packet({type:"h"})}catch(exc){this.error=`心跳发送异常: ${exc}`}}},HEARTBEAT_INTERVAL_SECONDS*1000)}
send_action(a){try{this._send_packet({type:"action",data:a});return[true,"操作已发送。"]}catch(exc){return[false,`发送失败: ${exc}`]}}
get_state(){if(this.state!==null)return this.state;const connMsg=(this.ws&&this.ws.readyState===WebSocket.OPEN)?"已连接,等待房间人数凑齐并开始对局...":"正在连接房间...";return{mode:"client",game_started:false,message:connMsg,you_index:this.my_index,room_size:this.room_size||2,players:[],board:{},log:this.error?[this.error]:[]}}
wait_welcome(timeoutMs=5000){const timeout=Math.max(500,i32(timeoutMs,5000));return new Promise((resolve,reject)=>{const t0=Date.now();const tick=()=>{if(this.my_index!==null&&this.my_index!==undefined){resolve({player_index:this.my_index,room_size:this.room_size||2});return}const err=String(this.error||"").trim();if(err){reject(new Error(err));return}if(!this.running){reject(new Error("与房间连接已断开。"));return}if(Date.now()-t0>=timeout){reject(new Error("连接超时,未收到服务器确认。"));return}setTimeout(tick,80)};tick()})}
get_resume_info(){return[this.resume_index,this.resume_key]}
close(){this.running=false;if(this._hbTimer!==null){clearInterval(this._hbTimer);this._hbTimer=null}if(this.ws){if(this.ws.readyState===WebSocket.OPEN){try{this._send_packet({type:"bye"})}catch(_){ }}try{this.ws.close()}catch(_){ }this.ws=null}}
}
class ClientController{constructor({host,port,name,resumeIndex=null,resumeKey="",wsProto="ws"}){this.conn=new BrowserClientConnection({host,port,name,resumeIndex,resumeKey,wsProto});this.conn.connect()}get_state(){return this.conn.get_state()}act(a){return this.conn.send_action(a)}wait_ready(timeoutMs=5000){return this.conn.wait_welcome(timeoutMs)}get_resume_info(){return this.conn.get_resume_info()}close(){this.conn.close()}}
const mkDialog=(title)=>{const d=document.createElement("dialog");d.className="modal";d.innerHTML='<div class="mhead"></div><div class="mbody"></div><div class="mbtns"></div>';d.querySelector(".mhead").textContent=String(title||"");document.body.appendChild(d);return d};
const closeDialog=(d)=>{try{d.close()}catch(_){ }d.remove()};
const alertDialog=(title,msg)=>new Promise(res=>{const d=mkDialog(title),b=d.querySelector(".mbody"),btns=d.querySelector(".mbtns");btns.classList.add("single");b.innerHTML=`<div class="mnote">${String(msg||"")}</div>`;const ok=document.createElement("button");ok.textContent="确定";ok.onclick=()=>{closeDialog(d);res()};btns.appendChild(ok);d.addEventListener("cancel",e=>{e.preventDefault();closeDialog(d);res()});d.showModal()});
const formDialog=({title,fields,note="",confirmText="确定"})=>new Promise(res=>{const d=mkDialog(title),body=d.querySelector(".mbody"),inputs={};for(const f of fields){const line=document.createElement("div");line.className="mline";const lab=document.createElement("label");lab.textContent=f.label;lab.htmlFor=`dlg_${f.id}`;const ip=document.createElement("input");ip.id=`dlg_${f.id}`;ip.type=f.type||"text";ip.value=String(f.value??"");if(f.type==="number"){if(f.min!==undefined)ip.min=String(f.min);if(f.max!==undefined)ip.max=String(f.max);ip.step=String(f.step??1)}if(f.placeholder)ip.placeholder=f.placeholder;line.appendChild(lab);line.appendChild(ip);body.appendChild(line);inputs[f.id]=ip}
if(note){const n=document.createElement("div");n.className="mnote";n.textContent=note;body.appendChild(n)}const btns=d.querySelector(".mbtns");const c=document.createElement("button");c.textContent="取消";c.onclick=()=>{closeDialog(d);res(null)};const ok=document.createElement("button");ok.textContent=confirmText;ok.onclick=()=>{const v={};for(const f of fields){const ip=inputs[f.id];v[f.id]=(f.type==="number")?i32(ip.value,f.value??0):String(ip.value??"")}closeDialog(d);res(v)};btns.appendChild(c);btns.appendChild(ok);d.addEventListener("cancel",e=>{e.preventDefault();closeDialog(d);res(null)});d.showModal();const f0=fields[0];if(f0&&inputs[f0.id]){inputs[f0.id].focus();inputs[f0.id].select()}});
const listDialog=({title,prompt,options,display=(x)=>String(x)})=>new Promise(res=>{const d=mkDialog(title),body=d.querySelector(".mbody"),btns=d.querySelector(".mbtns");const t=document.createElement("div");t.className="mnote";t.textContent=prompt;body.appendChild(t);const sel=document.createElement("select");sel.size=Math.min(12,Math.max(4,options.length));sel.style.minHeight=`${Math.min(12,Math.max(4,options.length))*28}px`;for(const o of options){const it=document.createElement("option");it.value=String(o);it.textContent=display(o);sel.appendChild(it)}if(options.length>0)sel.selectedIndex=0;const line=document.createElement("div");line.className="mline";line.style.gridTemplateColumns="1fr";line.appendChild(sel);body.appendChild(line);const c=document.createElement("button");c.textContent="取消";c.onclick=()=>{closeDialog(d);res(null)};const ok=document.createElement("button");ok.textContent="确定";ok.onclick=()=>{const i=sel.selectedIndex;if(i<0||i>=options.length)return;const v=options[i];closeDialog(d);res(v)};btns.appendChild(c);btns.appendChild(ok);sel.ondblclick=()=>{const i=sel.selectedIndex;if(i<0||i>=options.length)return;const v=options[i];closeDialog(d);res(v)};d.addEventListener("cancel",e=>{e.preventDefault();closeDialog(d);res(null)});d.showModal();sel.focus()});
const ionCountDialog=(card,maxCount)=>new Promise(res=>{const options=[];for(let i=1;i<=maxCount;i++)options.push(i);const d=mkDialog("选择离子出牌数量"),body=d.querySelector(".mbody"),btns=d.querySelector(".mbtns");const txt=document.createElement("div");txt.className="mnote";txt.textContent=`请选择 ${cname(card)} 的出牌数量(最多 ${maxCount} 张)`;body.appendChild(txt);const sel=document.createElement("select");sel.size=Math.min(12,maxCount);sel.style.minHeight=`${Math.min(12,maxCount)*28}px`;for(const n of options){const op=document.createElement("option");op.value=String(n);op.textContent=`${n} 张`;sel.appendChild(op)}sel.selectedIndex=0;const line=document.createElement("div");line.className="mline";line.style.gridTemplateColumns="1fr";line.appendChild(sel);body.appendChild(line);
const q=document.createElement("div");q.className="mbtns";q.style.padding="0";q.style.marginTop="4px";const b1=document.createElement("button");b1.textContent="1张";b1.onclick=()=>{sel.selectedIndex=0;sel.focus()};const bh=document.createElement("button");bh.textContent="半数";bh.onclick=()=>{sel.selectedIndex=Math.max(0,Math.floor((maxCount-1)/2));sel.focus()};const ba=document.createElement("button");ba.textContent="全出";ba.onclick=()=>{sel.selectedIndex=maxCount-1;sel.focus()};q.appendChild(b1);q.appendChild(bh);q.appendChild(ba);body.appendChild(q);
const c=document.createElement("button");c.textContent="取消";c.onclick=()=>{closeDialog(d);res(null)};const ok=document.createElement("button");ok.textContent="确定";ok.onclick=()=>{const i=sel.selectedIndex;if(i<0||i>=options.length)return;closeDialog(d);res(options[i])};btns.appendChild(c);btns.appendChild(ok);sel.ondblclick=()=>{const i=sel.selectedIndex;if(i<0||i>=options.length)return;closeDialog(d);res(options[i])};d.addEventListener("cancel",e=>{e.preventDefault();closeDialog(d);res(null)});d.showModal();sel.focus()});
const pendingDialog=({sourceName,effectCard,remaining,followAllowed,counterAllowed})=>new Promise(res=>{const d=mkDialog("摸牌效果响应"),body=d.querySelector(".mbody"),btns=d.querySelector(".mbtns");const a=document.createElement("div");a.className="mnote";a.textContent=`${sourceName} 打出 ${cname(effectCard)},当前轮到你处理摸牌(剩余 ${remaining} 张)。`;body.appendChild(a);const b=document.createElement("div");b.className="mnote";b.textContent="可选操作:直接摸牌;或按规则跟牌传递/加钠反制。";body.appendChild(b);btns.classList.add("single");btns.innerHTML="";const row=document.createElement("div");row.style.display="grid";row.style.gridTemplateColumns=(followAllowed||counterAllowed)?"repeat(3,1fr)":"1fr";row.style.gap="8px";
const bd=document.createElement("button");bd.textContent="摸牌";bd.onclick=()=>{closeDialog(d);res("draw")};row.appendChild(bd);
if(followAllowed){const bf=document.createElement("button");bf.textContent="跟牌传递";bf.onclick=()=>{closeDialog(d);res("follow")};row.appendChild(bf)}
if(counterAllowed){const bc=document.createElement("button");bc.textContent="加钠反制";bc.onclick=()=>{closeDialog(d);res("counter")};row.appendChild(bc)}
btns.appendChild(row);d.addEventListener("cancel",e=>{e.preventDefault();closeDialog(d);res(null)});d.showModal()});
class App{
constructor(){
this.controller=null;this.lastState=null;this.handCardsByIndex=[];this.selectedCard=null;this._canPlay=false;this._awaitResp=false;this._respToken=null;
this.localNames="玩家1,玩家2";this.localPlayerCount=2;this.localHandSize=10;
this.remoteServerHost="127.0.0.1";this.remoteWsProto="ws";this.remoteName="玩家";this.remoteRoomSize=3;this.remoteHandSize=10;
this.joinName="玩家";this.joinHost="127.0.0.1";this.joinWsProto="ws";this.joinPort="";
this.dom={status:document.getElementById("status"),btnLocal:document.getElementById("btnLocal"),btnRemote:document.getElementById("btnRemote"),btnJoin:document.getElementById("btnJoin"),btnStop:document.getElementById("btnStop"),hand:document.getElementById("handList"),sel:document.getElementById("sel"),btnPlay:document.getElementById("btnPlay"),btnWang:document.getElementById("btnWangzha"),turn:document.getElementById("turn"),players:document.getElementById("players"),board:document.getElementById("board"),log:document.getElementById("log")};
this._bind();this._set_action_controls_enabled(false,false);this._render_players_table([],null,null);this._set_text(this.dom.board,"",{preserveY:true});this._set_text(this.dom.log,"",{scrollToEnd:true});this._pollTimer=setInterval(()=>this._poll(),400)
}
_bind(){this.dom.btnLocal.onclick=()=>this._open_local();this.dom.btnRemote.onclick=()=>this._open_remote();this.dom.btnJoin.onclick=()=>this._open_join();this.dom.btnStop.onclick=()=>this._stop();this.dom.btnPlay.onclick=()=>this._play_selected();this.dom.btnWang.onclick=()=>this._play_wangzha();window.addEventListener("beforeunload",()=>this._stop({silent:true}))}
_status(msg){this.dom.status.textContent=String(msg||"")}
async _open_local(){const v=await formDialog({title:"本地对局设置",fields:[{id:"names",label:"玩家名(逗号分隔)",type:"text",value:this.localNames},{id:"player_count",label:"游戏人数",type:"number",min:2,max:8,step:1,value:this.localPlayerCount},{id:"hand_size",label:"初始手牌数",type:"number",min:2,max:10,step:1,value:this.localHandSize}],note:"留空玩家名将自动生成“玩家1,玩家2...”",confirmText:"开始本地对局"});if(!v)return;this.localNames=String(v.names||"");this.localPlayerCount=i32(v.player_count,2);this.localHandSize=i32(v.hand_size,10);this._start_local()}
async _show_access(host,port){const d=mkDialog("房间信息"),body=d.querySelector(".mbody"),btns=d.querySelector(".mbtns");const l1=document.createElement("div");l1.className="mline";l1.innerHTML='<label>IP地址</label><textarea readonly></textarea>';l1.querySelector("textarea").value=String(host);body.appendChild(l1);const l2=document.createElement("div");l2.className="mline";l2.innerHTML='<label>端口</label><textarea readonly></textarea>';l2.querySelector("textarea").value=String(port);body.appendChild(l2);
const row=document.createElement("div");row.className="mbtns";row.style.padding="0";const b1=document.createElement("button");b1.textContent="复制IP";b1.onclick=async()=>{try{await navigator.clipboard.writeText(String(host));this._status(`已复制IP:${host}`)}catch(_){this._status("IP复制失败。")}};const b2=document.createElement("button");b2.textContent="复制端口";b2.onclick=async()=>{try{await navigator.clipboard.writeText(String(port));this._status(`已复制端口:${port}`)}catch(_){this._status("端口复制失败。")}};row.appendChild(b1);row.appendChild(b2);body.appendChild(row);const note=document.createElement("div");note.className="mnote";note.textContent="房间信息仅在此处展示,请及时复制。";body.appendChild(note);
btns.classList.add("single");const c=document.createElement("button");c.textContent="关闭";c.onclick=()=>closeDialog(d);btns.appendChild(c);d.addEventListener("cancel",e=>{e.preventDefault();closeDialog(d)});d.showModal()}
async _open_remote(){const d=mkDialog("向服务器申请联机"),body=d.querySelector(".mbody"),btns=d.querySelector(".mbtns"),spinFrames=["◐","◓","◑","◒"];const addLine=(label,type,value,min,max,step)=>{const line=document.createElement("div");line.className="mline";const lab=document.createElement("label");lab.textContent=label;const ip=document.createElement("input");ip.type=type;ip.value=String(value??"");if(type==="number"){if(min!==undefined)ip.min=String(min);if(max!==undefined)ip.max=String(max);ip.step=String(step??1)}line.appendChild(lab);line.appendChild(ip);body.appendChild(line);return ip};const ipHost=addLine("服务器IP","text",this.remoteServerHost),ipName=addLine("你的昵称","text",this.remoteName),ipRoom=addLine("房间人数","number",this.remoteRoomSize,2,8,1),ipHand=addLine("初始手牌数","number",this.remoteHandSize,2,10,1);const note=document.createElement("div");note.className="mnote";note.textContent="所有请求都由当前设备浏览器直接发起,不经过网页部署服务器。可在服务器地址前使用 ws:// 或 wss://。";body.appendChild(note);const inputs=[ipHost,ipName,ipRoom,ipHand];const btnCancel=document.createElement("button");btnCancel.textContent="取消";const btnOk=document.createElement("button");btnOk.textContent="申请并加入对局";btns.appendChild(btnCancel);btns.appendChild(btnOk);let pending=false,spinTimer=null,spinIdx=0;const setPending=(on)=>{pending=!!on;for(const ip of inputs)ip.disabled=pending;btnCancel.disabled=pending;btnOk.disabled=pending;if(spinTimer!==null){clearInterval(spinTimer);spinTimer=null}if(pending){btnOk.textContent=`${spinFrames[0]} 申请并加入对局`;spinIdx=1;spinTimer=setInterval(()=>{if(!pending)return;btnOk.textContent=`${spinFrames[spinIdx%spinFrames.length]} 申请并加入对局`;spinIdx++},120)}else btnOk.textContent="申请并加入对局"};const closeIfIdle=()=>{if(pending)return;closeDialog(d)};btnCancel.onclick=()=>closeIfIdle();d.addEventListener("cancel",e=>{e.preventDefault();closeIfIdle()});btnOk.onclick=async()=>{if(pending)return;const hs=hostSpec(ipHost.value),host=hs.host,proto=hs.proto,serverPort=i32(hs.port,0),name=String(ipName.value||"玩家").trim()||"玩家",rs=i32(ipRoom.value,3),hcnt=i32(ipHand.value,10);if(serverPort>0&&serverPort!==5000){await alertDialog("错误","服务器主端口固定为 5000,请仅填写 IP,或填写 IP:5000。");return}if(rs<2||rs>8){await alertDialog("错误","远程房间人数必须在 2~8 之间。");return}if(hcnt<2||hcnt>10){await alertDialog("错误","远程初始手牌数必须在 2~10 之间。");return}this.remoteServerHost=host;this.remoteWsProto=proto;this.remoteName=name;this.remoteRoomSize=rs;this.remoteHandSize=hcnt;note.textContent="正在向服务器申请房间,请稍候...";setPending(true);let ok=false;try{ok=await this._start_remote()}finally{setPending(false)}if(ok){note.textContent="申请成功,可继续在本窗口修改参数后再次申请。";closeDialog(d)}else note.textContent="申请失败,请修改参数后重试。"};d.showModal();ipHost.focus();ipHost.select()}
async _open_join(){const d=mkDialog("加入服务器房间"),body=d.querySelector(".mbody"),btns=d.querySelector(".mbtns"),spinFrames=["◐","◓","◑","◒"];const addLine=(label,type,value,min,max,step)=>{const line=document.createElement("div");line.className="mline";const lab=document.createElement("label");lab.textContent=label;const ip=document.createElement("input");ip.type=type;ip.value=String(value??"");if(type==="number"){if(min!==undefined)ip.min=String(min);if(max!==undefined)ip.max=String(max);ip.step=String(step??1)}line.appendChild(lab);line.appendChild(ip);body.appendChild(line);return ip};const ipName=addLine("你的昵称","text",this.joinName),ipHost=addLine("服务器地址","text",this.joinHost),ipPort=addLine("对局端口","number",this.joinPort,1,65535,1);const note=document.createElement("div");note.className="mnote";note.textContent="连接请求由当前设备浏览器直接发起。注意:5000 是主端口,不是对局端口。";body.appendChild(note);const inputs=[ipName,ipHost,ipPort];const btnCancel=document.createElement("button");btnCancel.textContent="取消";const btnOk=document.createElement("button");btnOk.textContent="加入对局";btns.appendChild(btnCancel);btns.appendChild(btnOk);let pending=false,spinTimer=null,spinIdx=0;const setPending=(on)=>{pending=!!on;for(const ip of inputs)ip.disabled=pending;btnCancel.disabled=pending;btnOk.disabled=pending;if(spinTimer!==null){clearInterval(spinTimer);spinTimer=null}if(pending){btnOk.textContent=`${spinFrames[0]} 加入对局`;spinIdx=1;spinTimer=setInterval(()=>{if(!pending)return;btnOk.textContent=`${spinFrames[spinIdx%spinFrames.length]} 加入对局`;spinIdx++},120)}else btnOk.textContent="加入对局"};const closeIfIdle=()=>{if(pending)return;closeDialog(d)};btnCancel.onclick=()=>closeIfIdle();d.addEventListener("cancel",e=>{e.preventDefault();closeIfIdle()});btnOk.onclick=async()=>{if(pending)return;const name=String(ipName.value||"玩家").trim()||"玩家",hs=hostSpec(ipHost.value),host=hs.host,proto=hs.proto,embeddedPort=i32(hs.port,0),rawPort=String(ipPort.value??"").trim();let port=0;if(rawPort){port=i32(rawPort,0)}else if(embeddedPort>0){port=embeddedPort}if(embeddedPort>0&&rawPort&&port!==embeddedPort){await alertDialog("错误","地址中的端口与“对局端口”输入不一致,请统一后重试。");return}if(port<=0){await alertDialog("错误","端口输入无效。");return}if(port===5000){await alertDialog("错误","5000 是主端口,不能直接加入对局。请先“向服务器申请联机”或输入房主给出的对局端口。");return}this.joinName=name;this.joinHost=host;this.joinWsProto=proto;this.joinPort=port;note.textContent="正在连接房间并等待服务器确认,请稍候...";setPending(true);let ok=false;try{ok=await this._join()}finally{setPending(false)}if(ok){note.textContent="连接成功,可继续在本窗口修改参数后再次加入。";closeDialog(d)}else note.textContent="连接失败,请检查服务器地址和端口后重试。"};d.showModal();ipName.focus();ipName.select()}
_cur_hand(){if(!this.lastState)return{};const you=i32(this.lastState.you_index,-1),ps=Array.isArray(this.lastState.players)?this.lastState.players:[];if(you<0||you>=ps.length)return{};const h=ps[you]?.hand;return(h&&typeof h==="object")?h:{}}
_max_play(card){return Math.max(0,i32(this._cur_hand()[card],0))}
_selected(){if(!this.selectedCard){this._status("请先在手牌区选择一张牌。");return null}return this.selectedCard}
_selected_quiet(){return this.selectedCard}
_kind(card){if(ION_SET.has(card))return"离子牌";if(SPECIAL_SET.has(card))return"特殊牌";if(FUNCTION_SET.has(card))return"功能牌";return"未知牌型"}
_on_select(){if(!this.selectedCard){this.dom.sel.textContent="当前选中: 无";this._refresh_btns();return}const c=this.selectedCard;if(ION_SET.has(c)){const m=this._max_play(c);this.dom.sel.textContent=`当前选中: ${cname(c)}(${this._kind(c)},可出 1~${m} 张)`}else this.dom.sel.textContent=`当前选中: ${cname(c)}(${this._kind(c)})`;this._refresh_btns()}
_set_action_controls_enabled(canPlay,_canEnd){this._canPlay=!!canPlay;this._refresh_btns()}
_refresh_btns(){const c=this._selected_quiet(),can=this._canPlay&&!this._awaitResp,h=this._cur_hand(),wang=i32(h[t.Acid],0)>=1&&i32(h[t.Alk],0)>=1;let cps=false;if(can&&c){if(ION_SET.has(c))cps=this._max_play(c)>0;else if(SPECIAL_SET.has(c)||FUNCTION_SET.has(c))cps=true}this.dom.btnPlay.disabled=!cps;this.dom.btnWang.disabled=!(can&&wang)}
_render_players_table(players,current,you){const tb=this.dom.players;tb.innerHTML="";if(!Array.isArray(players)||players.length===0){const tr=document.createElement("tr"),td=document.createElement("td");td.textContent="暂无玩家信息";td.colSpan=2;tr.appendChild(td);tb.appendChild(tr);return}
const rp=document.createElement("tr"),rh=document.createElement("tr"),rs=document.createElement("tr");const hp=document.createElement("th");hp.textContent="玩家";const hh=document.createElement("th");hh.textContent="手牌数";const hs=document.createElement("th");hs.textContent="状态";rp.appendChild(hp);rh.appendChild(hh);rs.appendChild(hs);
for(let i=0;i<players.length;i++){const p=players[i]||{};let mk="";if(i===current)mk+="【当前】";if(i===you)mk+="【你】";const name=String(p.name??"").trim(),hRaw=p.hand_size,hTxt=(hRaw===null||hRaw===undefined)?"":String(hRaw).trim();let st=String(p.status??"").trim();if(!st&&name)st="在线";let seat=`${i+1}号位`;if(name)seat+=` ${name}`;if(mk)seat+=mk;const td1=document.createElement("td");td1.textContent=seat;rp.appendChild(td1);const td2=document.createElement("td");td2.textContent=hTxt;rh.appendChild(td2);const td3=document.createElement("td");td3.textContent=st;rs.appendChild(td3)}
tb.appendChild(rp);tb.appendChild(rh);tb.appendChild(rs)}
_fmt_dict(items){if(!items||typeof items!=="object"||Object.keys(items).length===0)return["(无)"];const ls=[];for(const [k,v] of Object.entries(items)){const dk=(k in TABLE.initCard)?cname(k):dtext(k);ls.push(`${dk}: ${v}`)}return ls}
_set_text(w,content,{scrollToEnd=false,preserveY=false}={}){const t=String(content??""),chg=w.value!==t,py=preserveY?w.scrollTop:0;if(chg){w.value=t;if(preserveY)w.scrollTop=py}if(scrollToEnd&&chg)w.scrollTop=w.scrollHeight;return chg}
_render_hand(hand){const box=this.dom.hand,ps=box.scrollTop,prev=this.selectedCard,cards=Object.keys(hand||{}).sort();box.innerHTML="";for(const c of cards){const b=document.createElement("button");b.type="button";b.className="hand-item";b.textContent=`${cname(c)} ×${hand[c]}`;b.dataset.card=c;b.onclick=()=>{this.selectedCard=c;this._hilite();this._on_select()};b.ondblclick=()=>{this.selectedCard=c;this._hilite();this._on_select();this._play_selected()};box.appendChild(b)}box.scrollTop=ps;this.handCardsByIndex=cards;if(prev&&cards.includes(prev))this.selectedCard=prev;else if(!cards.includes(this.selectedCard))this.selectedCard=null;this._hilite();this._on_select()}
_hilite(){for(const it of this.dom.hand.querySelectorAll(".hand-item")){if(it.dataset.card===this.selectedCard)it.classList.add("sel");else it.classList.remove("sel")}}
_remember_resume(ctrl){if(!(ctrl instanceof ClientController))return;const [idx,key]=ctrl.get_resume_info(),host=String(ctrl.conn.host||""),port=i32(ctrl.conn.port,0),name=String(ctrl.conn.name||"");if(idx===null&&!key)return;setResume(host,port,name,idx,key)}
_dispatch(action){if(!this.controller){this._status("当前没有活动会话。");return false}const kind=String(action.kind||"");if(this.lastState){const can=!!this.lastState.can_act,canEnd=!!(this.lastState.can_end_turn??can);if(kind==="end_turn"&&!canEnd){this._status("当前不能结束回合,请先出牌。");return false}if(kind!=="end_turn"&&!can){this._status("当前不是你的可操作时机。");return false}}
const [ok,msg]=this.controller.act(action);if(ok){this._status(dtext(String(msg)));this._poll();return true}this._status(`操作失败: ${dtext(String(msg))}`);return false}
async _play_selected(){if(this._awaitResp){this._status("当前需先处理摸牌响应。");return}const c=this._selected();if(!c)return;if(ION_SET.has(c)){await this._play_ion();return}if(SPECIAL_SET.has(c)){this._play_special();return}if(FUNCTION_SET.has(c)){await this._play_operation();return}this._status("无法识别该牌的牌型。")}
async _play_ion(){const c=this._selected();if(!c)return;if(!ION_SET.has(c)){this._status("选中的不是离子牌。");return}const m=this._max_play(c);if(m<=0){this._status("当前离子手牌数量不足。");return}if(m===1){this._dispatch({kind:"play_ion",card:c,count:1});return}const ch=await ionCountDialog(c,m);if(ch===null)return;this._dispatch({kind:"play_ion",card:c,count:ch})}
_play_special(){const c=this._selected();if(!c)return;if(!SPECIAL_SET.has(c)){this._status("选中的不是特殊牌。");return}this._dispatch({kind:"play_special",card:c})}
async _play_operation(){const c=this._selected();if(!c)return;if(!FUNCTION_SET.has(c)){this._status("选中的不是功能牌。");return}const action={kind:"play_operation",card:c};if(c===t.Enough&&this.lastState){const you=i32(this.lastState.you_index,-1),ps=Array.isArray(this.lastState.players)?this.lastState.players:[];let hand={};if(you>=0&&you<ps.length)hand=ps[you].hand||{};const ions=Object.keys(hand).filter(x=>ION_SET.has(x)&&i32(hand[x],0)>0).sort();if(!ions.length){this._status("使用“足量”前,手牌中至少要有一张离子牌。");return}const si=await listDialog({title:"足量目标离子",prompt:"请选择“足量”要指定的离子:",options:ions,display:(x)=>cname(x)});if(!si)return;const mx=i32(hand[si],0);if(mx<=0){this._status("所选离子数量不足。");return}let sc=1;if(mx>1){const cc=await ionCountDialog(si,mx);if(cc===null)return;sc=cc}action.target_ion=si;action.target_count=sc}this._dispatch(action)}
_play_wangzha(){if(this.lastState){const you=i32(this.lastState.you_index,-1),ps=Array.isArray(this.lastState.players)?this.lastState.players:[];if(you>=0&&you<ps.length){const h=ps[you].hand||{};if(i32(h[t.Acid],0)<1||i32(h[t.Alk],0)<1){this._status("王炸需要手牌中同时有“强酸”和“强碱”。");return}}}this._dispatch({kind:"play_wangzha"})}
_stop({silent=false}={}){this._remember_resume(this.controller);if(this.controller){try{this.controller.close()}catch(_){ }this.controller=null}this.lastState=null;this.handCardsByIndex=[];this.selectedCard=null;this._awaitResp=false;this._respToken=null;this.dom.hand.innerHTML="";this.dom.sel.textContent="当前选中: 无";this._set_action_controls_enabled(false,false);if(!silent)this._status("已停止当前会话。")}
_start_local(){const pc=i32(this.localPlayerCount,0);if(pc<2||pc>8){alertDialog("错误","本地人数必须在 2~8 之间。");return}const hs=i32(this.localHandSize,0);if(hs<2||hs>10){alertDialog("错误","初始手牌数必须在 2~10 之间。");return}
let names=String(this.localNames||"").split(",").map(x=>x.trim()).filter(x=>x);if(!names.length)names=Array.from({length:pc},(_,i)=>`玩家${i+1}`);else if(names.length<pc){for(let i=names.length;i<pc;i++)names.push(`玩家${i+1}`)}else if(names.length>pc)names=names.slice(0,pc);
this._stop({silent:true});this.controller=new LocalController(names,hs);this.localNames=names.join(",");this._status(`本地对局已开始,人数 ${names.length},初始手牌数 ${hs}。`);this._poll()}
async _req_remote_port(host,roomSize,hand,wsProto="ws"){const proto=(String(wsProto||"").toLowerCase()==="wss")?"wss":"ws";if(pageIsHttps&&proto==="ws"&&!isLocalHost(host))throw new Error("当前页面为 HTTPS,浏览器可能阻止连接 ws:// 非本机地址。请改用 wss:// 或通过 HTTP 打开网页。");const fallback=()=>new Promise((resolve,reject)=>{let done=false,tm=null,ws=null,opened=false;const fail=(err)=>{if(done)return;done=true;if(tm!==null)clearTimeout(tm);try{if(ws)ws.close()}catch(_){ }reject(err instanceof Error?err:new Error(String(err||"主服务器连接失败。")))};try{ws=new WebSocket(wsUrl(host,MAIN_PORT,proto))}catch(exc){fail(exc);return}
tm=setTimeout(()=>fail(new Error("等待主服务器分配端口超时。")),5000);
ws.onopen=()=>{opened=true;try{ws.send(JSON.stringify({type:"create_room",protocol:"web",room_size:roomSize,initial_hand_size:hand}))}catch(exc){fail(exc)}};
ws.onmessage=(ev)=>{let msg=null;try{msg=JSON.parse(String(ev.data||""))}catch(_){fail(new Error("主服务器返回了无法识别的数据。"));return}const t=String(msg?.type||"").trim();if(t==="room_created"){const port=i32(msg.port,0);if(port<=0){fail(new Error("主服务器返回的房间端口无效。"));return}done=true;if(tm!==null)clearTimeout(tm);try{ws.close()}catch(_){ }resolve({host,port});return}if(t==="error"){fail(new Error(String(msg.message||"主服务器返回错误。")));return}};
ws.onerror=()=>fail(new Error("主服务器连接失败。"));
ws.onclose=()=>{if(done)return;if(!opened){fail(new Error("主端口未完成 WebSocket 握手。请确认服务器为新版 server.py/server.exe,并开放 5000 端口。"));return}fail(new Error("主服务器连接已断开。"))}});
if(typeof Worker==="undefined")return await fallback();
return await new Promise((resolve,reject)=>{let done=false,url="",worker=null;const finish=(ok,payload)=>{if(done)return;done=true;try{if(worker)worker.terminate()}catch(_){ }if(url)try{URL.revokeObjectURL(url)}catch(_){ }if(ok)resolve(payload);else reject(payload instanceof Error?payload:new Error(String(payload||"主服务器连接失败。")))};const code=`self.onmessage=(ev)=>{const d=ev.data||{},host=String(d.host||"127.0.0.1").trim()||"127.0.0.1",mainPort=Number.parseInt(d.mainPort,10)||5000,roomSize=Number.parseInt(d.roomSize,10)||2,hand=Number.parseInt(d.hand,10)||7,timeoutMs=Math.max(1000,Number.parseInt(d.timeoutMs,10)||5000),proto=(d.proto==="wss")?"wss":"ws";let done=false,tm=null,ws=null,opened=false;const finish=(ok,payload)=>{if(done)return;done=true;if(tm!==null)clearTimeout(tm);try{if(ws)ws.close()}catch(_){ }postMessage({ok,payload})};const hostUrl=(host.includes(\":\")&&!host.startsWith(\"[\")&&!/^[^:]+:\\\\d+$/.test(host))?(\"[\"+host+\"]\"):host;try{ws=new WebSocket(proto+\"://\"+hostUrl+\":\"+mainPort)}catch(exc){finish(false,String(exc));return}tm=setTimeout(()=>finish(false,\"等待主服务器分配端口超时。\"),timeoutMs);ws.onopen=()=>{opened=true;try{ws.send(JSON.stringify({type:\"create_room\",protocol:\"web\",room_size:roomSize,initial_hand_size:hand}))}catch(exc){finish(false,String(exc))}};ws.onmessage=(ev2)=>{let msg=null;try{msg=JSON.parse(String(ev2.data||\"\"))}catch(_){finish(false,\"主服务器返回了无法识别的数据。\");return}const t=String(msg?.type||\"\").trim();if(t===\"room_created\"){const port=Number.parseInt(msg.port,10)||0;if(port<=0){finish(false,\"主服务器返回的房间端口无效。\");return}finish(true,{host,port});return}if(t===\"error\"){finish(false,String(msg.message||\"主服务器返回错误。\"))}};ws.onerror=()=>finish(false,\"主服务器连接失败。\");ws.onclose=()=>{if(done)return;if(!opened){finish(false,\"主端口未完成 WebSocket 握手。请确认服务器为新版 server.py/server.exe,并开放 5000 端口。\");return}finish(false,\"主服务器连接已断开。\")}}`;try{url=URL.createObjectURL(new Blob([code],{type:"application/javascript"}));worker=new Worker(url)}catch(exc){finish(false,exc);return}worker.onmessage=(ev)=>{const d=ev.data||{};finish(!!d.ok,d.payload)};worker.onerror=(ev)=>finish(false,new Error(String(ev?.message||"工作线程异常。")));try{worker.postMessage({host,mainPort:MAIN_PORT,roomSize,hand,timeoutMs:5000,proto})}catch(exc){finish(false,exc)}}).catch(async(exc)=>{try{return await fallback()}catch(_){throw exc}})}
async _start_remote(){const hsrv=hostSpec(this.remoteServerHost),host=hsrv.host,serverPort=i32(hsrv.port,0),proto=(this.remoteWsProto==="wss")?"wss":"ws",name=String(this.remoteName||"玩家").trim()||"玩家",rs=i32(this.remoteRoomSize,0),hs=i32(this.remoteHandSize,0);if(serverPort>0&&serverPort!==5000){await alertDialog("错误","服务器主端口固定为 5000,请仅填写 IP,或填写 IP:5000。");return false}if(rs<2||rs>8){await alertDialog("错误","远程房间人数必须在 2~8 之间。");return false}if(hs<2||hs>10){await alertDialog("错误","远程初始手牌数必须在 2~10 之间。");return false}
let asg=null;try{asg=await this._req_remote_port(host,rs,hs,proto)}catch(exc){await alertDialog("错误",`远程联机失败: ${exc}`);return false}
this._stop({silent:true});const port=asg.port;let ctrl=null;try{const [ri,rk]=getResume(host,port,name);ctrl=new ClientController({host,port,name,resumeIndex:ri,resumeKey:rk,wsProto:proto});await ctrl.wait_ready(5000);this.controller=ctrl;this.joinHost=host;this.joinWsProto=proto;this.joinPort=port;this.joinName=name;this._status(`远程房间已创建,地址 ${host}:${port}。你已作为“${name}”加入。`);await this._show_access(host,port);return true}catch(exc){if(ctrl)try{ctrl.close()}catch(_){ }await alertDialog("错误",`连接远程房间失败: ${exc}`);return false}}
async _join(){const port=i32(this.joinPort,0);if(port<=0){await alertDialog("错误","端口输入无效。");return false}if(port===5000){await alertDialog("错误","5000 是主端口,不能直接加入对局。");return false}const hs=hostSpec(this.joinHost),host=hs.host,proto=(this.joinWsProto==="wss")?"wss":"ws",name=String(this.joinName||"玩家").trim()||"玩家";
this._stop({silent:true});const [ri,rk]=getResume(host,port,name);let ctrl=null;try{ctrl=new ClientController({host,port,name,resumeIndex:ri,resumeKey:rk,wsProto:proto});await ctrl.wait_ready(5000);this.controller=ctrl;this._status(`已连接 ${host}:${port},你当前为“${name}”。`);return true}catch(exc){if(ctrl)try{ctrl.close()}catch(_){ }await alertDialog("错误",`加入房间失败: ${exc}`);return false}}
_poll(){if(this.controller){try{const st=this.controller.get_state();this.lastState=st;this._render(st);this._remember_resume(this.controller)}catch(exc){this._status(`状态更新异常: ${exc}`)}}}
_render(st){if(!st||typeof st!=="object")return;
if(!st.game_started){const msg=String(st.message||"等待中...");this.dom.turn.textContent=msg;const ps=Array.isArray(st.players)?st.players:[];this._render_players_table(ps,null,st.you_index??null);this._set_text(this.dom.board,"",{preserveY:true});this._set_text(this.dom.log,(st.log||[]).map(x=>dtext(String(x))).join("\n"),{scrollToEnd:true});this.handCardsByIndex=[];this.selectedCard=null;this.dom.hand.innerHTML="";this.dom.sel.textContent="当前选中: 无";this._awaitResp=false;this._respToken=null;this._set_action_controls_enabled(false,false);return}
const ps=Array.isArray(st.players)?st.players:[],cur=st.current_player,you=st.you_index,act=i32(st.actions_left,0),can=!!st.can_act,canEnd=!!(st.can_end_turn??(can&&act>0)),pending=st.pending_response||{},pact=!!pending.active,wp=pending.awaiting_player;const awaitResp=pact&&can&&wp!==null&&wp!==undefined&&you!==null&&you!==undefined&&i32(wp,-1)===i32(you,-2);this._awaitResp=awaitResp;if(!awaitResp)this._respToken=null;this._set_action_controls_enabled(can&&!awaitResp,canEnd&&!awaitResp);
const win=st.winner,deck=i32(st.deck_size,0),disc=i32(st.discard_size,0),dir=i32(st.direction,1);let dht=String(st.direction_hint||"").trim();if(!dht)dht=dir===1?"按座位顺序":"按座位逆序";
if(win===null||win===undefined){const cn=(ps&&cur!==null&&cur!==undefined&&ps[cur])?ps[cur].name:"?";let tt=`第${i32(st.turn_number,0)}回合 | 当前玩家: ${cn} | 剩余行动: ${act} | ${dht}`;if(pact){const wn=String(pending.awaiting_name||"").trim()||"某玩家",ef=String(pending.effect_card||"").trim(),rm=i32(pending.remaining,0);tt+=` | 等待 ${wn} 响应 ${cname(ef)}(剩余摸牌${rm})`}this.dom.turn.textContent=tt}else{const wn=ps[win]?.name||"?";this.dom.turn.textContent=`胜者: ${wn}`}
this._render_players_table(ps,cur,you);
let hand={};const yi=i32(you,-1);if(yi>=0&&yi<ps.length)hand=ps[yi]?.hand||{};this._render_hand(hand);
const board=st.board||{},skip=board.skip_turns||{},lines=[`牌堆: ${deck} | 弃牌堆: ${disc}`,"","【溶液区离子】",...this._fmt_dict(board.solution||{}),"","【沉淀区】",...this._fmt_dict(board.precipitates||{}),`金: ${i32(board.gold,0)}`,`铀: ${i32(board.uranium,0)}`,"","【气体区】",...this._fmt_dict(board.gases||{}),"","【弱电解质区】",...this._fmt_dict(board.weak_electrolytes||{}),"","【跳过队列】",...this._fmt_dict(skip)];
this._set_text(this.dom.board,lines.join("\n"),{preserveY:true});this._set_text(this.dom.log,(st.log||[]).map(x=>dtext(String(x))).join("\n"),{scrollToEnd:true});this._maybe_prompt(st)}
async _maybe_prompt(st){const p=st.pending_response||{};if(!p.active||!st.can_act)return;const you=st.you_index,wp=p.awaiting_player;if(you===null||you===undefined||wp===null||wp===undefined)return;if(i32(you,-1)!==i32(wp,-2))return;const tk=[i32(p.id,0),i32(wp,-1),i32(p.remaining,0),String(p.effect_card||"")].join("|");if(this._respToken===tk)return;this._respToken=tk;
const dec=await pendingDialog({sourceName:String(p.source_name||"").trim()||"上家",effectCard:String(p.effect_card||""),remaining:i32(p.remaining,0),followAllowed:!!p.follow_allowed,counterAllowed:!!p.counter_allowed});if(!dec){this._respToken=null;return}const ok=this._dispatch({kind:"respond_draw_effect",decision:dec});if(!ok)this._respToken=null}
render_game_to_text(){const st=this.lastState||{};return JSON.stringify({ui:{type:"dom",note:"无二维坐标系,信息按界面从上到下、从左到右展示"},setup:{local:{names:this.localNames,player_count:this.localPlayerCount,hand_size:this.localHandSize},remote:{server_host:this.remoteServerHost,room_size:this.remoteRoomSize,hand_size:this.remoteHandSize},join:{name:this.joinName,host:this.joinHost,port:this.joinPort}},current_state:st,selected_card:this.selectedCard,controls:{can_play:!this.dom.btnPlay.disabled,can_wangzha:!this.dom.btnWang.disabled,awaiting_response:this._awaitResp}})}
async advance_time(ms){const n=Math.max(1,Math.round(i32(ms,16)/16));for(let i=0;i<n;i++){this._poll();await new Promise(r=>setTimeout(r,0))}}
}
const app=new App();
window.__chem_game_app=app;
window.render_game_to_text=()=>app.render_game_to_text();
window.advanceTime=(ms)=>app.advance_time(ms);
})();
</script>
</body>
</html>