mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
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:
@@ -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: "🌑",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 4–6% 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)
|
||||
}
|
||||
|
||||
@@ -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 100–300, very rare 500–1200,
|
||||
// epic 500–1200 in §8.1 — recipes here lean tier 3–4, 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)
|
||||
|
||||
@@ -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`.")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 12–14 up to
|
||||
// T5 ~36–44. 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 44–52.
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
// T1–T5 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-
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
197
internal/plugin/postgame_bestiary.go
Normal file
197
internal/plugin/postgame_bestiary.go
Normal 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
|
||||
}()
|
||||
117
internal/plugin/postgame_magic_items.go
Normal file
117
internal/plugin/postgame_magic_items.go
Normal 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},
|
||||
}
|
||||
336
internal/plugin/postgame_zone_defs.go
Normal file
336
internal/plugin/postgame_zone_defs.go
Normal 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 T1–T5. 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",
|
||||
}
|
||||
}
|
||||
220
internal/plugin/postgame_zone_graph_builder.go
Normal file
220
internal/plugin/postgame_zone_graph_builder.go
Normal 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)
|
||||
}
|
||||
79
internal/plugin/postgame_zone_graph_test.go
Normal file
79
internal/plugin/postgame_zone_graph_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
89
internal/plugin/postgame_zones.go
Normal file
89
internal/plugin/postgame_zones.go
Normal 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 ""
|
||||
}
|
||||
75
internal/plugin/zone_graph_drowned_star.go
Normal file
75
internal/plugin/zone_graph_drowned_star.go
Normal 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())
|
||||
}
|
||||
25
internal/plugin/zone_graph_drowned_star_test.go
Normal file
25
internal/plugin/zone_graph_drowned_star_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
75
internal/plugin/zone_graph_first_hoard.go
Normal file
75
internal/plugin/zone_graph_first_hoard.go
Normal 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.5–3.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())
|
||||
}
|
||||
32
internal/plugin/zone_graph_first_hoard_test.go
Normal file
32
internal/plugin/zone_graph_first_hoard_test.go
Normal 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.5–3.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)
|
||||
}
|
||||
}
|
||||
75
internal/plugin/zone_graph_last_meridian.go
Normal file
75
internal/plugin/zone_graph_last_meridian.go
Normal 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())
|
||||
}
|
||||
25
internal/plugin/zone_graph_last_meridian_test.go
Normal file
25
internal/plugin/zone_graph_last_meridian_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
75
internal/plugin/zone_graph_ossuary_ascendant.go
Normal file
75
internal/plugin/zone_graph_ossuary_ascendant.go
Normal 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())
|
||||
}
|
||||
61
internal/plugin/zone_graph_ossuary_ascendant_test.go
Normal file
61
internal/plugin/zone_graph_ossuary_ascendant_test.go
Normal 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
|
||||
}
|
||||
77
internal/plugin/zone_graph_unplace.go
Normal file
77
internal/plugin/zone_graph_unplace.go
Normal 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())
|
||||
}
|
||||
25
internal/plugin/zone_graph_unplace_test.go
Normal file
25
internal/plugin/zone_graph_unplace_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user