diff --git a/servers/engine/combat.py b/servers/engine/combat.py index a4db4e07..d7774666 100644 --- a/servers/engine/combat.py +++ b/servers/engine/combat.py @@ -622,6 +622,14 @@ def tick_round_effects(ch: Character) -> list[str]: if eff.repeat_save is not None: surviving.append(eff) continue + # A TURN-ANCHORED advantage marker (#194 follow-up: Guiding Bolt's "before the end of + # your next turn") is exempt from the round-counter tick — its lifetime is anchored to + # the CASTER's next turn (expired by next_turn when that turn ends), NOT a round count. + # Without this exemption the round-boundary tick in next_turn would expire the marker at + # the very START of the new round, BEFORE the next attacker acts, losing the advantage. + if getattr(eff, "expires_end_of_turn_of", None) is not None: + surviving.append(eff) + continue if eff.scale in ("rounds", "minutes"): eff.rounds_remaining -= 1 if eff.rounds_remaining <= 0: diff --git a/servers/engine/models.py b/servers/engine/models.py index f0a473f7..fe6f2be9 100644 --- a/servers/engine/models.py +++ b/servers/engine/models.py @@ -675,6 +675,16 @@ class ActiveEffect(_StrictModel): # attack resolves (one-shot). Defaults False, so every existing effect (Bless, Hex, Mage # Armor) and every old snapshot is untouched — only an advantage-granting rider sets it. grants_advantage: bool = False + # TURN-ANCHORED expiry for "until the end of your next turn" riders (Guiding Bolt). Holds + # the CASTER's character_id. Guiding Bolt's advantage lasts "before the end of your next + # turn", which is anchored to the CASTER's turn — NOT a fixed round count. A plain + # rounds_remaining=1 marker cast in round 1 would be ticked out at the START of round 2 by + # next_turn's round-boundary tick, BEFORE the next attacker acts — losing the advantage SRD + # 5.2 owes it. So such a marker carries this caster id instead: it is EXEMPT from the + # round-counter tick (tick_round_effects) and is instead expired by next_turn when the + # caster's turn ends. None == NOT turn-anchored — every existing effect and old snapshot is + # unchanged (a plain duration marker still ticks by rounds_remaining exactly as before). + expires_end_of_turn_of: Optional[str] = None # END-OF-TURN repeat save (#209): a "save-ends" spell (Hold Person, Hypnotic Pattern, # a monster's hold) carries this so `next_turn` rolls the holder's recurring save and # frees them on a success instead of locking them indefinitely. None == no repeat save diff --git a/servers/engine/server.py b/servers/engine/server.py index d771091a..1228f802 100644 --- a/servers/engine/server.py +++ b/servers/engine/server.py @@ -4331,6 +4331,13 @@ def _turn_brief(ch: "Character", c: "Campaign") -> dict: entry_r["suggested_when"] = ( "Channel Divinity is available (e.g. War Domain Guided Strike: +10 to one " "attack roll) — spend it to turn a key miss into a hit.") + elif rid_l == "superiority_dice": + entry_r["suggested_when"] = ( + "Battle Master maneuvers: to add this 1d8 to a strike, declare it ON the " + "attack — attack(maneuver='Trip Attack', maneuver_resource='superiority_dice'). " + "The engine spends the die only on a hit, folds +1d8 into damage, and " + "crit-doubles it. Do NOT spend it via a bare use_resource — that burns the " + "die for no bonus.") resources[rid] = entry_r brief["resources"] = resources # --- reactions (#A2): surface a monster's stat-block reaction (Parry) so the DM knows @@ -4542,6 +4549,55 @@ def next_turn(campaign_id: str) -> dict: if caster is not None and not caster_concentrating: combat.end_repeat_save_effect(holder, eff) expired.append({"character_id": holder.id, "name": eff.name}) + # --- TURN-ANCHORED advantage markers (Guiding Bolt's "before the end of your next + # turn") ----------------------------------------------------------------------------- + # Such a marker (eff.expires_end_of_turn_of == the CASTER's id) is EXEMPT from the + # round-counter tick (combat.tick_round_effects), so the round-boundary tick above never + # expires it — that's what lets it survive into the caster's NEXT turn. It is instead + # ticked HERE, only when the CASTER's turn ends. `previous` is the combatant whose turn + # just ended (captured BEFORE turn_index advanced). The marker's rounds_remaining was + # bumped +1 at materialization so the CAST turn's own end (the cast happens DURING the + # caster's turn) decrements it to 1 without expiring; the caster's NEXT turn-end then + # decrements it to 0 and expires it — i.e. "the end of your next turn". The consume-on- + # attack path in attack() still removes the marker on the first qualifying attack; this + # tick only matters when NO one attacked the marked target during the window. + # + # ORPHAN GUARD: if the caster died OR was removed from combat, its next turn will never + # come (a dead combatant is skipped by the advance loop, so it never becomes `previous` + # and the anchor==prev_id tick can't fire) — the marker would leak for the rest of the + # fight and grant UNEARNED advantage to later attacks on the marked foe. So we expire any + # marker whose caster is no longer a LIVING active combatant. + # NOTE: death (_die) sets dead=True but does NOT pop c.combat.order (only remove_combatant + # pops it), so `active_ids` MUST be built from LIVING combatants, not mere order membership + # — otherwise a dead-but-unremoved caster (the common 0-HP monster case) defeats the guard. + prev_id = previous.id if previous is not None else None + active_ids = { + cb.character_id + for cb in order + if (h := c.characters.get(cb.character_id)) is not None and not h.dead + } + for cb in order: + holder = c.characters.get(cb.character_id) + if holder is None: + continue + for eff in list(holder.active_effects): + anchor = getattr(eff, "expires_end_of_turn_of", None) + if anchor is None: + continue + caster_gone = anchor not in active_ids + if caster_gone: + holder.active_effects = [ + e for e in holder.active_effects if e is not eff + ] + expired.append({"character_id": holder.id, "name": eff.name}) + continue + if anchor == prev_id: # the caster's turn just ended -> tick this marker + eff.rounds_remaining -= 1 + if eff.rounds_remaining <= 0: + holder.active_effects = [ + e for e in holder.active_effects if e is not eff + ] + expired.append({"character_id": holder.id, "name": eff.name}) # --- AUTO-ROLL the dying PC's death save at the START of its turn (F01-10, audit # 2026-06-11) ---------------------------------------------------------------------- # A downed PC/companion (0 HP, not dead, not stable) rolls a death save at the start @@ -5379,19 +5435,35 @@ def attack( if hit: applied = [] for r in riders: + _grants_adv = combat.spell_grants_advantage(r.name) + # TURN-ANCHOR the advantage marker to the CASTER's next turn (SRD 5.2: + # Guiding Bolt's advantage lasts "before the end of your next turn"). A plain + # rounds_remaining=1 marker is ticked out at the START of the next round — + # BEFORE the next attacker acts — losing the advantage SRD 5.2 owes it. + # Instead the marker is EXEMPT from the round-start counter tick (combat. + # tick_round_effects) and is decremented only at the CASTER's turn END inside + # next_turn. `r.source_id` is the spellcaster (set on the PendingOnHitRider + # at cast_spell time: source_id == the casting character_id, matched against + # attack()'s attacker == caster). We bump rounds_remaining by +1 so the + # marker survives the CAST turn's own end (the cast happens DURING the + # caster's turn, so the very next caster-turn-end is the cast turn — which + # must NOT expire it) and expires at the end of the caster's NEXT turn. Only + # advantage-granting riders are turn-anchored; every other rider is unchanged. + _rounds = r.rounds_remaining + 1 if _grants_adv else r.rounds_remaining eff = ActiveEffect( name=r.name, source_id=r.source_id, concentration=False, scale=r.scale, - rounds_remaining=r.rounds_remaining, + rounds_remaining=_rounds, expires_day=r.expires_day, expires_phase_index=r.expires_phase_index, until_long_rest=r.until_long_rest, # Flag the rider as advantage-granting (Guiding Bolt) so the NEXT # attack against this target auto-gets advantage via # combat.attack_modifiers and is consumed there (#194). - grants_advantage=combat.spell_grants_advantage(r.name), + grants_advantage=_grants_adv, + expires_end_of_turn_of=(r.source_id if _grants_adv else None), ) # Refresh, don't stack (mirrors cast_spell's write). target.active_effects = [ @@ -8348,6 +8420,18 @@ def use_resource( } if man_damage is not None: out["maneuver_damage"] = man_damage + # FOOTGUN ADVISORY (#213 follow-up): a die-pool resource (Superiority Dice) spent in + # active combat WITHOUT maneuver= burns the die for no damage bonus — the common Battle + # Master mistake. ADVISORY ONLY: the spend already happened and is correct (you may + # legitimately spend a die for a non-damage maneuver the engine doesn't model), so we + # do NOT block — we only surface a steer toward the attack(maneuver=) path. Inert unless + # the pool has a die AND no maneuver was declared AND combat is live. + if res.size.strip() and not man and c.combat.active: + out["warning"] = ( + f"{resource!r} is a die pool but no maneuver= was declared — this die added no " + "damage bonus. To fold +1d8 into a strike, declare it ON the attack instead: " + "attack(maneuver='Trip Attack', maneuver_resource='superiority_dice')." + ) return out diff --git a/servers/engine/tests/test_class_resources.py b/servers/engine/tests/test_class_resources.py index 682a7cd8..1b9ddc36 100644 --- a/servers/engine/tests/test_class_resources.py +++ b/servers/engine/tests/test_class_resources.py @@ -7,6 +7,7 @@ import rests import server import srd_tables +import store from dice import DiceRoll from models import Character @@ -264,3 +265,51 @@ def test_use_resource_preserved_across_level_up(cid): # max grew (5*4=20) but the 10 already spent is preserved. assert sheet["class_resources"]["lay_on_hands"]["max"] == 20 assert sheet["class_resources"]["lay_on_hands"]["used"] == 10 + + +# --- Battle Master maneuver CUE (#213 follow-up): _turn_brief steers the DM to declare the +# maneuver ON the attack so the superiority die isn't burned as a plain point ------------------ +def test_turn_brief_superiority_dice_suggests_maneuver_on_attack(cid): + """A Battle Master's superiority_dice surface in _turn_brief with a `suggested_when` cue + that steers the DM to the attack(maneuver=) path. Before this, only second_wind / + action_surge / channel_divinity got a cue, so the DM was never told the die belongs on the + attack — and it got burned as a plain point via a bare use_resource.""" + fid = server.create_character( + cid, "Aldric", kind="player", class_name="Fighter", level=3, apply_srd_defaults=True, + abilities={"strength": 16, "constitution": 14}, + )["id"] + server.set_class_resource(cid, fid, "superiority_dice", max=4, recharge="short", size="d8") + server.start_combat(cid, [fid]) # the BM is the current combatant + c = store.load_campaign(cid) + ch = c.characters[fid] + brief = server._turn_brief(ch, c) + sd = brief["resources"]["superiority_dice"] + assert "suggested_when" in sd + assert "attack(maneuver=" in sd["suggested_when"] + assert sd["label"].endswith("d8") # the die is surfaced + + +def test_use_resource_bare_superiority_die_in_combat_warns(cid): + """FOOTGUN ADVISORY: a die-pool spend (superiority_dice) in active combat with NO maneuver= + surfaces an advisory `warning` steering the DM to attack(maneuver=). Advisory only — the + spend still succeeds (it does not block).""" + fid = server.create_character( + cid, "Aldric", kind="player", class_name="Fighter", level=3, apply_srd_defaults=True, + abilities={"strength": 16, "constitution": 14}, + )["id"] + server.set_class_resource(cid, fid, "superiority_dice", max=4, recharge="short", size="d8") + server.start_combat(cid, [fid]) + out = server.use_resource(cid, fid, "superiority_dice") # bare spend, in combat + assert out["ok"] is True and out["remaining"] == 3 # the spend SUCCEEDS (advisory, not block) + assert "warning" in out and "attack(maneuver=" in out["warning"] + + +def test_use_resource_bare_superiority_die_out_of_combat_no_warning(cid): + """REGRESSION: the footgun advisory is inert OUT of combat — a bare die-pool spend with no + active combat is byte-identical to before (no `warning` key).""" + fid = server.create_character( + cid, "Aldric", kind="player", class_name="Fighter", level=3, apply_srd_defaults=True, + )["id"] + server.set_class_resource(cid, fid, "superiority_dice", max=4, recharge="short", size="d8") + out = server.use_resource(cid, fid, "superiority_dice") # no combat active + assert out["ok"] is True and "warning" not in out diff --git a/servers/engine/tests/test_effect_durations.py b/servers/engine/tests/test_effect_durations.py index 97b94646..87e3b057 100644 --- a/servers/engine/tests/test_effect_durations.py +++ b/servers/engine/tests/test_effect_durations.py @@ -371,6 +371,142 @@ def test_guiding_bolt_marker_auto_grants_advantage_to_next_attack_and_is_consume assert adv2 is False and dis2 is False +# --- CROSS-ROUND survival: the GB advantage marker is turn-anchored to the CASTER's next +# turn ("before the end of your next turn"), NOT a fixed round count. The same-round test +# above (test_guiding_bolt_marker_auto_grants_advantage_to_next_attack_and_is_consumed) +# never crossed a round boundary, so it never exercised next_turn's round-START tick — the +# exact place a rounds_remaining=1 marker was wrongly expired BEFORE the next attacker acted. +# These two tests target that cross-round bug. + + +def test_guiding_bolt_marker_survives_into_casters_next_turn(monkeypatch): + """Initiative [fighter, cleric, foe]: the fighter acts BEFORE the cleric every round + (fighter init >= cleric init via the tie -> input-order stub). The cleric casts+hits GB + in round 1, landing the advantage marker on the foe. Crossing into round 2, next_turn's + round-START tick must NOT expire the turn-anchored marker — it survives so the fighter + (first in round 2) gets the advantage SRD 5.2 owes it. Then the fighter attacks with NO + advantage flag: result advantage is True, advantage_source == 'Guiding Bolt', marker + consumed/gone afterward. Pre-fix (plain rounds_remaining=1) this marker was ticked out at + the START of round 2, so the fighter got no advantage.""" + cid = server.create_campaign("S")["id"] + cleric = _gb_cleric(cid) + fighter = _fighter(cid) + foe = server.create_character(cid, "Goblin", kind="monster", max_hp=80, armor_class=10)["id"] + server.cast_spell(cid, cleric, "Guiding Bolt", target_id=foe) + # Fixed-natural stub -> initiative ties -> order == input order [fighter, cleric, foe]. + monkeypatch.setattr(server.dice_mod, "roll", _d20_roll(15)) + server.start_combat(cid, [fighter, cleric, foe]) + # Round 1: fighter acts first (no marker yet), then it's the cleric's turn. + _advance_to(cid, cleric) + gb = server.attack(cid, cleric, foe, attack_bonus=5, damage_dice="4d6", + damage_type="radiant", is_ranged=True) + assert gb["hit"] is True and gb.get("on_hit_effect_applied") == ["Guiding Bolt"] + eff = _effects(cid, foe) + assert [e["name"] for e in eff] == ["Guiding Bolt"] + assert eff[0]["grants_advantage"] is True + # The marker is turn-anchored to the CASTER (cleric), not a round counter. + assert eff[0]["expires_end_of_turn_of"] == cleric + # Advance: cleric -> foe (still round 1), then foe -> wrap into ROUND 2 at the fighter. + # The wrap-into-round-2 next_turn runs the round-START tick; it must NOT expire the marker. + saw_gb_expired_at_round_boundary = False + reached_fighter = False + final_round = 1 + for _ in range(8): + v = _advance_turn(cid) + final_round = v["round"] # _combat_view exposes the current round + if any(e["name"] == "Guiding Bolt" for e in v.get("expired_effects", [])): + saw_gb_expired_at_round_boundary = True + if server.get_state(cid)["current_turn"] == fighter and final_round == 2: + reached_fighter = True + break + assert reached_fighter, "never reached the fighter in round 2" + assert final_round == 2 # we crossed the round boundary + # The Round-2-start tick did NOT expire Guiding Bolt; the marker is still live on the foe. + assert saw_gb_expired_at_round_boundary is False + assert [e["name"] for e in _effects(cid, foe)] == ["Guiding Bolt"] + # Fighter attacks the foe with NO advantage flag -> engine auto-grants from the live marker. + res = server.attack(cid, fighter, foe, attack_bonus=4, damage_dice="1d8+3", + damage_type="slashing") + assert res["advantage"] is True + assert res["advantage_source"] == "Guiding Bolt" and res["advantage_consumed"] is True + assert _effects(cid, foe) == [] # consumed by the attack -> gone + + +def test_guiding_bolt_marker_expires_at_end_of_casters_next_turn_if_unused(monkeypatch): + """If NO attack is made against the marked foe, the marker must expire once next_turn + advances PAST the caster (cleric) in round 2 — the end of the caster's next turn. It must + NOT leak into round 3. Initiative [fighter, cleric, foe] again: the marker, born in round + 1, survives the round-2-start tick, survives the fighter's round-2 turn (no attack), and is + expired when the cleric's round-2 turn ends (next_turn off the cleric).""" + cid = server.create_campaign("S")["id"] + cleric = _gb_cleric(cid) + fighter = _fighter(cid) + foe = server.create_character(cid, "Goblin", kind="monster", max_hp=80, armor_class=10)["id"] + server.cast_spell(cid, cleric, "Guiding Bolt", target_id=foe) + monkeypatch.setattr(server.dice_mod, "roll", _d20_roll(15)) + server.start_combat(cid, [fighter, cleric, foe]) + _advance_to(cid, cleric) + server.attack(cid, cleric, foe, attack_bonus=5, damage_dice="4d6", + damage_type="radiant", is_ranged=True) + assert [e["name"] for e in _effects(cid, foe)] == ["Guiding Bolt"] + # No one attacks the foe. Advance turns; record the round at which GB expires. + expired_round = None + for _ in range(12): + v = _advance_turn(cid) + if any(e["name"] == "Guiding Bolt" for e in v.get("expired_effects", [])): + expired_round = v["round"] + break + assert expired_round is not None, "Guiding Bolt marker never expired (it leaked)" + # It expires when the caster's round-2 turn ENDS -> within round 2 (the advance off the + # cleric stays in round 2; the next wrap to round 3 would be too late / a leak). + assert expired_round == 2 + assert _effects(cid, foe) == [] # gone, did not leak into round 3 + + +def test_guiding_bolt_marker_swept_when_caster_dies_midcombat(monkeypatch): + """ORPHAN-GUARD regression: a Guiding Bolt marker is anchored to the CASTER's next turn. If + the caster DIES mid-combat, its turn never comes again (a dead combatant is skipped by the + advance loop, so the anchor==prev_id tick can't fire) — and death does NOT pop the combatant + from c.combat.order, so mere order-membership does not detect it. Without the living-combatant + orphan guard the marker LEAKS for the rest of the fight and grants unearned advantage to a + later attack on the marked foe. Realistic case: an enemy spellcaster casts a GB-style rider, + is killed before its next turn, and the party then attacks that foe.""" + cid = server.create_campaign("S")["id"] + # Monster GB-caster: dies OUTRIGHT at 0 HP (dead=True) and stays in c.combat.order. + caster = server.create_character( + cid, "Enemy Acolyte", kind="monster", max_hp=8, armor_class=10)["id"] + server.learn_spells(cid, caster, ["Guiding Bolt"]) + server.prepare_spells(cid, caster, ["Guiding Bolt"]) + fighter = _fighter(cid) + foe = server.create_character(cid, "Goblin", kind="monster", max_hp=80, armor_class=10)["id"] + # Monster casts innately (no Vancian slot — its stat block carries none). + server.cast_spell(cid, caster, "Guiding Bolt", target_id=foe, innate=True) + monkeypatch.setattr(server.dice_mod, "roll", _d20_roll(15)) + server.start_combat(cid, [fighter, caster, foe]) + _advance_to(cid, caster) + server.attack(cid, caster, foe, attack_bonus=5, damage_dice="4d6", + damage_type="radiant", is_ranged=True) + assert [e["name"] for e in _effects(cid, foe)] == ["Guiding Bolt"] + # Kill the caster (monster -> dead at 0 HP); it remains in the initiative order. + server.set_hp(cid, caster, 0) + assert server.get_character(cid, caster)["dead"] is True + # Advance turns: the marker must be SWEPT by the orphan guard (it can never expire on the + # caster's turn-end, because that turn never comes), and it must NOT leak. + swept = False + for _ in range(12): + v = _advance_turn(cid) + if any(e["name"] == "Guiding Bolt" for e in v.get("expired_effects", [])): + swept = True + break + assert swept, "Guiding Bolt marker leaked after the caster died (orphan guard failed)" + assert _effects(cid, foe) == [] + # And a later attack on the foe must get NO unearned advantage. + res = server.attack(cid, fighter, foe, attack_bonus=5, damage_dice="1d8", + damage_type="slashing") + assert res.get("advantage") is not True + assert res.get("advantage_source") in (None, "") + + def test_guiding_bolt_marker_not_consumed_for_attack_on_other_target(monkeypatch): """The marker is the FOE's; an attack against a DIFFERENT (unmarked) target neither gets advantage from it nor consumes it — the marker stays live for the marked foe."""