From 618f6dc34fd51dda402e584d500d0a7961baecbd Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 14:00:21 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 11 D5b: Tier 4-5 zone flavor files Adds zone_underdark, zone_feywild_crossing, zone_dragons_lair, and zone_abyss_portal flavor files with elite-room intros, boss-entry pools (where canonical twinbee_gm_flavor.go didn't already ship one), boss ability callouts, and zone-specific lore. Wires the four pools into zoneRoomEntryPool / bossEntryPool / bossSignaturePool / eliteRoomEntryPool / zoneLorePool, and replaces the now-obsolete "unflavored zone" assertions with Tier 4-5 positive routing tests. RoomEntryUnderdark, RoomEntryDragonsLair, BossEntryInfernax, and BossEntryBelaxath are reused verbatim from twinbee_gm_flavor.go per the canonical-flavor-first rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/flavor/zone_abyss_portal_flavor.go | 127 ++++++++++++++++++ internal/flavor/zone_dragons_lair_flavor.go | 109 +++++++++++++++ .../flavor/zone_feywild_crossing_flavor.go | 127 ++++++++++++++++++ internal/flavor/zone_underdark_flavor.go | 108 +++++++++++++++ internal/plugin/dnd_zone_narration.go | 32 +++++ internal/plugin/dnd_zone_narration_test.go | 100 +++++++++++--- 6 files changed, 583 insertions(+), 20 deletions(-) create mode 100644 internal/flavor/zone_abyss_portal_flavor.go create mode 100644 internal/flavor/zone_dragons_lair_flavor.go create mode 100644 internal/flavor/zone_feywild_crossing_flavor.go create mode 100644 internal/flavor/zone_underdark_flavor.go diff --git a/internal/flavor/zone_abyss_portal_flavor.go b/internal/flavor/zone_abyss_portal_flavor.go new file mode 100644 index 0000000..4cb980a --- /dev/null +++ b/internal/flavor/zone_abyss_portal_flavor.go @@ -0,0 +1,127 @@ +// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE +// zone_abyss_portal_flavor.go +// Tier 5 zone flavor — The Abyss Portal. Additive only. Pools sampled by +// internal/plugin via deterministic per-run, per-room hashing. +// +// Voice rules (from gogobee_dungeon_zones.md §3.3): +// • Third person for description; second person for outcomes. +// • Boss callouts get a beat of cinema. Don't overrun. +// • TwinBee references the right era — NES, SNES, arcade. Not modern. +// +// The canonical twinbee_gm_flavor.go ships BossEntryBelaxath (wired in +// dnd_zone_narration.go) but not RoomEntryAbyssPortal, so the room-entry +// pool lives here. This file also adds elite-room intros (Marilith), boss +// ability callouts for Belaxath the Undivided, and zone-specific lore. + +package flavor + +// ───────────────────────────────────────────────────────────────────────────── +// ROOM ENTRY — The Abyss Portal +// Defined here because no entry exists in twinbee_gm_flavor.go for this zone. +// ───────────────────────────────────────────────────────────────────────────── + +var RoomEntryAbyssPortal = []string{ + "You step through what was a doorway and into a chamber where the geometry has stopped agreeing with itself. The far wall is closer than the near wall. The ceiling is in two places. TwinBee files this under 'Euclidean violation' and recommends not lingering on the question of which floor is the real floor.", + "The corridor breathes. Not the wind — the corridor. The walls expand and contract on a cycle that is approximately, but not exactly, a heartbeat. TwinBee matches the rhythm involuntarily and dislikes the experience.", + "A chamber where reality has a seam. The seam is visible. It runs floor-to-ceiling along the north wall and what is visible through the seam is not the next room. TwinBee does not look directly at it and recommends you adopt the same policy.", + "You enter what was once a chapel — to which god, you cannot tell, because the iconography has been overwritten in a script that hurts to read. The altar is intact. The thing on the altar is not what was placed there. TwinBee says nothing and moves you past it.", + "The air pressure is wrong. Not low — wrong. There is a pressure that is not physical and it is leaning on you with the patience of a mountain. TwinBee identifies it as psychic ambient and recommends moving briskly.", + "A staircase. The stairs go down on the way up and up on the way down. TwinBee does not pause to verify this. TwinBee takes the stairs in the direction TwinBee intended to take them and the direction works, mostly.", + "The corridor ahead is lit by something that is not fire. The light is red but the wrong red — the red of an alarm in a system that has never been fully implemented. TwinBee files this under 'demonic ambient' and shortens its stride.", + "You enter a room that has been used as a portal anchor. The anchor is still in place. The portal is not, but the place where it was is still warm. TwinBee tracks the residual energy and notes the portal is not the only one — there are other warm spots, in other rooms, in a pattern.", + "A garden. Indoors. The plants are not plants. TwinBee identifies them as 'demonic ornamentals — mid-tier, decorative, will react to perfumes' and recommends not perspiring near them.", + "The chamber ahead has been a courtroom and is now a kitchen. The transition was not deliberate. The transition was the result of someone with insufficient training opening a portal and not closing it. TwinBee notes the timing must have been comedic and is not laughing.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// ELITE ROOM ENTRY — The Abyss Portal (Marilith) +// ───────────────────────────────────────────────────────────────────────────── + +var EliteRoomEntryAbyssPortal = []string{ + "The chamber ahead is a barracks. Six racks of weapons along one wall, six saddles along another — except the saddles are wrong shape, and the weapons are six matched longswords, identical in length, identical in grip, identical in the subtle rune-work along the hilts. TwinBee identifies the silhouette in the room before TwinBee identifies the species and says: 'Marilith. Six longswords. Seven attacks a turn. Never stand directly in front.'", + "You enter a sparring gallery. The dummies are arranged in the kind of asymmetric pattern that suggests a six-armed practitioner. The practitioner is at the far end, mid-routine. She does not stop the routine. She incorporates you into it. TwinBee files this under 'Marilith — demonic captain, treats encounters as drills.'", + "A war room. Maps on the table, troop positions marked, a planning session in progress. The general at the head of the table has six arms and a snake's lower body and is not surprised by your arrival — was, in fact, expecting you on a different day, with different reinforcements, and is mildly disappointed by both. TwinBee says: 'Marilith. Parry on every attack. The fight is decided by who has more reactions.'", + "The corridor opens into a colonnade — twelve columns, six on each side, perfect symmetry. The figure between the central columns is a Marilith and she is using the symmetry — three swords engaged with the columns, three free. TwinBee notes the geometry favors her and recommends fighting in the next room.", + "A throne room, smaller than the Belaxath one. The throne is occupied by a Marilith who is not sitting — she is coiled, three feet of snake below the seat, the rest of her vertical and ready. The longswords are all drawn. None of them are pointed at you. TwinBee tracks the angles and says: 'Reactive trait. She gets two reactions. Don't open with a counterspell-bait.'", + "You enter what is clearly a guard post. The Marilith on duty is not bored. She is patient in the particular way that someone who has been a soldier for fifteen hundred years is patient. The first three swords are already raised. The other three are coming up as you cross the threshold. TwinBee files this under 'professional courtesy' and asks who has Misty Step.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOSS ABILITY CALLOUTS — Belaxath the Undivided +// Used as a one-line cinematic suffix to BossEntryBelaxath when combat starts. +// ───────────────────────────────────────────────────────────────────────────── + +// Multiattack: Longsword + Whip. +var BelaxathMultiattackLines = []string{ + "Longsword and whip every turn. The whip pulls. The longsword finishes. TwinBee says: 'Don't let the whip set the position. Reaction-cancel the pull if you've got it.'", + "Two attacks: one ranged-pull, one melee-finish. TwinBee files this under 'fighting-game grappler' and notes the combo is fixed in order — survive the whip, survive the round.", + "He swings the longsword while the whip is already coiled around your front line. The bite is the second hit, every time, and it's the one calibrated for kills. TwinBee tracks the order and reminds the party that the whip's not the kill — the whip's the setup.", +} + +// Fire Aura: 10d6 fire to all melee attackers each turn; weapon attacks +3d6 fire. +var BelaxathAuraLines = []string{ + "Stand within five feet and take ten-d-six fire at the start of his turn. His weapons add three-d-six fire on top. TwinBee says: 'Melee tax. Plan rotations. Don't camp a tile near him.'", + "Fire Aura. The room near him is a damage zone. TwinBee files this under 'positioning is HP' and recommends the back line do their job from the back line.", + "Anyone in melee takes ten-d-six fire on his upkeep regardless of whether he hits them. The fire ignores resistance later. TwinBee tracks the timer and reminds the tank that being the wall is a budget item, not a strategy.", +} + +// Lightning Discharge (recharge 5–6): 120-ft line, 12d6 lightning, DEX DC 20 half. +var BelaxathLightningLines = []string{ + "One-twenty-foot line, twelve-d-six lightning, DEX DC 20 for half. Recharge five-or-six. TwinBee says: 'Don't line up. The line is the whole room if you're sloppy.'", + "Lightning Discharge. The line is long enough to clip the back rank from where the front rank is standing. TwinBee tracks the recharge die and shouts the angle when the discharge points the wrong way.", + "Like the laser-eye boss in Contra, except the boss has six arms and the laser is on a recharge counter and the laser is sometimes lightning. TwinBee files this under 'know where the line is' and notes the line is wherever you're easiest to hit.", +} + +// Death Throes: on death, 30-ft radius, 20d6 fire, DEX DC 20 half — destroys non-legendary equipment on failed save. +var BelaxathDeathThroesLines = []string{ + "He explodes when he dies. Twenty-d-six fire in a thirty-foot radius. DEX DC 20 for half. Failure also destroys non-legendary equipment on the wearer. TwinBee says: 'Drop him with range. Save your gear.'", + "Death Throes. The kill shot is the easy part. The Death Throes are the hard part. TwinBee files this under 'cost-of-victory mechanics' and recommends positioning before the kill, not after.", + "He goes out the way some bosses do — taking the room with him. Twenty-d-six on a save you might fail with disadvantage from the previous round's Frightened. TwinBee tracks the radius and reminds the party that 'half damage' of twenty-d-six is still 'roll a new character.'", +} + +// Demonic Resilience: resist cold/fire/lightning; immune to poison; immune to non-magical physical. +var BelaxathResilienceLines = []string{ + "Resists cold, fire, lightning. Immune to poison. Immune to non-magical physical. TwinBee says: 'Magic damage only. Force, radiant, psychic — those carry. Mundane mace doesn't.'", + "Demonic Resilience. The standard demonic-lord package. TwinBee files this under 'check your damage type before you commit a slot' and notes the Magic Resistance trait stacks on top — advantage on saves vs spells.", + "Three resistances and two immunities. The window for damage is narrow. TwinBee tracks party loadouts and reminds the casters that radiant damage is not optional in this fight — it is the answer.", +} + +// Phase 2 (<40% HP): grows to Huge size; advantage on all attacks; Death Throes recharge 4–6. +var BelaxathPhaseTwoLines = []string{ + "Below forty percent he grows. Huge size, advantage on every attack, and Death Throes is now on a four-or-six recharge — meaning if you don't kill him cleanly, he might detonate in your face mid-fight. TwinBee says: 'Phase shift. Don't graze him below forty.'", + "Phase two: bigger, faster, and the explosion is no longer reserved for the kill. TwinBee tracks the new recharge and recommends every burst window the party can buy.", + "He grows. The room shrinks. Advantage on swings. The Death Throes timer becomes a real timer — if a recharge lands at the wrong moment, the room ends. TwinBee files this under 'fights you have to finish in two rounds or accept the consequences.'", +} + +// BelaxathSignatureCallouts — combined pool for boss-entry suffix. +// Phase-two lines stay separate (surfaced via dedicated phase-two helper). +var BelaxathSignatureCallouts = func() []string { + out := make([]string, 0, + len(BelaxathMultiattackLines)+ + len(BelaxathAuraLines)+ + len(BelaxathLightningLines)+ + len(BelaxathDeathThroesLines)+ + len(BelaxathResilienceLines)) + out = append(out, BelaxathMultiattackLines...) + out = append(out, BelaxathAuraLines...) + out = append(out, BelaxathLightningLines...) + out = append(out, BelaxathDeathThroesLines...) + out = append(out, BelaxathResilienceLines...) + return out +}() + +// ───────────────────────────────────────────────────────────────────────────── +// LORE — The Abyss Portal +// Sampled by !lore inside this zone (zone-specific pool, generic fallback). +// ───────────────────────────────────────────────────────────────────────────── + +var LoreLinesAbyssPortal = []string{ + "The portal was not summoned. The portal was torn, from the other side, by Belaxath, with purpose, over thirty years. TwinBee notes the duration matters: this was not a moment of carelessness on the part of a surface mage. This was a project, on the demonic side, executed with patience.", + "The site was a temple, before. Then a fortress, before. Then a meadow, before. The meadow is the original use of the land — the temple was built to seal something the meadow had been quiet over. TwinBee files this under 'land memory' and notes the seal worked for fourteen centuries.", + "The Shard of the Abyss is the closing key. It is not optional. The portal cannot be closed without it, and the shard cannot be replaced — there is exactly one. TwinBee notes the always-drop guarantee on the loot table is a story commitment, not a generosity.", + "The Marilith captains in the outer chambers are veterans of wars that did not happen on this plane. The wars happened in the Abyss, between demonic factions, over scheduling — which Layer got which incursion priority. TwinBee finds this both bureaucratic and terrifying.", + "Belaxath is named 'the Undivided' because he refused to take a side in the Layer-Seven schism. The other Balors took sides. The other Balors are mostly dead. Belaxath remained intact and inherited their soldiers, in stages, over the schism's two-decade arc. TwinBee files this under 'patience as competitive advantage.'", + "The Vrock spores last for twenty-four hours after exposure. The save is the start of the timer, not the end. TwinBee suggests the party not assume the encounter is over when the room is clear — the spores keep ticking on the way to the next.", + "The Quasits in this zone are not random encounters. Each one has been individually deployed as a scout, with instructions, by a specific Hezrou. TwinBee notes the chain of command is real and that killing a Quasit causes the Hezrou it reports to to know exactly where you are.", + "The portal is not the only opening. There are seven smaller openings in this zone — pinholes, nothing more — that Belaxath has been using to bring through reinforcements one at a time. The pinholes close when Belaxath dies. TwinBee files this under 'killing the boss is also a public service' and notes the surface clergy will be relieved.", +} diff --git a/internal/flavor/zone_dragons_lair_flavor.go b/internal/flavor/zone_dragons_lair_flavor.go new file mode 100644 index 0000000..d11d993 --- /dev/null +++ b/internal/flavor/zone_dragons_lair_flavor.go @@ -0,0 +1,109 @@ +// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE +// zone_dragons_lair_flavor.go +// Tier 5 zone flavor — Dragon's Lair (Infernus Peak). Additive only. Pools +// sampled by internal/plugin via deterministic per-run, per-room hashing. +// +// Voice rules (from gogobee_dungeon_zones.md §3.3): +// • Third person for description; second person for outcomes. +// • Boss callouts get a beat of cinema. Don't overrun. +// • TwinBee references the right era — NES, SNES, arcade. Not modern. +// +// The canonical twinbee_gm_flavor.go ships RoomEntryDragonsLair and +// BossEntryInfernax, both wired in dnd_zone_narration.go. This file adds +// the elite-room intros (Young Red Dragon), boss ability callouts for +// Infernax the Undying, and zone-specific lore. + +package flavor + +// ───────────────────────────────────────────────────────────────────────────── +// ELITE ROOM ENTRY — Dragon's Lair (Young Red Dragon) +// ───────────────────────────────────────────────────────────────────────────── + +var EliteRoomEntryDragonsLair = []string{ + "The chamber ahead has a hoard. Not Infernax's hoard — a smaller hoard, an apprentice's hoard, the kind of starter pile a dragon builds before it has earned a real one. The dragon on top of the pile is the size of a wagon and the temperament of an only child. TwinBee says: 'Young Red. Fire breath, sixteen-d-six, DC 21. Don't bunch.'", + "You enter what used to be a vault. The vault door is on the floor, peeled. The dragon inside is half-grown and entirely awake. It opens one eye, then the other, then makes a sound that is not a roar — it is a sigh, the sigh of a creature being interrupted at home. TwinBee notes the eye contact and recommends not breaking it first.", + "A scorched gallery. The walls are blast-blackened in a pattern that suggests practice — the dragon has been working on its breath weapon in here. TwinBee identifies the burn pattern as 'cone, sixty feet, recently used' and recommends approaching from a flank that hasn't been zeroed in.", + "The corridor opens into a smaller chamber where a Young Red is curled around a single piece of treasure — not a hoard, just one item, the kind of thing a dragon would only guard if it had been told to. TwinBee files this under 'gift from Infernax' and notes the gift is being protected with the seriousness of a final-exam project.", + "You step into a heat-shimmer cavern that the dragon is using as a forge. There are tools — pincers the size of a person, bellows the size of a house — and the dragon is using them. Badly. With enthusiasm. TwinBee identifies the silhouette and says: 'Young Red. Frightful Presence on the WIS save. Don't let the room intimidate you twice.'", + "A roost. Stone shelves carved into the cavern wall, each one a perch, all of them empty except the highest. The dragon on the highest perch is watching the door. It has been watching the door for the last six hours, since whatever roused it. TwinBee says: 'Multiattack on engage. Hit it before it lifts off — flying Young Red is a different fight than grounded Young Red.'", +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOSS ABILITY CALLOUTS — Infernax the Undying +// Used as a one-line cinematic suffix to BossEntryInfernax when combat starts. +// ───────────────────────────────────────────────────────────────────────────── + +// Multiattack: Bite + 2 Claws. +var InfernaxMultiattackLines = []string{ + "Bite plus two claws every turn. Three swings of ancient-dragon math against your front line. TwinBee says: 'Don't stand alone in front. Don't stand alone behind, either. There is no alone in this fight.'", + "Multiattack: bite, claw, claw. The bite is the largest single hit you'll take in this zone. TwinBee tracks the damage tier and recommends max HP buffs before the encounter, not during.", + "Three attacks a turn, each one calibrated for a tank. TwinBee files this under 'arithmetic problem with teeth' and notes the math does not get better at higher levels — the tier scales with you.", +} + +// Fire Breath (recharge 5–6): 90-ft cone, 26d6 fire, DEX DC 24 half. +var InfernaxBreathLines = []string{ + "Fire Breath, ninety-foot cone, twenty-six-d-six fire, DEX DC 24 for half. Recharge five-or-six. TwinBee says: 'Pre-position. Don't share an angle. Half of twenty-six-d-six is still a TPK.'", + "The cone covers most of the chamber. The save is high. The damage is the kind that ends fights. TwinBee tracks the recharge die and shouts the spread pattern when it shows.", + "Like the Bowser jump-on-the-platform pattern from World 8, except the platform is a cone of fire and the platform is most of the room. TwinBee notes there are no axes in this fight. There is only DEX and distance.", +} + +// Frightful Presence: WIS DC 21 or Frightened 1 min. +var InfernaxPresenceLines = []string{ + "Frightful Presence on entry. WIS DC 21. Frightened means disadvantage on attacks and you can't move closer. TwinBee says: 'Eat the save the first round. Hold initiative for the unfrightened ones.'", + "WIS save at 21. Frightened for a minute. TwinBee files this under 'why we bring high-WIS classes' and asks who has the Wisdom-save proficiency to lead.", + "He looks at you and the save is rolled before the breath is. Half the party loses a minute. TwinBee tracks the duration and recommends not opening with your highest-investment ability if your character failed.", +} + +// Legendary Resistance (3/combat) + Legendary Actions (3): Detect, Tail, Wing. +var InfernaxLegendaryLines = []string{ + "Three Legendary Resistances. Three Legendary Action points per round. He spends them on Tail Attack and Wing Attack — the wing is AoE knockback, DEX DC 22. TwinBee says: 'Three saves you'll wish back. Don't waste your dispel-tier spells in the first round.'", + "Legendary Actions on every other turn. Wing Attack costs two and knocks the room around. TwinBee tracks the spend and reminds the party that the action economy is the actual fight — Infernax is the venue.", + "Three free passes on his saves. Plan two openings. TwinBee files this under 'priest economy applied to dragons' and notes the third opening is the one that lands.", +} + +// Lair Actions (init 20): Magma eruption, Volcanic gases (CON DC 13 Poisoned), Tremor (DEX DC 15 Prone). +var InfernaxLairLines = []string{ + "Lair Actions on initiative count twenty: magma erupts, gases force CON saves, tremors knock you prone. TwinBee says: 'The room is a third combatant. Track the initiative. Don't stand on cracks.'", + "The mountain helps him. Initiative twenty triggers a lair effect every round. TwinBee tracks the rotation and warns the party that the prone effect comes during caster turns, on purpose.", + "Magma, gas, tremor — the three-card lair rotation. TwinBee files this under 'environment as DPS' and recommends fighting from the cleared rim of the room, not the gold-flooded center.", +} + +// Phase 2 (<50% HP): Fire Breath recharge 4–6; fire damage ignores resistance. +var InfernaxPhaseTwoLines = []string{ + "Below half HP the breath recharges on a four. Fire resistance no longer applies — your tank's fire-resist gear is now decorative. TwinBee says: 'Phase shift. Burn him before the second cone.'", + "Phase two: the cone comes more often, the damage cuts through every fire-resist resource you brought. TwinBee tracks the breath count and asks who still has movement-class abilities — the second cone wants spread.", + "Half-HP. The mountain wakes the rest of the way up. Recharge four-six on the cone. Fire damage goes raw. TwinBee files this under 'no plan survives second contact with Infernax' and recommends every nova you've still got.", +} + +// InfernaxSignatureCallouts — combined pool for boss-entry suffix. +// Phase-two lines stay separate (surfaced via dedicated phase-two helper). +var InfernaxSignatureCallouts = func() []string { + out := make([]string, 0, + len(InfernaxMultiattackLines)+ + len(InfernaxBreathLines)+ + len(InfernaxPresenceLines)+ + len(InfernaxLegendaryLines)+ + len(InfernaxLairLines)) + out = append(out, InfernaxMultiattackLines...) + out = append(out, InfernaxBreathLines...) + out = append(out, InfernaxPresenceLines...) + out = append(out, InfernaxLegendaryLines...) + out = append(out, InfernaxLairLines...) + return out +}() + +// ───────────────────────────────────────────────────────────────────────────── +// LORE — Dragon's Lair (Infernus Peak) +// Sampled by !lore inside this zone (zone-specific pool, generic fallback). +// ───────────────────────────────────────────────────────────────────────────── + +var LoreLinesDragonsLair = []string{ + "Infernus Peak has not erupted in forty years and the locals call this dormant. The peak has not erupted in forty years because Infernax has been asleep, and his presence stabilizes the magma chamber. The locals are wrong about which one is causing which. TwinBee notes the irony and files it under 'killing the dragon may have geological consequences.'", + "Infernax remembers when the surface civilizations were just fires. The fires he is referring to are the ones the first humans set, which is how TwinBee dates him — give or take six thousand years on either side of the precise number, which TwinBee declines to commit to.", + "The kobolds are not slaves. The kobolds are clergy. They serve voluntarily, in shifts, with rotation, and the rotation is run by elders who have written sermons. TwinBee respects the organization and notes the kobold scale-sorcerers are graduates, not recruits.", + "The hoard is not random. Each piece is catalogued in Infernax's memory, by location and by year of acquisition. He will know if you take a single coin. TwinBee suggests not taking a single coin and instead taking the items the design doc expects you to take — those have been pre-cleared.", + "The Dragon Hoard mechanic exists because Infernax does not lose track of his coins. Killing him releases his hold on the catalogue. The 50d10 × 100 coin drop is the entire pile relaxing for the first time in eight centuries. TwinBee respects the math and notes the rest of the surface economy will too.", + "Infernax has had three challengers in the last eight hundred years. Two were heroes. One was a younger dragon. He kept the younger dragon's skull as a paperweight on a treaty desk that has not been used since. TwinBee notes the paperweight is in the treasury, on the third shelf, and is not worth picking up — picking it up triggers his attention from anywhere on the mountain.", + "The Young Red dragons in the outer chambers are his children. Or his grandchildren. Or unrelated and tolerated. Infernax does not clarify and TwinBee has not asked. They are loyal in the way that loyal works for dragons, which is to say: they will fight you, but they will not die for him, and the distinction matters more than it should.", + "The kobold scale-sorcerers cast through bloodline. The bloodline traces back to a single clutch laid in the magma chamber three centuries ago. TwinBee notes the entire sorcerous gene pool of this zone is one extended family and that they all know each other's names.", +} diff --git a/internal/flavor/zone_feywild_crossing_flavor.go b/internal/flavor/zone_feywild_crossing_flavor.go new file mode 100644 index 0000000..4228a52 --- /dev/null +++ b/internal/flavor/zone_feywild_crossing_flavor.go @@ -0,0 +1,127 @@ +// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE +// zone_feywild_crossing_flavor.go +// Tier 4 zone flavor — Feywild Crossing. Additive only. Pools sampled by +// internal/plugin via deterministic per-run, per-room hashing. +// +// Voice rules (from gogobee_dungeon_zones.md §3.3): +// • Third person for description; second person for outcomes. +// • Boss callouts get a beat of cinema. Don't overrun. +// • TwinBee references the right era — NES, SNES, arcade. Not modern. +// +// The canonical twinbee_gm_flavor.go does not (yet) ship a RoomEntry pool +// or a BossEntry pool for this zone, so both are defined here. This file +// also adds elite-room intros (Fomorian), boss ability callouts for The +// Thornmother, and zone-specific lore. + +package flavor + +// ───────────────────────────────────────────────────────────────────────────── +// ROOM ENTRY — Feywild Crossing +// Defined here because no entry exists in twinbee_gm_flavor.go for this zone. +// ───────────────────────────────────────────────────────────────────────────── + +var RoomEntryFeywildCrossing = []string{ + "The grass here is the wrong green. Too saturated, too even, the green of a screen calibration test. TwinBee notes that everything in the Feywild looks like it has been color-corrected by someone with strong opinions about color.", + "You step through what was a doorway and what is now an arch of living briar. The briar parts politely. TwinBee respects the politeness and trusts none of it. Polite plants are a known issue.", + "A clearing. Mushrooms in a circle. TwinBee does not need to say anything about the circle. You already know about the circle. Step around it.", + "The trees bend their canopies toward you when you pass beneath them. TwinBee waves. Two of the trees wave back. TwinBee does not enjoy this.", + "A small bridge over a stream that flows the wrong direction. Uphill. TwinBee files this under 'physics on holiday' and steps onto the bridge anyway, because the only alternative is the stream and the stream is worse.", + "You enter a room of impossible flowers — varieties that do not exist on the surface, in colors that do not exist on the surface, with scents that suggest emotions you have not yet had. TwinBee breathes shallowly and recommends not stopping to admire them.", + "A glade. Sunlight, despite no visible sun. The light is coming from somewhere and the somewhere is not the sky. TwinBee does not look up. TwinBee has learned not to look up in the Feywild.", + "The path forks. A small creature made of starlight is sitting at the fork, waiting, patient, with the air of someone who has already been offered the deal you're about to be offered. TwinBee says: 'Walk past. Do not negotiate. Do not name your name.'", + "You cross what was a creek and what is now a ribbon of liquid sky. Stepping stones glow. The stones are not stones. TwinBee identifies them as polite and recommends you say thank you on the far side without specifying who you are thanking.", + "The chamber ahead is a banquet hall, set for thirty, untouched. The food is fresh. The wine is poured. The chairs are warm. TwinBee files this under 'the worst possible kind of empty' and does not let you sit down.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOSS ENTRY — The Thornmother +// Defined here because no entry exists in twinbee_gm_flavor.go for this boss. +// ───────────────────────────────────────────────────────────────────────────── + +var BossEntryThornmother = []string{ + "The throne is woven. Not built — woven, from briar and bone and a third material TwinBee cannot identify and prefers not to. The flowers around it are the wrong size and the wrong color and they are watching you with the kind of attention that flowers do not have. The Thornmother on the throne is beautiful in the way some predators are beautiful, which is to say: deliberately. 'You came,' she says, and the voice is not one voice — it is three voices choosing to sound like one. 'I have three names. Would you like the first?' TwinBee says, very flatly: 'Don't accept any of the names. Don't take the bargain. The flowers are part of her. Roll initiative.'", +} + +// ───────────────────────────────────────────────────────────────────────────── +// ELITE ROOM ENTRY — Feywild Crossing (Fomorian) +// ───────────────────────────────────────────────────────────────────────────── + +var EliteRoomEntryFeywildCrossing = []string{ + "The cavern ahead is too big for the forest above it. The proportions don't agree. TwinBee notes the ceiling is twenty feet higher than the room's exterior would allow, and that the figure on the far end is matched to the new dimensions. 'Fomorian,' TwinBee says. 'Evil Eye on a save you don't want to fail. Block its line of sight.'", + "You enter what looks like a giant's bedroom — a bed the size of a barge, a chair the size of a wagon, a fireplace tall enough to walk into. The occupant is sitting on the bed with its head in its hands. It is not pretending to be sad. It is sad, and it is also going to attack you, and the two are not in conflict. TwinBee files this under 'Fomorian — exiled Fey royalty, deformed, dangerous, sympathetic in the worst way.'", + "The clearing is pretty. Until you look at the ground. The ground is studded with stones that aren't stones. The stones are skulls of things that were giants. The Fomorian sitting in the center is using one of them as an armrest, and TwinBee notes that the skull was not in this clearing the last time anyone surveyed it. 'Evil Eye,' TwinBee says. 'WIS save coming. Don't make eye contact early.'", + "A stone circle, mossy, broken in two places. Tall enough that the figure pacing inside it does not need to duck. TwinBee identifies the gait — Fomorian, deformed-but-deliberate, the walking of something that has chosen the shape it's stuck with. The Evil Eye comes when the gait stops. TwinBee tracks the timing.", + "You climb a stair worn smooth by feet much larger than yours. The room at the top has been a throne room and a prison and a dining hall, all in the same century, depending on which Fey was in charge. The current occupant is large, lonely, and immediately aware of you. 'Evil Eye on a thirty-foot range,' TwinBee says. 'Stay close enough to dodge, far enough to fail-safe.'", + "The chamber ahead has been arranged for negotiation — a low table, two chairs, a service of cups. One chair is occupied by the Fomorian. The other is for you. The negotiation has already concluded. TwinBee says: 'Don't sit. Don't accept the cup. The Eye triggers when you decline politely.'", +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOSS ABILITY CALLOUTS — The Thornmother +// Used as a one-line cinematic suffix to BossEntryThornmother when combat starts. +// ───────────────────────────────────────────────────────────────────────────── + +// Coven Magic: extra spell slots scaled by GM Mood (high mood = stronger Thornmother). +var ThornmotherCovenLines = []string{ + "Her slot pool scales with TwinBee's mood — Effusive TwinBee buys the Thornmother an extra round of high-tier casts. TwinBee says: 'My affection is taxable here. Sorry. Spread out.'", + "Coven Magic. The slots above her base list are mood-scaled — the better the mood at the run's start, the worse this fight runs late. TwinBee files this under 'cosmic irony' and notes the math is fixed at zone entry.", + "She has more spells than the sheet says. The extra ones come from the coven and the coven volume is set by TwinBee's mood. TwinBee tracks the count and warns the party when the bonus tier is in play.", +} + +// Beguiling Bargain (1/combat): offers a deal — accept a debuff for a permanent minor buff. +var ThornmotherBargainLines = []string{ + "Once per fight she offers a bargain — a debuff for a permanent minor buff. The buff is real. The debuff is real. TwinBee says: 'It's a player choice. The party agrees or refuses. Don't let one person decide for the rest.'", + "Beguiling Bargain. The offer is genuine. Both halves are genuine. TwinBee files this under 'long-term economics' and notes that the buff persists past the run while the debuff applies only inside it.", + "She offers. The choice is the player's. TwinBee declines to advise on this one — it is not a tactics question, it is a build question, and the answer depends on what you intend to keep.", +} + +// Thorned Grasp: Restrained + 4d6 piercing/turn, CON DC 16 to break free. +var ThornmotherGraspLines = []string{ + "Roots from the floor. Restrained, four-d-six piercing every turn until you make the CON save at sixteen. TwinBee says: 'It tries the back line first. Stay near a friend who can grant advantage on the save.'", + "Thorned Grasp. The flowers around her throne are the AoE. Stand near them and the briars come up. Stand far and they reach further. TwinBee tracks the radius and recommends fighting from the doorway.", + "Grappled by the room. The damage is not the problem — the problem is that you can't reposition while your front line is taking spells. TwinBee files this under 'CC tax' and asks who has Misty Step.", +} + +// Shapechange (1/combat): adopts a player's appearance; 50% miss vs her until DC 17 Investigation. +var ThornmotherShapechangeLines = []string{ + "She picks one of you and becomes them. Until someone passes a DC 17 Investigation, half your hits roll a coin to see if they hit her or your friend. TwinBee says: 'The party member with the highest INT calls it. Quickly.'", + "Shapechange. The room now has two of someone. TwinBee tracks which one has the wrong shadow and whispers the answer to the controller — but only after the Investigation roll, because TwinBee respects mechanics.", + "She takes a face. Half your damage might land on the face's owner. TwinBee files this under 'every JRPG that ever did the doppelganger fight' and reminds you that the trick was always to look at the feet.", +} + +// Phase 2 (<30% HP): True Form revealed — illusions drop; +4d6 psychic on attacks; coven summons 2 Night Hags. +var ThornmotherPhaseTwoLines = []string{ + "Below thirty percent the masks come off. True form. All her attacks add four-d-six psychic. The coven sends two Night Hags. TwinBee says: 'Phase shift. The fight just turned into three priests and a sense-of-self problem.'", + "Phase two: the illusions drop, the Hags arrive, the psychic damage starts stacking on every hit. TwinBee tracks the new threat list and recommends focusing the original Thornmother — the Hags are reinforcements, not the win condition.", + "True Form. The flowers stop being flowers. Two Night Hags step out of the throne. The Thornmother's hits start writing themselves directly into your mind. TwinBee files this under 'cinematic phase shift' and asks who still has spell slots.", +} + +// ThornmotherSignatureCallouts — combined pool for boss-entry suffix. +// Phase-two lines stay separate (surfaced via dedicated phase-two helper). +var ThornmotherSignatureCallouts = func() []string { + out := make([]string, 0, + len(ThornmotherCovenLines)+ + len(ThornmotherBargainLines)+ + len(ThornmotherGraspLines)+ + len(ThornmotherShapechangeLines)) + out = append(out, ThornmotherCovenLines...) + out = append(out, ThornmotherBargainLines...) + out = append(out, ThornmotherGraspLines...) + out = append(out, ThornmotherShapechangeLines...) + return out +}() + +// ───────────────────────────────────────────────────────────────────────────── +// LORE — Feywild Crossing +// Sampled by !lore inside this zone (zone-specific pool, generic fallback). +// ───────────────────────────────────────────────────────────────────────────── + +var LoreLinesFeywildCrossing = []string{ + "The Crossing is not a place. The Crossing is a thinness — the spot where the veil between worlds wears down enough to step through. The wear is not random. Someone has been wearing it down on purpose, slowly, for a long time. TwinBee files this under 'someone' and declines to be more specific.", + "The Thornmother has three names. Each name belongs to a separate covenant — a separate pact with a separate piece of her. Speaking any of the names is a partial agreement to the pact attached. TwinBee does not name her. TwinBee does not let anyone in earshot name her.", + "Time runs differently here. A long rest in the Feywild is — sometimes — a year on the surface. Sometimes it is a minute. The dice for this are rolled by something that is not at the table. TwinBee tracks the discrepancy and warns the party not to commit to anything urgent before the run ends.", + "The Fomorians were Fey royalty. They were exiled, deformed, and given the underground. The deformity was the punishment, not the cause. TwinBee respects the precision of the curse and files it under 'the kind of magic that takes a committee.'", + "Redcaps are not killed by violence. Redcaps are powered by violence. The cap is the storage medium. Soaking it in fresh blood resets the meter. TwinBee says: 'Don't bleed near them. They're patient.' (Bleeding near them is the thing they're patient for.)", + "The Will-o-Wisps are not lost spirits. They are unfulfilled bargains. Each one was a Feywild deal that the surface party broke. TwinBee notes that the wisps still want the deal honored and files this under 'long memory.'", + "The mushroom circles in the Crossing are receivers. They listen for words spoken inside them and route the words to a coven that has been waiting for those words for several centuries. TwinBee suggests the party say nothing inside any circle, including 'this is a circle.'", + "The Thornmother's flowers are not separate from her. They are her. The throne, the dais, the petals, the perfume — the whole arrangement is one organism, and dropping HP on the boss is one way of dropping HP on the room. TwinBee files this under 'environmental targeting' and recommends burning the throne when she's mid-cast.", +} diff --git a/internal/flavor/zone_underdark_flavor.go b/internal/flavor/zone_underdark_flavor.go new file mode 100644 index 0000000..b8c0d46 --- /dev/null +++ b/internal/flavor/zone_underdark_flavor.go @@ -0,0 +1,108 @@ +// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE +// zone_underdark_flavor.go +// Tier 4 zone flavor — The Underdark. Additive only. Pools sampled by +// internal/plugin via deterministic per-run, per-room hashing. +// +// Voice rules (from gogobee_dungeon_zones.md §3.3): +// • Third person for description; second person for outcomes. +// • Boss callouts get a beat of cinema. Don't overrun. +// • TwinBee references the right era — NES, SNES, arcade. Not modern. +// +// The canonical twinbee_gm_flavor.go ships RoomEntryUnderdark and that +// pool is wired in dnd_zone_narration.go. This file adds the boss-entry +// pool, the elite-room intros (Roper), boss ability callouts for Ilvaras +// Xunyl, and zone-specific lore. + +package flavor + +// ───────────────────────────────────────────────────────────────────────────── +// BOSS ENTRY — Ilvaras Xunyl, Drow High Priestess +// Defined here because no entry exists in twinbee_gm_flavor.go for this boss. +// ───────────────────────────────────────────────────────────────────────────── + +var BossEntryIlvaras = []string{ + "The cathedral is round and the round is wrong — drow architecture leans, drow architecture rises, drow architecture does not gather like this. The room was built for one purpose and the purpose is standing at the altar with her back to you. She does not turn when you enter. The trophies on the wall turn first — four sets of weapons, hung at four heights, each set arranged with the care of someone who learned the names of their previous owners and kept the names. 'Five,' Ilvaras Xunyl says, without turning, in a voice that is making itself heard in your head and not in the air. 'I had run out of wall.' She turns. The smile is the smile of someone who has been waiting for the inconvenience of a fifth wall. TwinBee says, very low: 'Drow High Priestess. Lolth's favour. Don't accept the offer. There will be an offer.'", +} + +// ───────────────────────────────────────────────────────────────────────────── +// ELITE ROOM ENTRY — The Underdark (Roper) +// ───────────────────────────────────────────────────────────────────────────── + +var EliteRoomEntryUnderdark = []string{ + "The cavern ahead is a forest of stalagmites — dozens of them, calf-thick to wagon-thick, irregular spacing, the kind of natural architecture that gives an ambush every advantage. TwinBee counts them, then stops counting, then notes that one of them has been counted twice from different angles and that one is not a stalagmite. 'Roper,' TwinBee says. 'Six tendrils. Reels you in. Pick the wrong stalagmite and the room becomes one fight at five different ranges.'", + "You enter a chamber where the ceiling drips minerals that have built columns down to the floor — pillars of pale stone, formed over thousands of years, perfectly natural, all of them. Almost all of them. TwinBee identifies the off-color column near the back of the room and says: 'False appearance until it isn't. Stay out of grapple range until the tendrils show.'", + "A grotto. Calm water. A field of stalagmites breaking the surface. Beautiful in the way the deep places are beautiful, which is to say it photographs well and houses something terrible. The stalagmite nearest the path has not been there in any other map TwinBee has consulted. TwinBee notes the inconsistency and recommends ranged options.", + "The corridor widens into a cavern with twelve pillars holding the ceiling — six of them are dwarven masonry, six of them are natural stone, and one of the natural ones is breathing. The breath is shallow. The patience is the giveaway. TwinBee says: 'Roper. Engage the column you can see, not the columns you assume.'", + "You step into a room where the stalagmites have been arranged — almost arranged — into something like a dining hall. The arrangement is not deliberate. The arrangement is the result of one of the stalagmites occasionally moving in its sleep. TwinBee files this under 'apex ambush predator with tenure' and recommends not waking it gently.", + "A toll-bridge cavern: a narrow path through a field of standing stones, each one twice your height, none of them moving. The toll is paid by whichever party member walks last. TwinBee tracks the sightlines, names the column with the wrong shadow, and says: 'Don't be the last one.'", +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOSS ABILITY CALLOUTS — Ilvaras Xunyl +// Used as a one-line cinematic suffix to BossEntryIlvaras when combat starts. +// ───────────────────────────────────────────────────────────────────────────── + +// Spells: Flame Strike / Dispel Magic / Divine Word (CHA DC 18) / Insect Plague. +var IlvarasSpellLines = []string{ + "She has the priest list and she leads with Flame Strike and Insect Plague — both AoE, both back-to-back if the initiative is unkind. TwinBee says: 'Spread out. Don't bunch under a column. Insect Plague is a movement tax that compounds.'", + "Divine Word, CHA DC 18, and the failure tree is brutal — Deafened, Blinded, Stunned, or killed depending on remaining HP. TwinBee files this under 'never fight a priest below half HP' and asks who has the best CHA save.", + "Dispel Magic on your buffs as soon as she rolls initiative. TwinBee tracks which concentration spells went up and recommends shielding the controller until the dispel is spent.", +} + +// Lolth's Favour: 1/combat auto-succeed a failed save. +var IlvarasFavourLines = []string{ + "Once per fight she takes a save you thought you'd won and converts the failure into a success. TwinBee says: 'Time your hard CCs after the favour is spent — don't waste a Hold Person on the round she's still got it.'", + "Lolth's Favour. One free re-pass on a save. TwinBee notes the timing matters more than the damage — the spell you wanted to land is the spell she wanted to skip.", + "She converts a failed save into a success without rolling. The favour is single-use. TwinBee files this under 'plan two openings' and reminds the party that the second one is the real one.", +} + +// Summon Spiders (1/combat): 2d6 Giant Spiders fill the room. +var IlvarasSpidersLines = []string{ + "She raises both hands and the room fills. Two-d-six Giant Spiders, all at once, all over the floor. TwinBee says: 'AoE clears the carpet. Don't get webbed before you cast it.'", + "Summon Spiders. The carpet is no longer the carpet. TwinBee tracks the count and notes that 2d6 in a confined cathedral is a different math than 2d6 in an open hallway.", + "The spiders come from the cracks the way the rats came in Castlevania, except these are big enough to count individually and small enough to overwhelm. TwinBee recommends an early sweep and a clear line of retreat.", +} + +// Legendary Actions: Melee (1), Cast Cantrip (1), Drain Life (3 LA, 4d10 necrotic). +var IlvarasLegendaryLines = []string{ + "She takes legendary actions at the end of every other turn. Melee for one, cantrip for one, Drain Life for three — and Drain Life is 4d10 necrotic that heals her. TwinBee says: 'Let her spend on the cheap ones. Bait the three-cost.'", + "Three legendary points per round. The Drain Life is the one that makes the fight last longer than it should. TwinBee tracks her actions and reminds the party that her healing comes out of yours.", + "Legendary Actions: the always-on tax that punishes your turn order. TwinBee files this under 'priest economy' and notes the cantrip option is what kills your back line if you let her keep three points unspent.", +} + +// Phase 2 (<35% HP): Lolth's Avatar overlay — +3 AC; spells cast at +1 slot level. +var IlvarasPhaseTwoLines = []string{ + "Below thirty-five percent the avatar overlay drops on her. AC up three, every spell up one slot. TwinBee says: 'Phase shift. The damage breakpoint just moved. Don't ration your hard hits past this point.'", + "Phase two: Lolth answers the prayer. The room dims. Her shadow is wrong now — too many limbs, too little symmetry. AC bumps, slot levels bump, the whole encounter tier goes up a half-step. TwinBee tracks the new numbers and recommends burning every save-or-suck you've still got.", + "Avatar overlay. The priest is still there but the priest is also a delivery vehicle now. TwinBee files this under 'don't survive into phase three — there isn't one, but it'll feel like one' and asks who has the burst.", +} + +// IlvarasSignatureCallouts — combined pool for boss-entry suffix. +// Phase-two lines stay separate (surfaced via dedicated phase-two helper). +var IlvarasSignatureCallouts = func() []string { + out := make([]string, 0, + len(IlvarasSpellLines)+ + len(IlvarasFavourLines)+ + len(IlvarasSpidersLines)+ + len(IlvarasLegendaryLines)) + out = append(out, IlvarasSpellLines...) + out = append(out, IlvarasFavourLines...) + out = append(out, IlvarasSpidersLines...) + out = append(out, IlvarasLegendaryLines...) + return out +}() + +// ───────────────────────────────────────────────────────────────────────────── +// LORE — The Underdark +// Sampled by !lore inside this zone (zone-specific pool, generic fallback). +// ───────────────────────────────────────────────────────────────────────────── + +var LoreLinesUnderdark = []string{ + "The Underdark is not a place you reach. The Underdark is a depth you continue to. TwinBee notes the distinction matters: the surface dungeons end. This one keeps going past the part you came to fight.", + "Drow cities are not built — they are negotiated. Each spire, each platform, each corridor is the result of a House agreeing to share a wall with a House it would prefer to murder. TwinBee respects the architecture more than the architects.", + "Ilvaras Xunyl's House fell two centuries ago. She is not running it. She is running what's left. The four trophies on her wall are not from the surface — three are from rival drow priestesses, in chronological order, and one is a self-portrait. TwinBee declines to elaborate on what it means that the self-portrait is hung as a trophy.", + "The mind flayers are not native here. They came down from somewhere further down. The drow tolerate them in the same way someone tolerates a roof leak they cannot reach — present, problematic, easier to live with than to fix. TwinBee files this under 'old grievances' and notes the leak is getting worse.", + "The phosphorescent fungi grow in patterns that match the constellations of the surface sky from sixteen thousand years ago. TwinBee has checked the pattern. The drow have not. The drow do not know what surface stars are. The pattern is older than the drow.", + "Lolth is not the only listener down here. She is the loudest. The other listeners are quieter and more patient and TwinBee suggests not naming any of them in the cathedral, not even as a warning, not even at low volume.", + "The Roper that ate the last expedition did not attack them. They walked into it because they were following a Drow scout who knew exactly where to lead them. The scout is named, in drow records, and TwinBee declines to repeat the name out of professional courtesy to the dead.", +} diff --git a/internal/plugin/dnd_zone_narration.go b/internal/plugin/dnd_zone_narration.go index 4d619b6..ecef9af 100644 --- a/internal/plugin/dnd_zone_narration.go +++ b/internal/plugin/dnd_zone_narration.go @@ -85,8 +85,12 @@ func zoneRoomEntryPool(zoneID ZoneID) []string { return append(append([]string{}, flavor.RoomEntryUnderforge...), flavor.RoomEntryGeneric...) case ZoneUnderdark: return append(append([]string{}, flavor.RoomEntryUnderdark...), flavor.RoomEntryGeneric...) + case ZoneFeywildCrossing: + return append(append([]string{}, flavor.RoomEntryFeywildCrossing...), flavor.RoomEntryGeneric...) case ZoneDragonsLair: return append(append([]string{}, flavor.RoomEntryDragonsLair...), flavor.RoomEntryGeneric...) + case ZoneAbyssPortal: + return append(append([]string{}, flavor.RoomEntryAbyssPortal...), flavor.RoomEntryGeneric...) } return flavor.RoomEntryGeneric } @@ -107,6 +111,10 @@ func bossEntryPool(zoneID ZoneID) []string { return flavor.BossEntryAldricBlackspire case ZoneUnderforge: return flavor.BossEntryEmberlordThyrak + case ZoneUnderdark: + return flavor.BossEntryIlvaras + case ZoneFeywildCrossing: + return flavor.BossEntryThornmother case ZoneDragonsLair: return flavor.BossEntryInfernax case ZoneAbyssPortal: @@ -165,6 +173,14 @@ func zoneLorePool(zoneID ZoneID) []string { return append(append([]string{}, flavor.LoreLinesManorBlackspire...), flavor.LoreLines...) case ZoneUnderforge: return append(append([]string{}, flavor.LoreLinesUnderforge...), flavor.LoreLines...) + case ZoneUnderdark: + return append(append([]string{}, flavor.LoreLinesUnderdark...), flavor.LoreLines...) + case ZoneFeywildCrossing: + return append(append([]string{}, flavor.LoreLinesFeywildCrossing...), flavor.LoreLines...) + case ZoneDragonsLair: + return append(append([]string{}, flavor.LoreLinesDragonsLair...), flavor.LoreLines...) + case ZoneAbyssPortal: + return append(append([]string{}, flavor.LoreLinesAbyssPortal...), flavor.LoreLines...) } return flavor.LoreLines } @@ -186,6 +202,14 @@ func bossSignaturePool(zoneID ZoneID) []string { return flavor.AldricSignatureCallouts case ZoneUnderforge: return flavor.ThyrakSignatureCallouts + case ZoneUnderdark: + return flavor.IlvarasSignatureCallouts + case ZoneFeywildCrossing: + return flavor.ThornmotherSignatureCallouts + case ZoneDragonsLair: + return flavor.InfernaxSignatureCallouts + case ZoneAbyssPortal: + return flavor.BelaxathSignatureCallouts } return nil } @@ -206,6 +230,14 @@ func eliteRoomEntryPool(zoneID ZoneID) []string { return flavor.EliteRoomEntryManorBlackspire case ZoneUnderforge: return flavor.EliteRoomEntryUnderforge + case ZoneUnderdark: + return flavor.EliteRoomEntryUnderdark + case ZoneFeywildCrossing: + return flavor.EliteRoomEntryFeywildCrossing + case ZoneDragonsLair: + return flavor.EliteRoomEntryDragonsLair + case ZoneAbyssPortal: + return flavor.EliteRoomEntryAbyssPortal } return nil } diff --git a/internal/plugin/dnd_zone_narration_test.go b/internal/plugin/dnd_zone_narration_test.go index 93255c6..bf4aef4 100644 --- a/internal/plugin/dnd_zone_narration_test.go +++ b/internal/plugin/dnd_zone_narration_test.go @@ -185,17 +185,8 @@ func TestComposeBossEntry_AppendsAbilityCalloutForTier1(t *testing.T) { } } -func TestComposeBossEntry_NoCalloutForUnflavoredZone(t *testing.T) { - // Dragon's Lair has a named boss-entry pool (Infernax) but no - // signature-callout pool yet — composer should return the bare entry. - got := composeBossEntry(ZoneDragonsLair, "run-d2b", 1) - if got == "" { - t.Fatal("dragon's lair boss-entry should still render") - } - if n := strings.Count(got, "🎭 **TwinBee:**"); n != 1 { - t.Errorf("Dragon's Lair boss-entry expected 1 TwinBee block (no callout pool), got %d:\n%s", n, got) - } -} +// (Dragon's Lair gained signature callouts and an elite pool in D5b; see +// the Tier 4-5 routing tests below for its current behavior.) func TestComposeBossEntry_DeterministicAcrossCalls(t *testing.T) { a := composeBossEntry(ZoneGoblinWarrens, "stable-run", 4) @@ -217,10 +208,6 @@ func TestEliteRoomEntryLine_RoutesPerZone(t *testing.T) { if w == c { t.Errorf("Warrens and Crypt elite lines collided on same salt — pools shouldn't overlap") } - // Unflavored zone returns empty so the caller skips the prefix. - if got := eliteRoomEntryLine(ZoneDragonsLair, "run-elite", 3); got != "" { - t.Errorf("unflavored zone should return empty, got %q", got) - } } func TestZoneLorePool_PrependsZoneSpecific(t *testing.T) { @@ -229,11 +216,6 @@ func TestZoneLorePool_PrependsZoneSpecific(t *testing.T) { t.Errorf("Warrens lore pool should be larger than generic; got %d vs generic %d", len(w), len(flavor.LoreLines)) } - // Tier 3+ zone with no dedicated lore must fall back to the generic pool. - if got := zoneLorePool(ZoneDragonsLair); len(got) != len(flavor.LoreLines) { - t.Errorf("Dragon's Lair lore pool should fall back to generic; got %d, want %d", - len(got), len(flavor.LoreLines)) - } } // ── Phase 11 D3c — Tier 2 zone flavor file routing ────────────────────────── @@ -385,3 +367,81 @@ func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) { t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.GMMood) } } + +// ── Phase 11 D5b — Tier 4-5 zone flavor file routing ──────────────────────── + +func TestComposeBossEntry_AppendsAbilityCalloutForTier4(t *testing.T) { + got := composeBossEntry(ZoneUnderdark, "run-d5b", 5) + if !strings.Contains(got, "Ilvaras") { + t.Errorf("Underdark boss-entry should mention Ilvaras: %q", got) + } + if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { + t.Errorf("Underdark boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) + } + + got = composeBossEntry(ZoneFeywildCrossing, "run-d5b", 5) + if !strings.Contains(got, "Thornmother") { + t.Errorf("Feywild boss-entry should mention Thornmother: %q", got) + } + if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { + t.Errorf("Feywild boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) + } +} + +func TestComposeBossEntry_AppendsAbilityCalloutForTier5(t *testing.T) { + got := composeBossEntry(ZoneDragonsLair, "run-d5b", 5) + if !strings.Contains(got, "Infernax") { + t.Errorf("Dragon's Lair boss-entry should mention Infernax: %q", got) + } + if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { + t.Errorf("Dragon's Lair boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) + } + + got = composeBossEntry(ZoneAbyssPortal, "run-d5b", 5) + if !strings.Contains(got, "Belaxath") { + t.Errorf("Abyss boss-entry should mention Belaxath: %q", got) + } + if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { + t.Errorf("Abyss boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) + } +} + +func TestEliteRoomEntryLine_Tier4And5Routes(t *testing.T) { + u := eliteRoomEntryLine(ZoneUnderdark, "run-elite-t45", 3) + if u == "" || !strings.HasPrefix(u, "🎭 **TwinBee:**") { + t.Errorf("Underdark elite line missing/no prefix: %q", u) + } + f := eliteRoomEntryLine(ZoneFeywildCrossing, "run-elite-t45", 3) + if f == "" || !strings.HasPrefix(f, "🎭 **TwinBee:**") { + t.Errorf("Feywild elite line missing/no prefix: %q", f) + } + d := eliteRoomEntryLine(ZoneDragonsLair, "run-elite-t45", 3) + if d == "" || !strings.HasPrefix(d, "🎭 **TwinBee:**") { + t.Errorf("Dragon's Lair elite line missing/no prefix: %q", d) + } + a := eliteRoomEntryLine(ZoneAbyssPortal, "run-elite-t45", 3) + if a == "" || !strings.HasPrefix(a, "🎭 **TwinBee:**") { + t.Errorf("Abyss elite line missing/no prefix: %q", a) + } + if u == f || u == d || u == a || f == d || f == a || d == a { + t.Errorf("Tier 4-5 elite lines collided on same salt — pools shouldn't overlap") + } +} + +func TestZoneLorePool_Tier4And5PrependsZoneSpecific(t *testing.T) { + for _, z := range []ZoneID{ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal} { + if got := zoneLorePool(z); len(got) <= len(flavor.LoreLines) { + t.Errorf("%s lore pool should be larger than generic; got %d vs generic %d", + z, len(got), len(flavor.LoreLines)) + } + } +} + +func TestRoomEntryPool_Tier4And5PrependsZoneSpecific(t *testing.T) { + // All four T4-5 zones should now overlay zone-specific room entries. + for _, z := range []ZoneID{ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal} { + if got := zoneRoomEntryPool(z); len(got) <= len(flavor.RoomEntryGeneric) { + t.Errorf("%s room-entry pool should overlay zone-specific lines; got %d", z, len(got)) + } + } +}