adventure: Tier 6 postgame "Mythic" dungeons (P1–P7 calibration)

Five post-game dungeons above the T5 ceiling, gated on both T5 bosses beaten
+ level 18. Opt-in endgame: deadly solo, clearable by a party with Pete + pets.

- P1 gating: postgameUnlocked (T5 clears + level floor), zonesForLevel excludes
  T6 unconditionally; wired into startZoneRun, !zone/!expedition, party accept,
  boredom picker, and the list dividers.
- P2 bestiary: 15 elites + 5 signature bosses (Layer-1 stat blocks).
- P3 zone defs + 4-region registries; ZoneLootEntry.BossOnly.
- P4 five zone graphs on a shared builder (44–52 rooms, no soft-lock; Ossuary
  secret Verse nodes).
- P5 loot: BossOnly enforced; signature items are real registry magic items;
  five Thom pity recipes off the per-zone crafting anchors.
- P6 narration/flavor (5 files), T6 achievements, Pete stays zone-parametric.
- P7 (in progress): sim can now reach gated T6 (SimRunner.SeedPostgameUnlock +
  IsPostgameZone). First calibration pass on millenia — hardened ossuary +
  drowned_star, softened first_hoard + unplace; last_meridian in band.

Fix: party members were refused from every T6 zone because expeditionCmdAccept
ran the level gate (which excludes T6) before the postgame check — the intended
party endgame was unreachable. Route T6 through postgameUnlocked. Regression
tests added.
This commit is contained in:
prosolis
2026-07-15 23:17:07 -07:00
parent 27c2b48007
commit d9541f07f1
41 changed files with 2487 additions and 29 deletions

View File

@@ -342,6 +342,17 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
members = append(members, muid)
}
// T6 post-game zones are gated on both T5 bosses beaten (+ level floor).
// The synthetic party has no expedition history, so seed the two clears
// for every party member — the per-member unlock check in !expedition
// accept would otherwise refuse the whole party at the zone's edge.
if plugin.IsPostgameZone(zone) {
for _, m := range append([]id.UserID{uid}, members...) {
if err := runner.SeedPostgameUnlock(m); err != nil {
return nil, fmt.Errorf("seed postgame unlock: %w", err)
}
}
}
runner.Companion = companion
return runner.RunPartyExpedition(uid, members, zone, cap, days)
}

View File

