Financial updates and performance updates.

This commit is contained in:
retoor 2025-10-04 23:26:27 +02:00
parent 416cc4511b
commit 6a2f94337e
7 changed files with 278 additions and 298 deletions

View File

@ -2,6 +2,8 @@ import sqlite3
import json import json
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, List from typing import Optional, Dict, List
from server.logger import logger
import time
class Database: class Database:
"""Handles all database operations""" """Handles all database operations"""
@ -18,8 +20,8 @@ class Database:
def init_db(self): def init_db(self):
"""Initialize database tables""" """Initialize database tables"""
logger.info(f"Initializing database at {self.db_path}...")
with self._get_connection() as conn: with self._get_connection() as conn:
# Players table
conn.execute(''' conn.execute('''
CREATE TABLE IF NOT EXISTS players ( CREATE TABLE IF NOT EXISTS players (
player_id TEXT PRIMARY KEY, player_id TEXT PRIMARY KEY,
@ -30,8 +32,6 @@ class Database:
last_online REAL NOT NULL last_online REAL NOT NULL
) )
''') ''')
# Buildings table
conn.execute(''' conn.execute('''
CREATE TABLE IF NOT EXISTS buildings ( CREATE TABLE IF NOT EXISTS buildings (
x INTEGER NOT NULL, x INTEGER NOT NULL,
@ -44,78 +44,65 @@ class Database:
FOREIGN KEY (owner_id) REFERENCES players (player_id) FOREIGN KEY (owner_id) REFERENCES players (player_id)
) )
''') ''')
conn.commit() conn.commit()
logger.info("Database initialized successfully.")
def save_game_state(self, game_state): def save_game_state(self, game_state):
"""Save complete game state to database""" """Save complete game state to database"""
logger.debug(f"Saving game state to database... ({len(game_state.players)} players, {len(game_state.buildings)} buildings)")
start_time = time.perf_counter()
with self._get_connection() as conn: with self._get_connection() as conn:
# Save players using INSERT OR REPLACE to handle existing nicknames player_data = [
for player in game_state.players.values(): (p.player_id, p.nickname, p.money, p.population, p.color, p.last_online)
conn.execute(''' for p in game_state.players.values()
]
if player_data:
conn.executemany('''
INSERT OR REPLACE INTO players (player_id, nickname, money, population, color, last_online) INSERT OR REPLACE INTO players (player_id, nickname, money, population, color, last_online)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
''', ( ''', player_data)
player.player_id,
player.nickname,
player.money,
player.population,
player.color,
player.last_online
))
# Save buildings cursor = conn.execute('SELECT x, y FROM buildings')
conn.execute('DELETE FROM buildings') db_coords = {(row['x'], row['y']) for row in cursor}
gs_coords = set(game_state.buildings.keys())
for building in game_state.buildings.values(): coords_to_delete = db_coords - gs_coords
conn.execute(''' if coords_to_delete:
INSERT INTO buildings (x, y, type, owner_id, name, placed_at) conn.executemany('DELETE FROM buildings WHERE x = ? AND y = ?', list(coords_to_delete))
building_data = [
(b.x, b.y, b.building_type.value, b.owner_id, b.name, b.placed_at)
for b in game_state.buildings.values()
]
if building_data:
conn.executemany('''
INSERT OR REPLACE INTO buildings (x, y, type, owner_id, name, placed_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
''', ( ''', building_data)
building.x,
building.y,
building.building_type.value,
building.owner_id,
building.name,
building.placed_at
))
conn.commit() conn.commit()
duration = time.perf_counter() - start_time
logger.debug(f"Game state saved to database in {duration:.4f} seconds.")
def load_game_state(self) -> dict: def load_game_state(self) -> dict:
"""Load complete game state from database""" """Load complete game state from database"""
logger.info("Loading game state from database...")
start_time = time.perf_counter()
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
# Load players players, buildings = [], []
players = []
cursor = conn.execute('SELECT * FROM players') cursor = conn.execute('SELECT * FROM players')
for row in cursor: for row in cursor:
players.append({ players.append(dict(row))
"player_id": row["player_id"],
"nickname": row["nickname"],
"money": row["money"],
"population": row["population"],
"color": row["color"],
"last_online": row["last_online"]
})
# Load buildings
buildings = []
cursor = conn.execute('SELECT * FROM buildings') cursor = conn.execute('SELECT * FROM buildings')
for row in cursor: for row in cursor:
buildings.append({ buildings.append(dict(row))
"x": row["x"],
"y": row["y"],
"type": row["type"],
"owner_id": row["owner_id"],
"name": row["name"],
"placed_at": row["placed_at"]
})
return { duration = time.perf_counter() - start_time
"players": players, logger.info(f"Loaded {len(players)} players and {len(buildings)} buildings in {duration:.4f} seconds.")
"buildings": buildings return {"players": players, "buildings": buildings}
}
except Exception as e: except Exception as e:
print(f"Error loading game state: {e}") logger.error(f"Error loading game state: {e}", exc_info=True)
return {"players": [], "buildings": []} return {"players": [], "buildings": []}

