mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Companion: he carries his wounds, rations his slots, and heals himself
Three defects, all the same mistake, all found by sweep and not by tests: the companion has no database row for a thing to persist onto, so the thing "arrives fresh next time" — which for a resource means infinite. 1. His spell slots refilled every fight. The ledger went on his combat SEAT, and a seat is per-session. A human rations one pool across a 30-room run and gets it back at camp; rationing it IS the caster's game. Now on expedition_party.companion_slots_used, refreshed at camp. (Worth ~0pp alone — a run holds only ~2 real fights, so the pool never binds. I predicted this was the whole answer. It was not.) 2. His BODY refilled every fight. buildFightSeats seated him at Stats.MaxHP and the close-out skipped him — "he arrives fresh next time", said the comment. That is an infinite body: he soaked a share of every fight's incoming and then reset, while the humans beside him bled all the way to camp. THIS was the carry. Now expedition_party.companion_hp; healed at camp; a dropped companion returns on 1 HP rather than as a corpse, because there is no companion-death rule and inventing one inside a bug fix would be a second feature. 3. No autopiloted caster had ever healed ITSELF. simPickAllyHeal skipped `i == seat` and bailed on !IsParty(), so a solo cleric carried cure_wounds for a whole run and never once cast it. Now simPickHeal: heal whoever is worst off, which is sometimes you. Measured, 640 runs/arm, like-for-like (the leaders whose role-fill gives Pete a Cleric, against a human Cleric follower of the leader's own level): solo 69.0% + a human cleric 77.6% (+8.6pp) + Pete 66.1% (-2.9pp) The reference arm is the point. Against SOLO even a mace-only Pete looked like a carry — but parties are designed to be safer, so solo is the wrong yardstick. Against a human peer the real bug appeared: a gearless, level-penalized hireling was out-clearing a fully-geared human cleric of the leader's own level by 15pp, because he was the only combatant in the game who healed to full between fights. With the free lunches gone he is honest, and honestly a net negative — which is exactly the plan's §2 diagnosis, unmasked: a below-median seat cannot pay for its own enemy scaling (+15% boss HP and 2.4 enemy actions a round instead of 1). §2(a) is next, and the sweep now argues FOR it; before this commit it would have made things worse. Self-heal moved solo 66.1% -> 66.2%, so the balance corpus is undisturbed and no re-baseline is owed. It is also NOT the answer to §6 — casters reach for a healing consumable first and the sim stocks them, so a human rarely falls through to the spell. Pete carries no consumables, so it is his only heal. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
This commit is contained in:
@@ -410,6 +410,32 @@ func runMigrations(d *sql.DB) error {
|
||||
// where isCompanionSeat holds — a player's row never consults them.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_class TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_level INTEGER NOT NULL DEFAULT 0`,
|
||||
|
||||
// The companion's spell-slot ledger, "used" per slot level, as a compact
|
||||
// CSV of six ints (index 0 unused; cantrips cost nothing).
|
||||
//
|
||||
// It has to live on the *expedition*, next to the class and level it is a
|
||||
// pool for. A human caster's slots are dnd_spell_slots rows that persist
|
||||
// across every fight of the run and only come back at camp, so rationing a
|
||||
// pool across a 30-room day IS the caster's game. The first cut of this
|
||||
// parked the companion's ledger on his combat seat instead — and a seat is
|
||||
// per-session, so he walked into every single fight with a full pool. The
|
||||
// sim caught it: a level-penalized, gearless hireling out-cleared a human
|
||||
// cleric of the leader's own level by 15pp.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_slots_used TEXT NOT NULL DEFAULT ''`,
|
||||
|
||||
// The companion's body, carried across the run. -1 means "unset" — he is
|
||||
// at full, which is what a fresh hire is.
|
||||
//
|
||||
// He used to re-seat at full max HP for *every* fight, because he has no
|
||||
// dnd_character row for his HP to persist onto and the close-out skipped
|
||||
// him ("he arrives fresh next time"). That is an infinite body: a player
|
||||
// bleeds across a 30-room run and only heals at camp, while the hireling
|
||||
// soaked half the incoming damage and reset. Measured, it is most of why a
|
||||
// gearless, level-penalized hireling out-cleared a human cleric of the
|
||||
// leader's own level — his party fled 5 runs out of 640 where the human
|
||||
// party fled 56.
|
||||
`ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -412,6 +413,153 @@ func companionLoadout(expeditionID string) (DnDClass, int) {
|
||||
return DnDClass(class), level
|
||||
}
|
||||
|
||||
// ── his spell slots, which live on the expedition ────────────────────────────
|
||||
//
|
||||
// A human caster's slots are dnd_spell_slots rows: one pool, spent across every
|
||||
// fight of the run, refilled only at camp. Rationing it is the caster's game. The
|
||||
// companion has no rows, so his pool lives on his roster row — the same row his
|
||||
// class and level live on, and with the same lifetime.
|
||||
//
|
||||
// It must NOT live on his combat seat. A seat is per-session and every fight opens
|
||||
// a new one, so a seat-scoped pool refills itself between fights: an infinite
|
||||
// caster. That is not a theory — the first cut did exactly that, and the sim
|
||||
// measured a gearless, level-penalized hireling out-clearing a human cleric of the
|
||||
// leader's own level by 15pp.
|
||||
|
||||
// companionSlotsCSV encodes/decodes the ledger. CSV of six ints rather than JSON
|
||||
// because it is six ints.
|
||||
func companionSlotsDecode(s string) [6]int {
|
||||
var out [6]int
|
||||
for i, f := range strings.Split(s, ",") {
|
||||
if i >= len(out) {
|
||||
break
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(f))
|
||||
if err != nil || n < 0 {
|
||||
continue
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func companionSlotsEncode(used [6]int) string {
|
||||
parts := make([]string, len(used))
|
||||
for i, n := range used {
|
||||
parts[i] = strconv.Itoa(n)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// companionSlotsForRun reads the ledger for the companion on the expedition that
|
||||
// owns runID. A run with no companion (or no expedition) reads as an empty pool,
|
||||
// which is the correct answer: nobody spent anything.
|
||||
func companionSlotsForRun(runID string) [6]int {
|
||||
var raw string
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT p.companion_slots_used
|
||||
FROM expedition_party p
|
||||
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
|
||||
WHERE e.run_id = ? AND p.user_id = ?`,
|
||||
runID, string(companionUserID())).Scan(&raw)
|
||||
if err != nil {
|
||||
return [6]int{}
|
||||
}
|
||||
return companionSlotsDecode(raw)
|
||||
}
|
||||
|
||||
// setCompanionSlotsForRun writes it back.
|
||||
func setCompanionSlotsForRun(runID string, used [6]int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party
|
||||
SET companion_slots_used = ?
|
||||
WHERE user_id = ?
|
||||
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
|
||||
companionSlotsEncode(used), string(companionUserID()), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshCompanionSlots empties the ledger — his half of the camp rest that calls
|
||||
// refreshSpellSlots for every human. Keyed by expedition, because camp is.
|
||||
func refreshCompanionSlots(expeditionID string) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_slots_used = ''
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// ── his body, which is also carried across the run ───────────────────────────
|
||||
//
|
||||
// companionUnsetHP is "no wound recorded" — a fresh hire, or a companion who has
|
||||
// just broken camp. Seating reads it as full.
|
||||
const companionUnsetHP = -1
|
||||
|
||||
// companionHPFor reads the HP he carries into his next fight, or companionUnsetHP
|
||||
// when he is unhurt. A run with no companion reads unset, which is harmless: there
|
||||
// is nobody to seat.
|
||||
func companionHPFor(expeditionID string) int {
|
||||
hp := companionUnsetHP
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT companion_hp FROM expedition_party
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(companionUserID())).Scan(&hp)
|
||||
if err != nil {
|
||||
return companionUnsetHP
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
// companionSeatHP is what he actually sits down with: his carried wound, clamped
|
||||
// into [1, maxHP].
|
||||
//
|
||||
// The floor of 1 is deliberate. He can be dropped *inside* a fight — the engine
|
||||
// counts him out like any other seat — but he does not stay dead between them,
|
||||
// because there is no companion-death mechanic and inventing one here would be a
|
||||
// second feature smuggled into a bug fix. Coming back on 1 HP is a real penalty
|
||||
// (one hit and he is down again) without pretending to be a corpse rule.
|
||||
func companionSeatHP(expeditionID string, maxHP int) int {
|
||||
hp := companionHPFor(expeditionID)
|
||||
if hp == companionUnsetHP || hp > maxHP {
|
||||
return maxHP
|
||||
}
|
||||
if hp < 1 {
|
||||
return 1
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
// setCompanionHP records the HP he walked out of a fight with.
|
||||
func setCompanionHP(expeditionID string, hp int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_hp = ?
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
hp, expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// setCompanionHPForRun is setCompanionHP for the turn-based close-out, which
|
||||
// holds a run id rather than an expedition id.
|
||||
func setCompanionHPForRun(runID string, hp int) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party
|
||||
SET companion_hp = ?
|
||||
WHERE user_id = ?
|
||||
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
|
||||
hp, string(companionUserID()), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshCompanionHP is his half of the camp heal: back to full, like every human
|
||||
// at a standard rest.
|
||||
func refreshCompanionHP(expeditionID string) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE expedition_party SET companion_hp = ?
|
||||
WHERE expedition_id = ? AND user_id = ?`,
|
||||
companionUnsetHP, expeditionID, string(companionUserID()))
|
||||
return err
|
||||
}
|
||||
|
||||
// companionLoadoutForRun is companionLoadout keyed by the zone run instead of
|
||||
// the expedition. A CombatSession carries a RunID, not an expedition id, and the
|
||||
// per-turn rebuild is the hottest caller — so the join lives here rather than
|
||||
|
||||
@@ -55,12 +55,16 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||
for seat := range sess.RosterSize() {
|
||||
// The companion has no sheet to persist HP onto and no bookkeeping that
|
||||
// outlives the fight — he arrives fresh next time. Skipping him here is
|
||||
// not just tidiness: postCombatBookkeepingForSeat logs at ERROR when a
|
||||
// seat has no sheet, so leaving him in would file an error for every
|
||||
// party fight he is ever hired for.
|
||||
// The companion has no sheet, so none of the sheet-keyed bookkeeping below
|
||||
// applies to him — and postCombatBookkeepingForSeat logs at ERROR for a
|
||||
// seat with no sheet, which would file one for every party fight he is ever
|
||||
// hired for. But his HP is not bookkeeping: it is the fight's result. It
|
||||
// lands on his roster row, because that is the only row he has.
|
||||
//
|
||||
// He used to be skipped outright, and "he arrives fresh next time" was the
|
||||
// stated intent. It is a free lunch — see companionSeatHP.
|
||||
if isCompanionUser(sess.seatUserID(seat)) {
|
||||
_ = setCompanionHPForRun(sess.RunID, sess.seatHP(seat))
|
||||
continue
|
||||
}
|
||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||
|
||||
@@ -71,10 +71,17 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
// discover that would stall the fight and then announce him to the party as
|
||||
// an absent player.
|
||||
if isCompanionSeat(uid) {
|
||||
class, level := companionLoadout(companionExpeditionFor(roster[0]))
|
||||
expID := companionExpeditionFor(roster[0])
|
||||
class, level := companionLoadout(expID)
|
||||
player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
||||
// He carries his wounds between fights, like everyone else. Seating him at
|
||||
// full max HP — which is what this did — hands the party an infinite body:
|
||||
// he soaks a share of every fight's incoming and then resets, while the
|
||||
// humans beside him bleed all the way to camp.
|
||||
seats = append(seats, CombatSeatSetup{
|
||||
UserID: uid, HP: player.Stats.MaxHP, HPMax: player.Stats.MaxHP,
|
||||
UserID: uid,
|
||||
HP: companionSeatHP(expID, player.Stats.MaxHP),
|
||||
HPMax: player.Stats.MaxHP,
|
||||
Mods: player.Mods, C: &player, EngineDriven: true,
|
||||
})
|
||||
continue
|
||||
|
||||
@@ -86,15 +86,17 @@ func companionKnownSpells(class DnDClass, level int) []knownSpellRow {
|
||||
}
|
||||
|
||||
// companionSlotPool is his slot table (total, used) — the class/level progression
|
||||
// every caster shares, less what he has already spent this fight.
|
||||
func companionSlotPool(class DnDClass, level int, st ActorStatuses) map[int][2]int {
|
||||
// every caster shares, less what he has already spent. `used` is the expedition's
|
||||
// ledger, NOT a per-fight one: he rations one pool across the run and gets it back
|
||||
// at camp, exactly as a human caster does.
|
||||
func companionSlotPool(class DnDClass, level int, used [6]int) map[int][2]int {
|
||||
out := map[int][2]int{}
|
||||
for lvl, total := range slotsForClassLevel(class, level) {
|
||||
used := 0
|
||||
if lvl >= 0 && lvl < len(st.SlotsUsed) {
|
||||
used = min(st.SlotsUsed[lvl], total)
|
||||
spent := 0
|
||||
if lvl >= 0 && lvl < len(used) {
|
||||
spent = min(used[lvl], total)
|
||||
}
|
||||
out[lvl] = [2]int{total, used}
|
||||
out[lvl] = [2]int{total, spent}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -110,7 +112,7 @@ func seatKnownSpells(sess *CombatSession, seat int, uid id.UserID) ([]knownSpell
|
||||
// seatSpellSlots is getSpellSlots for a seat.
|
||||
func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) {
|
||||
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
|
||||
return companionSlotPool(class, level, sess.actorStatusesForSeat(seat)), nil
|
||||
return companionSlotPool(class, level, companionSlotsForRun(sess.RunID)), nil
|
||||
}
|
||||
return getSpellSlots(uid)
|
||||
}
|
||||
@@ -133,24 +135,27 @@ func seatKnowsSpell(sess *CombatSession, seat int, uid id.UserID, spellID string
|
||||
}
|
||||
|
||||
// consumeSeatSlot is consumeSpellSlot for a seat. The companion's spend lands on
|
||||
// his seat's persisted statuses rather than a table, so it survives the round
|
||||
// commit and a mid-fight restart — and so two players who have each hired him are
|
||||
// not sharing one slot pool, which a store keyed on his (single, shared) user id
|
||||
// would have given them.
|
||||
// the expedition's ledger — one pool for the whole run, refilled at camp — so he
|
||||
// rations his slots the way a human caster has to. Keying it by expedition also
|
||||
// keeps two parties who have each hired him from sharing a pool, which anything
|
||||
// keyed on his (single, shared) user id would have done.
|
||||
func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) {
|
||||
class, level, ok := seatCompanionLoadout(sess, uid)
|
||||
if !ok {
|
||||
return consumeSpellSlot(uid, slotLevel)
|
||||
}
|
||||
if slotLevel < 1 || slotLevel >= len(ActorStatuses{}.SlotsUsed) {
|
||||
used := companionSlotsForRun(sess.RunID)
|
||||
if slotLevel < 1 || slotLevel >= len(used) {
|
||||
return false, nil
|
||||
}
|
||||
st := sess.actorStatusesPtr(seat)
|
||||
pair, exists := companionSlotPool(class, level, *st)[slotLevel]
|
||||
pair, exists := companionSlotPool(class, level, used)[slotLevel]
|
||||
if !exists || pair[0]-pair[1] <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
st.SlotsUsed[slotLevel]++
|
||||
used[slotLevel]++
|
||||
if err := setCompanionSlotsForRun(sess.RunID, used); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -160,10 +165,10 @@ func refundSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int)
|
||||
if _, _, ok := seatCompanionLoadout(sess, uid); !ok {
|
||||
return refundSpellSlot(uid, slotLevel)
|
||||
}
|
||||
st := sess.actorStatusesPtr(seat)
|
||||
if slotLevel < 1 || slotLevel >= len(st.SlotsUsed) || st.SlotsUsed[slotLevel] <= 0 {
|
||||
used := companionSlotsForRun(sess.RunID)
|
||||
if slotLevel < 1 || slotLevel >= len(used) || used[slotLevel] <= 0 {
|
||||
return nil
|
||||
}
|
||||
st.SlotsUsed[slotLevel]--
|
||||
return nil
|
||||
used[slotLevel]--
|
||||
return setCompanionSlotsForRun(sess.RunID, used)
|
||||
}
|
||||
|
||||
@@ -116,14 +116,10 @@ func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) {
|
||||
t.Fatalf("effect = %+v, want an ally heal", action.Effect)
|
||||
}
|
||||
|
||||
// The slot came off his seat's ledger.
|
||||
if used := ct.sess.actorStatusesPtr(1).SlotsUsed[1]; used != 1 {
|
||||
t.Errorf("companion spent %d level-1 slots on his seat, want 1", used)
|
||||
}
|
||||
// ...and not off the leader's, which is the bug the seat-scoping exists to
|
||||
// prevent: one shared @pete id across every expedition that hires him.
|
||||
if used := ct.sess.actorStatusesPtr(0).SlotsUsed[1]; used != 0 {
|
||||
t.Errorf("the companion's cast debited the LEADER's seat (%d slots)", used)
|
||||
// The slot came off the expedition's ledger — which is where it has to live, so
|
||||
// that it is still spent in the NEXT fight of the same run.
|
||||
if used := companionSlotsForRun(ct.sess.RunID); used[1] != 1 {
|
||||
t.Errorf("companion spent %v level-1 slots on the run's ledger, want 1", used[1])
|
||||
}
|
||||
|
||||
for _, table := range []string{"dnd_character", "dnd_known_spells", "dnd_spell_slots", "player_meta"} {
|
||||
@@ -148,7 +144,7 @@ func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
|
||||
runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6)
|
||||
ct := petePartyFight(t, p, runID, leader, 20)
|
||||
|
||||
total := companionSlotPool(ClassCleric, 6, ActorStatuses{})[1][0]
|
||||
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
|
||||
if total <= 0 {
|
||||
t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve")
|
||||
}
|
||||
@@ -170,6 +166,95 @@ func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// His pool does NOT refill between fights, and it DOES come back at camp.
|
||||
//
|
||||
// This is the one the sweep caught and the unit tests did not. The first cut of
|
||||
// the spellbook parked his ledger on his combat seat — and a seat is per-session,
|
||||
// so every fight opened a fresh one and he walked in with full slots. A human
|
||||
// cleric rations a single pool across the whole run; rationing it IS the caster's
|
||||
// game. Handed an infinite pool, a gearless level-penalized hireling out-cleared a
|
||||
// same-level human cleric by 15pp in the sim.
|
||||
func TestCompanionSpells_PoolIsRationedAcrossTheRun(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
leader := id.UserID("@lead:example.org")
|
||||
runID := hireForFight(t, "exp-ration", leader, ClassCleric, 6)
|
||||
|
||||
// Fight one: spend every level-1 slot he owns.
|
||||
first := petePartyFight(t, p, runID, leader, 20)
|
||||
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
|
||||
for range total {
|
||||
if ok, err := consumeSeatSlot(first.sess, 1, companionUserID(), 1); err != nil || !ok {
|
||||
t.Fatalf("consume: %v (%v)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fight two, same run — a NEW combat session, which is exactly what used to
|
||||
// hand him a fresh pool.
|
||||
if err := markCombatSessionExpired(first.sess.SessionID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := petePartyFight(t, p, runID, leader, 20)
|
||||
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); ok {
|
||||
t.Error("the companion's spell slots refilled between fights — he is an infinite caster")
|
||||
}
|
||||
|
||||
// ...and camp gives them back, the same way it does for every human.
|
||||
if err := refreshCompanionSlots("exp-ration"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); !ok {
|
||||
t.Error("camp did not restore the companion's slots — his pool only ever goes down")
|
||||
}
|
||||
}
|
||||
|
||||
// He carries his wounds between fights, and camp patches him up.
|
||||
//
|
||||
// He used to re-seat at full max HP for every single fight — "he arrives fresh
|
||||
// next time" was the close-out's stated intent — which is an infinite body. A
|
||||
// player bleeds across a 30-room run and only heals at camp; the hireling soaked
|
||||
// his share of every fight and then reset. In the sim his party fled 5 runs out of
|
||||
// 640 where the same party with a *human* cleric fled 56.
|
||||
func TestCompanion_CarriesWoundsBetweenFights(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
seedExpedition(t, "exp-hp", leader, "active")
|
||||
seatLeaderFixture(t, "exp-hp")
|
||||
if err := hireCompanion("exp-hp", ClassCleric, 6); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A fresh hire is at full.
|
||||
if got := companionSeatHP("exp-hp", 100); got != 100 {
|
||||
t.Errorf("a fresh hire seats at %d/100 HP, want full", got)
|
||||
}
|
||||
|
||||
// He walks out of a fight on 30 HP; he walks into the next one on 30 HP.
|
||||
if err := setCompanionHP("exp-hp", 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := companionSeatHP("exp-hp", 100); got != 30 {
|
||||
t.Errorf("he re-seated at %d/100 HP after ending a fight on 30 — the wound did not carry", got)
|
||||
}
|
||||
|
||||
// Dropped in a fight, he comes back on his feet but barely — not as a corpse
|
||||
// (there is no companion-death rule) and not at full.
|
||||
if err := setCompanionHP("exp-hp", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := companionSeatHP("exp-hp", 100); got != 1 {
|
||||
t.Errorf("after being dropped he seats at %d/100 HP, want 1", got)
|
||||
}
|
||||
|
||||
// Camp puts him right, exactly as it does every human.
|
||||
if err := refreshCompanionHP("exp-hp"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := companionSeatHP("exp-hp", 100); got != 100 {
|
||||
t.Errorf("camp left him on %d/100 HP — his body only ever goes down", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A hired martial is still a martial: the spellbook is the class's, not a blanket
|
||||
// grant, so hiring a Fighter does not quietly buy a caster.
|
||||
func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) {
|
||||
|
||||
@@ -147,21 +147,6 @@ type ActorStatuses struct {
|
||||
// path can unset it.
|
||||
EngineDriven bool `json:"engine_driven,omitempty"`
|
||||
|
||||
// SlotsUsed is the companion's spell-slot ledger, indexed by slot level (1–5;
|
||||
// index 0 is unused, cantrips cost nothing). Zero for every human seat.
|
||||
//
|
||||
// A human's slots live in dnd_spell_slots, keyed by user id. The companion has
|
||||
// no rows there by design (see combat_seat_spells.go), so his spend is tracked
|
||||
// on the seat. The seat is also the *correct* home rather than merely the
|
||||
// available one: every expedition hires the same @pete, so a store keyed on his
|
||||
// user id would have two parties sharing one pool of slots.
|
||||
//
|
||||
// An array and not a map: ActorStatuses is a comparable value that gets copied
|
||||
// (`s := prior` in snapshotActor) and compared field-wise in the participant
|
||||
// tests. A map would alias across those copies and make the struct
|
||||
// uncomparable — both of which are how the next person gets hurt.
|
||||
SlotsUsed [6]int `json:"slots_used,omitempty"`
|
||||
|
||||
// Debuffs the enemy has stacked onto this character specifically.
|
||||
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||
|
||||
@@ -325,6 +325,13 @@ func applyCampRest(e *Expedition, kind string) string {
|
||||
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
|
||||
_ = refreshAllResources(uid)
|
||||
_ = refreshSpellSlots(uid)
|
||||
// The companion sleeps at the same fire. His slots and his wounds are not
|
||||
// dnd_spell_slots / dnd_character rows — he has none — so nothing above
|
||||
// reaches him, and without these two his pool and his body would only ever
|
||||
// go down. Camp is where a caster gets their slots back and a body gets
|
||||
// patched up, and he is at the camp.
|
||||
_ = refreshCompanionSlots(e.ID)
|
||||
_ = refreshCompanionHP(e.ID)
|
||||
_ = ReplenishHarvestNodes(e)
|
||||
}
|
||||
|
||||
|
||||
@@ -1237,7 +1237,13 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
// player does, and until the engine could target another seat it was not an
|
||||
// option the picker even had. It runs before the damage picks: a party member
|
||||
// about to die is a more urgent use of a slot than another Fireball.
|
||||
if id, target := simPickAllyHeal(c, uid, sess, seat); id != "" {
|
||||
// A healer heals whoever is worst off — the friend bleeding out beside them, or
|
||||
// themselves. An empty target is a self-cast, and `!cast <spell>` with no
|
||||
// @mention is exactly how a player writes that.
|
||||
if id, target := simPickHeal(c, uid, sess, seat); id != "" {
|
||||
if target == "" {
|
||||
return "cast", id
|
||||
}
|
||||
return "cast", id + " @" + target
|
||||
}
|
||||
|
||||
@@ -1352,15 +1358,27 @@ func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid
|
||||
// the party's damage output is what ends the fight.
|
||||
const simHealAllyThresholdPct = 45
|
||||
|
||||
// simPickAllyHeal returns the heal spell and target name a competent healer would
|
||||
// use on the worst-hurt *other* seat, or ("", "") if nobody needs it, the caster
|
||||
// cannot heal, or the fight is solo.
|
||||
// simPickHeal returns the heal spell a competent healer would cast this turn, and
|
||||
// the seat to put it on — which may be the healer's own. An empty spell id means
|
||||
// nobody needs healing, or this character cannot heal.
|
||||
//
|
||||
// This is the picker half of §1. Before the engine could target another seat there
|
||||
// was nothing for it to choose, so an autopiloted or engine-driven healer simply
|
||||
// swung a weapon while their friends died beside them.
|
||||
func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
|
||||
if sess == nil || !sess.IsParty() || c == nil || !isSpellcaster(c) {
|
||||
// Returning target "" means "cast it on yourself": the caller drops the @mention,
|
||||
// and the engine's ordinary self-heal path takes it.
|
||||
//
|
||||
// It considers the caster's own seat, and it runs for a solo fight, and BOTH of
|
||||
// those are recent. Until now the rule skipped `i == seat` and bailed on
|
||||
// `!sess.IsParty()`, which together meant something nobody had said out loud: **no
|
||||
// autopiloted caster in this game had ever cast a heal on themselves.** A solo
|
||||
// cleric carried cure_wounds for a whole 30-room run and used it exactly never,
|
||||
// while dying with a full pool of level-1 slots. That is a strong candidate for
|
||||
// why the class corpus has cleric at 46–56% against fighter's 82%
|
||||
// ([[project_d8prereq_baseline]], §6 of the combat-engine plan) — the picker was
|
||||
// not playing the class.
|
||||
//
|
||||
// The healer heals whoever is worst off. Usually that is the friend bleeding out
|
||||
// next to them. Sometimes it is them.
|
||||
func simPickHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
|
||||
if sess == nil || c == nil || !isSpellcaster(c) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
@@ -1368,9 +1386,6 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
|
||||
// and a heal aimed at a corpse is a lost turn.
|
||||
worst, worstPct := -1, 101
|
||||
for i := range sess.RosterSize() {
|
||||
if i == seat {
|
||||
continue
|
||||
}
|
||||
hp, hpMax := sess.seatHP(i), sess.seatHPMax(i)
|
||||
if hp <= 0 || hpMax <= 0 {
|
||||
continue
|
||||
@@ -1382,12 +1397,28 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
|
||||
if worst < 0 || worstPct >= simHealAllyThresholdPct {
|
||||
return "", ""
|
||||
}
|
||||
// Healing yourself is not an ally-target at all — it is the plain self-heal the
|
||||
// engine has always had. Hand the caller no target and let it say so.
|
||||
if worst == seat {
|
||||
return simPickHealSpell(c, uid, sess, seat), ""
|
||||
}
|
||||
|
||||
// The cheapest heal that is actually castable — burning a 5th-level slot to
|
||||
// top somebody up is not what a competent player does.
|
||||
best := simPickHealSpell(c, uid, sess, seat)
|
||||
if best == "" {
|
||||
return "", ""
|
||||
}
|
||||
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
|
||||
// the seats in this fight, so this round-trips without a room lookup.
|
||||
return best, id.UserID(sess.seatUserID(worst)).Localpart()
|
||||
}
|
||||
|
||||
// simPickHealSpell is the cheapest heal this caster can actually cast right now,
|
||||
// or "". Burning a 5th-level slot to top somebody up is not what a competent
|
||||
// player does, so it takes the lowest castable slot.
|
||||
func simPickHealSpell(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) string {
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
return ""
|
||||
}
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
best, bestLevel := "", 99
|
||||
@@ -1416,12 +1447,7 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
|
||||
best, bestLevel = sp.ID, sp.Level
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", ""
|
||||
}
|
||||
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
|
||||
// the seats in this fight, so this round-trips without a room lookup.
|
||||
return best, id.UserID(sess.seatUserID(worst)).Localpart()
|
||||
return best
|
||||
}
|
||||
|
||||
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {
|
||||
|
||||
@@ -79,8 +79,14 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
// 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) {
|
||||
class, level := companionLoadout(companionExpeditionFor(roster[0]))
|
||||
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
|
||||
}
|
||||
@@ -162,7 +168,12 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user