Two related changes:
1. TargetType.HOSTILE_ARTILLERY/HOSTILE_TANK renamed to ARTILLERY/TANK,
dropping the baked-in hostility assumption from the type name
itself (a type describes the unit kind now, not an allegiance).
Migration entries added for both, plus the already-existing
COASTAL_BATTERY one, so old save files still load.
2. A friendly contact ('FriendlyTank#1:', detected by stripping a
leading 'Friendly'/'Hostile' word off the type word before matching
it, see ocr.py's _resolve_target_type()) is NOT a Target with a flag
flipped, it's tracked as a new, entirely separate Ally
(Board.allies), with its own id namespace: an ally Tank#1 and a
hostile Target Tank#1 are two unrelated things that happen to share
an id, not a collision (verified directly, see the rendered
screenshot both coexisting). Ally intentionally has none of Target's
firing-relevant fields (shell/powder_charges/assignment/alive),
allies are never fired on. 'Hostile' and no prefix at all both mean
a regular (non-ally) Target, not-ally is the default.
Wired through: Board.add_ally/remove_ally, placed_entities_all()/
ambiguous_entities_all() (new 'ally' category, cyan on the map,
distinct from every other category's color), solver.resolve_board()
(allies' own clues resolve too), find_by_name() (an ally can be a
clue reference target), save/load round-trip, a new 'Allies' header
popover mirroring Targets' (position/hide/geo-overlay/remove, no
shell/charge/alive controls), and ParsedInfo.allies as a same-shaped
but separate dict from ParsedInfo.targets, merged by a new
_merge_allies() alongside _merge_targets() in _merge_all().
Verified: full test suite (added a dedicated OCR test for the Friendly/
Hostile/bare-prefix routing), a GTK smoke test round-tripping an ally
through save/load and the popover build, and a rendered screenshot
showing an ally Tank#1 and a hostile Target Tank#1 both on the map at
once with the same id, distinct colors, no collision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
107 lines
4.6 KiB
Python
107 lines
4.6 KiB
Python
"""Regression coverage for solver.py's geometric resolution."""
|
|
from fenigma import solver
|
|
from fenigma.models import Board, Clue, Coord, Location, TargetType
|
|
|
|
|
|
def _board_with_spotters(*coords):
|
|
board = Board()
|
|
for i, coord in enumerate(coords, start=1):
|
|
board.add_spotter(coord, i)
|
|
return board
|
|
|
|
|
|
def test_bearing_and_distance_from_one_reference_resolves_directly():
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0))
|
|
target = board.add_target(TargetType.TANK, id_="1")
|
|
target.location = Location.from_desc("x", [Clue(reference="Spotter#1", bearing_deg=90.0, distance_km=3.0)])
|
|
solver.resolve_board(board)
|
|
assert target.coord is not None
|
|
|
|
|
|
def test_two_bearings_resolve_via_ray_ray_intersection():
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0))
|
|
target = board.add_target(TargetType.TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", bearing_deg=90.0),
|
|
Clue(reference="Spotter#2", bearing_deg=180.0),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert target.coord is not None
|
|
|
|
|
|
def test_two_distances_that_actually_cross_are_ambiguous_not_resolved():
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("P", 5, 0, 0))
|
|
target = board.add_target(TargetType.TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=7.33),
|
|
Clue(reference="Spotter#2", distance_km=3.43),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert target.coord is None
|
|
assert len(target.location.potential_coords) == 2
|
|
|
|
|
|
def test_nested_distance_circles_fall_back_to_compromise_point():
|
|
"""Two distances that don't actually cross (one circle nested inside
|
|
the other, given how close the two spotters are) used to just give
|
|
up entirely. closest_compromise_point() finds a bounded, sane
|
|
stand-in instead, flagged via Location.note rather than treated as
|
|
a clean resolution."""
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("J", 5, 1, 0))
|
|
target = board.add_target(TargetType.TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=7.33),
|
|
Clue(reference="Spotter#2", distance_km=3.43),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert target.coord is not None
|
|
assert target.location.note is not None
|
|
assert "approximate" in target.location.note
|
|
|
|
|
|
def test_toleranced_bearing_is_never_used_to_triangulate():
|
|
"""A compass-word bearing (bearing_tolerance_deg set) names a whole
|
|
sector, solve_location() must never use it as if it were a precise
|
|
ray, even when it's the only bearing-shaped clue available."""
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0))
|
|
target = board.add_target(TargetType.TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=3.0),
|
|
Clue(reference="Spotter#2", bearing_deg=270.0, bearing_tolerance_deg=11.25),
|
|
])
|
|
solver.resolve_board(board)
|
|
# only one usable (distance-only) clue remains once the toleranced
|
|
# bearing is excluded, not enough to resolve anything on its own.
|
|
assert target.coord is None
|
|
assert not target.location.potential_coords
|
|
|
|
|
|
def test_explain_unresolved_ignores_a_toleranced_bearing_too():
|
|
"""A real inconsistency: solve_location() already excluded a
|
|
toleranced bearing from triangulation, but explain_unresolved()'s
|
|
own bearings list didn't, so it could describe a toleranced-only
|
|
bearing as if it were usable geometry. Only a distance clue is left
|
|
once the toleranced bearing is (correctly) excluded, one clue alone
|
|
is never inconsistent with itself, so there's nothing to explain."""
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0))
|
|
target = board.add_target(TargetType.TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=3.0),
|
|
Clue(reference="Spotter#2", bearing_deg=270.0, bearing_tolerance_deg=11.25),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert solver.explain_unresolved(target.location, board) is None
|
|
|
|
|
|
def test_manual_coord_override_clears_a_stale_note():
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("J", 5, 1, 0))
|
|
target = board.add_target(TargetType.TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=7.33),
|
|
Clue(reference="Spotter#2", distance_km=3.43),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert target.location.note is not None
|
|
target.coord = Coord("A", 1, 0, 0)
|
|
assert target.location.note is None
|