View File

@ -1,6 +1,8 @@
from server.game_state import GameState from server.game_state import GameState
from server.models import BUILDING_CONFIGS, BuildingType from server.models import BUILDING_CONFIGS, BuildingType
import time import time
from collections import defaultdict
from server.logger import logger
class EconomyEngine: class EconomyEngine:
"""Handles all economy calculations and ticks""" """Handles all economy calculations and ticks"""
@ -9,54 +11,44 @@ class EconomyEngine:
self.game_state = game_state self.game_state = game_state
def tick(self): def tick(self):
"""Process one economy tick for all players""" """
current_time = time.time() Process one economy tick for all players using an efficient aggregate calculation.
"""
logger.debug("Processing economy tick...")
start_time = time.perf_counter()
for player in self.game_state.players.values(): player_money_deltas = defaultdict(int)
# Calculate power factor (10% if offline, 100% if online) player_total_population = defaultdict(int)
time_diff = current_time - player.last_online
if player.is_online:
power_factor = 1.0
else:
power_factor = 0.1
# Process player economy # 1. Single pass over all buildings to aggregate their effects
self._process_player_economy(player, power_factor) for building in self.game_state.buildings.values():
def _process_player_economy(self, player, power_factor: float):
"""Process economy for a single player"""
total_income = 0
total_population = 0
# Get all player buildings
buildings = self.game_state.get_player_buildings(player.player_id)
for building in buildings:
config = BUILDING_CONFIGS[building.building_type] config = BUILDING_CONFIGS[building.building_type]
owner_id = building.owner_id
# Calculate base income
base_income = config.income base_income = config.income
# Apply connectivity bonus for income-generating buildings
if base_income > 0: if base_income > 0:
zone_size = self.game_state.get_building_zone_size(building.x, building.y) zone_size = self.game_state.get_building_zone_size(building.x, building.y)
connectivity_bonus = 1.0 + (zone_size * 0.05) # 5% per road in zone connectivity_bonus = 1.0 + (zone_size * 0.05)
base_income = int(base_income * connectivity_bonus) base_income = int(base_income * connectivity_bonus)
# Add to totals player_money_deltas[owner_id] += base_income
total_income += base_income player_total_population[owner_id] += config.population
total_population += config.population
# Apply power factor # 2. Apply the aggregated deltas to each player
total_income = int(total_income * power_factor) for player_id, player in self.game_state.players.items():
power_factor = 1.0 if player.is_online else 0.1
# Update player stats money_delta = player_money_deltas.get(player_id, 0)
player.money += total_income player.money += int(money_delta * power_factor)
player.population = max(0, total_population)
# Prevent negative money (but allow debt for realism) total_population = player_total_population.get(player_id, 0)
if player.money < -100000: player.population = max(0, total_population)
player.money = -100000
if player.money < -100000:
player.money = -100000
duration = time.perf_counter() - start_time
logger.debug(f"Economy tick processed in {duration:.4f} seconds for {len(self.game_state.players)} players.")
def calculate_building_stats(self, player_id: str, building_type: BuildingType) -> dict: def calculate_building_stats(self, player_id: str, building_type: BuildingType) -> dict:
"""Calculate what a building would produce for a player""" """Calculate what a building would produce for a player"""

View File

