mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 L2: rip legacy arena combat path; boss flow is the only path
Cancels the planned ARENA_BOSS_FLOW soak. The boss flow is shipped unconditionally with no legacy fallback — boss-flow errors now surface to the player and abort the round rather than silently falling back to the old CombatPower path. Deleted: - runArenaCombat (combat_bridge.go) — legacy CombatPower-vs-Lethality arena combat driver. - RenderCombatLogArena, renderArenaOutcome (combat_narrative.go) — legacy arena renderers. RenderCombatLogArena was a thin alias for RenderCombatLog; renderArenaOutcome was already dead. - renderArenaCombatFinalMessage (combat_bridge.go) — legacy "rewards + closer" trailing text. - arenaWinCloser, arenaLoseCloser (adventure_arena_combat.go) — flavor helpers that only fed the deleted renderers. File now just holds arenaParticipationXP. - arenaBossFlowEnabled + the ARENA_BOSS_FLOW env var (adventure_arena.go, .env.example). The env gate served only the now-removed fallback. - sendArenaCombatMessages (adventure_arena.go) — wrapper that switched pacing between boss flow and legacy. Replaced with direct sendZoneCombatMessages calls at the three call sites. - TestArenaBossFlowEnabled (bossflow test) and TestRenderCombatLogArena_ProducesPhaseMessages (narrative test). Simplified resolveArenaRound / resolveArenaSurvival / resolveArenaDeath: the bossNarr-vs-nil branches collapsed since narration is now always produced by resolveArenaBoss. Equipment degradation and the death-save reprieve hook are unchanged — both already worked off the boss-flow CombatResult. Migration doc (§4) updated to record the cancellation and the remaining tuning approach (playtest-driven, no flag soak window). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -193,46 +192,30 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// arenaBossNarration carries the staged-narration trio produced by
|
||||
// resolveArenaBoss (Phase L2 step 4b). When non-nil it tells the
|
||||
// survival/death handlers to skip the legacy RenderCombatLogArena +
|
||||
// dnd opening/closing/roll-summary stack and instead use the
|
||||
// pre-rendered boss-flow intro/phases/outcome.
|
||||
// resolveArenaBoss: an intro line, the per-phase combat log, and the
|
||||
// outcome block (TwinBee mood + headline + dice summary).
|
||||
type arenaBossNarration struct {
|
||||
intro string
|
||||
phases []string
|
||||
outcome string
|
||||
}
|
||||
|
||||
// resolveArenaRound runs a single arena round through the combat engine and
|
||||
// dispatches to survival or death handlers. When ARENA_BOSS_FLOW is set, the
|
||||
// round is routed through resolveArenaBoss (zone-boss combat + staged
|
||||
// narration) and the resulting CombatResult / narration are threaded into
|
||||
// the existing economic glue.
|
||||
// resolveArenaRound runs a single arena round through resolveArenaBoss
|
||||
// (zone-boss combat + staged narration) and threads the result into the
|
||||
// surrounding economic glue. There is no legacy fallback — a boss-flow
|
||||
// error surfaces to the player and aborts the round.
|
||||
func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error {
|
||||
var (
|
||||
result CombatResult
|
||||
condRepair int
|
||||
bossNarr *arenaBossNarration
|
||||
)
|
||||
|
||||
if arenaBossFlowEnabled() {
|
||||
intro, phases, outcome, res, err := p.resolveArenaBoss(ctx.Sender, ArenaBossEncounter{
|
||||
Tier: run.Tier,
|
||||
Round: run.Round,
|
||||
DisplayName: char.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("arena: boss flow failed, falling back to legacy", "user", ctx.Sender, "err", err)
|
||||
} else {
|
||||
result = res
|
||||
bossNarr = &arenaBossNarration{intro: intro, phases: phases, outcome: outcome}
|
||||
}
|
||||
}
|
||||
if bossNarr == nil {
|
||||
result, condRepair = p.runArenaCombat(ctx.Sender, char, equip, monster, run.Round, run.Tier)
|
||||
intro, phases, outcome, result, err := p.resolveArenaBoss(ctx.Sender, ArenaBossEncounter{
|
||||
Tier: run.Tier,
|
||||
Round: run.Round,
|
||||
DisplayName: char.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("arena: boss flow failed", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Arena combat encountered an error. Try again in a moment.")
|
||||
}
|
||||
bossNarr := &arenaBossNarration{intro: intro, phases: phases, outcome: outcome}
|
||||
|
||||
// Apply event-based equipment degradation
|
||||
degradation := combatDegradation(result, equip)
|
||||
for slot, eq := range equip {
|
||||
if d, ok := degradation[slot]; ok && d > 0 {
|
||||
@@ -240,9 +223,7 @@ func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, c
|
||||
}
|
||||
}
|
||||
|
||||
deathSaved := checkDeathSaveEvent(result.Events)
|
||||
|
||||
if deathSaved {
|
||||
if checkDeathSaveEvent(result.Events) {
|
||||
now := time.Now().UTC()
|
||||
char.DeathReprieveLast = &now
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
@@ -253,14 +234,6 @@ func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, c
|
||||
if !result.PlayerWon {
|
||||
return p.resolveArenaDeath(ctx, run, char, tier, monster, result, bossNarr)
|
||||
}
|
||||
|
||||
// Misty condition repair (post-combat) for the legacy path. Boss flow
|
||||
// runs through runZoneCombat, which calls npcRepairMostDamaged
|
||||
// internally — no surfaced return value needed.
|
||||
if condRepair > 0 {
|
||||
npcRepairMostDamaged(ctx.Sender, equip, condRepair)
|
||||
}
|
||||
|
||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, result, bossNarr)
|
||||
}
|
||||
|
||||
@@ -366,30 +339,9 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
}
|
||||
run.XPAccumulated += battleXP
|
||||
|
||||
// Render combat log as phased messages + final outcome. Boss-flow path
|
||||
// uses pre-rendered intro/phases/outcome; legacy path builds them here.
|
||||
var (
|
||||
phaseMessages []string
|
||||
finalMessage string
|
||||
)
|
||||
if bossNarr != nil {
|
||||
phaseMessages = bossFlowPhaseMessages(bossNarr)
|
||||
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
|
||||
finalMessage = bossNarr.outcome + fmt.Sprintf("\n🏆 +%d XP | €%d earned", battleXP, reward)
|
||||
} else {
|
||||
phaseMessages = RenderCombatLogArena(result, char.DisplayName, monster.Name)
|
||||
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
|
||||
if open := dndCombatOpeningLine(); open != "" && len(phaseMessages) > 0 {
|
||||
phaseMessages[0] = "_" + open + "_\n\n" + phaseMessages[0]
|
||||
}
|
||||
finalMessage = renderArenaCombatFinalMessage(result, monster, reward, battleXP, run.Round)
|
||||
// Boss = T5 final round. Use BossDeath flavor for that fight's win.
|
||||
isBoss := run.Tier == 5
|
||||
finalMessage += dndItalicize(dndCombatClosingLine(true, isBoss))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
finalMessage += "\n" + rollLine
|
||||
}
|
||||
}
|
||||
phaseMessages := bossFlowPhaseMessages(bossNarr)
|
||||
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
|
||||
finalMessage := bossNarr.outcome + fmt.Sprintf("\n🏆 +%d XP | €%d earned", battleXP, reward)
|
||||
|
||||
// Suppress the "(at risk)" line on the tier-completing round — those earnings
|
||||
// are about to be locked in by the tier-complete branch below, so labelling
|
||||
@@ -433,7 +385,7 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
finalMessage += fmt.Sprintf("\n🏆 **Tier %d cleared!** Round earnings: €%d + completion bonus: €%d (×%.1f streak)\n",
|
||||
tier.Number, tierRaw, tier.CompletionBonus, multiplier)
|
||||
|
||||
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
|
||||
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
|
||||
<-done
|
||||
return p.arenaCompleteSession(ctx.Sender, run, char, "")
|
||||
}
|
||||
@@ -452,7 +404,7 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
p.postArenaDropAnnouncement(char.DisplayName, dropped)
|
||||
}
|
||||
|
||||
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
|
||||
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
|
||||
go func() {
|
||||
<-done
|
||||
p.arenaCountdown(ctx.Sender, run)
|
||||
@@ -474,19 +426,10 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
finalMessage += "`!arena fight` — Face this opponent"
|
||||
}
|
||||
|
||||
p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMessage, bossNarr != nil)
|
||||
p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendArenaCombatMessages dispatches arena phase narration. Boss-flow uses
|
||||
// the tighter zone-combat pacing (2–3s); legacy uses arena pacing (5–8s).
|
||||
func (p *AdventurePlugin) sendArenaCombatMessages(userID id.UserID, phases []string, final string, bossFlow bool) <-chan struct{} {
|
||||
if bossFlow {
|
||||
return p.sendZoneCombatMessages(userID, phases, final)
|
||||
}
|
||||
return p.sendCombatMessages(userID, phases, final)
|
||||
}
|
||||
|
||||
// bossFlowPhaseMessages prepends the resolveArenaBoss intro line to the
|
||||
// staged combat phases, mirroring streamOrSend's intro+phases pattern in
|
||||
// dnd_zone_cmd.go.
|
||||
@@ -504,15 +447,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
||||
run.LastMonster = monster.Name
|
||||
lostEarnings := run.Earnings + run.TierEarnings
|
||||
|
||||
var phaseMessages []string
|
||||
if bossNarr != nil {
|
||||
phaseMessages = bossFlowPhaseMessages(bossNarr)
|
||||
} else {
|
||||
phaseMessages = RenderCombatLogArena(result, char.DisplayName, monster.Name)
|
||||
if open := dndCombatOpeningLine(); open != "" && len(phaseMessages) > 0 {
|
||||
phaseMessages[0] = "_" + open + "_\n\n" + phaseMessages[0]
|
||||
}
|
||||
}
|
||||
phaseMessages := bossFlowPhaseMessages(bossNarr)
|
||||
|
||||
dt := transitionDeath(DeathTransitionParams{
|
||||
Char: char,
|
||||
@@ -545,16 +480,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
||||
insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
|
||||
upsertArenaStats(run.UserID, 0, true, run.Tier)
|
||||
|
||||
var finalMsg string
|
||||
if bossNarr != nil {
|
||||
finalMsg = bossNarr.outcome + fmt.Sprintf("\n+%d XP (participation) | Back tomorrow.", arenaParticipationXP)
|
||||
} else {
|
||||
finalMsg = renderArenaCombatFinalMessage(result, monster, 0, arenaParticipationXP, run.Round)
|
||||
finalMsg += dndItalicize(dndCombatClosingLine(false, false))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
finalMsg += "\n" + rollLine
|
||||
}
|
||||
}
|
||||
finalMsg := bossNarr.outcome + fmt.Sprintf("\n+%d XP (participation) | Back tomorrow.", arenaParticipationXP)
|
||||
if dt.PetRecovered {
|
||||
finalMsg += fmt.Sprintf("\n\nYour pet dragged you out of the arena. Death timer reduced. All session earnings forfeited.")
|
||||
} else {
|
||||
@@ -567,7 +493,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
||||
}
|
||||
}
|
||||
|
||||
done := p.sendArenaCombatMessages(ctx.Sender, phaseMessages, finalMsg, bossNarr != nil)
|
||||
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMsg)
|
||||
|
||||
if dt.PetRecovered {
|
||||
gr := gamesRoom()
|
||||
@@ -1301,25 +1227,12 @@ func (p *AdventurePlugin) arenaRollGladiatorHelm(userID id.UserID, maxTierCleare
|
||||
return "The Gladiator's Helm"
|
||||
}
|
||||
|
||||
// ── Adv 2.0 boss-flow round resolver (Phase L2 step 4) ──────────────────────
|
||||
// ── Arena boss-flow round resolver ──────────────────────────────────────────
|
||||
//
|
||||
// resolveArenaBoss is the future arena combat path: a single arena
|
||||
// round routed through runZoneCombat + renderBossOutcome so the player
|
||||
// sees the same staged narration that zone bosses use (Nat20/Nat1 mood
|
||||
// lines, phase-two barb on T3+, BossDeath/PlayerDeath flavor, dice
|
||||
// summary). The legacy CombatPower-vs-Lethality flow stays in place
|
||||
// until ARENA_BOSS_FLOW soak completes — wiring lands in the next step.
|
||||
|
||||
// arenaBossFlowEnabled gates the new path on the ARENA_BOSS_FLOW env
|
||||
// var so the legacy code path remains the default until soak passes.
|
||||
// Empty string and "0" / "false" disable; anything else enables.
|
||||
func arenaBossFlowEnabled() bool {
|
||||
switch os.Getenv("ARENA_BOSS_FLOW") {
|
||||
case "", "0", "false", "FALSE", "False":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// resolveArenaBoss is the arena combat path: a single arena round
|
||||
// routed through runZoneCombat + renderBossOutcome so the player sees
|
||||
// the same staged narration zone bosses use (Nat20/Nat1 mood lines,
|
||||
// phase-two barb on T3+, BossDeath/PlayerDeath flavor, dice summary).
|
||||
|
||||
// ArenaBossEncounter is the single-round input for resolveArenaBoss.
|
||||
// Tier and Round identify the arenaBosses entry; DisplayName is the
|
||||
|
||||
@@ -84,30 +84,6 @@ func TestArenaBossPhaseTwoAt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase L2 step 4b — flag gate. Empty / "0" / "false" disable the new
|
||||
// boss-flow path; anything else (including "1") enables it.
|
||||
func TestArenaBossFlowEnabled(t *testing.T) {
|
||||
cases := []struct {
|
||||
val string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"0", false},
|
||||
{"false", false},
|
||||
{"FALSE", false},
|
||||
{"False", false},
|
||||
{"1", true},
|
||||
{"true", true},
|
||||
{"on", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Setenv("ARENA_BOSS_FLOW", c.val)
|
||||
if got := arenaBossFlowEnabled(); got != c.want {
|
||||
t.Errorf("arenaBossFlowEnabled with %q = %v want %v", c.val, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase L2 step 4b — staged-narration assembly. The intro line leads,
|
||||
// followed by the combat-log phases, mirroring streamOrSend's
|
||||
// intro+phases pattern in dnd_zone_cmd.go.
|
||||
|
||||
@@ -1,36 +1,3 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
const arenaParticipationXP = 60
|
||||
|
||||
// ── Closer Lines ───────────────────────────────────────────────────────────
|
||||
|
||||
func arenaWinCloser(loserName string, lastRound int) string {
|
||||
closers := []string{
|
||||
"%s fought. It counts.",
|
||||
"%s will be back. The arena keeps score.",
|
||||
fmt.Sprintf("%%s has until tomorrow to think about round %d.", lastRound),
|
||||
"%s gave you more trouble than you'd like to admit. They don't need to know that.",
|
||||
"%s loses this one. The next one is an open question.",
|
||||
"%s came here to fight and did. The result is a separate matter.",
|
||||
"%s is already planning the rematch. You can feel it.",
|
||||
}
|
||||
return fmt.Sprintf(closers[rand.IntN(len(closers))], loserName)
|
||||
}
|
||||
|
||||
func arenaLoseCloser(winnerName string, lastRound int) string {
|
||||
closers := []string{
|
||||
"You fought. It counts.",
|
||||
"You'll be back. The arena keeps score.",
|
||||
fmt.Sprintf("You have until tomorrow to think about round %d.", lastRound),
|
||||
fmt.Sprintf("You gave %s more trouble than they'd like to admit. Small comfort. Still comfort.", winnerName),
|
||||
"You lose this one. The next one is an open question.",
|
||||
"You came here to fight and did. The result is a separate matter.",
|
||||
fmt.Sprintf("%s won this one. You're already planning the rematch.", winnerName),
|
||||
}
|
||||
return closers[rand.IntN(len(closers))]
|
||||
}
|
||||
|
||||
@@ -203,8 +203,8 @@ func arenaGetMonster(tier, round int) *ArenaMonster {
|
||||
//
|
||||
// HP/AC/Attack are first-pass tier-banded values; BaseLethality biases
|
||||
// each monster up or down within its tier band so round-1 is the
|
||||
// weakest fight and round-4 is the cap. Final tuning happens during
|
||||
// the ARENA_BOSS_FLOW flag soak (gogobee_legacy_migration.md §4 Risk).
|
||||
// weakest fight and round-4 is the cap. Tuning is ongoing against
|
||||
// playtest data (gogobee_legacy_migration.md §4 Risk).
|
||||
var arenaBosses = map[string]DnDMonsterTemplate{}
|
||||
|
||||
// arenaBossID composes the canonical arena bestiary key.
|
||||
|
||||
@@ -10,99 +10,6 @@ import (
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// runArenaCombat executes arena combat using the new simulation engine.
|
||||
// Returns the CombatResult and the post-combat Misty condition repair amount (if any).
|
||||
func (p *AdventurePlugin) runArenaCombat(
|
||||
userID id.UserID,
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
monster *ArenaMonster,
|
||||
arenaRound int,
|
||||
arenaTier int,
|
||||
) (CombatResult, int) {
|
||||
bonuses := p.loadCombatBonuses(userID, char)
|
||||
chatLvl := p.chatLevel(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
|
||||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)
|
||||
|
||||
// Load consumables from inventory and auto-select
|
||||
consumables := p.loadConsumableInventory(userID)
|
||||
enemyStats, _ := DeriveArenaMonsterStats(monster)
|
||||
|
||||
// All combat is D&D. If the player has no sheet yet (or only a draft),
|
||||
// auto-migrate them to a sensible inferred character before fighting.
|
||||
dndChar, freshMigrate, err := ensureDnDCharacterForCombat(userID, char)
|
||||
if err != nil {
|
||||
slog.Error("dnd: ensureDnDCharacterForCombat (arena) failed", "user", userID, "err", err)
|
||||
return CombatResult{}, 0
|
||||
}
|
||||
if freshMigrate {
|
||||
p.maybeSendDnDOnboarding(userID, char, dndChar)
|
||||
}
|
||||
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", "user", userID, "ability", firedName)
|
||||
}
|
||||
applyDnDArenaMonsterLayer(&enemyStats, monster.ThreatLevel)
|
||||
applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats)
|
||||
|
||||
selected := SelectConsumables(consumables, playerStats, enemyStats, arenaRound, arenaTier)
|
||||
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
||||
|
||||
// Misty condition repair (post-combat, not part of engine)
|
||||
condRepair := 0
|
||||
now := time.Now().UTC()
|
||||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||
if rand.Float64() < 0.20 {
|
||||
condRepair = 5
|
||||
}
|
||||
}
|
||||
|
||||
player := Combatant{
|
||||
Name: char.DisplayName,
|
||||
Stats: playerStats,
|
||||
Mods: playerMods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: monster.Name,
|
||||
Stats: enemyStats,
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
Ability: monster.Ability,
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, defaultCombatPhases)
|
||||
result = injectConsumableEvents(result, selected, len(consumables))
|
||||
|
||||
// Consume used items from inventory
|
||||
for _, c := range selected {
|
||||
if err := removeAdvInventoryItem(c.InventoryID); err != nil {
|
||||
slog.Error("combat: failed to consume item", "id", c.InventoryID, "name", c.Def.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist (arena)", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
if xp := arenaCombatXP(result, monster.ThreatLevel); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
slog.Error("dnd: grantDnDXP arena", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, condRepair
|
||||
}
|
||||
|
||||
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||||
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
||||
if p.achievements == nil {
|
||||
@@ -658,15 +565,3 @@ func (p *AdventurePlugin) resolveDungeonAction(
|
||||
return result
|
||||
}
|
||||
|
||||
// renderArenaCombatFinalMessage builds the post-combat text (rewards, tier progress, etc.)
|
||||
// This is sent as the last message after the phase-by-phase combat messages.
|
||||
func renderArenaCombatFinalMessage(result CombatResult, monster *ArenaMonster, reward int64, battleXP int, round int) string {
|
||||
if result.PlayerWon {
|
||||
closer := arenaWinCloser(monster.Name, round)
|
||||
return fmt.Sprintf("💀 **%s** has been defeated.\n%s\n🏆 +%d XP | €%d earned",
|
||||
monster.Name, closer, battleXP, reward)
|
||||
}
|
||||
closer := arenaLoseCloser(monster.Name, round)
|
||||
return fmt.Sprintf("The healers are already moving.\n💀 **Defeated.**\n%s\n+%d XP (participation) | Back tomorrow.",
|
||||
closer, arenaParticipationXP)
|
||||
}
|
||||
|
||||
@@ -70,11 +70,6 @@ func RenderCombatLog(result CombatResult, playerName, enemyName string) []string
|
||||
return msgs
|
||||
}
|
||||
|
||||
// RenderCombatLogArena is RenderCombatLog for arena fights.
|
||||
func RenderCombatLogArena(result CombatResult, playerName, enemyName string) []string {
|
||||
return RenderCombatLog(result, playerName, enemyName)
|
||||
}
|
||||
|
||||
type phaseGroup struct {
|
||||
Name string
|
||||
Events []CombatEvent
|
||||
@@ -297,35 +292,6 @@ func renderOutcome(result CombatResult, playerName, enemyName string, reward int
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderArenaOutcome(result CombatResult, playerName, enemyName string, reward int64, xp int, tier, round int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if result.PlayerWon {
|
||||
sb.WriteString(fmt.Sprintf("💀 **%s** has been defeated.\n", enemyName))
|
||||
closer := arenaWinCloser(enemyName, round)
|
||||
sb.WriteString(closer + "\n")
|
||||
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned", xp, reward))
|
||||
} else {
|
||||
sb.WriteString("The healers are already moving.\n")
|
||||
sb.WriteString("💀 **Defeated.**\n")
|
||||
closer := arenaLoseCloser(enemyName, round)
|
||||
sb.WriteString(closer + "\n")
|
||||
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.", arenaParticipationXP))
|
||||
}
|
||||
|
||||
if result.SniperKilled {
|
||||
sb.WriteString("\n" + pickRand(narrativeSniperKill))
|
||||
}
|
||||
if result.MistyHealed {
|
||||
sb.WriteString("\n🌿 Misty's healing made the difference.")
|
||||
}
|
||||
if result.PetAttacked {
|
||||
sb.WriteString("\n🐾 Your pet contributed to the fight.")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func pickRand(pool []string) string {
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
@@ -106,27 +106,6 @@ func TestRenderCombatLog_LossPhaseMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLogArena_ProducesPhaseMessages(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 60,
|
||||
PlayerEndHP: 70,
|
||||
EnemyEndHP: 0,
|
||||
TotalRounds: 3,
|
||||
Closeness: 0.3,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 30, PlayerHP: 100},
|
||||
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 0, PlayerHP: 70},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLogArena(result, "Hero", "Ratticus")
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_ConsumableEvents(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
|
||||
Reference in New Issue
Block a user