From e9c2a631226a18cf90bb18b9503fd5457016a7a4 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 17:33:35 +0200 Subject: [PATCH] fix(runner): disambiguation suffix skips ids already taken by explicit combatants --- src/pf1e_simulator/runner.py | 15 +++++++++++---- tests/test_runner.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/pf1e_simulator/runner.py b/src/pf1e_simulator/runner.py index 26714ff..2073d11 100644 --- a/src/pf1e_simulator/runner.py +++ b/src/pf1e_simulator/runner.py @@ -109,12 +109,16 @@ def _first_fit_anchor( def build_states(spec: EncounterSpec) -> list[CombatantState]: """Fresh states for one battle; duplicate combatant ids get -N suffixes. + The suffix keeps incrementing until the candidate id is not already + taken, so an explicit id like ``goblin-2`` never collides with a + disambiguated duplicate of ``goblin`` (the engine keys stats by id). + Combatants of a side fill the zone's cells in (row, col) order. A side with more combatants than zone cells, or a zone with no cells, is a spec error. Creatures larger than 1x1 occupy an n x n footprint anchored at their cell. """ states: list[CombatantState] = [] - seen: dict[str, int] = {} + taken: set[str] = set() grid = Grid.from_spec(spec.map) for side in spec.sides: cells = zone_cells(spec.map, side.zone) @@ -137,9 +141,12 @@ def build_states(spec: EncounterSpec) -> list[CombatantState]: f"{combatant.id!r} ({combatant.size}, {n}x{n})" ) raise ValueError(msg) - count = seen.get(combatant.id, 0) + 1 - seen[combatant.id] = count - cid = combatant.id if count == 1 else f"{combatant.id}-{count}" + cid = combatant.id + suffix = 2 + while cid in taken: + cid = f"{combatant.id}-{suffix}" + suffix += 1 + taken.add(cid) effective = ( combatant if cid == combatant.id diff --git a/tests/test_runner.py b/tests/test_runner.py index a25c0d8..70d38d3 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -178,6 +178,30 @@ def test_build_states_disambiguates_duplicate_ids() -> None: assert all(s.hp == s.combatant.hp_max for s in states) +def test_build_states_suffix_skips_explicitly_taken_ids() -> None: + """A -N suffix must never land on an id another combatant already owns. + + Two "gob" docs plus an explicit "gob-2" used to yield two states named + "gob-2" (the per-base counter ignored explicit ids), merging their + attrition in the engine's by-id stats keys. + """ + spec = EncounterSpec( + map=make_map(), + sides=( + Side( + name="players", + combatants=(make_combatant("gob"), make_combatant("gob-2")), + zone="A", + ), + Side(name="monsters", combatants=(make_combatant("gob"),), zone="B"), + ), + ) + states = build_states(spec) + ids = [s.combatant.id for s in states] + assert ids == ["gob", "gob-2", "gob-3"] + assert len(set(ids)) == 3 + + def test_build_states_rejects_too_many_combatants_for_zone() -> None: """A zone with fewer cells than combatants is a spec error, not a runtime surprise.""" spec = EncounterSpec(