mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Review follow-up N: Misty's round-end procs fire in turn-based combat
DerivePlayerStats builds MistyHealProc / CrowdRevengeProc onto every turn-based combatant, but stepRoundEnd had no counterpart to endOfRoundForSeat, so neither was ever read. The buff (Misty's heal) was silently lost. The debuff (her crowd's revenge) was an exploit: a player who declined Misty escaped it entirely by fighting with !attack instead of letting the room auto-resolve -- no discovery required, just press the button. Hoisted both procs out of endOfRoundForSeat into shared helpers (mistyCrowdRevenge, mistyHeal) and a seatEndOfRound hook that runs the pair in the auto-resolve order. endOfRoundForSeat now calls the helpers; stepRoundEnd calls seatEndOfRound per seat, after the poison tick so a heal can answer the round's damage. A one-sided debuff-only hook would have been a second parallel sibling of the kind deferred item D warns about, so the heal ships with it -- a player-favourable discovery mechanic, consistent with the lift-trailers stance. Both helpers short-circuit before st.randFloat() when their proc is unarmed, so a character with no Misty history draws no dice: the sim corpus and combat_characterization.golden do not move. seatCombatResult now reads MistyHealed back off the misty_heal event (as combat_pet_save always has), so combat_misty_clutch is reachable turn-based. combat_sniper_kill stays unreachable -- Arina's proc is a pre-combat one-shot with no round-end seam. renderAllySeatEvent renders crowd_revenge as unattributed damage: an ally sees the hit but not Misty's name, keeping the grudge the owner's own discovery. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -448,6 +448,78 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Misty's pair, shared by both engines ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// These two are the only round-end effects the turn engine did not have its own
|
||||||
|
// copy of. Everything else in endOfRoundForSeat either exists there already (the
|
||||||
|
// pet, the spiritual weapon — fired after a player action rather than at round
|
||||||
|
// end), is deliberately absent (the environmental hazard: turnCombatPhase is a
|
||||||
|
// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an
|
||||||
|
// explicit player command (the consumable auto-heal, which is `!consume`).
|
||||||
|
//
|
||||||
|
// So they are hoisted rather than re-implemented. A parallel sibling is what let
|
||||||
|
// the two win close-outs drift apart (deferred item D); this pair is now one
|
||||||
|
// list of effects with two callers, and cannot.
|
||||||
|
//
|
||||||
|
// Neither draws from the RNG unless its proc is armed, so a character with no
|
||||||
|
// Misty history rolls exactly the dice it rolled before — the sim corpus and
|
||||||
|
// combat_characterization.golden do not move.
|
||||||
|
|
||||||
|
// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing
|
||||||
|
// at the end of the round. Returns true when it decided the fight — that is,
|
||||||
|
// when it dropped this character, the death save failed, and nobody else is
|
||||||
|
// standing. A downed character in a still-live party returns false.
|
||||||
|
func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool {
|
||||||
|
if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
dmg := player.Mods.CrowdRevengeDmg
|
||||||
|
st.playerHP = max(0, st.playerHP-dmg)
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||||
|
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
Desc: "Misty's crowd",
|
||||||
|
})
|
||||||
|
return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mistyHeal is the buff's payout. It cannot decide the fight, so it returns
|
||||||
|
// nothing. result may be a scratch value the caller discards (the turn engine's
|
||||||
|
// is) — the durable record is the misty_heal event on the log, which is what
|
||||||
|
// seatCombatResult reads to award combat_misty_clutch.
|
||||||
|
func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) {
|
||||||
|
if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
healAmt := player.Mods.MistyHealAmt
|
||||||
|
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||||
|
result.MistyHealed = true
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||||
|
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
Desc: "Misty",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatEndOfRound runs the round-end effects the turn engine owes one seat, in
|
||||||
|
// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat
|
||||||
|
// survived it — Misty's heal. Returns true when the fight is over.
|
||||||
|
//
|
||||||
|
// It exists because the turn engine's stepRoundEnd never ran either one. A
|
||||||
|
// player carrying Misty's buff lost it by fighting manually; worse, a player
|
||||||
|
// carrying her debuff *escaped* it by doing the same, which needed no discovery
|
||||||
|
// to exploit — just press !attack.
|
||||||
|
func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool {
|
||||||
|
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if st.playerHP <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mistyHeal(st, player, phaseName, result)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// endOfRoundForSeat runs the per-character close of a round against the seat the
|
// endOfRoundForSeat runs the per-character close of a round against the seat the
|
||||||
// cursor already points at: environment, Misty's crowd, the pet, the spiritual
|
// cursor already points at: environment, Misty's crowd, the pet, the spiritual
|
||||||
// weapon, Misty's heal, and the consumable auto-heal — in that order, which is
|
// weapon, Misty's heal, and the consumable auto-heal — in that order, which is
|
||||||
@@ -470,18 +542,9 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Misty crowd revenge (debuff for declining Misty)
|
// Misty crowd revenge (debuff for declining Misty)
|
||||||
if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
|
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
||||||
dmg := player.Mods.CrowdRevengeDmg
|
|
||||||
st.playerHP = max(0, st.playerHP-dmg)
|
|
||||||
st.events = append(st.events, CombatEvent{
|
|
||||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
|
||||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
||||||
Desc: "Misty's crowd",
|
|
||||||
})
|
|
||||||
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// A character the round just killed stops acting, but the fight goes on if
|
// A character the round just killed stops acting, but the fight goes on if
|
||||||
// anyone else is standing.
|
// anyone else is standing.
|
||||||
@@ -517,16 +580,7 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Misty heal
|
// Misty heal
|
||||||
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
mistyHeal(st, player, phaseName, result)
|
||||||
healAmt := player.Mods.MistyHealAmt
|
|
||||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
|
||||||
result.MistyHealed = true
|
|
||||||
st.events = append(st.events, CombatEvent{
|
|
||||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
|
||||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
||||||
Desc: "Misty",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consumable heal: triggers when the character drops below 60% HP. Fires up
|
// Consumable heal: triggers when the character drops below 60% HP. Fires up
|
||||||
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
|
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
|
||||||
|
|||||||
178
internal/plugin/combat_misty_turn_test.go
Normal file
178
internal/plugin/combat_misty_turn_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Misty's two round-end procs on the turn-based surface.
|
||||||
|
//
|
||||||
|
// Both were built onto every turn-based combatant by DerivePlayerStats and then
|
||||||
|
// never read: stepRoundEnd had no counterpart to endOfRoundForSeat. The buff was
|
||||||
|
// simply lost. The debuff was an exploit — a player who declined Misty escaped
|
||||||
|
// her crowd entirely by fighting with !attack instead of letting the room
|
||||||
|
// auto-resolve, which needed no discovery at all.
|
||||||
|
//
|
||||||
|
// seatEndOfRound is the shared hook. These pin that it fires, that it cannot be
|
||||||
|
// dodged, and that a character with no Misty history is not touched at all —
|
||||||
|
// the property that keeps the sim corpus and the golden file still.
|
||||||
|
|
||||||
|
// mistyState seats one combatant at hp, cursor armed, with a seeded RNG.
|
||||||
|
func mistyState(t *testing.T, player *Combatant, hp int) *combatState {
|
||||||
|
t.Helper()
|
||||||
|
st := testCombatState(hp, 100, 1, rand.New(rand.NewPCG(7, 7)))
|
||||||
|
st.actor.c = player
|
||||||
|
st.actor.hpMax = player.Stats.MaxHP
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two procs at certainty, so no test depends on a roll.
|
||||||
|
func mistyCursed(maxHP, dmg int) *Combatant {
|
||||||
|
return &Combatant{
|
||||||
|
Name: "Cursed",
|
||||||
|
Stats: CombatStats{MaxHP: maxHP, AC: 10},
|
||||||
|
Mods: CombatModifiers{CrowdRevengeProc: 1.0, CrowdRevengeDmg: dmg},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mistyBuffed(maxHP, heal int) *Combatant {
|
||||||
|
return &Combatant{
|
||||||
|
Name: "Buffed",
|
||||||
|
Stats: CombatStats{MaxHP: maxHP, AC: 10},
|
||||||
|
Mods: CombatModifiers{MistyHealProc: 1.0, MistyHealAmt: heal},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAction(events []CombatEvent, action string) bool {
|
||||||
|
for _, e := range events {
|
||||||
|
if e.Action == action {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the exploit ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The debuff must land at round end in a manual fight, exactly as it does when
|
||||||
|
// the room auto-resolves. Before seatEndOfRound, !attack was a clean escape.
|
||||||
|
func TestSeatEndOfRound_CrowdRevengeCannotBeDodgedByFightingManually(t *testing.T) {
|
||||||
|
st := mistyState(t, mistyCursed(40, 6), 40)
|
||||||
|
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||||
|
|
||||||
|
if over {
|
||||||
|
t.Fatal("a 6-damage swing against 40 HP should not end the fight")
|
||||||
|
}
|
||||||
|
if st.playerHP != 34 {
|
||||||
|
t.Errorf("playerHP = %d, want 34 — Misty's crowd took its swing", st.playerHP)
|
||||||
|
}
|
||||||
|
if !hasAction(st.events, "crowd_revenge") {
|
||||||
|
t.Error("no crowd_revenge event on the log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The debuff can be lethal, and a lethal one with nobody else standing ends the
|
||||||
|
// fight rather than leaving a corpse to take the next round's turn.
|
||||||
|
func TestSeatEndOfRound_CrowdRevengeCanEndASoloFight(t *testing.T) {
|
||||||
|
st := mistyState(t, mistyCursed(40, 30), 3)
|
||||||
|
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||||
|
|
||||||
|
if st.playerHP != 0 {
|
||||||
|
t.Fatalf("playerHP = %d, want 0", st.playerHP)
|
||||||
|
}
|
||||||
|
if !over {
|
||||||
|
t.Error("a solo character dropped by the crowd should end the fight")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the buff ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The heal fires, caps at max HP, and marks the result so the achievement can
|
||||||
|
// be granted.
|
||||||
|
func TestSeatEndOfRound_MistyHealFiresAndCaps(t *testing.T) {
|
||||||
|
st := mistyState(t, mistyBuffed(40, 12), 34)
|
||||||
|
var res CombatResult
|
||||||
|
seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &res)
|
||||||
|
|
||||||
|
if st.playerHP != 40 {
|
||||||
|
t.Errorf("playerHP = %d, want 40 — the heal caps at max HP", st.playerHP)
|
||||||
|
}
|
||||||
|
if !res.MistyHealed {
|
||||||
|
t.Error("MistyHealed flag not set")
|
||||||
|
}
|
||||||
|
if !hasAction(st.events, "misty_heal") {
|
||||||
|
t.Error("no misty_heal event on the log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A character the crowd just dropped is not then healed back up by the same
|
||||||
|
// hook. The heal is for the living.
|
||||||
|
func TestSeatEndOfRound_NoHealForACharacterTheCrowdJustDropped(t *testing.T) {
|
||||||
|
c := mistyCursed(40, 40)
|
||||||
|
c.Mods.MistyHealProc = 1.0
|
||||||
|
c.Mods.MistyHealAmt = 20
|
||||||
|
|
||||||
|
st := mistyState(t, c, 10)
|
||||||
|
st.actors = append(st.actors, &actor{playerHP: 5}) // an ally keeps the fight alive
|
||||||
|
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||||
|
|
||||||
|
if over {
|
||||||
|
t.Fatal("an ally is still standing — the fight is not over")
|
||||||
|
}
|
||||||
|
if st.playerHP != 0 {
|
||||||
|
t.Errorf("playerHP = %d, want 0 — the heal must not resurrect them", st.playerHP)
|
||||||
|
}
|
||||||
|
if hasAction(st.events, "misty_heal") {
|
||||||
|
t.Error("misty_heal fired on a downed character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the property that protects the corpus ────────────────────────────────────
|
||||||
|
|
||||||
|
// A character with no Misty history is untouched: no HP change, no events, and
|
||||||
|
// — because both procs short-circuit before st.randFloat() — no dice drawn. If
|
||||||
|
// the hook drew even one float, every simulated fight would diverge and
|
||||||
|
// combat_characterization.golden would move.
|
||||||
|
func TestSeatEndOfRound_UnbuffedCharacterIsUntouchedAndDrawsNoDice(t *testing.T) {
|
||||||
|
plain := &Combatant{Name: "Plain", Stats: CombatStats{MaxHP: 40, AC: 10}}
|
||||||
|
|
||||||
|
st := mistyState(t, plain, 40)
|
||||||
|
control := mistyState(t, plain, 40) // same seed, never passed to the hook
|
||||||
|
|
||||||
|
if over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}); over {
|
||||||
|
t.Fatal("an unbuffed character cannot be dropped by a hook that does nothing")
|
||||||
|
}
|
||||||
|
if st.playerHP != 40 || len(st.events) != 0 {
|
||||||
|
t.Errorf("unbuffed character was touched: hp=%d events=%d", st.playerHP, len(st.events))
|
||||||
|
}
|
||||||
|
// The RNG streams must still be in lockstep: the hook consumed nothing.
|
||||||
|
if got, want := st.randFloat(), control.randFloat(); got != want {
|
||||||
|
t.Errorf("seatEndOfRound consumed RNG: next float %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the wiring ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The unit tests above call seatEndOfRound directly, which proves the hook is
|
||||||
|
// correct but not that stepRoundEnd calls it. This drives the real session API
|
||||||
|
// through a round_end step. Comment the call out of stepRoundEnd and this test
|
||||||
|
// reports PlayerHP=40 — the exploit, exactly as it shipped.
|
||||||
|
func TestStepRoundEnd_WiresSeatEndOfRound(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
player := mistyCursed(40, 6)
|
||||||
|
enemy := &Combatant{Name: "Target", Stats: CombatStats{MaxHP: 100, AC: 10}}
|
||||||
|
sess := &CombatSession{
|
||||||
|
SessionID: "wiring", UserID: "@u:example.org", Status: CombatStatusActive,
|
||||||
|
PlayerHP: 40, PlayerHPMax: 40, EnemyHP: 100,
|
||||||
|
Phase: CombatPhaseRoundEnd, Round: 1,
|
||||||
|
}
|
||||||
|
if _, err := advanceCombatSession(sess, player, enemy, PlayerAction{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sess.PlayerHP != 34 {
|
||||||
|
t.Errorf("PlayerHP = %d, want 34 — stepRoundEnd never ran the hook", sess.PlayerHP)
|
||||||
|
}
|
||||||
|
if !hasAction(sess.TurnLog, "crowd_revenge") {
|
||||||
|
t.Error("no crowd_revenge event persisted to the session log")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -979,6 +979,12 @@ func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
|
|||||||
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
|
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
|
||||||
case "environmental":
|
case "environmental":
|
||||||
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
|
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
|
||||||
|
case "crowd_revenge":
|
||||||
|
// Deliberately unattributed. An ally sees the damage land — silence
|
||||||
|
// would read as HP vanishing — but Misty's grudge is the owner's own
|
||||||
|
// discovery, and naming her here would spoil it for the whole party.
|
||||||
|
// Same reason misty_heal below reads as a plain recovery.
|
||||||
|
return down(fmt.Sprintf("%s takes %d.", who, e.Damage))
|
||||||
case "flat_damage":
|
case "flat_damage":
|
||||||
return fmt.Sprintf("%s deals %d.", who, e.Damage)
|
return fmt.Sprintf("%s deals %d.", who, e.Damage)
|
||||||
case "heal_item", "misty_heal":
|
case "heal_item", "misty_heal":
|
||||||
|
|||||||
@@ -208,21 +208,38 @@ func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatMo
|
|||||||
// engine never builds a CombatResult — it persists a session and a shared event
|
// engine never builds a CombatResult — it persists a session and a shared event
|
||||||
// log — so the close-out has to assemble one.
|
// log — so the close-out has to assemble one.
|
||||||
//
|
//
|
||||||
// SniperKilled and MistyHealed stay false because the turn engine has no Arina
|
// MistyHealed is read back off the seat's event log rather than off a flag: the
|
||||||
// or Misty proc to set them: those two live only in the auto-resolve engine.
|
// turn engine's CombatResult is a scratch value it discards between steps, and
|
||||||
|
// the log is the only thing that survives to the close-out. This is how
|
||||||
|
// combat_pet_save has always been detected.
|
||||||
|
//
|
||||||
|
// SniperKilled stays false. Arina's proc is a pre-combat one-shot, not a
|
||||||
|
// round-end effect, so seatEndOfRound does not carry it and the turn engine
|
||||||
|
// still has no place to fire it — combat_sniper_kill remains unreachable from a
|
||||||
|
// manual kill.
|
||||||
|
//
|
||||||
// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max);
|
// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max);
|
||||||
// its loss-side meaning is unused here, since the only hook that reads it gates
|
// its loss-side meaning is unused here, since the only hook that reads it gates
|
||||||
// on PlayerWon.
|
// on PlayerWon.
|
||||||
func seatCombatResult(sess *CombatSession, seat int) CombatResult {
|
func seatCombatResult(sess *CombatSession, seat int) CombatResult {
|
||||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||||
won := sess.Status == CombatStatusWon
|
won := sess.Status == CombatStatusWon
|
||||||
|
events := eventsForSeat(sess.TurnLog, seat)
|
||||||
|
misty := false
|
||||||
|
for _, ev := range events {
|
||||||
|
if ev.Action == "misty_heal" {
|
||||||
|
misty = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return CombatResult{
|
return CombatResult{
|
||||||
PlayerWon: won,
|
PlayerWon: won,
|
||||||
Events: eventsForSeat(sess.TurnLog, seat),
|
Events: events,
|
||||||
PlayerEndHP: hp,
|
PlayerEndHP: hp,
|
||||||
EnemyEndHP: sess.EnemyHP,
|
EnemyEndHP: sess.EnemyHP,
|
||||||
TotalRounds: sess.Round,
|
TotalRounds: sess.Round,
|
||||||
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
|
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
|
||||||
|
MistyHealed: misty,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -753,6 +753,23 @@ func (te *turnEngine) stepRoundEnd() {
|
|||||||
}
|
}
|
||||||
te.stampSeat(mark, i)
|
te.stampSeat(mark, i)
|
||||||
}
|
}
|
||||||
|
// Misty's crowd, then Misty's heal — per seat, after the round's other
|
||||||
|
// damage has landed so the heal can answer it, which is the order
|
||||||
|
// endOfRoundForSeat uses. Both no-op (and draw no RNG) for a character with
|
||||||
|
// no Misty history, which is every simulated one.
|
||||||
|
for i := range st.actors {
|
||||||
|
st.seat(i)
|
||||||
|
if st.playerHP <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mark := len(st.events)
|
||||||
|
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
|
||||||
|
te.stampSeat(mark, i)
|
||||||
|
if over {
|
||||||
|
te.finish(CombatStatusLost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
|
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
|
||||||
// the enemy each round it stays up. Concentration is per-caster, so every
|
// the enemy each round it stays up. Concentration is per-caster, so every
|
||||||
// seat holding one pulses. Ticks before enemy regen so a lethal pulse
|
// seat holding one pulses. Ticks before enemy regen so a lethal pulse
|
||||||
|
|||||||
Reference in New Issue
Block a user