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.
- ptg_web-0.1.0.dist-info/METADATA +12 -0
- ptg_web-0.1.0.dist-info/RECORD +15 -0
- ptg_web-0.1.0.dist-info/WHEEL +5 -0
- ptg_web-0.1.0.dist-info/entry_points.txt +2 -0
- ptg_web-0.1.0.dist-info/top_level.txt +1 -0
- server/__init__.py +0 -0
- server/app.py +580 -0
- server/bot.py +128 -0
- server/game_manager.py +109 -0
- server/room.py +94 -0
- server/set_loader.py +156 -0
- server/state_builder.py +155 -0
- server/static/game.js +628 -0
- server/static/style.css +1038 -0
- server/templates/index.html +401 -0
|
@@ -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 @@
|
|
|
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()
|