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:
prosolis
2026-05-14 06:16:45 -07:00
parent 269d13ffe8
commit 886eb5a75b
8 changed files with 711 additions and 130 deletions

View File

@@ -179,6 +179,23 @@ func getCombatSession(sessionID string) (*CombatSession, error) {
return s, err
}
// getCombatSessionForEncounter returns the most recent session for a
// (run, encounter) pair regardless of status, or (nil, nil) if none exists.
// The room-resolution path uses it to tell "elite not yet fought" (nil) apart
// from "elite already won" (a terminal session) without an active-only filter.
func getCombatSessionForEncounter(runID, encounterID string) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
FROM combat_session
WHERE run_id = ? AND encounter_id = ?
ORDER BY started_at DESC
LIMIT 1`, runID, encounterID)
s, err := scanCombatSession(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
}
func scanCombatSession(row scanner) (*CombatSession, error) {
var (
s CombatSession