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

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

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

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

View File

@@ -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")