ChatCraft / backend /game /state.py
gabraken's picture
Opposition
aa65ffc
from __future__ import annotations
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from .buildings import Building, BuildingStatus, BuildingType, BUILDING_DEFS
from .map import GameMap, get_start_data, get_all_map_resources, get_tutorial_start_data
from .pathfinding import snap_to_walkable
from .units import Unit, UnitType, UNIT_DEFS, UnitStatus
class GamePhase(str, Enum):
LOBBY = "lobby"
PLAYING = "playing"
GAME_OVER = "game_over"
# Control groups: 1, 2, 3 → list of unit IDs (and optionally building IDs)
ControlGroups = dict[int, list[str]]
class PlayerState(BaseModel):
player_id: str
player_name: str
minerals: int = 50
gas: int = 0
supply_used: int = 0
supply_max: int = 10
units: dict[str, Unit] = Field(default_factory=dict)
buildings: dict[str, Building] = Field(default_factory=dict)
control_groups: ControlGroups = Field(default_factory=lambda: {1: [], 2: [], 3: []})
is_defeated: bool = False
def recalculate_supply(self) -> None:
self.supply_max = sum(
BUILDING_DEFS[b.building_type].supply_provided
for b in self.buildings.values()
if b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
)
self.supply_used = sum(
UNIT_DEFS[u.unit_type].supply_cost
for u in self.units.values()
)
def has_active(self, bt: BuildingType) -> bool:
return any(
b.building_type == bt
and b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
for b in self.buildings.values()
)
def active_buildings_of(self, bt: BuildingType) -> list[Building]:
return [
b for b in self.buildings.values()
if b.building_type == bt
and b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
]
def units_of(self, ut: UnitType) -> list[Unit]:
return [u for u in self.units.values() if u.unit_type == ut]
def command_center(self) -> Optional[Building]:
return next(
(b for b in self.buildings.values()
if b.building_type == BuildingType.COMMAND_CENTER
and b.status != BuildingStatus.DESTROYED),
None,
)
def command_centers(self) -> list[Building]:
"""Return all non-destroyed command centers."""
return [
b for b in self.buildings.values()
if b.building_type == BuildingType.COMMAND_CENTER
and b.status != BuildingStatus.DESTROYED
]
def nearest_command_center(self, x: float, y: float) -> Optional[Building]:
"""Return the non-destroyed command center closest to (x, y)."""
ccs = self.command_centers()
if not ccs:
return None
return min(ccs, key=lambda b: (b.x - x) ** 2 + (b.y - y) ** 2)
def summary(self, lang: str = "fr") -> str:
active = [
b.building_type.value for b in self.buildings.values()
if b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
]
constructing = [
f"{b.building_type.value}({b.construction_ticks_remaining}t)"
for b in self.buildings.values()
if b.status == BuildingStatus.CONSTRUCTING
]
counts: dict[str, int] = {}
for u in self.units.values():
counts[u.unit_type.value] = counts.get(u.unit_type.value, 0) + 1
if (lang or "fr").lower() == "en":
minerals_l, gas_l, supply_l = "Minerals", "Gas", "Supply"
buildings_l, constructing_l, units_l, none_l = "Active buildings", "Under construction", "Units", "none"
else:
minerals_l, gas_l, supply_l = "Minéraux", "Gaz", "Supply"
buildings_l, constructing_l, units_l, none_l = "Bâtiments actifs", "En construction", "Unités", "aucun"
lines = [
f"{minerals_l}: {self.minerals}, {gas_l}: {self.gas}, {supply_l}: {self.supply_used}/{self.supply_max}",
f"{buildings_l}: {', '.join(active) or none_l}",
]
if constructing:
lines.append(f"{constructing_l}: {', '.join(constructing)}")
if counts:
lines.append(f"{units_l}: {', '.join(f'{v} {k}' for k, v in counts.items())}")
return "\n".join(lines)
TUTORIAL_DUMMY_ID = "tutorial_dummy"
class GameState(BaseModel):
room_id: str
tick: int = 0
phase: GamePhase = GamePhase.LOBBY
players: dict[str, PlayerState] = Field(default_factory=dict)
game_map: GameMap = Field(default_factory=GameMap.create_default)
winner: Optional[str] = None
is_tutorial: bool = False
tutorial_target_x: Optional[float] = None
tutorial_target_y: Optional[float] = None
@classmethod
def create_tutorial(
cls,
room_id: str,
player_id: str,
player_name: str,
) -> "GameState":
state = cls(room_id=room_id, phase=GamePhase.PLAYING, is_tutorial=True)
p1 = PlayerState(player_id=player_id, player_name=player_name)
start1, _, target = get_tutorial_start_data()
state.game_map = GameMap.create_default(resources=get_all_map_resources())
state.tutorial_target_x = float(target[0])
state.tutorial_target_y = float(target[1])
cc1 = Building.create(BuildingType.COMMAND_CENTER, player_id, float(start1[0]), float(start1[1]))
cc1.status = BuildingStatus.ACTIVE
cc1.construction_ticks_remaining = 0
cc1.hp = float(cc1.max_hp)
p1.buildings[cc1.id] = cc1
_MAP_W = 80.0
_MAP_H = 80.0
_scv_offsets = [(-2.4, 3), (-1.2, 3), (0.0, 3), (1.2, 3), (2.4, 3)]
for ox, oy in _scv_offsets:
raw_x = max(0.5, min(_MAP_W - 0.5, start1[0] + ox))
raw_y = max(0.5, min(_MAP_H - 0.5, start1[1] + oy))
sx, sy = snap_to_walkable(raw_x, raw_y)
scv = Unit.create(UnitType.SCV, player_id, sx, sy)
p1.units[scv.id] = scv
p1.recalculate_supply()
state.players[player_id] = p1
# Dummy opponent with two marines blocking the path to the objective
dummy = PlayerState(player_id=TUTORIAL_DUMMY_ID, player_name="Training Ground")
for ex, ey in [(40.0, 50.0), (43.0, 54.0)]:
raw_x = max(0.5, min(_MAP_W - 0.5, ex))
raw_y = max(0.5, min(_MAP_H - 0.5, ey))
mx, my = snap_to_walkable(raw_x, raw_y)
enemy_marine = Unit.create(UnitType.MARINE, TUTORIAL_DUMMY_ID, mx, my)
dummy.units[enemy_marine.id] = enemy_marine
state.players[TUTORIAL_DUMMY_ID] = dummy
return state
@classmethod
def create_new(
cls,
room_id: str,
player1_id: str,
player1_name: str,
player2_id: str,
player2_name: str,
) -> "GameState":
state = cls(room_id=room_id, phase=GamePhase.PLAYING)
p1 = PlayerState(player_id=player1_id, player_name=player1_name)
p2 = PlayerState(player_id=player2_id, player_name=player2_name)
start1, _, start2, _ = get_start_data()
state.game_map = GameMap.create_default(resources=get_all_map_resources())
# Starting Command Centers (already built); x,y is center of building footprint
cc1 = Building.create(BuildingType.COMMAND_CENTER, player1_id, float(start1[0]), float(start1[1]))
cc1.status = BuildingStatus.ACTIVE
cc1.construction_ticks_remaining = 0
cc1.hp = float(cc1.max_hp)
p1.buildings[cc1.id] = cc1
cc2 = Building.create(BuildingType.COMMAND_CENTER, player2_id, float(start2[0]), float(start2[1]))
cc2.status = BuildingStatus.ACTIVE
cc2.construction_ticks_remaining = 0
cc2.hp = float(cc2.max_hp)
p2.buildings[cc2.id] = cc2
# 5 starting SCVs per player, spread in x around CC; snap each to nearest walkable point
_MAP_W = 80.0
_MAP_H = 80.0
# Spacing must be > 2*UNIT_RADIUS (1.0) to avoid collision-blocking at spawn
_scv_offsets = [(-2.4, 3), (-1.2, 3), (0.0, 3), (1.2, 3), (2.4, 3)]
for i, (ox, oy) in enumerate(_scv_offsets):
raw1x = max(0.5, min(_MAP_W - 0.5, start1[0] + ox))
raw1y = max(0.5, min(_MAP_H - 0.5, start1[1] + oy))
sx1, sy1 = snap_to_walkable(raw1x, raw1y)
scv1 = Unit.create(UnitType.SCV, player1_id, sx1, sy1)
p1.units[scv1.id] = scv1
raw2x = max(0.5, min(_MAP_W - 0.5, start2[0] + ox))
raw2y = max(0.5, min(_MAP_H - 0.5, start2[1] - oy))
sx2, sy2 = snap_to_walkable(raw2x, raw2y)
scv2 = Unit.create(UnitType.SCV, player2_id, sx2, sy2)
p2.units[scv2.id] = scv2
p1.recalculate_supply()
p2.recalculate_supply()
state.players[player1_id] = p1
state.players[player2_id] = p2
return state
def enemy_of(self, player_id: str) -> Optional[PlayerState]:
return next((p for pid, p in self.players.items() if pid != player_id), None)