@ -1,6 +1,7 @@
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from server.models import Player, Building, BuildingType, BUILDING_CONFIGS from server.models import Player, Building, BuildingType, BUILDING_CONFIGS
import time import time
from server.logger import logger
class GameState: class GameState:
"""Manages the complete game state""" """Manages the complete game state"""
@ -17,6 +18,7 @@ class GameState:
player = self.players[player_id] player = self.players[player_id]
player.is_online = True player.is_online = True
player.last_online = time.time() player.last_online = time.time()
logger.debug(f"Returning existing player '{nickname}' ({player_id}).")
return player return player
player = Player( player = Player(
@ -25,50 +27,48 @@ class GameState:
last_online=time.time() last_online=time.time()
) )
self.players[player_id] = player self.players[player_id] = player
logger.info(f"Created new player '{nickname}' ({player_id}) with ${player.money} starting money.")
return player return player
def place_building(self, player_id: str, building_type: str, x: int, y: int) -> dict: def place_building(self, player_id: str, building_type: str, x: int, y: int) -> dict:
"""Place a building on the map""" """Place a building on the map"""
# Check if tile is occupied logger.debug(f"Attempting to place '{building_type}' at ({x},{y}) for player {player_id}.")
if (x, y) in self.buildings: if (x, y) in self.buildings:
logger.warning(f"Placement failed for {player_id}: Tile ({x},{y}) already occupied.")
return {"success": False, "error": "Tile already occupied"} return {"success": False, "error": "Tile already occupied"}
# Get player
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
logger.error(f"Placement failed: Player {player_id} not found.")
return {"success": False, "error": "Player not found"} return {"success": False, "error": "Player not found"}
# Get building config
try: try:
b_type = BuildingType(building_type) b_type = BuildingType(building_type)
config = BUILDING_CONFIGS[b_type] config = BUILDING_CONFIGS[b_type]
except (ValueError, KeyError): except (ValueError, KeyError):
logger.error(f"Placement failed for {player_id}: Invalid building type '{building_type}'.")
return {"success": False, "error": "Invalid building type"} return {"success": False, "error": "Invalid building type"}
# Check if player can afford
if not player.can_afford(config.cost): if not player.can_afford(config.cost):
logger.debug(f"Placement failed for {player_id}: Cannot afford '{building_type}'. Cost: {config.cost}, Player Money: {player.money}.")
return {"success": False, "error": "Not enough money"} return {"success": False, "error": "Not enough money"}
# Check requirements
if config.requires_population > player.population: if config.requires_population > player.population:
logger.debug(f"Placement failed for {player_id}: '{building_type}' requires {config.requires_population} pop, has {player.population}.")
return {"success": False, "error": f"Requires {config.requires_population} population"} return {"success": False, "error": f"Requires {config.requires_population} population"}
if config.power_required and not self._has_power_plant(player_id): if config.power_required and not self._has_power_plant(player_id):
logger.debug(f"Placement failed for {player_id}: '{building_type}' requires a power plant.")
return {"success": False, "error": "Requires power plant"} return {"success": False, "error": "Requires power plant"}
# Place building building = Building(building_type=b_type, x=x, y=y, owner_id=player_id, placed_at=time.time())
building = Building(
building_type=b_type,
x=x,
y=y,
owner_id=player_id,
placed_at=time.time()
)
self.buildings[(x, y)] = building self.buildings[(x, y)] = building
player.deduct_money(config.cost) player.deduct_money(config.cost)
# Update road network if it's a road logger.info(f"Player {player_id} placed '{building_type}' at ({x},{y}). New balance: ${player.money}.")
if b_type == BuildingType.ROAD: if b_type == BuildingType.ROAD:
self.road_network.add((x, y)) self.road_network.add((x, y))
self._update_connected_zones() self._update_connected_zones()
@ -77,18 +77,20 @@ class GameState:
def remove_building(self, player_id: str, x: int, y: int) -> dict: def remove_building(self, player_id: str, x: int, y: int) -> dict:
"""Remove a building""" """Remove a building"""
logger.debug(f"Attempting to remove building at ({x},{y}) for player {player_id}.")
building = self.buildings.get((x, y)) building = self.buildings.get((x, y))
if not building: if not building:
logger.warning(f"Removal failed for {player_id}: No building at ({x},{y}).")
return {"success": False, "error": "No building at this location"} return {"success": False, "error": "No building at this location"}
if building.owner_id != player_id: if building.owner_id != player_id:
logger.warning(f"Removal failed for {player_id}: Does not own building at ({x},{y}). Owner is {building.owner_id}.")
return {"success": False, "error": "You don't own this building"} return {"success": False, "error": "You don't own this building"}
# Remove building
del self.buildings[(x, y)] del self.buildings[(x, y)]
logger.info(f"Player {player_id} removed '{building.building_type.value}' from ({x},{y}).")
# Update road network if it was a road
if building.building_type == BuildingType.ROAD: if building.building_type == BuildingType.ROAD:
self.road_network.discard((x, y)) self.road_network.discard((x, y))
self._update_connected_zones() self._update_connected_zones()
@ -98,21 +100,19 @@ class GameState:
def edit_building_name(self, player_id: str, x: int, y: int, name: str) -> dict: def edit_building_name(self, player_id: str, x: int, y: int, name: str) -> dict:
"""Edit building name""" """Edit building name"""
building = self.buildings.get((x, y)) building = self.buildings.get((x, y))
if not building: if not building:
return {"success": False, "error": "No building at this location"} return {"success": False, "error": "No building at this location"}
if building.owner_id != player_id: if building.owner_id != player_id:
return {"success": False, "error": "You don't own this building"} return {"success": False, "error": "You don't own this building"}
building.name = name building.name = name
logger.info(f"Player {player_id} renamed building at ({x},{y}) to '{name}'.")
return {"success": True} return {"success": True}
def _has_power_plant(self, player_id: str) -> bool: def _has_power_plant(self, player_id: str) -> bool:
"""Check if player has a power plant""" """Check if player has a power plant"""
for building in self.buildings.values(): for building in self.buildings.values():
if (building.owner_id == player_id and if (building.owner_id == player_id and building.building_type == BuildingType.POWER_PLANT):
building.building_type == BuildingType.POWER_PLANT):
return True return True
return False return False
@ -122,6 +122,9 @@ class GameState:
self.connected_zones = [] self.connected_zones = []
return return
logger.debug("Recalculating road network connected zones...")
start_time = time.perf_counter()
visited = set() visited = set()
self.connected_zones = [] self.connected_zones = []
@ -129,19 +132,13 @@ class GameState:
if road_pos in visited: if road_pos in visited:
continue continue
# Flood fill to find connected zone
zone = set() zone = set()
stack = [road_pos] stack = [road_pos]
while stack: while stack:
pos = stack.pop() pos = stack.pop()
if pos in visited: if pos in visited: continue
continue
visited.add(pos) visited.add(pos)
zone.add(pos) zone.add(pos)
# Check adjacent positions
x, y = pos x, y = pos
for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0)]: for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
adj_pos = (x + dx, y + dy) adj_pos = (x + dx, y + dy)
@ -150,13 +147,14 @@ class GameState:
self.connected_zones.append(zone) self.connected_zones.append(zone)
duration = time.perf_counter() - start_time
logger.debug(f"Found {len(self.connected_zones)} road zones in {duration:.4f} seconds.")
def get_building_zone_size(self, x: int, y: int) -> int: def get_building_zone_size(self, x: int, y: int) -> int:
"""Get the size of the connected zone for a building""" """Get the size of the connected zone for a building"""
# Find adjacent roads
for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0), (1, 1), (-1, 1), (1, -1), (-1, -1)]: for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0), (1, 1), (-1, 1), (1, -1), (-1, -1)]:
road_pos = (x + dx, y + dy) road_pos = (x + dx, y + dy)
if road_pos in self.road_network: if road_pos in self.road_network:
# Find which zone this road belongs to
for zone in self.connected_zones: for zone in self.connected_zones:
if road_pos in zone: if road_pos in zone:
return len(zone) return len(zone)
@ -178,13 +176,11 @@ class GameState:
if not state: if not state:
return return
# Load players
for player_data in state.get("players", []): for player_data in state.get("players", []):
player = Player(**player_data) player = Player(**player_data)
player.is_online = False player.is_online = False # All players start as offline
self.players[player.player_id] = player self.players[player.player_id] = player
# Load buildings
for building_data in state.get("buildings", []): for building_data in state.get("buildings", []):
building = Building( building = Building(
building_type=BuildingType(building_data["type"]), building_type=BuildingType(building_data["type"]),
@ -200,3 +196,4 @@ class GameState:
self.road_network.add((building.x, building.y)) self.road_network.add((building.x, building.y))
self._update_connected_zones() self._update_connected_zones()
logger.info(f"Loaded state with {len(self.players)} players and {len(self.buildings)} buildings from database.")

View File

@ -4,157 +4,139 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pathlib import Path from pathlib import Path
import time
from server.logger import logger
from server.websocket_manager import WebSocketManager from server.websocket_manager import WebSocketManager
from server.game_state import GameState from server.game_state import GameState
from server.economy import EconomyEngine from server.economy import EconomyEngine
from server.database import Database from server.database import Database
# Global instances # Global instances
ws_manager = WebSocketManager()
game_state = GameState() game_state = GameState()
ws_manager = WebSocketManager(game_state)
economy_engine = EconomyEngine(game_state) economy_engine = EconomyEngine(game_state)
database = Database() database = Database()
# Background task for economy ticks and persistence TICK_INTERVAL = 10 # seconds
async def game_loop():
"""Main game loop: economy ticks every 10 seconds, DB save every 10 seconds""" # --- FIX: Reverting to a simple, reliable 10-second loop ---
async def economy_loop():
"""A simple loop that runs the economy tick every 10 seconds."""
while True: while True:
try: await asyncio.sleep(TICK_INTERVAL)
await asyncio.sleep(10)
# Economy tick logger.info("Triggering scheduled economy tick.")
economy_engine.tick() start_time = time.perf_counter()
# Save to database # 1. Process one economy tick
database.save_game_state(game_state) economy_engine.tick()
# Broadcast state to all players # 2. Broadcast updates concurrently
await ws_manager.broadcast_game_state(game_state.get_state()) update_tasks = []
except Exception as e: for player_id, player in game_state.players.items():
print(f"Error in game loop: {e}") if player_id in ws_manager.active_connections:
# Continue running even if there's an error task = ws_manager.send_to_player(player_id, {
"type": "player_stats_update",
"player": player.to_dict()
})
update_tasks.append(task)
if update_tasks:
await asyncio.gather(*update_tasks)
# 3. Save the new state
database.save_game_state(game_state)
duration = time.perf_counter() - start_time
logger.info(f"Economy tick cycle completed in {duration:.4f} seconds.")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Startup and shutdown events""" """Startup and shutdown events."""
# Startup logger.info("Server starting up...")
database.init_db() database.init_db()
game_state.load_state(database.load_game_state()) game_state.load_state(database.load_game_state())
# Start game loop # Start the simple economy loop
task = asyncio.create_task(game_loop()) task = asyncio.create_task(economy_loop())
logger.info(f"Economy loop started with a {TICK_INTERVAL}-second interval.")
yield yield
# Shutdown logger.info("Server shutting down...")
task.cancel() task.cancel()
database.save_game_state(game_state) database.save_game_state(game_state)
logger.info("Final game state saved.")
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/") @app.get("/")
async def root(): async def root():
"""Serve index.html"""
return FileResponse("static/index.html") return FileResponse("static/index.html")
@app.websocket("/ws/{nickname}") @app.websocket("/ws/{nickname}")
async def websocket_endpoint(websocket: WebSocket, nickname: str): async def websocket_endpoint(websocket: WebSocket, nickname: str):
"""WebSocket endpoint for real-time game communication"""
await ws_manager.connect(websocket, nickname) await ws_manager.connect(websocket, nickname)
try: try:
# Send initial game state
player_id = await ws_manager.get_player_id(websocket) player_id = await ws_manager.get_player_id(websocket)
player = game_state.get_or_create_player(nickname, player_id) if player_id:
player = game_state.get_or_create_player(nickname, player_id)
await websocket.send_json({ await websocket.send_json({
"type": "init", "type": "init",
"player": player.to_dict(), "player": player.to_dict(),
"game_state": game_state.get_state() "game_state": game_state.get_state()
}) })
while True:
# Listen for messages data = await websocket.receive_json()
while True: await handle_message(websocket, data)
data = await websocket.receive_json()
await handle_message(websocket, data)
except WebSocketDisconnect: except WebSocketDisconnect:
await ws_manager.disconnect(websocket) await ws_manager.disconnect(websocket)
except Exception as e:
player_id = await ws_manager.get_player_id(websocket)
logger.error(f"An error occurred in the websocket endpoint for player {player_id}: {e}", exc_info=True)
async def handle_message(websocket: WebSocket, data: dict): async def handle_message(websocket: WebSocket, data: dict):
"""Handle incoming WebSocket messages""" """Handle incoming WebSocket messages."""
msg_type = data.get("type") msg_type = data.get("type")
player_id = await ws_manager.get_player_id(websocket) player_id = await ws_manager.get_player_id(websocket)
if not player_id:
logger.warning("Received message from an unknown player, ignoring.")
return
logger.debug(f"Received '{msg_type}' from player {player_id}")
if msg_type == "cursor_move": if msg_type == "cursor_move":
# Broadcast cursor position await ws_manager.broadcast({"type": "cursor_move", "player_id": player_id, "x": data["x"], "y": data["y"]}, exclude=websocket)
await ws_manager.broadcast({
"type": "cursor_move",
"player_id": player_id,
"x": data["x"],
"y": data["y"]
}, exclude=websocket)
elif msg_type == "place_building": elif msg_type == "place_building":
# Place building result = game_state.place_building(player_id, data["building_type"], data["x"], data["y"])
result = game_state.place_building(
player_id,
data["building_type"],
data["x"],
data["y"]
)
if result["success"]: if result["success"]:
# Broadcast to all players await ws_manager.broadcast({"type": "building_placed", "building": result["building"]})
await ws_manager.broadcast({ # --- CHANGE: Action now only saves, economy is handled by the loop ---
"type": "building_placed", database.save_game_state(game_state)
"building": result["building"]
})
else: else:
# Send error to player await websocket.send_json({"type": "error", "message": result["error"]})
await websocket.send_json({
"type": "error",
"message": result["error"]
})
elif msg_type == "remove_building": elif msg_type == "remove_building":
# Remove building
result = game_state.remove_building(player_id, data["x"], data["y"]) result = game_state.remove_building(player_id, data["x"], data["y"])
if result["success"]: if result["success"]:
await ws_manager.broadcast({ await ws_manager.broadcast({"type": "building_removed", "x": data["x"], "y": data["y"]})
"type": "building_removed", # --- CHANGE: Action now only saves, economy is handled by the loop ---
"x": data["x"], database.save_game_state(game_state)
"y": data["y"] else:
}) await websocket.send_json({"type": "error", "message": result["error"]})
elif msg_type == "edit_building": elif msg_type == "edit_building":
# Edit building name result = game_state.edit_building_name(player_id, data["x"], data["y"], data["name"])
result = game_state.edit_building_name(
player_id,
data["x"],
data["y"],
data["name"]
)
if result["success"]: if result["success"]:
await ws_manager.broadcast({ await ws_manager.broadcast({"type": "building_updated", "x": data["x"], "y": data["y"], "name": data["name"]})
"type": "building_updated", database.save_game_state(game_state)
"x": data["x"],
"y": data["y"],
"name": data["name"]
})
elif msg_type == "chat": elif msg_type == "chat":
# Broadcast chat message
nickname = await ws_manager.get_nickname(websocket) nickname = await ws_manager.get_nickname(websocket)
await ws_manager.broadcast({ await ws_manager.broadcast({"type": "chat", "nickname": nickname, "message": data["message"], "timestamp": data["timestamp"]})
"type": "chat",
"nickname": nickname,
"message": data["message"],
"timestamp": data["timestamp"]
})

View File

@ -1,90 +1,118 @@
from fastapi import WebSocket from fastapi import WebSocket
from typing import Dict, Set from typing import Dict, List
import uuid import uuid
import time
from server.game_state import GameState
from server.logger import logger
class WebSocketManager: class WebSocketManager:
"""Manages WebSocket connections for multiplayer""" """Manages WebSocket connections for multiplayer"""
def __init__(self): def __init__(self, game_state: GameState):
self.active_connections: Dict[str, WebSocket] = {} self.active_connections: Dict[str, List[WebSocket]] = {}
self.player_nicknames: Dict[str, str] = {} self.player_nicknames: Dict[str, str] = {}
self.nickname_to_id: Dict[str, str] = {} self.nickname_to_id: Dict[str, str] = {}
self.game_state = game_state
async def connect(self, websocket: WebSocket, nickname: str): async def connect(self, websocket: WebSocket, nickname: str):
"""Connect a new player""" """Connect a new player, allowing multiple connections per nickname."""
logger.debug(f"Connection attempt from nickname '{nickname}'.")
await websocket.accept() await websocket.accept()
# Generate or reuse player ID
if nickname in self.nickname_to_id: if nickname in self.nickname_to_id:
player_id = self.nickname_to_id[nickname] player_id = self.nickname_to_id[nickname]
else: else:
player_id = str(uuid.uuid4()) player_id = str(uuid.uuid4())
self.nickname_to_id[nickname] = player_id self.nickname_to_id[nickname] = player_id
logger.info(f"New player '{nickname}' assigned ID {player_id}.")
self.active_connections[player_id] = websocket player = self.game_state.get_or_create_player(nickname, player_id)
self.player_nicknames[player_id] = nickname
# Broadcast player joined if not self.active_connections.get(player_id):
await self.broadcast({ player.is_online = True
"type": "player_joined", self.active_connections[player_id] = []
"player_id": player_id, logger.info(f"Player '{nickname}' ({player_id}) is now online.")
"nickname": nickname
})
async def disconnect(self, websocket: WebSocket):
"""Disconnect a player"""
player_id = None
for pid, ws in self.active_connections.items():
if ws == websocket:
player_id = pid
break
if player_id:
del self.active_connections[player_id]
nickname = self.player_nicknames.pop(player_id, None)
# Broadcast player left
await self.broadcast({ await self.broadcast({
"type": "player_left", "type": "player_joined",
"player_id": player_id, "player_id": player_id,
"nickname": nickname "nickname": nickname
}) })
async def broadcast(self, message: dict, exclude: WebSocket = None): self.active_connections[player_id].append(websocket)
"""Broadcast message to all connected players""" self.player_nicknames[player_id] = nickname
disconnected = [] logger.debug(f"Added new websocket for '{nickname}'. Total connections for user: {len(self.active_connections[player_id])}.")
for player_id, websocket in self.active_connections.items(): async def disconnect(self, websocket: WebSocket):
"""Disconnect a player's specific websocket instance."""
player_id = await self.get_player_id(websocket)
if player_id and player_id in self.active_connections:
self.active_connections[player_id].remove(websocket)
logger.debug(f"Removed websocket for player {player_id}. Remaining connections: {len(self.active_connections[player_id])}.")
if not self.active_connections[player_id]:
del self.active_connections[player_id]
nickname = self.player_nicknames.pop(player_id, None)
# --- REVERTED CHANGE: The following lines that set a player
# --- to 'offline' have been removed to restore original economy.
# player = self.game_state.players.get(player_id)
# if player:
# player.is_online = False
# player.last_online = time.time()
logger.info(f"Player '{nickname}' ({player_id}) last connection closed.")
await self.broadcast({
"type": "player_left",
"player_id": player_id,
"nickname": nickname
})
async def send_to_player(self, player_id: str, message: dict):
"""Send a message to all active connections for a specific player."""
connections = self.active_connections.get(player_id, [])
if not connections: return
logger.debug(f"Sending '{message['type']}' to player {player_id} ({len(connections)} connections).")
disconnected_sockets = []
for websocket in connections:
try:
await websocket.send_json(message)
except Exception:
disconnected_sockets.append(websocket)
for ws in disconnected_sockets:
logger.warning(f"Found dead socket for player {player_id} during send. Cleaning up.")
if ws in self.active_connections.get(player_id, []):
self.active_connections[player_id].remove(ws)
async def broadcast(self, message: dict, exclude: WebSocket = None):
"""Broadcast message to all connected players and all their connections."""
msg_type = message.get("type", "unknown")
logger.debug(f"Broadcasting '{msg_type}' to all active connections.")
all_sockets = []
for ws_list in self.active_connections.values():
all_sockets.extend(ws_list)
for websocket in all_sockets:
if websocket == exclude: if websocket == exclude:
continue continue
try: try:
await websocket.send_json(message) await websocket.send_json(message)
except Exception: except Exception:
disconnected.append(player_id) pass
# Clean up disconnected websockets
for player_id in disconnected:
if player_id in self.active_connections:
del self.active_connections[player_id]
if player_id in self.player_nicknames:
del self.player_nicknames[player_id]
async def broadcast_game_state(self, state: dict):
"""Broadcast full game state"""
await self.broadcast({
"type": "game_state_update",
"state": state
})
async def get_player_id(self, websocket: WebSocket) -> str: async def get_player_id(self, websocket: WebSocket) -> str:
"""Get player ID from websocket""" """Get player ID from a specific websocket instance."""
for player_id, ws in self.active_connections.items(): for player_id, ws_list in self.active_connections.items():
if ws == websocket: if websocket in ws_list:
return player_id return player_id
return None return None
async def get_nickname(self, websocket: WebSocket) -> str: async def get_nickname(self, websocket: WebSocket) -> str:
"""Get nickname from websocket""" """Get nickname from websocket."""
player_id = await self.get_player_id(websocket) player_id = await self.get_player_id(websocket)
return self.player_nicknames.get(player_id, "Unknown") return self.player_nicknames.get(player_id, "Unknown")

