mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N3/P6e: a party that fights every room
Only elite and boss doorways seated a roster. Everything else -- exploration
rooms, patrol encounters, harvest interrupts -- resolved through SimulateCombat
against ctx.Sender, and P6d made the walk commands leader-only. So on a 38-room
T5 expedition a party of three fought together twice and the leader soloed the
other ~35, then died alone while two untouched members stood at full HP.
The plan said the N-body core was already there and only the callers passed one
player. It wasn't: SimulateCombat built a one-seat roster internally. But the
resolution primitives already read st.c -- the cursor's Combatant -- because the
turn engine has called them that way since P3. Only the round loop needed
widening.
combat_engine_party.go carries it: simulateParty, simulatePartyRound,
roundInitiative, enemyTargetSeat. Every roster short-circuit collapses for one
seat, copying P3's solo exemptions, so the RNG draw order is unchanged and
SimulateCombat is now simulateParty([]Combatant{p}, ...).Seats[0].
TestCombatCharacterization is byte-identical; TestSimulateCombat_IsTheOneSeatPartyCase
pins the delegation event-for-event across 40 seeds.
zone_combat_party.go carries the callers' half: runZoneCombatRoster fans out the
character-scoped close-out (HP, XP, achievements, subclass, heal items burned,
Misty's repair) per seat, while loot, threat, kill records and death stay with
whoever knows the room. runZoneCombat remains the explicit solo entry point --
the arena calls it, and an arena bout must never drag in a party.
Death is read per seat off HP, never off the fight's terminal status: a timed-out
party can still have lost somebody, and a solo player at 0 HP has already ended
the fight, so PlayerEndHP <= 0 is exactly the old !TimedOut rule.
Preserved deliberately: a solo player can win at 0 HP (a retaliate aura kills the
swinger on the killing blow, and resolvePlayerAttack returns before enemyDown is
consumed) and is not marked dead. A party marks its downed seats dead on a win,
which is what finishPartyWin always did.
Solo T5 re-sweep is unregressed (fighter 47-73%, cleric 20-33%). Party of 3 now
clears 100% of every T5 cell, which is P8's problem: the enemy takes one turn per
round and swings at one seat, so a party of N deals xN damage and each member
takes ~1/N^2 of the solo incoming. An HP scalar cannot close that -- it restores
the fight's duration, not the enemy's action economy.
This commit is contained in:
@@ -482,333 +482,12 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
|
||||
// The characterization test and the turn-based engine pass a seeded *rand.Rand
|
||||
// so a fight is fully reproducible. Passing nil is behaviorally identical to
|
||||
// the pre-injection code.
|
||||
//
|
||||
// This is the one-seat case of the N-body engine in combat_engine_party.go.
|
||||
// Every roster short-circuit in there collapses for a single player, so the
|
||||
// draw order — and therefore TestCombatCharacterization — is unchanged.
|
||||
func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *rand.Rand) CombatResult {
|
||||
playerStart := player.Stats.MaxHP
|
||||
if player.Stats.StartHP > 0 && player.Stats.StartHP < player.Stats.MaxHP {
|
||||
playerStart = player.Stats.StartHP
|
||||
}
|
||||
enemyStart := enemy.Stats.MaxHP
|
||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||
enemyStart = enemy.Stats.StartHP
|
||||
}
|
||||
// Solo roster: one seat, cursor parked on it for the whole fight. The
|
||||
// resolution primitives read the cursor, so this is the degenerate case
|
||||
// of the N-player model and draws RNG in exactly the pre-roster order.
|
||||
seat0 := newActor(&player)
|
||||
seat0.playerHP = playerStart
|
||||
st := &combatState{
|
||||
actor: seat0,
|
||||
actors: []*actor{seat0},
|
||||
enemyHP: enemyStart,
|
||||
enemySkipFirst: player.Mods.SpellEnemySkipFirst,
|
||||
rng: rng,
|
||||
}
|
||||
|
||||
result := CombatResult{
|
||||
PlayerStartHP: player.Stats.MaxHP,
|
||||
PlayerEntryHP: playerStart,
|
||||
EnemyStartHP: enemy.Stats.MaxHP,
|
||||
EnemyEntryHP: enemyStart,
|
||||
}
|
||||
|
||||
// Pre-combat: Arina sniper check
|
||||
if player.Mods.SniperKillProc > 0 && st.randFloat() < player.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,
|
||||
Desc: "Arina",
|
||||
})
|
||||
result.SniperKilled = true
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
|
||||
// Pre-combat: Coal Bomb / flat start damage
|
||||
if player.Mods.FlatDmgStart > 0 {
|
||||
dmg := player.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,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-combat: queued spell. Resolved by applyPendingCast() before this
|
||||
// runs — the modifiers carry the resolved damage and narrative hook.
|
||||
if player.Mods.SpellPreDamageDesc != "" {
|
||||
dmg := player.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,
|
||||
Desc: player.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,
|
||||
})
|
||||
}
|
||||
if st.enemyHP <= 0 {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
|
||||
|
||||
// Main simulation loop
|
||||
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 simulateRound(st, &player, &enemy, &phase, &result) {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhaust all phases without a kill, tiebreak by HP percentage
|
||||
// to decide the *outcome* (PlayerWon flag) — but DO NOT zero out HP
|
||||
// on the loser. Timeout = retreat, not lethal blow. Caller treats a
|
||||
// timeout loss as "fight ended, no character death".
|
||||
//
|
||||
// Absolute-HP tiebreak (briefly used on the legacy ~120 HP scale) was
|
||||
// wrong post HP-unification: monster pools (~30–175) are 2-3× player
|
||||
// pools (~13–83), so absolute always favored the larger combatant
|
||||
// even when the player took less proportional damage. Slight bias
|
||||
// toward the player on exact ties (frac >=).
|
||||
result.TimedOut = true
|
||||
playerFrac := float64(st.playerHP) / float64(max(1, player.Stats.MaxHP))
|
||||
enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP))
|
||||
playerWonTiebreak := playerFrac >= enemyFrac
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
out := finalize(result, st, player, enemy)
|
||||
out.PlayerWon = playerWonTiebreak
|
||||
return out
|
||||
}
|
||||
|
||||
// simulateRound runs one round. Returns true if combat is over.
|
||||
func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the
|
||||
// enemy's attack for one round. Logged as a dedicated event so narrative
|
||||
// can read it as a held/stunned beat rather than a generic miss.
|
||||
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
|
||||
abilityDealtDamage := enemyHeldThisRound
|
||||
if enemy.Ability != nil {
|
||||
if abilityFires(enemy.Ability, phaseName, st) {
|
||||
if applyAbility(st, player, enemy, phase, result) {
|
||||
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
|
||||
if st.poisonTicks > 0 {
|
||||
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,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if trySave(st, player, phaseName) {
|
||||
// survived
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
|
||||
petWhiff := player.Mods.PetWhiffProc > 0 && st.randFloat() < player.Mods.PetWhiffProc
|
||||
// Pet deflect: halves incoming damage to player this round
|
||||
petDeflect := player.Mods.PetDeflectProc > 0 && st.randFloat() < player.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
result.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 the player's
|
||||
// roll via player.Mods.InitiativeBias — +X means player goes first
|
||||
// more often, -X means the enemy does.
|
||||
playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight
|
||||
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
|
||||
playerInit := playerSpeed + st.randFloat()*10 + player.Mods.InitiativeBias
|
||||
enemyInit := enemySpeed + st.randFloat()*10
|
||||
playerFirst := playerInit >= enemyInit
|
||||
|
||||
if playerFirst {
|
||||
if resolvePlayerSwings(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
if !abilityDealtDamage {
|
||||
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !abilityDealtDamage {
|
||||
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if resolvePlayerSwings(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Misty crowd revenge (debuff for declining Misty)
|
||||
if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
|
||||
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 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
||||
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 player 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,
|
||||
})
|
||||
}
|
||||
|
||||
// Regenerate (monster ability): the enemy knits its wounds at the close of
|
||||
// every round once the ability has armed st.enemyRegen.
|
||||
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 player
|
||||
// swings after taking the threshold-crossing hit. But if the enemy
|
||||
// goes first this round AND next, the player 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.
|
||||
maybeTriggerOrcRage(st, player, phaseName)
|
||||
|
||||
return false
|
||||
return simulatePartyWithRNG([]Combatant{player}, enemy, phases, rng).Seats[0]
|
||||
}
|
||||
|
||||
// maybeTriggerOrcRage emits the "rage" event and arms pendingRageAttack
|
||||
@@ -1620,24 +1299,3 @@ func trySave(st *combatState, player *Combatant, phaseName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func finalize(result CombatResult, st *combatState, player, enemy Combatant) CombatResult {
|
||||
result.Events = st.events
|
||||
result.PlayerEndHP = st.playerHP
|
||||
result.EnemyEndHP = st.enemyHP
|
||||
result.TotalRounds = st.round
|
||||
result.PlayerWon = st.enemyHP <= 0
|
||||
|
||||
playerMax := max(1, player.Stats.MaxHP)
|
||||
enemyMax := max(1, enemy.Stats.MaxHP)
|
||||
if result.PlayerWon && st.playerHP > 0 {
|
||||
result.NearDeath = float64(st.playerHP) < float64(playerMax)*0.15
|
||||
winnerRemaining := float64(st.playerHP) / float64(playerMax)
|
||||
result.Closeness = 1.0 - winnerRemaining
|
||||
} else if !result.PlayerWon {
|
||||
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
|
||||
result.NearDeath = enemyRemaining < 0.15
|
||||
result.Closeness = 1.0 - enemyRemaining
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user