"""Board data model: coordinates and the entities placed on the map. Coordinate system (matches the in-game map): Large grid: X in A..T (20 cols), Y in 1..10 (10 rows), row 1 at the bottom. Sub-grid within a cell: x, y in 0..9. Not everything is known as an absolute grid coordinate. The typewriter also hands out *relative* fixes, "Bearing 293 from Alpha", "Distance 13.59km from Spotter#1", that only resolve to a Coord once whatever they're relative to is itself known, and that can chain (AmmoCache#3 is relative to AmmoCache#2, which is itself relative to Alpha and Spotter#1). A Location captures both cases: either a resolved Coord, or a raw description plus the Clues parsed out of it, each Clue naming another entity, so the Clues across the board form a dependency graph that solver.py walks (topologically, anchored at entities with a resolved Coord) to work out everything else. This module just defines the shape. """ from __future__ import annotations import string from dataclasses import dataclass, field from enum import Enum from .shells import Shell LARGE_X = string.ascii_uppercase[:20] # A..T LARGE_Y = range(1, 11) # 1..10 NATO_ALPHABET = [ "Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliett", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu", ] class TargetType(Enum): SUPPLY_CACHE = "Supply Cache" UNKNOWN = "Target" # generic contact, spotted but not yet identified FDC = "FDC" # Fire Direction Center, coordinates enemy counter-battery fire INFANTRY = "Infantry" # hostile ground troops MECHANIZED = "Mechanized" # hostile armored/vehicle unit HOSTILE_ARTILLERY = "Hostile Artillery" HOSTILE_TANK = "Hostile Tank" STRIKE = "Strike" # a planned impact point, not an enemy contact @property def short(self) -> str: """Compact form used in item names, e.g. 'SupplyCache' / 'FDC'.""" return self.value.replace(" ", "") # Renamed/removed enum members, for loading save files written before the # rename. AMMO_CACHE turned out to be a misreading of the game's actual # "SupplyCache" name and was dropped in favor of it. _TARGET_TYPE_MIGRATIONS = {"AMMO_CACHE": "SUPPLY_CACHE"} def _migrate_target_type(name: str) -> TargetType: return TargetType[_TARGET_TYPE_MIGRATIONS.get(name, name)] @dataclass(frozen=True) class Coord: X: str # 'A'..'T' Y: int # 1..10 x: int # 0..9 y: int # 0..9 def __post_init__(self) -> None: if self.X not in LARGE_X: raise ValueError(f"X must be one of A..T, got {self.X!r}") if self.Y not in LARGE_Y: raise ValueError(f"Y must be 1..10, got {self.Y!r}") if not (0 <= self.x <= 9): raise ValueError(f"x must be 0..9, got {self.x!r}") if not (0 <= self.y <= 9): raise ValueError(f"y must be 0..9, got {self.y!r}") def as_fraction(self) -> tuple[float, float]: """Position in board units: col in [0,20], row in [0,10], sub-cell centered.""" col = LARGE_X.index(self.X) + (self.x + 0.5) / 10 row = (self.Y - 1) + (self.y + 0.5) / 10 return col, row def label(self) -> str: return f"{self.X}{self.Y} {self.x}:{self.y}" def to_dict(self) -> dict: return {"X": self.X, "Y": self.Y, "x": self.x, "y": self.y} @classmethod def from_dict(cls, d: dict) -> "Coord": return cls(X=d["X"], Y=d["Y"], x=d["x"], y=d["y"]) def _coord_to_dict(coord: Coord | None) -> dict | None: return coord.to_dict() if coord is not None else None def _coord_from_dict(d: dict | None) -> Coord | None: return Coord.from_dict(d) if d is not None else None @dataclass(frozen=True) class Clue: """One relative-position reading: bearing and/or distance from another named entity (e.g. 'Spotter#1', 'Alpha', 'AmmoCache#2'). At least one of bearing/distance is set; a single clue with both fully determines a position given the reference, two clues (from different references) need triangulating.""" reference: str bearing_deg: float | None = None distance_km: float | None = None def to_dict(self) -> dict: return { "reference": self.reference, "bearing_deg": self.bearing_deg, "distance_km": self.distance_km, } @classmethod def from_dict(cls, d: dict) -> "Clue": return cls( reference=d["reference"], bearing_deg=d.get("bearing_deg"), distance_km=d.get("distance_km"), ) @dataclass class Location: """Where something is. `coord` is the resolved absolute position, known directly for entities given in grid form (Nest, Spotters), or filled in later by a solver once every clue's reference is resolved. `desc_raw` + `clues` hold a relative description before/instead of that resolution. `potential_coords` holds a solver result that was genuinely ambiguous (e.g. a bearing ray crossing a distance circle twice), shown on the map as candidates, never treated as resolved and never used to resolve anything else. `note` is set instead when the solver had to fall back to an approximate compromise point because the clues didn't quite geometrically agree (see solver.closest_compromise_point()), unlike potential_coords this *is* a single resolved coord, just flagged as not fully trustworthy.""" coord: Coord | None = None desc_raw: str | None = None clues: list[Clue] = field(default_factory=list) potential_coords: list[Coord] = field(default_factory=list) note: str | None = None @property def is_resolved(self) -> bool: return self.coord is not None @property def depends_on(self) -> list[str]: """Names of other entities this location's clues are relative to.""" return [c.reference for c in self.clues] @classmethod def from_coord(cls, coord: Coord | None) -> "Location": return cls(coord=coord) @classmethod def from_desc(cls, raw: str, clues: list[Clue] | None = None) -> "Location": return cls(desc_raw=raw, clues=list(clues) if clues else []) def to_dict(self) -> dict: return { "coord": _coord_to_dict(self.coord), "desc_raw": self.desc_raw, "clues": [c.to_dict() for c in self.clues], "potential_coords": [c.to_dict() for c in self.potential_coords], "note": self.note, } @classmethod def from_dict(cls, d: dict | None) -> "Location": if not d: return cls() return cls( coord=_coord_from_dict(d.get("coord")), desc_raw=d.get("desc_raw"), clues=[Clue.from_dict(c) for c in d.get("clues", [])], potential_coords=[Coord.from_dict(c) for c in d.get("potential_coords", [])], note=d.get("note"), ) def _as_location(value: "Location | Coord | None") -> Location: return value if isinstance(value, Location) else Location.from_coord(value) @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class Nest: location: Location = field(default_factory=Location) name: str = "Nest" hidden: bool = False show_geo_desc: bool = False @property def coord(self) -> Coord | None: return self.location.coord @coord.setter def coord(self, value: Coord | None) -> None: # Set the resolved position without clobbering any desc_raw/clues # already stored on this Location (provenance: how it got there). # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), # so it just goes inert rather than being deleted. `note` DOES get # cleared: it's the solver's "this was an approximate compromise, # not a real fix" flag, and any new coord here, solver-derived or # a manual override, invalidates whatever note was there before # (resolve_board() re-attaches a fresh one right after, if this # new coord is itself another approximate fix). self.location.coord = value self.location.note = None @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class Spotter: id: int location: Location = field(default_factory=Location) hidden: bool = False show_geo_desc: bool = False @property def name(self) -> str: return f"Spotter#{self.id}" @property def coord(self) -> Coord | None: return self.location.coord @coord.setter def coord(self, value: Coord | None) -> None: # Set the resolved position without clobbering any desc_raw/clues # already stored on this Location (provenance: how it got there). # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), # so it just goes inert rather than being deleted. `note` DOES get # cleared: it's the solver's "this was an approximate compromise, # not a real fix" flag, and any new coord here, solver-derived or # a manual override, invalidates whatever note was there before # (resolve_board() re-attaches a fresh one right after, if this # new coord is itself another approximate fix). self.location.coord = value self.location.note = None @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class ReferencePoint: rp_name: str location: Location = field(default_factory=Location) hidden: bool = False show_geo_desc: bool = False @property def name(self) -> str: return self.rp_name @property def coord(self) -> Coord | None: return self.location.coord @coord.setter def coord(self, value: Coord | None) -> None: # Set the resolved position without clobbering any desc_raw/clues # already stored on this Location (provenance: how it got there). # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), # so it just goes inert rather than being deleted. `note` DOES get # cleared: it's the solver's "this was an approximate compromise, # not a real fix" flag, and any new coord here, solver-derived or # a manual override, invalidates whatever note was there before # (resolve_board() re-attaches a fresh one right after, if this # new coord is itself another approximate fix). self.location.coord = value self.location.note = None @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class Target: type: TargetType id: str location: Location = field(default_factory=Location) hidden: bool = False show_geo_desc: bool = False alive: bool = True # None = use the computed minimum for the current distance; only set # once the user picks a value explicitly (see firing_panel.py). powder_charges: int | None = None # None = use effective_shell's type-based default; only set once the # user picks one explicitly. shell: Shell | None = None # Which gun this target is assigned to, if any, "unassigned" | "left" | "right". assignment: str = "unassigned" @property def name(self) -> str: return f"{self.type.short}#{self.id}" _AP_DEFAULT_TYPES = (TargetType.FDC, TargetType.SUPPLY_CACHE) @property def effective_shell(self) -> Shell: if self.shell is not None: return self.shell if self.type in self._AP_DEFAULT_TYPES: return Shell.AP if self.type is TargetType.STRIKE: return Shell.HCHE return Shell.HE @property def coord(self) -> Coord | None: return self.location.coord @coord.setter def coord(self, value: Coord | None) -> None: # Set the resolved position without clobbering any desc_raw/clues # already stored on this Location (provenance: how it got there). # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), # so it just goes inert rather than being deleted. `note` DOES get # cleared: it's the solver's "this was an approximate compromise, # not a real fix" flag, and any new coord here, solver-derived or # a manual override, invalidates whatever note was there before # (resolve_board() re-attaches a fresh one right after, if this # new coord is itself another approximate fix). self.location.coord = value self.location.note = None @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class ScoutFlight: """A planned scout overflight: a rectangle anchored on a large grid square's center and oriented along a bearing, both chosen by clicking the map (see GridCanvas's scout-flight placement mode and solver.scout_flight_corners for the actual rectangle geometry). Unlike Nest/Spotter/RP/Target, its position isn't a single Coord, a (col, row) km pair is the natural representation since the anchor is a cell center, not necessarily hittable by the integer sub-grid.""" id: int center: tuple[float, float] # (col, row) in board-units km bearing_deg: float hidden: bool = False @property def name(self) -> str: return f"ScoutFlight#{self.id}" SAVE_FORMAT_VERSION = 3 class Board: """Holds everything placed on the map and the naming rules for new items.""" def __init__(self) -> None: self.nest = Nest() self.spotters: list[Spotter] = [] self.reference_points: list[ReferencePoint] = [] self.targets: list[Target] = [] self.scout_flights: list[ScoutFlight] = [] self._spotter_seq = 0 self._scout_flight_seq = 0 # -- lookup by name, for resolving clue references ---------------------- def find_by_name(self, name: str): if self.nest.name == name: return self.nest for sp in self.spotters: if sp.name == name: return sp for rp in self.reference_points: if rp.name == name: return rp for t in self.targets: if t.name == name: return t return None # -- spotters -------------------------------------------------------- def next_spotter_id(self) -> int: return self._spotter_seq + 1 def add_spotter(self, location: Location | Coord | None = None, id_: int | None = None) -> Spotter: if id_ is None: id_ = self.next_spotter_id() self._spotter_seq = max(self._spotter_seq, id_) sp = Spotter(id=id_, location=_as_location(location)) self.spotters.append(sp) return sp def remove_spotter(self, spotter: Spotter) -> None: self.spotters.remove(spotter) # -- reference points ------------------------------------------------- def add_reference_point( self, location: Location | Coord | None = None, name: str | None = None ) -> ReferencePoint: if name is None: used = {rp.rp_name for rp in self.reference_points} name = next(n for n in NATO_ALPHABET if n not in used) rp = ReferencePoint(rp_name=name, location=_as_location(location)) self.reference_points.append(rp) return rp def remove_reference_point(self, rp: ReferencePoint) -> None: self.reference_points.remove(rp) # -- targets ------------------------------------------------------------ def add_target( self, type_: TargetType, location: Location | Coord | None = None, id_: str | None = None, ) -> Target: if not id_: used = {t.id for t in self.targets if t.type == type_} id_ = next(c for c in string.ascii_uppercase if c not in used) t = Target(type=type_, id=id_, location=_as_location(location)) self.targets.append(t) return t def remove_target(self, target: Target) -> None: self.targets.remove(target) # -- scout flights -------------------------------------------------- def next_scout_flight_id(self) -> int: return self._scout_flight_seq + 1 def add_scout_flight( self, center: tuple[float, float], bearing_deg: float, id_: int | None = None ) -> ScoutFlight: if id_ is None: id_ = self.next_scout_flight_id() self._scout_flight_seq = max(self._scout_flight_seq, id_) sf = ScoutFlight(id=id_, center=center, bearing_deg=bearing_deg) self.scout_flights.append(sf) return sf def remove_scout_flight(self, sf: ScoutFlight) -> None: self.scout_flights.remove(sf) # -- reset ---------------------------------------------------------- def clear(self) -> None: """Drop everything: Nest position, spotters, reference points, targets, scout flights. Used by the "clear board" action for a fresh start without restarting the app.""" self.nest = Nest() self.spotters.clear() self.reference_points.clear() self.targets.clear() self.scout_flights.clear() self._spotter_seq = 0 self._scout_flight_seq = 0 def reorder_target(self, target: Target, new_index: int) -> None: """Manual drag-order: `self.targets`' list order is itself the persisted order (saved/loaded as a plain JSON array), and is what the firing panel's sort falls back to as a tie-break once its other sort options are applied.""" self.targets.remove(target) new_index = max(0, min(new_index, len(self.targets))) self.targets.insert(new_index, target) # -- drawing helper ---------------------------------------------------- def placed_entities(self): """Yield (category, obj) for everything with a *resolved* coord that isn't hidden. Entities that only have a relative description (or only ambiguous potential_coords) aren't drawable this way. See ambiguous_entities(). See placed_entities_all()/ambiguous_entities_all() for the unfiltered versions (needed so the map can still show a hidden entity, darkened, while it's selected).""" for category, obj in self.placed_entities_all(): if not obj.hidden: yield category, obj def placed_entities_all(self): """Like placed_entities(), but includes hidden entities too.""" if self.nest.coord is not None: yield "nest", self.nest for sp in self.spotters: if sp.coord is not None: yield "spotter", sp for rp in self.reference_points: if rp.coord is not None: yield "rp", rp for t in self.targets: if t.coord is not None: yield "target", t def ambiguous_entities(self): """Yield (category, obj) for RPs/Targets that resolved to two or more equally-valid potential_coords instead of one definitive coord.""" for category, obj in self.ambiguous_entities_all(): if not obj.hidden: yield category, obj def ambiguous_entities_all(self): """Like ambiguous_entities(), but includes hidden entities too.""" for rp in self.reference_points: if rp.coord is None and rp.location.potential_coords: yield "rp", rp for t in self.targets: if t.coord is None and t.location.potential_coords: yield "target", t # -- save / load --------------------------------------------------------- def to_dict(self) -> dict: return { "version": SAVE_FORMAT_VERSION, "nest": { "location": self.nest.location.to_dict(), "hidden": self.nest.hidden, "show_geo_desc": self.nest.show_geo_desc, }, "spotters": [ { "id": sp.id, "location": sp.location.to_dict(), "hidden": sp.hidden, "show_geo_desc": sp.show_geo_desc, } for sp in self.spotters ], "reference_points": [ { "name": rp.rp_name, "location": rp.location.to_dict(), "hidden": rp.hidden, "show_geo_desc": rp.show_geo_desc, } for rp in self.reference_points ], "targets": [ { "type": t.type.name, "id": t.id, "location": t.location.to_dict(), "hidden": t.hidden, "show_geo_desc": t.show_geo_desc, "alive": t.alive, "powder_charges": t.powder_charges, "shell": t.shell.name if t.shell is not None else None, "assignment": t.assignment, } for t in self.targets ], "scout_flights": [ { "id": sf.id, "center": {"col": sf.center[0], "row": sf.center[1]}, "bearing_deg": sf.bearing_deg, "hidden": sf.hidden, } for sf in self.scout_flights ], } def load_from_dict(self, data: dict) -> None: """Replace all current state with what's in `data` (in place, so anything holding a reference to this Board keeps working).""" nest_data = data.get("nest", {}) self.nest = Nest( location=Location.from_dict(nest_data.get("location")), hidden=nest_data.get("hidden", False), show_geo_desc=nest_data.get("show_geo_desc", False), ) self.spotters = [ Spotter( id=sp["id"], location=Location.from_dict(sp.get("location")), hidden=sp.get("hidden", False), show_geo_desc=sp.get("show_geo_desc", False), ) for sp in data.get("spotters", []) ] self._spotter_seq = max((sp.id for sp in self.spotters), default=0) self.reference_points = [ ReferencePoint( rp_name=rp["name"], location=Location.from_dict(rp.get("location")), hidden=rp.get("hidden", False), show_geo_desc=rp.get("show_geo_desc", False), ) for rp in data.get("reference_points", []) ] self.targets = [ Target( type=_migrate_target_type(t["type"]), id=t["id"], location=Location.from_dict(t.get("location")), hidden=t.get("hidden", False), show_geo_desc=t.get("show_geo_desc", False), alive=t.get("alive", True), powder_charges=t.get("powder_charges"), shell=Shell[t["shell"]] if t.get("shell") else None, assignment=t.get("assignment", "unassigned"), ) for t in data.get("targets", []) ] self.scout_flights = [ ScoutFlight( id=sf["id"], center=(sf["center"]["col"], sf["center"]["row"]), bearing_deg=sf["bearing_deg"], hidden=sf.get("hidden", False), ) for sf in data.get("scout_flights", []) ] self._scout_flight_seq = max((sf.id for sf in self.scout_flights), default=0)