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 = 200 # Grid size in tiles PLAYER_START_SPAWN_RANGE = 50 BOSS_SPAWN_DISTANCE = 70 ACTION_POINTS_MAX = 30 # 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")) HP_REGEN_INTERVAL = float(os.getenv("HP_REGEN_INTERVAL", "2" if DEBUG_MODE else "15")) # seconds per 1 HP HP_REGEN_AMOUNT = 1 # HP healed per interval MONSTER_SPAWN_RATE = 0.05 # Probability per game tick MAX_MONSTERS = 50 BOSS_FIXED_HEALTH = 6000 MIN_LEVEL_FOR_BOSS = 10 BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1 RESOURCE_NODE_COUNT = 540 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 PLAYER_BASE_ATTACK = 5 PLAYER_BASE_DEFENSE = 2 PLAYER_BASE_MAX_HEALTH = 100 PLAYER_BASE_MOVE_CAPACITY = 1 # 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 last_hp_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.boss_spawned: bool = False 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() # Auto-start a game session immediately so the timer runs from launch. self.game_start_time = datetime.now().timestamp() self.is_game_active = True self._spawn_boss() self.boss_spawned = True 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": "Oversized Shrimp", "attack_bonus": 2, "range_bonus": 2}, {"name": "Hunting Bow", "attack_bonus": 1, "range_bonus": 5}, {"name": "Big Ass Axe", "attack_bonus": 3, "range_bonus": 1}, { "name": "Yaoi Paddle", "attack_bonus": 1, "range_bonus": 1, "aura_radius": 6, "aura_bonuses": {"action_regen": 1, "hp_regen": 1}, }, { "name": "Warden Guitar", "attack_bonus": 0, "range_bonus": 1, "aura_radius": 6, "aura_bonuses": {"defense": 2}, }, { "name": "Windstep Boots", "attack_bonus": 0, "range_bonus": 0, "aura_radius": 5, "aura_bonuses": {"free_move_chance": 0.7}, }, { "name": "Traveler Ring", "attack_bonus": 0, "range_bonus": 0, "movement_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.boss_spawned = False 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.attack = PLAYER_BASE_ATTACK player.defense = PLAYER_BASE_DEFENSE player.max_health = PLAYER_BASE_MAX_HEALTH player.max_action_points = ACTION_POINTS_MAX player.movement_capacity = PLAYER_BASE_MOVE_CAPACITY player.health = player.max_health player.action_points = player.max_action_points 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, ) self._spawn_boss() self.boss_spawned = True 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 once at the beginning of each session. if self.boss is None and not self.boss_spawned: self._spawn_boss() self.boss_spawned = True # 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 = _player_nearby_bonuses(player) if "action_regen" in nearby_bonuses: regen_amount += int(nearby_bonuses["action_regen"]) player.action_points = min( player.max_action_points, player.action_points + regen_amount, ) player.last_action_regen = current_time # HP regeneration if current_time - player.last_hp_regen >= HP_REGEN_INTERVAL: effective_max_health = _player_effective_max_health(player) hp_regen_amount = HP_REGEN_AMOUNT + int(_player_nearby_bonuses(player).get("hp_regen", 0)) if player.health < effective_max_health: player.health = min(effective_max_health, player.health + hp_regen_amount) player.last_hp_regen = current_time def _spawn_monster(self): """Spawn a random monster on the map with level 1-10""" monster_id = f"monster_{len(self.monsters)}_{self.generation}" level = random.randint(1, 10) x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) # HP scales with level: base 15 + 5 per level = 20-65 for levels 1-10 health = 15 + level * 5 self.monsters[monster_id] = Monster( id=monster_id, position=Position(x, y, 0), health=health, max_health=health, level=level, attack=2 + level, defense=1 + level // 3, 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)) if active_players else 1 # Spawn boss at a distance from players if possible, otherwise at map center. 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=BOSS_FIXED_HEALTH, max_health=BOSS_FIXED_HEALTH, level=max(10, 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 _player_effective_movement(player: Player) -> int: if 0 <= player.equipped_weapon_slot < len(player.weapon_slots): w = player.weapon_slots[player.equipped_weapon_slot] if w: return max(1, player.movement_capacity + int(w.get("movement_bonus", 0))) return player.movement_capacity def _recompute_player_stats_for_level(player: Player) -> None: # Slow progression so levels matter without runaway scaling. level = max(1, int(player.level)) old_max_health = player.max_health old_max_ap = player.max_action_points player.attack = PLAYER_BASE_ATTACK + (level - 1) // 3 player.defense = PLAYER_BASE_DEFENSE + (level - 1) // 4 player.max_health = PLAYER_BASE_MAX_HEALTH + ((level - 1) // 2) * 5 player.max_action_points = ACTION_POINTS_MAX + (level - 1) // 4 if player.max_health > old_max_health: player.health = min(player.max_health, player.health + (player.max_health - old_max_health)) else: player.health = min(player.max_health, player.health) if player.max_action_points > old_max_ap: player.action_points = min(player.max_action_points, player.action_points + (player.max_action_points - old_max_ap)) else: player.action_points = min(player.max_action_points, player.action_points) def _recompute_player_level_from_exp(player: Player) -> None: level = 1 xp_needed = 30 while player.exp >= xp_needed and level < 100: level += 1 xp_needed += 20 player.level = level _recompute_player_stats_for_level(player) def _player_nearby_bonuses(player: Player) -> Dict: if "game_world" not in globals(): return {} bonuses = dict(game_world._get_nearby_structure_bonuses(player.position)) for other_player in game_world.players.values(): if not other_player.active: continue if not (0 <= other_player.equipped_weapon_slot < len(other_player.weapon_slots)): continue item = other_player.weapon_slots[other_player.equipped_weapon_slot] if not item: continue aura_bonuses = item.get("aura_bonuses") or {} aura_radius = float(item.get("aura_radius", 0)) if not aura_bonuses or aura_radius <= 0: continue dist = ((other_player.position.x - player.position.x) ** 2 + (other_player.position.y - player.position.y) ** 2) ** 0.5 if dist > aura_radius: continue for key, value in aura_bonuses.items(): bonuses[key] = bonuses.get(key, 0) + value return bonuses def _player_effective_defense(player: Player) -> int: bonuses = _player_nearby_bonuses(player) return player.defense + int(bonuses.get("defense", 0)) def _player_effective_max_health(player: Player) -> int: bonuses = _player_nearby_bonuses(player) return player.max_health + int(bonuses.get("max_health", 0)) def _serialize_player(player: Player) -> Dict: data = player.to_dict() bonuses = _player_nearby_bonuses(player) effective_defense = _player_effective_defense(player) effective_max_health = _player_effective_max_health(player) effective_movement = _player_effective_movement(player) if player.health > effective_max_health: player.health = effective_max_health data["base_defense"] = player.defense data["defense"] = effective_defense data["base_max_health"] = player.max_health data["max_health"] = effective_max_health data["health"] = player.health data["base_movement_capacity"] = player.movement_capacity data["movement_capacity"] = effective_movement data["nearby_bonuses"] = bonuses return data 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 = _serialize_player(player) 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: _serialize_player(p) 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") nearby_bonuses = _player_nearby_bonuses(player) free_move_chance = max(0.0, min(0.95, float(nearby_bonuses.get("free_move_chance", 0)))) ap_cost = 0 if random.random() < free_move_chance else 1 if player.action_points < ap_cost: raise HTTPException(status_code=400, detail="Insufficient action points") # Calculate distance distance = (dx ** 2 + dy ** 2) ** 0.5 effective_movement = _player_effective_movement(player) if distance > effective_movement: 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 -= ap_cost return { "position": player.position.to_dict(), "action_points": player.action_points, "ap_cost": ap_cost, "free_move_triggered": ap_cost == 0, } @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] action_type = body.get("action_type") # Equip is free — no AP required if action_type == "equip": result = _handle_equip(player, body.get("slot_index")) return result if player.action_points < 1: raise HTTPException(status_code=400, detail="Insufficient action points") 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) effective_defense = _player_effective_defense(player) def max_retaliation_damage(monster_attack: int) -> int: # Retaliation roll is random in [-1, +2], so +2 is the worst-case outcome. return max(0, monster_attack + 2 - effective_defense) def apply_retaliation(monster_attack: int) -> Dict: retaliation_damage = max(0, monster_attack + random.randint(-1, 2) - effective_defense) if retaliation_damage <= 0: return {"retaliation_damage": 0, "player_health": player.health, "player_defeated": False} player.health = max(0, player.health - retaliation_damage) defeated = player.health <= 0 respawn_position = None if defeated: player.health = player.max_health player.position = Position( random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), 0, ) respawn_position = player.position.to_dict() return { "retaliation_damage": retaliation_damage, "player_health": player.health, "player_defeated": defeated, "respawn_position": respawn_position, } 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"} min_damage = max(1, attack_power - 2 - monster.defense) # If monster certainly survives this hit and guaranteed max retaliation is lethal, # block the attack before consuming AP. if monster.health > min_damage and player.health <= max_retaliation_damage(monster.attack): return {"success": False, "reason": "Attack blocked: retaliation would be lethal"} 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 _recompute_player_level_from_exp(player) return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True} retaliation = apply_retaliation(monster.attack) return { "success": True, "damage": damage, "monster_health_remaining": monster.health, **retaliation, } 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"} min_damage = max(1, attack_power - 2 - monster.defense) if monster.health > min_damage and player.health <= max_retaliation_damage(monster.attack + 2): return {"success": False, "reason": "Attack blocked: boss retaliation would be lethal"} 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 _recompute_player_stats_for_level(p) game_world.boss = None return {"success": True, "damage": damage, "boss_killed": True} retaliation = apply_retaliation(monster.attack + 2) return { "success": True, "damage": damage, "boss_health_remaining": monster.health, **retaliation, } 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 # Map resource_type -> inventory key RESOURCE_MAP = {"tree": "wood", "mountain": "stone"} gathered_total = {"wood": 0, "stone": 0} depleted = [] for rid, resource in list(game_world.resources.items()): dist = ((resource.position.x - player.position.x) ** 2 + (resource.position.y - player.position.y) ** 2) ** 0.5 if dist <= GATHER_RADIUS: inv_key = RESOURCE_MAP.get(resource.resource_type) if not inv_key: continue # Gather from every node in range (per-node cap = gathering_capacity). amount = min(player.gathering_capacity, resource.amount) resource.amount -= amount gathered_total[inv_key] = gathered_total.get(inv_key, 0) + 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 inv_key, amt in gathered_total.items(): player.inventory[inv_key] = player.inventory.get(inv_key, 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] # 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. 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"} # No structure can be placed on an existing structure. 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 by a structure"} for material, amount in cost.items(): if player.inventory.get(material, 0) < amount: return {"success": False, "reason": f"Insufficient {material}"} # Deduct cost only after placement is validated. for material, amount in cost.items(): player.inventory[material] -= amount 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: _serialize_player(p) 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"}