mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Combat engine §1: the hired companion can cast
Every spell lookup in the engine is keyed on a Matrix user id and answered by a dnd_* table. The companion has rows in none of them, deliberately — a sheet on disk is what would turn him into a real character everywhere. So the auto-picker's first statement, LoadDnDCharacter(uid), came back nil and returned "attack", every turn, for the whole fight. A hired Cleric swung a mace while the party died. Role-fill hands a lone martial a Cleric, so that was the common case of the feature. Adds a seat-scoped spellbook: seatKnownSpells / seatSpellSlots / seatKnowsSpell / consumeSeatSlot / refundSeatSlot. A human seat delegates to the DB functions verbatim — same queries, same order — so solo combat and the balance corpus are untouched (both goldens byte-identical). A companion seat is answered from his synthetic sheet and a slot ledger on his seat's persisted statuses. The seat is the correct home and not 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. He gets the same default kit a real character of his class and level gets. The below-median stays where it was — the level penalty, the never-Masterwork gear, the absent subclass and magic items. A bespoke weaker spell list would be a second nerf hidden in a different file. castActionForSeat was also a live hazard: it loaded the caster through ensureCharForDnDCmd, whose auto-migration branch, handed a user with no sheet, builds one at level 1 and *saves* it. Pointed at the companion that silently makes him a player. He now takes a branch that never reaches it, and a test counts rows in dnd_character / dnd_known_spells / dnd_spell_slots / player_meta to keep it that way. Measured, 640 runs/arm (10 classes x L10,L12 x 4 zones): solo 66.1% + Pete, mace-only (HEAD) 83.4% (+17.3pp) + Pete, casting 95.9% (+29.8pp) The fix does what it should. It also lands on top of an unpaid §2(a): the mace-only arm shows Pete was ALREADY a carry, taking the trailing band from 6.8% to 63.6% without casting a thing. The tell is the cleric leader, who role-fills a *Fighter* Pete — a seat this commit cannot touch — and still goes 26.6% -> 98.4%. That is enemy scaling undercharging for a seat, not spells. §2(a) is next, and is not optional. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
This commit is contained in:
@@ -1210,8 +1210,16 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio
|
||||
// which is seat 0. Driving a party member's turn off the leader's HP would heal
|
||||
// the wrong person and re-arm the wrong aura.
|
||||
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
if c == nil || sess == nil {
|
||||
if sess == nil {
|
||||
return "attack", ""
|
||||
}
|
||||
// seatPickSheet, not LoadDnDCharacter: the hired companion has no character row
|
||||
// and never will, so the raw load returned nil for him and this function bailed
|
||||
// to "attack" on its first line — every turn, for the whole fight. That one line
|
||||
// is why a hired Cleric swung a mace while the party died. He fights off the
|
||||
// same synthetic sheet the rest of his seat is built from.
|
||||
c := seatPickSheet(sess, uid)
|
||||
if c == nil {
|
||||
return "attack", ""
|
||||
}
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
@@ -1240,7 +1248,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
// otherwise never spends an L2 slot on it. Force the pick once
|
||||
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
||||
// it's not a damage option.
|
||||
if id := simPickSpiritualWeapon(c, uid, st); id != "" {
|
||||
if id := simPickSpiritualWeapon(c, sess, seat, uid, st); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
// Once a concentration aura is up, a competent caster maintains it and
|
||||
@@ -1248,7 +1256,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
// slot to re-arm the same aura — so the picker excludes concentration
|
||||
// spells while one is active.
|
||||
auraActive := st.ConcentrationDmg > 0
|
||||
if id := simPickSpell(c, uid, auraActive); id != "" {
|
||||
if id := simPickSpell(c, sess, seat, uid, auraActive); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
}
|
||||
@@ -1283,14 +1291,14 @@ func simMartialFirstClass(class DnDClass) bool {
|
||||
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
|
||||
// not worth burning the bigger slot's damage potential elsewhere; sim
|
||||
// behaves like a competent player and saves the high slot.
|
||||
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string {
|
||||
func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, st ActorStatuses) string {
|
||||
if c == nil || c.Class != ClassCleric {
|
||||
return ""
|
||||
}
|
||||
if st.BuffSpiritProc > 0 {
|
||||
return ""
|
||||
}
|
||||
known, err := listKnownSpells(uid)
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -1304,7 +1312,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
||||
if !prepared {
|
||||
return ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
const simMaxSlot = 5
|
||||
for sl := 2; sl <= simMaxSlot; sl++ {
|
||||
pair, ok := slots[sl]
|
||||
@@ -1377,11 +1385,11 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
|
||||
|
||||
// The cheapest heal that is actually castable — burning a 5th-level slot to
|
||||
// top somebody up is not what a competent player does.
|
||||
known, err := listKnownSpells(uid)
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
best, bestLevel := "", 99
|
||||
for _, k := range known {
|
||||
if !k.Prepared {
|
||||
@@ -1416,12 +1424,12 @@ func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat i
|
||||
return best, id.UserID(sess.seatUserID(worst)).Localpart()
|
||||
}
|
||||
|
||||
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
|
||||
known, err := listKnownSpells(uid)
|
||||
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {
|
||||
known, err := seatKnownSpells(sess, seat, uid)
|
||||
if err != nil || len(known) == 0 {
|
||||
return ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
slots, _ := seatSpellSlots(sess, seat, uid)
|
||||
type cand struct {
|
||||
id string
|
||||
slot int
|
||||
|
||||
Reference in New Issue
Block a user