mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a round. A seat COUNT charges the same for an under-levelled friend, a hired NPC, and a true peer — so a below-median body cost a full seat's worth of boss and did not give a full seat's worth back. Measured, once the companion's free full-heal was taken away and he became honest: hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this bug, and it has been live for every under-levelled friend anyone has ever invited. Seats now carry a SeatWeight, and both levers scale on the summed weight of the LIVING seats rather than on a head count. The weight is level-based, priced against the leader, times a discount for a hireling (no subclass, no magic items, gear that is never Masterwork — the layers a player accrues and a hireling never will). Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric below a fighter and quietly make every mixed HUMAN party easier, which is a difficulty regression smuggled in under a bug fix. The safety argument is one property: **a peer weighs exactly 1.0**. So the curves interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4), 2n-1 from 3 up — and every integer input returns exactly what it always returned. Solo is byte-identical, a party of same-level humans is byte-identical, both goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is the entire point of the change. It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed the head-count half; a corpse still carried its full weight until this. Measured, 640 runs/arm, same grid: solo 69.0% (unchanged — corpus intact) + Pete 76.8% (+7.8pp) + a human cleric peer 77.6% (+8.6pp) band solo +Pete lift trailing (<40%) 10.0% 31.0% +21.0pp middle 58.9% 76.8% +17.9pp leading (>=70%) 93.5% 99.2% +5.7pp Help, never a carry: he rescues the players who were drowning and barely moves the ones who were already fine — and he stays below a real human of the leader's level, which is the invariant a hireling must never break. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
314 lines
11 KiB
Go
314 lines
11 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// runZoneCombatRoster auto-resolves one zone encounter for a whole roster.
|
|
//
|
|
// It is the seam P6e opens: before it, only elite and boss doorways seated a
|
|
// party (through the turn engine), and every other fight in a zone — exploration
|
|
// rooms, patrol encounters, harvest interrupts — resolved as the leader alone.
|
|
// On a 38-room expedition that meant a party of three fought together twice and
|
|
// the leader soloed the other ~35, which is why P7 measured zero member deaths
|
|
// and zero TPKs across 240 seats: the members were never in the fight.
|
|
//
|
|
// Roster order is the seating order, and roster[0] is the leader. Callers get
|
|
// back the seats that actually sat down, aligned index-for-index with
|
|
// res.Seats, so the character-scoped close-out (loot, death) can fan out and the
|
|
// run-scoped one (threat, kill record) can stay with the leader.
|
|
//
|
|
// A member who is down sits the encounter out. A leader who is down is a
|
|
// caller-side error — the run is theirs and the walk should not have reached
|
|
// here.
|
|
//
|
|
// Effects applied here are exactly the character-scoped ones runZoneCombat
|
|
// always applied, now per seat: HP, XP, achievements, the subclass persist, the
|
|
// heal items actually burned, and Misty's condition repair. Loot, threat, kill
|
|
// records and death remain the caller's, because only the caller knows the room.
|
|
func (p *AdventurePlugin) runZoneCombatRoster(
|
|
roster []id.UserID,
|
|
monster DnDMonsterTemplate,
|
|
tier int,
|
|
phases []CombatPhase,
|
|
dmMood int,
|
|
) (PartyCombatResult, []id.UserID, error) {
|
|
if len(roster) == 0 {
|
|
return PartyCombatResult{}, nil, fmt.Errorf("runZoneCombatRoster: empty roster")
|
|
}
|
|
if phases == nil {
|
|
phases = dungeonCombatPhases
|
|
}
|
|
|
|
type seatBuild struct {
|
|
uid id.UserID
|
|
dndChar *DnDCharacter
|
|
advChar *AdventureCharacter
|
|
equip map[EquipmentSlot]*AdvEquipment
|
|
mods CombatModifiers
|
|
// companion marks the hired NPC seat: it fights, but it owns none of the
|
|
// character-scoped effects the close-out loop applies, and its dndChar /
|
|
// advChar / equip are nil precisely so a missed guard panics loudly here
|
|
// rather than silently writing rows for a bot.
|
|
companion bool
|
|
}
|
|
|
|
var (
|
|
players []Combatant
|
|
builds []seatBuild
|
|
seated []id.UserID
|
|
enemy Combatant
|
|
// Parallel to players: what each seat is worth, priced once the roster is
|
|
// final. A member who sat the encounter out is in none of these.
|
|
levels []int
|
|
companions []bool
|
|
)
|
|
|
|
for i, uid := range roster {
|
|
leader := i == 0
|
|
|
|
// The leader's failures are the encounter's failures; a member's only
|
|
// cost them their seat.
|
|
bail := func(err error) (PartyCombatResult, []id.UserID, error) {
|
|
return PartyCombatResult{}, nil, err
|
|
}
|
|
|
|
// The hired companion fights here too — the auto-resolve path is where
|
|
// most expedition rooms are actually decided. He is synthesized rather
|
|
// than loaded, and his seatBuild is flagged so the close-out loop below
|
|
// gives him no XP, no loot, and no post-combat persistence.
|
|
if isCompanionSeat(uid) {
|
|
expID := companionExpeditionFor(roster[0])
|
|
class, level := companionLoadout(expID)
|
|
player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
|
// The wounds he carries into this fight. His synthetic sheet is always
|
|
// written at full HP, so applyDnDHPScaling left StartHP at the
|
|
// "enter at MaxHP" sentinel and he healed himself for free between every
|
|
// room of the run. See companionSeatHP.
|
|
player.Stats.StartHP = companionSeatHP(expID, player.Stats.MaxHP)
|
|
if leader {
|
|
enemy = e
|
|
}
|
|
players = append(players, player)
|
|
builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true})
|
|
seated = append(seated, uid)
|
|
levels, companions = append(levels, level), append(companions, true)
|
|
continue
|
|
}
|
|
|
|
advChar, err := loadAdvCharacter(uid)
|
|
if err != nil || advChar == nil {
|
|
if leader {
|
|
return bail(fmt.Errorf("load adv character: %w", err))
|
|
}
|
|
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
equip, err := loadAdvEquipment(uid)
|
|
if err != nil {
|
|
if leader {
|
|
return bail(fmt.Errorf("load equipment: %w", err))
|
|
}
|
|
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
|
|
// A downed member sits it out. They own no close-out claim — and the
|
|
// check runs before the arm, so sitting out doesn't cost them the
|
|
// ability they had readied.
|
|
if !leader {
|
|
if hp, _ := dndHPSnapshot(uid); hp <= 0 {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Auto-resolve builds and fights in one breath, so this seat can arm and
|
|
// apply together. The turn-based path has to split the two — see
|
|
// consumeArmedAbility.
|
|
armed := ""
|
|
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
|
|
trySimAutoArm(c)
|
|
armed = consumeArmedAbility(c)
|
|
}
|
|
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
|
if err != nil {
|
|
if leader {
|
|
return bail(err)
|
|
}
|
|
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
if leader {
|
|
enemy = e
|
|
}
|
|
|
|
// Pre-combat one-shots that the turn-based path does NOT share: a queued
|
|
// spell and the panic-heal consumable trigger. Both mutate this seat's
|
|
// Combatant once, before the fight runs. The queued spell can also debuff
|
|
// the shared enemy, so every seat's cast lands on the one stat block.
|
|
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
|
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
|
|
|
|
players = append(players, player)
|
|
builds = append(builds, seatBuild{
|
|
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
|
})
|
|
seated = append(seated, uid)
|
|
lvl := 0
|
|
if dndChar != nil {
|
|
lvl = dndChar.Level
|
|
}
|
|
levels, companions = append(levels, lvl), append(companions, false)
|
|
}
|
|
|
|
// What each seat costs the enemy, priced against the leader — over the seats
|
|
// that were actually seated, so a member left behind is not charged.
|
|
ptrs := make([]*Combatant, len(players))
|
|
for i := range players {
|
|
ptrs[i] = &players[i]
|
|
}
|
|
applySeatWeights(ptrs, levels, companions)
|
|
|
|
res := simulateParty(players, enemy, phases)
|
|
dumpCombatEventsIfDebug(
|
|
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
|
|
res.Seats[0])
|
|
|
|
for i, b := range builds {
|
|
seatRes := res.Seats[i]
|
|
|
|
// The companion swings and then goes back to filing copy: no inventory to
|
|
// deduct from, no sheet to persist to, no XP to earn. Every call below
|
|
// would either write rows for a bot or log an error about the rows it
|
|
// hasn't got.
|
|
//
|
|
// His HP is the exception, and it is not bookkeeping — it is the fight's
|
|
// result. Without it he re-enters the next room at full while the humans
|
|
// beside him carry every wound to camp.
|
|
if b.companion {
|
|
_ = setCompanionHP(companionExpeditionFor(roster[0]), seatRes.PlayerEndHP)
|
|
continue
|
|
}
|
|
|
|
// Remove the actual heal items consumed during combat (one inventory item
|
|
// per heal_item event this seat fired). Cheapest-tier first.
|
|
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
|
|
|
|
// Misty condition repair (post-combat, same 20% chance as the arena and
|
|
// encounter paths in combat_bridge.go). Gourmet food keeps her gear in
|
|
// shape on long expeditions, not just in the arena.
|
|
if b.advChar.MistyBuffExpires != nil && time.Now().UTC().Before(*b.advChar.MistyBuffExpires) {
|
|
if rand.Float64() < 0.20 {
|
|
npcRepairMostDamaged(b.uid, b.equip, 5)
|
|
}
|
|
}
|
|
|
|
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
|
|
p.postCombatBookkeeping(b.uid, b.dndChar, b.mods.BerserkerRage, seatRes, b.mods)
|
|
|
|
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
|
|
if _, err := p.grantDnDXP(b.uid, xp); err != nil {
|
|
slog.Error("dnd: grantDnDXP zone", "user", b.uid, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return res, seated, nil
|
|
}
|
|
|
|
// zoneCombatRoster is the roster a zone encounter seats for this walker: their
|
|
// expedition's party if they lead one, otherwise just them. Downed members are
|
|
// filtered by runZoneCombatRoster, not here — this answers "who is on the
|
|
// expedition", not "who can stand up".
|
|
//
|
|
// It must not touch getActiveZoneRun: that lookup carries the §4.3 idle reap,
|
|
// and the callers are mid-walk on the run it would reap. fightRoster observes
|
|
// the same rule, for the same reason.
|
|
func zoneCombatRoster(walker id.UserID) []id.UserID { return fightRoster(walker) }
|
|
|
|
// closeOutZoneWin hands loot to every seat still standing and the hospital to
|
|
// every seat that isn't. It returns the leader's own loot line — the only one
|
|
// that belongs in the leader's room narration — and who went down.
|
|
//
|
|
// The one asymmetry is deliberate. A *solo* player can win at 0 HP: a retaliate
|
|
// aura kills the swinger on the same blow that drops the enemy, and
|
|
// resolvePlayerAttack returns before enemyDown is consumed. Today that player is
|
|
// not marked dead, and P6e is not the place to change it — it would move the
|
|
// sim's outcome labels. A *party* marks its downed seats dead on a win, which is
|
|
// exactly what the turn-engine close-out has always done (finishPartyWin).
|
|
func (p *AdventurePlugin) closeOutZoneWin(
|
|
res PartyCombatResult,
|
|
seated []id.UserID,
|
|
zone ZoneDefinition,
|
|
monster DnDMonsterTemplate,
|
|
isBoss, elite bool,
|
|
deathSource string,
|
|
) (leaderDrop string, downed []id.UserID) {
|
|
party := len(seated) > 1
|
|
for i, uid := range seated {
|
|
// The companion takes no cut and cannot die. He is not in the downed list
|
|
// either: that list is what the room narration mourns, and the party did
|
|
// not lose a friend when the hireling took a nap.
|
|
if isCompanionSeat(uid) {
|
|
continue
|
|
}
|
|
if res.Seats[i].PlayerEndHP > 0 {
|
|
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
|
|
if i == 0 {
|
|
leaderDrop = drop
|
|
}
|
|
continue
|
|
}
|
|
// Down when the enemy fell. No loot — they never saw it drop.
|
|
downed = append(downed, uid)
|
|
if party {
|
|
markAdventureDead(uid, deathSource, zone.Display)
|
|
}
|
|
}
|
|
return leaderDrop, downed
|
|
}
|
|
|
|
// closeOutZoneLoss marks every seat the fight actually killed. A loss is not the
|
|
// same as a death: the engine's timeout is a retreat ("ran out the clock"), and
|
|
// it leaves HP where it was. So death is read per seat off HP, never off the
|
|
// fight's terminal status — which for one seat is exactly the old
|
|
// `if !result.TimedOut` rule, since a solo player at 0 HP has already ended the
|
|
// fight.
|
|
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
|
|
for i, uid := range seated {
|
|
// The companion is not killed and is not counted among the dead — he is
|
|
// not in the graveyard, and a wipe that lists him would have the news bot
|
|
// reporting its own funeral.
|
|
if isCompanionSeat(uid) {
|
|
continue
|
|
}
|
|
if res.Seats[i].PlayerEndHP <= 0 {
|
|
markAdventureDead(uid, deathSource, zone.Display)
|
|
killed = append(killed, uid)
|
|
}
|
|
}
|
|
return killed
|
|
}
|
|
|
|
// partyCasualtyLine names the members a fight put on the floor, for the leader's
|
|
// narration. Empty for a solo walker, and empty when everyone walked away.
|
|
func partyCasualtyLine(downed []id.UserID) string {
|
|
if len(downed) == 0 {
|
|
return ""
|
|
}
|
|
names := make([]string, 0, len(downed))
|
|
for _, uid := range downed {
|
|
name, _ := loadDisplayName(uid)
|
|
if name == "" {
|
|
name = uid.Localpart()
|
|
}
|
|
names = append(names, "**"+name+"**")
|
|
}
|
|
return "💀 " + joinNames(names) + " went down."
|
|
}
|