FeNigma/src/fenigma/models.py
Dominik Roth 980be86908 Fall back to a compromise point when two distance circles don't cross
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.
2026-08-08 23:56:24 +02:00

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)