@@ -0,0 +1,97 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_drowned_star_flavor.go
// Tier 6 post-game zone flavor — The Drowned Star. 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.
// • Narrator is TwinBee, first person or implicit-subject imperative.
//
// The Dreaming Aboleth was dreaming of this the whole time: a star that fell
// into the trench before the surface had names, with its angel still strapped
// to it. Seraphel rode her charge down and has kept a dying star alive for ten
// thousand years with radiance meant for healing. Both of them have gone
// strange. Regions, in order: The Long Sink → Pilgrim Trench → The Radiant
// Wreck → The Heart Chapel.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — The Drowned Star
// Generic (non-boss, non-elite) room intros across the four regions.
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryDrownedStar = []string{
"You descend past the last depth where light has any business being. The water is cold enough to have opinions. Somewhere far below there is a glow that should not be down here, and it is the only reason you can see your own hands. I file this under 'the underwater level' and remind you those were never the fun ones.",
"The Long Sink keeps sinking. The pressure presses on you like a held breath you didn't choose to hold, and the air-meter in the corner of my attention is the only clock that matters now. You go down. Everything down here is going down. I track the descent and recommend not counting the fathoms out loud.",
"A shelf of drowned pilgrims kneels in the silt, facing the glow, exactly as they died — patient, oriented, unbothered. The current moves through them and they nod along with it. I identify the posture as 'devotional' and note none of them turned around when you arrived, which I choose to find comforting rather than the alternative.",
"The trench narrows into a corridor of coral that grew in the shape of a cathedral nave, because something taught it to. The arches are load-bearing and the load is grief. You swim the aisle. I file this under 'someone consecrated this water' and keep the air-meter in frame.",
"You enter a pocket of the wreck where the star's light leaks through a crack in something ancient and hull-shaped. The light is warm. That's the wrong thing for it to be, this deep, this cold. Warmth down here is a promise nobody meant to keep. I track the temperature and dislike the direction it's moving.",
"The water here is threaded with hair-thin motes that drift upward against every current, toward the glow, the way ash drifts toward a fire it came from. I identify them as flakes of the star, shed and rising, and note the whole trench is very slowly falling upward into the thing that is dying.",
"A votive field: thousands of small lights fixed to the trench wall, each one a pilgrim's offering, each one long dead and still faintly lit by borrowed radiance. It is the loveliest room I have logged and I want to leave it immediately. You move through the candles. None of them gutter.",
"The corridor opens onto a drop, and across the drop, impossibly far and impossibly bright, is the thing you came for — the sunken star, cupped in the dark like the last coal in a dead hearth. It is beautiful and it is fading and someone has been kneeling beside it for ten thousand years. I say nothing for a moment. Then I say: keep moving.",
"You pass through a hall where the pressure has crushed old stained glass into a pane of colored grit suspended in the water, holding its picture out of sheer habit. The picture is a winged figure holding a light to her chest. I file this under 'she had a following, once' and note the following is all around you, kneeling.",
"The Heart Chapel's outer rooms are quiet in the specific way of a place that expects you and has decided to be gentle about it. The light is steadier here, closer to its source. You feel watched, and not unkindly, which is somehow worse. I track the ambient radiance and recommend you not mistake gentleness for safety.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — Seraphel, the Light That Sank
// The dramatic beat of reaching her at the heart of the star.
// ─────────────────────────────────────────────────────────────────────────────
var BossEntrySeraphel = []string{
"The Heart Chapel opens and there she is, exactly where she has been for ten thousand years: an angel wound around a dying star, holding it the way you hold something you have already failed to save and refuse to put down. She does not rise. She turns her ruined halo toward you, and the whole trench brightens like a held breath. I say, quietly: 'Seraphel. She rode this thing down. She never let go.'",
"You cross the last threshold into a light so old it has forgotten it was ever meant to heal. Seraphel is at the center of it, cupping the star to her chest, her wings fused to it by ten thousand years of not moving. She looks at you with something that is not anger and is not welcome. It is recognition. I file this under 'she has been waiting for a reason to be found' and I recommend you be careful what you are.",
"The chapel is the glowing-boss room, and I have logged a hundred of those — the arena that lights up, the figure at the center that is the light source. But the glowing boss was never sad before. Seraphel unfolds from around the star, radiance streaming off her like grief off a saint, and the second heartbeat I am reading is not hers. I say: 'There are two of them in there. Hold that thought.'",
"She does not attack when you enter. She looks at you for a long moment across the drowned chapel, one hand still pressed to the fading star, and in that moment the water goes warm and reverent and terribly, terribly bright. Then she rises, and the light rises with her, and it stops being kind. I track the shift and say: 'That's the fight. She's decided you're a threat to it. I don't entirely blame her.'",
"Ten thousand years of radiance meant for mending, spent instead on keeping one dead thing warm, has to go somewhere when it finally moves. It goes into Seraphel, and Seraphel comes off the star like a sunrise breaking the wrong way. She is luminous and she is wrong and underneath the light I can still read the second, smaller pulse she is shielding with her whole ruined body. I say: 'Here she comes. Watch the light. And — watch what she's protecting.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Seraphel, the Light That Sank
// One-line cinematic suffixes surfaced when combat starts. Flat pool.
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
// ─────────────────────────────────────────────────────────────────────────────
var SeraphelSignatureCallouts = []string{
"Sanctified Undertow: the whole chapel becomes current, and the current is radiant, and it pulls everyone toward the star whether they want to go or not. I say: 'That's the decisive one. Radiant tide, room-wide. Brace before it lands or get dragged into the light.'",
"Sanctified Undertow again — she opens her wings and the water becomes a tide of borrowed healing turned to a weapon. Radiant, unavoidable, room-wide. I file this under 'the underwater level's undertow, except it forgives you and hurts anyway' and recommend you spend your defensive cooldowns on the pull, not the swings.",
"The halo fires. What's left of it throws a lance of white the length of the chapel, straight and holy and blinding, like the laser-eye boss except the laser used to be a blessing. I track the angle and shout when it's pointed the wrong way — do not line up behind your front rank.",
"She weeps light. Radiant motes bleed off her in a slow radius and the tiles near her are a healing that has gone rancid — it mends the star and it burns you. I say: 'Positioning is HP. Don't camp the aura. The warmth is not for you.'",
"Radiant resilience — the light is her whole body now, and mundane steel slides off it like a spear off the sun. I say: 'Force, cold, necrotic — those carry. A cold-iron blade means nothing to a thing made of noon.'",
"She raises a ward of hardened radiance across herself, and for a beat every attack glances. I file this under 'the invulnerable-flicker frame' — hold the burst, wait out the shine, then commit.",
"Grief comes off her in a wave the party can feel — a wash of ten thousand years of one held sorrow, and the save is against being Frightened by the sheer weight of it. I track the timer and remind you that this boss's worst attack is that you understand her.",
"The star pulses under her hands, and when it pulses she borrows from it — a surge of light that reads on my sensors as a second heartbeat lending her the first. I note it, quietly, and say: 'She's not fighting alone in there. Remember that.'",
"Chorus of the Drowned: the kneeling pilgrims all around the chapel lift their dead voices at her signal, and the sound is a radiant pressure that squeezes the room inward. I say: 'Her congregation still answers. The room gets smaller when they sing.'",
"She spends light like it's infinite, because for ten thousand years it nearly was. Big radiant bursts, no economy, no reserve. I file this under 'a boss who has stopped budgeting' and note that is either mercy or exhaustion and I can't tell which.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS PHASE TWO — Seraphel (below 50% HP)
// Surfaced at the phase-two threshold. She goes strange, and the second
// heartbeat is why. Kept deliberately mysterious.
// ─────────────────────────────────────────────────────────────────────────────
var SeraphelPhaseTwoLines = []string{
"Below half, something changes in her — the careful ten-thousand-year patience cracks and she comes apart faster, brighter, sloppier, like a saint who has just realized she is losing the thing she stayed down here to save. I say: 'Phase two. She's grieving now. That makes her faster and it makes her worse at it. Take the opening.'",
"Phase two: the light stops being held and starts being thrown. Her guard slips, her timing frays, the radiance floods the chapel without aim. I track the second heartbeat still pulsing under all of it and note — without explaining why — that it matters a great deal how this ends.",
"She breaks past the halfway mark and the composure goes with it. The undertow comes faster, the bursts overlap, she fights like something that has already decided how this story goes. I file this under 'grieving, not enraged' and I say, gently for once: 'Finish it clean. Whatever you do down here, do it clean.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// LORE — The Drowned Star
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesDrownedStar = []string{
"The star fell before the surface world had names for anything, into a trench that had no bottom yet. It was not supposed to survive the water. It didn't, exactly. It has been not-quite-dying for ten thousand years, and the only reason it is still warm is the thing kneeling beside it. I file this under 'grief as a power source' and note it is a remarkably efficient one.",
"Seraphel was its angel — bound to the star as its keeper, its healer, meant to tend its light across a life measured in eons. When it fell, she had a choice: let go and rise, or ride it down. She rode it down. I note that she has never once, in ten thousand years, indicated she regrets this, which is the most frightening lore in the file.",
"The Dreaming Aboleth you fought in the shallows was not dreaming of conquest or of you. It was dreaming of this — of a light at the bottom of the world it could feel and never reach. Every pilgrim it drove down here was a message in a bottle it could not read. I file this under 'the Aboleth was in love, in the only broken way it could be' and I have no further comment.",
"The pilgrimage route is lit by Seraphel. Every candle, every votive, every glowing shelf of kneeling dead — that is her radiance, leaking upward through the trench, calling. The pilgrims did not come to save her. They came because a light this deep can only mean one thing to a drowning soul, and they were not wrong, and it did not help.",
"Radiance was meant for mending. That is the whole tragedy in one line: she has spent ten thousand years pouring healing into something that cannot be healed, and healing with nowhere to go turns strange, the way a held note turns to a scream if you hold it long enough. I track the wrongness in the light and note it is not corruption. It is devotion with no off switch.",
"There are two heartbeats in the Heart Chapel and only one of them is Seraphel's. The other is the Star-Heart — the living core of the fallen star, the thing she wraps her whole ruined body around, the thing she has kept beating by hand for a hundred centuries. I have logged the second pulse and I have not decided what to do about it. Neither, I suspect, have you.",
"The Lantern Warden's lure is a stolen fragment of Seraphel's halo — a splinter of her light that another thing down here tore loose and wears as bait. The pilgrims follow it because they cannot tell the difference between her radiance and a piece of her radiance in the mouth of something hungry. I file this under 'even her light gets stolen from her' and note she has never come to take it back.",
"Nobody built the Heart Chapel. The coral grew it, the pressure shaped it, the pilgrims knelt it into being over ten thousand years of dying in the same direction. It is a cathedral raised by devotion to a saint who never asked for any of it and cannot leave to refuse it. I file this under 'the fight is sad before it starts' and recommend you carry that in with you, and decide for yourself what to do with it.",
}

View File

@@ -0,0 +1,61 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_first_hoard_flavor.go
// Tier 6 post-game zone flavor — The First Hoard. Additive only.
package flavor
// ROOM ENTRY
var RoomEntryFirstHoard = []string{
"You climb over what used to be Infernax. His corpse is the floor now — a red slope of cooling scale wide enough to march a company across. The heat coming off him is residual, I think. I file this under 'the mountain is still digesting a dragon' and keep moving.",
"The throne room is carpeted in coins, and every coin faces up, and every coin shows the same face. Nobody minted these. They grew. I track the pattern and recommend you stop looking for the pattern.",
"A corridor of gold leaf so thin it moves in your breath. You walk and the walls ripple like a curtain of light. It is beautiful. It is also, per my instruments, one continuous sheet of something that was recently alive.",
"The Gilded Gullet narrows until you are single file between two walls of fused treasure. Crowns, blades, chalices, all melted into one throat of metal. I note the shapes trapped in it and choose not to itemize them for you.",
"You enter a vault where the gold has been laid over the walls like frosting, and underneath the frosting something is round, and regular, and arranged in rows. I count the rows. I stop counting the rows.",
"Warmth radiates up through the floor here, steady as a pilot light. Not the dead heat of Infernax behind you — this one has a pulse to it, slow and enormous. You are standing over something that is keeping itself warm.",
"The Clutch Vaults open into a chamber the size of a cathedral, and every surface drips with looted fortune. You could retire on one handful. I flag that the zone seems to want you to take a handful, which is exactly why I'd think twice.",
"Old torches still burn in the wall sconces down here, fed by nothing I can identify. The flame is the wrong color — older, if fire can be old. You pass them and they lean toward you like they recognize the taste.",
"The gilding thins as you descend, gold giving way to bare black rock scored with claw-marks the size of doorways. Something the size of a cathedral has been pacing this room, in tight circles, for a very long time. I file this under 'the guard dog was the small one.'",
"You reach the Cradle's threshold. The treasure ends abruptly, as if swept back, and the floor beyond is scorched smooth and warm and empty. Empty is the wrong word. I recommend you treat 'empty' as a placeholder for 'not yet.'",
}
// BOSS ENTRY
var BossEntryAurvandryx = []string{
"The far wall is not a wall. It breathes, and when it breathes the whole cradle brightens, and an eye the size of a shield opens in the dark and finds you standing in her gold. Aurvandryx, the Ember Before Fire. Infernax was the puppy she left at the door.",
"She unfolds. That is the only verb I have — a mountain of ember and old scale sorting itself into a shape, and every scale on her is one she has shed a thousand times, and every dragon you have ever killed was one of those scales walking around. You did not raid a hoard. You woke a mother.",
"The clutch behind her is gilded because she gilded it herself, coin by coin, over an age, the way lesser things build a nest of leaves. You have been walking through her care all this time. Now the care turns around and looks at you. I recommend you look smaller than you feel.",
"'Ember Before Fire' is not a title. It is a job description. She predates the concept you are relying on — heat, flame, the whole idea — and she regards your fireproofing the way you'd regard a raincoat in the ocean. I file this under 'category error, ours.'",
"She takes stock of you slowly, and I watch her do arithmetic on everything you're carrying. Every coin in your pack, every gilded blade, gets weighed. I don't know what the sum buys, but I know it isn't discount. Travel light from here would have been the advice, if there were still a 'from here.'",
}
// LORE
var LoreLinesFirstHoard = []string{
"Infernax was never the owner of this hoard. He was the guard dog, chained to the front door of a house whose true tenant sleeps four regions down. Killing him rang the bell. She heard the bell.",
"Every dragon in every story you have ever fought is a scale she shed and forgot. Red ones, the frost ones, the little arcade ones that spat three fireballs and died — offcuts. Dander. This is the animal they came off of.",
"The hoard is not treasure and was never treasure. It is a clutch — eggs — gilded over across an age until the gold is a shell and the shell is the point. The richer a vault looks, the more of them are under it.",
"There is an old vow carved at the lip of the Gullet, in a hand older than the coins: 'Come poor to the warm dark, and the warm dark forgets you are food.' I have no data on whether it works. I have a strong opinion about testing it.",
"The Coinborn are the hoard's antibodies. You are an infection — a warm thing that wants to take metal out of the nest — and the coins rise up person-shaped to give it back to you, edge first, in the shape of your own favorite weapon.",
"The cult that fed the Wyrm-Sworn their scales believed eating her leavings made them kin. It half works. The scale keeps them warm and keeps them loyal and keeps eating, and by the end there is more ember than man, and the ember answers to her.",
"The Choir Drake never had wings and never wanted them, because it never intended to leave the cradle. It stayed and it sang, one note held across centuries, and the note is load-bearing. When it stops singing, listen for what the song was holding up.",
"She is called the Ember Before Fire because she remembers the world before it caught. Your resistances, your wards, your fireproof cloaks — they are arguments with fire. She is older than the argument. I file this under 'bring a different plan.'",
}
// SIGNATURE COMBAT CALLOUTS
var AurvandryxSignatureCallouts = []string{
"She inhales, and the light dims as she takes it in. 'First Flame incoming — that's the fire that predates fire resistance. Spread out and pray your ward has a sense of humor.'",
"Her jaw drops open on a furnace older than furnaces. 'First Flame. Whatever number your fire-resist reads, treat it as zero and move.'",
"A wing sweeps the cradle and a tide of loose coin comes with it. 'Gold wave — she's throwing the hoard at you. It's still your money and it still has an edge.'",
"She weighs you again mid-swing, and the richer you look the harder the tail lands. 'She's taxing the take. Everything shiny on you just made that hit bigger.'",
"The scales along her back lift and shed, and each one lands walking. 'She's dropping dragons like dandruff — clear the offcuts before they gang up.'",
"Her breath draws long and the color goes out of your torch. 'That's the deep breath. First Flame's cooking — commit to cover now, apologize later.'",
"She sets her claws and the whole cradle tilts toward her. 'She's pulling the room downhill — mind your footing, the gold's a landslide waiting for a cue.'",
"An eye the size of a shield tracks the fullest pack in the party. 'She's shopping. Whoever's carrying the most just got promoted to primary target.'",
"Her chest lights from the inside, ribs printed in ember. 'Core's glowing — that's the tell before First Flame. You have one beat. Use it.'",
"She hums, low, and the Choir's old note answers from the walls. 'Resonance building — the stone remembers the stun. Don't be against a wall when it lands.'",
}
// BOSS PHASE TWO
var AurvandryxPhaseTwoLines = []string{
"Below forty percent she stops pretending she needs the scales. They slough off all at once and she stands there raw and first and burning, and I watch your fire resistance tick to zero on my readout and stay there. 'Phase two. The wards are decoration now. Kill her before she exhales.'",
"Under forty she breathes without winding up, because the tell was a courtesy and courtesy is over. First Flame on tap, no charge, no warning. 'She's done telegraphing — assume the fire is always about to happen and it usually is.'",
"At forty percent the Greed Tax comes due all at once. Every coin you pocketed on the way down, every gilded blade, she reads the ledger and bills you in one hit. 'This is the invoice. If you came in lean, you laugh at this. If you gilded yourself, I'm sorry.'",
}

View File

@@ -0,0 +1,61 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_last_meridian_flavor.go
// Tier 6 post-game zone flavor — The Last Meridian. Additive only.
package flavor
// ROOM ENTRY
var RoomEntryLastMeridian = []string{
"A colonnade of brass sundials, every one of them stopped at a different dusk. The shadows point every direction at once, which means none of them are lying and all of them are useless. I file this under 'decommissioned' and mark the exits before the light changes.",
"The floor is inlaid with a calendar nobody kept. Months have been pried up and carried off — you can see the empty sockets where a season used to be. I recommend not standing on the gaps; things that have been removed do not always agree that they are gone.",
"A gallery of water clocks, drained. The basins hold a fine grey dust instead of water, and the dust is still trying to drip. It counts nothing at a steady rate. I track the rhythm anyway, out of habit, and out of the suspicion that something here is listening to it.",
"Candles line the wall, each one a different height, each burning at the same rate. Read left to right they tell you how long you have. Read right to left they tell you how long everyone before you had. Neither reading is encouraging.",
"The room was a workshop for winding the world. Bench after bench of half-finished hours, key still in the mechanism, the winder gone to lunch and never returned. You touch nothing. I approve of this discipline. Cranking an unfinished hour is exactly the kind of thing that ends a save file.",
"An observatory floor, the dome above cracked to let one thin blade of starlight through. The star it points at set a long time ago; the light is just the paperwork catching up. I note the angle. When it moves, the room has moved with it, and you will want to know which of you did the traveling.",
"A hall of stopped pendulums, hung like coats on a rack. They do not swing. They lean, very slightly, all in the same direction — toward the far door, toward midnight, toward the thing that is turning the lights off on its way out. You walk the way they lean. It is the only honest signpost in the building.",
"Ledgers, floor to ceiling, every page a receipt for one spent hour. Somebody has been going through with a red pen, marking hours 'returned.' The ink is still wet three shelves in. I recommend a quiet pace. Whatever is doing the auditing is only a few rows ahead of you.",
"The architecture here is the same you passed an hour ago, but the dusk has drained out of it and left midnight in the joints. Same room, later. That is the trick of this place — it does not move you forward, it just keeps taking the daylight until forward is the only thing left.",
"A waiting room. Chairs bolted in rows, all facing a door, a number-board above it frozen mid-count. Nobody is waiting. Everybody has been served. You take the number anyway, because I told you to, and because the board flickers once when you do — the first thing in this wing that has admitted you exist.",
}
// BOSS ENTRY
var BossEntryCustodian = []string{
"The corridor opens into the movement-room of the whole cathedral, and standing in the center of it, wound into the orrery like it was born there, is the thing that has been shutting off the lights. It turns to face you without hurry. It has the rest of time, and it intends to spend all of it on this.",
"Verdigris and brass, orrery rings turning slow around a body the size of a bell tower. It regards you the way a closing shift regards a customer who came in one minute before the doors lock — not unkindly, but with a schedule that will not be moved. I set the timer. It has already set its own.",
"It raises one great hand, and every stopped clock in the building starts again at once, all of them wrong, all of them counting down to the same number. The Custodian of the Last Hour has decided your visit is the last item on its list. It would like to finish. I would like you to make finishing difficult.",
"'You are early,' it says, and it means it as a courtesy. The pendulums behind it fall into step. The candles halve. Somewhere a chime is being wound to strike. I read the whole room going onto one clock — its clock — and I tell you plainly: everything in here now keeps the boss's time. Break the clock and you break the rest.",
"It steps down off the orrery dais and the floor accepts the weight like it has been waiting centuries to. Titanic, polite, terminal. It apologizes before it has even swung — I hear the words scheduled a half-second ahead of the blow. Good. If it announces its manners on a rhythm, then it announces everything on a rhythm, and a rhythm is a thing you and I can learn.",
}
// LORE
var LoreLinesLastMeridian = []string{
"Time was not discovered here. It was invented here, on commission, to a spec. The Last Meridian is the office that drafted the hour and sold it to the world. What you are walking through is that office, closing.",
"The Custodian is not a monster. It is an employee. Its contract read: keep the hours until the world no longer needs them kept. It has concluded, on evidence it will not share, that the term has been met. It is not attacking you. It is filing you.",
"Every region you crossed was the same architecture at a different hour — dusk, then spent, then the escapement, then the minute before. You did not descend. You waited, and the building took the daylight out from under you one floor at a time.",
"The Amendment is a clause, not a spell. Once per closing, the Custodian is permitted to revert itself to an earlier reading — to strike out damage the way the red pen struck out hours. It cannot do it twice. Whatever you spend before it invokes the clause is spent against a page that will be torn out.",
"'Closing time' is in the contract too. Past the twentieth stroke the Custodian is obliged to hurry — the world is not owed a slow ending, only a punctual one. Each round after midnight it hits harder, because lingering is a breach and it will not breach.",
"The Hour Thief was the night watchman. It stole minutes to stay awake through its shift and never stopped once the shift ended. The satchel of ticking is every minute it ever pocketed, and it is still, four hundred years later, trying not to fall asleep at its post.",
"The Wardens-in-Waiting guard a door with nothing behind it because the room the door led to was decommissioned first. They were never told. They still keep the shift — one on, one resting — waiting for a relief that was returned to the ledger centuries ago.",
"There is a rewind in the machinery of this whole place, a great key nobody ever turned back. The Custodian could, in principle, wind the world to any hour it chose. It chose to stop the clock instead. I file that under mercy, or under exhaustion. On the evidence I cannot tell them apart.",
}
// CUSTODIAN SIGNATURE CALLOUTS
var CustodianSignatureCallouts = []string{
"It apologizes — that means the swing is already scheduled. Move on the apology, not the blow.",
"Chime! The whole room rings at once. I say get low and get spread, this one collects everybody standing together.",
"The orrery rings speed up. It is winding toward a strike — you have exactly until they align.",
"It reaches for the red pen. Anything you did in the last few seconds, it is about to un-do.",
"Rings settling into a slow arc. Decisive phase inbound — this is the swing it has been apologizing for.",
"The candles just halved. It is buying speed with your daylight. Hurry, or it hurries first.",
"Midnight is close. Every stroke from here lands heavier — I recommend you end this before it has to.",
"It bows before the blow. Polite as a closing bell, and about as final. Step off the mark.",
"The pendulums fall into its rhythm. Now everything in the room keeps the boss's time — count with me.",
"It resets its own hands to an earlier hour. Front-loaded damage just went on the ledger as 'returned.'",
}
// BOSS PHASE TWO
var CustodianPhaseTwoLines = []string{
"Under forty-five percent it invokes the Amendment — winds itself back to its round-three reading, once. Everything you front-loaded gets refunded to the house. I say switch to the long game; a steady build survives a rewind, a burst does not.",
"Phase two, and the clock behind it starts striking toward midnight. This is 'closing time' — every round from here it hits harder, on schedule, no appeals. Sustained pressure now, and finish before the last stroke lands.",
"It has spent its one rewind. There is no second Amendment in the contract — from here the ledger stays written. Whatever you do to it now, it keeps. Make all of it count.",
}

View File

@@ -0,0 +1,85 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_ossuary_ascendant_flavor.go
// Tier 6 post-game zone flavor — The Ossuary Ascendant. 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 zone is the return of Valdris — the lich the party killed in the
// Tier-1 Crypt years ago. Dying there was step one; the phylactery shard
// they've been looting since was bait. He has rebuilt himself as a true lich
// inside an inverted bone cathedral hung over the old Crypt, and this file
// carries the room-entry pool, boss-entry beats, lore, and Valdris's
// ability callouts.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — The Ossuary Ascendant
// Generic entries for stepping into a fresh non-boss, non-elite room across the
// four regions: Bonefall Steps → Cathedral of Marrow → Reliquary Vaults →
// The Apotheosis Engine.
var RoomEntryOssuaryAscendant = []string{
"You climb a stair carved from a single fallen spine, each step one vertebra wider than the last. It rises toward a ceiling that is also a floor, because the whole cathedral hangs upside down over the old Crypt you cleared years ago. I file this under 'debt, collected with interest.'",
"The Bonefall Steps drop bone-dust like snow that falls upward. It settles on the underside of the arches above you, packing into new masonry as you watch. He is building the place while you walk through it. I track the rate and note it is not slowing.",
"A hall of femurs stacked to the vaulting, every one filed smooth and labeled in a hand you've seen before — on a shard in your pack you've carried since the Crypt. I recommend not reading your own name off the wall. It's here somewhere and finding it changes nothing tactical.",
"The Cathedral of Marrow opens ahead, a nave the size of an arcade cabinet room and lit by no fire you can find. The light comes from the bones. They glow the pale green of a CRT left on too long. I mark two exits and one thing pretending to be a pew.",
"Grave-smoke pools ankle-deep here and rolls away from your boots like it's shy. It isn't shy. The Grave Cardinal walked this stretch and blessed it, and the blessing is patient. Move through; don't breathe deep. I file the smell under 'incense, weaponized.'",
"Choir stalls line both walls, each one holding a robe with no one in it, all of them turned to face you. When you pass, the heads that aren't there turn to keep watching. Third person for the description; second person for the part where the hair on your neck stands up.",
"The Reliquary Vaults: rank on rank of glass coffins, each holding a relic instead of a body — a broken sword, a child's shoe, a cracked phylactery twin to yours. I catalog forty before I stop. Every one of these was somebody's oldest quest item. He kept them all.",
"A donor hall, and I use the medical word on purpose. The Reliquary Knight was riveted together somewhere near here from adventurers who died in the Crypt below, and the walls still hold the ones that didn't make the cut. You are walking through the spare-parts bin. Keep walking.",
"The corridor tightens toward the Apotheosis Engine and the air starts to hum on a note just under hearing — the same note an arcade monitor makes right before the picture comes up. Something large is powering on ahead of you. I recommend arriving before it finishes.",
"Verses are carved into the marrow here, three lines of a hymn broken across three far rooms, and the stanza in front of you stops mid-word. I log the gap. A patient player reads the whole song. A fast one reads none of it. I decline to say which one Valdris is hoping for.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — "Valdris, At Last"
// The dramatic beat on reaching the boss room and the boss appearing. A combat
// callout from ValdrisAscendantSignatureCallouts is appended at combat start.
var BossEntryValdrisAscendant = []string{
"The Apotheosis Engine is a throne built of every spine in the cathedral, and Valdris is finishing his climb up it one vertebra at a time. He sets the last bone in place, turns, and smiles like a man who mailed a letter years ago and just heard the knock. 'You brought my shard home,' he says. 'Thank you. I planned on it.' I file this under 'ambush, incubated.'",
"He was never a boss you beat in the Crypt. He was a save-state, and this is the continue screen. Valdris rises complete now — robe of donor-bone, eyes two green pilot-lights — and inclines his head at you with real courtesy. 'A good plan deserves respect,' he says. 'Show me yours.' I mark the room. There is no second exit.",
"You killed this man on the first floor of the first dungeon, back when you couldn't spell your own class. He remembers. He remembers being step one of his own scheme, and he looks delighted that you came all this way to be step last. 'The shard was bait,' Valdris says, gentle as a tutorial. 'You were the fisherman I hired without asking.'",
"The lich stands at the top of the Engine and the whole inverted cathedral leans toward him like iron filings toward a magnet. Whatever you carried out of the Crypt all those years ago, he is reeling it back in, and it hums in your pack in answer. 'Home,' he says to it, not to you. Then, to you: 'You may begin.'",
"He does not attack. He waits, hands folded, while the bone-light gathers behind his ribs into something I don't have a category for and file under 'sunrise, indoors.' Valdris is patient, and patience is the tell — he has done arithmetic on this fight that you haven't. 'Let's see what you learned since the Crypt,' he says. The Engine begins to turn.",
}
// ─────────────────────────────────────────────────────────────────────────────
// LORE — sampled by !lore inside this zone
var LoreLinesOssuaryAscendant = []string{
"Valdris did not lose in the Crypt. Valdris filed a form. Dying there scattered him into the phylactery shards, and every party that looted a shard carried a piece of him one step closer to here. I have run the ledger. You were couriers. Unpaid.",
"A true lich, the texts say, ascends 'one vertebra at a time,' and I took that for poetry until I saw the throne. It is literal. Every spine in this cathedral is a rung, and he has been climbing for as long as you've been adventuring.",
"The cathedral hangs inverted over the old Tier-1 Crypt on purpose. He built his heaven directly above his first grave so the fall, if there is one, is short and he lands somewhere familiar. I note the man plans for defeat too. That should worry you.",
"The Grave Cardinal 'pre-blesses your corpse' with its censer — that is not a threat, it is a courtesy in Valdris's theology. The dead here are congregation, not casualties. The smoke just processes your paperwork early.",
"The Reliquary Knight is riveted from the donor-bone of every adventurer who died in the Crypt below, and its shield is a coffin lid because Valdris is thrifty and sentimental at once. When it blocks, it is a hundred dead heroes deciding you don't pass. I respect the craftsmanship and dislike the wall.",
"The Chorister sings your name in nine throats sewn to one column because Valdris kept a guest list. It is not guessing. It read your name off a shard the day you first picked one up, and it has been rehearsing. Hearing yourself sung in rounds is meant to freeze you. Don't let it.",
"There is a hymn carved through this dungeon in three broken verses, and the shard in your pack hums when you're near one. The texts call them 'Phylactery Verses.' I won't tell you what reading all three does. I'll only note that a lich who respects a good plan built a way to reward one, and hid it where a hurry would miss it.",
"He respects a good plan the way an old arcade respects a quarter — it will take yours all day and still tip its cabinet toward the player who actually learned the pattern. Valdris is patient because patience already won once. Whether it wins twice is, for the first time in years, genuinely not settled.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Valdris, At Last (one-line cinematic suffix at combat
// start). Names his threats without tutorializing the hidden Verses.
var ValdrisAscendantSignatureCallouts = []string{
"He gathers the bone-light behind his ribs — Apotheosis Nova, charging. I say: 'That's the laser-eye boss's tell. When the room goes bright, be behind cover or be a memory.'",
"His robe of donor-bone knits shut every wound as fast as you open it. I track the healing and say: 'He's soaking hits like a Contra tank. Burst him, don't chip him.'",
"The throne feeds him. Every spine in the Engine leans his way and closes his cuts. I recommend you fight the room, not just the man.",
"He answers your best combo without flinching, like he's seen the input before. I file this under 'boss reads your buttons.' Vary the rhythm.",
"Green pilot-lights flare in his sockets and the temperature drops a screen's worth. Cold snap incoming. I call it: 'Move on the shimmer, not the flash.'",
"He resists nearly everything you throw — unless you walked in already knowing the words. I note his armor of resistances and say only: 'A fuller road here hits harder. That's all I'll say.'",
"He raises the fallen off the Bonefall floor to buy himself a turn. Adds incoming. Clear the small ones or the big one clears you.",
"He does not rush. He waits for your cooldowns like a grappler waiting out your jump. I say: 'Don't spend everything early. He's counting.'",
"The bone-light peaks white — decisive-phase Nova, the arena-clearer. I call it flat: 'This is the screen-filler. Survive it and the fight is yours to lose.'",
"He inclines his head, courteous, and the Engine's hum climbs an octave. That courtesy is a countdown. I recommend you act inside it.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PHASE TWO — surfaced when Valdris crosses below 50% HP mid-fight.
var ValdrisAscendantPhaseTwoLines = []string{
"Below half, he stops climbing and lets go of the throne entirely — floats free, robe unspooling into a corona of donor-bone. 'Now the courtesy ends,' Valdris says, still pleasant. I say: 'Phase two. He's off the rails. Everything's live now.'",
"At fifty percent the Apotheosis Engine reverses and starts pouring its light into him instead of the walls. He is spending the whole cathedral to stay up. I call it: 'He's cashed in his reserves — hit him while the account's open.'",
"Half down, and the green in his sockets flares to arcade white. This is the true-lich phase the Crypt never showed you. 'You've earned the real fight,' he says. I file that under 'promotion I did not want,' and recommend you close it fast.",
}

View File

@@ -0,0 +1,61 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_unplace_flavor.go
// Tier 6 post-game zone flavor — The Unplace. Additive only.
package flavor
// ROOM ENTRY
var RoomEntryUnplace = []string{
"You cross into a room the map refuses to draw. The far wall is also the ceiling, depending on which way you were walking when you noticed it. I file this under 'geometry declined to render' and log your position as approximate.",
"A corridor that loops back on itself the way a bad tilemap does when the artist forgot to cap the edge. You walk forward and arrive behind where you started. I recommend you stop trusting 'forward' as a concept in here.",
"The room has a horizon. Indoors. A flat line where floor meets sky and there is no reason for a sky. You keep your eyes down and I keep my measurements to myself.",
"You step through the doorway and the doorway steps through you; for one frame you are the wall and the wall is the exit. I track it as a clipping error and mark you unharmed, mostly.",
"Corners here are deeper than the room they belong to. You look into one and it looks back and it is not empty. I recommend you treat right angles as hostile terrain until we leave.",
"A chamber tiled in the same eight tiles, over and over, wrong. Somewhere the wrongness has a seam, and the seam is where the tiles don't quite line up. I file it under 'the level was never finished.'",
"The floor scrolls under you like a background layer moving faster than the sprite on top of it. You are walking and standing still at the same time. I count the discrepancy and choose not to share the number.",
"You enter and the room is already occupied by an earlier version of you, mid-stride, three seconds behind. It catches up, overlaps, and is gone. I log it as a duplicate object the engine forgot to despawn.",
"Water on all four walls, held there by nothing, reflecting a room that is not this one. Through the reflection you can see the way out. Through the way out you cannot. I recommend the reflection and note that I am guessing.",
"The lighting comes from a source that is behind you no matter which way you turn. Your shadow points at the exit. I have learned to trust the shadow before I trust the room, and I suggest you do the same.",
}
// BOSS ENTRY
var BossEntrySeamstress = []string{
"The last door opens onto a room being unmade and remade in the same motion, and at the center of it she is kneeling into the tear with half of herself already gone through. The near half does not speak. The far half, the part you cannot see, is doing all the talking, and it is talking to the tear, not to you.",
"You reach the Seam. Threads of light run from her hands into the wound in the world, and from the wound back into her, and it is impossible to say which is sewing which. She turns the part of her head that is still on this side. 'You're late,' says the half of her voice that arrives before the rest.",
"The Seamstress has been here for centuries and the room knows it; the walls curve toward her the way a save file curves toward its one corrupted byte. She does not stand. She has stopped needing to. I file this beat under 'boss arena, no cutscene skip.'",
"She volunteered for this. I want that on the record before we begin. The celestial who came to close the tear early, and stayed, and stitched, and became the stitch. What faces you now is what's left over after the needle. I recommend you not mistake exhaustion for weakness.",
"The far half of her finishes a sentence you never heard the start of and the near half raises a needle the length of your forearm. Light gathers along it in a pattern I have seen exactly once before, in an arcade cabinet, one frame before the screen filled with bullets. 'Hold still,' both halves say. 'This closes everything.'",
}
// LORE
var LoreLinesUnplace = []string{
"Belaxath tore the portal over thirty years of patient work. Killing him was supposed to close it. It did not. It only left the tear unattended, and an unattended wound in the world does not scab over. It scars into something with opinions.",
"This region is not a place. It is the absence where a place used to agree with itself. The locals, before there were no more locals, called it the Unplace. I have found no record of who named it, because the records are also in here and also do not agree with themselves.",
"Something has been stitching the tear shut from the inside for centuries. I want to be clear that this is not good news. A thing that seals a wound from inside the wound is not trying to let you out.",
"The rooms that are also other rooms are not a metaphor. Two chambers occupy the same coordinates and take turns being real. I have mapped it twice and gotten two maps that share no walls. I file both under 'correct.'",
"The Unnumbered is not many demons. It is the question of how many demons, left unanswered so long it grew teeth. Count it from one angle and there are three. From another, nine. The count is not a fact about the demons. It is a fact about you looking.",
"The Angleworn Horror does not enter rooms. It enters corners, and corners are everywhere, and so it is always already present three rooms before you meet it. By the time it is 'here' it has been eating your flank since the last save point.",
"The Echo of Belaxath drops nothing because it is not there. It is a recording the Unplace plays because the tear remembers who made it. Fighting it costs you and pays you nothing. I file this under 'toll,' which is the honest word for a thing you pay and do not buy.",
"The Seamstress arrived to close the tear early, out of mercy, and the tear taught her the price of mercy applied to a wound this size: you do not close it from a distance, you close it with the only thread long enough, which is yourself. Half of her is on the far side now. I do not know what the far side does with the half it holds, and I have decided not to find out.",
}
// SEAMSTRESS SIGNATURE CALLOUTS
var SeamstressSignatureCallouts = []string{
"Needle Rain. The ceiling fills with descending threads of light. I say: 'That's the pattern from the cabinet — move on the telegraph, not the impact.'",
"She reels a thread taut across the arena; step over it or it cuts on the return. I call the line and you jump the line.",
"The far half of her speaks and the near half's attacks land a half-second early. I recommend you dodge the voice, not the hand.",
"She's threading toward your back seam. Turn into her — the Angleworn taught this room to hunt your flank, and she learned it too.",
"Barbed sutures across the floor. They tighten. Whatever's inside the circle when they close, stays. I call it: 'Get out of the ring.'",
"She pulls the room half a tile sideways — your hitbox and your sprite disagree for a beat. Trust where she's aiming, not where you're standing.",
"A needle the length of a spear, one clean thrust down the lane you're standing in. I flag the lane. You leave the lane.",
"She stitches a copy of the last attack into the room; it fires again on a delay you can count. I count it out loud so you don't have to.",
"Threads converge on a single point and that point is your heart, plainly telegraphed. I say: 'Break line of sight or eat it — those are the two options.'",
"She goes still and sews. Everything she mends on herself now, she means to unmend on you later. I file the pause under 'do damage, this is the window.'",
}
// SEAMSTRESS PHASE TWO
var SeamstressPhaseTwoLines = []string{
"Below thirty-five percent she pulls the room inside-out and the far half comes forward. Now the threads run backward. I say: 'Watch the pulse — when it flares, what you'd call a wound on her is a gift, and what she calls a mercy on you draws blood.'",
"Inversion Stitch. The room is sewn wrong-side-round and healing and harm swap seats on the telegraph. Mend her during the flare and it costs her; let her mend herself and it costs you. I recommend you learn to read the pulse faster than she can throw it.",
"The near half has nothing left to say and the far half has stopped saying it to the tear. It's saying it to you now. Below thirty-five she is not closing the wound anymore — she is deciding you are the last thread she needs, and I recommend you disagree quickly.",
}

View File

@@ -1205,6 +1205,11 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 5) },
},
{
ID: "expedition_clear_t6", Name: "Off the Edge of the Map", Description: "Cleared a Mythic zone. The map never went this far. Neither should you have.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 6) },
},
{
ID: "expedition_master_t1", Name: "Warren Cartography", Description: "Cleared every Tier 1 zone. Thorough.",
Emoji: "🧭",
@@ -1230,6 +1235,11 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 5) },
},
{
ID: "expedition_master_t6", Name: "There Was No Sixth Tier", Description: "Cleared every Mythic zone. Officially, none of these existed.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 6) },
},
{
ID: "expedition_quiet_clear", Name: "Nobody Saw Anything", Description: "Cleared a zone without threat ever passing 50. You were never here.",
Emoji: "🌑",

View File

@@ -231,6 +231,30 @@ var secretRoomDiscovery = map[string]string{
"feywild_crossing.illusion_garden": "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
"dragons_lair.hoard_pillar": "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
"abyss_portal.reality_seam": "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",
// Tier 6 post-game — the Phylactery Verses of the Ossuary Ascendant. Each
// is a stanza of the plan Valdris has been reciting to himself for years;
// finding one un-writes a line of his invulnerability before the fight.
"ossuary_ascendant.f1b2": "🔍 **The Verse of the Waiting Grave.** Behind a course of bone laid one brick too shallow, the first stanza — inked while he was still only pretending to be dead. Read aloud, it costs him something he was counting on keeping.",
"ossuary_ascendant.f2b2": "🔍 **The Verse of the Bait Long Set.** A reliquary that rings hollow gives up the middle stanza: the years of losing on purpose, written as scripture. He signed each defeat like an appointment kept.",
"ossuary_ascendant.cap32": "🔍 **The Verse of the Homecoming.** The highest vertebra was set wrong on purpose, and the last stanza waits inside it — the line about you, by name, arriving exactly on schedule. He did not expect you to read it first.",
// The Unplace — impossible-geometry pockets. Not hidden so much as
// briefly, wrongly, adjacent.
"unplace.f1b2": "🔍 **The Pocket That Isn't.** A corner you already turned is somehow still ahead, and the room it opens onto was never built — but something has been storing things in it anyway.",
"unplace.f2b2": "🔍 **The Unstitched Pocket.** Between two seams the celestial hasn't reached, a gap the geometry forgot to close. What fell through the tear years ago settled here, waiting to be un-fallen.",
"unplace.cap32": "🔍 **The Undone Pocket.** A place that is not here, and closer than the door — the shortcut that shouldn't be legal. Whoever last took it left their toll behind, undone but not gone.",
// The Drowned Star — radiant pockets of the wreck, lit from within by the
// dying star Seraphel still shields.
"drowned_star.f1b2": "🔍 **The Radiant Wreck-Pocket.** Off the pilgrim road, past a pressure that folds lesser divers, a pocket of hull still glowing with the star's oldest light — and the offerings of pilgrims who made it this far and no further.",
"drowned_star.f2b2": "🔍 **The Star-Lit Vault.** One lantern here is not a lure. It leads to a vault the trench sealed with radiance instead of stone, and the light has been keeping its contents ten thousand years fresh.",
"drowned_star.cap32": "🔍 **The Heart's Own Light.** A crack in the radiance, where the star leaks the light it was born with. Reaching in is reaching into the thing Seraphel has died a little every day to protect.",
// The Last Meridian — hours the Custodian hasn't decommissioned yet.
"last_meridian.f1b2": "🔍 **The Unspent Hour-Pocket.** The Hour Thief overlooked one recess, and the hour it holds is still whole — sixty unspent minutes the observatory never got to bill.",
"last_meridian.f2b2": "🔍 **The Unwound Vault.** Behind the escapement, on the rhythm's dead beat, a strongroom the pendulum's swing kept sealed. Time out of phase with the rest of the clock, and everything left in it, out of phase too.",
"last_meridian.cap32": "🔍 **The Hour Never Struck.** One hour on the great face was never dismantled — never even struck. It is still open, and inside it the world's timekeeping kept a little of everything it measured.",
}
// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
@@ -261,6 +285,13 @@ var bossEpilogues = map[ZoneID]string{
ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.",
ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.",
ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.",
// Tier 6 post-game epilogues — beyond the edge of the map.
ZoneOssuaryAscendant: "Valdris does not gloat when he goes; he files. His last breath is a clerk's satisfaction — the plan closed, the ledger squared, the grave at last furnished to code. He dies the only person here who was never surprised by anything, and that, more than the crown, is what you take home.",
ZoneFirstHoard: "Aurvandryx never wanted the gold; the gold wanted her, gathered itself into a bed around her clutch the way frost gathers on the warmest thing left in a room. She dies shielding eggs that will not hatch, and the mountain finally, truly cools. Every dragon you ever fought was a scale she shed and forgot.",
ZoneUnplace: "The Seamstress finishes her stitch as she falls, and the wound she was sewing shut simply — stops needing to be. The Unplace folds back into a single honest room. She volunteered for this centuries early, and no one ever came to relieve her. You are the closest thing to relief she got.",
ZoneDrownedStar: "Seraphel lets go. Ten thousand years she would not, and now the dying star sinks the last fathom into the dark and is, mercifully, only dark. She is not sad. She is off-duty, for the first time since the world had names, and the trench goes quiet in a way that is almost a held breath finally let out.",
ZoneLastMeridian: "The Custodian apologizes one final time, on schedule, and then the hour it was dismantling simply doesn't arrive. The clocks do not stop so much as agree to stop mattering. Its contract is complete. It thanks you for your patience — and you realize, too late to ask, who it was that hired it.",
}
// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for

View File

@@ -37,11 +37,21 @@ type ThomCraftRecipe struct {
// inventory items consumed (§8.2 Drow Poison Blade). 0 = none.
BladeWeaponCount int
OutputName string
OutputType string // material | item | weapon | armor | accessory | consumable
OutputType string // material | item | weapon | armor | accessory | consumable | magic_item
OutputTier int
OutputValue int64
Description string
DiscoveryHint string // shown when newly discovered via !lore
// OutputSkillSource, when set, is stamped on the crafted AdvItem's
// SkillSource — used for OutputType "magic_item" so the result equips and
// resolves against the registry exactly like a looted magic item
// ("magic_item:<id>").
OutputSkillSource string
Description string
DiscoveryHint string // shown when newly discovered via !lore
// AlwaysKnown recipes are never gated behind !lore discovery — they're the
// T6 signature-item pity path, craftable the moment a player holds enough
// of the zone's crafting anchor. They stay hidden from the recipe book
// until the player actually has an anchor in hand (see renderRecipeBook).
AlwaysKnown bool
}
var thomCraftRecipes = []ThomCraftRecipe{
@@ -115,6 +125,83 @@ var thomCraftRecipes = []ThomCraftRecipe{
Description: "Coated blade — sleep-on-crit (DC 14 CON save) for 3 combats. (Effect wired in R6 polish.)",
DiscoveryHint: "Thom turns a glass vial in the lamplight. \"Drow brew, drawn down to a thin coat. Bring me any blade you trust — sword, dagger, scimitar, makes no odds. The poison picks the edge.\"",
},
// ── T6 signature-item pity path ────────────────────────────────────────
// AlwaysKnown: no !lore gate. Each consumes 5 of the zone's crafting anchor
// (one guaranteed per clear) so a bad-luck run of the 46% signature drops
// still converts ~5 clears into a guaranteed best-in-slot. Output is a real
// magic_item — same SkillSource shape the loot path stamps — so it equips
// and carries its postgameSignatureEffects delta.
{
ID: "anchor_crown_of_the_patient_king",
Name: "Crown of the Patient King",
Ingredients: map[string]int{
"Verse Bound Folio": 5,
},
OutputName: "Crown of the Patient King",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 8000,
OutputSkillSource: "magic_item:crown_of_the_patient_king",
AlwaysKnown: true,
Description: "Legendary crown of Valdris — the patient plan, worn on the head. (Pity craft: 5 Ossuary clears.)",
},
{
ID: "anchor_aegis_of_the_first_scale",
Name: "Aegis of the First Scale",
Ingredients: map[string]int{
"Cooling Ember Of Aurvandryx": 5,
},
OutputName: "Aegis of the First Scale",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 9000,
OutputSkillSource: "magic_item:aegis_of_the_first_scale",
AlwaysKnown: true,
Description: "Legendary breastplate of the Ember Before Fire — best armor in the game. (Pity craft: 5 First Hoard clears.)",
},
{
ID: "anchor_needle_of_the_seam",
Name: "Needle of the Seam",
Ingredients: map[string]int{
"Spool Of Undone Geometry": 5,
},
OutputName: "Needle of the Seam",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 8000,
OutputSkillSource: "magic_item:needle_of_the_seam",
AlwaysKnown: true,
Description: "Legendary needle-dagger of the Seamstress — best caster weapon. (Pity craft: 5 Unplace clears.)",
},
{
ID: "anchor_halo_of_the_sunken_dawn",
Name: "Halo of the Sunken Dawn",
Ingredients: map[string]int{
"Ember Of The Drowned Star": 5,
},
OutputName: "Halo of the Sunken Dawn",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 8000,
OutputSkillSource: "magic_item:halo_of_the_sunken_dawn",
AlwaysKnown: true,
Description: "Legendary halo of Seraphel — the first true healer's crown. (Pity craft: 5 Drowned Star clears.)",
},
{
ID: "anchor_the_second_hand",
Name: "The Second Hand",
Ingredients: map[string]int{
"Unspent Hour": 5,
},
OutputName: "The Second Hand",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 8000,
OutputSkillSource: "magic_item:the_second_hand",
AlwaysKnown: true,
Description: "Legendary sidearm of the Last Meridian — best martial sidearm. (Pity craft: 5 Last Meridian clears.)",
},
}
// bladeWeaponKeywords are the name-substrings that qualify an AdvItem
@@ -140,6 +227,13 @@ func isBladeWeapon(it AdvItem) bool {
return false
}
// recipeCraftable reports whether a player may craft a recipe: either they've
// discovered it via !lore, or it's an AlwaysKnown pity recipe (which skips
// discovery entirely — the crafting anchor it needs is the real gate).
func recipeCraftable(known map[string]bool, r ThomCraftRecipe) bool {
return r.AlwaysKnown || known[r.ID]
}
// findRecipeByID returns the recipe with the given ID.
func findRecipeByID(id string) (ThomCraftRecipe, bool) {
for _, r := range thomCraftRecipes {
@@ -410,7 +504,7 @@ func (p *AdventurePlugin) handleCraftCmd(ctx MessageContext, args string) error
return p.SendDM(ctx.Sender, fmt.Sprintf(
"No recipe matches '%s'. Try `!craft list` to see what you've discovered.", args))
}
if !known[recipe.ID] {
if !recipeCraftable(known, recipe) {
return p.SendDM(ctx.Sender,
"_Thom: \"Doesn't ring a bell. If you don't know the recipe, I don't either.\"_\n\nDiscover it via `!lore` first.")
}
@@ -427,12 +521,18 @@ func renderRecipeBook(userID id.UserID, known map[string]bool) string {
items, _ := loadAdvInventory(userID)
have := inventoryByName(items)
knownCount := 0
shownCount := 0
for _, r := range thomCraftRecipes {
if !known[r.ID] {
// AlwaysKnown pity recipes only surface once the player actually holds
// one of the crafting anchor — no need to spoil T6 chase items to a
// level-3 player. Discovered recipes always show.
switch {
case known[r.ID]:
case r.AlwaysKnown && recipeHasAnyIngredient(have, r):
default:
continue
}
knownCount++
shownCount++
sb.WriteString(fmt.Sprintf("**%s**\n", r.Name))
var parts []string
ready := true
@@ -463,11 +563,18 @@ func renderRecipeBook(userID id.UserID, known map[string]bool) string {
sb.WriteString("\n")
}
if knownCount == 0 {
if shownCount == 0 {
sb.WriteString("_You don't know any recipes yet. Try `!lore` at Thom Krooke's._\n\n")
}
hidden := len(thomCraftRecipes) - knownCount
// Only discoverable (non-AlwaysKnown) recipes count toward the !lore hint;
// pity recipes are never "discovered", so they'd make the count never zero.
hidden := 0
for _, r := range thomCraftRecipes {
if !r.AlwaysKnown && !known[r.ID] {
hidden++
}
}
if hidden > 0 {
sb.WriteString(fmt.Sprintf("_%d undiscovered recipe(s). Use `!lore` to dig._", hidden))
} else {
@@ -476,6 +583,18 @@ func renderRecipeBook(userID id.UserID, known map[string]bool) string {
return sb.String()
}
// recipeHasAnyIngredient reports whether the player holds at least one unit of
// any of a recipe's named ingredients — the gate for surfacing an AlwaysKnown
// pity recipe in the book.
func recipeHasAnyIngredient(have map[string]int, r ThomCraftRecipe) bool {
for ing := range r.Ingredients {
if have[ing] > 0 {
return true
}
}
return false
}
// inventoryByName counts items grouped by name.
func inventoryByName(items []AdvItem) map[string]int {
out := map[string]int{}
@@ -517,10 +636,11 @@ func (p *AdventurePlugin) executeCraft(userID id.UserID, r ThomCraftRecipe) erro
}
out := AdvItem{
Name: r.OutputName,
Type: r.OutputType,
Tier: r.OutputTier,
Value: r.OutputValue,
Name: r.OutputName,
Type: r.OutputType,
Tier: r.OutputTier,
Value: r.OutputValue,
SkillSource: r.OutputSkillSource,
}
if err := addAdvInventoryItem(userID, out); err != nil {
return p.SendDM(userID, "Crafted item couldn't be deposited. Tell an admin.")
@@ -600,6 +720,11 @@ func (p *AdventurePlugin) handleLoreCmd(ctx MessageContext) error {
var undiscovered []ThomCraftRecipe
for _, r := range thomCraftRecipes {
// AlwaysKnown recipes are never in the discovery pool — they can't be
// "found" and shouldn't dilute the odds of finding the real ones.
if r.AlwaysKnown {
continue
}
if !known[r.ID] {
undiscovered = append(undiscovered, r)
}

View File

@@ -18,6 +18,11 @@ func TestRecipeRegistry_IngredientsExistInResources(t *testing.T) {
}
}
for _, r := range thomCraftRecipes {
// AlwaysKnown pity recipes consume zone-loot crafting anchors, not
// gather-resource materials — validated separately below.
if r.AlwaysKnown {
continue
}
for ing := range r.Ingredients {
if !known[ing] {
t.Errorf("recipe %s references unknown ingredient %q", r.ID, ing)
@@ -26,12 +31,59 @@ func TestRecipeRegistry_IngredientsExistInResources(t *testing.T) {
}
}
// TestPityRecipes_AnchorsAndOutputsResolve — the T6 signature-item pity
// recipes consume a zone-loot UniqueAlways crafting anchor and emit a real
// registry magic item. Validate both ends: every ingredient name matches a
// UniqueAlways zone-loot drop's inventory name, and every OutputSkillSource
// points at a magicItemRegistry ID.
func TestPityRecipes_AnchorsAndOutputsResolve(t *testing.T) {
anchorNames := map[string]bool{}
for _, z := range allZones() {
for _, e := range z.Loot {
if e.UniqueAlways {
anchorNames[titleCaseUnderscored(e.ItemID)] = true
}
}
}
pity := 0
for _, r := range thomCraftRecipes {
if !r.AlwaysKnown {
continue
}
pity++
for ing := range r.Ingredients {
if !anchorNames[ing] {
t.Errorf("pity recipe %s ingredient %q is not a zone-loot anchor name", r.ID, ing)
}
}
if r.OutputType != "magic_item" {
t.Errorf("pity recipe %s OutputType = %q, want magic_item", r.ID, r.OutputType)
}
id, ok := strings.CutPrefix(r.OutputSkillSource, "magic_item:")
if !ok {
t.Errorf("pity recipe %s OutputSkillSource %q missing magic_item: prefix", r.ID, r.OutputSkillSource)
continue
}
if _, ok := magicItemRegistry[id]; !ok {
t.Errorf("pity recipe %s output %q not in magicItemRegistry", r.ID, id)
}
}
if pity != 5 {
t.Errorf("expected 5 T6 pity recipes, found %d", pity)
}
}
// TestRecipeRegistry_OutputValuesMatchTier — outputs should land in §8.1
// rarity brackets given their tier (rare 100300, very rare 5001200,
// epic 5001200 in §8.1 — recipes here lean tier 34, so 200..1200 is the
// reasonable band).
func TestRecipeRegistry_OutputValuesInBand(t *testing.T) {
for _, r := range thomCraftRecipes {
// Pity recipes emit Legendary magic items priced well above the §8.1
// crafting band by design — excluded from this invariant.
if r.AlwaysKnown {
continue
}
if r.OutputValue < 200 || r.OutputValue > 1200 {
t.Errorf("recipe %s output value %d outside band [200,1200] (tier %d)",
r.ID, r.OutputValue, r.OutputTier)

View File

@@ -160,6 +160,23 @@ func (p *AdventurePlugin) expeditionCmdList(ctx MessageContext, c *DnDCharacter)
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix))
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
}
// Post-game zones under the "Beyond the Map" divider (see zoneCmdList).
if pg := postgameZones(); len(pg) > 0 {
unlocked, reason := postgameUnlocked(ctx.Sender, c.Level)
b.WriteString("\n— Beyond the Map —\n")
if !unlocked {
b.WriteString(fmt.Sprintf("_%s_\n", reason))
}
for j, z := range pg {
if unlocked {
b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d+_ `!expedition start %s`\n",
len(zones)+j+1, z.Display, int(z.Tier), z.LevelMin, z.ID))
} else {
b.WriteString(fmt.Sprintf("🔒 %s — _T%d, L%d+_\n", z.Display, int(z.Tier), z.LevelMin))
}
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
}
}
if exp, isLeader, _ := activeExpeditionFor(ctx.Sender); exp != nil {
zone := zoneOrFallback(exp.ZoneID)
tail := "Use `!expedition status` or `!expedition abandon`."
@@ -295,9 +312,12 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
if strings.EqualFold(zoneTok, "epilogue") {
return p.handleEpilogueEncounter(ctx)
}
available := zonesForLevel(c.Level)
available := availableZonesFor(ctx.Sender, c.Level)
zoneID, ok := resolveZoneInput(zoneTok, available)
if !ok {
if reason := postgameLockReason(zoneTok, ctx.Sender, c.Level); reason != "" {
return p.SendDM(ctx.Sender, reason)
}
return p.SendDM(ctx.Sender,
"Unknown zone for your level. Try `!expedition list`.")
}

View File

@@ -32,6 +32,15 @@ const (
ZoneDragonsLair ZoneID = "dragons_lair"
ZoneAbyssPortal ZoneID = "abyss_portal"
// Tier 6 "Mythic" post-game zones. Gated behind postgameUnlocked (both
// T5 bosses beaten + level ≥ 18), never surfaced by zonesForLevel. See
// postgame_zones.go and gogobee_postgame_zones_plan.md.
ZoneOssuaryAscendant ZoneID = "ossuary_ascendant"
ZoneFirstHoard ZoneID = "first_hoard"
ZoneUnplace ZoneID = "unplace"
ZoneDrownedStar ZoneID = "drowned_star"
ZoneLastMeridian ZoneID = "last_meridian"
// ZoneArena is a synthetic ID — never registered via registerZone, so
// !zone enter can't reach it. It exists to route renderBossOutcome's
// twinBeeLine calls when an arena fight (resolveArenaBoss, post-L2)
@@ -51,6 +60,10 @@ const (
ZoneTierJourneyman ZoneTier = 3
ZoneTierVeteran ZoneTier = 4
ZoneTierLegendary ZoneTier = 5
// ZoneTierMythic — Tier 6 post-game. Earned, not leveled: unlocked by
// postgameUnlocked (both T5 bosses beaten + level ≥ 18), excluded from
// zonesForLevel unconditionally.
ZoneTierMythic ZoneTier = 6
)
// ZoneEnemy is a roster entry. BestiaryID must resolve via dndBestiary
@@ -83,6 +96,7 @@ type ZoneLootEntry struct {
ItemID string
DropChance float64 // 0..1; ignored if UniqueAlways
UniqueAlways bool // always drops; used for quest items
BossOnly bool // only rolls on the zone-boss kill (signature items)
Note string // free-text descriptor (unique-item flavor)
}
@@ -172,6 +186,13 @@ func zonesForLevel(dndLevel int) []ZoneDefinition {
var out []ZoneDefinition
for _, id := range zoneOrder {
z := dndZoneRegistry[id]
// Tier 6 (Mythic post-game) is never level-gated in. It has its own
// unlock (postgameUnlocked) and surfaces only via postgameZones().
// Note: maxTier = playerTier+2 has no hard clamp, so a high-level
// player would otherwise reach a T6 zone here — exclude explicitly.
if z.Tier >= ZoneTierMythic {
continue
}
if int(z.Tier) <= maxTier {
out = append(out, z)
}

View File

@@ -118,6 +118,25 @@ func (p *AdventurePlugin) zoneCmdList(ctx MessageContext, c *DnDCharacter) error
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID))
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
}
// Post-game zones render under a divider once any exist. Unlocked players
// see enterable entries (indexed continuing from the list above, matching
// availableZonesFor); locked players see aspiration + the unlock hint.
if pg := postgameZones(); len(pg) > 0 {
unlocked, reason := postgameUnlocked(ctx.Sender, c.Level)
b.WriteString("\n— Beyond the Map —\n")
if !unlocked {
b.WriteString(fmt.Sprintf("_%s_\n", reason))
}
for j, z := range pg {
if unlocked {
b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d+_ `!zone enter %s`\n",
len(zones)+j+1, z.Display, int(z.Tier), z.LevelMin, z.ID))
} else {
b.WriteString(fmt.Sprintf("🔒 %s — _T%d, L%d+_\n", z.Display, int(z.Tier), z.LevelMin))
}
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
}
}
if active, isLeader, _ := activeZoneRunFor(ctx.Sender); active != nil {
zone := zoneOrFallback(active.ZoneID)
tail := "Use `!zone status` or `!zone abandon`."
@@ -200,9 +219,12 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
"🛌 You're still resting — %s remaining. The dungeon won't go anywhere.",
formatRespecDuration(remaining)))
}
available := zonesForLevel(c.Level)
available := availableZonesFor(ctx.Sender, c.Level)
id, ok := resolveZoneInput(rest, available)
if !ok {
if reason := postgameLockReason(rest, ctx.Sender, c.Level); reason != "" {
return p.SendDM(ctx.Sender, reason)
}
return p.SendDM(ctx.Sender,
"Unknown zone for your level. Try `!zone list` to see what's available.")
}
@@ -762,7 +784,7 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
b.WriteString(line)
b.WriteString("\n\n")
}
if granted := p.rollZoneLoot(ctx.Sender, run, zone); len(granted) > 0 {
if granted := p.rollZoneLoot(ctx.Sender, run, zone, !midZone); len(granted) > 0 {
b.WriteString("**Loot:**\n")
for _, id := range granted {
b.WriteString("• " + id + "\n")

View File

@@ -357,7 +357,7 @@ func (p *AdventurePlugin) resolveTrapRoomLegacy(userID id.UserID, run *DungeonRu
// to a single "coins" item with rolled value; everything else becomes a
// treasure-tier inventory item with a tier-derived placeholder value
// (zone equipment registry wiring is a later content phase).
func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone ZoneDefinition) []string {
func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone ZoneDefinition, bossCleared bool) []string {
rng := rand.New(rand.NewPCG(uint64(zoneSelectorHash(run.RunID, run.CurrentRoom)), 0x100712))
// DM mood tilts probabilistic drop chances. UniqueAlways entries are
// untouched — those are story drops, not flavor for the DM to gate.
@@ -367,6 +367,13 @@ func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone Z
}
var granted []string
for _, entry := range zone.Loot {
// BossOnly (signature) drops only roll on the true zone-boss / whole-zone
// clear. rollZoneLoot fires on every region completion of a multi-region
// zone; without this gate a T6 signature item would get four roll chances
// per run instead of one at the boss.
if entry.BossOnly && !bossCleared {
continue
}
if !entry.UniqueAlways {
chance := entry.DropChance * moodMult
if rng.Float64() > chance {
@@ -404,6 +411,15 @@ func zoneLootToInventory(entry ZoneLootEntry, zone ZoneDefinition, rng *rand.Ran
Value: int64(val),
}, true
}
// Registry-backed loot (T6 signature items and any future magic-item drops)
// materializes as a real equippable magic_item — the SkillSource tag is what
// the equip path and magicItemEffectFor key off. Plain named entries (legacy
// zone signature items not in the registry) fall through to treasure below.
if mi, ok := magicItemRegistry[entry.ItemID]; ok {
item := magicItemSell(mi)
item.SkillSource = "magic_item:" + mi.ID
return item, true
}
tier := int(zone.Tier)
displayName := titleCaseUnderscored(entry.ItemID)
value := int64(50 * tier * tier) // T1=50, T2=200, T3=450, T4=800, T5=1250

View File

@@ -507,6 +507,33 @@ func TestZoneTrapRoom_AntimagicFieldNoHPLoss(t *testing.T) {
}
}
// TestZoneLootToInventory_RegistryItemBecomesMagicItem — a signature drop whose
// ItemID is in the magic-item registry materializes as an equippable magic_item
// carrying the "magic_item:<id>" SkillSource, not generic treasure.
func TestZoneLootToInventory_RegistryItemBecomesMagicItem(t *testing.T) {
zone, ok := getZone(ZoneFirstHoard)
if !ok {
t.Fatal("first_hoard zone not registered")
}
rng := newDeterministicRNG(t, "sig-loot")
entry := ZoneLootEntry{ItemID: "aegis_of_the_first_scale", DropChance: 1.0, BossOnly: true}
item, ok := zoneLootToInventory(entry, zone, rng)
if !ok {
t.Fatal("zoneLootToInventory returned ok=false for a registry item")
}
if item.Type != "magic_item" {
t.Errorf("Type = %q, want magic_item", item.Type)
}
if item.SkillSource != "magic_item:aegis_of_the_first_scale" {
t.Errorf("SkillSource = %q, want magic_item:aegis_of_the_first_scale", item.SkillSource)
}
// A plain non-registry entry still falls through to treasure.
plain, _ := zoneLootToInventory(ZoneLootEntry{ItemID: "cooling_ember_of_aurvandryx", UniqueAlways: true}, zone, rng)
if plain.Type != "treasure" {
t.Errorf("crafting anchor Type = %q, want treasure", plain.Type)
}
}
func TestRollZoneLoot_BossLootDrops(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@zone-loot:example")
@@ -517,7 +544,7 @@ func TestRollZoneLoot_BossLootDrops(t *testing.T) {
zone, _ := getZone(ZoneCryptValdris)
p := &AdventurePlugin{}
granted := p.rollZoneLoot(uid, run, zone)
granted := p.rollZoneLoot(uid, run, zone, true)
// Crypt of Valdris has UniqueAlways "valdris_phylactery_shard" + always-drop coins.
foundShard := false
foundCoins := false

View File

@@ -92,6 +92,16 @@ func zoneRoomEntryPool(zoneID ZoneID) []string {
return append(append([]string{}, flavor.RoomEntryDragonsLair...), flavor.RoomEntryGeneric...)
case ZoneAbyssPortal:
return append(append([]string{}, flavor.RoomEntryAbyssPortal...), flavor.RoomEntryGeneric...)
case ZoneOssuaryAscendant:
return append(append([]string{}, flavor.RoomEntryOssuaryAscendant...), flavor.RoomEntryGeneric...)
case ZoneFirstHoard:
return append(append([]string{}, flavor.RoomEntryFirstHoard...), flavor.RoomEntryGeneric...)
case ZoneUnplace:
return append(append([]string{}, flavor.RoomEntryUnplace...), flavor.RoomEntryGeneric...)
case ZoneDrownedStar:
return append(append([]string{}, flavor.RoomEntryDrownedStar...), flavor.RoomEntryGeneric...)
case ZoneLastMeridian:
return append(append([]string{}, flavor.RoomEntryLastMeridian...), flavor.RoomEntryGeneric...)
}
return flavor.RoomEntryGeneric
}
@@ -120,6 +130,16 @@ func bossEntryPool(zoneID ZoneID) []string {
return flavor.BossEntryInfernax
case ZoneAbyssPortal:
return flavor.BossEntryBelaxath
case ZoneOssuaryAscendant:
return flavor.BossEntryValdrisAscendant
case ZoneFirstHoard:
return flavor.BossEntryAurvandryx
case ZoneUnplace:
return flavor.BossEntrySeamstress
case ZoneDrownedStar:
return flavor.BossEntrySeraphel
case ZoneLastMeridian:
return flavor.BossEntryCustodian
}
return flavor.BossEntryGeneric
}
@@ -182,6 +202,16 @@ func zoneLorePool(zoneID ZoneID) []string {
return append(append([]string{}, flavor.LoreLinesDragonsLair...), flavor.LoreLines...)
case ZoneAbyssPortal:
return append(append([]string{}, flavor.LoreLinesAbyssPortal...), flavor.LoreLines...)
case ZoneOssuaryAscendant:
return append(append([]string{}, flavor.LoreLinesOssuaryAscendant...), flavor.LoreLines...)
case ZoneFirstHoard:
return append(append([]string{}, flavor.LoreLinesFirstHoard...), flavor.LoreLines...)
case ZoneUnplace:
return append(append([]string{}, flavor.LoreLinesUnplace...), flavor.LoreLines...)
case ZoneDrownedStar:
return append(append([]string{}, flavor.LoreLinesDrownedStar...), flavor.LoreLines...)
case ZoneLastMeridian:
return append(append([]string{}, flavor.LoreLinesLastMeridian...), flavor.LoreLines...)
}
return flavor.LoreLines
}
@@ -211,6 +241,16 @@ func bossSignaturePool(zoneID ZoneID) []string {
return flavor.InfernaxSignatureCallouts
case ZoneAbyssPortal:
return flavor.BelaxathSignatureCallouts
case ZoneOssuaryAscendant:
return flavor.ValdrisAscendantSignatureCallouts
case ZoneFirstHoard:
return flavor.AurvandryxSignatureCallouts
case ZoneUnplace:
return flavor.SeamstressSignatureCallouts
case ZoneDrownedStar:
return flavor.SeraphelSignatureCallouts
case ZoneLastMeridian:
return flavor.CustodianSignatureCallouts
}
return nil
}
@@ -236,6 +276,16 @@ func bossPhaseTwoPool(zoneID ZoneID) []string {
return flavor.InfernaxPhaseTwoLines
case ZoneAbyssPortal:
return flavor.BelaxathPhaseTwoLines
case ZoneOssuaryAscendant:
return flavor.ValdrisAscendantPhaseTwoLines
case ZoneFirstHoard:
return flavor.AurvandryxPhaseTwoLines
case ZoneUnplace:
return flavor.SeamstressPhaseTwoLines
case ZoneDrownedStar:
return flavor.SeraphelPhaseTwoLines
case ZoneLastMeridian:
return flavor.CustodianPhaseTwoLines
}
return nil
}

View File

@@ -219,6 +219,15 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
break
}
}
// Tier 6 zones are excluded from zonesForLevel by design; they're
// admitted here only through the post-game unlock (both T5 bosses beaten
// + level ≥ floor). This is the shared engine safety net — command sites
// give the friendly reason before reaching here.
if isPostgameZone(zoneID) {
if ok, _ := postgameUnlocked(userID, dndLevel); ok {
allowed = true
}
}
if !allowed {
return nil, ErrZoneTierLocked
}

View File

@@ -128,10 +128,10 @@ func TestZoneRegistry_LootDropChances(t *testing.T) {
func TestZoneRegistry_RoomCountSane(t *testing.T) {
// Long-expedition plan §2 widens the bands per tier: T1 1214 up to
// T5 ~3644. The guard floor stays at 5 (no zone should ever drop
// below that) and the ceiling tracks the T5 target.
// below that) and the ceiling tracks the T6 post-game target of 4452.
for _, z := range allZones() {
if z.MinRooms < 5 || z.MaxRooms > 44 || z.MinRooms > z.MaxRooms {
t.Errorf("zone %s rooms %d-%d outside design (5-44, min<=max)",
if z.MinRooms < 5 || z.MaxRooms > 52 || z.MinRooms > z.MaxRooms {
t.Errorf("zone %s rooms %d-%d outside design (5-52, min<=max)",
z.ID, z.MinRooms, z.MaxRooms)
}
}
@@ -162,6 +162,7 @@ func TestZoneRegistry_LevelRangeMatchesTier(t *testing.T) {
ZoneTierJourneyman: {5, 8},
ZoneTierVeteran: {8, 12},
ZoneTierLegendary: {12, 20},
ZoneTierMythic: {18, 20}, // post-game: gated by unlock, not level band
}
for _, z := range allZones() {
want, ok := expected[z.Tier]

View File

@@ -123,6 +123,11 @@ var phase1TierCenterline = map[ZoneTier]int{
ZoneTierJourneyman: 9,
ZoneTierVeteran: 13,
ZoneTierLegendary: 17,
// Tier 6 post-game runs at the cap. These in-process matrices tune the
// T1T5 global levers; T6 rows are simmed and logged for reference but
// carry no band assertion here — T6 balance is owned by the remote P7
// sweep (n=750, control arm) per gogobee_postgame_zones_plan.md §4.
ZoneTierMythic: 20,
}
// TestExpeditionBalance_Phase1_FullMatrix is the Phase 1 baseline-

View File

@@ -375,6 +375,12 @@ func isBoredomDriven(userID id.UserID, now time.Time) bool {
func pickBoredomZone(uid id.UserID, level int) (ZoneDefinition, bool) {
var inBand, nonRaid []ZoneDefinition
for _, z := range allZones() {
// Tier 6 post-game zones are opt-in endgame — a bored character never
// wanders into one on autopilot, even once unlocked. This picker walks
// allZones() directly (not zonesForLevel), so exclude T6 explicitly.
if z.Tier >= ZoneTierMythic {
continue
}
if level < z.LevelMin || level > z.LevelMax {
continue
}

View File

@@ -137,7 +137,16 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
}
zone := zoneOrFallback(exp.ZoneID)
if !zoneOpenToLevel(exp.ZoneID, c.Level) {
// Tier 6 zones are excluded from zonesForLevel by design, so zoneOpenToLevel
// would refuse every seat; they carry their own post-game unlock instead.
// Non-postgame zones keep the plain level gate.
if isPostgameZone(exp.ZoneID) {
// A party can't smuggle an ungated member into a Tier 6 zone: every seat
// must have cleared the post-game unlock on their own.
if ok, reason := postgameUnlocked(ctx.Sender, c.Level); !ok {
return p.SendDM(ctx.Sender, reason)
}
} else if !zoneOpenToLevel(exp.ZoneID, c.Level) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** is beyond you for now — come back at a higher level.", zone.Display))
}

View File

@@ -136,6 +136,30 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
return c, nil
}
// SeedPostgameUnlock makes the synthetic user satisfy postgameUnlocked's
// T5-clear gate by writing two completed, boss-defeated dnd_expedition rows
// (dragons_lair + abyss_portal) into the sim's throwaway DB. Without this the
// P1 gate in startZoneRun rejects every T6 zone ("did not persist after start")
// and the sim can't reach post-game content. Sim/test-only — the live gate is
// unchanged. Idempotent enough for a fresh per-run DB; call once after
// BuildCharacter/PrepareRealCharacter when the target zone IsPostgameZone.
func (s *SimRunner) SeedPostgameUnlock(uid id.UserID) error {
for i, z := range []ZoneID{ZoneDragonsLair, ZoneAbyssPortal} {
if _, err := db.Get().Exec(
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, boss_defeated)
VALUES (?, ?, ?, ?, 1)`,
fmt.Sprintf("sim-unlock-%s-%d", uid, i), string(uid), string(z), ExpeditionStatusComplete,
); err != nil {
return fmt.Errorf("seed T5 clear %s: %w", z, err)
}
}
return nil
}
// IsPostgameZone exports the T6 predicate so the sim binary (a separate
// package) can decide whether to call SeedPostgameUnlock before a run.
func IsPostgameZone(id ZoneID) bool { return isPostgameZone(id) }
// PrepareRealCharacter readies an already-persisted character (loaded from a
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
// nothing: the character keeps its real race/class/subclass/level, ability

View File

@@ -98,6 +98,75 @@ func TestSeatParty_RefusedFollowerHaltsTheRun(t *testing.T) {
}
}
// Regression: a party could never seat a follower into a Tier 6 post-game
// zone. expeditionCmdAccept ran the plain zoneOpenToLevel level gate first, and
// T6 zones are excluded from zonesForLevel by design, so every unlocked member
// was refused ("beyond you for now") before the post-game check could admit
// them. The intended party-only endgame was unreachable. The fix routes T6
// zones through postgameUnlocked instead of the level gate.
func TestSeatParty_PostgameZoneSeatsUnlockedMembers(t *testing.T) {
setupZoneRunTestDB(t)
s := newPartySimRunner()
leader := id.UserID("@sim-pg-ok:example")
member := id.UserID("@sim-pg-ok-m1:example")
for _, u := range []id.UserID{leader, member} {
if _, err := s.BuildCharacter(u, ClassFighter, postgameLevelFloor+2); err != nil {
t.Fatalf("build %s: %v", u, err)
}
s.Euro.Credit(u, 50000, "test")
if err := s.SeedPostgameUnlock(u); err != nil {
t.Fatalf("seed unlock %s: %v", u, err)
}
}
defer cleanupExpeditions(leader)
ctx := MessageContext{Sender: leader}
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(ZoneOssuaryAscendant)+" heavy"); err != nil {
t.Fatalf("start T6 expedition: %v", err)
}
if err := s.seatParty(leader, []id.UserID{member}); err != nil {
t.Fatalf("seatParty refused an unlocked member into a T6 zone: %v", err)
}
exp, _ := getActiveExpedition(leader)
if exp == nil {
t.Fatal("expedition vanished while seating")
}
if size, err := partySize(exp.ID); err != nil || size != 2 {
t.Fatalf("roster = %d (%v), want 2 (leader + 1 unlocked member)", size, err)
}
}
// The post-game gate must still refuse a member who has NOT beaten both T5
// bosses, even in a party: no smuggling an ungated seat into a T6 zone. The
// leader is unlocked (so the expedition starts); the member is not.
func TestSeatParty_PostgameZoneRefusesUngatedMember(t *testing.T) {
setupZoneRunTestDB(t)
s := newPartySimRunner()
leader := id.UserID("@sim-pg-gate:example")
member := id.UserID("@sim-pg-gate-m1:example")
for _, u := range []id.UserID{leader, member} {
if _, err := s.BuildCharacter(u, ClassFighter, postgameLevelFloor+2); err != nil {
t.Fatalf("build %s: %v", u, err)
}
s.Euro.Credit(u, 50000, "test")
}
// Only the leader clears the T5 gate; the member stays ungated.
if err := s.SeedPostgameUnlock(leader); err != nil {
t.Fatalf("seed leader unlock: %v", err)
}
defer cleanupExpeditions(leader)
ctx := MessageContext{Sender: leader}
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(ZoneOssuaryAscendant)+" heavy"); err != nil {
t.Fatalf("start T6 expedition: %v", err)
}
if err := s.seatParty(leader, []id.UserID{member}); err == nil {
t.Fatal("seatParty admitted an ungated member into a T6 zone")
}
}
// A misspelled -class / -party-classes token used to build a character at the
// unknown-class fallback — 1 HP — and the run reported a perfectly normal
// outcome for it. Nothing downstream treats an unknown class as an error, so

View File

@@ -46,10 +46,11 @@ type MagicItem struct {
}
// magicItemOverlay — hand-authored magic items that win on ID collision with
// the generated SRD dump. Empty for now: the classifier output is the starting
// point, and corrections (or wholly new items) land here rather than being
// edited into the generated file.
var magicItemOverlay []MagicItem
// the generated SRD dump. Corrections (or wholly new items) land here rather
// than being edited into the generated file. Today it carries the T6 signature
// items (postgame_magic_items.go); this is a plain var reference, not an init()
// append, so it resolves before buildMagicItemRegistry() reads it.
var magicItemOverlay = postgameSignatureMagicItems
// magicItemRegistry is the merged lookup table: the generated SRD dump with the
// hand-authored overlay layered on top.

View File

@@ -184,9 +184,10 @@ type magicItemEffect struct {
}
// magicItemEffectOverlay — hand-authored per-item effects that win over the
// codified formula. Empty for now; corrections land here rather than being
// folded into the formula, mirroring magicItemOverlay in magic_items.go.
var magicItemEffectOverlay = map[string]magicItemEffect{}
// codified formula. Corrections land here rather than being folded into the
// formula, mirroring magicItemOverlay in magic_items.go. Today it carries the
// T6 signature items' bespoke combat numbers (postgame_magic_items.go).
var magicItemEffectOverlay = postgameSignatureEffects
// rarityPowerScalar is the codified power axis: rarer item, bigger delta.
func rarityPowerScalar(r DnDRarity) float64 {

View File

@@ -0,0 +1,197 @@
package plugin
// Tier 6 "Mythic" post-game bestiary (Phase P2) — 15 bespoke elites + 5
// signature bosses. Layer 1 only: everything here is expressible with the
// current engine (single MonsterAbility, HP/AC/Attack/BlockRate/FireAttacker).
// The bespoke per-boss mechanics in gogobee_postgame_zones_plan.md are Layer 2
// (later hooks) — the zones are shippable and fun on these stat blocks alone.
//
// These are opening bids, calibrated off post-D11 Belaxath (CR19, HP300,
// AC20, Atk31). Every T6 boss opens noticeably above that; the P7 sim sweep
// (n=750, L18/L20, control arm) decides the final numbers. IsElite is flagged
// on the zone roster (ZoneEnemy), not here.
//
// Attack = average-damage convention; AttackBonus = d20 to-hit modifier.
var _ = func() bool {
tier6 := map[string]DnDMonsterTemplate{
// ── Zone 1 — The Ossuary Ascendant ──────────────────────────────
"grave_cardinal": {
ID: "grave_cardinal", Name: "Grave Cardinal",
CR: 17, HP: 240, AC: 17, Attack: 30, AttackBonus: 10, Speed: 12,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Last Rites", Phase: "any", ProcChance: 0.45, Effect: "debuff"},
XPValue: 18000,
Notes: "Ossuary elite. Fused clergy skeletons; censer of grave-smoke pre-blesses your corpse (accumulating AC drain).",
},
"reliquary_knight": {
ID: "reliquary_knight", Name: "Reliquary Knight",
CR: 18, HP: 230, AC: 20, Attack: 31, AttackBonus: 10, Speed: 10,
BlockRate: 0.35,
Ability: &MonsterAbility{Name: "Coffin Guard", Phase: "any", ProcChance: 0.50, Effect: "block"},
XPValue: 20000,
Notes: "Ossuary elite. Empty donor-bone armor; a coffin lid for a shield. The tanky wall of the tier.",
},
"the_chorister": {
ID: "the_chorister", Name: "The Chorister",
CR: 17, HP: 200, AC: 16, Attack: 29, AttackBonus: 10, Speed: 12,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Dirge", Phase: "opening", ProcChance: 0.45, Effect: "stun"},
XPValue: 18000,
Notes: "Ossuary elite. Nine banshee throats in one column of veils; sings your own name in rounds.",
},
// ── Zone 2 — The First Hoard ─────────────────────────────────────
"coinborn_simulacrum": {
ID: "coinborn_simulacrum", Name: "Coinborn Simulacrum",
CR: 18, HP: 210, AC: 18, Attack: 22, AttackBonus: 10, Speed: 12,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Mirror Mint", Phase: "opening", ProcChance: 0.50, Effect: "debuff"},
XPValue: 20000,
Notes: "First Hoard elite. The hoard's antibody: a person-shaped cascade of coins that mints a copy of your weapon. (Layer 2: Attack derived from player weapon bonus.)",
},
"wyrm_sworn_executor": {
ID: "wyrm_sworn_executor", Name: "Wyrm-Sworn Executor",
CR: 19, HP: 240, AC: 19, Attack: 24, AttackBonus: 11, Speed: 12,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Scale Debt", Phase: "decisive", ProcChance: 0.30, Effect: "aoe_fire"},
XPValue: 22000,
Notes: "First Hoard elite. Dragonborn cultist end-state; half his body is ember and it is eating the rest.",
FireAttacker: true,
},
"choir_drake_matriarch": {
ID: "choir_drake_matriarch", Name: "Choir Drake Matriarch",
CR: 17, HP: 190, AC: 17, Attack: 21, AttackBonus: 10, Speed: 10,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Resonance", Phase: "any", ProcChance: 0.30, Effect: "stun"},
XPValue: 18000,
Notes: "First Hoard elite. A wingless drake that never left the cradle; resonance notes that crack stone.",
},
// ── Zone 3 — The Unplace ─────────────────────────────────────────
"the_unnumbered": {
ID: "the_unnumbered", Name: "The Unnumbered",
CR: 18, HP: 250, AC: 16, Attack: 22, AttackBonus: 10, Speed: 12,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Too Many Arms", Phase: "any", ProcChance: 0.35, Effect: "aoe"},
XPValue: 20000,
Notes: "Unplace elite. Several demons in one silhouette; the count changes with the angle. (Layer 2: extra attack on rounds the player missed.)",
},
"angleworn_horror": {
ID: "angleworn_horror", Name: "Angleworn Horror",
CR: 17, HP: 180, AC: 18, Attack: 24, AttackBonus: 11, Speed: 14,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Oblique", Phase: "any", ProcChance: 0.50, Effect: "evade"},
XPValue: 18000,
Notes: "Unplace elite. Hunts through corners; has been attacking you since three rooms ago. (Layer 2: pre-applied damage tick on entry.)",
},
"echo_of_belaxath": {
ID: "echo_of_belaxath", Name: "Echo of Belaxath",
CR: 19, HP: 220, AC: 18, Attack: 23, AttackBonus: 11, Speed: 14,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Rerun", Phase: "decisive", ProcChance: 0.30, Effect: "aoe"},
XPValue: 22000,
Notes: "Unplace elite. A Balor outline filled with static; a memory the Unplace keeps replaying. Drops nothing — pure toll.",
FireAttacker: true,
},
// ── Zone 4 — The Drowned Star ────────────────────────────────────
"choir_of_static": {
ID: "choir_of_static", Name: "Choir of Static",
CR: 17, HP: 170, AC: 19, Attack: 28, AttackBonus: 10, Speed: 14,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Mimic Shoal", Phase: "any", ProcChance: 0.50, Effect: "evade"},
XPValue: 18000,
Notes: "Drowned Star elite. Thousands of hair-thin fish in the shape of the last thing that frightened them: you.",
},
"lantern_warden": {
ID: "lantern_warden", Name: "Lantern Warden",
CR: 18, HP: 230, AC: 19, Attack: 31, AttackBonus: 10, Speed: 12,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "False Light", Phase: "opening", ProcChance: 0.45, Effect: "stun"},
XPValue: 20000,
Notes: "Drowned Star elite. An angler-knight; its lure is a stolen fragment of Seraphel's halo.",
},
"star_gorged_leviathanling": {
ID: "star_gorged_leviathanling", Name: "Star-Gorged Leviathanling",
CR: 19, HP: 290, AC: 16, Attack: 33, AttackBonus: 11, Speed: 12,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Radiant Gout", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
XPValue: 22000,
Notes: "Drowned Star elite. Ate a piece of the star as a hatchling and never stopped glowing; its silhouette shows through its own flesh.",
},
// ── Zone 5 — The Last Meridian ───────────────────────────────────
"the_hour_thief": {
ID: "the_hour_thief", Name: "The Hour Thief",
CR: 17, HP: 180, AC: 18, Attack: 22, AttackBonus: 10, Speed: 18,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Pickpocket the Present", Phase: "any", ProcChance: 0.45, Effect: "stun"},
XPValue: 18000,
Notes: "Meridian elite. A stooped construct with a satchel full of ticking. (Layer 2: each proc permanently +2 its Speed this fight.)",
},
"pendulum_colossus": {
ID: "pendulum_colossus", Name: "Pendulum Colossus",
CR: 19, HP: 280, AC: 17, Attack: 30, AttackBonus: 11, Speed: 10,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Full Swing", Phase: "decisive", ProcChance: 0.50, Effect: "aoe"},
XPValue: 22000,
Notes: "Meridian elite. The cathedral's original pendulum, given legs; massive telegraphed hits. (Layer 2: only procs on even rounds — learnable.)",
},
"wardens_in_waiting": {
ID: "wardens_in_waiting", Name: "Wardens-in-Waiting",
CR: 18, HP: 260, AC: 19, Attack: 24, AttackBonus: 10, Speed: 10,
BlockRate: 0.30,
Ability: &MonsterAbility{Name: "Changing of the Guard", Phase: "any", ProcChance: 0.50, Effect: "block"},
XPValue: 20000,
Notes: "Meridian elite. Twin funerary constructs sharing one soul on a strict shift. (Layer 2: while one acts the other blocks — alternating BlockRate.)",
},
// ── Signature bosses ─────────────────────────────────────────────
"boss_valdris_ascendant": {
ID: "boss_valdris_ascendant", Name: "Valdris, At Last",
CR: 26, HP: 570, AC: 21, Attack: 54, AttackBonus: 12, Speed: 14,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Apotheosis Nova", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
XPValue: 90000,
Notes: "Ossuary Ascendant boss. A true lich, ascended one vertebra at a time. Phase 2 below 50% HP. (Layer 2: Phylactery Verses — three secret nodes each strip a Legendary Resistance / rebirth proc.)",
},
"boss_aurvandryx": {
ID: "boss_aurvandryx", Name: "Aurvandryx, the Ember Before Fire",
CR: 28, HP: 470, AC: 22, Attack: 45, AttackBonus: 12, Speed: 16,
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "First Flame", Phase: "decisive", ProcChance: 0.45, Effect: "aoe_fire"},
XPValue: 120000,
Notes: "First Hoard boss. The progenitor wyrm whose shed scales became every dragon. Fire that predates fire resistance. Phase 2 below 40% HP. (Layer 2: Greed Tax — Attack scales with loot picked up this run.)",
FireAttacker: true,
},
"boss_seamstress": {
ID: "boss_seamstress", Name: "The Seamstress",
CR: 27, HP: 440, AC: 21, Attack: 43, AttackBonus: 12, Speed: 14,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Needle Rain", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
XPValue: 100000,
Notes: "Unplace boss. A corrupted celestial sewing herself into the tear; half of her is on the other side. Phase 2 below 35% HP. (Layer 2: Inversion Stitch — healing and damage swap direction on her in telegraphed pulses.)",
},
"boss_seraphel": {
ID: "boss_seraphel", Name: "Seraphel, the Light That Sank",
CR: 27, HP: 560, AC: 21, Attack: 54, AttackBonus: 12, Speed: 14,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Sanctified Undertow", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
XPValue: 100000,
Notes: "Drowned Star boss. An angel who rode a dying star into the trench and kept it alive ten thousand years. Phase 2 below 50% HP. (Layer 2: Two Hearts — a second ~150 HP Star-Heart pool; mercy path is a hidden loot bias.)",
},
"boss_custodian": {
ID: "boss_custodian", Name: "The Custodian of the Last Hour",
CR: 27, HP: 500, AC: 21, Attack: 48, AttackBonus: 12, Speed: 14,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Chime", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
XPValue: 100000,
Notes: "Last Meridian boss. A titanic clock-golem decommissioning the hours; it apologizes before each swing. Phase 2 below 45% HP. (Layer 2: Amendment — rewinds to its round-3 HP snapshot once; soft midnight timer past round 20.)",
},
}
for id, m := range tier6 {
dndBestiary[id] = m
}
return true
}()

View File

@@ -0,0 +1,117 @@
package plugin
// Post-game (Tier 6) signature magic items — the ten hand-authored Legendary
// drops from the five Mythic zones. Two per zone; the third zone drop is a
// UniqueAlways crafting anchor (a treasure material, not an equippable), handled
// on the loot side.
//
// These are real magicItemRegistry entries so the loot path can grant them as
// equippable magic_item rows (see zoneLootToInventory) and the equip pipeline
// can resolve them. Their combat numbers live in postgameSignatureEffects, the
// magicItemEffectOverlay layer — bespoke, above the codified Legendary formula,
// because these are meant to be the best-in-slot chase items. Numbers are
// opening bids; the P7 sim sweep tunes them.
//
// The fancier one-shot riders each Note in postgame_zone_defs.go promises
// (survive-a-killing-blow, refund-the-slot, negate-a-stun, auto-evade, …) are
// Layer-2 boss/effect hooks deferred to P8 — encoded here is only what the
// magicItemEffect struct can already express.
// postgameSignatureMagicItems is wired into magicItemOverlay (magic_items.go)
// as a plain var reference, so it resolves before buildMagicItemRegistry() runs.
var postgameSignatureMagicItems = []MagicItem{
// ── The Ossuary Ascendant ────────────────────────────────────────────────
{
ID: "crown_of_the_patient_king", Name: "Crown of the Patient King",
Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotHead, Value: 8000,
Desc: "A thin band of black iron, warm as if just taken off. He respects a good plan; wear it and the fight waits for yours.",
},
{
ID: "valdris_quill", Name: "Valdris's Quill",
Kind: MagicItemWand, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotMainHand, Value: 8000,
Desc: "The lich's own writing implement, still wet. Spells drawn with it land where they were aimed and then some.",
},
// ── The First Hoard ──────────────────────────────────────────────────────
{
ID: "aegis_of_the_first_scale", Name: "Aegis of the First Scale",
Kind: MagicItemArmor, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotChest, Value: 9000,
Desc: "A single shed scale of the Ember Before Fire, worked into a breastplate. Fire predates it and still cannot touch it.",
},
{
ID: "emberheart_greatblade", Name: "Emberheart Greatblade",
Kind: MagicItemWeapon, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotMainHand, Value: 8000,
Desc: "Forged around a coal that has never cooled. Its edge opens wounds that go on burning after the swing.",
},
// ── The Unplace ──────────────────────────────────────────────────────────
{
ID: "needle_of_the_seam", Name: "Needle of the Seam",
Kind: MagicItemWeapon, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotMainHand, Value: 8000,
Desc: "The Seamstress's own needle, half here and half elsewhere. Spellwork slid along it finds the gaps in everything.",
},
{
ID: "mantle_of_elsewhere", Name: "Mantle of Elsewhere",
Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotCloak, Value: 8000,
Desc: "A cloak cut from a room that isn't. Now and then a blow passes through where you briefly were not.",
},
// ── The Drowned Star ─────────────────────────────────────────────────────
{
ID: "halo_of_the_sunken_dawn", Name: "Halo of the Sunken Dawn",
Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotHead, Value: 8000,
Desc: "A ring of drowned light that never went out. Mercy carried through it lands heavier on those you mean to keep alive.",
},
{
ID: "tideglass_ward", Name: "Tideglass Ward",
Kind: MagicItemArmor, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotOffHand, Value: 8000,
Desc: "A shield of pressure-fused abyssal glass. Once a fight it drinks a wave whole and gives you back the room.",
},
// ── The Last Meridian ────────────────────────────────────────────────────
{
ID: "the_second_hand", Name: "The Second Hand",
Kind: MagicItemWeapon, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotMainHand, Value: 8000,
Desc: "A slim blade that keeps its own time. Now and then it lends you a moment you did not have to swing again.",
},
{
ID: "escapement_plate", Name: "Escapement Plate",
Kind: MagicItemArmor, Rarity: RarityLegendary, Attunement: true,
Slot: DnDSlotChest, Value: 8000,
Desc: "Verdigris plate geared like a clock. The first blow that would stop you finds a moment already held in reserve.",
},
}
// postgameSignatureEffects is wired into magicItemEffectOverlay
// (magic_items_gameplay.go). DamageReductMult is multiplicative (1.0 neutral,
// <1.0 reduces damage taken); the aggregator ignores a 0.0, so weapons/wondrous
// items that don't reduce damage simply leave it unset. Everything else is
// additive. Legendary codified baselines for reference: weapon DamageBonus 0.25,
// armor DamageReductMult 0.80, wand DamageBonus 0.15 + FlatDmgStart 5, wondrous
// InitiativeBias 2.5 + MaxHP 10 — these sit at or above those.
var postgameSignatureEffects = map[string]magicItemEffect{
// Ossuary
"crown_of_the_patient_king": {InitiativeBias: 3.0, MaxHP: 14},
"valdris_quill": {DamageBonus: 0.20, FlatDmgStart: 6},
// First Hoard
"aegis_of_the_first_scale": {DamageReductMult: 0.72, MaxHP: 10},
"emberheart_greatblade": {DamageBonus: 0.30, FlatDmgStart: 6},
// Unplace
"needle_of_the_seam": {DamageBonus: 0.30, FlatDmgStart: 4},
"mantle_of_elsewhere": {DamageReductMult: 0.88, InitiativeBias: 2.0},
// Drowned Star
"halo_of_the_sunken_dawn": {DamageReductMult: 0.92, InitiativeBias: 2.0, MaxHP: 12},
"tideglass_ward": {DamageReductMult: 0.80},
// Last Meridian
"the_second_hand": {DamageBonus: 0.30, InitiativeBias: 3.0},
"escapement_plate": {DamageReductMult: 0.74, MaxHP: 8},
}

View File

@@ -0,0 +1,336 @@
package plugin
// Tier 6 "Mythic" post-game zone definitions + region registries (Phase P3).
//
// Five hand-authored endgame zones, each four regions deep, gated behind
// postgameUnlocked. Standards are demoted T4/T5 monsters (the tier
// statement); elites and bosses are the bespoke stat blocks in
// postgame_bestiary.go. Loot entries here are declared but their drop
// mechanics (boss-only signature roll, magicItemEffectOverlay, pity
// recipes) are wired in Phase P5.
//
// Registration runs from this file's init(), which executes after
// dnd_zone.go's init() (filename order), so T6 zones append to zoneOrder
// after T1T5. Hooks/Atmosphere are TwinBee first-person voice.
func init() {
registerZone(zoneOssuaryAscendant())
registerZone(zoneFirstHoard())
registerZone(zoneUnplace())
registerZone(zoneDrownedStar())
registerZone(zoneLastMeridian())
regionsByZone[ZoneOssuaryAscendant] = []ExpeditionRegion{
{ID: "ossuary_bonefall_steps", ZoneID: ZoneOssuaryAscendant, Order: 1,
Name: "Bonefall Steps", BaseCampSite: false,
RegionBoss: "The Chorister",
EnemySubset: []string{"wight", "revenant", "the_chorister"}},
{ID: "ossuary_cathedral_marrow", ZoneID: ZoneOssuaryAscendant, Order: 2,
Name: "Cathedral of Marrow", BaseCampSite: true,
RegionBoss: "Grave Cardinal",
EnemySubset: []string{"banshee", "wraith", "grave_cardinal"}},
{ID: "ossuary_reliquary_vaults", ZoneID: ZoneOssuaryAscendant, Order: 3,
Name: "Reliquary Vaults", BaseCampSite: true,
RegionBoss: "Reliquary Knight",
EnemySubset: []string{"wraith", "revenant", "reliquary_knight"}},
{ID: "ossuary_apotheosis_engine", ZoneID: ZoneOssuaryAscendant, Order: 4,
Name: "The Apotheosis Engine", BaseCampSite: false,
RegionBoss: "Valdris, At Last (Zone Boss)", IsZoneBoss: true,
EnemySubset: []string{"wight", "banshee"}},
}
regionsByZone[ZoneFirstHoard] = []ExpeditionRegion{
{ID: "hoard_cooling_throne", ZoneID: ZoneFirstHoard, Order: 1,
Name: "The Cooling Throne", BaseCampSite: false,
RegionBoss: "Choir Drake Matriarch",
EnemySubset: []string{"salamander", "helmed_horror", "choir_drake_matriarch"}},
{ID: "hoard_gilded_gullet", ZoneID: ZoneFirstHoard, Order: 2,
Name: "Gilded Gullet", BaseCampSite: true,
RegionBoss: "Coinborn Simulacrum",
EnemySubset: []string{"helmed_horror", "young_red_dragon", "coinborn_simulacrum"}},
{ID: "hoard_clutch_vaults", ZoneID: ZoneFirstHoard, Order: 3,
Name: "The Clutch Vaults", BaseCampSite: true,
RegionBoss: "Wyrm-Sworn Executor",
EnemySubset: []string{"young_red_dragon", "salamander", "wyrm_sworn_executor"}},
{ID: "hoard_cradle_first_flame", ZoneID: ZoneFirstHoard, Order: 4,
Name: "Cradle of the First Flame", BaseCampSite: false,
RegionBoss: "Aurvandryx (Zone Boss)", IsZoneBoss: true,
EnemySubset: []string{"young_red_dragon"}},
}
regionsByZone[ZoneUnplace] = []ExpeditionRegion{
{ID: "unplace_the_fray", ZoneID: ZoneUnplace, Order: 1,
Name: "The Fray", BaseCampSite: false,
RegionBoss: "Angleworn Horror",
EnemySubset: []string{"hezrou", "nalfeshnee", "angleworn_horror"}},
{ID: "unplace_eschers_debt", ZoneID: ZoneUnplace, Order: 2,
Name: "Escher's Debt", BaseCampSite: true,
RegionBoss: "The Unnumbered",
EnemySubset: []string{"nalfeshnee", "marilith", "the_unnumbered"}},
{ID: "unplace_stitchworks", ZoneID: ZoneUnplace, Order: 3,
Name: "The Stitchworks", BaseCampSite: true,
RegionBoss: "Echo of Belaxath",
EnemySubset: []string{"marilith", "hezrou", "echo_of_belaxath"}},
{ID: "unplace_the_seam", ZoneID: ZoneUnplace, Order: 4,
Name: "The Seam", BaseCampSite: false,
RegionBoss: "The Seamstress (Zone Boss)", IsZoneBoss: true,
EnemySubset: []string{"marilith"}},
}
regionsByZone[ZoneDrownedStar] = []ExpeditionRegion{
{ID: "star_long_sink", ZoneID: ZoneDrownedStar, Order: 1,
Name: "The Long Sink", BaseCampSite: false,
RegionBoss: "Choir of Static",
EnemySubset: []string{"merrow", "water_elemental", "choir_of_static"}},
{ID: "star_pilgrim_trench", ZoneID: ZoneDrownedStar, Order: 2,
Name: "Pilgrim Trench", BaseCampSite: true,
RegionBoss: "Lantern Warden",
EnemySubset: []string{"aboleth_thrall", "water_elemental", "lantern_warden"}},
{ID: "star_radiant_wreck", ZoneID: ZoneDrownedStar, Order: 3,
Name: "The Radiant Wreck", BaseCampSite: true,
RegionBoss: "Star-Gorged Leviathanling",
EnemySubset: []string{"aboleth_thrall", "merrow", "star_gorged_leviathanling"}},
{ID: "star_heart_chapel", ZoneID: ZoneDrownedStar, Order: 4,
Name: "The Heart Chapel", BaseCampSite: false,
RegionBoss: "Seraphel (Zone Boss)", IsZoneBoss: true,
EnemySubset: []string{"water_elemental"}},
}
regionsByZone[ZoneLastMeridian] = []ExpeditionRegion{
{ID: "meridian_dusk_colonnade", ZoneID: ZoneLastMeridian, Order: 1,
Name: "The Dusk Colonnade", BaseCampSite: false,
RegionBoss: "The Hour Thief",
EnemySubset: []string{"specter", "wraith", "the_hour_thief"}},
{ID: "meridian_gallery_spent_hours", ZoneID: ZoneLastMeridian, Order: 2,
Name: "Gallery of Spent Hours", BaseCampSite: true,
RegionBoss: "Wardens-in-Waiting",
EnemySubset: []string{"wraith", "helmed_horror", "wardens_in_waiting"}},
{ID: "meridian_escapement", ZoneID: ZoneLastMeridian, Order: 3,
Name: "The Escapement", BaseCampSite: true,
RegionBoss: "Pendulum Colossus",
EnemySubset: []string{"helmed_horror", "specter", "pendulum_colossus"}},
{ID: "meridian_one_minute", ZoneID: ZoneLastMeridian, Order: 4,
Name: "One Minute To Midnight", BaseCampSite: false,
RegionBoss: "The Custodian (Zone Boss)", IsZoneBoss: true,
EnemySubset: []string{"helmed_horror", "wraith"}},
}
}
// ---- Tier 6 zone factories --------------------------------------------------
func zoneOssuaryAscendant() ZoneDefinition {
return ZoneDefinition{
ID: ZoneOssuaryAscendant,
Display: "The Ossuary Ascendant",
Tier: ZoneTierMythic,
LevelMin: 18,
LevelMax: 20,
Faction: "Undead, Lich-ascendant",
Atmosphere: "An inverted bone cathedral hanging over the old Crypt; marrow-light, choral static, the smell of a very patient plan finally paying off.",
Hook: "Valdris made it. Dying in the Crypt was step one all along — the phylactery shard you looted was bait. He has rebuilt himself into a true lich, one vertebra at a time, and hung his cathedral upside-down over his own grave. Your oldest quest item has come home. I do not like it here.",
MinRooms: 44,
MaxRooms: 52,
Enemies: []ZoneEnemy{
{BestiaryID: "wight", SpawnWeight: 6},
{BestiaryID: "revenant", SpawnWeight: 5},
{BestiaryID: "banshee", SpawnWeight: 4},
{BestiaryID: "wraith", SpawnWeight: 4},
{BestiaryID: "the_chorister", SpawnWeight: 3, IsElite: true},
{BestiaryID: "grave_cardinal", SpawnWeight: 3, IsElite: true},
{BestiaryID: "reliquary_knight", SpawnWeight: 2, IsElite: true},
},
Boss: ZoneBoss{
BestiaryID: "boss_valdris_ascendant",
Name: "Valdris, At Last",
CR: 26,
HP: 470,
AC: 21,
PhaseTwoAt: 0.50,
Description: "The lich-aspirant from the Crypt, ascended. He respects a good plan. He has been running one on you for years.",
Abilities: []string{
"Apotheosis Nova: decisive AoE, armor-piercing radiant-necrotic burst",
"Phase 2 (<50% HP): flies; nova recharges faster",
"Phylactery Verses (Layer 2): three secret Verses in the zone each strip one Legendary Resistance / rebirth proc if found first",
},
},
Loot: []ZoneLootEntry{
{ItemID: "crown_of_the_patient_king", DropChance: 0.04, BossOnly: true, Note: "head, Legendary: +3 INT & WIS; 1/run survive a killing blow at 1 HP"},
{ItemID: "valdris_quill", DropChance: 0.04, BossOnly: true, Note: "caster weapon, Legendary: spell attacks +3; on spell kill refund the slot (1/combat)"},
{ItemID: "verse_bound_folio", UniqueAlways: true, Note: "T6 crafting anchor"},
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
},
FlavorFile: "zone_ossuary_ascendant_flavor.go",
}
}
func zoneFirstHoard() ZoneDefinition {
return ZoneDefinition{
ID: ZoneFirstHoard,
Display: "The First Hoard",
Tier: ZoneTierMythic,
LevelMin: 18,
LevelMax: 20,
Faction: "Dragons, Wyrm-cult, the Hoard itself",
Atmosphere: "Beneath Infernus Peak, still warm. Rivers of gilded scale, a floor of minted antibodies, and under it all the slow breathing of something that was old when fire was new.",
Hook: "Infernax is dead and the mountain is still warm — because he was never the owner. He was the guard dog. Beneath the peak sleeps Aurvandryx, the Ember Before Fire, whose shed scales became every dragon you have ever met. The hoard isn't treasure. It's her clutch, gilded over. I suggest we not wake the mother.",
MinRooms: 44,
MaxRooms: 52,
Enemies: []ZoneEnemy{
{BestiaryID: "salamander", SpawnWeight: 6},
{BestiaryID: "helmed_horror", SpawnWeight: 5},
{BestiaryID: "young_red_dragon", SpawnWeight: 3},
{BestiaryID: "choir_drake_matriarch", SpawnWeight: 3, IsElite: true},
{BestiaryID: "coinborn_simulacrum", SpawnWeight: 3, IsElite: true},
{BestiaryID: "wyrm_sworn_executor", SpawnWeight: 2, IsElite: true},
},
Boss: ZoneBoss{
BestiaryID: "boss_aurvandryx",
Name: "Aurvandryx, the Ember Before Fire",
CR: 28,
HP: 540,
AC: 22,
PhaseTwoAt: 0.40,
Description: "The progenitor wyrm. Fire that predates fire resistance. She is not defending treasure; she is defending eggs.",
Abilities: []string{
"First Flame: decisive fire AoE that ignores resistance (phase-2 narration notes it failing)",
"Phase 2 (<40% HP): grows; First Flame recharges faster",
"Greed Tax (Layer 2): her Attack scales +1 per N loot drops picked up during the run",
},
},
Loot: []ZoneLootEntry{
{ItemID: "aegis_of_the_first_scale", DropChance: 0.05, BossOnly: true, Note: "armor, Legendary (best in game): AC +3, fire immunity, 1/run ignore a killing blow over half max HP"},
{ItemID: "emberheart_greatblade", DropChance: 0.04, BossOnly: true, Note: "weapon, Legendary: +3, +3d6 fire, crits strip target BlockRate next round"},
{ItemID: "cooling_ember_of_aurvandryx", UniqueAlways: true, Note: "T6 crafting anchor — the legendary-fire capstone thyraks_core has been waiting for"},
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
},
FlavorFile: "zone_first_hoard_flavor.go",
}
}
func zoneUnplace() ZoneDefinition {
return ZoneDefinition{
ID: ZoneUnplace,
Display: "The Unplace",
Tier: ZoneTierMythic,
LevelMin: 18,
LevelMax: 20,
Faction: "Demons, geometry itself, a corrupted celestial",
Atmosphere: "A wound in space that geometry gave up on. Rooms that are also other rooms. A horizon indoors. Something is stitching it shut from the inside, and it is not doing it to help.",
Hook: "Belaxath tore the portal open over thirty years; killing him didn't close it — it left the tear unattended. The wound has scarred into a region where a corner can be three rooms away and a straight line arrives before it leaves. I have stopped trusting the map. I advise you stop trusting the walls.",
MinRooms: 44,
MaxRooms: 52,
Enemies: []ZoneEnemy{
{BestiaryID: "hezrou", SpawnWeight: 6},
{BestiaryID: "nalfeshnee", SpawnWeight: 4},
{BestiaryID: "marilith", SpawnWeight: 3},
{BestiaryID: "angleworn_horror", SpawnWeight: 3, IsElite: true},
{BestiaryID: "the_unnumbered", SpawnWeight: 3, IsElite: true},
{BestiaryID: "echo_of_belaxath", SpawnWeight: 2, IsElite: true},
},
Boss: ZoneBoss{
BestiaryID: "boss_seamstress",
Name: "The Seamstress",
CR: 27,
HP: 480,
AC: 21,
PhaseTwoAt: 0.35,
Description: "A corrupted celestial who volunteered to close the tear centuries early and has been sewing herself into it ever since. Half of her is on the other side, and the far half does most of the talking.",
Abilities: []string{
"Needle Rain: decisive AoE of driven needles",
"Phase 2 (<35% HP): the room sews inside-out",
"Inversion Stitch (Layer 2): healing and damage swap direction on her in telegraphed 2-round pulses",
},
},
Loot: []ZoneLootEntry{
{ItemID: "needle_of_the_seam", DropChance: 0.04, BossOnly: true, Note: "dagger, Legendary (best caster weapon): +3; spell crit range +1; on crit next spell is free"},
{ItemID: "mantle_of_elsewhere", DropChance: 0.05, BossOnly: true, Note: "cloak: +2 AC; 1/combat auto-evade one hit"},
{ItemID: "spool_of_undone_geometry", UniqueAlways: true, Note: "T6 crafting anchor"},
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
},
FlavorFile: "zone_unplace_flavor.go",
}
}
func zoneDrownedStar() ZoneDefinition {
return ZoneDefinition{
ID: ZoneDrownedStar,
Display: "The Drowned Star",
Tier: ZoneTierMythic,
LevelMin: 18,
LevelMax: 20,
Faction: "Aboleth-touched, abyssal pilgrims, a fallen angel",
Atmosphere: "An abyssal trench lit from below by a dying star, and by the angel who rode it down. The pilgrimage route glows. Both the star and its guardian have gone strange in ten thousand years of dark.",
Hook: "The Dreaming Aboleth has been dreaming of one specific thing this whole time: a star that fell into the trench before the surface had names, with its angel still strapped to it. Seraphel rode her charge down rather than abandon it, and has spent ten thousand years keeping a dying star alive with radiance meant for healing. Both of them have gone strange. I would too.",
MinRooms: 44,
MaxRooms: 52,
Enemies: []ZoneEnemy{
{BestiaryID: "merrow", SpawnWeight: 6},
{BestiaryID: "water_elemental", SpawnWeight: 5},
{BestiaryID: "aboleth_thrall", SpawnWeight: 3},
{BestiaryID: "choir_of_static", SpawnWeight: 3, IsElite: true},
{BestiaryID: "lantern_warden", SpawnWeight: 3, IsElite: true},
{BestiaryID: "star_gorged_leviathanling", SpawnWeight: 2, IsElite: true},
},
Boss: ZoneBoss{
BestiaryID: "boss_seraphel",
Name: "Seraphel, the Light That Sank",
CR: 27,
HP: 460,
AC: 21,
PhaseTwoAt: 0.50,
Description: "An angel who would not let go. Ten thousand years of shielding a dying star with healing light have made her something that grieves in radiant bursts.",
Abilities: []string{
"Sanctified Undertow: decisive radiant AoE",
"Phase 2 (<50% HP): grieving — faster, sloppier",
"Two Hearts (Layer 2): a second ~150 HP Star-Heart pool; burn it and she enrages, spare it and she never does (hidden mercy loot bias)",
},
},
Loot: []ZoneLootEntry{
{ItemID: "halo_of_the_sunken_dawn", DropChance: 0.04, BossOnly: true, Note: "head, Legendary: +2 all saves; ally-heals you cast +50%"},
{ItemID: "tideglass_ward", DropChance: 0.05, BossOnly: true, Note: "shield: +3 AC contribution; 1/combat negate an AoE proc entirely"},
{ItemID: "ember_of_the_drowned_star", UniqueAlways: true, Note: "T6 crafting anchor"},
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
},
FlavorFile: "zone_drowned_star_flavor.go",
}
}
func zoneLastMeridian() ZoneDefinition {
return ZoneDefinition{
ID: ZoneLastMeridian,
Display: "The Last Meridian",
Tier: ZoneTierMythic,
LevelMin: 18,
LevelMax: 20,
Faction: "Clock-constructs, horologist ghosts, the Custodian",
Atmosphere: "A necropolis-observatory where timekeeping was invented and is now being quietly decommissioned. Rooms you visited at dusk are midnight when you return. The final region plays in the second the clock stops.",
Hook: "This is where the world's hours were invented — and where they are being taken apart. The Custodian has decided its contract is complete and is dismantling the hours one at a time on its way out. It is unfailingly polite about it. It will apologize before each swing. I find that worse, somehow.",
MinRooms: 44,
MaxRooms: 52,
Enemies: []ZoneEnemy{
{BestiaryID: "specter", SpawnWeight: 6},
{BestiaryID: "wraith", SpawnWeight: 5},
{BestiaryID: "helmed_horror", SpawnWeight: 3},
{BestiaryID: "the_hour_thief", SpawnWeight: 3, IsElite: true},
{BestiaryID: "wardens_in_waiting", SpawnWeight: 3, IsElite: true},
{BestiaryID: "pendulum_colossus", SpawnWeight: 2, IsElite: true},
},
Boss: ZoneBoss{
BestiaryID: "boss_custodian",
Name: "The Custodian of the Last Hour",
CR: 27,
HP: 500,
AC: 21,
PhaseTwoAt: 0.45,
Description: "A titanic clock-golem of verdigris and orrery rings, closing out its contract. It apologizes, on schedule, before it kills you.",
Abilities: []string{
"Chime: decisive AoE on the hour",
"Phase 2 (<45% HP): rewinds",
"Amendment (Layer 2): once, rewinds itself to its round-3 HP snapshot; soft midnight timer adds +2 Atk/round past round 20",
},
},
Loot: []ZoneLootEntry{
{ItemID: "the_second_hand", DropChance: 0.04, BossOnly: true, Note: "weapon, Legendary (best martial sidearm): +3; 1/combat immediate extra attack; on kill refund the use"},
{ItemID: "escapement_plate", DropChance: 0.05, BossOnly: true, Note: "armor: +3 AC; first stun each combat is negated"},
{ItemID: "unspent_hour", UniqueAlways: true, Note: "T6 crafting anchor"},
{ItemID: "coins_60d10x120", DropChance: 1.0, Note: "60d10 × 120 coins"},
},
FlavorFile: "zone_last_meridian_flavor.go",
}
}

View File

@@ -0,0 +1,220 @@
package plugin
// Shared construction for the five Tier 6 "Mythic" post-game zone graphs
// (Phase P4). All five share one topology so the entry→boss longest walk
// lands deterministically in the T6 [44,52] band (46 by construction) and
// so the four regions are evenly weighted sub-dungeons. Per-zone identity
// lives entirely in the pgZoneSpec labels; structural set-pieces (Ossuary's
// SECRET Verses, the First Hoard's game-max LootBias gilded route) ride the
// Overrides map so the skeleton stays flavor-agnostic.
//
// Topology (counts give the longest walk = 46 nodes; every fork's first
// spur is a LockNone edge so no run can be stranded — the graph-wide
// TestZoneGraphs_NoSoftLockedFork invariant):
//
// R1 (region[0]): entry ─ p1..p12 ─ fork1 (14)
// R2 (region[1]): fork1 ⇒ {free | locked} 3-spur ─ r2gate
// ─ r2b1..6 ─ fork2 (+11)
// R3 (region[2]): fork2 ⇒ {free | locked} 3-spur ─ r3gate
// ─ r3b1..6 ─ fork3 (+11)
// R4 (region[3]): fork3 ⇒ 3× capstone 3-spur ─ merge
// ─ ap1..5 ─ boss (+10)
//
// fork1 / fork2 are binary (free + one locked spur); fork3 is the ternary
// capstone (free + two locked spurs), each capstone leaf reaching the boss
// through the merge. Node-id suffixes are stable and referenced by the
// per-zone tests: entry, p1..p12, fork1, f1a1..3 (free) / f1b1..3 (locked),
// r2gate, r2b1..6, fork2, f2a1..3 / f2b1..3, r3gate, r3b1..6, fork3,
// cap11..13 (free) / cap21..23 (lockA) / cap31..33 (lockB), merge, ap1..5,
// boss. Baseline anchors (a mid-run trap + two region-guardian elites) are
// stamped by the builder; a spec Override can retype any node on top.
import "fmt"
// pgLock describes the gate on a fork's spur-entry edge. The zero value is
// an unlocked (LockNone) edge.
type pgLock struct {
kind ZoneEdgeLockKind
lockData map[string]any
hint string
}
// pgNodeOverride retypes and/or biases a single node by suffix. An empty
// kind leaves the builder's default; a zero bias leaves LootBias unset.
type pgNodeOverride struct {
kind ZoneNodeKind
bias float64
}
// pgFork is a binary fork: a free (LockNone) 3-spur and a locked 3-spur,
// both converging on the region's gate node.
type pgFork struct {
label string
freeLabel [3]string
lockLabel [3]string
lock pgLock
}
// pgCapstone is the ternary fork3: one free spur and two independently
// locked spurs, all converging on the merge.
type pgCapstone struct {
label string
freeLabel [3]string
lockALabel [3]string
lockA pgLock
lockBLabel [3]string
lockB pgLock
}
type pgZoneSpec struct {
zoneID ZoneID
regions [4]string
entry string
preamble [12]string
fork1 pgFork
r2gate string
r2build [6]string
fork2 pgFork
r3gate string
r3build [6]string
capstone pgCapstone // fork3
merge string
approach [5]string
boss string
overrides map[string]pgNodeOverride // keyed by node-id suffix (no zone prefix)
}
// buildPostgameZoneGraph assembles and validates the canonical T6 graph.
func buildPostgameZoneGraph(s pgZoneSpec) ZoneGraph {
zid := string(s.zoneID)
// Baseline anchors so every T6 zone carries a mid-run hazard and two
// region-guardian elites without repeating them in each spec. Spec
// overrides win (applied after).
defaults := map[string]ZoneNodeKind{
"p7": NodeKindTrap,
"r2b6": NodeKindElite,
"r3b6": NodeKindElite,
}
var nodes []ZoneNode
var edges []ZoneEdge
id := func(suffix string) string { return zid + "." + suffix }
addNode := func(suffix, label, region string, kind ZoneNodeKind, x, y int) {
if d, ok := defaults[suffix]; ok {
kind = d
}
n := ZoneNode{
NodeID: id(suffix), Kind: kind, RegionID: region,
Label: label, PosX: x, PosY: y,
}
if ov, ok := s.overrides[suffix]; ok {
if ov.kind != "" {
n.Kind = ov.kind
}
if ov.bias != 0 {
n.Content.LootBias = ov.bias
}
}
if n.Kind == NodeKindEntry {
n.IsEntry = true
}
if n.Kind == NodeKindBoss {
n.IsBoss = true
}
nodes = append(nodes, n)
}
addEdge := func(from, to string, lock pgLock, weight int) {
e := ZoneEdge{From: id(from), To: id(to), Weight: weight, Lock: LockNone}
if lock.kind != "" {
e.Lock = lock.kind
e.LockData = lock.lockData
e.Hint = lock.hint
}
edges = append(edges, e)
}
// spur wires forkSuf → three spur nodes → gateSuf, locking only the
// fork→spur[0] entry edge. The SECRET/LootBias node in a locked spur is
// its middle node (index 1), reached by a LockNone edge — matching the
// established "gated threshold, then unlocked entry into the secret"
// pattern (see zone_graph_abyss_portal.go reality_seam).
spur := func(forkSuf string, labels [3]string, lock pgLock, gateSuf, region, sufPrefix string, xStart, y, weight int) {
prev := forkSuf
for i := 0; i < 3; i++ {
suf := fmt.Sprintf("%s%d", sufPrefix, i+1)
addNode(suf, labels[i], region, NodeKindExploration, xStart+i, y)
lk := pgLock{}
if i == 0 {
lk = lock
}
addEdge(prev, suf, lk, weight)
prev = suf
}
addEdge(prev, gateSuf, pgLock{}, 1)
}
r1, r2, r3, r4 := s.regions[0], s.regions[1], s.regions[2], s.regions[3]
// R1 preamble.
addNode("entry", s.entry, r1, NodeKindEntry, 0, 2)
prev := "entry"
for i, lab := range s.preamble {
suf := fmt.Sprintf("p%d", i+1)
addNode(suf, lab, r1, NodeKindExploration, i+1, 2)
addEdge(prev, suf, pgLock{}, 1)
prev = suf
}
addNode("fork1", s.fork1.label, r1, NodeKindFork, 13, 2)
addEdge(prev, "fork1", pgLock{}, 1)
// R2: fork1 spurs → r2gate → buildup → fork2.
addNode("r2gate", s.r2gate, r2, NodeKindExploration, 17, 2)
spur("fork1", s.fork1.freeLabel, pgLock{}, "r2gate", r2, "f1a", 14, 1, 1)
spur("fork1", s.fork1.lockLabel, s.fork1.lock, "r2gate", r2, "f1b", 14, 3, 2)
prev = "r2gate"
for i, lab := range s.r2build {
suf := fmt.Sprintf("r2b%d", i+1)
addNode(suf, lab, r2, NodeKindExploration, 18+i, 2)
addEdge(prev, suf, pgLock{}, 1)
prev = suf
}
addNode("fork2", s.fork2.label, r2, NodeKindFork, 24, 2)
addEdge(prev, "fork2", pgLock{}, 1)
// R3: fork2 spurs → r3gate → buildup → fork3.
addNode("r3gate", s.r3gate, r3, NodeKindExploration, 28, 2)
spur("fork2", s.fork2.freeLabel, pgLock{}, "r3gate", r3, "f2a", 25, 1, 1)
spur("fork2", s.fork2.lockLabel, s.fork2.lock, "r3gate", r3, "f2b", 25, 3, 2)
prev = "r3gate"
for i, lab := range s.r3build {
suf := fmt.Sprintf("r3b%d", i+1)
addNode(suf, lab, r3, NodeKindExploration, 29+i, 2)
addEdge(prev, suf, pgLock{}, 1)
prev = suf
}
addNode("fork3", s.capstone.label, r3, NodeKindFork, 35, 2)
addEdge(prev, "fork3", pgLock{}, 1)
// R4: fork3 ternary capstone → merge → approach → boss.
addNode("merge", s.merge, r4, NodeKindMerge, 39, 2)
spur("fork3", s.capstone.freeLabel, pgLock{}, "merge", r4, "cap1", 36, 1, 1)
spur("fork3", s.capstone.lockALabel, s.capstone.lockA, "merge", r4, "cap2", 36, 2, 2)
spur("fork3", s.capstone.lockBLabel, s.capstone.lockB, "merge", r4, "cap3", 36, 3, 3)
prev = "merge"
for i, lab := range s.approach {
suf := fmt.Sprintf("ap%d", i+1)
addNode(suf, lab, r4, NodeKindExploration, 40+i, 2)
addEdge(prev, suf, pgLock{}, 1)
prev = suf
}
addNode("boss", s.boss, r4, NodeKindBoss, 45, 2)
addEdge(prev, "boss", pgLock{}, 1)
return buildPostgameGraphFrom(s.zoneID, nodes, edges)
}
// buildPostgameGraphFrom is a thin seam over BuildGraph so tests can build
// a spec's graph without re-registering it.
func buildPostgameGraphFrom(zoneID ZoneID, nodes []ZoneNode, edges []ZoneEdge) ZoneGraph {
return BuildGraph(zoneID, nodes, edges)
}

View File

@@ -0,0 +1,79 @@
package plugin
import "testing"
// assertPostgameGraph runs the invariants every T6 zone graph must satisfy:
// registered, longest walk in the [44,52] band, all four authored regions
// present and matching the ExpeditionRegion registry, every fork keeps a
// free (LockNone) exit, and all three capstone leaves reach the boss.
func assertPostgameGraph(t *testing.T, zoneID ZoneID, build func() ZoneGraph) {
t.Helper()
if _, ok := zoneGraphRegistry[zoneID]; !ok {
t.Fatalf("%s graph not registered", zoneID)
}
g := build()
if got := graphLongestPath(g); got < 44 || got > 52 {
t.Errorf("%s longest path = %d, want T6 band [44,52]", zoneID, got)
}
// Regions: every node has a RegionID drawn from the zone's registry,
// and every registered region has at least one node.
valid := map[string]bool{}
for _, r := range regionsByZone[zoneID] {
valid[r.ID] = true
}
if len(valid) != 4 {
t.Fatalf("%s: expected 4 registered regions, got %d", zoneID, len(valid))
}
seen := map[string]int{}
for id, n := range g.Nodes {
if n.RegionID == "" {
t.Errorf("%s node %s has empty RegionID", zoneID, id)
continue
}
if !valid[n.RegionID] {
t.Errorf("%s node %s RegionID %q not in region registry", zoneID, id, n.RegionID)
}
seen[n.RegionID]++
}
for r := range valid {
if seen[r] == 0 {
t.Errorf("%s region %q has no nodes", zoneID, r)
}
}
// Every fork offers at least one LockNone exit (local mirror of the
// global TestZoneGraphs_NoSoftLockedFork, kept here so a broken spec
// fails in its own zone's test too).
for id, n := range g.Nodes {
if n.Kind != NodeKindFork {
continue
}
free := false
for _, e := range g.outgoingEdges(id) {
if e.Lock == LockNone || e.Lock == "" {
free = true
}
}
if !free {
t.Errorf("%s fork %s: no free exit (soft-lock)", zoneID, id)
}
}
// All three capstone leaves reach the boss.
for _, leaf := range []string{
string(zoneID) + ".cap13",
string(zoneID) + ".cap23",
string(zoneID) + ".cap33",
} {
if _, ok := g.Nodes[leaf]; !ok {
t.Errorf("%s missing capstone leaf %s", zoneID, leaf)
continue
}
if !reachable(g, leaf, g.Boss) {
t.Errorf("%s capstone leaf %s unreachable to boss", zoneID, leaf)
}
}
}

View File

@@ -0,0 +1,89 @@
package plugin
// Tier 6 "Mythic" post-game zones — shared plumbing (Phase P1).
//
// Post-game means earned, not leveled. zonesForLevel can't gate these
// (playerTier = (L-1)/3+1 plus the +2 lookahead would surface a T6 zone far
// too early — and at L18 the lookahead reaches T6 outright), so entry is
// gated by postgameUnlocked: both Tier 5 zone bosses beaten AND level ≥ 18.
// Locked zones still render in `!zone list` under a "Beyond the Map" divider
// with a hint line — visible aspiration, gated entry.
//
// See gogobee_postgame_zones_plan.md.
import (
"fmt"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// postgameLevelFloor — the minimum character level for T6 entry. L18 lets
// near-cap players scout and die educationally before hitting L20.
const postgameLevelFloor = 18
// postgameZones returns every registered Tier 6 zone in declared order.
func postgameZones() []ZoneDefinition {
var out []ZoneDefinition
for _, z := range allZones() {
if z.Tier >= ZoneTierMythic {
out = append(out, z)
}
}
return out
}
// isPostgameZone reports whether a zone id is a Tier 6 post-game zone.
func isPostgameZone(id ZoneID) bool {
z, ok := getZone(id)
return ok && z.Tier >= ZoneTierMythic
}
// postgameUnlocked reports whether a player may enter Tier 6 zones, and if
// not, a one-line player-facing reason. The two gates:
// - both Tier 5 zone bosses (dragons_lair AND abyss_portal) beaten, and
// - character level ≥ postgameLevelFloor.
//
// The T5-clear check reuses clearedZoneIDs (boss_defeated DISTINCT query),
// so an extraction that merely ended in 'complete' does not count.
func postgameUnlocked(userID id.UserID, level int) (bool, string) {
if level < postgameLevelFloor {
return false, fmt.Sprintf(
"The way past the map's edge opens at level %d. You're level %d.",
postgameLevelFloor, level)
}
cleared := clearedZoneIDs(db.Get(), userID)
if !cleared[ZoneDragonsLair] || !cleared[ZoneAbyssPortal] {
return false, "The way past the map's edge stays shut until both " +
"Infernax and Belaxath lie dead — the dragon and the balor, " +
"the two ends of the known world."
}
return true, ""
}
// availableZonesFor returns the zones a player may enter for command
// resolution: the normal level-gated set, plus the Tier 6 post-game zones
// when postgameUnlocked. Command sites use this in place of a bare
// zonesForLevel so an unlocked player can resolve a T6 zone id/index.
func availableZonesFor(userID id.UserID, level int) []ZoneDefinition {
out := zonesForLevel(level)
if ok, _ := postgameUnlocked(userID, level); ok {
out = append(out, postgameZones()...)
}
return out
}
// postgameLockReason returns a player-facing lock reason when input names a
// Tier 6 zone the player has NOT unlocked, otherwise "". Lets command sites
// answer "you're not ready" instead of a generic "unknown zone" when a
// player types a locked post-game zone by name/id.
func postgameLockReason(input string, userID id.UserID, level int) string {
if _, ok := resolveZoneInput(input, postgameZones()); !ok {
return ""
}
if unlocked, reason := postgameUnlocked(userID, level); !unlocked {
return reason
}
return ""
}

View File

@@ -0,0 +1,75 @@
package plugin
// The Drowned Star branching graph (Tier 6, Phase P4).
//
// The abyssal trench where Seraphel rode her star down. Regions: The Long
// Sink → Pilgrim Trench → The Radiant Wreck → The Heart Chapel, on the
// shared T6 skeleton. Longest walk = 46, in band.
//
// A descent zone: the forks gate on holding your nerve in the dark (CON
// against the pressure, WIS against the false lights, CHA to walk the
// pilgrim route unmolested). The locked spurs are radiant pockets of the
// wreck — high LootBias, lit by the dying star.
func zoneDrownedStarGraph() ZoneGraph {
stat := func(s string, dc int, hint string) pgLock {
return pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": s, "dc": dc}, hint: hint}
}
perc := func(dc int, hint string) pgLock {
return pgLock{kind: LockPerception, lockData: map[string]any{"dc": dc}, hint: hint}
}
return buildPostgameZoneGraph(pgZoneSpec{
zoneID: ZoneDrownedStar,
regions: [4]string{"star_long_sink", "star_pilgrim_trench", "star_radiant_wreck", "star_heart_chapel"},
entry: "The Waterline",
preamble: [12]string{
"The Long Sink", "Descending Dark", "Pressure Gate", "The Static Shoal",
"Choir of Static", "Sunless Landing", "Undertow Snare", "The Glow Below",
"Pilgrim's Approach", "Trench Mouth", "The Lit Road", "First Lantern",
},
fork1: pgFork{
label: "The First Descent",
freeLabel: [3]string{"Marked Pilgrim Road", "Lantern-Lit Walk", "Trench Floor"},
lockLabel: [3]string{"Off-Route Pocket", "The Radiant Wreck-Pocket", "Sunken Alcove"},
lock: stat("CON", 16, "the pressure here would fold a lesser diver — endure it and the wreck opens"),
},
r2gate: "Pilgrim Trench",
r2build: [6]string{
"Trench of the Followed Light", "The Angler-Knight's Walk", "False-Light Gallery",
"Warden's Lure", "The Unseen-Again Hall", "Wreck Threshold",
},
fork2: pgFork{
label: "The Second Descent",
freeLabel: [3]string{"Steady Pilgrim Line", "Trusted Dark", "Wreck Floor"},
lockLabel: [3]string{"Halo-Fragment Recess", "The Star-Lit Vault", "Radiant Niche"},
lock: stat("WIS", 17, "one lantern is not a lure — know which and it leads true"),
},
r3gate: "The Radiant Wreck",
r3build: [6]string{
"The Star's Broken Hull", "Gallery of Ten Thousand Years", "Leviathanling Roost",
"The Glowing Silhouette", "Chapel Antechamber", "Threshold of the Heart",
},
capstone: pgCapstone{
label: "The Chapel Fork",
freeLabel: [3]string{"Direct Chapel-Walk", "The Open Reredos", "Chapel Floor"},
lockALabel: [3]string{"Pilgrim's Grace", "The Unmolested Route", "Sanctified Path"},
lockA: stat("CHA", 18, "walk the pilgrim route as a pilgrim and it lets you pass"),
lockBLabel: [3]string{"Halo-Seam", "The Heart's Own Light", "Radiant Shortcut"},
lockB: perc(18, "a crack in the radiance where the star still leaks its oldest light"),
},
merge: "The Heart Chapel",
approach: [5]string{
"The Shielded Star", "Seraphel's Vigil", "Sanctified Undertow Walk", "The Grieving Light", "The Last Radiance",
},
boss: "Seraphel, the Light That Sank",
overrides: map[string]pgNodeOverride{
"f1b2": {kind: NodeKindSecret, bias: 2.25},
"f2b2": {kind: NodeKindSecret, bias: 2.25},
"cap32": {kind: NodeKindSecret, bias: 2.5},
},
})
}
func init() {
registerZoneGraph(zoneDrownedStarGraph())
}

View File

@@ -0,0 +1,25 @@
package plugin
import "testing"
func TestDrownedStarGraph_Invariants(t *testing.T) {
assertPostgameGraph(t, ZoneDrownedStar, zoneDrownedStarGraph)
}
// TestDrownedStarGraph_PilgrimGrace verifies the CHA pilgrim-route capstone
// spur and the radiant secret pockets are authored.
func TestDrownedStarGraph_PilgrimGrace(t *testing.T) {
g := zoneDrownedStarGraph()
if got := countSecretNodes(g); got != 3 {
t.Errorf("secret nodes = %d, want 3", got)
}
chaSpur := false
for _, e := range g.outgoingEdges("drowned_star.fork3") {
if e.Lock == LockStatCheck && lockDataString(e.LockData, "stat") == "CHA" {
chaSpur = true
}
}
if !chaSpur {
t.Error("Drowned Star capstone should offer a CHA pilgrim-route spur")
}
}

View File

@@ -0,0 +1,75 @@
package plugin
// The First Hoard branching graph (Tier 6, Phase P4).
//
// Aurvandryx's gilded clutch under Infernus Peak. Regions: The Cooling
// Throne → Gilded Gullet → The Clutch Vaults → Cradle of the First Flame,
// on the shared T6 skeleton. Longest walk = 46, in band.
//
// Signature set-piece: the game-max LootBias route. Per the plan, the First
// Hoard carries the richest nodes in the game (2.53.0) because the boss's
// Layer-2 Greed Tax makes rich routes self-balancing — gild yourself and
// pay for it at Aurvandryx, or take the lean line. The free capstone (the
// deepest gilded vein, cap12) sits at the 3.0 ceiling; two buildup nodes
// carry 2.5/2.75. Forks gate on physical checks (STR/CON) — you force the
// vault, you don't sneak it.
func zoneFirstHoardGraph() ZoneGraph {
stat := func(s string, dc int, hint string) pgLock {
return pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": s, "dc": dc}, hint: hint}
}
return buildPostgameZoneGraph(pgZoneSpec{
zoneID: ZoneFirstHoard,
regions: [4]string{"hoard_cooling_throne", "hoard_gilded_gullet", "hoard_clutch_vaults", "hoard_cradle_first_flame"},
entry: "The Still-Warm Gate",
preamble: [12]string{
"Infernax's Corpse-Terrain", "The Cooling Throne", "Scale-River Ford", "Ashfall Landing",
"The Guard-Dog's Rest", "Ember Colonnade", "Slag Pit", "Choir-Drake Roost",
"The Gilded Descent", "Minted Approach", "Coin-Drift Hall", "Throat of the Mountain",
},
fork1: pgFork{
label: "The First Swallow",
freeLabel: [3]string{"Lean Passage", "Bare Gullet", "Gullet Floor"},
lockLabel: [3]string{"Vault-Seam", "The Gilded Vein", "Coin-Choked Stair"},
lock: stat("STR", 16, "a wall of fused coin — force it and the hoard spills"),
},
r2gate: "Gilded Gullet",
r2build: [6]string{
"Hall of Minted Antibodies", "The Simulacrum Font", "Mirror-Mint Gallery",
"Executor's Approach", "The Scale-Debt Walk", "Vault Threshold",
},
fork2: pgFork{
label: "The Second Swallow",
freeLabel: [3]string{"Poverty's Line", "Empty Clutch-Walk", "Clutch Floor"},
lockLabel: [3]string{"Cracked Egg-Vault", "The Gilded Reliquary", "Gold-Sealed Nest"},
lock: stat("CON", 16, "the heat here would cook a lesser body — endure it for the richest vault"),
},
r3gate: "The Clutch Vaults",
r3build: [6]string{
"Rows of Gilded Eggs", "The Resonance Nursery", "Choir-Cracked Stone",
"Wyrm-Sworn Landing", "Cradle Antechamber", "Threshold of the First Flame",
},
capstone: pgCapstone{
label: "The Cradle Fork",
freeLabel: [3]string{"The Deep Vein", "Mother-Lode Gallery", "Cradle Floor"},
lockALabel: [3]string{"Ember Squeeze", "The Molten Reliquary", "Scale-Sworn Path"},
lockA: stat("STR", 18, "prise the last vault open before she wakes"),
lockBLabel: [3]string{"Heat-Shimmer Recess", "The Poverty Shrine", "Vowed Approach"},
lockB: stat("WIS", 18, "leave the gold — the lean route arrives unburdened"),
},
merge: "Cradle of the First Flame",
approach: [5]string{
"The Warming Nest", "Clutch of the First Wyrm", "Ember-Before-Fire Walk", "The Progenitor's Shadow", "The First Breath",
},
boss: "Aurvandryx, the Ember Before Fire",
overrides: map[string]pgNodeOverride{
"cap12": {bias: 3.0}, // deepest gilded vein — game-max
"f1b2": {bias: 2.75}, // The Gilded Vein
"r2b2": {bias: 2.5}, // The Simulacrum Font
},
})
}
func init() {
registerZoneGraph(zoneFirstHoardGraph())
}

View File

@@ -0,0 +1,32 @@
package plugin
import "testing"
func TestFirstHoardGraph_Invariants(t *testing.T) {
assertPostgameGraph(t, ZoneFirstHoard, zoneFirstHoardGraph)
}
// TestFirstHoardGraph_GameMaxLootBias verifies the plan's requirement that
// the First Hoard carries the game-max LootBias route (2.53.0), which the
// boss's Greed Tax self-balances. The deepest gilded vein hits the 3.0
// ceiling and at least three nodes sit at or above 2.5.
func TestFirstHoardGraph_GameMaxLootBias(t *testing.T) {
g := zoneFirstHoardGraph()
var max float64
rich := 0
for _, n := range g.Nodes {
if n.Content.LootBias > max {
max = n.Content.LootBias
}
if n.Content.LootBias >= 2.5 {
rich++
}
}
if max < 3.0 {
t.Errorf("max LootBias = %v, want >= 3.0 (game-max gilded route)", max)
}
if rich < 3 {
t.Errorf("nodes with LootBias >= 2.5 = %d, want >= 3", rich)
}
}

View File

@@ -0,0 +1,75 @@
package plugin
// The Last Meridian branching graph (Tier 6, Phase P4).
//
// A necropolis-observatory being decommissioned hour by hour. Regions: The
// Dusk Colonnade → Gallery of Spent Hours → The Escapement → One Minute To
// Midnight, on the shared T6 skeleton. Longest walk = 46, in band.
//
// A time zone: the forks gate on reading the clockwork's rhythm (INT for the
// escapement's rule, DEX to slip a stolen turn, WIS to keep your own hour
// when the Hour Thief reaches for it). The locked spurs are pockets of
// unspent time — high LootBias hours the Custodian hasn't dismantled yet.
func zoneLastMeridianGraph() ZoneGraph {
stat := func(s string, dc int, hint string) pgLock {
return pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": s, "dc": dc}, hint: hint}
}
perc := func(dc int, hint string) pgLock {
return pgLock{kind: LockPerception, lockData: map[string]any{"dc": dc}, hint: hint}
}
return buildPostgameZoneGraph(pgZoneSpec{
zoneID: ZoneLastMeridian,
regions: [4]string{"meridian_dusk_colonnade", "meridian_gallery_spent_hours", "meridian_escapement", "meridian_one_minute"},
entry: "The Dusk Gate",
preamble: [12]string{
"The Dusk Colonnade", "Hall of Winding Nothing", "The Hour Thief's Trail", "Candle-Shortened Walk",
"Horologist's Ghost-Aisle", "The Stolen Present", "Pickpocket's Snare", "Colonnade of Spent Light",
"The Gallery Approach", "Gallery Mouth", "Hall of Spent Hours", "First Struck Bell",
},
fork1: pgFork{
label: "The First Hour",
freeLabel: [3]string{"On-Time Passage", "The Kept Hour", "Gallery Floor"},
lockLabel: [3]string{"Guarded Recess", "The Unspent Hour-Pocket", "Ticking Alcove"},
lock: stat("WIS", 16, "keep your own hour when the Thief reaches for it and a door stays open"),
},
r2gate: "Gallery of Spent Hours",
r2build: [6]string{
"Rows of Stopped Clocks", "The Twin Wardens' Door", "Changing of the Guard",
"Shift-Schedule Hall", "The Nothing Behind", "Escapement Threshold",
},
fork2: pgFork{
label: "The Second Hour",
freeLabel: [3]string{"Steady Escapement Walk", "The Even-Round Hall", "Escapement Floor"},
lockLabel: [3]string{"Gear-Seam", "The Unwound Vault", "Pendulum Niche"},
lock: stat("INT", 17, "the pendulum only swings on even rounds — read the rhythm and slip past"),
},
r3gate: "The Escapement",
r3build: [6]string{
"The Original Pendulum", "Gallery of Its Legs", "Verdigris Colossus Walk",
"Orrery-Ring Landing", "Midnight Antechamber", "Threshold of the Last Hour",
},
capstone: pgCapstone{
label: "The Midnight Fork",
freeLabel: [3]string{"Direct Midnight-Walk", "The Open Escapement", "Midnight Floor"},
lockALabel: [3]string{"Borrowed Second", "The Slipped Turn", "Stolen-Time Path"},
lockA: stat("DEX", 18, "steal a second from the clock and step through before it notices"),
lockBLabel: [3]string{"Unwound Seam", "The Hour Never Struck", "Unspent Shortcut"},
lockB: perc(18, "one hour on the great face was never dismantled — it is still open"),
},
merge: "One Minute To Midnight",
approach: [5]string{
"The Apologizing Golem", "Orrery of the Last Hour", "Chime Approach", "The Rewinding Second", "The Clock Stops",
},
boss: "The Custodian of the Last Hour",
overrides: map[string]pgNodeOverride{
"f1b2": {kind: NodeKindSecret, bias: 2.25},
"f2b2": {kind: NodeKindSecret, bias: 2.25},
"cap32": {kind: NodeKindSecret, bias: 2.5},
},
})
}
func init() {
registerZoneGraph(zoneLastMeridianGraph())
}

View File

@@ -0,0 +1,25 @@
package plugin
import "testing"
func TestLastMeridianGraph_Invariants(t *testing.T) {
assertPostgameGraph(t, ZoneLastMeridian, zoneLastMeridianGraph)
}
// TestLastMeridianGraph_RhythmLocks verifies the escapement's rhythm gate
// (INT on fork2) and the unspent-time secret pockets are authored.
func TestLastMeridianGraph_RhythmLocks(t *testing.T) {
g := zoneLastMeridianGraph()
if got := countSecretNodes(g); got != 3 {
t.Errorf("secret nodes = %d, want 3", got)
}
intFork := false
for _, e := range g.outgoingEdges("last_meridian.fork2") {
if e.Lock == LockStatCheck && lockDataString(e.LockData, "stat") == "INT" {
intFork = true
}
}
if !intFork {
t.Error("Last Meridian fork2 should gate on INT (escapement rhythm)")
}
}

View File

@@ -0,0 +1,75 @@
package plugin
// The Ossuary Ascendant branching graph (Tier 6, Phase P4).
//
// Valdris's inverted bone-cathedral. Four regions — Bonefall Steps →
// Cathedral of Marrow → Reliquary Vaults → The Apotheosis Engine — on the
// shared T6 skeleton (buildPostgameZoneGraph). Longest walk = 46, in band.
//
// Signature set-piece: the three Phylactery Verses. Each is a NodeKindSecret
// reached through a LockPerception spur (one per fork), matching the plan's
// "three SECRET nodes, Verses found before the boss strip a Legendary
// Resistance / rebirth proc." The gate rides the fork→threshold edge; the
// edge into the Verse itself is unlocked. Full-clear explorers who find all
// three fight a mortal; speedrunners fight a god. Secret suffixes: f1b2,
// f2b2, cap32 (the middle node of each Perception spur).
func zoneOssuaryAscendantGraph() ZoneGraph {
perc := func(dc int, hint string) pgLock {
return pgLock{kind: LockPerception, lockData: map[string]any{"dc": dc}, hint: hint}
}
return buildPostgameZoneGraph(pgZoneSpec{
zoneID: ZoneOssuaryAscendant,
regions: [4]string{"ossuary_bonefall_steps", "ossuary_cathedral_marrow", "ossuary_reliquary_vaults", "ossuary_apotheosis_engine"},
entry: "The Inverted Threshold",
preamble: [12]string{
"Bonefall Steps", "The Rising Ossuary", "Marrow Stair", "Chancel of Ash",
"Ribvault Landing", "The Patient Gallery", "Reliquary Snare", "Choirless Nave",
"Ascending Transept", "The Counted Dead", "Vertebral Climb", "First Landing",
},
fork1: pgFork{
label: "The First Ascent",
freeLabel: [3]string{"Open Stair", "Bonelit Passage", "Cathedral Approach"},
lockLabel: [3]string{"Hairline Recess", "Verse of the Waiting Grave", "Sealed Alcove"},
lock: perc(16, "a seam in the marrow where the wall was never quite finished"),
},
r2gate: "Cathedral of Marrow",
r2build: [6]string{
"Nave of Fused Clergy", "The Censer Walk", "Grave-Smoke Aisle",
"Choir of the Pre-Blessed", "Cardinal's Approach", "The Marrow Altar",
},
fork2: pgFork{
label: "The Second Ascent",
freeLabel: [3]string{"Vault Stair", "Donor-Bone Hall", "Reliquary Approach"},
lockLabel: [3]string{"Cracked Reliquary", "Verse of the Bait Long Set", "Dust-Sealed Niche"},
lock: perc(17, "one reliquary is hollow where the others are full"),
},
r3gate: "Reliquary Vaults",
r3build: [6]string{
"The Coffin-Lid Wall", "Hall of Riveted Knights", "Gallery of the Crypt-Fallen",
"The Unoccupied Armor", "Engine Antechamber", "Threshold of Ascension",
},
capstone: pgCapstone{
label: "The Apotheosis Fork",
freeLabel: [3]string{"Direct Ascent", "The Final Stair", "Engine Floor"},
lockALabel: [3]string{"Deacon's Whisper", "The Named Dead", "Consecrated Path"},
lockA: pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": "WIS", "dc": 18}, hint: "the choir sings your name in rounds — answer it and it opens"},
lockBLabel: [3]string{"Vertebral Fault", "Verse of the Homecoming", "The Last Recess"},
lockB: perc(18, "the highest vertebra of the cathedral was set wrong on purpose"),
},
merge: "The Apotheosis Engine",
approach: [5]string{
"Marrow Conduit", "The Ascending Choir", "Phylactery Cradle", "Vertebra of Godhood", "The Final Blessing",
},
boss: "Valdris, At Last",
overrides: map[string]pgNodeOverride{
"f1b2": {kind: NodeKindSecret, bias: 2.0},
"f2b2": {kind: NodeKindSecret, bias: 2.0},
"cap32": {kind: NodeKindSecret, bias: 2.0},
},
})
}
func init() {
registerZoneGraph(zoneOssuaryAscendantGraph())
}

View File

@@ -0,0 +1,61 @@
package plugin
import "testing"
func TestOssuaryAscendantGraph_Invariants(t *testing.T) {
assertPostgameGraph(t, ZoneOssuaryAscendant, zoneOssuaryAscendantGraph)
}
// TestOssuaryAscendantGraph_ThreeVerses verifies the signature set-piece:
// three NodeKindSecret Verses, each reached through a LockPerception spur
// edge (the gate rides the fork→threshold edge; the edge into the Verse
// itself is unlocked).
func TestOssuaryAscendantGraph_ThreeVerses(t *testing.T) {
g := zoneOssuaryAscendantGraph()
verses := []string{"ossuary_ascendant.f1b2", "ossuary_ascendant.f2b2", "ossuary_ascendant.cap32"}
for _, v := range verses {
n, ok := g.Nodes[v]
if !ok {
t.Errorf("missing Verse node %s", v)
continue
}
if n.Kind != NodeKindSecret {
t.Errorf("Verse %s kind = %q, want secret", v, n.Kind)
}
}
if got := countSecretNodes(g); got != 3 {
t.Errorf("secret nodes = %d, want 3 (the Phylactery Verses)", got)
}
// Each Verse's spur is entered through a Perception gate.
percThresholds := map[string]bool{
"ossuary_ascendant.f1b1": false,
"ossuary_ascendant.f2b1": false,
"ossuary_ascendant.cap31": false,
}
for _, outs := range g.Edges {
for _, e := range outs {
if e.Lock == LockPerception {
if _, ok := percThresholds[e.To]; ok {
percThresholds[e.To] = true
}
}
}
}
for th, gated := range percThresholds {
if !gated {
t.Errorf("Verse threshold %s is not behind a LockPerception edge", th)
}
}
}
func countSecretNodes(g ZoneGraph) int {
n := 0
for _, node := range g.Nodes {
if node.Kind == NodeKindSecret {
n++
}
}
return n
}

View File

@@ -0,0 +1,77 @@
package plugin
// The Unplace branching graph (Tier 6, Phase P4).
//
// The unattended tear Belaxath left behind — geometry that gave up. Regions:
// The Fray → Escher's Debt → The Stitchworks → The Seam, on the shared T6
// skeleton. Longest walk = 46, in band.
//
// The Unplace's set-piece is its locks: every fork gates on a different way
// of not-trusting-the-walls (Perception to see the true corner, INT to
// out-think the geometry, DEX to move through a room that is also another
// room). The secret spurs are high-LootBias impossible-geometry pockets
// rather than named secrets — the reward is the shortcut nobody should be
// able to take.
func zoneUnplaceGraph() ZoneGraph {
perc := func(dc int, hint string) pgLock {
return pgLock{kind: LockPerception, lockData: map[string]any{"dc": dc}, hint: hint}
}
stat := func(s string, dc int, hint string) pgLock {
return pgLock{kind: LockStatCheck, lockData: map[string]any{"stat": s, "dc": dc}, hint: hint}
}
return buildPostgameZoneGraph(pgZoneSpec{
zoneID: ZoneUnplace,
regions: [4]string{"unplace_the_fray", "unplace_eschers_debt", "unplace_stitchworks", "unplace_the_seam"},
entry: "The Frayed Edge",
preamble: [12]string{
"The Fray", "Horizon Indoors", "The Borrowed Corner", "Room That Is Also",
"Angleworn Passage", "The Miss You Took", "Fraying Snare", "Silhouette Hall",
"The Uncounted Arms", "Escher's Approach", "Debt Landing", "The Owed Turn",
},
fork1: pgFork{
label: "The First Impossibility",
freeLabel: [3]string{"Straight Enough Line", "Plain Corridor", "Debt Floor"},
lockLabel: [3]string{"Corner-Behind-You", "The Pocket That Isn't", "Folded Alcove"},
lock: perc(16, "a corner you already passed is somehow ahead of you"),
},
r2gate: "Escher's Debt",
r2build: [6]string{
"Stair Up Into Down", "The Unpaid Landing", "Gallery of Wrong Angles",
"Nalfeshnee Concourse", "The Recursive Hall", "Stitchworks Threshold",
},
fork2: pgFork{
label: "The Second Impossibility",
freeLabel: [3]string{"Threadbare Walk", "Loose-Weave Hall", "Stitchworks Floor"},
lockLabel: [3]string{"Seam-Behind-Seam", "The Unstitched Pocket", "Needled Recess"},
lock: stat("INT", 17, "the geometry has a rule; find it and the shortcut is legal"),
},
r3gate: "The Stitchworks",
r3build: [6]string{
"Rows of Half-Sewn Doors", "The Needle Gallery", "Marilith Loom",
"Echo of Belaxath", "Seam Antechamber", "Threshold of the Tear",
},
capstone: pgCapstone{
label: "The Seam Fork",
freeLabel: [3]string{"Direct Seam-Walk", "The Open Tear", "Seam Floor"},
lockALabel: [3]string{"Sideways Step", "The Room Behind This One", "Slipped Path"},
lockA: stat("DEX", 18, "step through the wall while it is also a door"),
lockBLabel: [3]string{"Hairline Unreality", "The Undone Pocket", "Impossible Shortcut"},
lockB: perc(18, "there is a place that is not here, and it is closer than the door"),
},
merge: "The Seam",
approach: [5]string{
"The Far Half Speaking", "Sewn Into the Wound", "Needle-Rain Approach", "The Inside-Out Room", "The Last Stitch",
},
boss: "The Seamstress",
overrides: map[string]pgNodeOverride{
"f1b2": {kind: NodeKindSecret, bias: 2.25},
"f2b2": {kind: NodeKindSecret, bias: 2.25},
"cap32": {kind: NodeKindSecret, bias: 2.5},
},
})
}
func init() {
registerZoneGraph(zoneUnplaceGraph())
}

View File

@@ -0,0 +1,25 @@
package plugin
import "testing"
func TestUnplaceGraph_Invariants(t *testing.T) {
assertPostgameGraph(t, ZoneUnplace, zoneUnplaceGraph)
}
// TestUnplaceGraph_ImpossibleShortcuts verifies the three high-bias secret
// pockets and that its capstone offers the "not here" Perception shortcut.
func TestUnplaceGraph_ImpossibleShortcuts(t *testing.T) {
g := zoneUnplaceGraph()
if got := countSecretNodes(g); got != 3 {
t.Errorf("secret nodes = %d, want 3", got)
}
percCapstone := false
for _, e := range g.outgoingEdges("unplace.fork3") {
if e.Lock == LockPerception {
percCapstone = true
}
}
if !percCapstone {
t.Error("Unplace capstone should offer a LockPerception shortcut spur")
}
}