feat(combat): activate standard+move economy (full-speed movement, move-then-attack)
- default_policy returns (move, attack) when target out of reach: full-speed move along the Dijkstra path (up to speed cells, 5-10-5 diagonals, stops adjacent to nearest enemy), then attack if a weapon is usable; returns (attack,) when already in range. - _move logs the full path in one line (start)->(step1)->...->(end); silent on empty path. _execute is silent on invalid attacks/moves (no spurious wait log); run() adds a wait line only when a combatant's turn produced no transcript line. - Extract _take_turn helper from run() to keep cyclomatic complexity <= 10. - Module docstring updated for activated economy. - 4 pinned transcripts regenerated (move-then-attack shifts combat pacing): test_default_policy_moves_toward_enemy, test_scripted_2v2_transcript (monsters win in R2 instead of R3 — orc-2 moves + attacks same turn), test_ranged_no_line_of_effect_moves_around_wall (archer rounds wall and shoots in 1 turn), test_ranged_weapon_unusable_beyond_maximum_range (archer moves 6 cells then attacks at -14 penalty). - README: demo numbers refreshed (1v2: 49.6%->22.0%, 11.4->5.2 rounds; 3v2: 97.8%->91.4%, 6.9->6.0 rounds), rules and non-modeled sections updated for the activated economy.
This commit is contained in:
+32
-26
@@ -348,13 +348,12 @@ def test_scripted_2v2_transcript() -> None:
|
||||
14, 1, # R1 gob-2 hits orc-2 for 1
|
||||
17, 2, 3, # R1 orc-2 hits gob-2 for 2+3+4=9
|
||||
19, 11, 2, # R2 gob-1 crits orc-1: 19 threatens, 11 confirms, 2*2=4
|
||||
7, # R3 gob-1 misses orc-2
|
||||
15, 1, 4, # R3 orc-2 hits gob-1 for 1+4+4=9
|
||||
15, 1, 4, # R2 orc-2 hits gob-1 for 1+4+4=9
|
||||
]
|
||||
engine = make_engine(queue, states)
|
||||
result = engine.run()
|
||||
assert result.winner == "monsters"
|
||||
assert result.rounds == 3
|
||||
assert result.rounds == 2
|
||||
assert result.transcript == (
|
||||
"initiative: gob-1 d20=15+6=21",
|
||||
"initiative: orc-1 d20=14+0=14",
|
||||
@@ -368,10 +367,9 @@ def test_scripted_2v2_transcript() -> None:
|
||||
"round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)",
|
||||
"round 2 gob-1: orc-1 down",
|
||||
"round 2 orc-2: move (3,4)->(2,3)",
|
||||
"round 3 gob-1: short sword vs orc-2 d20=7+2=9 AC 13 -> MISS",
|
||||
"round 3 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)",
|
||||
"round 3 orc-2: gob-1 down",
|
||||
"battle over: monsters win in 3 rounds",
|
||||
"round 2 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)",
|
||||
"round 2 orc-2: gob-1 down",
|
||||
"battle over: monsters win in 2 rounds",
|
||||
)
|
||||
assert result.stats["gob-1"] == CombatantStats(hits=2, crits=1, damage_dealt=7, damage_taken=9)
|
||||
assert result.stats["gob-2"] == CombatantStats(hits=1, crits=0, damage_dealt=1, damage_taken=9)
|
||||
@@ -402,15 +400,16 @@ def test_default_policy_moves_toward_enemy() -> None:
|
||||
gob = make_combatant("gob", speed=30)
|
||||
orc = make_combatant("orc", speed=0)
|
||||
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))]
|
||||
engine = make_engine([10, 9], states, round_cap=2)
|
||||
engine = make_engine([10, 9, 12, 3, 11, 2, 10, 1], states, round_cap=2)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: gob d20=10+0=10",
|
||||
"initiative: orc d20=9+0=9",
|
||||
"round 1 gob: move (1,1)->(0,2)",
|
||||
"round 1 orc: wait",
|
||||
"round 2 gob: move (0,2)->(0,3)",
|
||||
"round 2 orc: wait",
|
||||
"round 1 gob: move (1,1)->(0,2)->(0,3)->(0,4)",
|
||||
"round 1 gob: short sword vs orc d20=12+2=14 AC 13 -> HIT 3 damage (6->3)",
|
||||
"round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (6->4)",
|
||||
"round 2 gob: short sword vs orc d20=10+2=12 AC 13 -> MISS",
|
||||
"round 2 orc: short sword vs gob d20=1+2=3 AC 13 -> MISS",
|
||||
"battle over: draw after 2 rounds",
|
||||
)
|
||||
|
||||
@@ -527,7 +526,7 @@ def test_melee_attack_gets_cover_bonus_across_wall_corner() -> None:
|
||||
|
||||
|
||||
def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
||||
"""An archer without line of effect cannot shoot; the policy moves until it gains sight."""
|
||||
"""An archer without line of effect moves around the wall at full speed, then shoots."""
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||
@@ -537,21 +536,21 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
||||
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||
target = make_combatant("target", speed=0)
|
||||
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
||||
engine = CombatEngine(ScriptedRng([10, 9, 11, 3, 5, 14, 2, 8]), grid, states, round_cap=4)
|
||||
rng = ScriptedRng([10, 9, 15, 3, 10, 12, 2, 8, 14, 1])
|
||||
engine = CombatEngine(rng, grid, states, round_cap=4)
|
||||
result = engine.run()
|
||||
assert result.winner is None
|
||||
assert result.winner == "players"
|
||||
assert result.transcript == (
|
||||
"initiative: archer d20=10+0=10",
|
||||
"initiative: target d20=9+0=9",
|
||||
"round 1 archer: move (0,1)->(1,2)",
|
||||
"round 1 target: wait",
|
||||
"round 2 archer: short bow vs target d20=11+4=15 AC 13 -> HIT 3 damage (6->3)",
|
||||
"round 2 target: wait",
|
||||
"round 3 archer: short bow vs target d20=5+4=9 AC 13 -> MISS",
|
||||
"round 3 target: wait",
|
||||
"round 4 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 2 damage (3->1)",
|
||||
"round 4 target: wait",
|
||||
"battle over: draw after 4 rounds",
|
||||
"round 1 archer: move (0,1)->(1,2)->(0,3)",
|
||||
"round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
|
||||
"round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS",
|
||||
"round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)",
|
||||
"round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS",
|
||||
"round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)",
|
||||
"round 3 archer: target down",
|
||||
"battle over: players win in 3 rounds",
|
||||
)
|
||||
|
||||
|
||||
@@ -660,10 +659,17 @@ def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
|
||||
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
|
||||
target = make_combatant("target", speed=0)
|
||||
states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 21))]
|
||||
engine = CombatEngine(ScriptedRng([10, 9]), grid, states, round_cap=1)
|
||||
engine = CombatEngine(ScriptedRng([10, 9, 8]), grid, states, round_cap=1)
|
||||
assert engine.weapon_for(states[0], states[1]) is None
|
||||
result = engine.run()
|
||||
assert result.transcript[2] == "round 1 archer: move (0,0)->(0,1)"
|
||||
assert result.transcript == (
|
||||
"initiative: archer d20=10+0=10",
|
||||
"initiative: target d20=9+0=9",
|
||||
"round 1 archer: move (0,0)->(0,1)->(0,2)->(0,3)->(0,4)->(0,5)->(0,6)",
|
||||
"round 1 archer: short bow vs target d20=8+4-14=-2 AC 13 -> MISS",
|
||||
"round 1 target: wait",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_ranged_soft_cover_from_creature_between() -> None:
|
||||
|
||||
Reference in New Issue
Block a user