mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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:
@@ -425,7 +425,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
// returns the spell plus the resolved slot level. errMsg is non-empty and
|
||||
// player-facing on any validation failure. It performs NO resource spend —
|
||||
// the caller debits the slot only once the round is about to resolve.
|
||||
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
||||
//
|
||||
// It takes the seat, not just the user, because "do you know this spell" is a
|
||||
// question about a combatant and only *usually* a question about a database row:
|
||||
// the hired companion has no rows and answers it from his synthetic sheet.
|
||||
func parseCombatCast(sess *CombatSession, seat int, userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
||||
tokens := strings.Fields(args)
|
||||
upcast := 0
|
||||
var spellTokens []string
|
||||
@@ -473,7 +477,7 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
|
||||
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
|
||||
}
|
||||
}
|
||||
known, prepared, err := playerKnowsSpell(userID, spell.ID)
|
||||
known, prepared, err := seatKnowsSpell(sess, seat, userID, spell.ID)
|
||||
if err != nil {
|
||||
return SpellDefinition{}, 0, "Couldn't check your spell list."
|
||||
}
|
||||
@@ -611,8 +615,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
return PlayerAction{}, noop, targetErr
|
||||
}
|
||||
|
||||
advChar, _ := loadAdvCharacter(uid)
|
||||
c, err := p.ensureCharForDnDCmd(uid, advChar)
|
||||
c, err := p.seatCastSheet(ct.sess, uid)
|
||||
if err != nil || c == nil {
|
||||
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
|
||||
}
|
||||
@@ -621,7 +624,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
|
||||
}
|
||||
|
||||
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
|
||||
spell, slotLevel, errMsg := parseCombatCast(ct.sess, seat, uid, c, strings.TrimSpace(args))
|
||||
if errMsg != "" {
|
||||
return PlayerAction{}, noop, errMsg
|
||||
}
|
||||
@@ -632,7 +635,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
|
||||
refund := func(ok bool) {
|
||||
if !ok && spell.Level > 0 {
|
||||
_ = refundSpellSlot(uid, slotLevel)
|
||||
_ = refundSeatSlot(ct.sess, seat, uid, slotLevel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,7 +652,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"%s has no effect the turn-based engine can apply yet.", spell.Name)
|
||||
}
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||||
@@ -671,7 +674,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
|
||||
}
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
// Park the Necromancy kill-heal stash on the casting seat. The
|
||||
@@ -735,18 +738,30 @@ func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
|
||||
// success the caller owns the slot and must refundSpellSlot if the round itself
|
||||
// errors. Material components (rare in a fight) are not refunded if the slot
|
||||
// debit then fails — matching the auto-resolve cast path.
|
||||
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||||
func (p *AdventurePlugin) chargeSpellCost(sess *CombatSession, seat int, userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||||
_, _, companion := seatCompanionLoadout(sess, userID)
|
||||
|
||||
// The companion carries no purse — he has no wallet to debit and no inventory
|
||||
// to stock one from, so a component cost is not a price he can pay but a spell
|
||||
// he cannot cast. Refusing here (rather than letting the debit fail on an empty
|
||||
// account) keeps that an explicit rule instead of an accident of his balance.
|
||||
if spell.MaterialCost > 0 {
|
||||
if companion {
|
||||
return fmt.Sprintf("%s needs a component %s doesn't carry.", spell.Name, companionDisplayName)
|
||||
}
|
||||
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
||||
}
|
||||
}
|
||||
if spell.Level > 0 {
|
||||
ok, serr := consumeSpellSlot(userID, slotLevel)
|
||||
ok, serr := consumeSeatSlot(sess, seat, userID, slotLevel)
|
||||
if serr != nil {
|
||||
return "Couldn't consume slot: " + serr.Error()
|
||||
}
|
||||
if !ok {
|
||||
if companion {
|
||||
return fmt.Sprintf("%s is out of level-%d energy.", companionDisplayName, slotLevel)
|
||||
}
|
||||
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user