mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user