mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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
654 lines
23 KiB
Go
654 lines
23 KiB
Go
package plugin
|
|
|
|
import (
|
|
"math/rand/v2"
|
|
"sort"
|
|
)
|
|
|
|
// ── N-body auto-resolve ──────────────────────────────────────────────────────
|
|
//
|
|
// The auto-resolve engine seats a roster, exactly as the turn-based engine has
|
|
// since P3. `SimulateCombat` is the one-seat case and nothing more: for a solo
|
|
// roster every short-circuit below collapses to the pre-roster code path and
|
|
// the engine draws from the RNG in precisely the pre-roster order. That is what
|
|
// keeps `TestCombatCharacterization` byte-identical and the d8prereq_corpus
|
|
// baselines comparable. If the golden moves, solo balance moved — stop.
|
|
//
|
|
// The invariants the solo path rests on, all of them mirrored from P3:
|
|
//
|
|
// - enemyTargetSeat draws nothing for a one-seat roster (there is only one
|
|
// target), so the enemy's choice costs no randomness.
|
|
// - the initiative loop draws one player roll then one enemy roll, which is
|
|
// the pre-roster order; ties go to the player, as `playerInit >= enemyInit`
|
|
// always did.
|
|
// - the per-seat loops run exactly once.
|
|
//
|
|
// Per-actor state (poison, charges, rage, wards) follows the `combatState`
|
|
// cursor, so the resolution primitives need no changes: they already read
|
|
// `st.c` — the turn engine has called them that way since P3.
|
|
|
|
// PartyCombatResult is one fight, seen from every seat at once. The fight-scoped
|
|
// fields (the enemy, the round count, the event log) are shared; `Seats` holds
|
|
// the per-character view, and `Seats[i]` is a complete `CombatResult` so a solo
|
|
// caller can take `Seats[0]` and be handed exactly what `SimulateCombat` always
|
|
// returned.
|
|
type PartyCombatResult struct {
|
|
PlayerWon bool
|
|
TimedOut bool
|
|
TotalRounds int
|
|
Events []CombatEvent
|
|
|
|
EnemyStartHP int
|
|
EnemyEntryHP int
|
|
EnemyEndHP int
|
|
|
|
Seats []CombatResult
|
|
}
|
|
|
|
// AnySurvivor reports whether at least one seat is still standing. A party can
|
|
// win a fight it did not all walk away from.
|
|
func (r PartyCombatResult) AnySurvivor() bool {
|
|
for i := range r.Seats {
|
|
if r.Seats[i].PlayerEndHP > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// simulateParty auto-resolves one enemy against a roster of N player characters.
|
|
// Production auto-resolve passes a nil rng (package global); the sim harness and
|
|
// the characterization test seed it.
|
|
func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) PartyCombatResult {
|
|
return simulatePartyWithRNG(players, enemy, phases, nil)
|
|
}
|
|
|
|
func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult {
|
|
enemyStart := enemy.Stats.MaxHP
|
|
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
|
enemyStart = enemy.Stats.StartHP
|
|
}
|
|
|
|
actors := make([]*actor, len(players))
|
|
seats := make([]CombatResult, len(players))
|
|
for i := range players {
|
|
actors[i] = newActor(&players[i])
|
|
seats[i] = CombatResult{
|
|
PlayerStartHP: players[i].Stats.MaxHP,
|
|
PlayerEntryHP: actors[i].playerHP,
|
|
EnemyStartHP: enemy.Stats.MaxHP,
|
|
EnemyEntryHP: enemyStart,
|
|
}
|
|
}
|
|
|
|
st := &combatState{
|
|
actor: actors[0],
|
|
actors: actors,
|
|
enemyHP: enemyStart,
|
|
rng: rng,
|
|
}
|
|
// Holding the enemy holds it for everyone, so the control effect is
|
|
// fight-scoped: any caster who queued one arms it.
|
|
for i := range players {
|
|
if players[i].Mods.SpellEnemySkipFirst {
|
|
st.enemySkipFirst = true
|
|
}
|
|
}
|
|
|
|
// Pre-combat one-shots, grouped by kind rather than by seat. A one-seat
|
|
// roster walks these in the pre-roster order.
|
|
for i := range actors {
|
|
st.seat(i)
|
|
if st.c.Mods.SniperKillProc > 0 && st.randFloat() < st.c.Mods.SniperKillProc {
|
|
st.enemyHP = 0
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
|
|
Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0,
|
|
Seat: i, Desc: "Arina",
|
|
})
|
|
seats[i].SniperKilled = true
|
|
return finalizeParty(seats, st, players, enemy)
|
|
}
|
|
}
|
|
|
|
for i := range actors {
|
|
st.seat(i)
|
|
if st.c.Mods.FlatDmgStart > 0 {
|
|
dmg := st.c.Mods.FlatDmgStart
|
|
st.enemyHP = max(0, st.enemyHP-dmg)
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage",
|
|
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
|
})
|
|
if st.enemyHP <= 0 {
|
|
return finalizeParty(seats, st, players, enemy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Queued spells. Resolved by applyPendingCast() before this runs — the
|
|
// modifiers carry the resolved damage and narrative hook.
|
|
for i := range actors {
|
|
st.seat(i)
|
|
if st.c.Mods.SpellPreDamageDesc == "" {
|
|
continue
|
|
}
|
|
dmg := st.c.Mods.SpellPreDamage
|
|
resisted := dmg > 0 && enemyResistsSpells(&enemy, st)
|
|
if resisted {
|
|
dmg = max(1, dmg/2)
|
|
}
|
|
if dmg > 0 {
|
|
st.enemyHP = max(0, st.enemyHP-dmg)
|
|
}
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast",
|
|
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
|
Desc: st.c.Mods.SpellPreDamageDesc,
|
|
})
|
|
if resisted {
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle",
|
|
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
|
})
|
|
}
|
|
if st.enemyHP <= 0 {
|
|
return finalizeParty(seats, st, players, enemy)
|
|
}
|
|
}
|
|
|
|
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
|
|
|
|
for _, phase := range phases {
|
|
roundsThisPhase := phase.Rounds
|
|
// Add slight variance: ±1 round for non-Decisive phases
|
|
if phase.Name != "Decisive" && roundsThisPhase > 1 {
|
|
roundsThisPhase += st.roll(2) // 0 or +1
|
|
}
|
|
for r := 0; r < roundsThisPhase; r++ {
|
|
st.round++
|
|
if simulatePartyRound(st, &enemy, &phase, seats) {
|
|
return finalizeParty(seats, st, players, enemy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exhausted the phase clock without a kill. Tiebreak on HP percentage to
|
|
// decide the outcome — but DO NOT zero out HP on the loser. Timeout =
|
|
// retreat, not a lethal blow, so no character-death side effects fire.
|
|
//
|
|
// The party reads its fraction off the pooled roster, which for one seat is
|
|
// that seat's own fraction, i.e. the pre-roster comparison. Slight bias to
|
|
// the players on exact ties (frac >=).
|
|
var partyHP, partyMax int
|
|
for i := range st.actors {
|
|
partyHP += st.actors[i].playerHP
|
|
partyMax += players[i].Stats.MaxHP
|
|
}
|
|
playerFrac := float64(partyHP) / float64(max(1, partyMax))
|
|
enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP))
|
|
playerWonTiebreak := playerFrac >= enemyFrac
|
|
st.seat(0)
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
|
|
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
})
|
|
for i := range seats {
|
|
seats[i].TimedOut = true
|
|
}
|
|
out := finalizeParty(seats, st, players, enemy)
|
|
out.TimedOut = true
|
|
out.PlayerWon = playerWonTiebreak
|
|
for i := range out.Seats {
|
|
out.Seats[i].PlayerWon = playerWonTiebreak
|
|
}
|
|
return out
|
|
}
|
|
|
|
// combatOver reads the fight's terminal condition off HP rather than off a
|
|
// primitive's bool. resolvePlayerAttack documents why: a retaliate aura can
|
|
// drop the swinger with the enemy still standing, so `true` means "something
|
|
// decisive happened", not "the players won". For a one-seat roster this is the
|
|
// same answer the old `return true` gave.
|
|
func combatOver(st *combatState) bool {
|
|
return st.enemyHP <= 0 || !st.anyAlive()
|
|
}
|
|
|
|
// enemyTargetSeat picks who the enemy swings at. A one-seat roster draws no
|
|
// randomness — there is only one target — which is what keeps the solo RNG
|
|
// stream identical. Shared with the turn engine.
|
|
func enemyTargetSeat(st *combatState) (int, bool) {
|
|
if len(st.actors) == 1 {
|
|
return 0, st.actors[0].playerHP > 0
|
|
}
|
|
standing := make([]int, 0, len(st.actors))
|
|
for i, a := range st.actors {
|
|
if a.playerHP > 0 {
|
|
standing = append(standing, i)
|
|
}
|
|
}
|
|
if len(standing) == 0 {
|
|
return 0, false
|
|
}
|
|
return standing[st.roll(len(standing))], true
|
|
}
|
|
|
|
// eventsForSeat is the sub-log a single character is responsible for. Events the
|
|
// engine never stamped — the enemy regenerating, the phase clock running out —
|
|
// carry seat 0, so they read as the leader's. Only ever called for a party: a
|
|
// solo seat is handed the whole log by identity.
|
|
func eventsForSeat(events []CombatEvent, seat int) []CombatEvent {
|
|
out := make([]CombatEvent, 0, len(events))
|
|
for _, e := range events {
|
|
if e.Seat == seat {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// stampEventSeats attributes every event appended since `mark` to a seat, so
|
|
// party narration can say who did what. Seat 0 stamps a zero, which is
|
|
// `omitempty` — a solo fight's event log is byte-identical.
|
|
func stampEventSeats(st *combatState, mark, seat int) {
|
|
for i := mark; i < len(st.events); i++ {
|
|
st.events[i].Seat = seat
|
|
}
|
|
}
|
|
|
|
// roundInitiative rolls the round's turn order: every seat, then the enemy, in
|
|
// seating order — the pre-roster draw order when there is one seat. Ties favour
|
|
// the players, then the lower seat.
|
|
func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []int {
|
|
type entry struct {
|
|
seat int
|
|
init float64
|
|
}
|
|
entries := make([]entry, 0, len(st.actors)+1)
|
|
for i, a := range st.actors {
|
|
speed := float64(a.c.Stats.Speed) * phase.SpeedWeight
|
|
entries = append(entries, entry{i, speed + st.randFloat()*10 + a.c.Mods.InitiativeBias})
|
|
}
|
|
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
|
|
entries = append(entries, entry{enemySeat, enemySpeed + st.randFloat()*10})
|
|
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
|
a, b := entries[i], entries[j]
|
|
if a.init != b.init {
|
|
return a.init > b.init
|
|
}
|
|
if (a.seat == enemySeat) != (b.seat == enemySeat) {
|
|
return b.seat == enemySeat
|
|
}
|
|
return a.seat < b.seat
|
|
})
|
|
order := make([]int, len(entries))
|
|
for i, e := range entries {
|
|
order[i] = e.seat
|
|
}
|
|
return order
|
|
}
|
|
|
|
// simulatePartyRound runs one round for the whole roster. Returns true if the
|
|
// fight is over.
|
|
func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool {
|
|
phaseName := phase.Name
|
|
|
|
// Whoever the enemy is looking at this round. Chosen before the ability
|
|
// fires, because the ability lands on its target. Costs no RNG when solo.
|
|
target, alive := enemyTargetSeat(st)
|
|
if !alive {
|
|
return true
|
|
}
|
|
st.seat(target)
|
|
|
|
// Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the
|
|
// enemy's attack for one round. Fight-scoped — holding it holds it for all.
|
|
enemyHeldThisRound := false
|
|
if st.enemySkipFirst {
|
|
st.enemySkipFirst = false
|
|
if enemyImmuneToControl(enemy, st) {
|
|
// fear_immune: the control spell can't take hold — the enemy acts
|
|
// as normal this round.
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist",
|
|
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
})
|
|
} else {
|
|
enemyHeldThisRound = true
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
|
|
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Monster ability: check at round start. It resolves against the enemy's
|
|
// target, which the cursor already points at.
|
|
abilityDealtDamage := enemyHeldThisRound
|
|
if enemy.Ability != nil {
|
|
if abilityFires(enemy.Ability, phaseName, st) {
|
|
mark := len(st.events)
|
|
over := applyAbility(st, st.c, enemy, phase, &seats[target])
|
|
stampEventSeats(st, mark, target)
|
|
if over && combatOver(st) {
|
|
return true
|
|
}
|
|
// Cleave and lifesteal deal damage — skip normal enemy attack this round
|
|
switch enemy.Ability.Effect {
|
|
case "cleave", "lifesteal":
|
|
abilityDealtDamage = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Poison tick from previous round. Stacked per character, so every seat
|
|
// carrying it bleeds.
|
|
for i := range st.actors {
|
|
st.seat(i)
|
|
if st.poisonTicks <= 0 {
|
|
continue
|
|
}
|
|
st.playerHP = max(0, st.playerHP-st.poisonDmg)
|
|
st.poisonTicks--
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick",
|
|
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
|
})
|
|
if st.playerHP <= 0 && !trySave(st, st.c, phaseName) && !st.anyAlive() {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// The poison may have dropped the seat the enemy had picked. Re-target
|
|
// before the swing procs, which read the target's own modifiers.
|
|
if st.actors[target].playerHP <= 0 {
|
|
if target, alive = enemyTargetSeat(st); !alive {
|
|
return true
|
|
}
|
|
}
|
|
st.seat(target)
|
|
|
|
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
|
|
petWhiff := st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
|
|
// Pet deflect: halves incoming damage to the target this round
|
|
petDeflect := st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
|
|
if petDeflect {
|
|
seats[target].PetDeflected = true
|
|
}
|
|
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
|
|
sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15
|
|
|
|
// Determine initiative. DM mood (Effusive/Hostile) biases a player's roll
|
|
// via Mods.InitiativeBias — +X means they go first more often.
|
|
for _, s := range roundInitiative(st, enemy, phase) {
|
|
if s == enemySeat {
|
|
if abilityDealtDamage {
|
|
continue
|
|
}
|
|
st.seat(target)
|
|
mark := len(st.events)
|
|
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[target], petWhiff, petDeflect, sporeMiss)
|
|
stampEventSeats(st, mark, target)
|
|
if over && combatOver(st) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if st.actors[s].playerHP <= 0 {
|
|
// A seat that went down earlier this round forfeits its swing.
|
|
continue
|
|
}
|
|
st.seat(s)
|
|
mark := len(st.events)
|
|
over := resolvePlayerSwings(st, st.c, enemy, phase, &seats[s])
|
|
stampEventSeats(st, mark, s)
|
|
if over && combatOver(st) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// End-of-round, per surviving character.
|
|
for i := range st.actors {
|
|
st.seat(i)
|
|
if st.playerHP <= 0 {
|
|
continue
|
|
}
|
|
mark := len(st.events)
|
|
if over := endOfRoundForSeat(st, phase, &seats[i]); over {
|
|
stampEventSeats(st, mark, i)
|
|
return true
|
|
}
|
|
stampEventSeats(st, mark, i)
|
|
}
|
|
|
|
// Regenerate (monster ability): the enemy knits its wounds at the close of
|
|
// every round once the ability has armed st.enemyRegen. Fight-scoped.
|
|
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP {
|
|
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick",
|
|
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
})
|
|
}
|
|
|
|
// End-of-round Orc Rage backstop. The primary trigger sits at the top of
|
|
// resolvePlayerAttack so rage fires same-round when the character swings
|
|
// after taking the threshold-crossing hit. But if the enemy goes first this
|
|
// round AND next, they can be two-shot without ever getting back to that
|
|
// check. Re-checking here ensures the rage event always fires while HP > 0
|
|
// and below 50%, even if the buff goes unused.
|
|
for i := range st.actors {
|
|
st.seat(i)
|
|
mark := len(st.events)
|
|
maybeTriggerOrcRage(st, st.c, phaseName)
|
|
stampEventSeats(st, mark, i)
|
|
}
|
|
|
|
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
|
|
// 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
|
|
// the pre-roster order. Returns true if the fight is over.
|
|
func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult) bool {
|
|
phaseName := phase.Name
|
|
player := st.c
|
|
|
|
// Environmental hazard
|
|
if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc {
|
|
envDmg := 2 + st.roll(5)
|
|
st.playerHP = max(0, st.playerHP-envDmg)
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
|
|
Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
})
|
|
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Misty crowd revenge (debuff for declining Misty)
|
|
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
|
return true
|
|
}
|
|
|
|
// A character the round just killed stops acting, but the fight goes on if
|
|
// anyone else is standing.
|
|
if st.playerHP <= 0 {
|
|
return false
|
|
}
|
|
|
|
// Pet attack
|
|
if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc {
|
|
petDmg := player.Mods.PetAttackDmg + st.roll(5)
|
|
st.enemyHP = max(0, st.enemyHP-petDmg)
|
|
result.PetAttacked = true
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
|
|
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
})
|
|
if enemyDown(st, phaseName) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Spiritual Weapon strike
|
|
if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc {
|
|
swDmg := player.Mods.SpiritWeaponDmg + st.roll(5)
|
|
st.enemyHP = max(0, st.enemyHP-swDmg)
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
|
|
Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
})
|
|
if enemyDown(st, phaseName) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Misty heal
|
|
mistyHeal(st, player, phaseName, result)
|
|
|
|
// Consumable heal: triggers when the character drops below 60% HP. Fires up
|
|
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
|
|
// stack from inventory).
|
|
//
|
|
// Threshold is 60% rather than 50% to give low-HP classes (cleric, mage)
|
|
// more breathing room — at 50% a cleric was bleeding into the danger zone
|
|
// before the heal fired.
|
|
if st.healChargesLeft > 0 && player.Mods.HealItem > 0 &&
|
|
st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 {
|
|
st.healChargesLeft--
|
|
healAmt := player.Mods.HealItem
|
|
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
|
st.events = append(st.events, CombatEvent{
|
|
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
|
|
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
|
})
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// finalizeParty closes the fight, giving every seat its own view. The enemy's
|
|
// numbers and the event log are shared; HP, closeness and near-death are read
|
|
// per character. For one seat this is byte-for-byte the old `finalize`.
|
|
func finalizeParty(seats []CombatResult, st *combatState, players []Combatant, enemy Combatant) PartyCombatResult {
|
|
won := st.enemyHP <= 0
|
|
enemyMax := max(1, enemy.Stats.MaxHP)
|
|
|
|
// A solo fight's seat holds the whole log, which is the same slice header the
|
|
// pre-roster engine returned. A party's seats each hold their own events, so
|
|
// the per-seat close-out (heal items burned, combat achievements) counts what
|
|
// that character actually did rather than what the party did.
|
|
solo := len(seats) == 1
|
|
|
|
for i := range seats {
|
|
a := st.actors[i]
|
|
r := &seats[i]
|
|
if solo {
|
|
r.Events = st.events
|
|
} else {
|
|
r.Events = eventsForSeat(st.events, i)
|
|
}
|
|
r.PlayerEndHP = a.playerHP
|
|
r.EnemyEndHP = st.enemyHP
|
|
r.TotalRounds = st.round
|
|
r.PlayerWon = won
|
|
|
|
playerMax := max(1, players[i].Stats.MaxHP)
|
|
if won && a.playerHP > 0 {
|
|
r.NearDeath = float64(a.playerHP) < float64(playerMax)*0.15
|
|
winnerRemaining := float64(a.playerHP) / float64(playerMax)
|
|
r.Closeness = 1.0 - winnerRemaining
|
|
} else if !won {
|
|
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
|
|
r.NearDeath = enemyRemaining < 0.15
|
|
r.Closeness = 1.0 - enemyRemaining
|
|
}
|
|
}
|
|
|
|
return PartyCombatResult{
|
|
PlayerWon: won,
|
|
TotalRounds: st.round,
|
|
Events: st.events,
|
|
EnemyStartHP: enemy.Stats.MaxHP,
|
|
EnemyEntryHP: seats[0].EnemyEntryHP,
|
|
EnemyEndHP: st.enemyHP,
|
|
Seats: seats,
|
|
}
|
|
}
|