mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Combat: wire turn-based elite/boss fights to the command surface
Routes Elite/Boss rooms off the auto-resolve SimulateCombat path and onto the persisted turn-based engine. !zone advance now stops at an Elite/Boss doorway; the player engages with !fight, then resolves one full round per !attack / !flee. A won CombatSession is the record that the room's combat is done, so a fresh !zone advance clears the room and advances the graph. - buildZoneCombatants: shared player/enemy Combatant builder extracted from runZoneCombat; combatantsForSession rebuilds the pair from a session row. - runCombatRound loops the phase state machine through a whole round; finishCombatSession runs HP/XP/loot/kill/threat/mood close-out. - getCombatSessionForEncounter lets the room resolver tell "already won" apart from "not yet fought". - !zone advance/enter/go blocked while a session is active. - resolveBossRoom deleted (dead after the reroute). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
125
internal/plugin/combat_session_build.go
Normal file
125
internal/plugin/combat_session_build.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 13 — Combatant reconstruction for turn-based fights.
|
||||
//
|
||||
// The auto-resolve path (runZoneCombat) builds a player/enemy Combatant pair,
|
||||
// runs SimulateCombat, and discards them. The turn-based path needs the same
|
||||
// pair, but rebuilt fresh on every player command from persisted session
|
||||
// state — so the build is extracted here as buildZoneCombatants, and
|
||||
// combatantsForSession wraps it for the resume case.
|
||||
//
|
||||
// HP is deliberately NOT carried by the Combatant pair: the turn engine reads
|
||||
// live HP from the CombatSession row (sess.PlayerHP / sess.EnemyHP), so the
|
||||
// rebuilt Combatants only need correct Stats/Mods/Ability. Re-deriving from
|
||||
// current character state each round is safe because zone equipment, streaks,
|
||||
// and bonuses don't change mid-fight.
|
||||
//
|
||||
// applyPendingCast and the auto-heal consumable setup are intentionally left
|
||||
// out of the shared builder — those are one-shot mutations that runZoneCombat
|
||||
// applies once before SimulateCombat. Re-applying them on every turn-based
|
||||
// round would double-consume. Per-round !cast / !consume is a later sub-phase.
|
||||
|
||||
// buildZoneCombatants derives the player/enemy Combatant pair for a zone
|
||||
// encounter: the player's full D&D layer (stats + class/race/subclass passives
|
||||
// + armed ability) and the monster's bestiary stat block with tier scaling and
|
||||
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
|
||||
// callers can run post-combat subclass persistence without reloading it.
|
||||
func (p *AdventurePlugin) buildZoneCombatants(
|
||||
userID id.UserID,
|
||||
monster DnDMonsterTemplate,
|
||||
tier int,
|
||||
dmMood int,
|
||||
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
||||
tilt := dmMoodCombatTilt(dmMood)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil {
|
||||
return Combatant{}, Combatant{}, nil, fmt.Errorf("load adv character: %w", err)
|
||||
}
|
||||
equip, err := loadAdvEquipment(userID)
|
||||
if err != nil {
|
||||
return Combatant{}, Combatant{}, nil, fmt.Errorf("load equipment: %w", err)
|
||||
}
|
||||
bonuses := p.loadCombatBonuses(userID, char)
|
||||
chatLvl := p.chatLevel(userID)
|
||||
|
||||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, false)
|
||||
|
||||
dndChar, _, err = ensureDnDCharacterForCombat(userID, char)
|
||||
if err != nil {
|
||||
return Combatant{}, Combatant{}, nil, fmt.Errorf("ensure dnd character: %w", err)
|
||||
}
|
||||
applyDnDPlayerLayer(&playerStats, dndChar)
|
||||
applyDnDEquipmentLayer(&playerStats, dndChar, equip)
|
||||
applyDnDHPScaling(&playerStats, dndChar)
|
||||
applyClassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
||||
}
|
||||
|
||||
enemyStats, enemyMods := monster.toCombatStats()
|
||||
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
|
||||
// tier floor — boss/elite stat blocks already encode their challenge, so
|
||||
// we never double-scale a block that's already above the floor.
|
||||
if tier > 1 {
|
||||
floorAC := dndDungeonACBase + tier
|
||||
if enemyStats.AC < floorAC {
|
||||
enemyStats.AC = floorAC
|
||||
}
|
||||
floorAB := dndDungeonAtkBase + tier
|
||||
if enemyStats.AttackBonus < floorAB {
|
||||
enemyStats.AttackBonus = floorAB
|
||||
}
|
||||
}
|
||||
|
||||
// DM mood tilts: monster Attack delta + player initiative bias.
|
||||
enemyStats.Attack += tilt.EnemyAttackDelta
|
||||
if enemyStats.Attack < 1 {
|
||||
enemyStats.Attack = 1
|
||||
}
|
||||
playerMods.InitiativeBias += tilt.InitiativeBias
|
||||
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
player = Combatant{
|
||||
Name: displayName,
|
||||
Stats: playerStats,
|
||||
Mods: playerMods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy = Combatant{
|
||||
Name: monster.Name,
|
||||
Stats: enemyStats,
|
||||
Mods: enemyMods,
|
||||
Ability: monster.Ability,
|
||||
}
|
||||
return player, enemy, dndChar, nil
|
||||
}
|
||||
|
||||
// combatantsForSession reconstructs the Combatant pair for an in-flight
|
||||
// CombatSession. It reloads the zone run for the DM mood + tier and looks the
|
||||
// enemy up in the bestiary by the session's EnemyID. The pair carries no HP —
|
||||
// the turn engine reads that from the session row.
|
||||
func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) {
|
||||
run, rerr := getZoneRun(sess.RunID)
|
||||
if rerr != nil {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("load zone run: %w", rerr)
|
||||
}
|
||||
if run == nil {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID)
|
||||
}
|
||||
monster, ok := dndBestiary[sess.EnemyID]
|
||||
if !ok {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID)
|
||||
}
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood)
|
||||
return player, enemy, err
|
||||
}
|
||||
Reference in New Issue
Block a user