|
|
""" |
|
|
Fire-Rescue - Data Models |
|
|
|
|
|
Defines core data structures for the fire rescue simulation. |
|
|
""" |
|
|
|
|
|
from dataclasses import dataclass, field |
|
|
from enum import Enum |
|
|
from typing import Optional |
|
|
import random |
|
|
|
|
|
from config import SCENARIO_DEFAULTS |
|
|
|
|
|
FIRE_COUNT_DEFAULTS = SCENARIO_DEFAULTS["fire_count"] |
|
|
BUILDING_COUNT_DEFAULTS = SCENARIO_DEFAULTS["building_count"] |
|
|
|
|
|
|
|
|
class UnitType(str, Enum): |
|
|
"""Types of firefighting units available.""" |
|
|
FIRE_TRUCK = "fire_truck" |
|
|
HELICOPTER = "helicopter" |
|
|
|
|
|
|
|
|
class CellType(str, Enum): |
|
|
"""Types of terrain cells in the grid.""" |
|
|
EMPTY = "empty" |
|
|
BUILDING = "building" |
|
|
FOREST = "forest" |
|
|
|
|
|
|
|
|
class SimulationStatus(str, Enum): |
|
|
"""Status of the simulation.""" |
|
|
IDLE = "idle" |
|
|
RUNNING = "running" |
|
|
SUCCESS = "success" |
|
|
FAIL = "fail" |
|
|
|
|
|
|
|
|
class FireLevel(str, Enum): |
|
|
"""Initial fire intensity levels.""" |
|
|
LOW = "low" |
|
|
MEDIUM = "medium" |
|
|
HIGH = "high" |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class Fire: |
|
|
"""Represents a fire cell in the grid.""" |
|
|
x: int |
|
|
y: int |
|
|
intensity: float |
|
|
|
|
|
def to_dict(self) -> dict: |
|
|
return { |
|
|
"x": self.x, |
|
|
"y": self.y, |
|
|
"intensity": round(self.intensity, 2) |
|
|
} |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class Unit: |
|
|
"""Represents a firefighting unit.""" |
|
|
id: str |
|
|
unit_type: UnitType |
|
|
owner: str |
|
|
x: int |
|
|
y: int |
|
|
cooldown: int = 0 |
|
|
|
|
|
def to_dict(self) -> dict: |
|
|
return { |
|
|
"id": self.id, |
|
|
"type": self.unit_type.value, |
|
|
"owner": self.owner, |
|
|
"x": self.x, |
|
|
"y": self.y |
|
|
} |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class Event: |
|
|
"""Represents a simulation event.""" |
|
|
tick: int |
|
|
event_type: str |
|
|
details: dict = field(default_factory=dict) |
|
|
|
|
|
def to_dict(self) -> dict: |
|
|
return { |
|
|
"tick": self.tick, |
|
|
"type": self.event_type, |
|
|
**self.details |
|
|
} |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class Cell: |
|
|
"""Represents a cell in the grid.""" |
|
|
x: int |
|
|
y: int |
|
|
cell_type: CellType |
|
|
fire_intensity: float = 0.0 |
|
|
damage: float = 0.0 |
|
|
|
|
|
def is_on_fire(self) -> bool: |
|
|
return self.fire_intensity > 0.0 |
|
|
|
|
|
def is_destroyed(self) -> bool: |
|
|
return self.damage >= 1.0 |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class WorldState: |
|
|
""" |
|
|
Represents the complete state of the simulation world. |
|
|
Uses a 2D grid system. |
|
|
""" |
|
|
width: int |
|
|
height: int |
|
|
tick: int = 0 |
|
|
status: SimulationStatus = SimulationStatus.IDLE |
|
|
|
|
|
|
|
|
grid: list[list[Cell]] = field(default_factory=list) |
|
|
|
|
|
|
|
|
units: list[Unit] = field(default_factory=list) |
|
|
|
|
|
|
|
|
recent_events: list[Event] = field(default_factory=list) |
|
|
|
|
|
|
|
|
building_integrity: float = 1.0 |
|
|
forest_burn_ratio: float = 0.0 |
|
|
|
|
|
|
|
|
max_ticks: int = 200 |
|
|
max_units: int = 10 |
|
|
seed: Optional[int] = None |
|
|
|
|
|
|
|
|
building_positions: list[tuple[int, int]] = field(default_factory=list) |
|
|
|
|
|
|
|
|
_unit_counter: int = 0 |
|
|
|
|
|
def initialize_grid( |
|
|
self, |
|
|
seed: Optional[int] = None, |
|
|
fire_count: int = 4, |
|
|
fire_intensity: float = 0.6, |
|
|
building_count: int = 16 |
|
|
): |
|
|
f""" |
|
|
Initialize the grid with terrain and initial fires. |
|
|
|
|
|
Args: |
|
|
seed: Random seed for reproducibility |
|
|
fire_count: Number of initial fire points ({int(FIRE_COUNT_DEFAULTS.minimum)}-{int(FIRE_COUNT_DEFAULTS.maximum)}) |
|
|
fire_intensity: Initial fire intensity (0.0-1.0) |
|
|
building_count: Number of buildings to place ({int(BUILDING_COUNT_DEFAULTS.minimum)}-{int(BUILDING_COUNT_DEFAULTS.maximum)}) |
|
|
""" |
|
|
if seed is not None: |
|
|
random.seed(seed) |
|
|
self.seed = seed |
|
|
|
|
|
|
|
|
fire_count = int(FIRE_COUNT_DEFAULTS.clamp(fire_count)) |
|
|
fire_intensity = max(0.0, min(1.0, fire_intensity)) |
|
|
building_count = int(BUILDING_COUNT_DEFAULTS.clamp(building_count)) |
|
|
|
|
|
|
|
|
self.building_positions = self._generate_building_positions(building_count) |
|
|
building_set = set(self.building_positions) |
|
|
|
|
|
self.grid = [] |
|
|
for y in range(self.height): |
|
|
row = [] |
|
|
for x in range(self.width): |
|
|
|
|
|
cell_type = CellType.FOREST |
|
|
|
|
|
|
|
|
if (x, y) in building_set: |
|
|
cell_type = CellType.BUILDING |
|
|
|
|
|
row.append(Cell(x=x, y=y, cell_type=cell_type)) |
|
|
self.grid.append(row) |
|
|
|
|
|
|
|
|
fires_placed = 0 |
|
|
attempts = 0 |
|
|
max_attempts = 100 |
|
|
|
|
|
while fires_placed < fire_count and attempts < max_attempts: |
|
|
x = random.randint(0, self.width - 1) |
|
|
y = random.randint(0, self.height - 1) |
|
|
|
|
|
|
|
|
cell = self.grid[y][x] |
|
|
if cell.cell_type == CellType.FOREST and cell.fire_intensity == 0: |
|
|
cell.fire_intensity = fire_intensity |
|
|
fires_placed += 1 |
|
|
|
|
|
attempts += 1 |
|
|
|
|
|
def _generate_building_positions(self, count: int) -> list[tuple[int, int]]: |
|
|
""" |
|
|
Generate random building positions ensuring connectivity. |
|
|
|
|
|
Buildings grow as a connected cluster from a random starting point. |
|
|
At least 2 buildings will be adjacent (if count >= 2). |
|
|
""" |
|
|
if count <= 0: |
|
|
return [] |
|
|
|
|
|
positions = [] |
|
|
|
|
|
|
|
|
start_x = random.randint(2, self.width - 3) |
|
|
start_y = random.randint(2, self.height - 3) |
|
|
positions.append((start_x, start_y)) |
|
|
|
|
|
if count == 1: |
|
|
return positions |
|
|
|
|
|
|
|
|
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] |
|
|
|
|
|
while len(positions) < count: |
|
|
|
|
|
candidates = set() |
|
|
for (px, py) in positions: |
|
|
for dx, dy in directions: |
|
|
nx, ny = px + dx, py + dy |
|
|
|
|
|
if 0 <= nx < self.width and 0 <= ny < self.height: |
|
|
if (nx, ny) not in positions: |
|
|
candidates.add((nx, ny)) |
|
|
|
|
|
if not candidates: |
|
|
|
|
|
break |
|
|
|
|
|
|
|
|
new_pos = random.choice(list(candidates)) |
|
|
positions.append(new_pos) |
|
|
|
|
|
return positions |
|
|
|
|
|
def get_cell(self, x: int, y: int) -> Optional[Cell]: |
|
|
"""Get cell at position, returns None if out of bounds.""" |
|
|
if 0 <= x < self.width and 0 <= y < self.height: |
|
|
return self.grid[y][x] |
|
|
return None |
|
|
|
|
|
def get_fires(self) -> list[Fire]: |
|
|
"""Get list of all active fires.""" |
|
|
fires = [] |
|
|
for row in self.grid: |
|
|
for cell in row: |
|
|
if cell.is_on_fire(): |
|
|
fires.append(Fire(x=cell.x, y=cell.y, intensity=cell.fire_intensity)) |
|
|
return fires |
|
|
|
|
|
def generate_unit_id(self, unit_type: UnitType) -> str: |
|
|
"""Generate a unique unit ID.""" |
|
|
self._unit_counter += 1 |
|
|
prefix = "truck" if unit_type == UnitType.FIRE_TRUCK else "heli" |
|
|
return f"{prefix}_{self._unit_counter}" |
|
|
|
|
|
def add_unit(self, unit_type: UnitType, x: int, y: int, source: str) -> Optional[Unit]: |
|
|
"""Add a new unit to the world. Returns None if limit reached or position invalid.""" |
|
|
if len(self.units) >= self.max_units: |
|
|
return None |
|
|
|
|
|
if not (0 <= x < self.width and 0 <= y < self.height): |
|
|
return None |
|
|
|
|
|
|
|
|
cell = self.get_cell(x, y) |
|
|
if cell is None: |
|
|
return None |
|
|
|
|
|
|
|
|
if cell.fire_intensity > 0: |
|
|
return None |
|
|
|
|
|
|
|
|
if cell.cell_type == CellType.BUILDING: |
|
|
return None |
|
|
|
|
|
unit = Unit( |
|
|
id=self.generate_unit_id(unit_type), |
|
|
unit_type=unit_type, |
|
|
owner="player", |
|
|
x=x, |
|
|
y=y |
|
|
) |
|
|
self.units.append(unit) |
|
|
|
|
|
|
|
|
self.recent_events.append(Event( |
|
|
tick=self.tick, |
|
|
event_type="deploy_unit", |
|
|
details={ |
|
|
"by": source, |
|
|
"unit_type": unit_type.value, |
|
|
"x": x, |
|
|
"y": y |
|
|
} |
|
|
)) |
|
|
|
|
|
|
|
|
if len(self.recent_events) > 20: |
|
|
self.recent_events = self.recent_events[-20:] |
|
|
|
|
|
return unit |
|
|
|
|
|
def calculate_metrics(self): |
|
|
"""Recalculate global metrics (building damage ratio).""" |
|
|
total_buildings = 0 |
|
|
burning_buildings = 0 |
|
|
|
|
|
for row in self.grid: |
|
|
for cell in row: |
|
|
if cell.cell_type == CellType.BUILDING: |
|
|
total_buildings += 1 |
|
|
|
|
|
if cell.fire_intensity > 0: |
|
|
burning_buildings += 1 |
|
|
|
|
|
|
|
|
if total_buildings > 0: |
|
|
self.building_integrity = (total_buildings - burning_buildings) / total_buildings |
|
|
else: |
|
|
self.building_integrity = 1.0 |
|
|
|
|
|
|
|
|
self._total_buildings = total_buildings |
|
|
self._burning_buildings = burning_buildings |
|
|
|
|
|
|
|
|
self.forest_burn_ratio = 0.0 |
|
|
|
|
|
def to_dict(self) -> dict: |
|
|
"""Serialize world state to dictionary.""" |
|
|
return { |
|
|
"tick": self.tick, |
|
|
"status": self.status.value, |
|
|
"width": self.width, |
|
|
"height": self.height, |
|
|
"fires": [f.to_dict() for f in self.get_fires()], |
|
|
"units": [u.to_dict() for u in self.units], |
|
|
"building_integrity": round(self.building_integrity, 2), |
|
|
"forest_burn_ratio": round(self.forest_burn_ratio, 2), |
|
|
"recent_events": [e.to_dict() for e in self.recent_events[-5:]], |
|
|
"buildings": [{"x": x, "y": y} for x, y in self.building_positions], |
|
|
"max_units": self.max_units, |
|
|
} |
|
|
|
|
|
|