diff --git a/internal/db/db.go b/internal/db/db.go index c59ce5d..326f154 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 { diff --git a/internal/plugin/adventure_companion.go b/internal/plugin/adventure_companion.go index 0b83afa..2f9fccb 100644 --- a/internal/plugin/adventure_companion.go +++ b/internal/plugin/adventure_companion.go @@ -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 diff --git a/internal/plugin/combat_party_finish.go b/internal/plugin/combat_party_finish.go index 2e6ed97..7c612b7 100644 --- a/internal/plugin/combat_party_finish.go +++ b/internal/plugin/combat_party_finish.go @@ -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)) diff --git a/internal/plugin/combat_party_start.go b/internal/plugin/combat_party_start.go index 750ced8..6da2b9d 100644 --- a/internal/plugin/combat_party_start.go +++ b/internal/plugin/combat_party_start.go @@ -71,11 +71,18 @@ 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, - Mods: player.Mods, C: &player, EngineDriven: true, + UserID: uid, + HP: companionSeatHP(expID, player.Stats.MaxHP), + HPMax: player.Stats.MaxHP, + Mods: player.Mods, C: &player, EngineDriven: true, }) continue } diff --git a/internal/plugin/combat_seat_spells.go b/internal/plugin/combat_seat_spells.go index 3ae815b..1ba5bf3 100644 --- a/internal/plugin/combat_seat_spells.go +++ b/internal/plugin/combat_seat_spells.go @@ -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) } diff --git a/internal/plugin/combat_seat_spells_test.go b/internal/plugin/combat_seat_spells_test.go index 9d4ea09..cb1928e 100644 --- a/internal/plugin/combat_seat_spells_test.go +++ b/internal/plugin/combat_seat_spells_test.go @@ -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) { diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index c93c973..49b13b3 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -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"` diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 47b38e3..3d216ce 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -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) } diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index e448316..c7caa1f 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -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 ` 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 { diff --git a/internal/plugin/zone_combat_party.go b/internal/plugin/zone_combat_party.go index ffd5a2d..b8e4322 100644 --- a/internal/plugin/zone_combat_party.go +++ b/internal/plugin/zone_combat_party.go @@ -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 }