View File

@ -15,41 +15,33 @@ export class App {
players: {}, players: {},
buildings: {} buildings: {}
}; };
this.selectedBuildingType = null; this.selectedBuildingType = null;
this.isPlacingBuilding = false; this.isPlacingBuilding = false;
} }
init() { init() {
console.log('Initializing City Builder...'); console.log('Initializing City Builder...');
// Initialize UI Manager // Initialize UI Manager
this.uiManager = new UIManager(this); this.uiManager = new UIManager(this);
this.uiManager.init(); this.uiManager.init();
// Show login screen // Show login screen
this.uiManager.showLoginScreen(); this.uiManager.showLoginScreen();
} }
async startGame(nickname) { async startGame(nickname) {
console.log(`Starting game for ${nickname}...`); console.log(`Starting game for ${nickname}...`);
// Hide login, show game UI // Hide login, show game UI
this.uiManager.hideLoginScreen(); this.uiManager.hideLoginScreen();
this.uiManager.showGameUI(); this.uiManager.showGameUI();
// Initialize renderer // Initialize renderer
this.renderer = new GameRenderer(); this.renderer = new GameRenderer();
this.renderer.init(); this.renderer.init();
// Initialize input handler // Initialize input handler
this.inputHandler = new InputHandler(this); this.inputHandler = new InputHandler(this);
this.inputHandler.init(); this.inputHandler.init();
// Connect to WebSocket // Connect to WebSocket
this.wsClient = new WebSocketClient(this); this.wsClient = new WebSocketClient(this);
await this.wsClient.connect(nickname); await this.wsClient.connect(nickname);
// Start render loop // Start render loop
this.renderer.startRenderLoop(); this.renderer.startRenderLoop();
} }
@ -62,21 +54,22 @@ export class App {
// Update UI // Update UI
this.uiManager.updateStats(this.player); this.uiManager.updateStats(this.player);
this.uiManager.updateBuildingToolbox(this.player); this.uiManager.updateBuildingToolbox(this.player);
// Render initial state // Render initial state
this.renderer.updateGameState(gameState); this.renderer.updateGameState(gameState);
} }
onGameStateUpdate(state) { onPlayerStatsUpdate(playerData) {
this.gameState = state; // Update our own player object and UI if the update is for us
this.renderer.updateGameState(state); if (this.player && this.player.player_id === playerData.player_id) {
this.player = playerData;
// Update own player stats
if (this.player && state.players[this.player.player_id]) {
this.player = state.players[this.player.player_id];
this.uiManager.updateStats(this.player); this.uiManager.updateStats(this.player);
this.uiManager.updateBuildingToolbox(this.player); this.uiManager.updateBuildingToolbox(this.player);
} }
// Update the player in the global game state as well
if (this.gameState.players[playerData.player_id]) {
this.gameState.players[playerData.player_id] = playerData;
}
} }
onCursorMove(playerId, x, y) { onCursorMove(playerId, x, y) {
@ -86,16 +79,22 @@ export class App {
onBuildingPlaced(building) { onBuildingPlaced(building) {
console.log('Building placed:', building); console.log('Building placed:', building);
this.renderer.addBuilding(building); this.renderer.addBuilding(building);
// Also add to local game state for context menu checks etc.
this.gameState.buildings[`${building.x},${building.y}`] = building;
} }
onBuildingRemoved(x, y) { onBuildingRemoved(x, y) {
console.log('Building removed at:', x, y); console.log('Building removed at:', x, y);
this.renderer.removeBuilding(x, y); this.renderer.removeBuilding(x, y);
delete this.gameState.buildings[`${x},${y}`];
} }
onBuildingUpdated(x, y, name) { onBuildingUpdated(x, y, name) {
console.log('Building updated:', x, y, name); console.log('Building updated:', x, y, name);
this.renderer.updateBuildingName(x, y, name); this.renderer.updateBuildingName(x, y, name);
if (this.gameState.buildings[`${x},${y}`]) {
this.gameState.buildings[`${x},${y}`].name = name;
}
} }
onPlayerJoined(playerId, nickname) { onPlayerJoined(playerId, nickname) {
@ -127,7 +126,6 @@ export class App {
placeBuilding(x, y) { placeBuilding(x, y) {
if (!this.selectedBuildingType) return; if (!this.selectedBuildingType) return;
console.log('Placing building:', this.selectedBuildingType, 'at', x, y); console.log('Placing building:', this.selectedBuildingType, 'at', x, y);
this.wsClient.placeBuilding(this.selectedBuildingType, x, y); this.wsClient.placeBuilding(this.selectedBuildingType, x, y);
} }

View File

@ -12,7 +12,6 @@ export class WebSocketClient {
try { try {
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
@ -50,10 +49,9 @@ export class WebSocketClient {
this.app.onPlayerInit(data.player, data.game_state); this.app.onPlayerInit(data.player, data.game_state);
break; break;
case 'game_state_update': case 'player_stats_update':
this.app.onGameStateUpdate(data.state); this.app.onPlayerStatsUpdate(data.player);
break; break;
case 'cursor_move': case 'cursor_move':
this.app.onCursorMove(data.player_id, data.x, data.y); this.app.onCursorMove(data.player_id, data.x, data.y);
break; break;
@ -61,11 +59,9 @@ export class WebSocketClient {
case 'building_placed': case 'building_placed':
this.app.onBuildingPlaced(data.building); this.app.onBuildingPlaced(data.building);
break; break;
case 'building_removed': case 'building_removed':
this.app.onBuildingRemoved(data.x, data.y); this.app.onBuildingRemoved(data.x, data.y);
break; break;
case 'building_updated': case 'building_updated':
this.app.onBuildingUpdated(data.x, data.y, data.name); this.app.onBuildingUpdated(data.x, data.y, data.name);
break; break;