Previously two distance-only clues whose circles didn't actually intersect (real typewriter data isn't perfectly consistent, a misplaced spotter or an off-by-a-bit distance reading is enough) just gave up entirely, even when they were nearly touching. solver.closest_compromise_point() picks the midpoint between each circle's point facing the other, the standard notion of the closest approach between two circles. It stays well-behaved even when the centers are nearly coincident, unlike projecting along the center line the way a real intersection's formula does, which diverges as the centers get close while the radii stay far apart, exactly the near-coincident case this is for. The result is flagged rather than treated as a clean fix: Location gained a field, set whenever solve_location() had to use this fallback, cleared by any subsequent coord.setter call (manual or solver), shown in the entity's status label and toasted after a manual clue edit. Persisted through save/load.
630 lines
24 KiB
Python
630 lines
24 KiB
Python
"""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)
|