mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
A solo fight is a conversation: the player types, the engine answers, and
nothing happens in between. A party fight is a queue, and three things follow.
Turn ownership. Only the seat on the clock may act, so beginCombatTurn resolves
the sender's seat and refuses the rest before anyone spends a slot or burns an
item. A fight lock, because three members typing !attack at once took three
different user locks and the check would have passed for all three: it takes the
fight's lock (keyed on seat 0) then the member's own, always in that order, and
a solo fight -- whose owner is the sender, and sync.Mutex is not reentrant --
takes exactly the one lock it always took. And a turn deadline, because one
member who wanders off must not freeze the other two for the hour it takes the
session reaper to wake up.
The deadline is three minutes, not the plan's sixty seconds. The sweep rides the
existing one-minute ticker, so any deadline really fires in [d, d+1m); and
expeditions here run for days, so the asymmetry favours patience over robbing
someone of their boss turn while they read the room on their phone. A lapse
latches that seat onto the auto-picker for the rest of the fight, so an absent
member costs the party one wait rather than one per round. Typing anything hands
the wheel back. Solo is never swept.
Three seat-0 leaks fixed on the way past, all of which would have surfaced as
the leader quietly doing everyone's business:
- mid-fight buffs folded into the session's embedded ActorStatuses, so a
member casting Shield on themselves would have armoured the leader;
- pickAutoCombatAction read sess.PlayerHP and Statuses.ConcentrationDmg, so
playing an away member's turn would have healed the wrong person and
re-armed the wrong aura;
- runCombatRound rested on any player_turn, and a downed seat still holds one
-- the round would have come to rest on a corpse and waited for a dead
member to type !attack. settleCombatSession drains it. beginCombatTurn
settles before reading the clock, which also fixes a latent solo bug: a
fight interrupted mid enemy_turn resumed parked there and silently ate the
player's next !attack.
The narration turned out to be written in the second person -- "You score 9
damage", "A hit gets through your guard" -- so swapping a name per seat would
have told three people they each landed the same blow. A round is rendered once
per reader instead: your own events go through the untouched flavor pool, your
allies' through a terse third-person summary. CombatEvent carries the seat to
make that possible, stamped once per phase step rather than at the twenty-odd
append sites in the primitives, which emit against the cursor and know nothing
of seats.
Closing out fans along the seam the data model already cut. Threat, the
zone-kill record, the boss-defeat drop and the run teardown all resolve through
getActiveExpedition or getActiveZoneRun, and a member owns neither row -- so
they fire once, for the owner. Fanning them out would have tripled the threat a
single kill costs. HP, XP, loot and death are the character's, and every seat
gets their own. A member can be dead in a fight the party won, so death is read
per seat off HP, not off the session's status.
The reaper stays attack-only. Finishing an abandoned fight should not quietly
burn the player's spell slots and potions; the deadline latch does use the
picker, because that member is mid-fight with a party waiting on them.
startPartyCombatSession has no production caller yet -- handleFightCmd still
opens a solo session. P6 seats the party.
TestCombatCharacterization is byte-identical: solo balance did not move.
1045 lines
43 KiB
Go
1045 lines
43 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"strings"
|
|
)
|
|
|
|
// actionPicker tracks used indices per pool to avoid repeats within a fight.
|
|
type actionPicker struct {
|
|
enemy map[int]bool
|
|
player map[int]bool
|
|
playerMiss map[int]bool
|
|
enemyMiss map[int]bool
|
|
block map[int]bool
|
|
environment map[int]bool
|
|
}
|
|
|
|
func newActionPicker() *actionPicker {
|
|
return &actionPicker{
|
|
enemy: make(map[int]bool),
|
|
player: make(map[int]bool),
|
|
playerMiss: make(map[int]bool),
|
|
enemyMiss: make(map[int]bool),
|
|
block: make(map[int]bool),
|
|
environment: make(map[int]bool),
|
|
}
|
|
}
|
|
|
|
func pickFrom(pool []string, used map[int]bool, damage int) string {
|
|
if len(used) >= len(pool) {
|
|
for k := range used {
|
|
delete(used, k)
|
|
}
|
|
}
|
|
idx := rand.IntN(len(pool))
|
|
for used[idx] {
|
|
idx = (idx + 1) % len(pool)
|
|
}
|
|
used[idx] = true
|
|
return fmt.Sprintf(pool[idx], damage)
|
|
}
|
|
|
|
func pickFromNoFmt(pool []string, used map[int]bool) string {
|
|
if len(used) >= len(pool) {
|
|
for k := range used {
|
|
delete(used, k)
|
|
}
|
|
}
|
|
idx := rand.IntN(len(pool))
|
|
for used[idx] {
|
|
idx = (idx + 1) % len(pool)
|
|
}
|
|
used[idx] = true
|
|
return pool[idx]
|
|
}
|
|
|
|
// RenderCombatLog converts a CombatResult into a slice of messages — one per
|
|
// phase plus a final outcome message. The caller sends them with 5-8 second
|
|
// delays between messages to create an unfolding fight feel.
|
|
func RenderCombatLog(result CombatResult, playerName, enemyName string) []string {
|
|
picker := newActionPicker()
|
|
phases := groupEventsByPhase(result.Events)
|
|
|
|
var msgs []string
|
|
for _, pg := range phases {
|
|
msg := renderPhaseBlock(pg, playerName, enemyName, result, picker)
|
|
if msg != "" {
|
|
msgs = append(msgs, msg)
|
|
}
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
type phaseGroup struct {
|
|
Name string
|
|
Events []CombatEvent
|
|
}
|
|
|
|
func groupEventsByPhase(events []CombatEvent) []phaseGroup {
|
|
var groups []phaseGroup
|
|
var current *phaseGroup
|
|
|
|
for _, e := range events {
|
|
if current == nil || current.Name != e.Phase {
|
|
groups = append(groups, phaseGroup{Name: e.Phase})
|
|
current = &groups[len(groups)-1]
|
|
}
|
|
current.Events = append(current.Events, e)
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result CombatResult, picker *actionPicker) string {
|
|
var sb strings.Builder
|
|
|
|
header := phaseHeader(pg.Name)
|
|
sb.WriteString(header + "\n")
|
|
|
|
// Walk events with a 3+ consecutive-trivial collapse: long runs of
|
|
// "miss / miss / miss" get one TwinBee yadda-yadda line instead of N
|
|
// individual lines. Keeps boss fights (16 rounds) from drowning in
|
|
// repetition while still rendering every impactful beat.
|
|
i := 0
|
|
for i < len(pg.Events) {
|
|
e := pg.Events[i]
|
|
if isTrivialEvent(e) {
|
|
run := 1
|
|
for i+run < len(pg.Events) && isTrivialEvent(pg.Events[i+run]) {
|
|
run++
|
|
}
|
|
if run >= 3 {
|
|
sb.WriteString(pickRand(narrativeFillerSlog) + "\n")
|
|
i += run
|
|
continue
|
|
}
|
|
}
|
|
line := renderEvent(e, playerName, enemyName, result, picker)
|
|
if line != "" {
|
|
if roll := rollAnnotation(e); roll != "" {
|
|
line = line + " " + roll
|
|
}
|
|
sb.WriteString(line + "\n")
|
|
}
|
|
i++
|
|
}
|
|
|
|
if len(pg.Events) == 0 {
|
|
return ""
|
|
}
|
|
lastEvent := pg.Events[len(pg.Events)-1]
|
|
sb.WriteString(fmt.Sprintf(" [%s: %d/%d | %s: %d/%d]",
|
|
playerName, clampHP(lastEvent.PlayerHP), result.PlayerStartHP,
|
|
enemyName, clampHP(lastEvent.EnemyHP), result.EnemyStartHP))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// isTrivialEvent — events that don't move HP or set up a tactical hook.
|
|
// A run of 3+ of these collapses into a TwinBee filler line.
|
|
func isTrivialEvent(e CombatEvent) bool {
|
|
switch e.Action {
|
|
case "miss", "spore_miss", "pet_whiff":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// narrativeFillerSlog covers a run of consecutive trivial events
|
|
// (misses, whiffs) so long boss fights don't read as a wall of
|
|
// "you swing wide / they swing wide" lines.
|
|
var narrativeFillerSlog = []string{
|
|
"_The exchange settles into a rhythm. A few rounds of nothing meaningful — I skip to the next thing that matters._",
|
|
"_I narrate the next few seconds in a single sigh. Both sides keep swinging. Both sides keep missing._",
|
|
"_And the battle goes on. Yadda yadda yadda. I will narrate the next part where something actually lands._",
|
|
"_The grind continues. I use the time to mentally redesign the dungeon's lighting._",
|
|
"_A flurry of misses on both sides. Nobody is impressing anybody. I wait for the next hit._",
|
|
"_I politely decline to narrate this stretch. It was, in my professional opinion, a waste of everyone's time._",
|
|
}
|
|
|
|
func clampHP(hp int) int {
|
|
if hp < 0 {
|
|
return 0
|
|
}
|
|
return hp
|
|
}
|
|
|
|
// rollAnnotation returns a compact d20 tag for events that came from the
|
|
// d20-vs-AC resolution path. Empty for ability/heal/consumable events
|
|
// where Roll is unset. Format: "_(🎲 14 vs AC 13)_" — italicized so the
|
|
// dice fact reads as a sidebar to the narrative line.
|
|
func rollAnnotation(e CombatEvent) string {
|
|
if e.Roll <= 0 {
|
|
return ""
|
|
}
|
|
switch e.Action {
|
|
case "hit", "crit", "miss", "block":
|
|
// d20 contests — annotate with roll vs target AC.
|
|
default:
|
|
return ""
|
|
}
|
|
if e.RollAgainst > 0 {
|
|
return fmt.Sprintf("_(🎲 %d vs AC %d)_", e.Roll, e.RollAgainst)
|
|
}
|
|
return fmt.Sprintf("_(🎲 %d)_", e.Roll)
|
|
}
|
|
|
|
func phaseHeader(name string) string {
|
|
switch name {
|
|
case "Opening", "opening":
|
|
return pickRand(openingHeaders)
|
|
case "Clash", "clash":
|
|
return pickRand(clashHeaders)
|
|
case "Decisive", "decisive":
|
|
return pickRand(decisiveHeaders)
|
|
case "pre_combat", "pre":
|
|
return "⚔️ **The fight begins.**"
|
|
case "Sudden Death", "sudden_death":
|
|
return "⚔️ **Sudden Death — No Quarter**"
|
|
case "exhaust":
|
|
return "⚔️ **Exhaustion — Time Runs Out**"
|
|
default:
|
|
return "⚔️ **" + name + "**"
|
|
}
|
|
}
|
|
|
|
func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResult, picker *actionPicker) string {
|
|
switch e.Action {
|
|
case "hit":
|
|
if e.Actor == "player" {
|
|
return pickFrom(narrativePlayerHit, picker.player, e.Damage)
|
|
}
|
|
return pickFrom(narrativeEnemyHit, picker.enemy, e.Damage)
|
|
|
|
case "crit":
|
|
if e.Actor == "player" {
|
|
if e.Desc == "auto_crit" {
|
|
return pickFrom(narrativePlayerAutoCrit, picker.player, e.Damage)
|
|
}
|
|
return pickFrom(narrativePlayerCrit, picker.player, e.Damage)
|
|
}
|
|
return pickFrom(narrativeEnemyCrit, picker.enemy, e.Damage)
|
|
|
|
case "block":
|
|
if e.Actor == "player" {
|
|
return fmt.Sprintf(pickRand(narrativeEnemyBlock), e.Damage)
|
|
}
|
|
return fmt.Sprintf(pickRand(narrativePlayerBlock), e.Damage)
|
|
|
|
case "miss":
|
|
if e.Actor == "player" {
|
|
return pickFromNoFmt(narrativePlayerMiss, picker.playerMiss)
|
|
}
|
|
return pickFromNoFmt(narrativeEnemyMiss, picker.enemyMiss)
|
|
|
|
case "pet_attack":
|
|
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
|
|
|
|
case "spirit_weapon_strike":
|
|
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
|
|
|
|
case "concentration_tick":
|
|
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
|
|
|
|
case "pet_deflect":
|
|
return pickRand(narrativePetDeflect)
|
|
|
|
case "pet_whiff":
|
|
return pickRand(narrativePetWhiff)
|
|
|
|
case "sniper_kill":
|
|
return pickRand(narrativeSniperKill)
|
|
|
|
case "misty_heal":
|
|
return fmt.Sprintf(pickRand(narrativeMistyHeal), e.Damage)
|
|
|
|
case "death_save":
|
|
return pickRand(narrativeDeathSave)
|
|
|
|
case "environmental":
|
|
return pickFrom(narrativeEnvironmental, picker.environment, e.Damage)
|
|
|
|
case "use_consumable":
|
|
return renderConsumableUse(e.Desc)
|
|
|
|
case "consumable_skip":
|
|
return "_Supplies conserved — threat assessed as manageable._"
|
|
|
|
case "ward_absorb":
|
|
return pickRand(narrativeWardAbsorb)
|
|
|
|
case "spore_miss":
|
|
return pickRand(narrativeSporeCloud)
|
|
|
|
case "reflect_damage":
|
|
return fmt.Sprintf(pickRand(narrativeReflect), e.Damage)
|
|
|
|
case "crowd_revenge":
|
|
return fmt.Sprintf(pickRand(narrativeCrowdRevenge), e.Damage)
|
|
|
|
case "flat_damage":
|
|
return fmt.Sprintf(pickRand(narrativeFlatDmg), e.Damage)
|
|
|
|
case "heal_item":
|
|
return fmt.Sprintf(pickRand(narrativeHealItem), e.Damage)
|
|
|
|
// Monster abilities
|
|
case "poison_tick":
|
|
return fmt.Sprintf(pickRand(narrativePoisonTick), e.Damage)
|
|
case "enrage":
|
|
return pickRand(narrativeEnrage)
|
|
case "armor_break":
|
|
return pickRand(narrativeArmorBreak)
|
|
case "stun":
|
|
return pickRand(narrativeStun)
|
|
case "stunned":
|
|
return pickRand(narrativeStunned)
|
|
case "poison":
|
|
return pickRand(narrativePoisonApply)
|
|
case "lifesteal":
|
|
return fmt.Sprintf(pickRand(narrativeLifesteal), e.Damage)
|
|
case "cleave":
|
|
return fmt.Sprintf(pickRand(narrativeCleave), e.Damage)
|
|
case "bonus_damage":
|
|
return fmt.Sprintf(pickRand(narrativeBonusDamage), e.Damage)
|
|
case "aoe":
|
|
return fmt.Sprintf(pickRand(narrativeAoE), e.Damage)
|
|
case "execute":
|
|
return fmt.Sprintf(pickRand(narrativeExecute), e.Damage)
|
|
case "self_heal":
|
|
return fmt.Sprintf(pickRand(narrativeSelfHeal), e.Damage)
|
|
case "ability_flavor":
|
|
return pickRand(narrativeAbilityFlavor)
|
|
|
|
// Monster abilities — slice 3 stateful effects
|
|
case "evade":
|
|
return pickRand(narrativeEvade)
|
|
case "parry_stance":
|
|
return pickRand(narrativeParryStance)
|
|
case "advantage":
|
|
return pickRand(narrativeAdvantage)
|
|
case "retaliate_aura":
|
|
return pickRand(narrativeRetaliateAura)
|
|
case "retaliate":
|
|
return fmt.Sprintf(pickRand(narrativeRetaliate), e.Damage)
|
|
case "regenerate":
|
|
return pickRand(narrativeRegenerate)
|
|
case "regen_tick":
|
|
return fmt.Sprintf(pickRand(narrativeRegenTick), e.Damage)
|
|
case "survive_armed":
|
|
return pickRand(narrativeSurviveArmed)
|
|
case "survive_at_1":
|
|
return pickRand(narrativeSurvive)
|
|
case "stat_drain":
|
|
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
|
|
case "debuff":
|
|
return fmt.Sprintf(pickRand(narrativeDebuff), e.Damage)
|
|
case "max_hp_drain":
|
|
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
|
|
|
|
// Monster abilities — slice 4 (former flavor-only placeholders)
|
|
case "spell_resist":
|
|
return pickRand(narrativeSpellResist)
|
|
case "spell_fizzle":
|
|
return fmt.Sprintf(pickRand(narrativeSpellFizzle), e.Damage)
|
|
case "reveal_armed":
|
|
return pickRand(narrativeRevealArmed)
|
|
case "revealed":
|
|
return pickRand(narrativeRevealed)
|
|
case "fear_immune":
|
|
return pickRand(narrativeFearImmune)
|
|
case "fear_resist":
|
|
return pickRand(narrativeFearResist)
|
|
case "ally_buff":
|
|
return fmt.Sprintf(pickRand(narrativeAllyBuff), e.Damage)
|
|
|
|
case "timeout":
|
|
return pickRand(narrativeTimeout)
|
|
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func renderConsumableUse(itemName string) string {
|
|
templates := []string{
|
|
"You crack open a **%s**. The fight just got interesting.",
|
|
"**%s** consumed. Its effects take hold immediately.",
|
|
"You down a **%s** before things get worse.",
|
|
"The **%s** does its work. You feel the difference.",
|
|
}
|
|
return fmt.Sprintf(templates[rand.IntN(len(templates))], itemName)
|
|
}
|
|
|
|
func renderOutcome(result CombatResult, playerName, enemyName string, reward int64, xp int) string {
|
|
var sb strings.Builder
|
|
|
|
if result.PlayerWon {
|
|
sb.WriteString(fmt.Sprintf("💀 **%s** has been defeated.\n", enemyName))
|
|
if result.NearDeath {
|
|
sb.WriteString(pickRand(narrativeNearDeathWin) + "\n")
|
|
} else if result.Closeness < 0.3 {
|
|
sb.WriteString(pickRand(narrativeDominantWin) + "\n")
|
|
} else {
|
|
sb.WriteString(pickRand(narrativeCleanWin) + "\n")
|
|
}
|
|
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned", xp, reward))
|
|
} else {
|
|
sb.WriteString("💀 **Defeated.**\n")
|
|
if result.NearDeath {
|
|
sb.WriteString(pickRand(narrativeNearDeathLoss) + "\n")
|
|
} else if result.Closeness < 0.3 {
|
|
sb.WriteString(pickRand(narrativeDominantLoss) + "\n")
|
|
} else {
|
|
sb.WriteString(pickRand(narrativeCloseLoss) + "\n")
|
|
}
|
|
sb.WriteString(fmt.Sprintf("+%d XP (participation)", xp))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func pickRand(pool []string) string {
|
|
return pool[rand.IntN(len(pool))]
|
|
}
|
|
|
|
// ── Phase Headers ────────────────────────────────────────────────────────────
|
|
|
|
var openingHeaders = []string{
|
|
"⚔️ **Opening — The First Exchange**",
|
|
"⚔️ **Opening — Testing the Waters**",
|
|
"⚔️ **Opening — Both Sides Size Each Other Up**",
|
|
"⚔️ **Opening — The Fight Begins**",
|
|
}
|
|
|
|
var clashHeaders = []string{
|
|
"⚔️ **Clash — The Real Fight**",
|
|
"⚔️ **Clash — No More Warm-Ups**",
|
|
"⚔️ **Clash — Steel Meets Steel**",
|
|
"⚔️ **Clash — The Exchange Intensifies**",
|
|
}
|
|
|
|
var decisiveHeaders = []string{
|
|
"⚔️ **Decisive — One of You Isn't Walking Away**",
|
|
"⚔️ **Decisive — The Final Blow**",
|
|
"⚔️ **Decisive — Everything on the Line**",
|
|
"⚔️ **Decisive — This Ends Now**",
|
|
}
|
|
|
|
// ── Narrative Pools ──────────────────────────────────────────────────────────
|
|
|
|
var narrativePlayerHit = []string{
|
|
"You connect cleanly. %d damage. Nothing fancy. Functional violence.",
|
|
"A solid strike lands true. %d damage. The enemy reconsiders their career choices.",
|
|
"You find an opening and take it. %d damage. The opening did not volunteer.",
|
|
"Your weapon finds its mark. %d damage. Your weapon has better aim than you do most days.",
|
|
"You press the advantage. %d damage. The advantage was already pressed. You pressed it further.",
|
|
"A measured strike. %d damage. Nothing flashy. Just effective. The crowd is politely impressed.",
|
|
"You swing and it connects where it matters. %d damage. Where it matters is the enemy's face.",
|
|
"You hit them with a move you've been mentally rehearsing for weeks. %d damage. It worked. You're as surprised as they are.",
|
|
"You land a clean hit for %d damage and immediately start explaining to no one how you did that.",
|
|
"You score %d damage. The enemy seems fine. You are less fine about this than they are.",
|
|
}
|
|
|
|
var narrativeEnemyHit = []string{
|
|
"The enemy strikes back. %d damage. They were not asking permission.",
|
|
"A hit gets through your guard. %d damage. Your guard had one job.",
|
|
"The enemy finds a gap in your defense. %d damage. The gap is now a feature.",
|
|
"You take a hit. %d damage. It stings. Your pride stings more.",
|
|
"The enemy's weapon connects with something important. %d damage. It was you. You were the important thing.",
|
|
"A blow you didn't see coming. %d damage. In fairness, you weren't looking.",
|
|
"The enemy is faster than expected. %d damage. Your expectations need recalibrating.",
|
|
"The enemy questions your life choices mid-swing. You pause to reflect. %d damage during the pause.",
|
|
"The enemy yawns before hitting you. Not performatively. Genuinely. %d damage while you process the disrespect.",
|
|
"The enemy hits you with what is technically the bare minimum of effort. %d damage. You gave it everything. They did not.",
|
|
}
|
|
|
|
var narrativePlayerCrit = []string{
|
|
"💥 **CRITICAL HIT.** You drive the blow home. %d damage. The crowd notices. The enemy notices more.",
|
|
"💥 **CRIT!** Everything lined up perfectly. %d damage. You will never reproduce this.",
|
|
"💥 A devastating strike. %d damage. You didn't know you had that in you. Neither did they.",
|
|
"💥 You put everything behind this one. %d damage. It shows. It shows on their face specifically.",
|
|
"💥 **CRIT!** %d damage. You look at your weapon like it just got a promotion.",
|
|
}
|
|
|
|
// narrativePlayerAutoCrit is used when a forced/auto crit fires *not* from a
|
|
// natural high roll — Rogue's first-strike instinct, a held/paralyzed enemy,
|
|
// or a consumable like Ancient Artifact Oil. The roll itself is unimpressive;
|
|
// the *setup* is what made the hit devastating.
|
|
var narrativePlayerAutoCrit = []string{
|
|
"💥 **CRIT — opening exploit.** Years of looking for this exact gap pay off. %d damage. The roll didn't matter. You did.",
|
|
"💥 Your training takes over before you do. The opening was there; instinct found it. %d damage.",
|
|
"💥 The enemy left exactly the gap your subclass trained you to punish. %d damage. They did not get to protest.",
|
|
"💥 **First-strike instinct fires.** You don't aim; you *know*. %d damage. The dice were a formality.",
|
|
"💥 You read the seam between their guard and their breath. Slip in, slip out. %d damage. They notice on the way down.",
|
|
"💥 The setup was perfect — the *attack* was just paperwork. %d damage.",
|
|
}
|
|
|
|
var narrativeEnemyCrit = []string{
|
|
"💥 **CRITICAL HIT** from the enemy. %d damage. That one rearranged something.",
|
|
"💥 The enemy finds a weak point you didn't know you had. %d damage. Now you know.",
|
|
"💥 A brutal strike. %d damage. You feel that one in places you didn't know could feel things.",
|
|
"💥 The enemy's eyes narrow before a devastating blow. %d damage. The narrowing was a warning. You missed it.",
|
|
"💥 **CRIT.** %d damage. The enemy doesn't even look impressed with themselves. That's worse.",
|
|
}
|
|
|
|
var narrativeDodge = []string{
|
|
"You sidestep at the last moment. Nothing lands. You style it out. Nobody is convinced.",
|
|
"The attack passes through empty air. Clean dodge. You had no idea you could move like that.",
|
|
"You read the movement and step aside. The enemy's weapon finds nothing but disappointment.",
|
|
"Too slow. The strike finds nothing. The enemy retrieves their dignity from the floor.",
|
|
"A dodge so clean it almost looks rehearsed. It was not. You were running away and it happened to work.",
|
|
"You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was.",
|
|
}
|
|
|
|
var narrativePlayerBlock = []string{
|
|
"You brace and absorb the impact. Reduced to %d damage. Your arms disagree with this strategy.",
|
|
"Your guard holds. Only %d damage gets through. The rest is your problem later.",
|
|
"A solid block, but some force still connects. %d damage. Physics remains undefeated.",
|
|
"You block it. %d damage still gets through. Blocking is more of a suggestion than a solution.",
|
|
}
|
|
|
|
var narrativeEnemyBlock = []string{
|
|
"The enemy blocks your strike. Only %d damage penetrates. They seem unimpressed.",
|
|
"A partial block. %d damage — less than you wanted. Less than they deserved.",
|
|
"The enemy's defense absorbs most of it. %d damage. The enemy's forearm speaks to the pathetic nature of your striking.",
|
|
}
|
|
|
|
var narrativePlayerMiss = []string{
|
|
"Your swing goes wide. Nothing connects. The air you hit had it coming.",
|
|
"The enemy anticipated that one. Complete miss. They're already bored.",
|
|
"You overcommit and hit nothing but air. The air files no complaint.",
|
|
"A whiff. It happens. It happens to you more than average.",
|
|
"You attempt a move you saw in a film once. It does not work like in the film.",
|
|
"You close your eyes for the strike because it feels more dramatic. You miss. Everything about this was predictable.",
|
|
}
|
|
|
|
var narrativeEnemyMiss = []string{
|
|
"The enemy lunges. Finds only air. The air takes the slight personally.",
|
|
"Their strike misses by inches. They curse in a language you don't recognize.",
|
|
"A wild swing, deflected by your stance. They expected better of themselves.",
|
|
"The blow comes in too predictable. You're already gone. They commit. They miss.",
|
|
"The enemy whiffs. The look on their face — what passes for one — is priceless.",
|
|
"Their weapon meets your guard, slides past harmless. They've used this combo before. It worked then.",
|
|
"The strike is fast. You are slightly faster. The math works out in your favor for once.",
|
|
}
|
|
|
|
var narrativePetAttack = []string{
|
|
"🐾 Your pet lunges at the enemy from absolutely nowhere. %d damage. Your pet has been waiting for this.",
|
|
"🐾 Your pet strikes from the side with zero hesitation. %d additional damage. The enemy was not monitoring the pet. Mistake.",
|
|
"🐾 Your pet joins the fray with a well-timed attack. %d damage. The timing was suspicious. Your pet may be smarter than you.",
|
|
"🐾 Your faithful companion lands a hit for %d damage. More faithful than accurate, but today both applied.",
|
|
}
|
|
|
|
var narrativeSpiritWeapon = []string{
|
|
"✨ The spectral mace swings on its own and lands for %d damage. Floating menace, well-balanced.",
|
|
"✨ Your spiritual weapon hovers, picks an angle, strikes — %d damage. No grip, all conviction.",
|
|
"✨ A glowing weapon arcs in from beside you. %d damage. The enemy keeps trying to track it. Cannot.",
|
|
"✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.",
|
|
}
|
|
|
|
var narrativeConcentrationTick = []string{
|
|
"🌀 The lingering aura grinds the enemy down — %d damage. Still humming. Still hungry.",
|
|
"🌀 Your spell hasn't let go: the spirits sweep through again for %d damage.",
|
|
"🌀 The radiant field pulses once more — %d damage. Concentration holds.",
|
|
"🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.",
|
|
}
|
|
|
|
var narrativePetDeflect = []string{
|
|
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
|
|
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
|
|
"🐾 Your companion takes part of the hit for you. This is more loyalty than you deserve.",
|
|
}
|
|
|
|
var narrativePetWhiff = []string{
|
|
"🐾 Your pet distracts the enemy completely. Their attack goes wide. Your pet is not sorry.",
|
|
"🐾 Your pet startles the enemy mid-swing. Complete miss. The enemy glares at the pet. The pet does not care.",
|
|
"🐾 The enemy flinches at your pet's sudden movement. Missed entirely. Your pet sits back down like nothing happened.",
|
|
}
|
|
|
|
var narrativeSniperKill = []string{
|
|
"🎯 A shot rings out from the shadows. The enemy drops before the fight even starts. Arina collects her fee. She was leaving anyway.",
|
|
"🎯 Arina's shot finds its mark. The enemy never knew what happened. Arina is already gone.",
|
|
"🎯 One shot. One kill. Arina waves from somewhere you can't see. You wave back. She's already somewhere else.",
|
|
}
|
|
|
|
var narrativeMistyHeal = []string{
|
|
"🌿 Misty's presence soothes your wounds. +%d HP. She makes it look effortless because for her it is.",
|
|
"🌿 A warm glow surrounds you. Misty restores %d HP. She doesn't explain how. You don't ask.",
|
|
"🌿 Misty whispers something. You feel %d HP return. Whatever she said, it worked.",
|
|
"🌿 The air shimmers. Misty heals you for %d HP. The enemy watches this happen and seems annoyed about it.",
|
|
}
|
|
|
|
var narrativeDeathSave = []string{
|
|
"👑 The Sovereign set flares with light. You refuse to fall. 1 HP remains. You should be dead. You are not. The set has opinions about your mortality.",
|
|
"👑 Something keeps you standing. The Sovereign set burns bright. 1 HP. The enemy is visibly upset about this.",
|
|
"👑 You should be dead. The Sovereign set disagrees. 1 HP. The disagreement is final.",
|
|
}
|
|
|
|
var narrativeEnvironmental = []string{
|
|
"The arena floor shifts beneath you. %d damage. The floor has no allegiance.",
|
|
"A loose stone catches you off-balance. %d damage. The arena's maintenance budget is your problem now.",
|
|
"Something in the environment works against you. %d damage. The environment was never on your side.",
|
|
"The terrain betrays you. %d damage. It was never loyal to begin with.",
|
|
"A hazard you didn't notice. %d damage. In fairness, you were busy being hit by other things.",
|
|
"A bird lands between you and the enemy. Both combatants stop. The bird leaves. The enemy recovers first. %d damage.",
|
|
"Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.",
|
|
}
|
|
|
|
var narrativeWardAbsorb = []string{
|
|
"✨ The Quartz Ward flares. The hit is absorbed completely. Money well spent.",
|
|
"✨ Your ward shatters — but the damage doesn't reach you. One and done. Worth it.",
|
|
"✨ The ward takes the hit. You don't. The ward has no opinions about this arrangement.",
|
|
}
|
|
|
|
var narrativeSporeCloud = []string{
|
|
"🍄 The spore cloud confuses the enemy. They swing at nothing. The nothing is grateful.",
|
|
"🍄 Spores fill the air. The enemy can't find you. You're right here. They still can't find you.",
|
|
"🍄 The enemy chokes on spores and misses entirely. They will have questions about this later.",
|
|
}
|
|
|
|
var narrativeReflect = []string{
|
|
"💎 The Voidstone Shard activates. %d damage reflected back. The enemy hit themselves, technically.",
|
|
"💎 The enemy's own force is turned against them. %d reflected. They did this to themselves.",
|
|
"💎 Half the blow bounces back. %d damage to the enemy. Karma. Immediate karma.",
|
|
}
|
|
|
|
var narrativeTimeout = []string{
|
|
"The fight drags on. Neither side will yield. The judges decide on points.",
|
|
"Exhaustion sets in. Both fighters are spent. The one still standing taller takes it.",
|
|
"Time runs out. The crowd is restless. The decision falls to whoever bled less.",
|
|
}
|
|
|
|
var narrativeCrowdRevenge = []string{
|
|
"🪨 The crowd throws something at you. %d damage. Misty's fans hold grudges.",
|
|
"🪨 Someone in the stands got a clear shot. %d damage. You should have tipped better.",
|
|
"🪨 The crowd is not on your side today. %d damage. Misty sends her regards.",
|
|
}
|
|
|
|
var narrativeFlatDmg = []string{
|
|
"💣 The Coal Bomb detonates. %d damage before the fight even starts. The enemy was not ready. That was the point.",
|
|
"💣 An explosion kicks things off. %d damage to the enemy. First impressions matter.",
|
|
}
|
|
|
|
var narrativeHealItem = []string{
|
|
"🧪 You feel the potion take effect. +%d HP restored. Tastes terrible. Works perfectly.",
|
|
"🧪 The salve kicks in. +%d HP. You don't know what's in it. You don't want to know.",
|
|
"🧪 Your consumable heals you for %d HP mid-fight. The enemy watches you heal and seems personally offended.",
|
|
}
|
|
|
|
// Monster abilities
|
|
var narrativePoisonApply = []string{
|
|
"☠️ The enemy's attack carries something extra. You feel it immediately. Poison.",
|
|
"☠️ A venomous strike. The wound burns differently than it should. That's not good.",
|
|
"☠️ Something coats the enemy's weapon. It's in you now. The burning starts.",
|
|
}
|
|
|
|
var narrativePoisonTick = []string{
|
|
"☠️ Venom courses through you. %d poison damage. Your blood has opinions about this.",
|
|
"☠️ The poison burns. %d damage. It's not getting better on its own.",
|
|
"☠️ You feel the toxin working. %d damage this round. Time is not on your side.",
|
|
}
|
|
|
|
var narrativeEnrage = []string{
|
|
"🔥 The enemy's eyes go red. Something has changed. They're stronger now. You preferred them before.",
|
|
"🔥 **ENRAGE.** The enemy is wounded and furious. Attack increased. This was not the plan.",
|
|
"🔥 Cornered and desperate, the enemy unleashes everything. Desperate enemies are the most dangerous kind.",
|
|
}
|
|
|
|
var narrativeArmorBreak = []string{
|
|
"🛡️💥 Your armor cracks under the impact. Defense reduced. That sound was expensive.",
|
|
"🛡️💥 The enemy shatters your guard. Your defense won't hold like it did. Neither will your confidence.",
|
|
"🛡️💥 **Armor Break.** You feel exposed. Because you are.",
|
|
}
|
|
|
|
var narrativeStun = []string{
|
|
"⚡ The enemy winds up something that looks bad. It is bad.",
|
|
"⚡ The enemy prepares a stunning blow. Your immediate future just got complicated.",
|
|
"⚡ The enemy's weapon crackles with intent. This is going to hurt differently.",
|
|
}
|
|
|
|
var narrativeStunned = []string{
|
|
"⚡ You're stunned. Can't move. Can't attack. Can barely think.",
|
|
"⚡ The blow leaves you reeling. You lose your turn. You lose some dignity too.",
|
|
"⚡ Everything goes white for a moment. You skip your attack. The moment passes. The opportunity does not return.",
|
|
}
|
|
|
|
var narrativeLifesteal = []string{
|
|
"🩸 The enemy drains your vitality. %d damage — and they heal. This is deeply unfair.",
|
|
"🩸 You feel your strength being siphoned. %d damage, enemy heals. They're taking what's yours.",
|
|
"🩸 **Lifesteal.** The enemy grows stronger as you weaken. %d damage. The math is going in the wrong direction.",
|
|
}
|
|
|
|
var narrativeCleave = []string{
|
|
"⚔️⚔️ The enemy strikes twice in rapid succession. %d damage from the double blow. That should not be legal.",
|
|
"⚔️⚔️ **Cleave!** Two strikes before you can react. %d damage total. One hit was enough. They did two.",
|
|
"⚔️⚔️ A devastating combo. %d damage from both hits. The second one was personal.",
|
|
}
|
|
|
|
var narrativeBonusDamage = []string{
|
|
"💢 The enemy finds an opening you didn't know you'd left. %d extra damage. Lesson noted.",
|
|
"💢 A second strike slips past your guard before the first one finished. %d damage on top.",
|
|
"💢 The enemy presses the advantage — %d more damage than the round had any right to deal.",
|
|
}
|
|
|
|
var narrativeAoE = []string{
|
|
"💥 The attack erupts outward. %d damage, and your armor barely slows it. Nowhere to dodge.",
|
|
"💥 A burst of force washes over you. %d damage — there was no good way to stand for that.",
|
|
"💥 The enemy unleashes something wide and indiscriminate. %d damage. Cover would have been nice.",
|
|
}
|
|
|
|
var narrativeExecute = []string{
|
|
"☠️ The enemy goes for the kill — %d damage aimed squarely at finishing you.",
|
|
"☠️ A finishing blow. %d damage. The enemy can smell the end of this fight.",
|
|
"☠️ The enemy commits everything to one last strike. %d damage. They want this over.",
|
|
}
|
|
|
|
var narrativeSelfHeal = []string{
|
|
"✨ The enemy knits its wounds closed. +%d HP. That's going to make this longer.",
|
|
"✨ The enemy mends itself before your eyes. +%d HP restored. Rude.",
|
|
"✨ Something restorative passes over the enemy. +%d HP. The progress bar moved the wrong way.",
|
|
}
|
|
|
|
var narrativeAbilityFlavor = []string{
|
|
"🌀 The enemy does *something* — you feel the shape of it more than the effect. Stay wary.",
|
|
"🌀 The air shifts around the enemy. Whatever that was, it wasn't nothing.",
|
|
"🌀 The enemy invokes a power you can't quite read. File it under 'concerning'.",
|
|
}
|
|
|
|
var narrativeEvade = []string{
|
|
"💨 Your strike lands on nothing — the enemy was never quite where it looked.",
|
|
"💨 The enemy slips the blow. Your weapon finds only the air it left behind.",
|
|
"💨 A clean swing, a clean miss. The enemy ghosts aside at the last instant.",
|
|
}
|
|
|
|
var narrativeParryStance = []string{
|
|
"🛡️ The enemy settles into a tight defensive guard. Hits are going to come harder now.",
|
|
"🛡️ The enemy raises its guard and *means* it — every blow from here will have to earn it.",
|
|
"🛡️ The enemy shifts its stance, weapon angled to turn your strikes aside.",
|
|
}
|
|
|
|
var narrativeAdvantage = []string{
|
|
"🎯 The enemy reads your rhythm. Its strikes start coming with unsettling certainty.",
|
|
"🎯 Something clicks for the enemy — it's anticipating you now, and it shows.",
|
|
"🎯 The enemy presses an advantage you can't quite see. Its aim has sharpened.",
|
|
}
|
|
|
|
var narrativeRetaliateAura = []string{
|
|
"🔥 The enemy flares with a punishing aura. Hitting it is about to cost you.",
|
|
"🔥 A searing field wraps the enemy — every blow you land will bite back.",
|
|
"🔥 The enemy wreathes itself in retaliation. Strike it and you share the pain.",
|
|
}
|
|
|
|
var narrativeRetaliate = []string{
|
|
"🔥 The enemy's aura lashes back — %d damage for the privilege of hitting it.",
|
|
"🔥 Your strike lands, and the recoil burns. %d damage bounces straight back into you.",
|
|
"🔥 %d damage reflected. The enemy made you pay for that hit in real time.",
|
|
}
|
|
|
|
var narrativeRegenerate = []string{
|
|
"♻️ The enemy's wounds begin to close on their own. This just got slower.",
|
|
"♻️ Torn flesh knits and seals. The enemy has started regenerating.",
|
|
"♻️ The enemy's body refuses to stay damaged — it's healing between blows now.",
|
|
}
|
|
|
|
var narrativeRegenTick = []string{
|
|
"♻️ The enemy mends another %d HP. The damage you're doing keeps un-doing itself.",
|
|
"♻️ +%d HP for the enemy as its wounds seal over. Frustrating.",
|
|
"♻️ The enemy claws back %d HP. You'll have to out-pace the regeneration.",
|
|
}
|
|
|
|
var narrativeSurviveArmed = []string{
|
|
"🕯️ The enemy refuses the idea of dying. Something keeps it on its feet past where it should fall.",
|
|
"🕯️ A grim resilience settles over the enemy — it will not go down easy.",
|
|
"🕯️ The enemy digs in. Whatever's holding it together, it isn't ready to let go.",
|
|
}
|
|
|
|
var narrativeSurvive = []string{
|
|
"🕯️ The killing blow lands clean — and the enemy *stays standing* at 1 HP. Unbelievable.",
|
|
"🕯️ That should have ended it. The enemy clings to a single point of HP through sheer spite.",
|
|
"🕯️ The enemy by all rights should be down. It is, instead, very barely up.",
|
|
}
|
|
|
|
var narrativeStatDrain = []string{
|
|
"🩸 The enemy saps your strength — your swings feel heavier, weaker. (-%d hit damage)",
|
|
"🩸 Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)",
|
|
"🩸 The enemy leeches your vigour. -%d damage on every strike from here.",
|
|
}
|
|
|
|
var narrativeDebuff = []string{
|
|
"😖 The enemy fouls your footing — your guard slips. (-%d AC)",
|
|
"😖 A wave of something sickly rolls over you. Harder to defend now. (-%d AC)",
|
|
"😖 The enemy's effect frays your defence. -%d AC, and their hits will notice.",
|
|
}
|
|
|
|
var narrativeMaxHPDrain = []string{
|
|
"☠️ The enemy drains your life force — %d HP gone, and your maximum drops with it.",
|
|
"☠️ Something cold pulls %d HP out of you and won't give it back. Your ceiling just fell.",
|
|
"☠️ %d HP siphoned away, max and all. There's less of you to work with now.",
|
|
}
|
|
|
|
var narrativeSpellResist = []string{
|
|
"🚫 The enemy's form shimmers with anti-magic. Spells are going to land soft from here.",
|
|
"🚫 A warding sheen wraps the enemy — magic slides off it like rain off glass.",
|
|
"🚫 The enemy shrugs the weave aside. Whatever you cast, it'll only half-stick.",
|
|
}
|
|
|
|
var narrativeSpellFizzle = []string{
|
|
"🚫 Your spell breaks against the enemy's anti-magic — only %d damage bleeds through.",
|
|
"🚫 The weave fizzles on contact. The enemy's resistance eats half of it; %d lands.",
|
|
"🚫 Half your magic just evaporates. %d damage is all the enemy lets through.",
|
|
}
|
|
|
|
var narrativeRevealArmed = []string{
|
|
"👁️ The enemy's eyes track your intent — it knows what you're about to do.",
|
|
"👁️ The enemy reads you. Your next swing is telegraphed before you've thrown it.",
|
|
"👁️ Something behind the enemy's gaze clicks. It's already seen your next move.",
|
|
}
|
|
|
|
var narrativeRevealed = []string{
|
|
"👁️ The enemy saw it coming — your strike is forced wide before it begins.",
|
|
"👁️ Telegraphed and countered. The enemy was already moving as you committed.",
|
|
"👁️ Your swing meets a defence that read it a beat early. No clean angle left.",
|
|
}
|
|
|
|
var narrativeFearImmune = []string{
|
|
"🐲 The enemy's resolve is iron — nothing you do is going to rattle it.",
|
|
"🐲 Whatever fear you were counting on, the enemy doesn't have the receptors for it.",
|
|
"🐲 The enemy stands utterly unshaken. Control magic is going to slide right off.",
|
|
}
|
|
|
|
var narrativeFearResist = []string{
|
|
"🐲 Your control spell washes over the enemy and finds no purchase. It acts anyway.",
|
|
"🐲 The enemy shrugs off the compulsion mid-cast and keeps coming.",
|
|
"🐲 The spell should have held it. The enemy's will simply refuses the idea.",
|
|
}
|
|
|
|
var narrativeAllyBuff = []string{
|
|
"📢 The enemy rallies itself with a roar — its blows are about to hit harder. (+%d attack)",
|
|
"📢 Something emboldens the enemy. Its strikes pick up weight. (+%d attack)",
|
|
"📢 The enemy steadies and surges. Expect heavier hits from here. (+%d attack)",
|
|
}
|
|
|
|
// Outcome flavor
|
|
var narrativeNearDeathWin = []string{
|
|
"You survived by the skin of your teeth. Barely standing. The healers are on standby.",
|
|
"That was closer than anyone should be comfortable with. But you won. Technically. Medically questionable.",
|
|
"The healers rush over. You wave them off. Mostly because you can't lift your arm to do anything else.",
|
|
}
|
|
|
|
var narrativeDominantWin = []string{
|
|
"Decisive. The enemy never had a chance. They knew it. You knew it. The crowd knew it.",
|
|
"That wasn't a fight. That was a demonstration. The enemy was the visual aid.",
|
|
"You barely broke a sweat. The enemy cannot say the same. The enemy cannot say much of anything right now.",
|
|
}
|
|
|
|
var narrativeCleanWin = []string{
|
|
"A solid fight. You came out on top. The enemy came out on a stretcher.",
|
|
"Well fought. The outcome was never really in doubt. Just in question.",
|
|
"The enemy put up a fight. You put up a better one. The scoreboard reflects this.",
|
|
}
|
|
|
|
var narrativeNearDeathLoss = []string{
|
|
"So close. One more hit and it would've gone the other way. One more hit didn't come.",
|
|
"You almost had it. Almost doesn't count here. Almost doesn't count anywhere.",
|
|
"A razor-thin margin. The wrong side of it. The margin doesn't care whose side it was.",
|
|
}
|
|
|
|
var narrativeDominantLoss = []string{
|
|
"That was over before it started. The starting was a formality.",
|
|
"Outclassed. Completely. There's no gentle way to say it so here it is.",
|
|
"Some fights aren't fights. This was one of those. You were present. That's about all.",
|
|
}
|
|
|
|
var narrativeCloseLoss = []string{
|
|
"A good fight. Not good enough. The scoreboard is indifferent to effort.",
|
|
"You gave it everything. It wasn't quite sufficient. Sufficiency is the enemy's department today.",
|
|
"The enemy earned that one. So did you. Only one of you gets paid.",
|
|
}
|
|
|
|
// ── Turn-based per-round narration ───────────────────────────────────────────
|
|
//
|
|
// RenderTurnRound narrates one resolved round of a manual elite/boss fight.
|
|
// Where RenderCombatLog (auto-resolve) groups a whole fight into phased,
|
|
// multi-message blocks, a turn-based round is one message: the events from
|
|
// player_turn → enemy_turn → round_end, in order. The shared hit/crit/miss/
|
|
// ability events the turn engine emits through the same primitives as
|
|
// auto-resolve fall through to renderEvent and reuse the full narrative pools,
|
|
// so TwinBee's voice is identical across both engines. Only the four
|
|
// turn-specific actions (flee, spell_cast, use_consumable, spell_held) get
|
|
// their own pools here.
|
|
//
|
|
// A fresh actionPicker per round is intentional: a round emits at most a
|
|
// handful of events, and across a long boss fight the picker resetting each
|
|
// round reads as variety rather than staleness.
|
|
func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string {
|
|
return RenderPartyTurnRound(events, []string{playerName}, enemyName, 0)
|
|
}
|
|
|
|
// RenderPartyTurnRound renders a round for one member of a party — the reader at
|
|
// viewerSeat.
|
|
//
|
|
// The turn narration pool is written in the second person: "You score 9 damage",
|
|
// "A hit gets through your guard". That is exactly right for the reader's own
|
|
// events and exactly wrong for everybody else's, so this splits on the event's
|
|
// Seat. The reader's own events go through the same flavor pool a solo fight
|
|
// uses, untouched; an ally's are summarised third-person by renderAllySeatEvent.
|
|
//
|
|
// A solo fight has one seat, every event belongs to it, and the reader is it —
|
|
// so it renders the same bytes it always has.
|
|
func RenderPartyTurnRound(events []CombatEvent, seatNames []string, enemyName string, viewerSeat int) string {
|
|
if len(seatNames) == 0 {
|
|
seatNames = []string{"You"}
|
|
}
|
|
seatName := func(seat int) string {
|
|
if seat > 0 && seat < len(seatNames) {
|
|
return seatNames[seat]
|
|
}
|
|
return seatNames[0]
|
|
}
|
|
|
|
picker := newActionPicker()
|
|
var lines []string
|
|
for _, e := range events {
|
|
var line string
|
|
if e.Seat == viewerSeat {
|
|
line = renderTurnEvent(e, seatName(e.Seat), enemyName, picker)
|
|
if roll := rollAnnotation(e); line != "" && roll != "" {
|
|
line += " " + roll
|
|
}
|
|
} else {
|
|
line = renderAllySeatEvent(e, seatName(e.Seat), enemyName)
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
if len(lines) == 0 {
|
|
return "_The round passes without a clean blow landed._"
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// renderAllySeatEvent summarises one event belonging to somebody else's
|
|
// character, for a party member's DM. Terse on purpose: the reader wants the
|
|
// shape of the round, not three sentences of flavor about their friend's pet.
|
|
// Anything not worth a line in someone else's DM renders as "".
|
|
func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
|
|
who := "**" + name + "**"
|
|
down := func(line string) string {
|
|
if e.PlayerHP <= 0 {
|
|
return line + " " + who + " is down."
|
|
}
|
|
return line
|
|
}
|
|
switch e.Action {
|
|
case "hit":
|
|
if e.Actor == "player" {
|
|
return fmt.Sprintf("%s hits %s for %d.", who, enemyName, e.Damage)
|
|
}
|
|
return down(fmt.Sprintf("%s hits %s for %d.", enemyName, who, e.Damage))
|
|
case "crit":
|
|
if e.Actor == "player" {
|
|
return fmt.Sprintf("%s crits %s for %d!", who, enemyName, e.Damage)
|
|
}
|
|
return down(fmt.Sprintf("%s crits %s for %d!", enemyName, who, e.Damage))
|
|
case "miss":
|
|
if e.Actor == "player" {
|
|
return fmt.Sprintf("%s misses.", who)
|
|
}
|
|
return fmt.Sprintf("%s misses %s.", enemyName, who)
|
|
case "block":
|
|
// renderEvent's convention: Actor "player" means the *enemy* blocked the
|
|
// player's swing, and vice versa.
|
|
if e.Actor == "player" {
|
|
return fmt.Sprintf("%s blocks %s (%d).", enemyName, who, e.Damage)
|
|
}
|
|
return fmt.Sprintf("%s blocks (%d).", who, e.Damage)
|
|
case "spell_cast":
|
|
label := e.Desc
|
|
if label == "" {
|
|
label = "a spell"
|
|
}
|
|
return fmt.Sprintf("%s casts %s.", who, label)
|
|
case "use_consumable":
|
|
label := e.Desc
|
|
if label == "" {
|
|
label = "an item"
|
|
}
|
|
return fmt.Sprintf("%s uses %s.", who, label)
|
|
case "pet_attack":
|
|
return fmt.Sprintf("🐾 %s's pet strikes for %d.", who, e.Damage)
|
|
case "spirit_weapon_strike":
|
|
return fmt.Sprintf("%s's spirit weapon strikes for %d.", who, e.Damage)
|
|
case "concentration_tick":
|
|
return fmt.Sprintf("%s's aura burns %s for %d.", who, enemyName, e.Damage)
|
|
case "poison_tick":
|
|
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
|
|
case "environmental":
|
|
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
|
|
case "flat_damage":
|
|
return fmt.Sprintf("%s deals %d.", who, e.Damage)
|
|
case "heal_item", "misty_heal":
|
|
return fmt.Sprintf("%s recovers %d.", who, e.Damage)
|
|
case "death_save":
|
|
return fmt.Sprintf("%s clings on.", who)
|
|
case "stun", "stunned":
|
|
return fmt.Sprintf("%s is stunned.", who)
|
|
case "flee":
|
|
return fmt.Sprintf("%s breaks off.", who)
|
|
default:
|
|
// Ward absorbs, spore misses, reflects, enrage cues: real, but noise in
|
|
// somebody else's DM. The owner sees them in full in their own.
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *actionPicker) string {
|
|
switch e.Action {
|
|
case "flee":
|
|
return pickRand(narrativeTurnFlee)
|
|
case "spell_held":
|
|
return pickRand(narrativeTurnSpellHeld)
|
|
case "spell_cast":
|
|
label := e.Desc
|
|
if label == "" {
|
|
label = "a spell"
|
|
}
|
|
return fmt.Sprintf(pickRand(narrativeTurnSpellCast), label)
|
|
case "use_consumable":
|
|
label := e.Desc
|
|
if label == "" {
|
|
label = "an item"
|
|
}
|
|
return fmt.Sprintf(pickRand(narrativeTurnConsumable), label)
|
|
}
|
|
return renderEvent(e, playerName, enemyName, CombatResult{}, picker)
|
|
}
|
|
|
|
var narrativeTurnFlee = []string{
|
|
"🏃 You break off and run. No shame in it — the dead don't get a sequel.",
|
|
"🏃 You decide this fight isn't yours to win and make for the exit. I respect the math.",
|
|
"🏃 You disengage and bolt. The enemy doesn't chase. The enemy didn't have to.",
|
|
}
|
|
|
|
var narrativeTurnSpellHeld = []string{
|
|
"🌀 The enemy strains against the spell and gets nowhere. No attack this round.",
|
|
"🌀 Held fast. The enemy's turn dissolves into furious, useless effort.",
|
|
"🌀 The control holds. The enemy spends the round being a statue with opinions.",
|
|
}
|
|
|
|
var narrativeTurnSpellCast = []string{
|
|
"✨ You loose the spell. **%s**.",
|
|
"✨ The weave answers. **%s**.",
|
|
"✨ Words, gesture, intent — **%s**.",
|
|
"✨ You spend the magic where it counts. **%s**.",
|
|
}
|
|
|
|
var narrativeTurnConsumable = []string{
|
|
"🧪 You crack it open mid-fight. **%s**.",
|
|
"🧪 No time to be precious about it. **%s**.",
|
|
"🧪 You burn a consumable on the spot. **%s**.",
|
|
}
|