trioskosmos commited on
Commit
b872b7e
·
verified ·
1 Parent(s): a94f4b4

Upload folder using huggingface_hub

Browse files
backend/Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: gunicorn server:app
backend/__init__.py ADDED
File without changes
backend/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (170 Bytes). View file
 
backend/__pycache__/rust_serializer.cpython-312.pyc ADDED
Binary file (35.9 kB). View file
 
backend/__pycache__/server.cpython-312.pyc ADDED
Binary file (72.6 kB). View file
 
backend/check_paths.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
5
+ PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
6
+ FRONTEND_DIR = os.path.join(PROJECT_ROOT, "frontend")
7
+ IMG_DIR = os.path.join(FRONTEND_DIR, "img")
8
+
9
+ results = []
10
+ results.append(f"CD: {CURRENT_DIR}")
11
+ results.append(f"PR: {PROJECT_ROOT}")
12
+ results.append(f"ID: {IMG_DIR}")
13
+
14
+ filename = "icon_blade.png"
15
+ p1 = os.path.join(IMG_DIR, filename)
16
+ results.append(f"P1: {p1}")
17
+ results.append(f"E1: {os.path.exists(p1)}")
18
+
19
+ p2 = os.path.join(IMG_DIR, "texticon", filename)
20
+ results.append(f"P2: {p2}")
21
+ results.append(f"E2: {os.path.exists(p2)}")
22
+
23
+ for r in results:
24
+ print(r)
backend/rust_serializer.py ADDED
@@ -0,0 +1,815 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import sys
4
+
5
+ # --- PATH SETUP ---
6
+ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
7
+ PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
8
+ if PROJECT_ROOT not in sys.path:
9
+ sys.path.insert(0, PROJECT_ROOT)
10
+
11
+ import engine_rust
12
+
13
+ from engine.game.desc_utils import get_action_desc
14
+
15
+ TRIGGER_ICONS = {
16
+ 1: "【登場】",
17
+ 2: "【ライブ開始】",
18
+ 3: "【ライブ成功時】",
19
+ 6: "【常時】",
20
+ 7: "【起動】",
21
+ }
22
+
23
+
24
+ class RustCompatPlayer:
25
+ def __init__(self, p):
26
+ self._p = p
27
+ self.player_id = p.player_id
28
+ self.hand = p.hand
29
+ self.discard = p.discard
30
+ self.success_lives = p.success_lives
31
+ self.stage = p.stage
32
+ self.live_zone = p.live_zone
33
+ # Convert bitmask to set for compatibility with 'idx in p.mulligan_selection'
34
+ self.mulligan_selection = {i for i in range(len(p.hand)) if (p.mulligan_selection >> i) & 1}
35
+
36
+ def __getattr__(self, name):
37
+ return getattr(self._p, name)
38
+
39
+
40
+ class RustCompatGameState:
41
+ def __init__(self, gs, py_member_db, py_live_db, py_energy_db=None):
42
+ self._gs = gs
43
+ self.member_db = py_member_db
44
+ self.live_db = py_live_db
45
+ self.energy_db = py_energy_db
46
+ self.current_player = gs.current_player
47
+ self.phase = gs.phase
48
+ self.turn_number = gs.turn
49
+ self.triggered_abilities = []
50
+
51
+ @property
52
+ def pending_choices(self):
53
+ # Convert Rust Vec<(String, String)> to [(type, params_dict), ...]
54
+ raw = self._gs.pending_choices
55
+ result = []
56
+ for t, p in raw:
57
+ try:
58
+ params = json.loads(p)
59
+ result.append((t, params))
60
+ except:
61
+ result.append((t, {}))
62
+ return result
63
+
64
+ @property
65
+ def active_player(self):
66
+ return RustCompatPlayer(self._gs.get_player(self._gs.current_player))
67
+
68
+ @property
69
+ def inactive_player(self):
70
+ return RustCompatPlayer(self._gs.get_player(1 - self._gs.current_player))
71
+
72
+ @property
73
+ def inactive_player_idx(self):
74
+ return 1 - self._gs.current_player
75
+
76
+ @property
77
+ def players(self):
78
+ return [RustCompatPlayer(self._gs.get_player(0)), RustCompatPlayer(self._gs.get_player(1))]
79
+
80
+ def get_player(self, idx):
81
+ return RustCompatPlayer(self._gs.get_player(idx))
82
+
83
+ def get_legal_actions(self):
84
+ return self._gs.get_legal_actions()
85
+
86
+
87
+ def serialize_card_rust(card_id, db: engine_rust.PyCardDatabase, is_viewable=True):
88
+ if card_id < 0:
89
+ return None
90
+ if not is_viewable:
91
+ return {"id": int(card_id), "name": "???", "type": "unknown", "img": "cards/back.png", "hidden": True}
92
+
93
+ # In Rust engine, card_id is already the index in DB for basic lookups?
94
+ # Actually PyCardDatabase needs to expose card data.
95
+ # If the Rust PyCardDatabase doesn't expose full card objects yet,
96
+ # we might need to load the JSON DB in Python too just for metadata.
97
+
98
+ # For now, let's assume we use the Python member_db/live_db as a dictionary of metadata
99
+ # that matches the IDs in Rust.
100
+ pass
101
+
102
+
103
+ class RustGameStateSerializer:
104
+ def __init__(self, py_member_db, py_live_db, py_energy_db):
105
+ from engine.game.state_utils import MaskedDB
106
+
107
+ self.member_db = py_member_db if isinstance(py_member_db, MaskedDB) else MaskedDB(py_member_db)
108
+ self.live_db = py_live_db if isinstance(py_live_db, MaskedDB) else MaskedDB(py_live_db)
109
+ self.energy_db = py_energy_db if isinstance(py_energy_db, MaskedDB) else MaskedDB(py_energy_db)
110
+ self._card_cache = {} # Cache for base card metadata
111
+
112
+ def serialize_card(self, cid, is_viewable=True, peek=False):
113
+ if cid < 0:
114
+ return None
115
+ if not is_viewable and not peek:
116
+ return {"id": int(cid), "name": "???", "type": "unknown", "img": "icon_blade.png", "hidden": True}
117
+
118
+ # Fallback to icon_blade.png for unknown cards if no image path exists
119
+ def fix_img(img):
120
+ if not img:
121
+ return "icon_blade.png"
122
+ if img.startswith("assets/"):
123
+ return img # energy_card.png
124
+ return img
125
+
126
+ cid_int = int(cid)
127
+ base_id = cid_int & 0xFFFFF # Mask with BASE_ID_MASK (20 bits)
128
+
129
+ if base_id in self._card_cache:
130
+ res = self._card_cache[base_id].copy()
131
+ res["id"] = cid_int
132
+ return res
133
+
134
+ res = None
135
+ # Using the Python DB for metadata (names, images, text)
136
+ bid_str = str(base_id)
137
+ if bid_str in self.member_db:
138
+ m = self.member_db[bid_str]
139
+ # Fallback for ability text if not populated
140
+ # Prioritize pseudocode (raw_text) for consistent frontend translation
141
+ abilities = getattr(m, "abilities", [])
142
+ at = "\n".join([getattr(ab, "raw_text", "") for ab in abilities if getattr(ab, "raw_text", "")])
143
+
144
+ # Fallback to static ability text if no pseudocode available
145
+ if not at:
146
+ at = getattr(m, "ability_text", "")
147
+
148
+ res = {
149
+ "card_no": m.card_no,
150
+ "name": m.name,
151
+ "type": "member",
152
+ "cost": m.cost,
153
+ "blade": m.blades,
154
+ "img": m.img_path,
155
+ "hearts": list(m.hearts),
156
+ "blade_hearts": list(m.blade_hearts),
157
+ "text": at,
158
+ "original_text": m.original_text,
159
+ }
160
+ elif bid_str in self.live_db:
161
+ l = self.live_db[bid_str]
162
+
163
+ # Prioritize pseudocode for lives too
164
+ abilities = getattr(l, "abilities", [])
165
+ at = "\n".join([getattr(ab, "raw_text", "") for ab in abilities if getattr(ab, "raw_text", "")])
166
+ if not at:
167
+ at = getattr(l, "ability_text", "")
168
+
169
+ res = {
170
+ "card_no": l.card_no,
171
+ "name": l.name,
172
+ "type": "live",
173
+ "score": l.score,
174
+ "img": l.img_path,
175
+ "required_hearts": list(l.required_hearts),
176
+ "text": at,
177
+ "original_text": l.original_text,
178
+ }
179
+ elif bid_str in self.energy_db:
180
+ e = self.energy_db[bid_str]
181
+ res = {
182
+ "card_no": e.card_no,
183
+ "name": e.name,
184
+ "type": "energy",
185
+ "img": e.img_path,
186
+ "text": e.ability_text,
187
+ "original_text": e.original_text,
188
+ }
189
+
190
+ if res:
191
+ self._card_cache[base_id] = res
192
+ res_instance = res.copy()
193
+ res_instance["id"] = cid_int
194
+ return res_instance
195
+
196
+ return {"id": cid_int, "name": f"Card {base_id}", "type": "unknown", "img": "icon_blade.png"}
197
+
198
+ def serialize_player(
199
+ self, p: engine_rust.PyPlayerState, gs: engine_rust.PyGameState, p_idx, viewer_idx=0, legal_mask=None
200
+ ):
201
+ is_viewable = p_idx == viewer_idx
202
+
203
+ hand = []
204
+ # Use cached legal_mask if provided, otherwise fetch (fallback for direct calls)
205
+ if legal_mask is None:
206
+ legal_mask = gs.get_legal_actions() if gs.current_player == p_idx else []
207
+ elif gs.current_player != p_idx:
208
+ legal_mask = [] # Clear mask for non-active player
209
+
210
+ for i, cid in enumerate(p.hand):
211
+ c = self.serialize_card(cid, is_viewable=is_viewable)
212
+ if is_viewable:
213
+ c["is_new"] = (p.hand_added_turn[i] == gs.turn) if i < len(p.hand_added_turn) else False
214
+
215
+ valid_actions = []
216
+ if len(legal_mask) > 0:
217
+ # Mapping logic matching Python serializer
218
+ # Play Member: 1 + hand_idx * 3 + slot_idx
219
+ for area in range(3):
220
+ aid = 1 + i * 3 + area
221
+ if aid < len(legal_mask) and legal_mask[aid]:
222
+ valid_actions.append(aid)
223
+ # Other hand-related actions: Mulligan (300+), LiveSet (400+), SelectHand (500+)
224
+ for aid in [300 + i, 400 + i, 500 + i]:
225
+ if aid < len(legal_mask) and legal_mask[aid]:
226
+ valid_actions.append(aid)
227
+ c["valid_actions"] = valid_actions
228
+ hand.append(c)
229
+
230
+ stage = []
231
+ rust_stage = p.stage
232
+ rust_tapped = p.tapped_members
233
+ for i in range(3):
234
+ cid = rust_stage[i]
235
+ if cid >= 0:
236
+ c = self.serialize_card(cid, is_viewable=True)
237
+ c["tapped"] = bool(rust_tapped[i])
238
+ c["energy"] = int(getattr(p, "stage_energy_count", [0, 0, 0])[i])
239
+ c["locked"] = False # Rust doesn't track locked members yet
240
+
241
+ # Fetch effective stats from Rust
242
+ eff_blade = gs.get_effective_blades(p_idx, i)
243
+ eff_hearts = gs.get_effective_hearts(p_idx, i)
244
+
245
+ # Update stats in card dict
246
+ c["blade"] = int(eff_blade)
247
+ c["hearts"] = [int(h) for h in eff_hearts]
248
+
249
+ # Calculate modifiers for UI highlighting (Attack +1, etc.)
250
+ modifiers = []
251
+ base_m = self.member_db.get(int(cid))
252
+ if base_m:
253
+ if c["blade"] > base_m.blades:
254
+ modifiers.append(
255
+ {
256
+ "type": "blade",
257
+ "value": c["blade"] - base_m.blades,
258
+ "label": f"Attack +{c['blade'] - base_m.blades}",
259
+ }
260
+ )
261
+ elif c["blade"] < base_m.blades:
262
+ modifiers.append(
263
+ {
264
+ "type": "blade",
265
+ "value": c["blade"] - base_m.blades,
266
+ "label": f"Attack {c['blade'] - base_m.blades}",
267
+ }
268
+ )
269
+
270
+ for j in range(len(c["hearts"])):
271
+ if j < len(base_m.hearts) and c["hearts"][j] > base_m.hearts[j]:
272
+ modifiers.append(
273
+ {"type": "heart", "color_idx": j, "value": c["hearts"][j] - base_m.hearts[j]}
274
+ )
275
+
276
+ c["modifiers"] = modifiers
277
+
278
+ # Add valid actions for stage highlighting
279
+ valid_actions = []
280
+ if len(legal_mask) > 0:
281
+ # ABILITY is 200 + slot_idx * 10 + ab_idx
282
+ for ab_idx in range(10):
283
+ aid = 200 + i * 10 + ab_idx
284
+ if aid < len(legal_mask) and legal_mask[aid]:
285
+ valid_actions.append(aid)
286
+ # SELECT_STAGE is 560 + slot_idx
287
+ aid = 560 + i
288
+ if aid < len(legal_mask) and legal_mask[aid]:
289
+ valid_actions.append(aid)
290
+ c["valid_actions"] = valid_actions
291
+
292
+ stage.append(c)
293
+ else:
294
+ stage.append(None)
295
+
296
+ # Live Guide Logic
297
+ total_hearts = gs.get_total_hearts(p_idx) # [u32; 7]
298
+ temp_hearts = list(total_hearts)
299
+
300
+ live_zone = []
301
+ rust_lives = p.live_zone
302
+ rust_revealed = p.live_zone_revealed
303
+ for i in range(3):
304
+ cid = rust_lives[i]
305
+ if cid >= 0:
306
+ c = self.serialize_card(cid, is_viewable=rust_revealed[i], peek=is_viewable)
307
+
308
+ # Fulfillment (Rule 8.4.1)
309
+ if cid in self.live_db:
310
+ l = self.live_db[cid]
311
+ req = l.required_hearts
312
+ filled = [0] * 7
313
+ # Specific
314
+ for ci in range(6):
315
+ take = min(temp_hearts[ci], req[ci])
316
+ filled[ci] = int(take)
317
+ temp_hearts[ci] -= take
318
+ # Any
319
+ req_any = req[6] if len(req) > 6 else 0
320
+ rem_total = sum(temp_hearts[:6]) + temp_hearts[6]
321
+ take_any = min(rem_total, req_any)
322
+ filled[6] = int(take_any)
323
+ # Note: We don't decrement from temp_hearts for 'any' matching the Python serializer's logic
324
+
325
+ c["filled_hearts"] = filled
326
+ c["is_cleared"] = all(filled[ci] >= req[ci] for ci in range(6)) and (filled[6] >= req_any)
327
+ c["required_hearts"] = list(req)
328
+
329
+ c["modifiers"] = []
330
+ live_zone.append(c)
331
+ else:
332
+ live_zone.append(None)
333
+
334
+ energy = []
335
+ rust_energy = p.energy_zone
336
+ rust_tapped_energy = p.tapped_energy
337
+ for i, cid in enumerate(rust_energy):
338
+ energy.append(
339
+ {"id": i, "tapped": rust_tapped_energy[i], "card": self.serialize_card(cid, is_viewable=False)}
340
+ )
341
+
342
+ # Convert bitmask to list of selected indices for frontend
343
+ mulligan_selection_list = [i for i in range(len(p.hand)) if (p.mulligan_selection >> i) & 1]
344
+
345
+ return {
346
+ "player_id": p.player_id,
347
+ "score": p.score,
348
+ "is_active": (gs.current_player == p_idx),
349
+ "hand": hand,
350
+ "hand_count": len(hand),
351
+ "deck_count": p.deck_count,
352
+ "energy_deck_count": p.energy_deck_count,
353
+ "discard": [self.serialize_card(cid) for cid in p.discard],
354
+ "discard_count": len(p.discard),
355
+ "energy": energy,
356
+ "energy_count": len(energy),
357
+ "energy_untapped": sum(1 for t in rust_tapped_energy if not t),
358
+ "live_zone": live_zone,
359
+ "live_zone_count": sum(1 for cid in rust_lives if cid >= 0),
360
+ "stage": stage,
361
+ "success_lives": [self.serialize_card(cid) for cid in p.success_lives],
362
+ "restrictions": [],
363
+ "total_hearts": [int(h) for h in total_hearts],
364
+ "total_blades": int(gs.get_total_blades(p_idx)),
365
+ "mulligan_selection": mulligan_selection_list,
366
+ "looked_cards": [self.serialize_card(cid) for cid in getattr(p, "looked_cards", [])],
367
+ }
368
+
369
+ def serialize_state(self, gs: engine_rust.PyGameState, viewer_idx=0, mode="pve", is_pvp=False):
370
+ # Cache legal_mask once to avoid multiple expensive calls
371
+ legal_mask = gs.get_legal_actions()
372
+
373
+ players = [
374
+ self.serialize_player(gs.get_player(0), gs, 0, viewer_idx, legal_mask),
375
+ self.serialize_player(gs.get_player(1), gs, 1, viewer_idx, legal_mask),
376
+ ]
377
+
378
+ # Action Metadata - reuse cached legal_mask
379
+ legal_actions = []
380
+
381
+ # Compatibility wrapper for get_action_desc
382
+ compat_gs = RustCompatGameState(gs, self.member_db, self.live_db, self.energy_db)
383
+
384
+ # Only show actions if viewer is active (or in PvP/Hotseat which we assume viewer_idx represents)
385
+ if viewer_idx == gs.current_player:
386
+ for i, v in enumerate(legal_mask):
387
+ if v:
388
+ desc = get_action_desc(i, compat_gs)
389
+ meta = {"id": i, "desc": desc, "name": desc, "description": desc}
390
+
391
+ # Enrich with metadata for UI highlighting
392
+ phase = gs.phase # Assumes phase is exposed as int
393
+
394
+ if 1 <= i <= 180:
395
+ meta["type"] = "PLAY"
396
+ meta["hand_idx"] = (i - 1) // 3
397
+ meta["area_idx"] = (i - 1) % 3
398
+ curr_p = gs.get_player(gs.current_player)
399
+ if meta["hand_idx"] < len(curr_p.hand):
400
+ cid = curr_p.hand[meta["hand_idx"]]
401
+ c = self.serialize_card(cid)
402
+ hand_cost = gs.get_member_cost(gs.current_player, cid, -1)
403
+ net_cost = gs.get_member_cost(gs.current_player, cid, meta["area_idx"])
404
+ meta.update(
405
+ {
406
+ "img": c["img"],
407
+ "name": c["name"],
408
+ "cost": int(net_cost),
409
+ "base_cost": int(hand_cost),
410
+ "text": c.get("text", ""),
411
+ "source_card_id": int(cid),
412
+ }
413
+ )
414
+ elif 200 <= i <= 299:
415
+ meta["type"] = "ABILITY"
416
+ adj = i - 200
417
+ meta["area_idx"] = adj // 10
418
+ meta["ability_idx"] = adj % 10
419
+ curr_p = gs.get_player(gs.current_player)
420
+ if meta["area_idx"] < len(curr_p.stage):
421
+ cid = curr_p.stage[meta["area_idx"]]
422
+ if cid >= 0:
423
+ c = self.serialize_card(cid)
424
+ # Extract specific ability trigger/text
425
+ base_id = int(cid) & 0xFFFFF
426
+ triggers = []
427
+ raw_text = ""
428
+ if base_id in self.member_db:
429
+ m = self.member_db[base_id]
430
+ if hasattr(m, "abilities") and len(m.abilities) > meta["ability_idx"]:
431
+ ab = m.abilities[meta["ability_idx"]]
432
+ triggers = [int(ab.trigger)]
433
+ raw_text = ab.raw_text
434
+
435
+ meta.update(
436
+ {
437
+ "img": c["img"],
438
+ "name": desc,
439
+ "source_card_id": int(cid),
440
+ "triggers": triggers,
441
+ "raw_text": raw_text,
442
+ "text": "", # Delay ability text
443
+ "ability_idx": meta["ability_idx"],
444
+ "description": desc,
445
+ }
446
+ )
447
+ elif 300 <= i <= 359:
448
+ meta["type"] = "MULLIGAN"
449
+ meta["hand_idx"] = i - 300
450
+ curr_p = gs.get_player(gs.current_player)
451
+ if meta["hand_idx"] < len(curr_p.hand):
452
+ cid = curr_p.hand[meta["hand_idx"]]
453
+ c = self.serialize_card(cid)
454
+ meta.update(
455
+ {"img": c["img"], "name": c["name"], "text": c.get("text", ""), "description": desc}
456
+ )
457
+ elif 400 <= i <= 459:
458
+ meta["type"] = "LIVE_SET"
459
+ meta["hand_idx"] = i - 400
460
+ curr_p = gs.get_player(gs.current_player)
461
+ if meta["hand_idx"] < len(curr_p.hand):
462
+ cid = curr_p.hand[meta["hand_idx"]]
463
+ c = self.serialize_card(cid)
464
+ meta.update(
465
+ {"img": c["img"], "name": c["name"], "text": c.get("text", ""), "description": desc}
466
+ )
467
+ elif 100 <= i <= 159 or 500 <= i <= 559:
468
+ meta["type"] = "SELECT_HAND"
469
+ meta["hand_idx"] = (i - 100) if (100 <= i <= 159) else (i - 500)
470
+ curr_p = gs.get_player(gs.current_player)
471
+ if meta["hand_idx"] < len(curr_p.hand):
472
+ cid = curr_p.hand[meta["hand_idx"]]
473
+ c = self.serialize_card(cid)
474
+ meta.update(
475
+ {"img": c["img"], "name": c["name"], "text": c.get("text", ""), "description": desc}
476
+ )
477
+ elif 560 <= i <= 562:
478
+ meta["type"] = "SELECT_STAGE"
479
+ meta["area_idx"] = i - 560
480
+ curr_p = gs.get_player(gs.current_player)
481
+ cid = curr_p.stage[meta["area_idx"]]
482
+ if cid >= 0:
483
+ c = self.serialize_card(cid)
484
+ meta.update({"img": c["img"], "name": c["name"], "text": "", "description": desc})
485
+
486
+ # Add pending context for UI grouping
487
+ if gs.pending_card_id >= 0:
488
+ meta["source_card_id"] = int(gs.pending_card_id)
489
+ c = self.serialize_card(gs.pending_card_id)
490
+ meta["source_name"] = c["name"]
491
+ meta["source_img"] = c["img"]
492
+ elif 570 <= i <= 579:
493
+ meta["type"] = "SELECT_MODE"
494
+ meta["index"] = i - 570
495
+ elif 580 <= i <= 585:
496
+ meta["type"] = "COLOR_SELECT"
497
+ meta["index"] = i - 580
498
+ colors = ["Red", "Blue", "Green", "Yellow", "Purple", "Pink"]
499
+ if meta["index"] < len(colors):
500
+ meta["color"] = colors[meta["index"]]
501
+ meta["name"] = f"Color: {colors[meta['index']]}"
502
+ meta["description"] = meta["name"]
503
+ elif 900 <= i <= 902:
504
+ meta["type"] = "SELECT_LIVE"
505
+ meta["area_idx"] = i - 900
506
+ curr_p = gs.get_player(gs.current_player)
507
+ if meta["area_idx"] < len(curr_p.live_zone):
508
+ cid = curr_p.live_zone[meta["area_idx"]]
509
+ if cid >= 0:
510
+ c = self.serialize_card(cid)
511
+ meta.update(
512
+ {
513
+ "img": c["img"],
514
+ "name": c["name"],
515
+ "source_card_id": int(cid),
516
+ "raw_text": c.get("text", ""),
517
+ "description": desc,
518
+ }
519
+ )
520
+ elif 590 <= i <= 599:
521
+ meta["type"] = "ABILITY_TRIGGER"
522
+ elif 550 <= i <= 849:
523
+ # Shared range for Ability choices, Card selections, and Opponent targeting
524
+ meta["type"] = "ABILITY"
525
+ meta["area_idx"] = gs.pending_area_idx
526
+
527
+ # Enrich based on pending choice context
528
+ raw_choices = compat_gs.pending_choices
529
+ if raw_choices:
530
+ ctype, cparams = raw_choices[0]
531
+ # index within the 10-slot block for this ability
532
+ choice_idx = (i - 550) % 10
533
+
534
+ # 1. Selection from a list (e.g. Look at top 3, choose 1)
535
+ if ctype in (
536
+ "SELECT_FROM_LIST",
537
+ "SELECT_SUCCESS_LIVE",
538
+ "ORDER_DECK",
539
+ "SELECT_FROM_DISCARD",
540
+ ):
541
+ cards = cparams.get("cards", [])
542
+ if choice_idx < len(cards):
543
+ cid = cards[choice_idx]
544
+ c = self.serialize_card(cid)
545
+ meta.update(
546
+ {
547
+ "type": "SELECT",
548
+ "img": c["img"],
549
+ "name": c["name"],
550
+ "source_card_id": int(cid),
551
+ }
552
+ )
553
+ if ctype == "ORDER_DECK":
554
+ meta["type"] = "ORDER_DECK"
555
+
556
+ # 2. Target Opponent Member (600-602)
557
+ elif ctype == "TARGET_OPPONENT_MEMBER" and 600 <= i <= 602:
558
+ meta["type"] = "TARGET_OPPONENT"
559
+ meta["index"] = i - 600
560
+ opp = gs.get_player(1 - gs.current_player)
561
+ cid = opp.stage[meta["index"]]
562
+ if cid >= 0:
563
+ c = self.serialize_card(cid)
564
+ meta.update({"img": c["img"], "name": c["name"], "source_card_id": int(cid)})
565
+
566
+ # 3. Fallback: Source card metadata
567
+ else:
568
+ cid = gs.pending_card_id
569
+ if cid >= 0:
570
+ c = self.serialize_card(cid)
571
+ meta.update(
572
+ {"img": c["img"], "name": desc, "text": c.get("text", ""), "description": desc}
573
+ )
574
+ else:
575
+ # Fallback if no pending choice context
576
+ cid = gs.pending_card_id
577
+ if cid >= 0:
578
+ c = self.serialize_card(cid)
579
+ meta.update(
580
+ {"img": c["img"], "name": desc, "text": c.get("text", ""), "description": desc}
581
+ )
582
+
583
+ elif 2000 <= i <= 2999:
584
+ meta["type"] = "ABILITY"
585
+ adj = i - 2000
586
+ discard_idx = adj // 10
587
+ ability_idx = adj % 10
588
+ curr_p = gs.get_player(gs.current_player)
589
+ if discard_idx < len(curr_p.discard):
590
+ cid = curr_p.discard[discard_idx]
591
+ c = self.serialize_card(cid)
592
+ meta.update(
593
+ {
594
+ "img": c["img"],
595
+ "name": desc,
596
+ "source_card_id": int(cid),
597
+ "ability_idx": ability_idx,
598
+ "description": desc,
599
+ "location": "discard",
600
+ }
601
+ )
602
+ elif 1000 <= i <= 1999:
603
+ # Range for OnPlay choices (Mode select, slot select context)
604
+ # We treat these as PLAY actions so they group with the placement grid
605
+ meta["type"] = "PLAY"
606
+ adj = i - 1000
607
+ meta["hand_idx"] = adj // 100
608
+ meta["area_idx"] = (adj % 100) // 10
609
+ meta["choice_idx"] = adj % 10
610
+ curr_p = gs.get_player(gs.current_player)
611
+ if meta["hand_idx"] < len(curr_p.hand):
612
+ cid = curr_p.hand[meta["hand_idx"]]
613
+ c = self.serialize_card(cid)
614
+ # Get costs for UI if applicable
615
+ hand_cost = gs.get_member_cost(gs.current_player, cid, -1)
616
+ net_cost = gs.get_member_cost(gs.current_player, cid, meta["area_idx"])
617
+ meta.update(
618
+ {
619
+ "img": c["img"],
620
+ "name": c["name"],
621
+ "cost": int(net_cost),
622
+ "base_cost": int(hand_cost),
623
+ "text": c.get("text", ""),
624
+ "source_card_id": int(cid),
625
+ }
626
+ )
627
+
628
+ legal_actions.append(meta)
629
+
630
+ # Pending Choice Serialization
631
+ pending_choice = None
632
+
633
+ # 1. Try to get explicit pending_choices (Python/Compat engine)
634
+ raw_choices = compat_gs.pending_choices
635
+
636
+ if raw_choices:
637
+ choice_type, params = raw_choices[0]
638
+
639
+ source_name = params.get("source_member", "Ability Root")
640
+ source_img = None
641
+ source_id = params.get("source_card_id", -1)
642
+
643
+ if source_id != -1:
644
+ c = self.serialize_card(source_id)
645
+ source_name = c["name"]
646
+ source_img = c["img"]
647
+ elif "area" in params:
648
+ curr_p = gs.get_player(gs.current_player)
649
+ cid = curr_p.stage[params["area"]]
650
+ if cid >= 0:
651
+ c = self.serialize_card(cid)
652
+ source_name = c["name"]
653
+ source_img = c["img"]
654
+ source_id = int(cid)
655
+
656
+ pending_choice = {
657
+ "type": choice_type,
658
+ "description": params.get("effect_description", desc),
659
+ "source_ability": params.get("source_ability", ""),
660
+ "source_member": source_name,
661
+ "source_img": source_img,
662
+ "min": params.get("min", 1),
663
+ "max": params.get("max", 1),
664
+ "can_skip": params.get("can_skip", False),
665
+ "params": params,
666
+ }
667
+
668
+ # 2. Fallback: Infer pending choice from legal action ranges (Rust Engine)
669
+ elif not raw_choices and any(v for i, v in enumerate(legal_mask) if i >= 500):
670
+ # Check ranges in priority order
671
+ inferred_type = None
672
+ inferred_desc = "Make a selection"
673
+
674
+ has_select_hand = any(legal_mask[i] for i in range(500, 560))
675
+ has_select_stage = any(legal_mask[i] for i in range(560, 570))
676
+ has_select_mode = any(legal_mask[i] for i in range(570, 580))
677
+ has_select_color = any(legal_mask[i] for i in range(580, 586))
678
+ has_ability_trigger = any(legal_mask[i] for i in range(590, 600))
679
+ has_target_opp = any(legal_mask[i] for i in range(600, 603)) and (gs.phase == 4 or gs.phase == 8) # MAIN or LIVE_RESULT (usually only MAIN)
680
+ has_select_discard = any(legal_mask[i] for i in range(660, 720))
681
+ # LIVE_RESULT choices (600+)
682
+ has_select_success_live = any(legal_mask[i] for i in range(600, 720)) and gs.phase == 8 # Phase.LIVE_RESULT
683
+
684
+ # Generic list/mode choices (600+) - catch all if not special
685
+ has_generic_choice = any(legal_mask[i] for i in range(600, 720)) and not has_select_success_live and not has_target_opp
686
+
687
+ # EXCLUDE 1000-1999 from triggering a generic modal if it's a placement choice
688
+ # as these are handled in the board grid.
689
+ has_select_list = any(legal_mask[i] for i in range(1000, 2000)) and gs.phase != 4 # Phase.MAIN
690
+
691
+ inferred_params = {}
692
+
693
+ if has_ability_trigger:
694
+ # Triggers are top level, not usually a "choice" modal but a button
695
+ pass
696
+ elif has_select_color:
697
+ inferred_type = "SELECT_COLOR"
698
+ inferred_desc = "Select a Color"
699
+ elif has_select_mode:
700
+ inferred_type = "SELECT_MODE"
701
+ inferred_desc = "Select a Mode"
702
+ elif has_select_success_live:
703
+ inferred_type = "SELECT_SUCCESS_LIVE"
704
+ inferred_desc = "獲得するライブカードを1枚選んでください"
705
+ elif has_target_opp:
706
+ inferred_type = "TARGET_OPPONENT_MEMBER"
707
+ inferred_desc = "Select Opponent Member"
708
+ elif has_generic_choice:
709
+ # Catch-all for Rust engine list choices
710
+ inferred_type = "SELECT_FROM_LIST"
711
+ inferred_desc = "Choose an option"
712
+ elif has_select_discard:
713
+ inferred_type = "SELECT_FROM_DISCARD"
714
+ inferred_desc = "Select from Discard"
715
+ curr_p = gs.get_player(gs.current_player)
716
+ inferred_params["available_members"] = list(curr_p.discard)
717
+ elif has_select_stage:
718
+ inferred_type = "SELECT_STAGE"
719
+ inferred_desc = "Select a Member"
720
+ elif has_select_hand:
721
+ inferred_type = "SELECT_FROM_HAND"
722
+ inferred_desc = "Select from Hand"
723
+ elif has_select_list:
724
+ inferred_type = "SELECT_FROM_LIST"
725
+ inferred_desc = "Choose an option"
726
+
727
+ if inferred_type:
728
+ # Try to resolve source info from gs.pending_card_id
729
+ source_name = "Game"
730
+ source_img = None
731
+ source_id = int(gs.pending_card_id)
732
+ if source_id >= 0:
733
+ c = self.serialize_card(source_id)
734
+ source_name = c["name"]
735
+ source_img = c["img"]
736
+
737
+ pending_choice = {
738
+ "type": inferred_type,
739
+ "description": inferred_desc,
740
+ "source_member": source_name,
741
+ "source_img": source_img,
742
+ "source_id": source_id,
743
+ "min": 1,
744
+ "max": 1,
745
+ "can_skip": False,
746
+ "params": inferred_params,
747
+ }
748
+
749
+ # 3. New: Support Phase.RESPONSE (Choice postponing)
750
+ elif gs.phase == 10:
751
+ pending_card_id = gs.pending_card_id
752
+ choice_type = gs.pending_choice_type or "PENDING_ABILITY"
753
+
754
+ choice_desc = "Select an option"
755
+ inferred_params = {}
756
+
757
+ if choice_type == "ORDER_DECK":
758
+ choice_desc = "デッキの順番を選んでください"
759
+ # Looking at p[gs.current_player].looked_cards
760
+ curr_p = gs.get_player(gs.current_player)
761
+ inferred_params["cards"] = list(curr_p.looked_cards)
762
+ elif pending_card_id >= 0:
763
+ c = self.serialize_card(pending_card_id)
764
+ # Infer type from legal actions as fallback
765
+ has_color = any(legal_mask[i] for i in range(1000, 2000) if i % 10 == 5) or any(
766
+ legal_mask[i] for i in range(550, 850) if i % 10 == 5
767
+ )
768
+ has_mode = any(legal_mask[i] for i in range(1000, 2000) if i % 10 == 1) or any(
769
+ legal_mask[i] for i in range(550, 850) if i % 10 == 1
770
+ )
771
+
772
+ if not gs.pending_choice_type:
773
+ if has_color:
774
+ choice_type = "SELECT_COLOR"
775
+ choice_desc = "選択してください: ピースの色"
776
+ elif has_mode:
777
+ choice_type = "SELECT_MODE"
778
+ choice_desc = "選択してください: モード"
779
+
780
+ source_name = c["name"]
781
+ source_img = c["img"]
782
+ else:
783
+ source_name = "Game"
784
+ source_img = None
785
+
786
+ pending_choice = {
787
+ "type": choice_type,
788
+ "description": choice_desc,
789
+ "source_member": source_name,
790
+ "source_img": source_img,
791
+ "source_id": int(pending_card_id),
792
+ "min": 1,
793
+ "max": 1,
794
+ "can_skip": False,
795
+ "params": inferred_params,
796
+ }
797
+
798
+ return {
799
+ "turn": gs.turn,
800
+ "phase": gs.phase,
801
+ "active_player": gs.current_player,
802
+ "game_over": gs.is_terminal(),
803
+ "winner": gs.get_winner(),
804
+ "players": players,
805
+ "legal_actions": legal_actions,
806
+ "pending_choice": pending_choice,
807
+ "rule_log": gs.rule_log,
808
+ "performance_results": {int(k): v for k, v in json.loads(gs.last_performance_results).items()}
809
+ if gs.phase in (6, 7, 8)
810
+ else {},
811
+ "last_performance_results": {int(k): v for k, v in json.loads(gs.last_performance_results).items()},
812
+ "performance_history": json.loads(gs.performance_history),
813
+ "mode": mode,
814
+ "is_pvp": is_pvp,
815
+ }
backend/server.py ADDED
@@ -0,0 +1,1868 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flask Backend for Love Live Card Game Web UI
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import sys
9
+ import threading
10
+ import uuid
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ from flask import Flask, jsonify, request, send_from_directory
16
+ from flask.json.provider import DefaultJSONProvider
17
+
18
+ # Ensure project root is in sys.path for absolute imports
19
+ if getattr(sys, "frozen", False):
20
+ PROJECT_ROOT = sys._MEIPASS # type: ignore
21
+ CURRENT_DIR = os.path.join(PROJECT_ROOT, "backend")
22
+ else:
23
+ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
24
+ PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
25
+
26
+ if PROJECT_ROOT not in sys.path:
27
+ sys.path.insert(0, PROJECT_ROOT)
28
+
29
+ # Rust Engine
30
+ import engine_rust
31
+
32
+ from ai.headless_runner import RandomAgent, create_easy_cards
33
+ from ai.headless_runner import SmartHeuristicAgent as SmartAgent
34
+ from engine.game.data_loader import CardDataLoader
35
+ from engine.game.desc_utils import get_action_desc
36
+ from engine.game.enums import Phase
37
+ from engine.game.game_state import GameState
38
+ from engine.game.replay_manager import inflate_history, optimize_history
39
+ from engine.game.serializer import serialize_state
40
+ from engine.game.state_utils import create_uid
41
+
42
+ try:
43
+ from rust_serializer import RustGameStateSerializer
44
+ except ImportError:
45
+ from backend.rust_serializer import RustGameStateSerializer
46
+
47
+ INSTANCE_SHIFT = 20
48
+ BASE_ID_MASK = 0xFFFFF
49
+
50
+ # --- MODULE DIRECTORIES ---
51
+ ENGINE_DIR = os.path.join(PROJECT_ROOT, "engine")
52
+ AI_DIR = os.path.join(PROJECT_ROOT, "ai")
53
+ TOOLS_DIR = os.path.join(PROJECT_ROOT, "tools")
54
+ DATA_DIR = os.path.join(PROJECT_ROOT, "data")
55
+
56
+ # Tools imports (optional)
57
+ try:
58
+ from tools.deck_extractor import extract_deck_data
59
+ except ImportError:
60
+ print("Warning: Could not import deck_extractor from tools.")
61
+
62
+ def extract_deck_data(content, db):
63
+ return [], [], {}, ["Importer not found"]
64
+
65
+
66
+ # Static folder is now in frontend/web_ui
67
+ FRONTEND_DIR = os.path.join(PROJECT_ROOT, "frontend")
68
+
69
+ WEB_UI_DIR = os.path.join(FRONTEND_DIR, "web_ui")
70
+ IMG_DIR = os.path.join(FRONTEND_DIR, "img") # Images seem to be in frontend/img
71
+ # Note: frontend/web_ui has its own js/css folders which index.html likely uses
72
+
73
+
74
+ app = Flask(__name__, static_folder=WEB_UI_DIR)
75
+
76
+
77
+ class NumpyJSONProvider(DefaultJSONProvider):
78
+ def default(self, obj):
79
+ if isinstance(obj, np.integer):
80
+ return int(obj)
81
+ elif isinstance(obj, np.floating):
82
+ return float(obj)
83
+ elif isinstance(obj, np.bool_):
84
+ return bool(obj)
85
+ elif isinstance(obj, np.ndarray):
86
+ return obj.tolist()
87
+ return super().default(obj)
88
+
89
+
90
+ app.json = NumpyJSONProvider(app)
91
+
92
+ @app.route("/img/<path:filename>")
93
+ def serve_img(filename):
94
+ # Sanitize and normalize the filename
95
+ filename = filename.replace("\\", "/").lstrip("/")
96
+
97
+ # Check if this is a card image request
98
+ if filename.startswith("cards/") or filename.startswith("cards_webp/"):
99
+ # Remove old nested 'cards/' prefix if it's there
100
+ pure_filename = os.path.basename(filename)
101
+ webp_path = os.path.join(IMG_DIR, "cards_webp", pure_filename)
102
+
103
+ # Priority 1: Flat WebP folder
104
+ if os.path.exists(webp_path) and os.path.isfile(webp_path):
105
+ return send_from_directory(os.path.join(IMG_DIR, "cards_webp"), pure_filename)
106
+
107
+ # Priority 2: Try falling back to original nested PNGs for backward compatibility/backup
108
+ # (This is mostly for non-compiled access or manual links)
109
+ pass
110
+
111
+ # Define possible search directories relative to PROJECT_ROOT
112
+ search_dirs = [
113
+ os.path.join(IMG_DIR, "cards_webp"), # Flattened WebP first
114
+ IMG_DIR, # frontend/img
115
+ os.path.join(IMG_DIR, "texticon"), # frontend/img/texticon
116
+ os.path.join(WEB_UI_DIR, "img"), # frontend/web_ui/img
117
+ FRONTEND_DIR # Allow direct frontend access if needed
118
+ ]
119
+
120
+ for base_dir in search_dirs:
121
+ full_path = os.path.join(base_dir, filename)
122
+ if os.path.exists(full_path) and os.path.isfile(full_path):
123
+ return send_from_directory(base_dir, filename)
124
+
125
+ # Fallback for .webp requesting .png or vice-versa
126
+ if filename.endswith(".webp"):
127
+ png_fallback = filename[:-5] + ".png"
128
+ full_png_path = os.path.join(base_dir, png_fallback)
129
+ if os.path.exists(full_png_path) and os.path.isfile(full_png_path):
130
+ return send_from_directory(base_dir, png_fallback)
131
+
132
+ # Extra fallback for common icons if they are misplaced
133
+ if filename == "icon_blade.png" or "icon_blade" in filename:
134
+ # Try to find it anywhere in frontend/img
135
+ for root, dirs, files in os.walk(IMG_DIR):
136
+ if "icon_blade.png" in files:
137
+ return send_from_directory(root, "icon_blade.png")
138
+
139
+ print(f"DEBUG_IMG_404: Could not find {filename} in {search_dirs}")
140
+ return "Image not found", 404
141
+
142
+ @app.route("/icon_blade.png")
143
+ def serve_icon_root():
144
+ return serve_img("icon_blade.png")
145
+
146
+
147
+ # ai_agent = SmartHeuristicAgent()
148
+ ai_agent = SmartAgent() # Use original heuristic AI
149
+
150
+ # Global game state
151
+ # Room Registry
152
+ ROOMS: dict[str, dict[str, Any]] = {}
153
+ game_lock = threading.Lock()
154
+
155
+ # Rust Card DB (Global Singleton for performance)
156
+ RUST_DB = None
157
+ try:
158
+ compiled_data_path = os.path.join(DATA_DIR, "cards_compiled.json")
159
+ with open(compiled_data_path, "r", encoding="utf-8") as f:
160
+ RUST_DB = engine_rust.PyCardDatabase(f.read())
161
+ except Exception as e:
162
+ print(f"Warning: Failed to load RUST_DB from {compiled_data_path}: {e}")
163
+
164
+ # Python DBs (for metadata/serialization)
165
+ member_db: dict[int, Any] = {}
166
+ live_db: dict[int, Any] = {}
167
+ energy_db: dict[int, Any] = {}
168
+
169
+ rust_serializer = None # Initialized after data load
170
+ game_history: list[dict] = [] # Global replay history (might need per-room later)
171
+
172
+ # Legacy custom deck globals (used by init_game)
173
+ custom_deck_p0: list[str] | None = None
174
+ custom_deck_p1: list[str] | None = None
175
+ custom_energy_deck_p0: list[str] | None = None
176
+ custom_energy_deck_p1: list[str] | None = None
177
+
178
+
179
+ def load_game_data():
180
+ """Load card data into global databases."""
181
+ global member_db, live_db, energy_db, rust_serializer
182
+ try:
183
+ cards_path = os.path.join(DATA_DIR, "cards.json")
184
+ print(f"Loading card data from: {cards_path}")
185
+ loader = CardDataLoader(cards_path)
186
+ m, l, e = loader.load()
187
+ member_db.update(m)
188
+ live_db.update(l)
189
+ energy_db.update(e)
190
+
191
+ # Initialize rust_serializer
192
+ rust_serializer = RustGameStateSerializer(member_db, live_db, energy_db)
193
+
194
+ # Build mapping
195
+ build_card_no_mapping()
196
+
197
+ print(f"Data loaded: {len(member_db)} Members, {len(live_db)} Lives, {len(energy_db)} Energy")
198
+ print(f"DEBUG PATHS: PROJECT_ROOT={PROJECT_ROOT}")
199
+ print(f"DEBUG PATHS: FRONTEND_DIR={FRONTEND_DIR}")
200
+ print(f"DEBUG PATHS: WEB_UI_DIR={WEB_UI_DIR}")
201
+ print(f"DEBUG PATHS: IMG_DIR={IMG_DIR}")
202
+ except Exception as ex:
203
+ print(f"CRITICAL ERROR loading card data: {ex}")
204
+ import sys
205
+ sys.exit(1)
206
+
207
+
208
+ # Load data immediately on import
209
+
210
+
211
+ def get_room_id() -> str:
212
+ """Extract room_id from request header or query param."""
213
+ # Priority: Header > Query Param > Default "SINGLE_PLAYER"
214
+ rid = request.headers.get("X-Room-Id") or request.args.get("room_id")
215
+ if not rid:
216
+ # Debug why no ID found
217
+ # print(f"DEBUG: No X-Room-Id or room_id param. Headers: {request.headers}", file=sys.stderr)
218
+ rid = "SINGLE_PLAYER"
219
+ return rid
220
+
221
+
222
+ def get_player_idx():
223
+ """Extract player perspective from X-Player-Idx header or viewer query param."""
224
+ # Try query param 'viewer' first (commonly used by frontend)
225
+ viewer = request.args.get("viewer")
226
+ if viewer is not None:
227
+ try:
228
+ return int(viewer)
229
+ except (ValueError, TypeError):
230
+ pass
231
+
232
+ # Fallback to header
233
+ try:
234
+ return int(request.headers.get("X-Player-Idx", 0))
235
+ except (ValueError, TypeError):
236
+ return 0
237
+
238
+
239
+ def get_room(room_id: str) -> dict[str, Any] | None:
240
+ """Get room data safely."""
241
+ with game_lock:
242
+ room = ROOMS.get(room_id)
243
+ if room:
244
+ room["last_active"] = datetime.now()
245
+ return room
246
+
247
+
248
+ # Reverse mapping: card_no string -> internal integer ID
249
+ card_no_to_id: dict[str, int] = {}
250
+
251
+
252
+ def build_card_no_mapping():
253
+ """Build reverse lookup from card_no string to internal ID using compiled data.
254
+ Ensures consistency with the Rust engine's internal ID assignments.
255
+ """
256
+ global card_no_to_id
257
+ card_no_to_id = {}
258
+
259
+ try:
260
+ compiled_path = os.path.join(DATA_DIR, "cards_compiled.json")
261
+ if not os.path.exists(compiled_path):
262
+ print(f"Warning: {compiled_path} not found. Mapping will be empty.")
263
+ return
264
+
265
+ with open(compiled_path, "r", encoding="utf-8") as f:
266
+ data = json.load(f)
267
+
268
+ # Build mapping from dbs
269
+ count = 0
270
+ for db_name in ["member_db", "live_db", "energy_db"]:
271
+ db = data.get(db_name, {})
272
+ for internal_id, card_data in db.items():
273
+ card_no = card_data.get("card_no")
274
+ if card_no:
275
+ # Convert string key to integer ID
276
+ card_no_to_id[card_no] = int(internal_id)
277
+ count += 1
278
+
279
+ print(f"Built card_no_to_id mapping from compiled data: {count} entries")
280
+ except Exception as e:
281
+ print(f"Error building mapping from compiled data: {e}")
282
+
283
+
284
+ # Load data immediately on import
285
+ load_game_data()
286
+
287
+
288
+ # Initialize mapping on startup
289
+ build_card_no_mapping()
290
+
291
+
292
+ def convert_deck_strings_to_ids(deck_strings):
293
+ """Convert list of card_no strings to internal IDs (Unique Instance IDs)."""
294
+ ids = []
295
+ counts = {}
296
+ for card_no in deck_strings:
297
+ if card_no in card_no_to_id:
298
+ base_id = card_no_to_id[card_no]
299
+ count = counts.get(base_id, 0)
300
+ uid = create_uid(base_id, count)
301
+ counts[base_id] = count + 1
302
+ ids.append(uid)
303
+ else:
304
+ print(f"Warning: Unknown card_no '{card_no}', skipping.")
305
+ return ids
306
+
307
+
308
+ def save_replay(gs: GameState | None = None):
309
+ """Save the provided game state's history to a file."""
310
+ if gs is None or not gs.rule_log:
311
+ return
312
+
313
+ try:
314
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
315
+ os.makedirs("replays", exist_ok=True)
316
+ filename = f"replays/replay_{timestamp}.json"
317
+ filename_opt = f"replays/replay_{timestamp}_opt.json"
318
+
319
+ # Use historical states from rule_log or history if we maintain one
320
+ # For now, we assume GS has what we need or we pass history
321
+ history = [] # In this engine, standard replays are often built from logs or incremental states
322
+
323
+ # 1. Save Standard Replay (Compatible)
324
+ data = {
325
+ "game_id": 0,
326
+ "timestamp": timestamp,
327
+ "winner": gs.winner if gs else -1,
328
+ "states": history,
329
+ }
330
+
331
+ with open(filename, "w", encoding="utf-8") as f:
332
+ json.dump(data, f, ensure_ascii=False)
333
+ print(f"Replay saved to {filename}")
334
+
335
+ # 2. Save Optimized Replay (Dict Encoded)
336
+ try:
337
+ print("Optimizing replay...")
338
+
339
+ # Gather Level 3 Context
340
+ deck_info = None
341
+ if gs:
342
+ deck_info = {
343
+ "p0_deck": list(getattr(gs.players[0], "initial_deck_indices", [])),
344
+ "p1_deck": list(getattr(gs.players[1], "initial_deck_indices", [])),
345
+ }
346
+
347
+ opt_data = optimize_history(
348
+ history,
349
+ member_db,
350
+ live_db,
351
+ energy_db,
352
+ exclude_db_cards=True,
353
+ # seed=current_seed,
354
+ # action_log=action_log,
355
+ deck_info=deck_info,
356
+ )
357
+
358
+ final_opt = {
359
+ "game_id": 0,
360
+ "timestamp": timestamp,
361
+ "winner": gs.winner if gs else -1,
362
+ }
363
+
364
+ # Merge optimization data
365
+ if "level" in opt_data and opt_data["level"] == 3:
366
+ final_opt.update(opt_data) # seed, decks, action_log
367
+ print("Level 3 Optimization Active (Action Log)")
368
+ else:
369
+ final_opt["states"] = opt_data["states"]
370
+
371
+ with open(filename_opt, "w", encoding="utf-8") as f:
372
+ json.dump(final_opt, f, ensure_ascii=False)
373
+
374
+ # Calculate savings
375
+ size_std = os.path.getsize(filename)
376
+ size_opt = os.path.getsize(filename_opt)
377
+ savings = (1 - size_opt / size_std) * 100
378
+ print(f"Optimized replay saved to {filename_opt}")
379
+ print(f"Compression: {size_std / 1024:.1f}KB -> {size_opt / 1024:.1f}KB ({savings:.1f}% savings)")
380
+
381
+ except Exception as e:
382
+ print(f"Failed to save optimized replay: {e}")
383
+ import traceback
384
+
385
+ traceback.print_exc()
386
+
387
+ except Exception as e:
388
+ print(f"Failed to save replay: {e}")
389
+
390
+
391
+ game_history = [] # For replay recording
392
+ action_log = [] # For action-based replay
393
+ current_seed = 0 # For deterministic replay
394
+
395
+
396
+ def init_game(deck_type="normal"):
397
+ global game_state, member_db, live_db, energy_db, game_history, current_seed, action_log
398
+
399
+ # Ensure true randomness for each game
400
+ import time
401
+
402
+ real_seed = int(time.time() * 1000) % (2**31)
403
+ current_seed = real_seed
404
+ random.seed(real_seed)
405
+
406
+ # Store action history separately for Level 3 Replay
407
+ global action_log
408
+ action_log = []
409
+
410
+ # DATA PATH: data/cards.json
411
+ cards_path = os.path.join(DATA_DIR, "cards.json")
412
+ loader = CardDataLoader(cards_path)
413
+ member_db, live_db, energy_db = loader.load()
414
+
415
+ # CRITICAL: Populate GameState static DBs so validations work
416
+ # Use initialize_class_db to ensure proper wrapping with MaskedDB
417
+ GameState.initialize_class_db(member_db, live_db)
418
+ GameState.energy_db = energy_db
419
+
420
+ # Initialize JIT arrays for performance
421
+ GameState._init_jit_arrays()
422
+
423
+ # Build reverse mapping for custom deck support
424
+ build_card_no_mapping()
425
+
426
+ # Pre-calculate Start Deck card IDs
427
+
428
+ # Load raw JSON to check product field for filtering
429
+ cards_path = os.path.join(DATA_DIR, "cards.json")
430
+ with open(cards_path, "r", encoding="utf-8") as f:
431
+ json.load(f)
432
+
433
+ for _cid, _m in member_db.items():
434
+ # Find raw key by matching name/cost/type? Or better, DataLoader should store product.
435
+ # Since DataLoader doesn't verify product yet, we'll try to guess or just use ALL valid cards
436
+ # that are from Start Deck (usually ID < 100 for this mock loader or by string ID).
437
+ # Actually, let's just use ALL loaded members/lives for 'normal' and specific ones for 'starter'.
438
+ # For 'start_deck', we can filter by card string ID prefix 'PL!-sd1' or 'LL-E'.
439
+
440
+ # But 'member_db' keys are integers 0..N. We need a way to link back.
441
+ # The loader assigns IDs sequentially.
442
+ # Let's just build a random valid deck from ALL cards for now,
443
+ # unless 'easy' mode.
444
+ pass
445
+
446
+ # If deck_type is 'easy', we use the simple mock cards for logic testing.
447
+ # If deck_type is 'normal' or 'starter', we use REAL cards.
448
+
449
+ if deck_type == "easy":
450
+ easy_m, easy_l = create_easy_cards()
451
+ member_db[easy_m.card_id] = easy_m
452
+ live_db[easy_l.card_id] = easy_l
453
+
454
+ game_state = GameState()
455
+
456
+ # Setup players
457
+ for pidx, p in enumerate(game_state.players):
458
+ # Check for custom deck first
459
+ custom_deck = custom_deck_p0 if pidx == 0 else custom_deck_p1
460
+
461
+ if custom_deck:
462
+ # Use custom deck
463
+ p.main_deck = convert_deck_strings_to_ids(custom_deck)
464
+ random.shuffle(p.main_deck) # Shuffle custom deck for variety
465
+ print(f"Player {pidx}: Using custom deck ({len(p.main_deck)} cards, shuffled)")
466
+ elif deck_type == "easy":
467
+ # Use Easy Cards (888/999) but mapped to real images
468
+ p.main_deck = [888] * 48 + [999] * 12
469
+ else:
470
+ # NORMAL / STARTER MODE: Build a valid deck
471
+ # Rule: Max 4 copies of same card number.
472
+ # Total: 48 Members + 12 Lives (Total 60 in main deck per game_state spec)
473
+
474
+ p.main_deck = []
475
+
476
+ # 1. Select Members (48)
477
+ available_members = list(member_db.keys())
478
+ if available_members:
479
+ # Shuffle availability to vary decks
480
+ random.shuffle(available_members)
481
+
482
+ member_bucket = []
483
+ for mid in available_members:
484
+ # Add 4 copies of each until we have enough
485
+ # Use create_uid for unique instance IDs
486
+ for i in range(4):
487
+ uid = create_uid(mid, i)
488
+ member_bucket.append(uid)
489
+
490
+ if len(member_bucket) >= 150: # Optimization: Don't build massive list
491
+ break
492
+
493
+ # Pick 48 from the bucket
494
+ if len(member_bucket) < 48:
495
+ # Fallback if DB too small
496
+ while len(member_bucket) < 48:
497
+ member_bucket.extend(available_members)
498
+
499
+ # Ensure we don't accidentally pick >4 if we just slice
500
+ # Actually, simply taking the first 48 from our constructed bucket (which has 4 of each distinct card)
501
+ # guarantees validity if we shuffle the CARDS/TYPES, not the final list.
502
+ # Steps:
503
+ # 1. Shuffle types.
504
+ # 2. Add 4 of Type A, 4 of Type B...
505
+ # 3. Take first 48 cards.
506
+
507
+ p.main_deck.extend(member_bucket[:48])
508
+
509
+ # 2. Select Lives (12)
510
+ available_lives = list(live_db.keys())
511
+ if available_lives:
512
+ random.shuffle(available_lives)
513
+ live_bucket = []
514
+ for lid in available_lives:
515
+ # live_bucket.extend([lid] * 4)
516
+ for i in range(4):
517
+ uid = create_uid(lid, i)
518
+ live_bucket.append(uid)
519
+
520
+ if len(live_bucket) >= 50:
521
+ break
522
+
523
+ if len(live_bucket) < 12:
524
+ while len(live_bucket) < 12:
525
+ live_bucket.extend(available_lives)
526
+
527
+ p.main_deck.extend(live_bucket[:12])
528
+
529
+ random.shuffle(p.main_deck)
530
+
531
+ # Energy Deck (12 cards)
532
+ # Use actual Energy Card ID if available (2000+)
533
+ if energy_db:
534
+ eid = list(energy_db.keys())[0] # Take first energy card type found
535
+ p.energy_deck = [eid] * 12
536
+ else:
537
+ p.energy_deck = [40000] * 12 # Fallback
538
+
539
+ # Custom Energy Deck Override
540
+ custom_energy = custom_energy_deck_p0 if pidx == 0 else custom_energy_deck_p1
541
+ if custom_energy:
542
+ p.energy_deck = convert_deck_strings_to_ids(custom_energy)
543
+ print(f"Player {pidx}: Using custom energy deck ({len(p.energy_deck)} cards)")
544
+
545
+ # Explicit shuffle before drawing
546
+ random.shuffle(p.main_deck)
547
+ if game_state.players.index(p) == 0:
548
+ print(f"DEBUG: P0 Deck Shuffled. Top 5: {p.main_deck[-5:]}")
549
+
550
+ # Initial draw (6 cards - standard Mulligan start)
551
+ for _ in range(6):
552
+ if p.main_deck:
553
+ p.hand.append(p.main_deck.pop())
554
+ p.hand_added_turn.append(game_state.turn_number)
555
+
556
+ # Initial energy: 3 cards (Rule 6.2.1.7)
557
+ for _ in range(3):
558
+ if p.energy_deck:
559
+ p.energy_zone.append(p.energy_deck.pop(0))
560
+
561
+ # Randomly determine first player
562
+ game_state.first_player = random.randint(0, 1)
563
+
564
+ # For Mulligan Phase (P1/Index 0), Current Player MUST be 0
565
+ # The 'first_player' variable determines who acts first in ACTIVE phase (Round 1)
566
+ game_state.current_player = 0
567
+
568
+ # Start in MULLIGAN phase
569
+ game_state.phase = Phase.MULLIGAN_P1
570
+
571
+
572
+ def create_room_internal(
573
+ room_id: str,
574
+ mode: str = "pve",
575
+ deck_type: str = "normal",
576
+ public: bool = False,
577
+ custom_decks: dict = None,
578
+ ) -> dict[str, Any]:
579
+ """Helper to initialize a room using the RUST engine."""
580
+ print(
581
+ f"DEBUG: Creating Rust Room {room_id} (Mode: {mode}, Deck: {deck_type}, Public: {public}, CustomDecks: {bool(custom_decks)})"
582
+ )
583
+
584
+ if RUST_DB is None:
585
+ raise Exception("RUST_DB not initialized")
586
+
587
+ gs = engine_rust.PyGameState(RUST_DB)
588
+
589
+ # helper for deck generation
590
+ def get_random_decks():
591
+ m_ids = list(member_db.keys())
592
+ l_ids = list(live_db.keys())
593
+ random.shuffle(m_ids)
594
+ random.shuffle(l_ids)
595
+
596
+ main_ids = []
597
+ for mid in m_ids[:15]:
598
+ main_ids.extend([mid] * 4)
599
+ for lid in l_ids[:4]:
600
+ main_ids.extend([lid] * 4)
601
+ random.shuffle(main_ids)
602
+
603
+ e_deck = [list(energy_db.keys())[0]] * 12 if energy_db else [40000] * 12
604
+ l_deck = l_ids[
605
+ :3
606
+ ] # Actually the Rust engine treats lives as a separate param in some versions or part of deck?
607
+ # Checked engine_rust/src/py_bindings.rs: initialize_game needs p0_deck, p1_deck, p0_energy, p1_energy, p0_lives, p1_lives
608
+
609
+ return main_ids[:60], e_deck, l_ids[:3]
610
+
611
+ # Defaults
612
+ p0_m, p0_e, p0_l = get_random_decks()
613
+ p1_m, p1_e, p1_l = get_random_decks()
614
+
615
+ # Override with custom decks if provided
616
+ final_custom_decks = {0: {"main": [], "energy": []}, 1: {"main": [], "energy": []}}
617
+ if custom_decks:
618
+ final_custom_decks.update(custom_decks)
619
+ for pid in [0, 1]:
620
+ cdeck = custom_decks.get(str(pid)) or custom_decks.get(pid)
621
+ if cdeck and cdeck.get("main"):
622
+ # Convert strings to IDs
623
+ main_ids = convert_deck_strings_to_ids(cdeck["main"])
624
+ random.shuffle(main_ids)
625
+
626
+ # Extract Live cards for the initial Live Zone (3 cards)
627
+ # Note: cid is a UID, so we must mask it to compare with live_db keys
628
+ live_ids = [cid for cid in main_ids if (cid & BASE_ID_MASK) in live_db]
629
+
630
+ if len(main_ids) > 0:
631
+ if pid == 0:
632
+ p0_m = main_ids
633
+ if len(live_ids) >= 3:
634
+ p0_l = live_ids[:3] # Pick first 3 as starting lives
635
+ elif len(live_ids) > 0:
636
+ p0_l = live_ids # Use whatever lives are available
637
+ else:
638
+ p1_m = main_ids
639
+ if len(live_ids) >= 3:
640
+ p1_l = live_ids[:3]
641
+ elif len(live_ids) > 0:
642
+ p1_l = live_ids
643
+
644
+ # Energy
645
+ if cdeck.get("energy"):
646
+ e_ids = convert_deck_strings_to_ids(cdeck["energy"])
647
+ if pid == 0:
648
+ p0_e = e_ids
649
+ else:
650
+ p1_e = e_ids
651
+
652
+ # Warning: We are not extracting initial lives from main deck for p0_l/p1_l if custom.
653
+ # The engine probably draws them?
654
+ # If `p0_l` is required, we should pick random 3 from lives in deck or DB?
655
+ # For now, let's keep random lives for the Live Zone if not specified, or just reuse random ones.
656
+
657
+ gs.initialize_game(p0_m, p1_m, p0_e, p1_e, p0_l, p1_l)
658
+
659
+ return {
660
+ "state": gs,
661
+ "mode": mode,
662
+ "public": public,
663
+ "created_at": datetime.now(),
664
+ "last_active": datetime.now(),
665
+ "ai_agent": None, # MCTS is built-in
666
+ "custom_decks": final_custom_decks,
667
+ "sessions": {},
668
+ "engine": "rust",
669
+ }
670
+
671
+
672
+ def join_room_logic(room_id: str) -> dict[str, Any]:
673
+ """
674
+ Logic to add a user session to a room.
675
+ Returns {"session_id": str, "player_id": int}
676
+ """
677
+ if room_id not in ROOMS:
678
+ return {"error": "Room not found"}
679
+
680
+ room = ROOMS[room_id]
681
+ sessions = room["sessions"]
682
+
683
+ # Simple assignment logic:
684
+ # If 0 is free, take 0.
685
+ # If 1 is free, take 1.
686
+ # Else, maybe return spectator? For now, just return -1 or error.
687
+
688
+ # Check current players
689
+ taken_pids = set(sessions.values())
690
+
691
+ new_pid = -1
692
+ if 0 not in taken_pids:
693
+ new_pid = 0
694
+ elif 1 not in taken_pids:
695
+ new_pid = 1
696
+ else:
697
+ # Both full. Spectator?
698
+ new_pid = -1
699
+ # For spectator, maybe we still give a session but with pid -1?
700
+
701
+ session_id = str(uuid.uuid4())
702
+ sessions[session_id] = new_pid
703
+
704
+ return {"session_id": session_id, "player_id": new_pid}
705
+
706
+
707
+ # --- ROOM MANAGEMENT API ---
708
+
709
+
710
+ @app.route("/api/rooms/create", methods=["POST"])
711
+ def create_new_room():
712
+ print("DEBUG: Entered create_new_room endpoint", file=sys.stderr)
713
+ try:
714
+ data = request.json or {}
715
+ except Exception as e:
716
+ print(f"DEBUG: Failed to parse JSON: {e}", file=sys.stderr)
717
+ data = {}
718
+
719
+ mode = data.get("mode", "pve")
720
+ is_public = data.get("public", False)
721
+ custom_decks = data.get("decks", None) # Optional initial decks
722
+
723
+ # Generate 4-char code
724
+ import string
725
+
726
+ chars = string.ascii_uppercase + string.digits
727
+ while True:
728
+ room_id = "".join(random.choices(chars, k=4))
729
+ if room_id not in ROOMS:
730
+ break
731
+
732
+ print(f"DEBUG: Generated room_id {room_id}, acquiring lock...", file=sys.stderr)
733
+ res = {}
734
+ with game_lock:
735
+ print("DEBUG: Lock acquired. Creating room internal...", file=sys.stderr)
736
+ ROOMS[room_id] = create_room_internal(room_id, mode, public=is_public, custom_decks=custom_decks)
737
+ print("DEBUG: Room created internally. Joining creator...", file=sys.stderr)
738
+
739
+ # Auto-join creator
740
+ join_res = join_room_logic(room_id)
741
+
742
+ print("DEBUG: Returning response.", file=sys.stderr)
743
+ return jsonify({"success": True, "room_id": room_id, "mode": mode, "session": join_res})
744
+
745
+
746
+ @app.route("/api/rooms/list", methods=["GET"])
747
+ def list_public_rooms():
748
+ """Return a list of public rooms."""
749
+ public_rooms = []
750
+ with game_lock:
751
+ for rid, room in ROOMS.items():
752
+ if room.get("public", False):
753
+ # Calculate player count
754
+ sessions = room.get("sessions", {})
755
+ player_count = len(set(sessions.values())) # Approximate, might need better logic if spectators exist
756
+ # Or just count occupied slots (0 and 1)
757
+ occupied_slots = 0
758
+ taken_pids = set(sessions.values())
759
+ if 0 in taken_pids:
760
+ occupied_slots += 1
761
+ if 1 in taken_pids:
762
+ occupied_slots += 1
763
+
764
+ # Basic Info
765
+ gs = room.get("state")
766
+ turn = gs.turn_number if gs else 0
767
+ phase = str(gs.phase) if gs else "?"
768
+
769
+ public_rooms.append(
770
+ {
771
+ "room_id": rid,
772
+ "mode": room.get("mode", "pve"),
773
+ "players": occupied_slots,
774
+ "turn": turn,
775
+ "phase": phase,
776
+ "created_at": room.get("created_at", datetime.now()).isoformat(),
777
+ }
778
+ )
779
+
780
+ # Sort by creation time desc
781
+ public_rooms.sort(key=lambda x: x["created_at"], reverse=True)
782
+ return jsonify({"success": True, "rooms": public_rooms})
783
+
784
+
785
+ @app.route("/api/rooms/join", methods=["POST"])
786
+ def join_room():
787
+ print("DEBUG: Entered join_room", file=sys.stderr)
788
+ data = request.json or {}
789
+ room_id = data.get("room_id", "").upper().strip()
790
+ print(f"DEBUG: Entered join_room for ID: '{room_id}'", file=sys.stderr)
791
+
792
+ with game_lock:
793
+ if room_id in ROOMS:
794
+ mode = ROOMS[room_id]["mode"]
795
+ print(f"DEBUG: Found room {room_id}, mode={mode}", file=sys.stderr)
796
+
797
+ # Assign a session/seat to the joining player
798
+ join_res = join_room_logic(room_id)
799
+ if "error" in join_res:
800
+ return jsonify({"success": False, "error": join_res["error"]}), 400
801
+
802
+ return jsonify(
803
+ {
804
+ "success": True,
805
+ "room_id": room_id,
806
+ "mode": mode,
807
+ "session_id": join_res.get("session_id"),
808
+ "player_id": join_res.get("player_id"),
809
+ }
810
+ )
811
+
812
+ return jsonify({"success": False, "error": "Room not found"}), 404
813
+
814
+
815
+ @app.route("/")
816
+ def index():
817
+ return send_from_directory(WEB_UI_DIR, "index.html")
818
+
819
+
820
+ @app.route("/board")
821
+ def game_board():
822
+ return send_from_directory(WEB_UI_DIR, "game_board.html") # Assuming it exists there
823
+
824
+
825
+ @app.route("/js/<path:filename>")
826
+ def serve_js(filename):
827
+ return send_from_directory(os.path.join(WEB_UI_DIR, "js"), filename)
828
+
829
+
830
+ @app.route("/css/<path:filename>")
831
+ def serve_css(filename):
832
+ return send_from_directory(os.path.join(WEB_UI_DIR, "css"), filename)
833
+
834
+
835
+ @app.route("/icon_blade.png")
836
+ def serve_icon():
837
+ # If icon is in root or img, adjust. Assuming img for now or checking existence.
838
+ # Fallback to IMG_DIR or WEB_UI_DIR
839
+ return send_from_directory(IMG_DIR, "icon_blade.png")
840
+
841
+
842
+ @app.route("/deck_builder.html")
843
+ def serve_deck_builder():
844
+ return send_from_directory(WEB_UI_DIR, "deck_builder.html")
845
+
846
+
847
+ @app.route("/data/<path:filename>")
848
+ def serve_data(filename):
849
+ return send_from_directory(DATA_DIR, filename)
850
+
851
+
852
+ import threading
853
+ import time
854
+
855
+ # Threading setup
856
+ game_lock = threading.RLock() # Re-entrant lock to prevent self-deadlock
857
+ game_thread = None
858
+
859
+
860
+ def background_game_loop():
861
+ """
862
+ Runs the game logic (AI and auto-phases) for ALL active rooms.
863
+ """
864
+ print("Background Game Loop Started (Multi-Room)", file=sys.stderr)
865
+
866
+ while True:
867
+ try:
868
+ # print("DEBUG: Background Loop acquiring lock...", file=sys.stderr)
869
+ with game_lock:
870
+ # Iterate over a copy of keys to avoid modification issues if needed
871
+ active_room_ids = list(ROOMS.keys())
872
+
873
+ for rid in active_room_ids:
874
+ # print(f"DEBUG: Processing room {rid}...", file=sys.stderr)
875
+ room = ROOMS.get(rid)
876
+ if not room:
877
+ continue
878
+
879
+ gs = room["state"]
880
+ game_mode = room["mode"]
881
+ ai_agent = room["ai_agent"]
882
+
883
+ if not gs.is_terminal():
884
+ # 1. Auto-Advance Phases
885
+ if gs.phase in (
886
+ Phase.ACTIVE,
887
+ Phase.ENERGY,
888
+ Phase.DRAW,
889
+ Phase.PERFORMANCE_P1,
890
+ Phase.PERFORMANCE_P2,
891
+ ):
892
+ # Safe attribute access for Rust engine compatibility
893
+ p_choices = getattr(gs, "pending_choices", [])
894
+ p_effects = getattr(gs, "pending_effects", [])
895
+ if not (p_choices or p_effects):
896
+ res = gs.step(0)
897
+ if res is not None:
898
+ room["state"] = res
899
+ gs = res
900
+
901
+ elif gs.current_player == 1 and game_mode == "pve":
902
+ is_continue_choice = False
903
+ if gs.pending_choices and gs.pending_choices[0][0].startswith("CONTINUE"):
904
+ is_continue_choice = True
905
+
906
+ if gs.phase == Phase.LIVE_RESULT and is_continue_choice:
907
+ # Wait for Human
908
+ pass
909
+ else:
910
+ if gs.phase in (Phase.MULLIGAN_P1, Phase.MULLIGAN_P2):
911
+ aid = 0
912
+ res = gs.step(aid)
913
+ if res is not None:
914
+ room["state"] = res
915
+ else:
916
+ if room.get("engine") == "rust":
917
+ # Use Greedy (1-ply) AI for Rust engine in PVE to maximize responsiveness
918
+ gs.step_opponent_greedy()
919
+ else:
920
+ aid = ai_agent.choose_action(gs, 1)
921
+ res = gs.step(aid)
922
+ if res is not None:
923
+ room["state"] = res
924
+
925
+ time.sleep(0.1)
926
+
927
+ except Exception as e:
928
+ print(f"Error in game loop: {e}")
929
+ import traceback
930
+
931
+ traceback.print_exc()
932
+ time.sleep(1.0)
933
+
934
+
935
+ @app.route("/api/state")
936
+ def get_state():
937
+ room_id = get_room_id()
938
+ session_token = request.headers.get("X-Session-Token")
939
+
940
+ with game_lock:
941
+ # Development convenience: Auto-create room if missing IF it's "SINGLE_PLAYER"
942
+ if room_id == "SINGLE_PLAYER" and room_id not in ROOMS:
943
+ ROOMS[room_id] = create_room_internal(room_id)
944
+
945
+ room = get_room(room_id)
946
+ if not room:
947
+ return jsonify({"success": False, "error": "Room not found or expired"}), 404
948
+
949
+ gs = room["state"]
950
+ mode = room["mode"]
951
+ viewer_idx = get_player_idx()
952
+
953
+ if room.get("engine") == "rust":
954
+ s_state = rust_serializer.serialize_state(gs, viewer_idx=viewer_idx, mode=mode, is_pvp=(mode == "pvp"))
955
+ else:
956
+ s_state = serialize_state(
957
+ gs,
958
+ viewer_idx=viewer_idx,
959
+ is_pvp=(mode == "pvp" and request.headers.get("X-Player-Idx") is None),
960
+ mode=mode,
961
+ )
962
+
963
+ # Meta info about decks
964
+ cdecks = room.get("custom_decks", {})
965
+ meta = {
966
+ "p0_deck_set": bool(cdecks.get(0, {}).get("main") or cdecks.get("0", {}).get("main")),
967
+ "p1_deck_set": bool(cdecks.get(1, {}).get("main") or cdecks.get("1", {}).get("main")),
968
+ "mode": mode,
969
+ }
970
+
971
+ return jsonify({"success": True, "state": s_state, "meta": meta})
972
+
973
+
974
+ @app.route("/api/set_deck", methods=["POST"])
975
+ def set_deck():
976
+ """Accept a custom deck for a player in a specific room."""
977
+ data = request.json
978
+ player_id = data.get("player", 0)
979
+ deck_ids = data.get("deck", []) # List of card_no strings
980
+ energy_ids = data.get("energy_deck", [])
981
+
982
+ room_id = get_room_id()
983
+
984
+ with game_lock:
985
+ room = get_room(room_id)
986
+ # For setting deck, we might want to allow it even if room doesn't exist yet?
987
+ # But conceptually, you create a room, then set deck, then reset/start.
988
+ if not room:
989
+ # Auto-create for dev workflow
990
+ ROOMS[room_id] = create_room_internal(room_id)
991
+ room = ROOMS[room_id]
992
+
993
+ room["custom_decks"][player_id] = {"main": deck_ids, "energy": energy_ids}
994
+
995
+ return jsonify(
996
+ {
997
+ "status": "ok",
998
+ "player": player_id,
999
+ "deck_size": len(deck_ids),
1000
+ "message": f"Deck set for Player {player_id + 1} in Room {room_id}. Reset game to apply.",
1001
+ }
1002
+ )
1003
+
1004
+
1005
+ @app.route("/api/upload_deck", methods=["POST"])
1006
+ def upload_deck():
1007
+ """Accept a raw deck file content (decktest.txt style) and load it."""
1008
+ data = request.json
1009
+ content = data.get("content", "")
1010
+ player_id = data.get("player", 0)
1011
+
1012
+ room_id = get_room_id()
1013
+
1014
+ # Parse content
1015
+ try:
1016
+ if content.strip().startswith("{") or content.strip().startswith("["):
1017
+ # JSON format
1018
+ deck_data = json.loads(content)
1019
+ # Support both simple list and object
1020
+ if isinstance(deck_data, list):
1021
+ main_deck = deck_data
1022
+ energy_deck = [] # JSON list implies only main deck usually?
1023
+ elif "main" in deck_data:
1024
+ main_deck = deck_data["main"]
1025
+ energy_deck = deck_data.get("energy", [])
1026
+ else:
1027
+ return jsonify(
1028
+ {"success": False, "error": "Invalid JSON deck format. Expected list or object with 'main' key."}
1029
+ )
1030
+ else:
1031
+ # HTML/Text format
1032
+ card_db = {}
1033
+ try:
1034
+ cards_path = os.path.join(DATA_DIR, "cards.json")
1035
+ with open(cards_path, "r", encoding="utf-8") as f:
1036
+ card_db = json.load(f)
1037
+ except Exception as e:
1038
+ return jsonify({"success": False, "error": f"Failed to load card DB for validation: {e}"})
1039
+
1040
+ main_deck, energy_deck, _, errors = extract_deck_data(content, card_db) # Pass DB for validation
1041
+ if errors:
1042
+ return jsonify({"success": False, "error": "Validation Errors:\n" + "\n".join(errors)})
1043
+
1044
+ except json.JSONDecodeError:
1045
+ return jsonify({"success": False, "error": "Invalid JSON format."})
1046
+ except Exception as e:
1047
+ print(f"Deck parsing error: {e}")
1048
+ return jsonify({"success": False, "error": str(e)})
1049
+
1050
+ if not main_deck and not energy_deck:
1051
+ return jsonify({"success": False, "error": "No cards found in file."})
1052
+
1053
+ with game_lock:
1054
+ room = get_room(room_id)
1055
+ if not room:
1056
+ ROOMS[room_id] = create_room_internal(room_id)
1057
+ room = ROOMS[room_id]
1058
+
1059
+ room["custom_decks"][player_id] = {"main": main_deck, "energy": energy_deck}
1060
+
1061
+ # Auto-apply?
1062
+ # Re-init room with "custom" logic?
1063
+ # For now, let's just create a new room state using these decks immediately for convenience
1064
+ # But we need to respect the loop.
1065
+ # Actually existing logic calls init_game(deck_type="custom").
1066
+
1067
+ # We'll just trigger a reset logic manually
1068
+ # This duplicates logic in reset() but scoped to this room + custom deck applied.
1069
+
1070
+ # For simplicity, we just store it. User must click "Reset" or we call reset internal?
1071
+ # The frontend usually expects upload to just work.
1072
+ pass
1073
+
1074
+ # Trigger Reset via API logic simulation or just return success and let caller Reset?
1075
+ # Existing behavior: calls init_game("custom").
1076
+ # So we should probably do the same: reset the room's state using these custom decks.
1077
+ # We can reuse the create_room_internal logic if we modify it to accept custom decks directly?
1078
+ # Or just rely on the room["custom_decks"] being set.
1079
+
1080
+ # Let's call reset internal logic here?
1081
+ # Better: Update endpoints first, then we can verify flow.
1082
+ # For now, we assume user clicks Reset or we simulate it.
1083
+
1084
+ # Actually, let's just return success. The frontend typically reloads or resets.
1085
+
1086
+ return jsonify(
1087
+ {
1088
+ "success": True,
1089
+ "main_count": len(main_deck),
1090
+ "energy_count": len(energy_deck),
1091
+ "room_id": room_id,
1092
+ "message": f"Deck Loaded! ({len(main_deck)} Main, {len(energy_deck)} Energy). Please Reset.",
1093
+ }
1094
+ )
1095
+
1096
+
1097
+ @app.route("/api/get_test_deck", methods=["GET"])
1098
+ def get_test_deck_api():
1099
+ """Read deck files from ai/decks/ directory and return card list."""
1100
+ from engine.game.deck_utils import extract_deck_data
1101
+
1102
+ deck_name = request.args.get("deck", "") # Optional deck name parameter
1103
+
1104
+ # Path to ai/decks directory
1105
+ # Use PROJECT_ROOT for reliability
1106
+ ai_decks_dir = os.path.join(PROJECT_ROOT, "ai", "decks")
1107
+
1108
+ if not os.path.exists(ai_decks_dir):
1109
+ # Fallback: try CWD relative
1110
+ ai_decks_dir = os.path.abspath(os.path.join("ai", "decks"))
1111
+
1112
+ if not os.path.exists(ai_decks_dir):
1113
+ return jsonify({"success": False, "error": "ai/decks directory not found"})
1114
+
1115
+ # List available decks (excluding verify script)
1116
+ available_decks = []
1117
+ for f in os.listdir(ai_decks_dir):
1118
+ if f.endswith(".txt") and not f.startswith("verify"):
1119
+ available_decks.append(f.replace(".txt", ""))
1120
+
1121
+ # If no deck specified, return list of available decks
1122
+ if not deck_name:
1123
+ # Default to aqours_cup for "Load Test Deck" button compatibility
1124
+ deck_name = "aqours_cup"
1125
+ message = "Defaulting to 'aqours_cup'. Specify ?deck=NAME to load a specific deck."
1126
+ else:
1127
+ message = f"Loaded '{deck_name}'"
1128
+
1129
+ # Find matching deck file
1130
+ deck_file = os.path.join(ai_decks_dir, f"{deck_name}.txt")
1131
+ if not os.path.exists(deck_file):
1132
+ return jsonify({"success": False, "error": f"Deck '{deck_name}' not found", "available_decks": available_decks})
1133
+
1134
+ try:
1135
+ with open(deck_file, "r", encoding="utf-8") as f:
1136
+ content = f.read()
1137
+
1138
+ # Load card DB for parsing
1139
+ card_db_path = os.path.join(CURRENT_DIR, "..", "data", "cards.json")
1140
+ card_db = {}
1141
+ if os.path.exists(card_db_path):
1142
+ with open(card_db_path, "r", encoding="utf-8") as f_db:
1143
+ card_db = json.load(f_db)
1144
+
1145
+ # Use the unified parser
1146
+ main_deck, energy_deck, type_counts, errors = extract_deck_data(content, card_db)
1147
+
1148
+ return jsonify(
1149
+ {
1150
+ "success": True,
1151
+ "deck_name": deck_name,
1152
+ "content": main_deck, # For compatibility with older frontend
1153
+ "main_deck": main_deck,
1154
+ "energy_deck": energy_deck,
1155
+ "available_decks": available_decks,
1156
+ "message": f"{message} ({len(main_deck)} Main, {len(energy_deck)} Energy)",
1157
+ "errors": errors,
1158
+ }
1159
+ )
1160
+
1161
+ except Exception as e:
1162
+ return jsonify({"success": False, "error": str(e)})
1163
+
1164
+
1165
+ @app.route("/api/validate_cards", methods=["POST"])
1166
+ def validate_cards():
1167
+ """Validate card IDs against the database and provide type breakdown."""
1168
+ data = request.json
1169
+ card_ids = data.get("card_ids", [])
1170
+ card_counts = data.get("card_counts", {}) # Optional: {card_id: quantity}
1171
+
1172
+ # Ensure mapping is built
1173
+ if not card_no_to_id:
1174
+ print("DEBUG: validation - mapping empty, rebuilding...", flush=True)
1175
+ build_card_no_mapping()
1176
+
1177
+ print(f"DEBUG: validation - map size: {len(card_no_to_id)}", flush=True)
1178
+ test_key = "PL!SP-bp1-004-R"
1179
+ if test_key in card_no_to_id:
1180
+ print(f"DEBUG: validation - found {test_key}: {card_no_to_id[test_key]}", flush=True)
1181
+ else:
1182
+ print(f"DEBUG: validation - {test_key} NOT FOUND in map!", flush=True)
1183
+
1184
+ known = []
1185
+ unknown = []
1186
+ card_info = {} # card_id -> {type, name, internal_id}
1187
+
1188
+ # Type counters
1189
+ member_count = 0
1190
+ live_count = 0
1191
+ energy_count = 0
1192
+
1193
+ for card_id in card_ids:
1194
+ # print(f"DEBUG: Checking {card_id}", flush=True)
1195
+ qty = card_counts.get(card_id, 1)
1196
+ if card_id in card_no_to_id:
1197
+ internal_id = card_no_to_id[card_id]
1198
+ known.append(card_id)
1199
+
1200
+ # Determine type and get name
1201
+ if internal_id in member_db:
1202
+ card_info[card_id] = {"type": "Member", "name": member_db[internal_id].name}
1203
+ member_count += qty
1204
+ elif internal_id in live_db:
1205
+ card_info[card_id] = {"type": "Live", "name": live_db[internal_id].name}
1206
+ live_count += qty
1207
+ elif internal_id in energy_db:
1208
+ card_info[card_id] = {"type": "Energy", "name": energy_db[internal_id].name}
1209
+ energy_count += qty
1210
+ else:
1211
+ unknown.append(card_id)
1212
+
1213
+ debug_info = {
1214
+ "map_size": len(card_no_to_id),
1215
+ "test_key_exists": "PL!SP-bp1-004-R" in card_no_to_id,
1216
+ "test_key_val": card_no_to_id.get("PL!SP-bp1-004-R", "N/A"),
1217
+ "first_5_keys": list(card_no_to_id.keys())[:5],
1218
+ }
1219
+
1220
+ return jsonify(
1221
+ {
1222
+ "known": known,
1223
+ "unknown": unknown,
1224
+ "known_count": len(known),
1225
+ "unknown_count": len(unknown),
1226
+ "card_info": card_info,
1227
+ "breakdown": {"member": member_count, "live": live_count, "energy": energy_count},
1228
+ "_debug": debug_info,
1229
+ }
1230
+ )
1231
+
1232
+
1233
+ @app.route("/api/clear_performance", methods=["POST"])
1234
+ def clear_performance():
1235
+ room_id = get_room_id()
1236
+ with game_lock:
1237
+ room = get_room(room_id)
1238
+ if room:
1239
+ gs = room["state"]
1240
+ # Clear the results dictionary
1241
+ gs.performance_results.clear()
1242
+ return jsonify({"status": "ok"})
1243
+
1244
+
1245
+ @app.route("/api/action", methods=["POST"])
1246
+ def do_action():
1247
+ room_id = get_room_id()
1248
+ session_token = request.headers.get("X-Session-Token")
1249
+
1250
+ with game_lock:
1251
+ start_time = time.time()
1252
+ try:
1253
+ room = get_room(room_id)
1254
+ if not room:
1255
+ return jsonify({"success": False, "error": "Room not found"}), 404
1256
+
1257
+ gs = room["state"]
1258
+ game_mode = room["mode"]
1259
+ ai_agent = room["ai_agent"]
1260
+ sessions = room.get("sessions", {})
1261
+
1262
+ # Session Validation (Enforce Turn)
1263
+ if session_token and session_token in sessions:
1264
+ pid = sessions[session_token]
1265
+ if pid != -1:
1266
+ # Check Pending Choice Turn
1267
+ p_choices = getattr(gs, "pending_choices", [])
1268
+ if p_choices:
1269
+ # Handle both Rust (str, str) and Python (str, dict) formats
1270
+ params = p_choices[0][1]
1271
+ if isinstance(params, str):
1272
+ # Rust format: parse JSON
1273
+ try:
1274
+ params = json.loads(params)
1275
+ except:
1276
+ params = {}
1277
+ choice_pid = params.get("player_id", gs.current_player)
1278
+ if choice_pid != pid:
1279
+ return jsonify(
1280
+ {"success": False, "error": f"Not your turn to choose (Waiting for P{choice_pid})"}
1281
+ ), 403
1282
+ # Check Main Turn
1283
+ elif gs.current_player != pid:
1284
+ return jsonify(
1285
+ {"success": False, "error": f"Not your turn (Waiting for P{gs.current_player})"}
1286
+ ), 403
1287
+
1288
+ data = request.json
1289
+ action_id = data.get("action_id", 0)
1290
+ force = data.get("force", False)
1291
+
1292
+ legal_mask = gs.get_legal_actions()
1293
+
1294
+ # Validate Action
1295
+ if not (0 <= action_id < len(legal_mask)):
1296
+ return jsonify({"success": False, "error": "Invalid action ID"}), 400
1297
+
1298
+ # Enforce Perspective/Active Player consistency in PvP
1299
+ requester_idx = get_player_idx()
1300
+ if game_mode == "pvp":
1301
+ if requester_idx != gs.current_player:
1302
+ return jsonify(
1303
+ {"success": False, "error": f"Not your turn! It's P{gs.current_player + 1}'s turn."}
1304
+ ), 403
1305
+ elif game_mode == "pve":
1306
+ # In PvE, if it's AI turn (P1), don't allow manual action from UI
1307
+ if gs.current_player == 1:
1308
+ return jsonify({"success": False, "error": "AI is playing, please wait."}), 403
1309
+
1310
+ is_legal = legal_mask[action_id]
1311
+
1312
+ if force or is_legal:
1313
+ # Step 1: Execute User Action
1314
+ res = gs.step(action_id)
1315
+ if res is not None:
1316
+ room["state"] = res
1317
+ gs = res
1318
+
1319
+ # Step 2: Auto-Advance & AI Handling
1320
+ max_safety = 50
1321
+ while not gs.is_terminal() and max_safety > 0:
1322
+ max_safety -= 1
1323
+
1324
+ # A. Automatic Phases (-2=Setup, 1=Active, 2=Energy, 3=Draw, 6=Perf1, 7=Perf2, 8=LiveResult)
1325
+ if gs.phase in (-2, 1, 2, 3, 6, 7, 8):
1326
+ res = gs.step(0)
1327
+ if res is not None:
1328
+ room["state"] = res
1329
+ gs = res
1330
+ continue
1331
+
1332
+ # B. AI Turn (P1) - ONLY if PVE
1333
+ if gs.current_player == 1 and game_mode == "pve":
1334
+ if room.get("engine") == "rust":
1335
+ gs.step_opponent_mcts(10)
1336
+ else:
1337
+ # Python AI
1338
+ aid = ai_agent.choose_action(gs, 1)
1339
+ res = gs.step(aid)
1340
+ if res is not None:
1341
+ room["state"] = res
1342
+ gs = res
1343
+ continue
1344
+
1345
+ break
1346
+
1347
+ viewer_idx = get_player_idx()
1348
+ duration = time.time() - start_time
1349
+ print(f"[PERF] /api/action took {duration:.3f}s (Action: {action_id})")
1350
+ return jsonify(
1351
+ {
1352
+ "success": True,
1353
+ "state": rust_serializer.serialize_state(gs, viewer_idx=viewer_idx, mode=game_mode),
1354
+ }
1355
+ )
1356
+ else:
1357
+ return jsonify({"success": False, "error": f"Illegal action {action_id}"}), 400
1358
+
1359
+ except Exception as e:
1360
+ import traceback
1361
+
1362
+ traceback.print_exc()
1363
+
1364
+ # Auto-report issue on crash
1365
+ try:
1366
+ report_dir = os.path.join(CURRENT_DIR, "reports")
1367
+ os.makedirs(report_dir, exist_ok=True)
1368
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1369
+ crash_file = os.path.join(report_dir, f"crash_{timestamp}.json")
1370
+
1371
+ try:
1372
+ if room is not None and room.get("engine") == "rust":
1373
+ serialized_state = rust_serializer.serialize_state(
1374
+ gs, viewer_idx=get_player_idx(), mode=game_mode
1375
+ )
1376
+ else:
1377
+ serialized_state = serialize_state(
1378
+ gs, viewer_idx=get_player_idx(), is_pvp=(game_mode == "pvp"), mode=game_mode
1379
+ )
1380
+
1381
+ with open(crash_file, "w", encoding="utf-8") as f:
1382
+ # Use app.json.dumps to handle Numpy types
1383
+ f.write(
1384
+ app.json.dumps(
1385
+ {
1386
+ "error": str(e),
1387
+ "trace": traceback.format_exc(),
1388
+ "state": serialized_state,
1389
+ }
1390
+ )
1391
+ )
1392
+ except Exception as inner_e:
1393
+ # Fallback if serialization fails
1394
+ with open(crash_file, "w", encoding="utf-8") as f:
1395
+ f.write(
1396
+ app.json.dumps(
1397
+ {"error": str(e), "trace": traceback.format_exc(), "serialization_error": str(inner_e)}
1398
+ )
1399
+ )
1400
+ except:
1401
+ pass
1402
+
1403
+ return jsonify({"success": False, "error": str(e), "trace": traceback.format_exc()}), 500
1404
+
1405
+
1406
+ @app.route("/")
1407
+ def index_route():
1408
+ return send_from_directory(app.static_folder, "index.html")
1409
+
1410
+
1411
+ @app.route("/<path:path>")
1412
+ def static_proxy(path):
1413
+ return send_from_directory(app.static_folder, path)
1414
+
1415
+
1416
+ @app.route("/api/exec", methods=["POST"])
1417
+ def god_mode():
1418
+ room_id = get_room_id()
1419
+ code = request.json.get("code", "")
1420
+
1421
+ with game_lock:
1422
+ room = get_room(room_id)
1423
+ if not room:
1424
+ return jsonify({"success": False, "error": "Room not found"})
1425
+
1426
+ gs = room["state"]
1427
+ try:
1428
+ p = gs.active_player
1429
+ exec(code, {"state": gs, "p": p, "np": np})
1430
+ return jsonify({"success": True, "state": serialize_state(gs, is_pvp=(room["mode"] == "pvp"))})
1431
+ except Exception as e:
1432
+ return jsonify({"success": False, "error": str(e)})
1433
+
1434
+
1435
+ @app.route("/api/reset", methods=["POST"])
1436
+ def reset():
1437
+ room_id = get_room_id()
1438
+
1439
+ with game_lock:
1440
+ data = request.json or {}
1441
+ deck_type = data.get("deck_type", "normal")
1442
+ # Allow changing mode on reset
1443
+ new_mode = data.get("mode") # Optional
1444
+
1445
+ # Check if room exists to preserve existing params if not specified
1446
+ old_room = ROOMS.get(room_id)
1447
+ mode = new_mode if new_mode else (old_room["mode"] if old_room else "pve")
1448
+
1449
+ ROOMS[room_id] = create_room_internal(room_id, mode, deck_type)
1450
+ room = ROOMS[room_id]
1451
+
1452
+ # Check for custom decks and apply them if they exist for this room
1453
+ if old_room and "custom_decks" in old_room:
1454
+ room["custom_decks"] = old_room["custom_decks"]
1455
+
1456
+ # Preserve sessions
1457
+ if old_room and "sessions" in old_room:
1458
+ room["sessions"] = old_room["sessions"]
1459
+
1460
+ if deck_type == "custom":
1461
+ # Apply custom decks to the fresh state
1462
+ gs = room["state"]
1463
+ for pid in [0, 1]:
1464
+ cdeck = room["custom_decks"].get(pid)
1465
+ if cdeck and cdeck["main"]:
1466
+ gs.players[pid].main_deck = convert_deck_strings_to_ids(cdeck["main"])
1467
+ random.shuffle(gs.players[pid].main_deck)
1468
+ # Re-draw hand?
1469
+ gs.players[pid].hand = []
1470
+ gs.players[pid].hand_added_turn = []
1471
+ for _ in range(6):
1472
+ if gs.players[pid].main_deck:
1473
+ gs.players[pid].hand.append(gs.players[pid].main_deck.pop())
1474
+ gs.players[pid].hand_added_turn.append(0)
1475
+ if cdeck and cdeck["energy"]:
1476
+ # Re-fill energy
1477
+ gs.players[pid].energy_deck = convert_deck_strings_to_ids(cdeck["energy"])
1478
+ gs.players[pid].energy_zone = []
1479
+ for _ in range(3):
1480
+ if gs.players[pid].energy_deck:
1481
+ gs.players[pid].energy_zone.append(gs.players[pid].energy_deck.pop(0))
1482
+
1483
+ gs = room["state"]
1484
+ game_mode = room["mode"]
1485
+
1486
+ # Auto-advance (AI goes first or Init steps)
1487
+ max_safety = 100
1488
+ while not gs.is_terminal() and max_safety > 0:
1489
+ max_safety -= 1
1490
+ # Automatic phases
1491
+ if gs.phase in (-2, 1, 2, 3, 6, 7, 8):
1492
+ gs.step(0)
1493
+ continue
1494
+
1495
+ # AI Turn (P1)
1496
+ if gs.current_player == 1 and game_mode == "pve":
1497
+ gs.step_opponent_mcts(10)
1498
+ continue
1499
+
1500
+ break # P0 turn or user input needed
1501
+
1502
+ return jsonify({"success": True, "state": rust_serializer.serialize_state(gs, mode=game_mode)})
1503
+
1504
+
1505
+ @app.route("/api/ai_suggest", methods=["POST"])
1506
+ def ai_suggest():
1507
+ room_id = get_room_id()
1508
+ data = request.json or {}
1509
+ sims = data.get("sims", 10)
1510
+
1511
+ with game_lock:
1512
+ room = get_room(room_id)
1513
+ if not room:
1514
+ return jsonify({"error": "Room not found"}), 404
1515
+
1516
+ gs = room["state"]
1517
+ # Only run if not terminal
1518
+ if gs.is_terminal():
1519
+ return jsonify({"suggestions": []})
1520
+
1521
+ stats = gs.get_mcts_suggestions(sims)
1522
+
1523
+ # Shim for get_action_desc
1524
+ class RustShim:
1525
+ def __init__(self, gs):
1526
+ self.phase = gs.phase
1527
+ self.current_player = gs.current_player
1528
+ self.active_player = gs.get_player(gs.current_player)
1529
+ self.member_db = member_db
1530
+ self.live_db = live_db
1531
+ self.pending_choices = [] # TODO: expose from rust if needed
1532
+
1533
+ shim = RustShim(gs)
1534
+
1535
+ # Enrich stats with descriptions
1536
+ enriched = []
1537
+ for action, value, visits in stats:
1538
+ desc = get_action_desc(action, shim)
1539
+ enriched.append({"action_id": action, "value": float(value), "visits": int(visits), "desc": desc})
1540
+
1541
+ return jsonify({"success": True, "suggestions": enriched})
1542
+
1543
+
1544
+ @app.route("/api/replays", methods=["GET"])
1545
+ def list_replays():
1546
+ # 1. Root replays
1547
+ try:
1548
+ if os.path.exists("replays"):
1549
+ for f in os.listdir("replays"):
1550
+ if f.endswith(".json") and os.path.isfile(os.path.join("replays", f)):
1551
+ replays.append({"filename": f, "folder": ""})
1552
+
1553
+ # 2. Tournament subfolder
1554
+ tourney_dir = os.path.join("replays", "tournament")
1555
+ if os.path.exists(tourney_dir):
1556
+ for f in os.listdir(tourney_dir):
1557
+ if f.endswith(".json"):
1558
+ # We need to handle pathing. The frontend might expect just filename.
1559
+ # But get_replay takes "filename".
1560
+ # We should probably update get_replay to handle subpaths or encode it.
1561
+ # For now let's just use the relative path as the filename
1562
+ replays.append({"filename": f"tournament/{f}", "folder": "tournament"})
1563
+
1564
+ except Exception as e:
1565
+ print(f"Error listing replays: {e}")
1566
+ return jsonify({"success": False, "error": str(e)})
1567
+
1568
+ # Sort by filename desc (usually timestamp)
1569
+ replays.sort(key=lambda x: x["filename"], reverse=True)
1570
+ return jsonify({"success": True, "replays": replays})
1571
+
1572
+
1573
+ def get_replay(filename):
1574
+ """Serve replay JSON files"""
1575
+ replay_path = f"replays/{filename}"
1576
+ if os.path.exists(replay_path):
1577
+ with open(replay_path, "r", encoding="utf-8") as f:
1578
+ data = json.load(f)
1579
+ # Auto-inflate if it's an optimized replay
1580
+ if "registry" in data and "states" in data:
1581
+ print(f"Inflating optimized replay: {filename}")
1582
+ inflated_states = inflate_history(data, member_db, live_db, energy_db)
1583
+ # Reconstruct standard format
1584
+ data["states"] = inflated_states
1585
+ # Remove registry to avoid confusing frontend if it doesn't expect it
1586
+ del data["registry"]
1587
+
1588
+ return jsonify(data)
1589
+ return jsonify({"error": "Replay not found"}), 404
1590
+
1591
+
1592
+ @app.route("/api/advance", methods=["POST"])
1593
+ def advance():
1594
+ room_id = get_room_id()
1595
+ with game_lock:
1596
+ room = get_room(room_id)
1597
+ if not room:
1598
+ return jsonify({"success": False, "error": "Room not found"}), 404
1599
+
1600
+ gs = room["state"]
1601
+ ai_agent = room["ai_agent"]
1602
+
1603
+ # Run auto-advance loop
1604
+ max_safety = 50
1605
+ while not gs.is_terminal() and max_safety > 0:
1606
+ max_safety -= 1
1607
+ # Advance if in an automatic phase (AND no choices pending)
1608
+ if not gs.pending_choices and gs.phase in (
1609
+ Phase.ACTIVE,
1610
+ Phase.ENERGY,
1611
+ Phase.DRAW,
1612
+ Phase.PERFORMANCE_P1,
1613
+ Phase.PERFORMANCE_P2,
1614
+ ):
1615
+ gs = gs.step(0)
1616
+ room["state"] = gs
1617
+ continue
1618
+
1619
+ # Determine who should act (Check pending choices first)
1620
+ next_actor = gs.current_player
1621
+ if gs.pending_choices:
1622
+ # Handle both Rust (str, str) and Python (str, dict) formats
1623
+ params = gs.pending_choices[0][1]
1624
+ if isinstance(params, str):
1625
+ try:
1626
+ params = json.loads(params)
1627
+ except:
1628
+ params = {}
1629
+ next_actor = params.get("player_id", gs.current_player)
1630
+
1631
+ # If it's the AI's turn (P1) or the AI has a pending choice, let it act immediately
1632
+ if next_actor == 1 and not gs.is_terminal():
1633
+ aid = ai_agent.choose_action(gs, 1)
1634
+ gs = gs.step(aid)
1635
+ room["state"] = gs
1636
+ continue
1637
+
1638
+ break
1639
+
1640
+ return jsonify(
1641
+ {
1642
+ "success": True,
1643
+ "state": serialize_state(gs, is_pvp=(room["mode"] == "pvp"), mode=room["mode"]),
1644
+ }
1645
+ )
1646
+
1647
+
1648
+ @app.route("/api/full_log", methods=["GET"])
1649
+ def get_full_log():
1650
+ """Return the complete rule log without truncation."""
1651
+ room_id = get_room_id()
1652
+ with game_lock:
1653
+ room = get_room(room_id)
1654
+ if not room:
1655
+ return jsonify({"log": [], "total_entries": 0})
1656
+ gs = room["state"]
1657
+ return jsonify({"log": gs.rule_log, "total_entries": len(gs.rule_log)})
1658
+
1659
+
1660
+ @app.route("/api/set_ai", methods=["POST"])
1661
+ def set_ai():
1662
+ room_id = get_room_id()
1663
+ data = request.json
1664
+ mode = data.get("ai_mode", "smart")
1665
+
1666
+ with game_lock:
1667
+ room = get_room(room_id)
1668
+ if not room:
1669
+ return jsonify({"success": False, "error": "Room not found"})
1670
+
1671
+ if mode == "random":
1672
+ room["ai_agent"] = RandomAgent()
1673
+ elif mode == "smart":
1674
+ room["ai_agent"] = SmartAgent()
1675
+ else:
1676
+ return jsonify({"success": False, "error": f"Unknown AI mode: {mode}"})
1677
+
1678
+ return jsonify({"success": True, "mode": mode})
1679
+
1680
+
1681
+ @app.route("/api/report_issue", methods=["POST"])
1682
+ def report_issue():
1683
+ """Save the current game state and user explanation to a report file."""
1684
+ try:
1685
+ room_id = get_room_id()
1686
+ room = get_room(room_id)
1687
+ gs = room["state"] if room else None
1688
+
1689
+ data = request.json
1690
+ explanation = data.get("explanation", "")
1691
+ # We can take the current state from the request or just use our global game_state
1692
+ # Providing it in the request is safer if the user is looking at a specific frame (e.g. in replay mode)
1693
+ # But for now, let's use the provided state if it exists, otherwise capture the current one.
1694
+ if room and room.get("engine") == "rust":
1695
+ serialized = rust_serializer.serialize_state(gs, viewer_idx=0, mode=room.get("mode", "pve"))
1696
+ else:
1697
+ serialized = serialize_state(gs, is_pvp=(room["mode"] == "pvp" if room else False))
1698
+
1699
+ state_to_save = data.get("state") or serialized
1700
+ history = data.get("history", [])
1701
+
1702
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1703
+ os.makedirs("reports", exist_ok=True)
1704
+
1705
+ filename = f"reports/report_{timestamp}.json"
1706
+ with open(filename, "w", encoding="utf-8") as f:
1707
+ json.dump(
1708
+ {
1709
+ "timestamp": timestamp,
1710
+ "explanation": explanation,
1711
+ "state": state_to_save,
1712
+ "history": history,
1713
+ "performance_history": state_to_save.get("performance_history", []),
1714
+ "performance_results": state_to_save.get("performance_results", {}),
1715
+ "action_desc": get_action_desc(state_to_save.get("last_action", 0), gs)
1716
+ if gs and "last_action" in state_to_save
1717
+ else "N/A",
1718
+ },
1719
+ f,
1720
+ indent=2,
1721
+ ensure_ascii=False,
1722
+ )
1723
+
1724
+ return jsonify({"success": True, "filename": filename})
1725
+ except Exception as e:
1726
+ import traceback
1727
+
1728
+ traceback.print_exc()
1729
+ return jsonify({"success": False, "error": str(e)}), 500
1730
+
1731
+
1732
+ def generate_random_deck_list(member_db, live_db) -> list[str]:
1733
+ "Generate a valid random deck list (card_no strings)."
1734
+ deck = []
1735
+
1736
+ # 1. Select Members (48)
1737
+ available_members = [c.card_no for c in member_db.values()]
1738
+ if available_members:
1739
+ member_bucket = []
1740
+ for m_no in available_members:
1741
+ member_bucket.extend([m_no] * 4)
1742
+ random.shuffle(member_bucket)
1743
+ while len(member_bucket) < 48:
1744
+ member_bucket.extend(available_members)
1745
+ deck.extend(member_bucket[:48])
1746
+
1747
+ # 2. Select Lives (12)
1748
+ available_lives = [c.card_no for c in live_db.values()]
1749
+ if available_lives:
1750
+ live_bucket = []
1751
+ for l_no in available_lives:
1752
+ live_bucket.extend([l_no] * 4)
1753
+ random.shuffle(live_bucket)
1754
+ while len(live_bucket) < 12:
1755
+ live_bucket.extend(available_lives)
1756
+ deck.extend(live_bucket[:12])
1757
+
1758
+ return deck
1759
+
1760
+
1761
+ @app.route("/api/get_random_deck", methods=["GET"])
1762
+ def get_random_deck_api():
1763
+ global member_db, live_db
1764
+ deck_list = generate_random_deck_list(member_db, live_db)
1765
+ return jsonify(
1766
+ {"success": True, "content": deck_list, "message": f"Generated Random Deck ({len(deck_list)} cards)"}
1767
+ )
1768
+
1769
+
1770
+ @app.route("/api/presets", methods=["GET"])
1771
+ def get_presets():
1772
+ """Return list of preset decks from tests/presets.json."""
1773
+ try:
1774
+ preset_path = os.path.join(CURRENT_DIR, "..", "tests", "presets.json")
1775
+ if os.path.exists(preset_path):
1776
+ with open(preset_path, "r", encoding="utf-8") as f:
1777
+ data = json.load(f)
1778
+ return jsonify({"success": True, "presets": data})
1779
+ return jsonify({"success": False, "error": "presets.json not found", "presets": []})
1780
+ except Exception as e:
1781
+ return jsonify({"success": False, "error": str(e)})
1782
+
1783
+
1784
+ if __name__ == "__main__":
1785
+ # PyInstaller Bundle Check
1786
+ if getattr(sys, "frozen", False):
1787
+ # If frozen, we might need to adjust static folder or templates folder depending on how flask finds them.
1788
+ # However, we added paths with --add-data, so they should be in sys._MEIPASS.
1789
+ # Flask's root_path defaults to __main__ directory, which in onefile mode is temporary.
1790
+ # We need to explicitly point static_folder to the MEIPASS location.
1791
+ bundle_dir = getattr(sys, "_MEIPASS", ".") # type: ignore
1792
+ app.static_folder = os.path.join(bundle_dir, "web_ui")
1793
+ # app.template_folder = os.path.join(bundle_dir, 'templates') # if we used templates
1794
+
1795
+ # Also need to make sure data loader finds 'data/cards.json'
1796
+ # CardDataLoader expects relative path. We might need to chdir or patch it.
1797
+ # Easiest is to chdir to the bundle dir so relative paths work?
1798
+ # BUT 'replays' need to be written to writable cwd, not temp dir.
1799
+ # So we should NOT chdir globally.
1800
+ # Instead, we should update filenames to be absolute paths based on bundle_dir if read-only.
1801
+
1802
+ # Monkey patch the loader path just for this instance if needed,
1803
+ # but CardDataLoader takes a path arg.
1804
+ # We need to ensure 'init_game' calls it with the correct absolute path.
1805
+ pass
1806
+
1807
+ # Patched init_game for Frozen state to find data
1808
+ original_init_game = init_game
1809
+
1810
+ def frozen_init_game(deck_type="normal"):
1811
+ if getattr(sys, "frozen", False):
1812
+ bundle_dir = getattr(sys, "_MEIPASS", ".") # type: ignore
1813
+ os.path.join(bundle_dir, "data", "cards.json")
1814
+
1815
+ # We need to temporarily force the loader to use this path
1816
+ # But init_game hardcodes "data/cards.json" in correct logic?
1817
+ # actually checking init_game source:
1818
+ # loader = CardDataLoader("data/cards.json")
1819
+ # We need to change that line or intercept.
1820
+
1821
+ # Use os.chdir to temp dir for READS? No, we need writes to real dir.
1822
+ # Best way: Just ensure data/cards.json exists in CWD? No, user won't have it.
1823
+
1824
+ # HACK: We can't easily change the hardcoded string inside init_game without rewriting it.
1825
+ # However, we can patch CardDataLoader class to fix the path!
1826
+ # Assuming CardDataLoader is imported from engine.game.data_loader
1827
+ from engine.game.data_loader import CardDataLoader
1828
+
1829
+ ops_init = CardDataLoader.__init__
1830
+
1831
+ def new_init(self, filepath):
1832
+ if not os.path.exists(filepath) and getattr(sys, "frozen", False):
1833
+ # Try bundle path
1834
+ bundle_path = os.path.join(sys._MEIPASS, filepath) # type: ignore
1835
+ if os.path.exists(bundle_path):
1836
+ filepath = bundle_path
1837
+ ops_init(self, filepath)
1838
+
1839
+ CardDataLoader.__init__ = new_init # type: ignore[method-assign]
1840
+
1841
+ original_init_game(deck_type)
1842
+
1843
+ init_game = frozen_init_game
1844
+
1845
+ # Run Server
1846
+ # use_reloader=False is crucial for PyInstaller to implicit avoid spawning subprocesses incorrectly
1847
+ port = int(os.environ.get("PORT", 8000))
1848
+
1849
+ # Auto-open browser
1850
+ import webbrowser
1851
+ from threading import Timer
1852
+
1853
+ def open_browser():
1854
+ webbrowser.open_new(f"http://localhost:{port}/")
1855
+
1856
+ if not getattr(sys, "frozen", False) or os.environ.get("OPEN_BROWSER", "true").lower() == "true":
1857
+ Timer(1.5, open_browser).start()
1858
+
1859
+ # Start Background Game Loop
1860
+ if game_thread is None:
1861
+ game_thread = threading.Thread(target=background_game_loop, daemon=True)
1862
+ game_thread.start()
1863
+
1864
+ if __name__ == "__main__":
1865
+ port = int(os.environ.get("PORT", 7860))
1866
+ # In production/container, usually don't want debug mode
1867
+ debug_mode = os.environ.get("FLASK_DEBUG", "True").lower() == "true"
1868
+ app.run(host="0.0.0.0", port=port, debug=debug_mode)