mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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:
@@ -6,7 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -117,6 +116,11 @@ func zoneSelectorHash(runID string, roomIdx int) uint64 {
|
||||
//
|
||||
// Returns the engine result so the caller can branch on PlayerWon and
|
||||
// fold combat events into the per-room narration.
|
||||
// runZoneCombat resolves a zone encounter for exactly one player. It is the
|
||||
// one-seat case of runZoneCombatRoster, and the entry point for every fight
|
||||
// that is not on an expedition — the arena's encounter bouts included, which is
|
||||
// why the roster is not resolved here: an arena fighter must never drag their
|
||||
// party into the ring.
|
||||
func (p *AdventurePlugin) runZoneCombat(
|
||||
userID id.UserID,
|
||||
monster DnDMonsterTemplate,
|
||||
@@ -124,61 +128,11 @@ func (p *AdventurePlugin) runZoneCombat(
|
||||
phases []CombatPhase,
|
||||
dmMood int,
|
||||
) (CombatResult, error) {
|
||||
if phases == nil {
|
||||
phases = dungeonCombatPhases
|
||||
}
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil {
|
||||
return CombatResult{}, fmt.Errorf("load adv character: %w", err)
|
||||
}
|
||||
equip, err := loadAdvEquipment(userID)
|
||||
if err != nil {
|
||||
return CombatResult{}, fmt.Errorf("load equipment: %w", err)
|
||||
}
|
||||
|
||||
// Player/enemy Combatant pair — shared with the turn-based engine. The
|
||||
// builder folds in the D&D layer, tier scaling, and the DM-mood tilt.
|
||||
player, enemy, dndChar, err := p.buildZoneCombatants(userID, monster, tier, dmMood)
|
||||
res, _, err := p.runZoneCombatRoster([]id.UserID{userID}, monster, tier, phases, dmMood)
|
||||
if err != nil {
|
||||
return CombatResult{}, err
|
||||
}
|
||||
|
||||
// Pre-combat one-shots that the turn-based path does NOT share: a queued
|
||||
// spell and the panic-heal consumable trigger. Both mutate the Combatant
|
||||
// pair once, before the fight runs.
|
||||
applyPendingCast(userID, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
||||
consumables := p.loadConsumableInventory(userID)
|
||||
setupAutoHealFromInventory(consumables, &player.Mods)
|
||||
|
||||
result := SimulateCombat(player, enemy, phases)
|
||||
dumpCombatEventsIfDebug(fmt.Sprintf("zone:%s vs %s", monster.ID, player.Name), result)
|
||||
|
||||
// Remove the actual heal items consumed during combat (one inventory
|
||||
// item per heal_item event fired). Cheapest-tier first.
|
||||
consumeFiredHealingItems(userID, countHealEventsFired(result))
|
||||
|
||||
// Misty condition repair (post-combat, same 20% chance as arena/encounter
|
||||
// paths in combat_bridge.go). Mirrors the buff's intent — gourmet food
|
||||
// keeps her gear in shape on long expeditions, not just in the arena.
|
||||
if char.MistyBuffExpires != nil && time.Now().UTC().Before(*char.MistyBuffExpires) {
|
||||
if rand.Float64() < 0.20 {
|
||||
npcRepairMostDamaged(userID, equip, 5)
|
||||
}
|
||||
}
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, player.Mods.BerserkerRage, result, player.Mods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist (zone)", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
if xp := zoneCombatXP(result, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
slog.Error("dnd: grantDnDXP zone", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return res.Seats[0], nil
|
||||
}
|
||||
|
||||
// zoneCombatXP — CR-weighted XP per kill, with a tier-based floor so
|
||||
|
||||
Reference in New Issue
Block a user