ptg-web 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: ptg-web
3
+ Version: 0.1.0
4
+ Author: Daniel Pérez Rodríguez
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: ptg>=0.1.15
7
+ Requires-Dist: flask
8
+ Requires-Dist: flask-socketio
9
+ Requires-Dist: eventlet
10
+ Requires-Dist: pyyaml
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest; extra == "dev"
@@ -0,0 +1,15 @@
1
+ server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ server/app.py,sha256=G3_dDdgtEEAK4jgb_qJzTeXBwmvJgjTDA9sPiViwWio,19002
3
+ server/bot.py,sha256=z5LXLJKMOxJXKDAJxl62vNiNrDGMWrzhAUT6Xuvp60o,3817
4
+ server/game_manager.py,sha256=poFp7goHZUNzbX8B7rcg9Q6GTIjd8mSF_UgZlC6rPp0,3944
5
+ server/room.py,sha256=AjgKuCwKRFVv608Z2pRcQQfO0zclmMcvvue4HUFqPGI,2927
6
+ server/set_loader.py,sha256=YH1a8r4zRN8Q-W1QWobBwy_zo2HIVt17WYW8I_hi7ZI,5419
7
+ server/state_builder.py,sha256=hGNaQ59EXqPn4mQCk2jE5jaOHpwjAGa-WA91KFkOz7E,5121
8
+ server/static/game.js,sha256=uo1R1nImqK9-yqoV0PxY8evdrTMln5zoFb5JzwSQOx4,19573
9
+ server/static/style.css,sha256=zG7KngHdYfvpcv_aKpnVyoExf7jfVgMt52jU1ukARjA,20826
10
+ server/templates/index.html,sha256=dOiUacbjHbXnyHfGHdxS57ei1prr221pE4VJyZ1BYqs,20619
11
+ ptg_web-0.1.0.dist-info/METADATA,sha256=PqrAlU0J3re8LjJ2GXiPbUN4V8RUALfYFtmmTtHFMls,290
12
+ ptg_web-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ ptg_web-0.1.0.dist-info/entry_points.txt,sha256=2Am4bXsYuICvjAHWRi2AMCdsTH8gJHrATac7o0-Mzzg,44
14
+ ptg_web-0.1.0.dist-info/top_level.txt,sha256=StKOSmRhvWS5IPcvhsDRbtxUTEofJgYFGOu5AAJdSWo,7
15
+ ptg_web-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ptg-web = server.app:main
@@ -0,0 +1 @@
1
+ server
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,580 @@
1
+ import os
2
+ import eventlet
3
+
4
+ eventlet.monkey_patch()
5
+
6
+ import logging
7
+ import sys
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from flask import Flask, render_template, request, jsonify
14
+ from flask_socketio import SocketIO, emit
15
+
16
+ from ptg.api import (
17
+ PlayCardAction, DeclareAttackAction, DeclareDefenseAction,
18
+ ActivateAbilityAction, load_cards_from_dir, build_catalog,
19
+ PhaseType,
20
+ )
21
+
22
+ import ptg.effects # noqa: F401 — ensure package is importable
23
+
24
+ import importlib
25
+ import pkgutil
26
+
27
+
28
+ def _import_all_effects():
29
+ """Dynamically import all effect modules so they self-register."""
30
+ path = ptg.effects.__path__
31
+ prefix = ptg.effects.__name__ + "."
32
+ for _, modname, is_pkg in pkgutil.iter_modules(path, prefix):
33
+ if is_pkg:
34
+ continue
35
+ if modname.endswith((".base", ".registry")):
36
+ continue
37
+ importlib.import_module(modname)
38
+
39
+
40
+ _import_all_effects()
41
+
42
+ from .game_manager import GameManager
43
+ from .bot import bot_play_main, bot_declare_attacks, bot_declare_defense
44
+ from .set_loader import SetLoader
45
+
46
+ logging.basicConfig(
47
+ level=logging.INFO,
48
+ format="%(asctime)s [%(levelname)s] %(message)s",
49
+ stream=sys.stdout,
50
+ )
51
+ logger = logging.getLogger(__name__)
52
+
53
+ # ── Config ──
54
+
55
+ CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.yaml"
56
+ _config = {}
57
+ if CONFIG_PATH.is_file():
58
+ with open(CONFIG_PATH) as f:
59
+ _config = yaml.safe_load(f) or {}
60
+
61
+ HOST = os.environ.get("PTG_HOST", _config.get("host", "0.0.0.0"))
62
+ PORT = int(os.environ.get("PTG_PORT", _config.get("port", 5000)))
63
+ SETS_DIR = Path(os.environ.get(
64
+ "PTG_SETS_DIR",
65
+ _config.get("sets_dir", str(Path(__file__).resolve().parent.parent / "sets"))
66
+ ))
67
+
68
+ # Fallback: look for sets/ alongside the server package or at project root
69
+ if not SETS_DIR.is_dir():
70
+ alt = Path(__file__).resolve().parent / "sets"
71
+ if alt.is_dir():
72
+ SETS_DIR = alt
73
+
74
+ if not SETS_DIR.is_dir() or not any(
75
+ (d / "manifest.yaml").is_file() for d in SETS_DIR.iterdir() if d.is_dir()
76
+ ):
77
+ logger.error("No sets found in %s. Create a sets/ directory with at least one set.", SETS_DIR)
78
+ sys.exit(1)
79
+
80
+ set_loader = SetLoader(SETS_DIR)
81
+ logger.info("Loaded %d set(s) from %s", len(set_loader.list_sets()), SETS_DIR)
82
+
83
+ app = Flask(__name__, template_folder="templates", static_folder="static")
84
+ app.config["SECRET_KEY"] = "ptg-secret-key"
85
+
86
+ socketio = SocketIO(app, async_mode="eventlet", cors_allowed_origins="*")
87
+
88
+ game_manager = GameManager(set_loader=set_loader)
89
+
90
+
91
+ def _card_dict(cd):
92
+ return {
93
+ "card_id": cd.card_id,
94
+ "name": cd.name,
95
+ "type": cd.type.value,
96
+ "mana": {t.value: a for t, a in cd.mana.items()},
97
+ "defense": cd.defense,
98
+ "attack": cd.attack,
99
+ "description": cd.description,
100
+ "abilities": [
101
+ {"name": a.name, "description": a.description, "ability_type": a.ability_type.value}
102
+ for a in (cd.abilities or [])
103
+ ],
104
+ }
105
+
106
+
107
+ @app.route("/")
108
+ def index():
109
+ return render_template("index.html")
110
+
111
+
112
+ @app.route("/api/sets")
113
+ def api_sets():
114
+ return jsonify(set_loader.list_sets())
115
+
116
+
117
+ @app.route("/api/sets/<set_id>/catalog")
118
+ def api_set_catalog(set_id):
119
+ try:
120
+ catalog = set_loader.get_catalog(set_id)
121
+ return jsonify([_card_dict(cd) for cd in catalog.values()])
122
+ except (ValueError, FileNotFoundError) as e:
123
+ return jsonify({"error": str(e)}), 404
124
+
125
+
126
+ @app.route("/api/sets/<set_id>/decks")
127
+ def api_set_decks(set_id):
128
+ try:
129
+ return jsonify(set_loader.list_decks(set_id))
130
+ except ValueError as e:
131
+ return jsonify({"error": str(e)}), 404
132
+
133
+
134
+ def _ensure_player_id() -> str:
135
+ sid = request.sid
136
+ for pid, rsid in list(game_manager._player_sids.items()):
137
+ if rsid == sid:
138
+ return pid
139
+ pid = str(uuid.uuid4())
140
+ game_manager._player_sids[pid] = sid
141
+ return pid
142
+
143
+
144
+ def _resolve_player(room) -> tuple[str, str] | None:
145
+ session_id = _ensure_player_id()
146
+ player_uuid = room.player_uuid_for(session_id)
147
+ if player_uuid is None:
148
+ return None
149
+ return (session_id, player_uuid)
150
+
151
+
152
+ def _broadcast_state(room):
153
+ for session_id in [sid for sid in [room.host_id, room.guest_id] if sid]:
154
+ sid = game_manager.get_sid(session_id)
155
+ if sid:
156
+ player_uuid = room.player_uuid_for(session_id)
157
+ if player_uuid is None:
158
+ continue
159
+ view = game_manager.state_view(room, player_uuid)
160
+ socketio.emit("state", view, to=sid)
161
+ _maybe_schedule_bot(room)
162
+
163
+ _log_graveyards(room)
164
+
165
+
166
+ def _log_graveyards(room):
167
+ game = room.game
168
+ if game is None or game.state is None:
169
+ return
170
+ g = game.state.graveyards
171
+ pids = list(g.keys())
172
+ if len(pids) >= 2:
173
+ logger.info(
174
+ "GRAVEYARDS: P0=%d cards, P1=%d cards (P0=%s, P1=%s)",
175
+ len(g.get(pids[0], [])), len(g.get(pids[1], [])),
176
+ pids[0][:8], pids[1][:8],
177
+ )
178
+
179
+
180
+ _SCHEDULABLE_PHASES = {
181
+ PhaseType.MAIN, PhaseType.ATTACK, PhaseType.DEFENSE,
182
+ PhaseType.MAIN_POSTCOMBAT,
183
+ }
184
+
185
+
186
+ def _bot_should_act(room) -> bool:
187
+ if not room.bot_game or room.game is None:
188
+ return False
189
+ bot_player = room.game.players[1]
190
+ active = room.game.active_player
191
+ cp = room.game.current_phase
192
+
193
+ if cp not in _SCHEDULABLE_PHASES:
194
+ return False
195
+
196
+ if cp in (PhaseType.MAIN, PhaseType.ATTACK, PhaseType.MAIN_POSTCOMBAT):
197
+ return bot_player == active
198
+ elif cp == PhaseType.DEFENSE:
199
+ return bot_player != active
200
+ return False
201
+
202
+
203
+ def _maybe_schedule_bot(room):
204
+ if room._bot_acting:
205
+ return
206
+ if not _bot_should_act(room):
207
+ return
208
+
209
+ room._bot_acting = True
210
+ socketio.start_background_task(_run_bot_turn, room)
211
+
212
+
213
+ def _auto_resolve_bot_triggers(room):
214
+ """Auto-resolve any pending triggers the bot triggered."""
215
+ game = room.game
216
+ if game is None or game.state is None:
217
+ return
218
+ triggers = game.state.pending_triggers
219
+ if not triggers:
220
+ return
221
+ for i in reversed(range(len(triggers))):
222
+ t = triggers[i]
223
+ if t.source_player_id != room.bot_id:
224
+ continue
225
+ from ptg.effects.base import PlayerEffect
226
+ if isinstance(t.effect, PlayerEffect):
227
+ target = room.game.opponent.uuid
228
+ else:
229
+ opp_bf = game.state.battlefield.get(room.game.opponent.uuid, [])
230
+ target = next((cid for cid in opp_bf if game.state.all_cards[cid].is_alive), None)
231
+ if target is None:
232
+ target = next((cid for cid in game.state.battlefield.get(room.bot_id, [])
233
+ if cid != t.source_card_id and game.state.all_cards[cid].is_alive), None)
234
+ if target is not None:
235
+ try:
236
+ game.resolve_pending_trigger(i, target)
237
+ except Exception as e:
238
+ logger.debug("Bot trigger resolution error: %s", e)
239
+
240
+
241
+ def _run_bot_turn(room):
242
+ try:
243
+ import eventlet as ev
244
+
245
+ game = room.game
246
+ bot_id = room.bot_id
247
+ cp = game.current_phase
248
+
249
+ if cp in (PhaseType.MAIN, PhaseType.MAIN_POSTCOMBAT):
250
+ ev.sleep(1.0)
251
+ card_ids = bot_play_main(game.state, bot_id)
252
+ for cid in card_ids:
253
+ try:
254
+ game.apply_action(PlayCardAction(), player_id=bot_id, card_id=cid)
255
+ logger.info("Bot played card %s", cid)
256
+ _auto_resolve_bot_triggers(room)
257
+ _broadcast_state(room)
258
+ ev.sleep(0.7)
259
+ except Exception as e:
260
+ logger.debug("Bot play_card failed: %s", e)
261
+
262
+ if game.current_phase == PhaseType.ATTACK:
263
+ ev.sleep(0.8)
264
+ attackers = bot_declare_attacks(game.state, bot_id)
265
+ for aid in attackers:
266
+ try:
267
+ game.apply_action(DeclareAttackAction(), player_id=bot_id, attacker_id=aid)
268
+ logger.info("Bot declared attack: %s", aid)
269
+ _broadcast_state(room)
270
+ ev.sleep(0.5)
271
+ except Exception as e:
272
+ logger.debug("Bot declare_attack failed: %s", e)
273
+
274
+ if game.current_phase == PhaseType.DEFENSE:
275
+ ev.sleep(0.8)
276
+ assignments = bot_declare_defense(game.state, bot_id)
277
+ for blocker_id, combat_idx in assignments:
278
+ try:
279
+ game.apply_action(DeclareDefenseAction(), player_id=bot_id,
280
+ blocker_id=blocker_id, combat_index=combat_idx)
281
+ logger.info("Bot declared defense: %s -> idx %d", blocker_id, combat_idx)
282
+ _broadcast_state(room)
283
+ ev.sleep(0.5)
284
+ except Exception as e:
285
+ logger.debug("Bot declare_defense failed: %s", e)
286
+
287
+ room._bot_acting = False
288
+ game.advance_phase()
289
+ if game.current_phase == PhaseType.DRAW:
290
+ game.advance_phase()
291
+
292
+ winner = game.check_victory()
293
+ if winner:
294
+ _broadcast_state(room)
295
+ return
296
+
297
+ _broadcast_state(room)
298
+
299
+ except Exception as e:
300
+ logger.error("Bot turn error: %s", e)
301
+ room._bot_acting = False
302
+
303
+
304
+ @socketio.on("connect")
305
+ def handle_connect():
306
+ pid = _ensure_player_id()
307
+ logger.info("Client connected: sid=%s pid=%s", request.sid, pid)
308
+ emit("connected", {"player_id": pid})
309
+
310
+
311
+ @socketio.on("disconnect")
312
+ def handle_disconnect():
313
+ sid = request.sid
314
+ pid = None
315
+ for p, rsid in list(game_manager._player_sids.items()):
316
+ if rsid == sid:
317
+ pid = p
318
+ break
319
+ if pid is None:
320
+ return
321
+ logger.info("Client disconnected: sid=%s pid=%s", sid, pid)
322
+ room = game_manager.remove_player(pid)
323
+ if room and room.guest_id:
324
+ opp_sid = game_manager.get_sid(room.guest_id)
325
+ if opp_sid:
326
+ socketio.emit("opponent_left", {}, to=opp_sid)
327
+
328
+
329
+ @socketio.on("create_game")
330
+ def handle_create_game(data=None):
331
+ pid = _ensure_player_id()
332
+ set_id = (data or {}).get("set_id", "default")
333
+ code = game_manager.create_room(pid, set_id=set_id)
334
+ emit("room_created", {"room": code})
335
+ logger.info("Room created: %s by %s (set=%s)", code, pid, set_id)
336
+
337
+
338
+ @socketio.on("create_bot_game")
339
+ def handle_create_bot_game(data=None):
340
+ pid = _ensure_player_id()
341
+ set_id = (data or {}).get("set_id", "default")
342
+ code = game_manager.create_bot_game(pid, set_id=set_id)
343
+ emit("game_start", {
344
+ "opponent_name": "Bot",
345
+ "phase": "deck_select",
346
+ "set_id": set_id,
347
+ })
348
+ logger.info("Bot game created: %s by %s (set=%s)", code, pid, set_id)
349
+
350
+
351
+ @socketio.on("join_game")
352
+ def handle_join_game(data):
353
+ pid = _ensure_player_id()
354
+ code = data.get("room", "").upper()
355
+ room = game_manager.join_room(code, pid)
356
+ if room is None:
357
+ emit("error", {"message": "Room not found or full."})
358
+ return
359
+
360
+ host_sid = game_manager.get_sid(room.host_id)
361
+ if host_sid:
362
+ socketio.emit("game_start", {
363
+ "opponent_name": "Player 2",
364
+ "phase": "deck_select",
365
+ "set_id": room.set_id,
366
+ }, to=host_sid)
367
+
368
+ emit("game_start", {
369
+ "opponent_name": "Player 1",
370
+ "phase": "deck_select",
371
+ "set_id": room.set_id,
372
+ })
373
+ logger.info("Game started in room %s", code)
374
+
375
+
376
+ @socketio.on("select_deck")
377
+ def handle_select_deck(data):
378
+ session_id = _ensure_player_id()
379
+ room = game_manager.get_room_for_player(session_id)
380
+ if room is None:
381
+ emit("error", {"message": "Not in a room."})
382
+ return
383
+
384
+ deck_name = data.get("deck_name")
385
+ cards = data.get("cards", {})
386
+ selected_set = data.get("set_id")
387
+ if selected_set:
388
+ room.set_id = selected_set
389
+
390
+ if deck_name:
391
+ try:
392
+ catalog = set_loader.get_catalog(room.set_id)
393
+ spec = set_loader.get_deck_spec(room.set_id, deck_name)
394
+ except (ValueError, FileNotFoundError) as e:
395
+ emit("error", {"message": str(e)})
396
+ return
397
+ else:
398
+ spec = cards
399
+
400
+ ok = game_manager.set_deck(session_id, spec)
401
+ if not ok:
402
+ emit("error", {"message": "Failed to set deck."})
403
+ return
404
+
405
+ total = sum(spec.values())
406
+ logger.info("Player %s selected deck: %d cards%s",
407
+ session_id, total, f" (pre-built: {deck_name})" if deck_name else "")
408
+
409
+ if room.both_decks_ready():
410
+ room.start_game()
411
+ room.game.advance_phase()
412
+ _broadcast_state(room)
413
+ logger.info("Game started in room %s", room.code)
414
+
415
+
416
+ @socketio.on("action")
417
+ def handle_action(data):
418
+ session_id = _ensure_player_id()
419
+ room = game_manager.get_room_for_player(session_id)
420
+ if room is None or room.game is None:
421
+ emit("error", {"message": "Not in an active game."})
422
+ return
423
+
424
+ resolved = _resolve_player(room)
425
+ if resolved is None:
426
+ emit("error", {"message": "Not in a game."})
427
+ return
428
+ _, player_uuid = resolved
429
+
430
+ action_type = data.get("action")
431
+ game = room.game
432
+ cp = game.current_phase
433
+
434
+ logger.info("Action: %s by %s in phase %s", action_type, player_uuid, cp.value if cp else "none")
435
+
436
+ try:
437
+ if action_type == "next_phase":
438
+ if cp not in (PhaseType.MAIN, PhaseType.MAIN_POSTCOMBAT,
439
+ PhaseType.ATTACK, PhaseType.DEFENSE, PhaseType.COMBAT):
440
+ emit("error", {"message": f"Cannot advance from {cp.value}."})
441
+ return
442
+ if cp in (PhaseType.DEFENSE, PhaseType.COMBAT):
443
+ if player_uuid not in [p.uuid for p in game.players]:
444
+ emit("error", {"message": "Not in this game."})
445
+ return
446
+ else:
447
+ if player_uuid != game.active_player.uuid:
448
+ emit("error", {"message": "Not your turn."})
449
+ return
450
+
451
+ if cp == PhaseType.ATTACK:
452
+ attacker_ids = data.get("attacker_ids", [])
453
+ for aid in attacker_ids:
454
+ try:
455
+ game.apply_action(DeclareAttackAction(),
456
+ player_id=player_uuid, attacker_id=aid)
457
+ except ValueError:
458
+ pass
459
+
460
+ if cp == PhaseType.DEFENSE:
461
+ blocker_ids = data.get("blocker_ids", [])
462
+ for i, bid in enumerate(blocker_ids):
463
+ pending = game.state.pending_combats
464
+ if not pending:
465
+ break
466
+ combat_idx = i % len(pending)
467
+ try:
468
+ game.apply_action(DeclareDefenseAction(),
469
+ player_id=player_uuid,
470
+ blocker_id=bid, combat_index=combat_idx)
471
+ except ValueError:
472
+ pass
473
+
474
+ game.advance_phase()
475
+ if game.current_phase == PhaseType.DRAW:
476
+ game.advance_phase()
477
+
478
+ if game.check_victory():
479
+ _broadcast_state(room)
480
+ return
481
+
482
+ _broadcast_state(room)
483
+ return
484
+
485
+ elif action_type == "play_card":
486
+ if not _can_play(cp, player_uuid, game):
487
+ emit("error", {"message": "Cannot play cards right now."})
488
+ return
489
+ card_id = data.get("card_id")
490
+ action_obj = PlayCardAction()
491
+ game.apply_action(action_obj, player_id=player_uuid, card_id=card_id)
492
+ logger.info("Played card %s by %s", card_id, player_uuid)
493
+
494
+ _broadcast_state(room)
495
+ return
496
+
497
+ elif action_type == "declare_attack":
498
+ if cp != PhaseType.ATTACK:
499
+ emit("error", {"message": "Can only attack during attack phase."})
500
+ return
501
+ if player_uuid != game.active_player.uuid:
502
+ emit("error", {"message": "Not your turn."})
503
+ return
504
+ attacker_id = data.get("attacker_id")
505
+ action_obj = DeclareAttackAction()
506
+ game.apply_action(action_obj, player_id=player_uuid, attacker_id=attacker_id)
507
+ logger.info("Declared attack: %s by %s", attacker_id, player_uuid)
508
+ _broadcast_state(room)
509
+ return
510
+
511
+ elif action_type == "declare_defense":
512
+ if cp != PhaseType.DEFENSE:
513
+ emit("error", {"message": "Can only defend during defense phase."})
514
+ return
515
+ if player_uuid == game.active_player.uuid:
516
+ emit("error", {"message": "Only the defender can declare defense."})
517
+ return
518
+ blocker_id = data.get("blocker_id")
519
+ combat_index = data.get("combat_index", 0)
520
+ action_obj = DeclareDefenseAction()
521
+ game.apply_action(action_obj, player_id=player_uuid,
522
+ blocker_id=blocker_id, combat_index=combat_index)
523
+ logger.info("Declared defense: %s blocking combat %d by %s",
524
+ blocker_id, combat_index, player_uuid)
525
+ _broadcast_state(room)
526
+ return
527
+
528
+ elif action_type == "activate_ability":
529
+ if not _can_play(cp, player_uuid, game):
530
+ emit("error", {"message": "Cannot activate abilities right now."})
531
+ return
532
+ card_id = data.get("card_id")
533
+ ability_index = data.get("ability_index", 0)
534
+ target_id = data.get("target_id")
535
+ action_obj = ActivateAbilityAction()
536
+ kwargs = dict(player_id=player_uuid, card_id=card_id, ability_index=ability_index)
537
+ if target_id:
538
+ kwargs["target_id"] = target_id
539
+ game.apply_action(action_obj, **kwargs)
540
+ logger.info("Activated ability %d on %s by %s", ability_index, card_id, player_uuid)
541
+
542
+ _broadcast_state(room)
543
+ return
544
+
545
+ elif action_type == "resolve_trigger":
546
+ trigger_index = data.get("trigger_index", 0)
547
+ target_id = data.get("target_id")
548
+ if not target_id:
549
+ emit("error", {"message": "No target selected."})
550
+ return
551
+ try:
552
+ game.resolve_pending_trigger(trigger_index, target_id)
553
+ except (IndexError, ValueError) as e:
554
+ emit("error", {"message": str(e)})
555
+ return
556
+ _broadcast_state(room)
557
+ return
558
+
559
+ else:
560
+ emit("error", {"message": f"Unknown action: {action_type}"})
561
+ return
562
+
563
+ except Exception as e:
564
+ logger.warning("Action error: %s", e)
565
+ emit("error", {"message": str(e)})
566
+
567
+
568
+ def _can_play(cp: PhaseType, player_uuid: str, game) -> bool:
569
+ if cp not in (PhaseType.MAIN, PhaseType.MAIN_POSTCOMBAT):
570
+ return False
571
+ return player_uuid == game.active_player.uuid
572
+
573
+
574
+ def main():
575
+ import eventlet.wsgi
576
+ eventlet.wsgi.server(eventlet.listen((HOST, PORT)), app)
577
+
578
+
579
+ if __name__ == "__main__":
580
+ main()