From ebce89b6c642b83de891077d4891d509c8ca978c Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 14:12:17 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 11 D6: boss phase-two narration Wires the eight prewritten *PhaseTwoLines pools (Valdris / HollowKing / Aldric / Thyrak / Ilvaras / Thornmother / Infernax / Belaxath) into resolveBossRoom. When the combat event log shows enemy HP crossing the zone's PhaseTwoAt threshold, a TwinBee phase-two callout is injected between the boss-stat header and the win/loss line. Bosses without a phase-two transition (Grol, Aboleth) are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_zone_cmd.go | 6 +++ internal/plugin/dnd_zone_narration.go | 55 ++++++++++++++++++++++ internal/plugin/dnd_zone_narration_test.go | 55 ++++++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index eb3796b..3ab2efa 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -448,6 +448,12 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon var b strings.Builder b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC)) + if phaseTwoCrossedInEvents(result.Events, result.EnemyStartHP, zone.Boss.PhaseTwoAt) { + if line := bossPhaseTwoLine(zone.ID, run.RunID, run.CurrentRoom); line != "" { + b.WriteString(line) + b.WriteString("\n") + } + } if !result.PlayerWon { _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) diff --git a/internal/plugin/dnd_zone_narration.go b/internal/plugin/dnd_zone_narration.go index ecef9af..8435301 100644 --- a/internal/plugin/dnd_zone_narration.go +++ b/internal/plugin/dnd_zone_narration.go @@ -214,6 +214,61 @@ func bossSignaturePool(zoneID ZoneID) []string { return nil } +// bossPhaseTwoPool returns the zone-boss phase-two callout pool — the +// line surfaced when the boss crosses its PhaseTwoAt HP threshold mid-fight. +// Returns nil for bosses with no phase-two transition (Grol, Aboleth). +func bossPhaseTwoPool(zoneID ZoneID) []string { + switch zoneID { + case ZoneCryptValdris: + return flavor.ValdrisPhaseTwoLines + case ZoneForestShadows: + return flavor.HollowKingPhaseTwoLines + case ZoneManorBlackspire: + return flavor.AldricPhaseTwoLines + case ZoneUnderforge: + return flavor.ThyrakPhaseTwoLines + case ZoneUnderdark: + return flavor.IlvarasPhaseTwoLines + case ZoneFeywildCrossing: + return flavor.ThornmotherPhaseTwoLines + case ZoneDragonsLair: + return flavor.InfernaxPhaseTwoLines + case ZoneAbyssPortal: + return flavor.BelaxathPhaseTwoLines + } + return nil +} + +// bossPhaseTwoLine renders the zone-specific phase-two transition line. +// Empty string when the zone has no phase-two pool. +func bossPhaseTwoLine(zoneID ZoneID, runID string, roomIdx int) string { + pool := bossPhaseTwoPool(zoneID) + if len(pool) == 0 { + return "" + } + line := pickLineDeterministic(pool, runID, roomIdx^0x71F4A2D3) + if line == "" { + return "" + } + return "🎭 **TwinBee:** " + line +} + +// phaseTwoCrossedInEvents reports whether enemy HP crossed the +// PhaseTwoAt fraction of startHP at any point in the events log. Returns +// false when threshold <= 0 (no phase 2) or startHP <= 0. +func phaseTwoCrossedInEvents(events []CombatEvent, startHP int, threshold float64) bool { + if threshold <= 0 || startHP <= 0 { + return false + } + cutoff := int(float64(startHP) * threshold) + for _, ev := range events { + if ev.EnemyHP <= cutoff { + return true + } + } + return false +} + // eliteRoomEntryPool returns the zone-specific elite-room atmospheric // pool. Returns nil when the zone has no dedicated elite prose. func eliteRoomEntryPool(zoneID ZoneID) []string { diff --git a/internal/plugin/dnd_zone_narration_test.go b/internal/plugin/dnd_zone_narration_test.go index bf4aef4..a7a9008 100644 --- a/internal/plugin/dnd_zone_narration_test.go +++ b/internal/plugin/dnd_zone_narration_test.go @@ -445,3 +445,58 @@ func TestRoomEntryPool_Tier4And5PrependsZoneSpecific(t *testing.T) { } } } + +// ── Phase 11 D6 — boss phase-two narration ────────────────────────────────── + +func TestBossPhaseTwoPool_RoutesEightZones(t *testing.T) { + cases := map[ZoneID]int{ + ZoneCryptValdris: len(flavor.ValdrisPhaseTwoLines), + ZoneForestShadows: len(flavor.HollowKingPhaseTwoLines), + ZoneManorBlackspire: len(flavor.AldricPhaseTwoLines), + ZoneUnderforge: len(flavor.ThyrakPhaseTwoLines), + ZoneUnderdark: len(flavor.IlvarasPhaseTwoLines), + ZoneFeywildCrossing: len(flavor.ThornmotherPhaseTwoLines), + ZoneDragonsLair: len(flavor.InfernaxPhaseTwoLines), + ZoneAbyssPortal: len(flavor.BelaxathPhaseTwoLines), + } + for z, want := range cases { + got := bossPhaseTwoPool(z) + if len(got) == 0 || len(got) != want { + t.Errorf("%s phase-two pool len mismatch: got %d, want %d", z, len(got), want) + } + } + // Bosses without a phase-two transition return nil. + for _, z := range []ZoneID{ZoneGoblinWarrens, ZoneSunkenTemple} { + if got := bossPhaseTwoPool(z); got != nil { + t.Errorf("%s should not have a phase-two pool (no PhaseTwoAt)", z) + } + } +} + +func TestBossPhaseTwoLine_PrependsTwinBee(t *testing.T) { + line := bossPhaseTwoLine(ZoneCryptValdris, "run-d6", 4) + if !strings.HasPrefix(line, "🎭 **TwinBee:**") { + t.Errorf("phase-two line missing TwinBee prefix: %q", line) + } + if bossPhaseTwoLine(ZoneGoblinWarrens, "run-d6", 4) != "" { + t.Errorf("Grol has no phase-two pool — expected empty string") + } +} + +func TestPhaseTwoCrossedInEvents(t *testing.T) { + startHP := 100 + threshold := 0.5 + events := []CombatEvent{{EnemyHP: 80}, {EnemyHP: 60}, {EnemyHP: 40}} + if !phaseTwoCrossedInEvents(events, startHP, threshold) { + t.Errorf("expected crossing at EnemyHP=40 (cutoff=50)") + } + if phaseTwoCrossedInEvents(events[:2], startHP, threshold) { + t.Errorf("did not cross 50%% by event 2 (EnemyHP=60); should be false") + } + if phaseTwoCrossedInEvents(events, startHP, 0) { + t.Errorf("threshold 0 means no phase 2 — should be false") + } + if phaseTwoCrossedInEvents(events, 0, threshold) { + t.Errorf("startHP 0 — should be false (degenerate)") + } +}