mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-17 01:12:41 +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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user