mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N1/A1-A3: rewire the drop systems Phase R orphaned
Treasure, masterwork, and consumable drops each had zero call sites: they only ever fired from the legacy daily activity loop, which adventure.go now intercepts with a deprecation DM. Hook all three to the zone-combat seam. A1 - treasure: rollAdvTreasureDropDetailed takes a weight, applied to the base rate. Boss x4, elite x2, standard x1, plus one x1 roll on zone clear. Near-miss DMs now fire only for weighted moments; at x1 on autopilot they'd land on ~3% of every kill. A2 - masterwork: the catalog is keyed to mining/fishing/foraging, so the dungeon lookup returned nil and the hook would have been a silent no-op. masterworkDefForZone rolls across all three slot lines at the zone's tier. Flavor now follows loc.Activity rather than the item's catalog line, with a new dungeon pool - a crypt boss must not narrate a pickaxe striking ore. A3 - consumables + ingredients: the audit found more than the four named ingredients were stranded. generateAdvLoot is reachable only from resolveDungeonAction, which has no callers, so all four legacy loot tables were dead and every one of the 12 recipes was uncraftable. rollZoneIngredient draws from those tables directly at the zone's tier, reviving them wholesale rather than re-keying 24 ingredients into the per-zone slates.
This commit is contained in:
@@ -1072,13 +1072,19 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
|
|||||||
|
|
||||||
// ── Treasure Drop Check ─────────────────────────────────────────────────────
|
// ── Treasure Drop Check ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
|
// checkTreasureDrop rolls the treasure table for one earned moment. weight
|
||||||
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID))
|
// scales the drop rate by how big that moment was (see advTreasureWeight*).
|
||||||
|
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation, weight float64) {
|
||||||
|
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID), weight)
|
||||||
if drop == nil {
|
if drop == nil {
|
||||||
// Near-miss feedback: when the roll was within 2× of the drop rate,
|
// Near-miss feedback: when the roll was within 2× of the drop rate,
|
||||||
// tell the player they almost got it. Treasure rates are 0.15–1.5%
|
// tell the player they almost got it. Treasure rates are 0.15–1.5%
|
||||||
// so without this signal the system feels invisible.
|
// so without this signal the system feels invisible.
|
||||||
if rate > 0 && roll < rate*2 {
|
//
|
||||||
|
// Only for weighted moments (boss/elite/zone clear). A standard kill
|
||||||
|
// fires dozens of times a day on autopilot, and a near-miss DM on 3%
|
||||||
|
// of them turns the signal into noise.
|
||||||
|
if weight > 1 && rate > 0 && roll < rate*2 {
|
||||||
p.SendDM(userID, fmt.Sprintf("🎁 *Treasure: just missed* — rolled %.2f%% against %.2f%% drop chance.",
|
p.SendDM(userID, fmt.Sprintf("🎁 *Treasure: just missed* — rolled %.2f%% against %.2f%% drop chance.",
|
||||||
roll*100, rate*100))
|
roll*100, rate*100))
|
||||||
}
|
}
|
||||||
|
|||||||
96
internal/plugin/adventure_ingredients_test.go
Normal file
96
internal/plugin/adventure_ingredients_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// N1/A3 — every crafting ingredient must be obtainable from some live drop
|
||||||
|
// source. Phase R retired the legacy activity loop, which orphaned
|
||||||
|
// generateAdvLoot (reachable only from the now-uncalled resolveDungeonAction)
|
||||||
|
// and with it every ingredient in the game. rollZoneIngredient reconnects the
|
||||||
|
// legacy tables to zone combat; this test is the guard that keeps them
|
||||||
|
// reachable.
|
||||||
|
|
||||||
|
// liveIngredientSources returns every item name a player can obtain from the
|
||||||
|
// loot tables zone combat draws on, keyed to the tier it drops at.
|
||||||
|
func liveIngredientSources() map[string][]int {
|
||||||
|
sources := map[string][]int{}
|
||||||
|
for _, act := range advIngredientActivities {
|
||||||
|
table := advLootTable(act)
|
||||||
|
for tier, defs := range table {
|
||||||
|
for _, d := range defs {
|
||||||
|
sources[d.Name] = append(sources[d.Name], tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryRecipeIngredientHasALiveSource(t *testing.T) {
|
||||||
|
sources := liveIngredientSources()
|
||||||
|
for _, recipe := range craftingRecipes {
|
||||||
|
for _, ing := range recipe.Ingredients {
|
||||||
|
if tiers, ok := sources[ing]; !ok || len(tiers) == 0 {
|
||||||
|
t.Errorf("recipe %q needs %q, which no live loot table drops",
|
||||||
|
recipe.Result, ing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The drop is keyed by zone tier, so an ingredient that only exists at
|
||||||
|
// legacy tier N is only reachable in a tier-N zone. Assert each recipe is
|
||||||
|
// completable by someone — i.e. every ingredient sits in tier 1..5.
|
||||||
|
func TestIngredientTiersAreReachableByZoneTier(t *testing.T) {
|
||||||
|
sources := liveIngredientSources()
|
||||||
|
for _, recipe := range craftingRecipes {
|
||||||
|
for _, ing := range recipe.Ingredients {
|
||||||
|
for _, tier := range sources[ing] {
|
||||||
|
if tier < 1 || tier > 5 {
|
||||||
|
t.Errorf("%q drops at tier %d, outside the zone tier range",
|
||||||
|
ing, tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every zone tier must be able to produce an ingredient, or that tier's
|
||||||
|
// players are cut out of crafting entirely.
|
||||||
|
func TestRollZoneIngredient_EveryTierCanDrop(t *testing.T) {
|
||||||
|
for tier := 1; tier <= 5; tier++ {
|
||||||
|
t.Run(fmt.Sprintf("tier%d", tier), func(t *testing.T) {
|
||||||
|
got := false
|
||||||
|
for i := 0; i < 500 && !got; i++ {
|
||||||
|
if item := rollZoneIngredient(tier); item != nil {
|
||||||
|
got = true
|
||||||
|
if item.Tier != tier {
|
||||||
|
t.Errorf("tier %d drop carried tier %d", tier, item.Tier)
|
||||||
|
}
|
||||||
|
if item.Value <= 0 {
|
||||||
|
t.Errorf("%q dropped with value %d", item.Name, item.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !got {
|
||||||
|
t.Errorf("tier %d never dropped an ingredient in 500 rolls", tier)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinLootLines_SkipsEmpties(t *testing.T) {
|
||||||
|
if got := joinLootLines("", ""); got != "" {
|
||||||
|
t.Errorf("all-empty = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := joinLootLines("a", ""); got != "a" {
|
||||||
|
t.Errorf("trailing empty = %q, want %q", got, "a")
|
||||||
|
}
|
||||||
|
if got := joinLootLines("", "b"); got != "b" {
|
||||||
|
t.Errorf("leading empty = %q, want %q", got, "b")
|
||||||
|
}
|
||||||
|
if got := joinLootLines("a", "b"); got != "a\nb" {
|
||||||
|
t.Errorf("both = %q, want %q", got, "a\nb")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,10 +115,37 @@ func masterworkDefFor(activity AdvActivityType, tier int) *MasterworkDef {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// masterworkSlotActivities are the three activity lines the catalog covers,
|
||||||
|
// one equipment slot each: mining→weapon, fishing→armor, foraging→boots.
|
||||||
|
var masterworkSlotActivities = []AdvActivityType{
|
||||||
|
AdvActivityMining, AdvActivityFishing, AdvActivityForaging,
|
||||||
|
}
|
||||||
|
|
||||||
|
// masterworkDefForZone picks a masterwork for a dungeon kill. The catalog is
|
||||||
|
// keyed by gathering activity — there is no dungeon line — so a zone drop
|
||||||
|
// rolls across all three slots and hands back that line's item at the zone's
|
||||||
|
// tier. The item keeps its own SkillSource: a masterwork blade found in a
|
||||||
|
// crypt still helps you mine.
|
||||||
|
func masterworkDefForZone(tier int) *MasterworkDef {
|
||||||
|
act := masterworkSlotActivities[rand.IntN(len(masterworkSlotActivities))]
|
||||||
|
return masterworkDefFor(act, tier)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Drop Flavor Text (DM) ─────────────────────────────────────────────────
|
// ── Drop Flavor Text (DM) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
|
func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
|
||||||
switch activity {
|
switch activity {
|
||||||
|
case AdvActivityDungeon:
|
||||||
|
switch {
|
||||||
|
case tier <= 2:
|
||||||
|
return "The thing you killed was carrying it, which raises the question of where it got it. You check the body for a maker's mark and find none. You check the body for anything else and find nothing. You take the gear and you do not think about the question."
|
||||||
|
case tier == 3:
|
||||||
|
return "It's propped against the wall behind the corpse, upright, deliberate. Not dropped. Placed. Someone came down here, set this down carefully, and did not come back for it. You pick it up. It fits your hand better than anything you paid for."
|
||||||
|
case tier == 4:
|
||||||
|
return "There's a pile of gear in the corner, most of it ruined, all of it belonging to people who came this far and no further. One piece is untouched. The rust stopped at its edge, as if the rust knew better. You add it to your kit and you leave the rest as you found it."
|
||||||
|
default:
|
||||||
|
return "It is the only thing in the room that isn't broken. Whatever lived here kept it, and kept it well, and cleaned it, and did not use it. You take it because you won. That is the arrangement. On the way out you do not turn around, and you tell yourself that is a choice."
|
||||||
|
}
|
||||||
case AdvActivityMining:
|
case AdvActivityMining:
|
||||||
switch {
|
switch {
|
||||||
case tier <= 2:
|
case tier <= 2:
|
||||||
@@ -164,9 +191,15 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
def := masterworkDefFor(loc.Activity, loc.Tier)
|
// Dungeons have no catalog line of their own; they roll across all three.
|
||||||
|
var def *MasterworkDef
|
||||||
|
if loc.Activity == AdvActivityDungeon {
|
||||||
|
def = masterworkDefForZone(loc.Tier)
|
||||||
|
} else {
|
||||||
|
def = masterworkDefFor(loc.Activity, loc.Tier)
|
||||||
|
}
|
||||||
if def == nil {
|
if def == nil {
|
||||||
return // no masterwork available for this activity+tier (e.g. dungeon)
|
return // no masterwork available for this activity+tier
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roll for drop (chat level rare bonus applied additively)
|
// Roll for drop (chat level rare bonus applied additively)
|
||||||
@@ -250,8 +283,9 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
|
|||||||
sb.WriteString("_This doesn't come from the shop._\n\n")
|
sb.WriteString("_This doesn't come from the shop._\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flavor text
|
// Flavor follows where the item was found, not which catalog line it came
|
||||||
flavor := masterworkDropFlavorText(def.Activity, def.Tier)
|
// from — a dungeon drop must not open with a pickaxe.
|
||||||
|
flavor := masterworkDropFlavorText(loc.Activity, def.Tier)
|
||||||
if flavor != "" {
|
if flavor != "" {
|
||||||
sb.WriteString(fmt.Sprintf("_%s_\n\n", flavor))
|
sb.WriteString(fmt.Sprintf("_%s_\n\n", flavor))
|
||||||
}
|
}
|
||||||
|
|||||||
68
internal/plugin/adventure_masterwork_test.go
Normal file
68
internal/plugin/adventure_masterwork_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// N1/A2 — the masterwork catalog is keyed to gathering activities, so the
|
||||||
|
// zone-combat seam had nothing to look up. masterworkDefForZone bridges it.
|
||||||
|
|
||||||
|
func TestMasterworkDefForZone_CoversEveryTier(t *testing.T) {
|
||||||
|
for tier := 1; tier <= 5; tier++ {
|
||||||
|
if def := masterworkDefForZone(tier); def == nil {
|
||||||
|
t.Errorf("tier %d has no masterwork def", tier)
|
||||||
|
} else if def.Tier != tier {
|
||||||
|
t.Errorf("tier %d returned a tier-%d def", tier, def.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dungeon drop must be able to land in any of the three slots — otherwise
|
||||||
|
// zone play would only ever upgrade one piece of gear.
|
||||||
|
func TestMasterworkDefForZone_RollsAllSlots(t *testing.T) {
|
||||||
|
seen := map[EquipmentSlot]bool{}
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
if def := masterworkDefForZone(3); def != nil {
|
||||||
|
seen[def.Slot] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotBoots} {
|
||||||
|
if !seen[slot] {
|
||||||
|
t.Errorf("slot %v never rolled in 300 draws", slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pre-existing lookup must keep working for the gathering activities.
|
||||||
|
func TestMasterworkDefFor_GatheringUnchanged(t *testing.T) {
|
||||||
|
for _, act := range masterworkSlotActivities {
|
||||||
|
for tier := 1; tier <= 5; tier++ {
|
||||||
|
def := masterworkDefFor(act, tier)
|
||||||
|
if def == nil {
|
||||||
|
t.Fatalf("%s tier %d lost its def", act, tier)
|
||||||
|
}
|
||||||
|
if def.Activity != act {
|
||||||
|
t.Errorf("%s tier %d returned activity %s", act, tier, def.Activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flavor follows the place, not the catalog line. A crypt boss must not
|
||||||
|
// narrate a pickaxe striking ore.
|
||||||
|
func TestMasterworkDropFlavorText_DungeonHasItsOwnVoice(t *testing.T) {
|
||||||
|
gatheringWords := []string{"pickaxe", "fishing", "foraging", "vein", "ore", "branch"}
|
||||||
|
for tier := 1; tier <= 5; tier++ {
|
||||||
|
text := masterworkDropFlavorText(AdvActivityDungeon, tier)
|
||||||
|
if text == "" {
|
||||||
|
t.Fatalf("dungeon tier %d has no flavor", tier)
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
for _, w := range gatheringWords {
|
||||||
|
if strings.Contains(lower, w) {
|
||||||
|
t.Errorf("dungeon tier %d flavor leaks gathering word %q", tier, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -246,12 +246,17 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
|||||||
// and the effective drop rate, so callers can surface near-miss feedback
|
// and the effective drop rate, so callers can surface near-miss feedback
|
||||||
// ("rolled 1.8% vs 1.5% chance — just missed"). Players never see treasure
|
// ("rolled 1.8% vs 1.5% chance — just missed"). Players never see treasure
|
||||||
// math otherwise, which makes rare drops feel mythical.
|
// math otherwise, which makes rare drops feel mythical.
|
||||||
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (drop *AdvTreasureDrop, roll, rate float64) {
|
//
|
||||||
|
// weight scales the base rate for the moment that produced the roll: a boss
|
||||||
|
// kill is worth more than a corridor skirmish. The chat-level bonus is added
|
||||||
|
// after scaling so it stays a flat contribution rather than being multiplied
|
||||||
|
// up alongside it.
|
||||||
|
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int, weight float64) (drop *AdvTreasureDrop, roll, rate float64) {
|
||||||
r, ok := advTreasureDropRates[tier]
|
r, ok := advTreasureDropRates[tier]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, 0, 0
|
return nil, 0, 0
|
||||||
}
|
}
|
||||||
rate = r + chatLevelRareBonus(chatLevel)
|
rate = r*weight + chatLevelRareBonus(chatLevel)
|
||||||
roll = rand.Float64()
|
roll = rand.Float64()
|
||||||
|
|
||||||
if roll >= rate {
|
if roll >= rate {
|
||||||
@@ -283,7 +288,7 @@ func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (dro
|
|||||||
// rollAdvTreasureDrop is the legacy single-return variant used by call sites
|
// rollAdvTreasureDrop is the legacy single-return variant used by call sites
|
||||||
// that don't surface near-miss feedback (auto-babysit, twinbee shares).
|
// that don't surface near-miss feedback (auto-babysit, twinbee shares).
|
||||||
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
|
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
|
||||||
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel)
|
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel, 1)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
81
internal/plugin/adventure_treasure_test.go
Normal file
81
internal/plugin/adventure_treasure_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// N1/A1 — treasure drops were orphaned by the Phase R transition and now
|
||||||
|
// hang off zone combat. The weight is what makes that safe: expedition
|
||||||
|
// combat rolls far more often than the one-a-day legacy activity the base
|
||||||
|
// rates were tuned for, so only boss/elite moments get a multiplier.
|
||||||
|
|
||||||
|
func TestAdvTreasureDropRate_ScalesWithWeight(t *testing.T) {
|
||||||
|
// A roll under the (weighted) rate reaches the duplicate check, which
|
||||||
|
// reads the treasure table.
|
||||||
|
if err := db.Init(t.TempDir()); err != nil {
|
||||||
|
t.Fatalf("db.Init: %v", err)
|
||||||
|
}
|
||||||
|
const user = "@weight:example.org"
|
||||||
|
base := advTreasureDropRates[1]
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
weight float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"standard kill", advTreasureWeightStandard, base},
|
||||||
|
{"elite doubles", advTreasureWeightElite, base * 2},
|
||||||
|
{"boss quadruples", advTreasureWeightBoss, base * 4},
|
||||||
|
{"zone clear is one bonus roll", advTreasureWeightZoneClear, base},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
_, _, rate := rollAdvTreasureDropDetailed(1, user, 0, c.weight)
|
||||||
|
if rate != c.want {
|
||||||
|
t.Fatalf("rate for weight %v = %v, want %v", c.weight, rate, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvTreasureDropRate_ZeroWeightNeverDrops(t *testing.T) {
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
drop, _, rate := rollAdvTreasureDropDetailed(1, "@zero:example.org", 0, 0)
|
||||||
|
if drop != nil || rate != 0 {
|
||||||
|
t.Fatalf("weight 0 produced drop=%v rate=%v", drop, rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced roll must actually yield a treasure — this is the "the seam is
|
||||||
|
// live again" assertion. A weight above 1/rate drives the effective rate
|
||||||
|
// past 1.0, so every roll lands on the drop path.
|
||||||
|
func TestAdvTreasureDropDetailed_ForcedRollGrantsTreasure(t *testing.T) {
|
||||||
|
if err := db.Init(t.TempDir()); err != nil {
|
||||||
|
t.Fatalf("db.Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
drop, _, _ := rollAdvTreasureDropDetailed(1, "@forced:example.org", 0, 1000)
|
||||||
|
if drop == nil || drop.Def == nil {
|
||||||
|
t.Fatal("forced roll produced no treasure")
|
||||||
|
}
|
||||||
|
if drop.Def.Tier != 1 {
|
||||||
|
t.Fatalf("tier-1 roll produced a tier-%d treasure", drop.Def.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The treasure and masterwork systems predate zones and speak AdvLocation.
|
||||||
|
func TestAdvLocForZone(t *testing.T) {
|
||||||
|
loc := advLocForZone(ZoneGoblinWarrens)
|
||||||
|
if loc.Activity != AdvActivityDungeon {
|
||||||
|
t.Errorf("activity = %q, want dungeon", loc.Activity)
|
||||||
|
}
|
||||||
|
if want := zoneTierFromID(ZoneGoblinWarrens); loc.Tier != want {
|
||||||
|
t.Errorf("tier = %d, want %d", loc.Tier, want)
|
||||||
|
}
|
||||||
|
if loc.Name == "" || loc.Name == string(ZoneGoblinWarrens) {
|
||||||
|
t.Errorf("name = %q, want the zone's display name", loc.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -287,7 +287,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
|||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("%s **%s** down. You finished at **%d/%d HP**.\n",
|
b.WriteString(fmt.Sprintf("%s **%s** down. You finished at **%d/%d HP**.\n",
|
||||||
emoji, enemy.Name, sess.PlayerHP, sess.PlayerHPMax))
|
emoji, enemy.Name, sess.PlayerHP, sess.PlayerHPMax))
|
||||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite); drop != "" {
|
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite, elite); drop != "" {
|
||||||
b.WriteString(drop + "\n")
|
b.WriteString(drop + "\n")
|
||||||
}
|
}
|
||||||
if bossOnExpedition {
|
if bossOnExpedition {
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
|||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).",
|
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).",
|
||||||
monster.Name, preCombatHP, postHP, maxHP))
|
monster.Name, preCombatHP, postHP, maxHP))
|
||||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
if drop := p.dropZoneLoot(userID, zone.ID, monster, false, false); drop != "" {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(drop)
|
b.WriteString(drop)
|
||||||
}
|
}
|
||||||
@@ -536,7 +536,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
|||||||
ob.WriteString("\n")
|
ob.WriteString("\n")
|
||||||
ob.WriteString(rollLine)
|
ob.WriteString(rollLine)
|
||||||
}
|
}
|
||||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
if drop := p.dropZoneLoot(userID, zone.ID, monster, false, false); drop != "" {
|
||||||
ob.WriteString("\n")
|
ob.WriteString("\n")
|
||||||
ob.WriteString(drop)
|
ob.WriteString(drop)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
|
|||||||
}
|
}
|
||||||
exp.Status = ExpeditionStatusComplete
|
exp.Status = ExpeditionStatusComplete
|
||||||
_ = retireAllRegionRuns(exp)
|
_ = retireAllRegionRuns(exp)
|
||||||
|
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
|
||||||
return p.AwardCompletionMilestones(exp, false)
|
return p.AwardCompletionMilestones(exp, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1016,7 +1016,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
ob.WriteString(".")
|
ob.WriteString(".")
|
||||||
recordZoneKillForUser(userID, monster.ID)
|
recordZoneKillForUser(userID, monster.ID)
|
||||||
applyRoomCombatThreatForUser(userID, elite || isBoss)
|
applyRoomCombatThreatForUser(userID, elite || isBoss)
|
||||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, isBoss); drop != "" {
|
if drop := p.dropZoneLoot(userID, zone.ID, monster, isBoss, elite); drop != "" {
|
||||||
ob.WriteString(" ")
|
ob.WriteString(" ")
|
||||||
ob.WriteString(drop)
|
ob.WriteString(drop)
|
||||||
}
|
}
|
||||||
@@ -1121,7 +1121,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
}
|
}
|
||||||
recordZoneKillForUser(userID, monster.ID)
|
recordZoneKillForUser(userID, monster.ID)
|
||||||
applyRoomCombatThreatForUser(userID, elite)
|
applyRoomCombatThreatForUser(userID, elite)
|
||||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
if drop := p.dropZoneLoot(userID, zone.ID, monster, false, elite); drop != "" {
|
||||||
ob.WriteString("\n")
|
ob.WriteString("\n")
|
||||||
ob.WriteString(drop)
|
ob.WriteString(drop)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,31 +382,166 @@ func rngIntN(rng *rand.Rand, n int) int {
|
|||||||
// the biome slate item. Keeps magic items a treat, not the norm.
|
// the biome slate item. Keeps magic items a treat, not the norm.
|
||||||
const magicItemDropChance = 0.15
|
const magicItemDropChance = 0.15
|
||||||
|
|
||||||
func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster DnDMonsterTemplate, isBoss bool) string {
|
// Treasure-roll weights by the kind of fight that earned the roll. The base
|
||||||
|
// rates (advTreasureDropRates, 1.5%→0.15%) are tuned for one roll per day of
|
||||||
|
// legacy activity; expedition combat rolls far more often, so standard kills
|
||||||
|
// stay at ×1 and the weight goes to the moments a player remembers.
|
||||||
|
const (
|
||||||
|
advTreasureWeightStandard = 1.0
|
||||||
|
advTreasureWeightElite = 2.0
|
||||||
|
advTreasureWeightBoss = 4.0
|
||||||
|
advTreasureWeightZoneClear = 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// advLocForZone adapts a zone into the AdvLocation shape the treasure and
|
||||||
|
// masterwork systems were written against, back when every drop came from a
|
||||||
|
// legacy daily activity. Zones are dungeons.
|
||||||
|
func advLocForZone(zoneID ZoneID) *AdvLocation {
|
||||||
|
name := string(zoneID)
|
||||||
|
if z, ok := getZone(zoneID); ok {
|
||||||
|
name = z.Display
|
||||||
|
}
|
||||||
|
return &AdvLocation{
|
||||||
|
Name: name,
|
||||||
|
Activity: AdvActivityDungeon,
|
||||||
|
Tier: zoneTierFromID(zoneID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollZoneTreasure gives the treasure table a shot at a zone moment. Silent
|
||||||
|
// on the overwhelmingly common no-drop path; checkTreasureDrop owns all
|
||||||
|
// player-facing output (discovery DM, auto-swap, T5 room announce).
|
||||||
|
func (p *AdventurePlugin) rollZoneTreasure(userID id.UserID, zoneID ZoneID, weight float64) {
|
||||||
|
char, err := loadAdvCharacter(userID)
|
||||||
|
if err != nil || char == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.checkTreasureDrop(userID, char, advLocForZone(zoneID), weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollZoneMasterwork gives the masterwork catalog a shot at an elite or boss
|
||||||
|
// kill. Arena gear stays the premium line (×1.5 effective tier vs ×1.25), so
|
||||||
|
// this competes with the shop, not with the arena.
|
||||||
|
func (p *AdventurePlugin) rollZoneMasterwork(userID id.UserID, zoneID ZoneID, isBoss bool) {
|
||||||
|
equip, err := loadAdvEquipment(userID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outcome := AdvOutcomeSuccess
|
||||||
|
if isBoss {
|
||||||
|
outcome = AdvOutcomeExceptional
|
||||||
|
}
|
||||||
|
p.checkMasterworkDrop(userID, equip, advLocForZone(zoneID), outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advIngredientDropChance — per-win chance a crafting ingredient drops.
|
||||||
|
//
|
||||||
|
// Every recipe ingredient in adventure_consumables.go lives in the legacy
|
||||||
|
// gathering tables (advDungeonLoot/advMiningLoot/advForagingLoot/
|
||||||
|
// advFishingLoot), which the Phase R transition left with no live caller:
|
||||||
|
// generateAdvLoot is only reachable from resolveDungeonAction, and nothing
|
||||||
|
// calls that. Rather than re-key 24 ingredients into the per-zone slates,
|
||||||
|
// zone combat draws from the legacy tables directly at the zone's tier —
|
||||||
|
// their values are already tuned, and every recipe becomes craftable again.
|
||||||
|
const advIngredientDropChance = 0.15
|
||||||
|
|
||||||
|
// advIngredientActivities are the four legacy tables a zone kill can draw an
|
||||||
|
// ingredient from. Zone tier indexes the table tier directly.
|
||||||
|
var advIngredientActivities = []AdvActivityType{
|
||||||
|
AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing,
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
||||||
|
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
||||||
|
func rollZoneIngredient(zoneTier int) *AdvItem {
|
||||||
|
if rand.Float64() >= advIngredientDropChance {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||||||
|
defs := advLootTable(act)[zoneTier]
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d := defs[rand.IntN(len(defs))]
|
||||||
|
value := d.MinValue
|
||||||
|
if d.MaxValue > d.MinValue {
|
||||||
|
value += rand.Int64N(d.MaxValue - d.MinValue + 1)
|
||||||
|
}
|
||||||
|
return &AdvItem{
|
||||||
|
Name: d.Name,
|
||||||
|
Type: d.Type,
|
||||||
|
Tier: zoneTier,
|
||||||
|
Value: value,
|
||||||
|
SkillSource: "zone_ingredient:" + string(act),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantZoneItem deposits a rolled item and returns its narration line.
|
||||||
|
func (p *AdventurePlugin) grantZoneItem(userID id.UserID, item *AdvItem, icon string) string {
|
||||||
|
if err := addAdvInventoryItem(userID, *item); err != nil {
|
||||||
|
slog.Error("zone loot: failed to add item", "user", userID, "item", item.Name, "err", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s **%s** — %d coin baseline.", icon, item.Name, item.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster DnDMonsterTemplate, isBoss, isElite bool) string {
|
||||||
|
// Treasure rolls on every win, independent of whether the zone loot
|
||||||
|
// table produced an item — the two systems are separate rewards.
|
||||||
|
weight := advTreasureWeightStandard
|
||||||
|
switch {
|
||||||
|
case isBoss:
|
||||||
|
weight = advTreasureWeightBoss
|
||||||
|
case isElite:
|
||||||
|
weight = advTreasureWeightElite
|
||||||
|
}
|
||||||
|
p.rollZoneTreasure(userID, zoneID, weight)
|
||||||
|
|
||||||
|
// Masterwork is an elite/boss reward only — it's a permanent gear
|
||||||
|
// upgrade, not a per-kill trinket.
|
||||||
|
if isBoss || isElite {
|
||||||
|
p.rollZoneMasterwork(userID, zoneID, isBoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumables and ingredients roll on any win, and independently of the
|
||||||
|
// zone slate below — a dry slate roll shouldn't cost the player these.
|
||||||
|
zTier := zoneTierFromID(zoneID)
|
||||||
|
var extra []string
|
||||||
|
if item := RollConsumableDrop(AdvActivityDungeon, zTier); item != nil {
|
||||||
|
if line := p.grantZoneItem(userID, item, "🧪"); line != "" {
|
||||||
|
extra = append(extra, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item := rollZoneIngredient(zTier); item != nil {
|
||||||
|
if line := p.grantZoneItem(userID, item, "🌿"); line != "" {
|
||||||
|
extra = append(extra, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailer := strings.Join(extra, "\n")
|
||||||
|
|
||||||
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return trailer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Magic-item substitution: swap the biome slate item for a registry
|
// Magic-item substitution: swap the biome slate item for a registry
|
||||||
// magic item of the same rarity tier (see magic_items_gameplay.go).
|
// magic item of the same rarity tier (see magic_items_gameplay.go).
|
||||||
if rngFloat(nil) < magicItemDropChance {
|
if rngFloat(nil) < magicItemDropChance {
|
||||||
if mi, miOK := pickMagicItemForRarity(tier.rarity(), nil); miOK {
|
if mi, miOK := pickMagicItemForRarity(tier.rarity(), nil); miOK {
|
||||||
return p.dropMagicItemLoot(userID, mi, tier)
|
return joinLootLines(p.dropMagicItemLoot(userID, mi, tier), trailer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tierVal := tier
|
tierVal := tier
|
||||||
zoneTier := zoneTierFromID(zoneID)
|
|
||||||
item := AdvItem{
|
item := AdvItem{
|
||||||
Name: entry.Name,
|
Name: entry.Name,
|
||||||
Type: entry.ItemType,
|
Type: entry.ItemType,
|
||||||
Tier: zoneTier,
|
Tier: zTier,
|
||||||
Value: int64(entry.BaseValue),
|
Value: int64(entry.BaseValue),
|
||||||
SkillSource: fmt.Sprintf("zone_loot:%s:%s", zoneID, tierVal),
|
SkillSource: fmt.Sprintf("zone_loot:%s:%s", zoneID, tierVal),
|
||||||
}
|
}
|
||||||
if err := addAdvInventoryItem(userID, item); err != nil {
|
if err := addAdvInventoryItem(userID, item); err != nil {
|
||||||
return fmt.Sprintf("_(Loot drop persistence error: %v.)_", err)
|
return joinLootLines(fmt.Sprintf("_(Loot drop persistence error: %v.)_", err), trailer)
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if line := lootFlavorLine(tierVal); line != "" {
|
if line := lootFlavorLine(tierVal); line != "" {
|
||||||
@@ -422,7 +557,19 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster
|
|||||||
b.WriteString(fmt.Sprintf("%s **%s** (%s) — %d coin baseline.",
|
b.WriteString(fmt.Sprintf("%s **%s** (%s) — %d coin baseline.",
|
||||||
icon, entry.Name, tierVal, entry.BaseValue))
|
icon, entry.Name, tierVal, entry.BaseValue))
|
||||||
}
|
}
|
||||||
return b.String()
|
return joinLootLines(b.String(), trailer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinLootLines stitches the zone-slate narration together with the
|
||||||
|
// consumable/ingredient trailer, tolerating either being empty.
|
||||||
|
func joinLootLines(parts ...string) string {
|
||||||
|
var kept []string
|
||||||
|
for _, s := range parts {
|
||||||
|
if s != "" {
|
||||||
|
kept = append(kept, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// dropMagicItemLoot deposits a registry magic item as a combat-victory drop
|
// dropMagicItemLoot deposits a registry magic item as a combat-victory drop
|
||||||
@@ -519,28 +666,28 @@ func zoneItemDescription(zoneID ZoneID, itemName string) string {
|
|||||||
// exemplar — short, in-character, biome-distinctive.
|
// exemplar — short, in-character, biome-distinctive.
|
||||||
var zoneItemFlavor = map[ZoneID]map[string]string{
|
var zoneItemFlavor = map[ZoneID]map[string]string{
|
||||||
ZoneGoblinWarrens: {
|
ZoneGoblinWarrens: {
|
||||||
"Goblin Field Ration": "Wrapped in burlap. Smells like onions, fear, and someone's last meal.",
|
"Goblin Field Ration": "Wrapped in burlap. Smells like onions, fear, and someone's last meal.",
|
||||||
"Stolen Coin Pouch": "Mismatched currency, three different empires represented. The goblins didn't discriminate.",
|
"Stolen Coin Pouch": "Mismatched currency, three different empires represented. The goblins didn't discriminate.",
|
||||||
"Worg-Tooth Trinket": "Strung on sinew. Still has bone fragments embedded in the gum-line.",
|
"Worg-Tooth Trinket": "Strung on sinew. Still has bone fragments embedded in the gum-line.",
|
||||||
"Hobgoblin Captain's Insignia": "Bronze, dented, and unmistakably stolen from somewhere it mattered.",
|
"Hobgoblin Captain's Insignia": "Bronze, dented, and unmistakably stolen from somewhere it mattered.",
|
||||||
"Crude Alchemy Vial": "The label is in goblin shorthand. The contents fizz when you tilt it.",
|
"Crude Alchemy Vial": "The label is in goblin shorthand. The contents fizz when you tilt it.",
|
||||||
"Goblin-Forged Shortblade": "Edge holds. Balance is wrong. Built by hand, not by a smith.",
|
"Goblin-Forged Shortblade": "Edge holds. Balance is wrong. Built by hand, not by a smith.",
|
||||||
"War Standard Banner": "Heraldry of three warbands stitched over each other. The bottom layer is older than the empire.",
|
"War Standard Banner": "Heraldry of three warbands stitched over each other. The bottom layer is older than the empire.",
|
||||||
"Shaman's Carved Stave": "Knotwood, rune-burned. Hums faintly when you stop paying attention to it.",
|
"Shaman's Carved Stave": "Knotwood, rune-burned. Hums faintly when you stop paying attention to it.",
|
||||||
"Hobgoblin General's Cuirass": "Plate-and-leather, embossed with a sigil that's been scraped halfway off and re-stamped.",
|
"Hobgoblin General's Cuirass": "Plate-and-leather, embossed with a sigil that's been scraped halfway off and re-stamped.",
|
||||||
"King Grobnar's Iron Crown": "Three sizes too large for any goblin who ever lived. Whatever wore this first wasn't a goblin.",
|
"King Grobnar's Iron Crown": "Three sizes too large for any goblin who ever lived. Whatever wore this first wasn't a goblin.",
|
||||||
},
|
},
|
||||||
ZoneCryptValdris: {
|
ZoneCryptValdris: {
|
||||||
"Tarnished Burial Coin": "Stamped with a face nobody alive remembers. Heavier than it should be.",
|
"Tarnished Burial Coin": "Stamped with a face nobody alive remembers. Heavier than it should be.",
|
||||||
"Cracked Bone Charm": "Wrist-strung. Whoever wore it didn't make it out.",
|
"Cracked Bone Charm": "Wrist-strung. Whoever wore it didn't make it out.",
|
||||||
"Funeral-Wrap Linen": "Brittle, brown, and still smelling faintly of myrrh.",
|
"Funeral-Wrap Linen": "Brittle, brown, and still smelling faintly of myrrh.",
|
||||||
"Pre-Empire Signet Ring": "The crest pre-dates any standing kingdom. The metal is still warm somehow.",
|
"Pre-Empire Signet Ring": "The crest pre-dates any standing kingdom. The metal is still warm somehow.",
|
||||||
"Necrotic-Etched Dagger": "Black-veined steel. Cold to hold, colder to put down.",
|
"Necrotic-Etched Dagger": "Black-veined steel. Cold to hold, colder to put down.",
|
||||||
"Sealed Funerary Vial": "Wax-stoppered. Whatever's inside has had centuries to think about itself.",
|
"Sealed Funerary Vial": "Wax-stoppered. Whatever's inside has had centuries to think about itself.",
|
||||||
"Valdris Scriptorium Tablet": "Stone, carved in a script that splits opinion among the few scholars who can read it.",
|
"Valdris Scriptorium Tablet": "Stone, carved in a script that splits opinion among the few scholars who can read it.",
|
||||||
"Silver-Inlaid Reliquary": "Empty now. Someone took whatever was inside and didn't replace it.",
|
"Silver-Inlaid Reliquary": "Empty now. Someone took whatever was inside and didn't replace it.",
|
||||||
"Crypt-Lord's Burial Mantle": "Heavy embroidery. The threads aren't thread.",
|
"Crypt-Lord's Burial Mantle": "Heavy embroidery. The threads aren't thread.",
|
||||||
"Valdris's Sealing Sigil": "It hums. The Crypt is older than the empire. This is older than the Crypt.",
|
"Valdris's Sealing Sigil": "It hums. The Crypt is older than the empire. This is older than the Crypt.",
|
||||||
},
|
},
|
||||||
ZoneForestShadows: {
|
ZoneForestShadows: {
|
||||||
"Foraged Trail Loaf": "Dense, dark, and faintly bitter. Travelers' bread, baked under a canopy that doesn't quite let light through.",
|
"Foraged Trail Loaf": "Dense, dark, and faintly bitter. Travelers' bread, baked under a canopy that doesn't quite let light through.",
|
||||||
@@ -555,87 +702,87 @@ var zoneItemFlavor = map[ZoneID]map[string]string{
|
|||||||
"Heart-Wood of the First Grove": "A palm-sized chunk of something that pulses if you hold it long enough. Don't.",
|
"Heart-Wood of the First Grove": "A palm-sized chunk of something that pulses if you hold it long enough. Don't.",
|
||||||
},
|
},
|
||||||
ZoneSunkenTemple: {
|
ZoneSunkenTemple: {
|
||||||
"Salt-Crusted Coin": "The denomination is unreadable but the metal is real silver. Bring it to Thom Krooke; he knows the era.",
|
"Salt-Crusted Coin": "The denomination is unreadable but the metal is real silver. Bring it to Thom Krooke; he knows the era.",
|
||||||
"Pearl Sliver": "Fragment, not a whole pearl. Something cracked it open trying to eat what was inside.",
|
"Pearl Sliver": "Fragment, not a whole pearl. Something cracked it open trying to eat what was inside.",
|
||||||
"Pressure-Sealed Vial": "Cold to touch. Pop the seal and it whistles air for a full second before it stops.",
|
"Pressure-Sealed Vial": "Cold to touch. Pop the seal and it whistles air for a full second before it stops.",
|
||||||
"Kuo-toa Spear-Head": "Bone-and-shell composite. Wickedly barbed. Still smells like the deep.",
|
"Kuo-toa Spear-Head": "Bone-and-shell composite. Wickedly barbed. Still smells like the deep.",
|
||||||
"Coral-Set Pendant": "The coral is bleached except where it touched the wearer's skin.",
|
"Coral-Set Pendant": "The coral is bleached except where it touched the wearer's skin.",
|
||||||
"Tide-Blessed Censer": "Brass, pitted. Still holds a faint trace of incense and old prayer.",
|
"Tide-Blessed Censer": "Brass, pitted. Still holds a faint trace of incense and old prayer.",
|
||||||
"Drowned Acolyte's Tome": "Pages stuck together. The ones that separate cleanly are the ones you should read carefully.",
|
"Drowned Acolyte's Tome": "Pages stuck together. The ones that separate cleanly are the ones you should read carefully.",
|
||||||
"Aboleth-Glass Lens": "Looks ordinary. Look through it and you see things ten feet to the left of where they actually are.",
|
"Aboleth-Glass Lens": "Looks ordinary. Look through it and you see things ten feet to the left of where they actually are.",
|
||||||
"Tide-Caller's Trident Head": "The barbs are folded inward. Whoever forged this didn't want it pulled out clean.",
|
"Tide-Caller's Trident Head": "The barbs are folded inward. Whoever forged this didn't want it pulled out clean.",
|
||||||
"Dar'eth's Drowned Crown": "Coral-encrusted gold. Fits no human skull comfortably.",
|
"Dar'eth's Drowned Crown": "Coral-encrusted gold. Fits no human skull comfortably.",
|
||||||
},
|
},
|
||||||
ZoneManorBlackspire: {
|
ZoneManorBlackspire: {
|
||||||
"Tarnished Silver Spoon": "Estate-marked. The set it belonged to is presumably still in a drawer somewhere upstairs.",
|
"Tarnished Silver Spoon": "Estate-marked. The set it belonged to is presumably still in a drawer somewhere upstairs.",
|
||||||
"Forty-Year-Old Tincture": "Label dated 1486. Still potent. The Blackspires kept good apothecaries.",
|
"Forty-Year-Old Tincture": "Label dated 1486. Still potent. The Blackspires kept good apothecaries.",
|
||||||
"Mourner's Cameo": "A widow's profile, ivory-on-jet. Likeness unknown; mood unmistakable.",
|
"Mourner's Cameo": "A widow's profile, ivory-on-jet. Likeness unknown; mood unmistakable.",
|
||||||
"Vampire-Hunter's Stake Set": "Three stakes, one mallet, all hawthorn. The mallet has been used.",
|
"Vampire-Hunter's Stake Set": "Three stakes, one mallet, all hawthorn. The mallet has been used.",
|
||||||
"Cursed Family Locket": "Don't open it. (You'll open it. They always do.)",
|
"Cursed Family Locket": "Don't open it. (You'll open it. They always do.)",
|
||||||
"Estate-Sealed Wine Bottle": "Wax intact. The label's faded but the vintage is legible — and good.",
|
"Estate-Sealed Wine Bottle": "Wax intact. The label's faded but the vintage is legible — and good.",
|
||||||
"Blackspire Family Diary": "Penmanship is neat for the first hundred pages and then becomes other things.",
|
"Blackspire Family Diary": "Penmanship is neat for the first hundred pages and then becomes other things.",
|
||||||
"Ghost-Iron Candelabrum": "Cold even when lit. The flames lean toward you regardless of where you stand.",
|
"Ghost-Iron Candelabrum": "Cold even when lit. The flames lean toward you regardless of where you stand.",
|
||||||
"Manor-Lord's Funeral Coat": "Tailored. Mourning-black. Smells faintly of formaldehyde and rosewater.",
|
"Manor-Lord's Funeral Coat": "Tailored. Mourning-black. Smells faintly of formaldehyde and rosewater.",
|
||||||
"Blackspire Patriarch's Death Mask": "Plaster, eyes closed. The expression is wrong for someone who died in their sleep.",
|
"Blackspire Patriarch's Death Mask": "Plaster, eyes closed. The expression is wrong for someone who died in their sleep.",
|
||||||
},
|
},
|
||||||
ZoneUnderforge: {
|
ZoneUnderforge: {
|
||||||
"Forge-Slag Trinket": "A cooled drip of something that used to be molten. Dwarves keep these. Nobody knows why.",
|
"Forge-Slag Trinket": "A cooled drip of something that used to be molten. Dwarves keep these. Nobody knows why.",
|
||||||
"Iron-Filing Pouch": "Heavy for its size. Magnetic. Useful, somehow, to someone, eventually.",
|
"Iron-Filing Pouch": "Heavy for its size. Magnetic. Useful, somehow, to someone, eventually.",
|
||||||
"Dwarven Hardtack": "You could break a tooth on this. The dwarves who made it would consider that a feature.",
|
"Dwarven Hardtack": "You could break a tooth on this. The dwarves who made it would consider that a feature.",
|
||||||
"Azer-Forged Hammer Head": "Still warm. Will be warm tomorrow. Will be warm a hundred years from now.",
|
"Azer-Forged Hammer Head": "Still warm. Will be warm tomorrow. Will be warm a hundred years from now.",
|
||||||
"Heat-Etched Sigil Plate": "The runes were burned in by hand. The hand that did it was not a hand that minded heat.",
|
"Heat-Etched Sigil Plate": "The runes were burned in by hand. The hand that did it was not a hand that minded heat.",
|
||||||
"Salamander-Oil Flask": "Sealed in glass. The oil ripples even when nothing else does.",
|
"Salamander-Oil Flask": "Sealed in glass. The oil ripples even when nothing else does.",
|
||||||
"Master-Smith's Ledger Page": "Old dwarven shorthand. Inventory of a forge that stopped recording entries the year the elementals woke up.",
|
"Master-Smith's Ledger Page": "Old dwarven shorthand. Inventory of a forge that stopped recording entries the year the elementals woke up.",
|
||||||
"Mithral-Thread Gauntlet": "Light enough to be wrong. Sharper than it has any right to be along the knuckles.",
|
"Mithral-Thread Gauntlet": "Light enough to be wrong. Sharper than it has any right to be along the knuckles.",
|
||||||
"Forge-Marshal's Anvil-Charm": "A miniature anvil on a chain. Heavy enough to be impractical. Worn anyway.",
|
"Forge-Marshal's Anvil-Charm": "A miniature anvil on a chain. Heavy enough to be impractical. Worn anyway.",
|
||||||
"The First Hammer's Striking Head": "The dwarven creation-myths name this. They don't agree on which dwarf swung it.",
|
"The First Hammer's Striking Head": "The dwarven creation-myths name this. They don't agree on which dwarf swung it.",
|
||||||
},
|
},
|
||||||
ZoneUnderdark: {
|
ZoneUnderdark: {
|
||||||
"Cave-Spider Silk Skein": "Wound on a bone-spool. Tensile strength embarrassing to surface looms.",
|
"Cave-Spider Silk Skein": "Wound on a bone-spool. Tensile strength embarrassing to surface looms.",
|
||||||
"Dim-Glow Mushroom Bundle": "Tied with hair. Edible. Slightly hallucinogenic. Drow eat them as a snack.",
|
"Dim-Glow Mushroom Bundle": "Tied with hair. Edible. Slightly hallucinogenic. Drow eat them as a snack.",
|
||||||
"Drow-Worked Coin": "Etched on both sides. The faces are not faces.",
|
"Drow-Worked Coin": "Etched on both sides. The faces are not faces.",
|
||||||
"Drow Hand-Crossbow Bolt Case": "Six bolts. Two are tipped with something dark. Two are tipped with something darker. Two are bare.",
|
"Drow Hand-Crossbow Bolt Case": "Six bolts. Two are tipped with something dark. Two are tipped with something darker. Two are bare.",
|
||||||
"Faerzress-Etched Charm": "Magic-disrupting. Useful as a hex-breaker; useless inside an active wardline.",
|
"Faerzress-Etched Charm": "Magic-disrupting. Useful as a hex-breaker; useless inside an active wardline.",
|
||||||
"Diluted Drow Sleep Vial": "House-watered for trade with the surface. Still drops a person flat in twenty seconds.",
|
"Diluted Drow Sleep Vial": "House-watered for trade with the surface. Still drops a person flat in twenty seconds.",
|
||||||
"Mind Flayer Codex Page": "The script reads itself into your head if you stare too long. Don't.",
|
"Mind Flayer Codex Page": "The script reads itself into your head if you stare too long. Don't.",
|
||||||
"Drow-Adamantine Bracer": "Forged in fae-fire. Black with a violet undersheen. Doesn't tarnish.",
|
"Drow-Adamantine Bracer": "Forged in fae-fire. Black with a violet undersheen. Doesn't tarnish.",
|
||||||
"Matron's House-Sigil Brooch": "Eight-legged crest. The matron who wore it is presumably no longer wearing it.",
|
"Matron's House-Sigil Brooch": "Eight-legged crest. The matron who wore it is presumably no longer wearing it.",
|
||||||
"Eyeless King's Skull-Crown": "Bone-set, with sockets where stones should go. Worn smooth by something patient.",
|
"Eyeless King's Skull-Crown": "Bone-set, with sockets where stones should go. Worn smooth by something patient.",
|
||||||
},
|
},
|
||||||
ZoneFeywildCrossing: {
|
ZoneFeywildCrossing: {
|
||||||
"Pressed Fey Petal": "Color shifts when you blink. Smells like a season you don't have a name for.",
|
"Pressed Fey Petal": "Color shifts when you blink. Smells like a season you don't have a name for.",
|
||||||
"Honey-Wax Candle": "Burns blue. The wax pools upward.",
|
"Honey-Wax Candle": "Burns blue. The wax pools upward.",
|
||||||
"Dream-Spun Thread": "Spider silk except it sings if you pluck it.",
|
"Dream-Spun Thread": "Spider silk except it sings if you pluck it.",
|
||||||
"Pixie-Wing Glassine": "Pressed wing-membrane between two slips of glass. Iridescence depends on who is watching.",
|
"Pixie-Wing Glassine": "Pressed wing-membrane between two slips of glass. Iridescence depends on who is watching.",
|
||||||
"Wisp-Lantern Charm": "Glows softly when held; goes dark when set down. Doesn't ask permission to do either.",
|
"Wisp-Lantern Charm": "Glows softly when held; goes dark when set down. Doesn't ask permission to do either.",
|
||||||
"Hag-Brewed Bitter Cordial": "Black, syrupy, and tastes exactly like one specific regret.",
|
"Hag-Brewed Bitter Cordial": "Black, syrupy, and tastes exactly like one specific regret.",
|
||||||
"Time-Locked Music Box": "Plays a tune that's three notes longer than the box should physically be able to hold.",
|
"Time-Locked Music Box": "Plays a tune that's three notes longer than the box should physically be able to hold.",
|
||||||
"Fey Court Invitation Scroll": "Sealed in honeycomb wax. The invitation is for a party that hasn't happened yet, or already did.",
|
"Fey Court Invitation Scroll": "Sealed in honeycomb wax. The invitation is for a party that hasn't happened yet, or already did.",
|
||||||
"Summer-Knight's Thorn Buckler": "Thorn-bossed, briar-rimmed. The thorns flex when struck and then re-form.",
|
"Summer-Knight's Thorn Buckler": "Thorn-bossed, briar-rimmed. The thorns flex when struck and then re-form.",
|
||||||
"Thornmother's Heart-Thorn Reliquary": "Sealed bronze, briar-wrapped. The thorn inside still pulses on a slow rhythm.",
|
"Thornmother's Heart-Thorn Reliquary": "Sealed bronze, briar-wrapped. The thorn inside still pulses on a slow rhythm.",
|
||||||
},
|
},
|
||||||
ZoneDragonsLair: {
|
ZoneDragonsLair: {
|
||||||
"Half-Melted Gold Coin": "Slumped flat by heat. Still gold. Still legal tender if Thom Krooke can read the year.",
|
"Half-Melted Gold Coin": "Slumped flat by heat. Still gold. Still legal tender if Thom Krooke can read the year.",
|
||||||
"Volcanic Glass Sliver": "Sharper than steel and a quarter the weight. Edges chip on impact.",
|
"Volcanic Glass Sliver": "Sharper than steel and a quarter the weight. Edges chip on impact.",
|
||||||
"Kobold Field Pouch": "Trail rations, a fire-starter, two prayer-beads to a god named Kurtulmak.",
|
"Kobold Field Pouch": "Trail rations, a fire-starter, two prayer-beads to a god named Kurtulmak.",
|
||||||
"Drake-Scale Bracer": "Overlapping scales, still attached to a strip of original drake-hide. Heat-resistant.",
|
"Drake-Scale Bracer": "Overlapping scales, still attached to a strip of original drake-hide. Heat-resistant.",
|
||||||
"Kobold Trap-Mechanism": "Pre-built, single-use, and surprisingly clever. Read the inscribed instructions before activating.",
|
"Kobold Trap-Mechanism": "Pre-built, single-use, and surprisingly clever. Read the inscribed instructions before activating.",
|
||||||
"Drake-Blood Cordial": "Distilled fire. Burns going down. Fire-resistance for an hour and a hangover for a day.",
|
"Drake-Blood Cordial": "Distilled fire. Burns going down. Fire-resistance for an hour and a hangover for a day.",
|
||||||
"Hoard-Inventory Page": "An accountant's record of items the dragon owns. The dragon does not know it lost this page.",
|
"Hoard-Inventory Page": "An accountant's record of items the dragon owns. The dragon does not know it lost this page.",
|
||||||
"Dragonfire-Forged Spike": "Blackened steel, never cools. Driven into stone with the hilt-end first.",
|
"Dragonfire-Forged Spike": "Blackened steel, never cools. Driven into stone with the hilt-end first.",
|
||||||
"Wyrmguard's Cinder-Cloak Clasp": "Brass-and-obsidian. The clasp is dragonshape. The wearer was, briefly, a problem.",
|
"Wyrmguard's Cinder-Cloak Clasp": "Brass-and-obsidian. The clasp is dragonshape. The wearer was, briefly, a problem.",
|
||||||
"Infernax's Tooth-Reliquary": "A single tooth, mounted in gold-and-bone. The dragon is younger by exactly this tooth.",
|
"Infernax's Tooth-Reliquary": "A single tooth, mounted in gold-and-bone. The dragon is younger by exactly this tooth.",
|
||||||
},
|
},
|
||||||
ZoneAbyssPortal: {
|
ZoneAbyssPortal: {
|
||||||
"Brimstone-Caked Coin": "Sulfur-yellow at the edges. Burns the nose if you sniff it. (You won't sniff it twice.)",
|
"Brimstone-Caked Coin": "Sulfur-yellow at the edges. Burns the nose if you sniff it. (You won't sniff it twice.)",
|
||||||
"Sulfur-Wax Stub": "Half-burned demon-tallow. Doesn't smell like tallow.",
|
"Sulfur-Wax Stub": "Half-burned demon-tallow. Doesn't smell like tallow.",
|
||||||
"Demon-Ichor Smear-Vial": "Stoppered tight. The contents move on their own when you turn away.",
|
"Demon-Ichor Smear-Vial": "Stoppered tight. The contents move on their own when you turn away.",
|
||||||
"Quasit-Bound Ring": "Tiny, plain, and humming. Don't put it on. There's something in there.",
|
"Quasit-Bound Ring": "Tiny, plain, and humming. Don't put it on. There's something in there.",
|
||||||
"Corrupted-Metal Buckle": "Was steel once. Is something else now. The shape held; the metal didn't.",
|
"Corrupted-Metal Buckle": "Was steel once. Is something else now. The shape held; the metal didn't.",
|
||||||
"Planar-Shard Pendant": "Looks like a fragment of a much larger reality. Held wrong, it cuts where there's nothing to cut.",
|
"Planar-Shard Pendant": "Looks like a fragment of a much larger reality. Held wrong, it cuts where there's nothing to cut.",
|
||||||
"Abyssal-Marked Codex Sheet": "Vellum, demon-scribed. The script reads in a voice that isn't yours.",
|
"Abyssal-Marked Codex Sheet": "Vellum, demon-scribed. The script reads in a voice that isn't yours.",
|
||||||
"Marilith-Steel Edge": "Six-bladed weapon-fragment, broken from a longer edge. The break is centuries old. The edge is sharp.",
|
"Marilith-Steel Edge": "Six-bladed weapon-fragment, broken from a longer edge. The break is centuries old. The edge is sharp.",
|
||||||
"Nalfeshnee's Honor-Sigil": "A demon's idea of dignity. Brass, blood-stained, embossed with a name you can't pronounce.",
|
"Nalfeshnee's Honor-Sigil": "A demon's idea of dignity. Brass, blood-stained, embossed with a name you can't pronounce.",
|
||||||
"Belaxath's Bound Truename Tablet": "Stone, demon-runed. The truename is bound by inscription. Read it and the binding holds. Speak it and it doesn't.",
|
"Belaxath's Bound Truename Tablet": "Stone, demon-runed. The truename is bound by inscription. Read it and the binding holds. Speak it and it doesn't.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user