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

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