Files
web-adventure/backend.py
T
2026-05-29 14:29:55 +02:00

779 lines
29 KiB
Python

import os
import json
import asyncio
from datetime import datetime, timedelta
from typing import Dict, List, Set
from dataclasses import dataclass, field
import random
import hashlib
from fastapi import FastAPI, WebSocket, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
# Configuration
DEBUG_MODE = os.getenv("DEBUG", "0").strip().lower() in {"1", "true", "yes", "on"}
GAME_DURATION = 30 * 60 # 30 minutes in seconds
GRID_SIZE = 500 # Grid size in tiles
PLAYER_START_SPAWN_RANGE = 50
BOSS_SPAWN_DISTANCE = 150
ACTION_POINTS_MAX = 20
# In debug mode AP regenerates much faster for rapid testing.
ACTION_POINTS_REGEN_INTERVAL = float(os.getenv("ACTION_REGEN_INTERVAL", "1" if DEBUG_MODE else "60"))
MONSTER_SPAWN_RATE = 0.1 # Probability per game tick
MAX_MONSTERS = 50
BOSS_HEALTH_BASE = 1000
MIN_LEVEL_FOR_BOSS = 10
BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1
RESOURCE_NODE_COUNT = 180
CHEST_SPAWN_RATE = 0.015
MAX_CHESTS = 15
CHEST_LOOT_RANGE = 3
ATTACK_RANGE_BASE = 4
WEAPON_SLOT_COUNT = 5
BLOCK_BUILD_COST_WOOD = 2
BLOCK_HEALTH = 5
# Data Models
@dataclass
class Position:
x: float
y: float
z: float = 0
def to_dict(self):
return {"x": self.x, "y": self.y, "z": self.z}
@classmethod
def from_dict(cls, d):
return cls(x=d["x"], y=d["y"], z=d.get("z", 0))
@dataclass
class Player:
id: str
username: str
color: str
level: int = 1
exp: int = 0
position: Position = field(default_factory=lambda: Position(0, 0, 0))
health: int = 100
max_health: int = 100
action_points: int = ACTION_POINTS_MAX
max_action_points: int = ACTION_POINTS_MAX
last_action_regen: float = 0
inventory: Dict[str, int] = field(default_factory=lambda: {"wood": 0, "stone": 0})
attack: int = 5
defense: int = 2
movement_capacity: int = 1
gathering_capacity: int = 10
attack_range: float = ATTACK_RANGE_BASE
weapon_slots: List[Dict] = field(default_factory=lambda: [None] * WEAPON_SLOT_COUNT)
equipped_weapon_slot: int = -1
active: bool = True
session_id: str = ""
def to_dict(self):
equipped_weapon = None
if 0 <= self.equipped_weapon_slot < len(self.weapon_slots):
equipped_weapon = self.weapon_slots[self.equipped_weapon_slot]
attack_bonus = equipped_weapon.get("attack_bonus", 0) if equipped_weapon else 0
range_bonus = equipped_weapon.get("range_bonus", 0) if equipped_weapon else 0
return {
"id": self.id,
"username": self.username,
"color": self.color,
"level": self.level,
"exp": self.exp,
"position": self.position.to_dict(),
"health": self.health,
"max_health": self.max_health,
"action_points": self.action_points,
"max_action_points": self.max_action_points,
"inventory": self.inventory,
"attack": self.attack,
"effective_attack": self.attack + attack_bonus,
"attack_range": self.attack_range + range_bonus,
"defense": self.defense,
"movement_capacity": self.movement_capacity,
"gathering_capacity": self.gathering_capacity,
"weapon_slots": self.weapon_slots,
"equipped_weapon_slot": self.equipped_weapon_slot,
"active": self.active,
}
@dataclass
class Monster:
id: str
position: Position
health: int
max_health: int
level: int
attack: int
defense: int = 1
is_boss: bool = False
boss_progress: float = 0.0 # Percentage of damage done
def to_dict(self):
return {
"id": self.id,
"position": self.position.to_dict(),
"health": self.health,
"max_health": self.max_health,
"level": self.level,
"attack": self.attack,
"defense": self.defense,
"is_boss": self.is_boss,
"boss_progress": self.boss_progress,
}
@dataclass
class Chest:
id: str
position: Position
opened: bool = False
rarity: str = "rare"
weapon: Dict = field(default_factory=dict)
def to_dict(self):
return {
"id": self.id,
"position": self.position.to_dict(),
"opened": self.opened,
"rarity": self.rarity,
"weapon": self.weapon,
}
@dataclass
class Structure:
id: str
position: Position
structure_type: str # "house", "farm", "guard_tower"
owner_id: str
health: int
bonuses: Dict = field(default_factory=dict)
def to_dict(self):
return {
"id": self.id,
"position": self.position.to_dict(),
"structure_type": self.structure_type,
"owner_id": self.owner_id,
"health": self.health,
"bonuses": self.bonuses,
}
@dataclass
class Resource:
id: str
position: Position
resource_type: str # "tree", "mountain"
amount: int
def to_dict(self):
return {
"id": self.id,
"position": self.position.to_dict(),
"resource_type": self.resource_type,
"amount": self.amount,
}
class GameWorld:
def __init__(self):
self.players: Dict[str, Player] = {}
self.monsters: Dict[str, Monster] = {}
self.structures: Dict[str, Structure] = {}
self.resources: Dict[str, Resource] = {}
self.chests: Dict[str, Chest] = {}
self.boss: Monster = None
self.game_start_time: float = 0
self.is_game_active: bool = False
self.connected_sessions: Dict[str, str] = {} # session_id -> player_id
self.generation = 0
self._init_resources()
self._init_chests()
def _init_resources(self):
"""Initialize static resources on the map"""
for _ in range(RESOURCE_NODE_COUNT):
resource_id = f"resource_{len(self.resources)}"
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
resource_type = random.choice(["tree", "mountain"])
self.resources[resource_id] = Resource(
id=resource_id,
position=Position(x, y, 0),
resource_type=resource_type,
amount=random.randint(50, 200),
)
def _init_chests(self):
"""Spawn rare chests with weapon loot."""
for _ in range(8):
self._spawn_chest()
def _spawn_chest(self):
chest_id = f"chest_{len(self.chests)}_{self.generation}_{random.randint(100, 999)}"
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
weapon_templates = [
{"name": "Long Spear", "attack_bonus": 2, "range_bonus": 2},
{"name": "Hunting Bow", "attack_bonus": 1, "range_bonus": 3},
{"name": "Battle Axe", "attack_bonus": 3, "range_bonus": 1},
]
self.chests[chest_id] = Chest(
id=chest_id,
position=Position(x, y, 0),
weapon=random.choice(weapon_templates),
)
def start_game(self):
"""Start or restart the game"""
self.game_start_time = datetime.now().timestamp()
self.is_game_active = True
self.boss = None
self.monsters.clear()
self.chests.clear()
self.resources.clear()
self.structures.clear()
self._init_resources()
self._init_chests()
self.generation += 1
# Keep players but reset their state
for player in self.players.values():
if not player.active:
continue
player.level = 1
player.exp = 0
player.health = player.max_health
player.action_points = ACTION_POINTS_MAX
player.inventory = {"wood": 0, "stone": 0}
player.weapon_slots = [None] * WEAPON_SLOT_COUNT
player.equipped_weapon_slot = -1
player.attack_range = ATTACK_RANGE_BASE
player.position = Position(
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
0,
)
def get_elapsed_time(self) -> float:
if not self.is_game_active:
return 0
return datetime.now().timestamp() - self.game_start_time
def check_game_over(self) -> bool:
if not self.is_game_active:
return False
elapsed = self.get_elapsed_time()
if elapsed >= GAME_DURATION:
self.is_game_active = False
return True
return False
def update_tick(self):
"""Called periodically to update game state"""
current_time = datetime.now().timestamp()
# Spawn boss if conditions are met
if self.boss is None and len(self.players) > 0:
active_players = [p for p in self.players.values() if p.active]
if active_players and any(p.level >= MIN_LEVEL_FOR_BOSS for p in active_players):
self._spawn_boss()
# Spawn monsters
if random.random() < MONSTER_SPAWN_RATE and len(self.monsters) < MAX_MONSTERS:
self._spawn_monster()
# Spawn rare chests over time
if random.random() < CHEST_SPAWN_RATE and len(self.chests) < MAX_CHESTS:
self._spawn_chest()
# Regenerate player action points
for player in self.players.values():
if not player.active:
continue
if current_time - player.last_action_regen >= ACTION_POINTS_REGEN_INTERVAL:
regen_amount = 1
nearby_bonuses = self._get_nearby_structure_bonuses(player.position)
if "action_regen" in nearby_bonuses:
regen_amount = nearby_bonuses["action_regen"]
player.action_points = min(
player.max_action_points,
player.action_points + regen_amount,
)
player.last_action_regen = current_time
def _spawn_monster(self):
"""Spawn a random monster on the map"""
monster_id = f"monster_{len(self.monsters)}_{self.generation}"
level = random.randint(1, 5)
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
health = 20 + level * 10
self.monsters[monster_id] = Monster(
id=monster_id,
position=Position(x, y, 0),
health=health,
max_health=health,
level=level,
attack=3 + level,
defense=1 + level // 2,
is_boss=False,
)
def _spawn_boss(self):
"""Spawn the boss monster"""
boss_id = f"boss_{self.generation}"
active_players = [p for p in self.players.values() if p.active]
avg_level = sum(p.level for p in active_players) / len(active_players)
health = int(BOSS_HEALTH_BASE + avg_level * 500)
# Spawn boss at a distance from players
if active_players:
player_pos = active_players[0].position
angle = random.random() * 2 * 3.14159
x = player_pos.x + BOSS_SPAWN_DISTANCE * (angle ** 0.5) * (1 if random.random() > 0.5 else -1)
y = player_pos.y + BOSS_SPAWN_DISTANCE * (1 - angle ** 0.5) * (1 if random.random() > 0.5 else -1)
else:
x = y = 0
self.boss = Monster(
id=boss_id,
position=Position(x, y, 0),
health=health,
max_health=health,
level=int(avg_level) + 5,
attack=15 + int(avg_level),
defense=4 + int(avg_level // 2),
is_boss=True,
)
def _player_effective_attack(player: Player) -> int:
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
w = player.weapon_slots[player.equipped_weapon_slot]
if w:
return player.attack + int(w.get("attack_bonus", 0))
return player.attack
def _player_effective_range(player: Player) -> float:
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
w = player.weapon_slots[player.equipped_weapon_slot]
if w:
return player.attack_range + float(w.get("range_bonus", 0))
return player.attack_range
def _get_nearby_structure_bonuses_impl(self, position: Position, radius: int = 10) -> Dict:
bonuses = {}
for structure in self.structures.values():
dist = ((structure.position.x - position.x) ** 2 + (structure.position.y - position.y) ** 2) ** 0.5
if dist <= radius:
for key, value in structure.bonuses.items():
bonuses[key] = bonuses.get(key, 0) + value
return bonuses
GameWorld._get_nearby_structure_bonuses = _get_nearby_structure_bonuses_impl
# Use plain dicts instead of Pydantic models
# Global game state
game_world = GameWorld()
print("DEBUG: GameWorld initialized")
app = FastAPI()
print("DEBUG: FastAPI app created")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ==================== API ENDPOINTS ====================
@app.post("/api/login")
async def login(request: Request):
"""Login user and create/get player"""
print("===== LOGIN START =====", flush=True)
try:
print("[1] Parsing JSON...", flush=True)
body = await request.json()
print(f"[2] Got body: {body}", flush=True)
except Exception as e:
print(f"[X] Failed to parse JSON: {e}", flush=True)
raise HTTPException(status_code=400, detail="Invalid JSON")
print("[3] Extracting fields...", flush=True)
username = body.get("username", "").strip()
color = body.get("color", "")
print(f"[4] Got username={username}, color={color}", flush=True)
if len(username) < 1 or len(username) > 30:
print("[5a] Invalid username length", flush=True)
raise HTTPException(status_code=400, detail="Invalid username length")
print("[5b] Generating player ID...", flush=True)
player_id = hashlib.md5(f"{username}_{game_world.generation}".encode()).hexdigest()[:12]
print(f"[6] Player_id={player_id}", flush=True)
print("[7] Checking if player exists...", flush=True)
if player_id not in game_world.players:
print("[8] Creating new player...", flush=True)
player = Player(
id=player_id,
username=username,
color=color,
position=Position(
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
0,
),
)
print("[9] Adding player to world...", flush=True)
game_world.players[player_id] = player
print("[10] Player added", flush=True)
print("[11] Getting player from world...", flush=True)
player = game_world.players[player_id]
player.active = True
print("[12] Creating session...", flush=True)
session_id = hashlib.md5(f"{player_id}_{datetime.now().timestamp()}".encode()).hexdigest()[:16]
player.session_id = session_id
game_world.connected_sessions[session_id] = player_id
print("[13] Session created", flush=True)
print("[14] Converting player to dict...", flush=True)
player_dict = player.to_dict()
print(f"[15] Player dict has {len(player_dict)} keys", flush=True)
print("[16] Building response...", flush=True)
response = {
"player_id": player_id,
"session_id": session_id,
"player": player_dict,
"game_active": game_world.is_game_active,
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
}
print("[17] Response built, returning...", flush=True)
return response
@app.get("/api/game/state")
async def get_game_state():
"""Get full game state"""
return {
"grid_size": GRID_SIZE,
"players": {pid: p.to_dict() for pid, p in game_world.players.items()},
"monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()},
"boss": game_world.boss.to_dict() if game_world.boss else None,
"structures": {sid: s.to_dict() for sid, s in game_world.structures.items()},
"resources": {rid: r.to_dict() for rid, r in game_world.resources.items()},
"chests": {cid: c.to_dict() for cid, c in game_world.chests.items()},
"game_active": game_world.is_game_active,
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
}
@app.post("/api/player/{player_id}/move")
async def move_player(player_id: str, request: Request):
"""Move player"""
if player_id not in game_world.players:
raise HTTPException(status_code=404, detail="Player not found")
body = await request.json()
dx = body.get("dx", 0)
dy = body.get("dy", 0)
player = game_world.players[player_id]
if not player.active:
raise HTTPException(status_code=400, detail="Player not active")
if player.action_points < 1:
raise HTTPException(status_code=400, detail="Insufficient action points")
# Calculate distance
distance = (dx ** 2 + dy ** 2) ** 0.5
if distance > player.movement_capacity:
raise HTTPException(status_code=400, detail="Movement exceeds capacity")
next_x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x + dx))
next_y = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.y + dy))
# Blocks are impassable.
for s in game_world.structures.values():
if s.structure_type != "block":
continue
bdist = ((s.position.x - next_x) ** 2 + (s.position.y - next_y) ** 2) ** 0.5
if bdist < 0.6:
raise HTTPException(status_code=400, detail="Blocked by structure")
player.position.x = next_x
player.position.y = next_y
player.action_points -= 1
return {"position": player.position.to_dict(), "action_points": player.action_points}
@app.post("/api/player/{player_id}/action")
async def player_action(player_id: str, request: Request):
"""Player performs an action"""
if player_id not in game_world.players:
raise HTTPException(status_code=404, detail="Player not found")
body = await request.json()
player = game_world.players[player_id]
if player.action_points < 1:
raise HTTPException(status_code=400, detail="Insufficient action points")
action_type = body.get("action_type")
if action_type == "attack":
result = _handle_attack(player, body.get("target_id"))
elif action_type == "gather":
result = _handle_gather(player, body.get("target_id"))
elif action_type == "build":
result = _handle_build(player, body.get("tx"), body.get("ty"), body.get("structure_type"))
elif action_type == "loot":
result = _handle_loot(player)
elif action_type == "equip":
result = _handle_equip(player, body.get("slot_index"))
else:
return {"action": action_type, "success": False, "reason": "Unknown action"}
# Only consume AP if the action actually succeeded
if result.get("success"):
player.action_points -= 1
return result
def _handle_attack(player: Player, target_id: str) -> Dict:
"""Handle player attack"""
attack_range = _player_effective_range(player)
attack_power = _player_effective_attack(player)
if target_id in game_world.monsters:
monster = game_world.monsters[target_id]
dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5
if dist > attack_range:
return {"success": False, "reason": "Target too far"}
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
monster.health -= damage
if monster.health <= 0:
del game_world.monsters[target_id]
player.exp += monster.level * 10
player.level = 1 + int(player.exp / 100)
return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True}
return {"success": True, "damage": damage, "monster_health_remaining": monster.health}
elif game_world.boss and target_id == game_world.boss.id:
monster = game_world.boss
dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5
if dist > attack_range:
return {"success": False, "reason": "Boss too far"}
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
monster.health -= damage
old_progress = monster.boss_progress
monster.boss_progress = (monster.max_health - monster.health) / monster.max_health * 100
if monster.health <= 0:
# Victory!
for p in game_world.players.values():
if p.active:
p.level += 5
p.exp += 500
game_world.boss = None
return {"success": True, "damage": damage, "boss_killed": True}
return {"success": True, "damage": damage, "boss_health_remaining": monster.health}
if target_id in game_world.structures:
structure = game_world.structures[target_id]
if structure.structure_type != "block":
return {"success": False, "reason": "Only block structures are destructible"}
dist = ((structure.position.x - player.position.x) ** 2 + (structure.position.y - player.position.y) ** 2) ** 0.5
if dist > attack_range:
return {"success": False, "reason": "Block too far"}
structure.health -= 1
if structure.health <= 0:
del game_world.structures[target_id]
return {"success": True, "damage": 1, "block_destroyed": True}
return {"success": True, "damage": 1, "block_health_remaining": structure.health}
return {"success": False, "reason": "Target not found"}
def _handle_gather(player: Player, target_id: str) -> Dict:
"""Gather from all resources within GATHER_RADIUS of the player (target_id is ignored)."""
GATHER_RADIUS = 5
gathered_total = {"wood": 0, "stone": 0}
depleted = []
remaining_capacity = player.gathering_capacity
for rid, resource in list(game_world.resources.items()):
if remaining_capacity <= 0:
break
dist = ((resource.position.x - player.position.x) ** 2 +
(resource.position.y - player.position.y) ** 2) ** 0.5
if dist <= GATHER_RADIUS:
amount = min(remaining_capacity, resource.amount)
resource.amount -= amount
gathered_total[resource.resource_type] = gathered_total.get(resource.resource_type, 0) + amount
remaining_capacity -= amount
if resource.amount <= 0:
depleted.append(rid)
for rid in depleted:
del game_world.resources[rid]
total_gathered = sum(gathered_total.values())
if total_gathered == 0:
return {"success": False, "reason": "No resources within reach (radius 5)"}
for rtype, amt in gathered_total.items():
player.inventory[rtype] = player.inventory.get(rtype, 0) + amt
return {"success": True, "gathered": gathered_total, "inventory": player.inventory}
def _handle_build(player: Player, tx: float, ty: float, structure_type: str) -> Dict:
"""Handle structure building"""
if structure_type not in ["house", "farm", "guard_tower", "block"]:
return {"success": False, "reason": "Invalid structure type"}
costs = {
"house": {"wood": 20, "stone": 10},
"farm": {"wood": 15, "stone": 5},
"guard_tower": {"wood": 30, "stone": 20},
"block": {"wood": BLOCK_BUILD_COST_WOOD, "stone": 0},
}
cost = costs[structure_type]
for material, amount in cost.items():
if player.inventory.get(material, 0) < amount:
return {"success": False, "reason": f"Insufficient {material}"}
# Deduct cost
for material, amount in cost.items():
player.inventory[material] -= amount
# Create structure (block is placed 1 tile north of player)
structure_id = f"struct_{len(game_world.structures)}"
bonuses = {}
px, py = tx, ty
if structure_type == "block":
px, py = player.position.x, player.position.y - 1
# Cannot place on top of players or existing structures.
for p in game_world.players.values():
if ((p.position.x - px) ** 2 + (p.position.y - py) ** 2) ** 0.5 < 0.6:
return {"success": False, "reason": "Cannot place block on player"}
for s in game_world.structures.values():
if ((s.position.x - px) ** 2 + (s.position.y - py) ** 2) ** 0.5 < 0.6:
return {"success": False, "reason": "Tile already occupied"}
if structure_type == "farm":
bonuses["action_regen"] = 2
elif structure_type == "guard_tower":
bonuses["defense"] = 2
elif structure_type == "house":
bonuses["max_health"] = 20
game_world.structures[structure_id] = Structure(
id=structure_id,
position=Position(px, py, 0),
structure_type=structure_type,
owner_id=player.id,
health=BLOCK_HEALTH if structure_type == "block" else 100,
bonuses=bonuses,
)
return {"success": True, "structure_id": structure_id, "inventory": player.inventory}
def _handle_loot(player: Player) -> Dict:
nearby = []
for chest in game_world.chests.values():
if chest.opened:
continue
dist = ((chest.position.x - player.position.x) ** 2 + (chest.position.y - player.position.y) ** 2) ** 0.5
if dist <= CHEST_LOOT_RANGE:
nearby.append((dist, chest))
if not nearby:
return {"success": False, "reason": "No chest nearby"}
nearby.sort(key=lambda x: x[0])
chest = nearby[0][1]
empty_slot = next((i for i, item in enumerate(player.weapon_slots) if item is None), None)
if empty_slot is None:
return {"success": False, "reason": "Weapon slots full"}
player.weapon_slots[empty_slot] = chest.weapon
chest.opened = True
return {"success": True, "weapon": chest.weapon, "slot_index": empty_slot, "weapon_slots": player.weapon_slots}
def _handle_equip(player: Player, slot_index) -> Dict:
if slot_index is None:
return {"success": False, "reason": "slot_index is required"}
if not isinstance(slot_index, int) or slot_index < 0 or slot_index >= len(player.weapon_slots):
return {"success": False, "reason": "Invalid slot index"}
if player.weapon_slots[slot_index] is None:
return {"success": False, "reason": "No weapon in that slot"}
player.equipped_weapon_slot = slot_index
return {"success": True, "equipped_weapon_slot": slot_index, "weapon": player.weapon_slots[slot_index]}
@app.websocket("/ws/{player_id}")
async def websocket_endpoint(websocket: WebSocket, player_id: str):
"""WebSocket for real-time game updates"""
if player_id not in game_world.players:
await websocket.close(code=4004, reason="Player not found")
return
player = game_world.players[player_id]
# Capture the session at the time this WS connection is established so that
# if the player re-logs (new session) while this socket is still open, the
# finally block doesn't incorrectly mark the re-logged player as inactive.
ws_session_id = player.session_id
await websocket.accept()
try:
while True:
# Send game state updates every 100ms
await asyncio.sleep(0.1)
game_world.update_tick()
if game_world.check_game_over():
await websocket.send_json({"type": "game_over", "time_remaining": 0})
break
state = {
"type": "state_update",
"players": {pid: p.to_dict() for pid, p in game_world.players.items()},
"monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()},
"boss": game_world.boss.to_dict() if game_world.boss else None,
"structures": {sid: s.to_dict() for sid, s in game_world.structures.items()},
"resources": {rid: r.to_dict() for rid, r in game_world.resources.items()},
"chests": {cid: c.to_dict() for cid, c in game_world.chests.items()},
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
"elapsed_time": game_world.get_elapsed_time(),
}
await websocket.send_json(state)
except Exception as e:
print(f"WebSocket error: {e}")
finally:
# Only deactivate if this WS belongs to the current session (not superseded by a re-login).
if player.session_id == ws_session_id:
player.active = False
@app.post("/api/game/start")
async def start_game():
"""Start a new game"""
game_world.start_game()
return {"game_active": True, "time_remaining": GAME_DURATION}
@app.get("/api/health")
async def health():
"""Health check"""
return {"status": "ok"}