mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +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:
@@ -171,6 +171,9 @@ func atoiSafe(s string) int {
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest string) error {
|
||||
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil {
|
||||
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
|
||||
}
|
||||
if rest == "" {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"`!zone enter <id|#>` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.")
|
||||
@@ -379,11 +382,36 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
if run == nil {
|
||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||
}
|
||||
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil {
|
||||
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
|
||||
}
|
||||
_ = applyMoodDecayIfStale(run)
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
prev := run.CurrentRoomType()
|
||||
prevIdx := run.CurrentRoom
|
||||
|
||||
// Elite/Boss rooms are manual: !zone advance stops at the doorway and the
|
||||
// player engages with !fight. Patrols don't roll while standing at the
|
||||
// door — only on the advance that walked the player here, which already
|
||||
// fired below on the previous room. A won CombatSession for the encounter
|
||||
// means the fight's done; fall through so the graph clears the room.
|
||||
if prev == RoomElite || prev == RoomBoss {
|
||||
encID := encounterIDForRoom(prevIdx)
|
||||
sess, serr := getCombatSessionForEncounter(run.RunID, encID)
|
||||
if serr != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+serr.Error())
|
||||
}
|
||||
if sess == nil || sess.Status != CombatStatusWon {
|
||||
kind := "Elite"
|
||||
if prev == RoomBoss {
|
||||
kind = "Boss"
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"**Room %d/%d — %s.** Type `!fight` to engage.",
|
||||
prevIdx+1, run.TotalRooms, kind))
|
||||
}
|
||||
}
|
||||
|
||||
// §4.1 Patrol Encounter: at Threat-Clock Alert+, patrols may move
|
||||
// through cleared rooms. Roll on `!advance` *before* the next room's
|
||||
// own resolution. Player KO ends the run.
|
||||
@@ -562,10 +590,12 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
||||
return
|
||||
case RoomExploration:
|
||||
return p.resolveCombatRoom(userID, run, zone, false)
|
||||
case RoomElite:
|
||||
return p.resolveCombatRoom(userID, run, zone, true)
|
||||
case RoomBoss:
|
||||
return p.resolveBossRoom(userID, run, zone)
|
||||
case RoomElite, RoomBoss:
|
||||
// Manual turn-based combat (!fight / !attack). zoneCmdAdvance only
|
||||
// reaches resolveRoom for an Elite/Boss room once a won CombatSession
|
||||
// exists for it — so there's nothing to resolve here. Return empty
|
||||
// and let the caller advance the graph past the now-cleared room.
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -681,71 +711,6 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
return
|
||||
}
|
||||
|
||||
// resolveBossRoom runs the zone-boss bestiary entry through the same
|
||||
// combat path as room combat. Win → caller drops zone loot.
|
||||
//
|
||||
// Returns staged messages — see resolveCombatRoom for the contract.
|
||||
func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
monster, ok := dndBestiary[zone.Boss.BestiaryID]
|
||||
if !ok {
|
||||
outcome = fmt.Sprintf("_(Boss %s not in bestiary — skipping.)_", zone.Boss.Name)
|
||||
return
|
||||
}
|
||||
preHP, _ := dndHPSnapshot(userID)
|
||||
// Bosses use bossCombatPhases — wider Sudden Death budget so a player
|
||||
// grinding the boss down has time to actually close the HP gap.
|
||||
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), bossCombatPhases, run.DMMood)
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
return
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
|
||||
|
||||
intro = fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC)
|
||||
|
||||
playerName := "You"
|
||||
if name, _ := loadDisplayName(userID); name != "" {
|
||||
playerName = name
|
||||
}
|
||||
phases = RenderCombatLog(result, playerName, monster.Name)
|
||||
|
||||
outcome = renderBossOutcome(BossOutcomeInputs{
|
||||
ZoneID: zone.ID,
|
||||
RunID: run.RunID,
|
||||
RoomIdx: run.CurrentRoom,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: preHP,
|
||||
PostHP: postHP,
|
||||
MaxHP: maxHP,
|
||||
PhaseTwoAt: zone.Boss.PhaseTwoAt,
|
||||
Nat20s: nat20s,
|
||||
Nat1s: nat1s,
|
||||
DefeatHeadline: fmt.Sprintf("💀 **%s** stands over your body. Run ended.", monster.Name),
|
||||
VictoryHeadline: fmt.Sprintf("🏆 **%s** falls. You finished at **%d/%d HP**.", monster.Name, postHP, maxHP),
|
||||
})
|
||||
if !result.PlayerWon {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
_ = abandonZoneRun(userID)
|
||||
if !result.TimedOut {
|
||||
markAdventureDead(userID, "zone", zone.Display)
|
||||
}
|
||||
ended = true
|
||||
return
|
||||
}
|
||||
recordZoneKillForUser(userID, monster.ID)
|
||||
// §8.1: zone boss defeat drops expedition threat by 20. Silent
|
||||
// no-op for standalone zone runs (no active expedition).
|
||||
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
}
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, true); drop != "" {
|
||||
outcome = outcome + "\n" + drop
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// BossOutcomeInputs is the carrier struct for renderBossOutcome — the
|
||||
// shared staged-narration body used by zone bosses (resolveBossRoom)
|
||||
// and arena bosses (resolveArenaBoss, post-L2). Pure inputs: every
|
||||
|
||||
Reference in New Issue
Block a user