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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user