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

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