feat(combat): add charge action (straight-line 2x speed, +2 attack, -2 AC)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 004d6f1c2b
commit 7543c55ed4
3 changed files with 306 additions and 27 deletions
+75 -6
View File
@@ -367,8 +367,8 @@ def test_scripted_2v2_transcript() -> None:
"round 1 orc-2: gob-2 down",
"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 2 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)",
"round 2 orc-2: charge (3,4)->(2,4)->(1,3)",
"round 2 orc-2: charge vs gob-1 d20=15+7=22 AC 16 -> HIT 9 damage (6->-3)",
"round 2 orc-2: gob-1 down",
"battle over: monsters win in 2 rounds",
)
@@ -397,7 +397,7 @@ def test_same_seed_replay_is_identical() -> None:
assert first.rounds == second.rounds
def test_default_policy_moves_toward_enemy() -> None:
def test_default_policy_charges_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))]
@@ -406,9 +406,9 @@ def test_default_policy_moves_toward_enemy() -> None:
assert result.transcript == (
"initiative: gob d20=10+0=10",
"initiative: orc d20=9+0=9",
"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 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)",
"round 1 gob: charge vs orc d20=12+4=16 AC 13 -> HIT 3 damage (6->3)",
"round 1 orc: short sword vs gob d20=11+2=13 AC 11 -> 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",
@@ -1076,3 +1076,72 @@ def test_two_guards_each_one_aoo() -> None:
"round 1 dest: wait",
"battle over: draw after 1 rounds",
)
# --------------------------------------------------------------------------- #
# Charge (CRB: straight-line 2x speed, +2 attack, -2 AC, single melee attack) #
# --------------------------------------------------------------------------- #
def _gob_charges_orc_attacks(
engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "gob":
return (Action(kind="charge", target_id="orc"),)
return (Action(kind="attack", target_id="gob"),)
def test_charge_straight_line_and_bonus() -> None:
"""Charge moves in a straight line and attacks at +2; enemy retaliates."""
gob = make_combatant("gob", hp=10, ac=15, attack_bonus=2, speed=30)
orc = make_combatant("orc", hp=10, ac=15, attack_bonus=0, speed=0)
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))]
engine = CombatEngine(
ScriptedRng([10, 9, 12, 3, 11, 2]),
make_grid(), states, round_cap=1, policy=_gob_charges_orc_attacks,
)
result = engine.run()
assert result.transcript == (
"initiative: gob d20=10+0=10",
"initiative: orc d20=9+0=9",
"round 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)",
"round 1 gob: charge vs orc d20=12+4=16 AC 15 -> HIT 3 damage (10->7)",
"round 1 orc: short sword vs gob d20=11+0=11 AC 13 -> MISS",
"battle over: draw after 1 rounds",
)
def test_charge_ac_penalty_applies() -> None:
"""The -2 AC penalty from charging applies to the enemy's attack the same round."""
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30)
orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0)
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 4))]
engine = CombatEngine(
ScriptedRng([10, 9, 12, 3, 11, 2]),
make_grid(), states, round_cap=1, policy=_gob_charges_orc_attacks,
)
result = engine.run()
assert result.transcript == (
"initiative: gob d20=10+0=10",
"initiative: orc d20=9+0=9",
"round 1 gob: charge (1,1)->(1,2)->(0,3)",
"round 1 gob: charge vs orc d20=12+4=16 AC 15 -> HIT 3 damage (20->17)",
"round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (20->18)",
"battle over: draw after 1 rounds",
)
def test_charge_too_close_full_attacks_instead() -> None:
"""When adjacent (cannot charge minimum 2 squares), default_policy full-attacks."""
gob = make_combatant("gob", hp=10, ac=15, attack_bonus=2, speed=30)
orc = make_combatant("orc", hp=10, ac=15, attack_bonus=0, speed=0)
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 2))]
engine = make_engine([10, 9, 12, 3], states, round_cap=1)
result = engine.run()
assert result.transcript == (
"initiative: gob d20=10+0=10",
"initiative: orc d20=9+0=9",
"round 1 gob: short sword vs orc d20=12+2=14 AC 15 -> MISS",
"round 1 orc: short sword vs gob d20=3+0=3 AC 15 -> MISS",
"battle over: draw after 1 rounds",
)