From 83cdf073743243953b017082f683fd4e5037818c Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 17:34:49 -0700 Subject: [PATCH] Adv 2.0 D&D Phase R R2: Harvest nodes & per-room registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-zone resource registry (§3, all 10 zones) and full per-room harvest layer wired into expeditions: !forage / !mine / !scavenge / !essence / !commune / !resources. Auto-spawns a DungeonRun per region on !expedition start, swaps it on !region travel, retires on abandon/extract. Long rest at camp replenishes nodes across every room and region. Reuses existing flavor.Harvest* / RichYield / NodeDepleted / per-zone Harvest pools. Also clears two pre-existing test failures introduced by the E6c flavor expansion (briefing substring list + combat-lift trial count). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/adventure.go | 24 + internal/plugin/dnd_equipment.go | 1 + internal/plugin/dnd_expedition.go | 11 + internal/plugin/dnd_expedition_camp.go | 1 + internal/plugin/dnd_expedition_cmd.go | 12 + internal/plugin/dnd_expedition_cycle_test.go | 8 +- internal/plugin/dnd_expedition_extract.go | 14 + internal/plugin/dnd_expedition_harvest.go | 732 ++++++++++++++++++ .../plugin/dnd_expedition_harvest_test.go | 300 +++++++ internal/plugin/dnd_expedition_region_cmd.go | 15 + internal/plugin/dnd_resource_registry.go | 175 +++++ internal/plugin/dnd_subclass_combat_test.go | 9 +- internal/plugin/dnd_zone_run.go | 18 + 13 files changed, 1314 insertions(+), 6 deletions(-) create mode 100644 internal/plugin/dnd_expedition_harvest.go create mode 100644 internal/plugin/dnd_expedition_harvest_test.go create mode 100644 internal/plugin/dnd_resource_registry.go diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index fc26556..3bceabb 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -134,6 +134,12 @@ func (p *AdventurePlugin) Commands() []CommandDef { {Name: "cast", Description: "Cast an Adv 2.0 spell (queues for next combat, or resolves out-of-combat)", Usage: "!cast [--upcast N] [--target @user]", Category: "Games"}, {Name: "spells", Description: "List your known spells, prepared spells, and spell slots", Usage: "!spells [learn ]", Category: "Games"}, {Name: "prepare", Description: "Cleric: prepare a spell for the day (cap = WIS mod + level)", Usage: "!prepare | !prepare clear ", Category: "Games"}, + {Name: "forage", Description: "Forage in your current expedition region (WIS / Survival)", Usage: "!forage", Category: "Games"}, + {Name: "mine", Description: "Mine or salvage in your current expedition region (STR / Athletics)", Usage: "!mine", Category: "Games"}, + {Name: "scavenge", Description: "Scavenge in your current expedition region (INT / Investigation)", Usage: "!scavenge", Category: "Games"}, + {Name: "essence", Description: "Harvest magical essence in your current expedition region (INT / Arcana)", Usage: "!essence", Category: "Games"}, + {Name: "commune", Description: "Commune with spiritual energy in your current expedition region (Cleric primary)", Usage: "!commune", Category: "Games"}, + {Name: "resources", Description: "List harvestable resources in your current expedition region", Usage: "!resources", Category: "Games"}, } } @@ -273,6 +279,24 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "map") { return p.handleExpeditionMapCmd(ctx, p.GetArgs(ctx.Body, "map")) } + if p.IsCommand(ctx.Body, "forage") { + return p.handleHarvestCmd(ctx, HarvestForage) + } + if p.IsCommand(ctx.Body, "mine") { + return p.handleHarvestCmd(ctx, HarvestMine) + } + if p.IsCommand(ctx.Body, "scavenge") { + return p.handleHarvestCmd(ctx, HarvestScavenge) + } + if p.IsCommand(ctx.Body, "essence") { + return p.handleHarvestCmd(ctx, HarvestEssence) + } + if p.IsCommand(ctx.Body, "commune") { + return p.handleHarvestCmd(ctx, HarvestCommune) + } + if p.IsCommand(ctx.Body, "resources") { + return p.handleResourcesCmd(ctx) + } // 1. Arena commands (work in rooms and DMs) if p.IsCommand(ctx.Body, "bail") { diff --git a/internal/plugin/dnd_equipment.go b/internal/plugin/dnd_equipment.go index ec4cbb7..1c89890 100644 --- a/internal/plugin/dnd_equipment.go +++ b/internal/plugin/dnd_equipment.go @@ -64,6 +64,7 @@ const ( RarityUncommon DnDRarity = "Uncommon" RarityRare DnDRarity = "Rare" RarityEpic DnDRarity = "Epic" + RarityVeryRare DnDRarity = "VeryRare" RarityLegendary DnDRarity = "Legendary" ) diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index c4ab5f8..e03e253 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -312,6 +312,17 @@ func updateCamp(expID string, c *CampState) error { return err } +// setExpeditionRunID writes the expedition's linked zone-run pointer. +// Used during region transitions and on initial spawn (R2). +func setExpeditionRunID(expID, runID string) error { + _, err := db.Get().Exec(` + UPDATE dnd_expedition + SET run_id = ?, + last_activity = CURRENT_TIMESTAMP + WHERE expedition_id = ?`, nullableString(runID), expID) + return err +} + // abandonExpedition flags the active expedition as abandoned. Idempotent. func abandonExpedition(userID id.UserID) error { e, err := getActiveExpedition(userID) diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 00367a2..44f4cff 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -274,6 +274,7 @@ func processOvernightCamp(e *Expedition) string { if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase { _ = refreshAllResources(uid) _ = refreshSpellSlots(uid) + _ = ReplenishHarvestNodes(e) } // Threat reduction (§8.1: -5 for fortified long rest). diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 67c6e0f..543e6ab 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -196,6 +196,17 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter return p.SendDM(ctx.Sender, "Couldn't start expedition: "+err.Error()) } + // R2 — auto-spawn the DungeonRun for the starting region. Per-room + // harvest depends on this; the existing zone-run !advance/!retreat + // commands now operate on this run while the expedition is active. + if _, err := ensureRegionRun(exp, c.Level); err != nil { + // Refund and tear the expedition row back down — without a + // linked run, harvest and rooms can't function. + _ = abandonExpedition(ctx.Sender) + p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund (run-spawn failed)") + return p.SendDM(ctx.Sender, "Couldn't outfit the first region: "+err.Error()) + } + // Log the start with prewritten flavor. startLine := flavor.Pick(flavor.ExpeditionStart) _ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine) @@ -375,6 +386,7 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error { if err := abandonExpedition(ctx.Sender); err != nil { return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) } + _ = retireAllRegionRuns(exp) _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") return p.SendDM(ctx.Sender, fmt.Sprintf( "Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.", diff --git a/internal/plugin/dnd_expedition_cycle_test.go b/internal/plugin/dnd_expedition_cycle_test.go index 863d149..b81e16e 100644 --- a/internal/plugin/dnd_expedition_cycle_test.go +++ b/internal/plugin/dnd_expedition_cycle_test.go @@ -19,11 +19,11 @@ func TestPickMorningBriefing_DayBands(t *testing.T) { day int want []string // any-of pool }{ - {1, []string{"First morning", "Day one complete"}}, - {3, []string{"Day three", "Three days in"}}, + {1, []string{"First morning", "Day one", "day two"}}, + {3, []string{"Day three", "Three days"}}, {7, []string{"One week", "Seven days", "A week underground"}}, - {14, []string{"Two weeks", "Fourteen days"}}, - {21, []string{"Three weeks"}}, + {14, []string{"Two weeks", "Fourteen days", "Day fifteen"}}, + {21, []string{"Three weeks", "Day twenty-one"}}, } for _, c := range cases { got := pickMorningBriefing(c.day) diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 3cadcf1..6b6f480 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -63,6 +63,7 @@ func voluntaryExtractExpedition(userID id.UserID) (*Expedition, error) { } e.CurrentDay++ e.Status = ExpeditionStatusExtracting + _ = retireAllRegionRuns(e) return e, nil } @@ -87,6 +88,7 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) { return nil, 0, fmt.Errorf("forced extract: %w", err) } e.Status = ExpeditionStatusAbandoned + _ = retireAllRegionRuns(e) return e, tax, nil } @@ -223,6 +225,18 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error p.euro.Credit(ctx.Sender, cost, "expedition resume refund") return p.SendDM(ctx.Sender, "Couldn't resume: "+err.Error()) } + exp.Status = ExpeditionStatusActive + exp.Supplies = supplies + // Spawn a fresh DungeonRun for the resumed region; the prior run was + // abandoned at extract time. Per-room harvest state in RegionState is + // preserved as-is so progression isn't reset. + exp.RunID = "" + exp.RegionState[regionStateRegionRuns] = map[string]string{} + _ = persistRegionState(exp) + if _, err := ensureRegionRun(exp, c.Level); err != nil { + p.euro.Credit(ctx.Sender, cost, "expedition resume refund (run-spawn failed)") + return p.SendDM(ctx.Sender, "Couldn't outfit the resumed region: "+err.Error()) + } line := flavor.Pick(flavor.ExpeditionResume) _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition resumed", line) diff --git a/internal/plugin/dnd_expedition_harvest.go b/internal/plugin/dnd_expedition_harvest.go new file mode 100644 index 0000000..7744ac8 --- /dev/null +++ b/internal/plugin/dnd_expedition_harvest.go @@ -0,0 +1,732 @@ +package plugin + +// Phase R2 — Expedition harvest nodes & commands (full implementation). +// Spec: gogobee_resource_combat_integration.md §2 + §3. +// +// Architecture: +// - On !expedition start, ensureRegionRun spawns a DungeonRun for the +// current region and pins it via Expedition.RunID. Multi-region zones +// get one run per region, indexed in RegionState["region_runs"]. +// - On !region travel, the outgoing region's run is abandoned and a +// fresh run is spawned for the new region. +// - HarvestNode tables are keyed by (regionKey, roomIdx) under +// RegionState["harvest_nodes"]. Each room's nodes are lazy-seeded +// from the §3 zone registry on first harvest in that room. +// - Harvest only succeeds in cleared rooms (entry rooms auto-pass; +// non-entry require roomIdx ∈ DungeonRun.RoomsCleared). +// - Long rest at camp replenishes every node across every room and +// region (§2.3). +// +// Deferred to R3 (combat-link): +// - Combat Interrupt rolls (§4.2) +// - RequiresKill resources (writer for RegionState["kills"] not yet wired) + +import ( + "encoding/json" + "fmt" + "math/rand/v2" + "strconv" + "strings" + + "gogobee/internal/flavor" + "maunium.net/go/mautrix/id" +) + +// RegionState slot keys used by the harvest layer. +// +// RegionState["harvest_nodes"][regionKey][roomKey] = []HarvestNode +// RegionState["region_runs"][regionKey] = runID string +const ( + regionStateHarvestKey = "harvest_nodes" + regionStateRegionRuns = "region_runs" +) + +// HarvestNode is one resource node in a specific room. +type HarvestNode struct { + ResourceID string `json:"resource_id"` + CurrentCharges int `json:"current_charges"` + MaxCharges int `json:"max_charges"` +} + +// HarvestOutcome enumerates §2.2 outcomes. +type HarvestOutcome string + +const ( + OutcomeNothing HarvestOutcome = "nothing" + OutcomeCommon HarvestOutcome = "common" + OutcomeStandard HarvestOutcome = "standard" + OutcomeRich HarvestOutcome = "rich" +) + +// resolveHarvestOutcome maps (roll, dc) to the §2.2 bracket. +func resolveHarvestOutcome(roll, dc int) HarvestOutcome { + delta := roll - dc + switch { + case delta >= 5: + return OutcomeRich + case delta >= 0: + return OutcomeStandard + case delta >= -4: + return OutcomeCommon + default: + return OutcomeNothing + } +} + +// classHarvestBonus implements §2.1 +2 class affinity. +func classHarvestBonus(class DnDClass, action HarvestAction) int { + match := false + switch action { + case HarvestForage, HarvestFish: + match = class == ClassRanger + case HarvestMine: + match = class == ClassFighter + case HarvestScavenge: + match = class == ClassRogue + case HarvestEssence: + match = class == ClassMage + case HarvestCommune: + match = class == ClassCleric + } + if match { + return 2 + } + return 0 +} + +// harvestActionAbility returns the §2.1 ability modifier for `action`. +func harvestActionAbility(c *DnDCharacter, action HarvestAction) int { + switch action { + case HarvestForage: + return abilityModifier(c.WIS) + case HarvestMine: + return abilityModifier(c.STR) + case HarvestScavenge: + return abilityModifier(c.INT) + case HarvestEssence: + return abilityModifier(c.INT) + case HarvestCommune: + return abilityModifier(c.WIS) + case HarvestFish: + return abilityModifier(c.DEX) + } + return 0 +} + +// regionHarvestKey returns the storage key for the player's current region. +// Single-region zones store under "". +func regionHarvestKey(e *Expedition) string { return e.CurrentRegion } + +// roomHarvestKey returns the JSON-safe key for a 0-based room index. +func roomHarvestKey(roomIdx int) string { return strconv.Itoa(roomIdx) } + +// loadHarvestNodes returns the (region, room)'s nodes, lazy-seeding from +// the §3 registry on first access. Caller must persist via +// saveHarvestNodes after mutation. +func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode { + if e == nil { + return nil + } + if e.RegionState == nil { + e.RegionState = map[string]any{} + } + table := loadHarvestTable(e) + regionKey := regionHarvestKey(e) + roomKey := roomHarvestKey(roomIdx) + + regionMap, ok := table[regionKey] + if !ok { + regionMap = map[string][]HarvestNode{} + table[regionKey] = regionMap + } + if existing, ok := regionMap[roomKey]; ok { + return existing + } + seeded := seedRoomNodes(e.ZoneID) + regionMap[roomKey] = seeded + table[regionKey] = regionMap + e.RegionState[regionStateHarvestKey] = table + return seeded +} + +// saveHarvestNodes writes the supplied node slice into the (region, room) +// slot and persists the expedition state. +func saveHarvestNodes(e *Expedition, roomIdx int, nodes []HarvestNode) error { + if e.RegionState == nil { + e.RegionState = map[string]any{} + } + table := loadHarvestTable(e) + regionKey := regionHarvestKey(e) + roomKey := roomHarvestKey(roomIdx) + regionMap, ok := table[regionKey] + if !ok { + regionMap = map[string][]HarvestNode{} + } + regionMap[roomKey] = nodes + table[regionKey] = regionMap + e.RegionState[regionStateHarvestKey] = table + return persistRegionState(e) +} + +// loadHarvestTable parses RegionState["harvest_nodes"] into the typed +// nested-map shape, regardless of how JSON unmarshalled it. +func loadHarvestTable(e *Expedition) map[string]map[string][]HarvestNode { + table := map[string]map[string][]HarvestNode{} + if e == nil || e.RegionState == nil { + return table + } + raw, ok := e.RegionState[regionStateHarvestKey] + if !ok { + return table + } + switch v := raw.(type) { + case map[string]map[string][]HarvestNode: + return v + default: + // JSON path: re-marshal the generic shape and re-parse into the + // typed nested shape. + b, err := json.Marshal(v) + if err != nil { + return table + } + _ = json.Unmarshal(b, &table) + } + return table +} + +// seedRoomNodes constructs the initial node set for a single room from +// the zone registry. Each ZoneResource becomes one node sized to its +// MaxCharges entry. +func seedRoomNodes(zoneID ZoneID) []HarvestNode { + all := ZoneResourcesAll(zoneID) + nodes := make([]HarvestNode, 0, len(all)) + for _, r := range all { + max := r.MaxCharges + if max <= 0 { + max = 1 + } + nodes = append(nodes, HarvestNode{ + ResourceID: r.ID, + CurrentCharges: max, + MaxCharges: max, + }) + } + return nodes +} + +// ReplenishHarvestNodes resets every node's CurrentCharges across every +// (region, room) tracked under this expedition. Called from the long-rest +// path (processOvernightCamp). +func ReplenishHarvestNodes(e *Expedition) error { + if e == nil { + return nil + } + table := loadHarvestTable(e) + if len(table) == 0 { + return nil + } + for regionKey, regionMap := range table { + for roomKey, nodes := range regionMap { + for i := range nodes { + nodes[i].CurrentCharges = nodes[i].MaxCharges + } + regionMap[roomKey] = nodes + } + table[regionKey] = regionMap + } + if e.RegionState == nil { + e.RegionState = map[string]any{} + } + e.RegionState[regionStateHarvestKey] = table + return persistRegionState(e) +} + +// regionKillsContains reports whether RegionState["kills"][region] holds +// `kind`. Combat-link writes this list (R3); until then it's empty. +func regionKillsContains(e *Expedition, kind string) bool { + if e == nil || e.RegionState == nil || kind == "" { + return false + } + raw, ok := e.RegionState["kills"] + if !ok { + return false + } + table := map[string][]string{} + switch v := raw.(type) { + case map[string][]string: + table = v + case map[string]any: + if b, err := json.Marshal(v); err == nil { + _ = json.Unmarshal(b, &table) + } + default: + return false + } + for _, k := range table[regionHarvestKey(e)] { + if k == kind { + return true + } + } + return false +} + +// pickAvailableNode picks the lowest-DC eligible node matching `action` +// in `nodes`. Returns the node index or -1 if none usable. +func pickAvailableNode(nodes []HarvestNode, e *Expedition, char *DnDCharacter, action HarvestAction) int { + bestIdx := -1 + bestDC := 1<<31 - 1 + for i := range nodes { + n := &nodes[i] + if n.CurrentCharges <= 0 { + continue + } + res, ok := FindZoneResource(e.ZoneID, n.ResourceID) + if !ok || res.Action != action { + continue + } + if res.ClassRestrict != "" && res.ClassRestrict != char.Class { + continue + } + if res.RequiresKill != "" && !regionKillsContains(e, res.RequiresKill) { + continue + } + if res.ConditionalEvent != "" && !regionEventActive(e, res.ConditionalEvent) { + continue + } + if res.DC < bestDC { + bestDC = res.DC + bestIdx = i + } + } + return bestIdx +} + +// regionEventActive checks RegionState for an event flag (e.g. tidal_event). +func regionEventActive(e *Expedition, eventID string) bool { + if e == nil || e.RegionState == nil { + return false + } + switch eventID { + case "tidal_event": + v, _ := e.RegionState["tidal_active"].(bool) + return v + } + return false +} + +// ── room-state helpers ────────────────────────────────────────────────────── + +// currentRoomCleared reports whether the player's current room is safe +// for harvest. Entry rooms auto-clear; otherwise the index must be in +// run.RoomsCleared. +func currentRoomCleared(run *DungeonRun) bool { + if run == nil { + return false + } + if run.CurrentRoomType() == RoomEntry { + return true + } + for _, idx := range run.RoomsCleared { + if idx == run.CurrentRoom { + return true + } + } + return false +} + +// ── command surface ──────────────────────────────────────────────────────── + +// handleHarvestCmd is the shared dispatcher for !forage/!mine/!scavenge/ +// !essence/!commune. Resolves a single attempt and DMs the outcome. +func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAction) error { + exp, err := getActiveExpedition(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't load expedition state.") + } + if exp == nil { + return p.SendDM(ctx.Sender, "You're not on an expedition. Start one with `!expedition start `.") + } + char, err := LoadDnDCharacter(ctx.Sender) + if err != nil || char == nil { + return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.") + } + if exp.RunID == "" { + return p.SendDM(ctx.Sender, "No active region run. Try `!region` to refresh, or restart the expedition.") + } + run, err := getZoneRun(exp.RunID) + if err != nil || run == nil { + return p.SendDM(ctx.Sender, "Couldn't load region state.") + } + if !currentRoomCleared(run) { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You can't harvest here — the room (%s) isn't cleared. Resolve combat first.", + prettyRoomType(run.CurrentRoomType()))) + } + + roomIdx := run.CurrentRoom + nodes := loadHarvestNodes(exp, roomIdx) + idx := pickAvailableNode(nodes, exp, char, action) + if idx < 0 { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "Nothing left to %s in this room. Try `!resources` to see what's still gatherable.", action)) + } + + res, _ := FindZoneResource(exp.ZoneID, nodes[idx].ResourceID) + roll := rand.IntN(20) + 1 + mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action) + total := roll + mod + outcome := resolveHarvestOutcome(total, res.DC) + + var b strings.Builder + verb := strings.ToTitle(string(action)[:1]) + string(action)[1:] + b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d\n\n", + verb, res.Name, roll, mod, total, res.DC)) + + switch outcome { + case OutcomeNothing: + b.WriteString(flavor.Pick(flavor.HarvestFail)) + b.WriteString("\n\n_No yield. The node is still here — try again._") + case OutcomeCommon: + _ = grantHarvestYield(ctx.Sender, res, 1) + b.WriteString(harvestSuccessLine(action, exp.ZoneID)) + b.WriteString(fmt.Sprintf("\n\n_+1 %s._", res.Name)) + nodes[idx].CurrentCharges-- + case OutcomeStandard: + qty := rand.IntN(3) + 1 + _ = grantHarvestYield(ctx.Sender, res, qty) + b.WriteString(harvestSuccessLine(action, exp.ZoneID)) + b.WriteString(fmt.Sprintf("\n\n_+%d %s._", qty, res.Name)) + nodes[idx].CurrentCharges-- + case OutcomeRich: + qty := rand.IntN(3) + 1 + _ = grantHarvestYield(ctx.Sender, res, qty) + bonus := pickRichBonus(exp.ZoneID, res.ID) + bonusLine := "" + if bonus != nil { + _ = grantHarvestYield(ctx.Sender, *bonus, 1) + bonusLine = fmt.Sprintf(" + 1 %s (bonus)", bonus.Name) + } + b.WriteString(flavor.Pick(flavor.RichYield)) + b.WriteString(fmt.Sprintf("\n\n_+%d %s%s._", qty, res.Name, bonusLine)) + nodes[idx].CurrentCharges-- + } + + if outcome != OutcomeNothing && nodes[idx].CurrentCharges <= 0 { + b.WriteString("\n\n") + b.WriteString(flavor.Pick(flavor.NodeDepleted)) + } + + if err := saveHarvestNodes(exp, roomIdx, nodes); err != nil { + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest", + fmt.Sprintf("persistence error: %v", err), "") + } + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest", + fmt.Sprintf("%s %s d20=%d total=%d dc=%d → %s", + string(action), res.ID, roll, total, res.DC, string(outcome)), "") + + return p.SendDM(ctx.Sender, b.String()) +} + +// harvestSuccessLine picks the action's generic success pool and +// occasionally layers a zone-specific Harvest* postscript. +func harvestSuccessLine(action HarvestAction, zoneID ZoneID) string { + var generic []string + switch action { + case HarvestForage: + generic = flavor.HarvestForageSuccess + case HarvestMine: + generic = flavor.HarvestMineSuccess + case HarvestScavenge: + generic = flavor.HarvestScavengeSuccess + case HarvestEssence: + generic = flavor.HarvestEssenceSuccess + case HarvestCommune: + generic = flavor.HarvestCommuneSuccess + case HarvestFish: + generic = flavor.HarvestFishSuccess + } + line := flavor.Pick(generic) + if rand.IntN(3) == 0 { + if zoneLine := zoneHarvestLine(zoneID); zoneLine != "" { + return line + " " + zoneLine + } + } + return line +} + +// zoneHarvestLine picks one entry from the zone-specific Harvest* pool. +func zoneHarvestLine(zoneID ZoneID) string { + switch zoneID { + case ZoneGoblinWarrens: + return flavor.Pick(flavor.HarvestGoblinWarrens) + case ZoneCryptValdris: + return flavor.Pick(flavor.HarvestCryptValdris) + case ZoneForestShadows: + return flavor.Pick(flavor.HarvestForestShadows) + case ZoneSunkenTemple: + return flavor.Pick(flavor.HarvestSunkenTemple) + case ZoneManorBlackspire: + return flavor.Pick(flavor.HarvestHauntedManor) + case ZoneUnderforge: + return flavor.Pick(flavor.HarvestUnderforge) + case ZoneUnderdark: + return flavor.Pick(flavor.HarvestUnderdark) + case ZoneFeywildCrossing: + return flavor.Pick(flavor.HarvestFeywild) + case ZoneDragonsLair: + return flavor.Pick(flavor.HarvestDragonsLair) + case ZoneAbyssPortal: + return flavor.Pick(flavor.HarvestAbyssPortal) + } + return "" +} + +// pickRichBonus picks a higher-rarity zone resource (different ID, any +// action) as the §2.2 rich-yield bonus drop. Returns nil if no candidate. +func pickRichBonus(zoneID ZoneID, baseID string) *ZoneResource { + all := ZoneResourcesAll(zoneID) + candidates := make([]ZoneResource, 0, len(all)) + for _, r := range all { + if r.ID == baseID { + continue + } + switch r.Rarity { + case RarityUncommon, RarityRare, RarityVeryRare: + candidates = append(candidates, r) + } + } + if len(candidates) == 0 { + return nil + } + pick := candidates[rand.IntN(len(candidates))] + return &pick +} + +// grantHarvestYield deposits the resource into the player's inventory. +func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error { + if qty <= 0 { + return nil + } + tier := zoneTierFromID(res.ZoneID) + for i := 0; i < qty; i++ { + item := AdvItem{ + Name: res.Name, + Type: "material", + Tier: tier, + Value: int64(res.SellValue), + SkillSource: string(res.Action), + } + if err := addAdvInventoryItem(userID, item); err != nil { + return err + } + } + return nil +} + +// zoneTierFromID returns the zone's tier for sell-value bookkeeping. +func zoneTierFromID(zoneID ZoneID) int { + z, ok := getZone(zoneID) + if !ok { + return 1 + } + return int(z.Tier) +} + +// ── !resources ──────────────────────────────────────────────────────────── + +// handleResourcesCmd lists active nodes in the current room. +func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error { + exp, err := getActiveExpedition(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't load expedition state.") + } + if exp == nil { + return p.SendDM(ctx.Sender, "You're not on an expedition. Start one with `!expedition start `.") + } + char, err := LoadDnDCharacter(ctx.Sender) + if err != nil || char == nil { + return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.") + } + if exp.RunID == "" { + return p.SendDM(ctx.Sender, "No active region run. Try `!region` to refresh, or restart the expedition.") + } + run, err := getZoneRun(exp.RunID) + if err != nil || run == nil { + return p.SendDM(ctx.Sender, "Couldn't load region state.") + } + roomIdx := run.CurrentRoom + nodes := loadHarvestNodes(exp, roomIdx) + _ = saveHarvestNodes(exp, roomIdx, nodes) // persist seed if first touch + + zone, _ := getZone(exp.ZoneID) + regionLabel := exp.CurrentRegion + if regionLabel == "" { + regionLabel = string(exp.ZoneID) + } + cleared := currentRoomCleared(run) + + var b strings.Builder + b.WriteString(fmt.Sprintf("**Resources — %s · %s · Room %d/%d (%s)**\n\n", + zone.Display, regionLabel, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType()))) + if !cleared { + b.WriteString("_Room not cleared — harvest blocked until combat resolves._\n\n") + } + + any := false + for _, n := range nodes { + res, ok := FindZoneResource(exp.ZoneID, n.ResourceID) + if !ok { + continue + } + gates := []string{} + if res.ClassRestrict != "" && res.ClassRestrict != char.Class { + gates = append(gates, fmt.Sprintf("%s only", strings.Title(string(res.ClassRestrict)))) + } + if res.RequiresKill != "" && !regionKillsContains(exp, res.RequiresKill) { + gates = append(gates, "post-"+res.RequiresKill+" kill") + } + if res.ConditionalEvent != "" && !regionEventActive(exp, res.ConditionalEvent) { + gates = append(gates, res.ConditionalEvent+" only") + } + status := fmt.Sprintf("%d/%d", n.CurrentCharges, n.MaxCharges) + if n.CurrentCharges == 0 { + status = "depleted" + } + gateStr := "" + if len(gates) > 0 { + gateStr = " _(" + strings.Join(gates, "; ") + ")_" + } + b.WriteString(fmt.Sprintf("• `!%s` · **%s** (DC %d, %s) — %s%s\n", + res.Action, res.Name, res.DC, res.Rarity, status, gateStr)) + any = true + } + if !any { + b.WriteString("_Zone has no harvest registry._") + } else { + b.WriteString("\n_Long rest at camp replenishes nodes across every room._") + } + return p.SendDM(ctx.Sender, b.String()) +} + +// ── region-run linkage (auto-spawn DungeonRun per region) ───────────────── + +// regionRunsMap reads the regionKey→runID pointer table from RegionState. +func regionRunsMap(e *Expedition) map[string]string { + out := map[string]string{} + if e == nil || e.RegionState == nil { + return out + } + raw, ok := e.RegionState[regionStateRegionRuns] + if !ok { + return out + } + switch v := raw.(type) { + case map[string]string: + return v + case map[string]any: + for k, vv := range v { + if s, ok := vv.(string); ok { + out[k] = s + } + } + } + return out +} + +// setRegionRun persists `runID` as the region's run pointer and rewrites +// exp.RunID to match. The caller is responsible for actually creating +// (or having created) the run. Pass "" to clear. +func setRegionRun(e *Expedition, regionKey, runID string) error { + if e.RegionState == nil { + e.RegionState = map[string]any{} + } + m := regionRunsMap(e) + if runID == "" { + delete(m, regionKey) + } else { + m[regionKey] = runID + } + e.RegionState[regionStateRegionRuns] = m + if err := persistRegionState(e); err != nil { + return err + } + e.RunID = runID + return setExpeditionRunID(e.ID, runID) +} + +// ensureRegionRun guarantees a DungeonRun exists for the player's current +// region and that exp.RunID points at it. If a run already exists for +// the region, it is reloaded; otherwise a fresh one is started after +// abandoning any other active run for the player. +// +// charLevel is needed for the zone-tier gate inside startZoneRun. +func ensureRegionRun(exp *Expedition, charLevel int) (*DungeonRun, error) { + regionKey := regionHarvestKey(exp) + runs := regionRunsMap(exp) + if existing, ok := runs[regionKey]; ok && existing != "" { + run, err := getZoneRun(existing) + if err != nil { + return nil, err + } + if run != nil { + if exp.RunID != run.RunID { + if err := setExpeditionRunID(exp.ID, run.RunID); err != nil { + return nil, err + } + exp.RunID = run.RunID + } + return run, nil + } + } + + // No run mapped for this region; if a different active run exists for + // the player (stale link from prior region), abandon it first. + if other, _ := getActiveZoneRun(id.UserID(exp.UserID)); other != nil { + if err := abandonZoneRunByID(other.RunID); err != nil { + return nil, err + } + } + + run, err := startZoneRun(id.UserID(exp.UserID), exp.ZoneID, charLevel, nil) + if err != nil { + return nil, fmt.Errorf("ensureRegionRun: %w", err) + } + if err := setRegionRun(exp, regionKey, run.RunID); err != nil { + return nil, err + } + return run, nil +} + +// retireRegionRun abandons the player's run for the given regionKey (or +// the current region if regionKey == "" matches CurrentRegion). Idempotent. +// Used by handleRegionCmd "travel" before transitioning. +func retireRegionRun(exp *Expedition, regionKey string) error { + runs := regionRunsMap(exp) + runID, ok := runs[regionKey] + if !ok || runID == "" { + return nil + } + if err := abandonZoneRunByID(runID); err != nil { + return err + } + return setRegionRun(exp, regionKey, "") +} + +// retireAllRegionRuns abandons every region's linked run. Called from the +// expedition-end paths (abandon, complete, forced extract) so that the +// per-user "active zone run" guard stays clean for the next adventure. +func retireAllRegionRuns(exp *Expedition) error { + if exp == nil { + return nil + } + for region := range regionRunsMap(exp) { + if err := retireRegionRun(exp, region); err != nil { + return err + } + } + // Also clear any unmapped active run for the user (defensive). + if other, _ := getActiveZoneRun(id.UserID(exp.UserID)); other != nil { + _ = abandonZoneRunByID(other.RunID) + } + return nil +} diff --git a/internal/plugin/dnd_expedition_harvest_test.go b/internal/plugin/dnd_expedition_harvest_test.go new file mode 100644 index 0000000..1f7083c --- /dev/null +++ b/internal/plugin/dnd_expedition_harvest_test.go @@ -0,0 +1,300 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// ── pure-function tests (no DB) ──────────────────────────────────────────── + +func TestResolveHarvestOutcome_Brackets(t *testing.T) { + cases := []struct { + roll, dc int + want HarvestOutcome + }{ + {20, 10, OutcomeRich}, // +10 = rich + {15, 10, OutcomeRich}, // exactly +5 + {14, 10, OutcomeStandard}, // +4 + {10, 10, OutcomeStandard}, // exact + {9, 10, OutcomeCommon}, // -1 + {6, 10, OutcomeCommon}, // -4 + {5, 10, OutcomeNothing}, // -5 = nothing + {1, 20, OutcomeNothing}, + } + for _, c := range cases { + got := resolveHarvestOutcome(c.roll, c.dc) + if got != c.want { + t.Errorf("resolveHarvestOutcome(%d, %d) = %s, want %s", + c.roll, c.dc, got, c.want) + } + } +} + +func TestClassHarvestBonus_Affinity(t *testing.T) { + cases := []struct { + class DnDClass + action HarvestAction + want int + }{ + {ClassRanger, HarvestForage, 2}, + {ClassRanger, HarvestFish, 2}, + {ClassRanger, HarvestMine, 0}, + {ClassFighter, HarvestMine, 2}, + {ClassRogue, HarvestScavenge, 2}, + {ClassMage, HarvestEssence, 2}, + {ClassCleric, HarvestCommune, 2}, + {ClassCleric, HarvestEssence, 0}, + } + for _, c := range cases { + got := classHarvestBonus(c.class, c.action) + if got != c.want { + t.Errorf("classHarvestBonus(%s, %s) = %d, want %d", + c.class, c.action, got, c.want) + } + } +} + +func TestZoneResourcesByAction_AllZonesPopulated(t *testing.T) { + zones := []ZoneID{ + ZoneGoblinWarrens, ZoneCryptValdris, ZoneForestShadows, + ZoneSunkenTemple, ZoneManorBlackspire, ZoneUnderforge, + ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal, + } + for _, z := range zones { + all := ZoneResourcesAll(z) + if len(all) == 0 { + t.Errorf("zone %s has empty registry", z) + } + // Every entry has zone id backfilled by init(). + for _, r := range all { + if r.ZoneID != z { + t.Errorf("zone %s entry %s has wrong zone id %s", z, r.ID, r.ZoneID) + } + if r.Action == "" || r.Name == "" || r.ID == "" { + t.Errorf("zone %s entry malformed: %+v", z, r) + } + } + } +} + +func TestSeedRoomNodes_MatchesRegistry(t *testing.T) { + nodes := seedRoomNodes(ZoneGoblinWarrens) + all := ZoneResourcesAll(ZoneGoblinWarrens) + if len(nodes) != len(all) { + t.Fatalf("seeded %d, registry %d", len(nodes), len(all)) + } + for i, n := range nodes { + if n.CurrentCharges <= 0 { + t.Errorf("node %d charged 0 at seed", i) + } + if n.CurrentCharges != n.MaxCharges { + t.Errorf("node %d charges %d != max %d", i, n.CurrentCharges, n.MaxCharges) + } + } +} + +func TestPickAvailableNode_PrefersLowestDC(t *testing.T) { + exp := &Expedition{ZoneID: ZoneGoblinWarrens} + char := &DnDCharacter{Class: ClassFighter, STR: 14} + nodes := []HarvestNode{ + {ResourceID: "scrap_iron", CurrentCharges: 2, MaxCharges: 2}, // DC 10 mine + } + idx := pickAvailableNode(nodes, exp, char, HarvestMine) + if idx != 0 { + t.Errorf("idx = %d, want 0 (scrap iron)", idx) + } + // Wrong action returns -1. + if idx := pickAvailableNode(nodes, exp, char, HarvestForage); idx != -1 { + t.Errorf("forage on mine-only registry = %d, want -1", idx) + } + // Class-restricted: shaman reagents are mage-only. + mageOnly := []HarvestNode{ + {ResourceID: "shaman_reagents", CurrentCharges: 1, MaxCharges: 1}, + } + if idx := pickAvailableNode(mageOnly, exp, char, HarvestEssence); idx != -1 { + t.Errorf("fighter on shaman reagents = %d, want -1 (mage-only)", idx) + } + mageChar := &DnDCharacter{Class: ClassMage, INT: 16} + if idx := pickAvailableNode(mageOnly, exp, mageChar, HarvestEssence); idx != 0 { + t.Errorf("mage on shaman reagents = %d, want 0", idx) + } +} + +func TestPickAvailableNode_SkipsRequiresKill(t *testing.T) { + exp := &Expedition{ZoneID: ZoneGoblinWarrens, RegionState: map[string]any{}} + char := &DnDCharacter{Class: ClassRogue, INT: 12} + nodes := []HarvestNode{ + {ResourceID: "worg_pelt", CurrentCharges: 1, MaxCharges: 1}, // requires worg kill + } + if idx := pickAvailableNode(nodes, exp, char, HarvestScavenge); idx != -1 { + t.Errorf("requires-kill resource selectable without kill: idx=%d", idx) + } + // With kill recorded, becomes available. + exp.RegionState["kills"] = map[string][]string{"": {"worg"}} + if idx := pickAvailableNode(nodes, exp, char, HarvestScavenge); idx != 0 { + t.Errorf("post-kill, idx = %d, want 0", idx) + } +} + +func TestCurrentRoomCleared_EntryAutoPasses(t *testing.T) { + run := &DungeonRun{ + CurrentRoom: 0, + RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomBoss}, + RoomsCleared: []int{}, + } + if !currentRoomCleared(run) { + t.Error("entry room should auto-clear") + } + run.CurrentRoom = 1 + if currentRoomCleared(run) { + t.Error("non-cleared exploration room should not be cleared") + } + run.RoomsCleared = []int{1} + if !currentRoomCleared(run) { + t.Error("room in RoomsCleared should report cleared") + } +} + +// ── round-trip tests (require DB) ────────────────────────────────────────── + +func cleanupHarvestState(uid id.UserID) { + cleanupExpeditions(uid) + _, _ = db.Get().Exec(`DELETE FROM dnd_zone_run WHERE user_id = ?`, string(uid)) + _, _ = db.Get().Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(uid)) +} + +func TestEnsureRegionRun_LinksExpeditionToRun(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@harvest-ensure:example.org") + defer cleanupHarvestState(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + run, err := ensureRegionRun(exp, 1) + if err != nil { + t.Fatalf("ensureRegionRun: %v", err) + } + if run.RunID == "" { + t.Error("run id empty") + } + if exp.RunID != run.RunID { + t.Errorf("exp.RunID = %q, want %q", exp.RunID, run.RunID) + } + // Idempotent: calling again returns the same run. + again, err := ensureRegionRun(exp, 1) + if err != nil || again.RunID != run.RunID { + t.Errorf("second ensureRegionRun returned different run: %v vs %v", again, run) + } + // And the mapping persists. + runs := regionRunsMap(exp) + if runs[""] != run.RunID { + t.Errorf("region_runs[\"\"]=%q, want %q", runs[""], run.RunID) + } +} + +func TestRetireRegionRun_AbandonsAndClears(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@harvest-retire:example.org") + defer cleanupHarvestState(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + run, err := ensureRegionRun(exp, 1) + if err != nil { + t.Fatal(err) + } + if err := retireRegionRun(exp, ""); err != nil { + t.Fatalf("retireRegionRun: %v", err) + } + // Underlying run should be marked abandoned. + got, _ := getZoneRun(run.RunID) + if got == nil || !got.Abandoned { + t.Errorf("expected run abandoned, got %+v", got) + } + // Mapping cleared. + if got := regionRunsMap(exp); got[""] != "" { + t.Errorf("region_runs not cleared: %+v", got) + } + // exp.RunID cleared. + if exp.RunID != "" { + t.Errorf("exp.RunID = %q, want empty", exp.RunID) + } +} + +func TestReplenishHarvestNodes_RestoresAllRooms(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@harvest-replenish:example.org") + defer cleanupHarvestState(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + + // Seed two rooms and drain one node in each. + r0 := loadHarvestNodes(exp, 0) + r0[0].CurrentCharges = 0 + if err := saveHarvestNodes(exp, 0, r0); err != nil { + t.Fatal(err) + } + r1 := loadHarvestNodes(exp, 1) + r1[0].CurrentCharges = 0 + if err := saveHarvestNodes(exp, 1, r1); err != nil { + t.Fatal(err) + } + + if err := ReplenishHarvestNodes(exp); err != nil { + t.Fatal(err) + } + for _, idx := range []int{0, 1} { + nodes := loadHarvestNodes(exp, idx) + for i, n := range nodes { + if n.CurrentCharges != n.MaxCharges { + t.Errorf("room %d node %d not replenished: %d/%d", + idx, i, n.CurrentCharges, n.MaxCharges) + } + } + } +} + +func TestSaveHarvestNodes_PersistsAcrossLoad(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@harvest-persist:example.org") + defer cleanupHarvestState(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + nodes := loadHarvestNodes(exp, 2) + nodes[0].CurrentCharges = 0 + if err := saveHarvestNodes(exp, 2, nodes); err != nil { + t.Fatal(err) + } + // Reload from DB. + reloaded, err := getActiveExpedition(uid) + if err != nil { + t.Fatal(err) + } + got := loadHarvestNodes(reloaded, 2) + if got[0].CurrentCharges != 0 { + t.Errorf("after reload, room 2 node 0 charges = %d, want 0", got[0].CurrentCharges) + } + // Other rooms should still be untouched (lazy-seeded fresh). + other := loadHarvestNodes(reloaded, 5) + for _, n := range other { + if n.CurrentCharges != n.MaxCharges { + t.Errorf("room 5 leaked depletion: %+v", n) + } + } +} diff --git a/internal/plugin/dnd_expedition_region_cmd.go b/internal/plugin/dnd_expedition_region_cmd.go index d3a0125..84d0b1e 100644 --- a/internal/plugin/dnd_expedition_region_cmd.go +++ b/internal/plugin/dnd_expedition_region_cmd.go @@ -142,6 +142,12 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e tc := resolveTransitWanderingCheck(exp, charClass, nil) _ = processTransitWanderingCheck(exp, tc) + // R2 — retire the outgoing region's DungeonRun before mutating + // CurrentRegion so retireRegionRun keys the right region. + if err := retireRegionRun(exp, cur.ID); err != nil { + return p.SendDM(ctx.Sender, "Couldn't retire previous region run: "+err.Error()) + } + // Update CurrentRegion + visited list. if err := setCurrentRegion(exp, next.ID); err != nil { return p.SendDM(ctx.Sender, "Couldn't update current region: "+err.Error()) @@ -150,6 +156,15 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e return p.SendDM(ctx.Sender, "Couldn't mark region visited: "+err.Error()) } + // Spawn the new region's DungeonRun and pin exp.RunID. + charLevel := 1 + if c != nil { + charLevel = c.Level + } + if _, err := ensureRegionRun(exp, charLevel); err != nil { + return p.SendDM(ctx.Sender, "Couldn't outfit the new region: "+err.Error()) + } + // Log + flavor. departure := strings.ReplaceAll(flavor.Pick(flavor.RegionTransitDeparture), "[REGION_NEXT]", next.Name) diff --git a/internal/plugin/dnd_resource_registry.go b/internal/plugin/dnd_resource_registry.go new file mode 100644 index 0000000..2fcc968 --- /dev/null +++ b/internal/plugin/dnd_resource_registry.go @@ -0,0 +1,175 @@ +package plugin + +// Resource & Combat Integration §3 — Zone resource tables. +// Per-zone harvestable materials/items, indexed by harvest action. +// Section 3 of gogobee_resource_combat_integration.md is the source of truth; +// when adding a new resource, update both the spec and this registry. + +// HarvestAction identifies which command harvests a resource. +type HarvestAction string + +const ( + HarvestForage HarvestAction = "forage" + HarvestMine HarvestAction = "mine" + HarvestScavenge HarvestAction = "scavenge" + HarvestEssence HarvestAction = "essence" + HarvestCommune HarvestAction = "commune" + HarvestFish HarvestAction = "fish" +) + +// ZoneResource defines a harvestable material or item for a zone (§7). +// MaxCharges = node-count-per-region for the per-region MVP scope. +type ZoneResource struct { + ID string + Name string + ZoneID ZoneID + Action HarvestAction + DC int + Rarity DnDRarity + Type string // "material", "item", "fish", "quest" + SellValue int // base coins at Thom Krooke (§8.1 midpoint) + MaxCharges int // node depletion budget per region per long-rest cycle + // RequiresKill: monster kind that must be defeated in the region first + // (empty = no prerequisite). Resolved against expedition kill-log when + // combat-link wires in; for now treated as "never available" if non-empty + // unless RegionState records the kill. + RequiresKill string + // ClassRestrict: empty for any-class; otherwise only this class can pull + // the resource (mind flayer brain matter, drow poison post-kill, etc.). + ClassRestrict DnDClass + // ConditionalEvent: zone-event ID that gates availability (e.g. tidal + // crystals only during Day 6 Tidal Event). Empty = always available. + ConditionalEvent string +} + +// zoneResources is the full Section 3 registry. +var zoneResources = map[ZoneID][]ZoneResource{ + ZoneGoblinWarrens: { + {ID: "scrap_iron", Name: "Scrap Iron", Action: HarvestMine, DC: 10, Rarity: RarityCommon, Type: "material", SellValue: 10, MaxCharges: 2}, + {ID: "alchemy_pouch", Name: "Goblin Alchemy Pouch", Action: HarvestScavenge, DC: 13, Rarity: RarityUncommon, Type: "material", SellValue: 40, MaxCharges: 1}, + {ID: "worg_pelt", Name: "Worg Pelt", Action: HarvestScavenge, DC: 11, Rarity: RarityUncommon, Type: "material", SellValue: 35, MaxCharges: 1, RequiresKill: "worg"}, + {ID: "crude_weapon_cache", Name: "Crude Weapon Cache", Action: HarvestScavenge, DC: 15, Rarity: RarityUncommon, Type: "item", SellValue: 50, MaxCharges: 1}, + {ID: "war_standard", Name: "Hobgoblin War Standard", Action: HarvestScavenge, DC: 18, Rarity: RarityRare, Type: "material", SellValue: 200, MaxCharges: 1}, + {ID: "shaman_reagents", Name: "Goblin Shaman's Reagents", Action: HarvestEssence, DC: 17, Rarity: RarityRare, Type: "material", SellValue: 180, MaxCharges: 1, ClassRestrict: ClassMage}, + }, + ZoneCryptValdris: { + {ID: "grave_soil", Name: "Grave Soil", Action: HarvestScavenge, DC: 9, Rarity: RarityCommon, Type: "material", SellValue: 8, MaxCharges: 2}, + {ID: "bone_dust", Name: "Bone Dust", Action: HarvestScavenge, DC: 9, Rarity: RarityCommon, Type: "material", SellValue: 7, MaxCharges: 2}, + {ID: "ancient_coin", Name: "Ancient Coin (pre-Empire)", Action: HarvestScavenge, DC: 14, Rarity: RarityUncommon, Type: "material", SellValue: 50, MaxCharges: 1}, + {ID: "necrotic_essence", Name: "Necrotic Essence", Action: HarvestEssence, DC: 15, Rarity: RarityUncommon, Type: "material", SellValue: 45, MaxCharges: 1}, + {ID: "silver_grave_dust", Name: "Silver Grave Dust", Action: HarvestCommune, DC: 17, Rarity: RarityRare, Type: "material", SellValue: 200, MaxCharges: 1, ClassRestrict: ClassCleric}, + {ID: "valdris_seal_fragment", Name: "Valdris's Seal Fragment", Action: HarvestScavenge, DC: 20, Rarity: RarityRare, Type: "quest", SellValue: 250, MaxCharges: 1}, + }, + ZoneForestShadows: { + {ID: "shadow_herb", Name: "Shadow Herb", Action: HarvestForage, DC: 10, Rarity: RarityCommon, Type: "material", SellValue: 10, MaxCharges: 2}, + {ID: "beast_pelt", Name: "Beast Pelt", Action: HarvestForage, DC: 11, Rarity: RarityCommon, Type: "material", SellValue: 12, MaxCharges: 1, RequiresKill: "beast"}, + {ID: "darkwood", Name: "Darkwood", Action: HarvestMine, DC: 13, Rarity: RarityUncommon, Type: "material", SellValue: 40, MaxCharges: 1}, + {ID: "fey_bloom", Name: "Corrupted Fey Bloom", Action: HarvestForage, DC: 16, Rarity: RarityUncommon, Type: "material", SellValue: 55, MaxCharges: 1}, + {ID: "owlbear_feather", Name: "Owlbear Feather", Action: HarvestScavenge, DC: 12, Rarity: RarityUncommon, Type: "material", SellValue: 35, MaxCharges: 1, RequiresKill: "owlbear"}, + {ID: "dryads_tears", Name: "Dryad's Tears", Action: HarvestEssence, DC: 18, Rarity: RarityRare, Type: "material", SellValue: 220, MaxCharges: 1}, + {ID: "biolum_fungi", Name: "Bioluminescent Fungi", Action: HarvestForage, DC: 17, Rarity: RarityRare, Type: "material", SellValue: 200, MaxCharges: 1}, + }, + ZoneSunkenTemple: { + {ID: "sea_glass", Name: "Sea Glass Shard", Action: HarvestScavenge, DC: 9, Rarity: RarityCommon, Type: "material", SellValue: 7, MaxCharges: 2}, + {ID: "deep_coral", Name: "Deep Coral", Action: HarvestMine, DC: 11, Rarity: RarityCommon, Type: "material", SellValue: 12, MaxCharges: 1}, + {ID: "kuotoa_scale", Name: "Kuo-toa Scale", Action: HarvestScavenge, DC: 12, Rarity: RarityUncommon, Type: "material", SellValue: 35, MaxCharges: 1, RequiresKill: "kuo-toa"}, + {ID: "black_pearl", Name: "Black Pearl", Action: HarvestFish, DC: 16, Rarity: RarityUncommon, Type: "material", SellValue: 55, MaxCharges: 1}, + {ID: "aboleth_mucus", Name: "Aboleth Mucus (refined)", Action: HarvestEssence, DC: 18, Rarity: RarityRare, Type: "material", SellValue: 230, MaxCharges: 1, ClassRestrict: ClassMage}, + {ID: "temple_relic", Name: "Ancient Temple Relic", Action: HarvestScavenge, DC: 20, Rarity: RarityRare, Type: "item", SellValue: 250, MaxCharges: 1}, + {ID: "tidal_crystal", Name: "Tidal Crystal", Action: HarvestMine, DC: 17, Rarity: RarityRare, Type: "material", SellValue: 200, MaxCharges: 1, ConditionalEvent: "tidal_event"}, + }, + ZoneManorBlackspire: { + {ID: "spectral_grave_dust", Name: "Grave Dust (spectral)", Action: HarvestScavenge, DC: 10, Rarity: RarityCommon, Type: "material", SellValue: 12, MaxCharges: 2}, + {ID: "cursed_trinket", Name: "Cursed Trinket", Action: HarvestScavenge, DC: 12, Rarity: RarityCommon, Type: "item", SellValue: 14, MaxCharges: 1}, + {ID: "ghost_silk", Name: "Ghost Silk", Action: HarvestEssence, DC: 15, Rarity: RarityUncommon, Type: "material", SellValue: 45, MaxCharges: 1}, + {ID: "vampire_ash", Name: "Vampire Ash", Action: HarvestScavenge, DC: 14, Rarity: RarityUncommon, Type: "material", SellValue: 50, MaxCharges: 1, RequiresKill: "vampire_spawn"}, + {ID: "shadow_essence", Name: "Shadow Essence", Action: HarvestEssence, DC: 18, Rarity: RarityRare, Type: "material", SellValue: 220, MaxCharges: 1}, + {ID: "blackspire_volume", Name: "Blackspire Library Volume", Action: HarvestScavenge, DC: 19, Rarity: RarityRare, Type: "item", SellValue: 240, MaxCharges: 1}, + {ID: "wraith_core", Name: "Wraith Core", Action: HarvestCommune, DC: 20, Rarity: RarityRare, Type: "material", SellValue: 260, MaxCharges: 1, ClassRestrict: ClassCleric, RequiresKill: "wraith"}, + }, + ZoneUnderforge: { + {ID: "iron_ore", Name: "Iron Ore", Action: HarvestMine, DC: 10, Rarity: RarityCommon, Type: "material", SellValue: 10, MaxCharges: 2}, + {ID: "volcanic_glass", Name: "Volcanic Glass", Action: HarvestMine, DC: 11, Rarity: RarityCommon, Type: "material", SellValue: 12, MaxCharges: 2}, + {ID: "azer_ingot", Name: "Azer Metal Ingot", Action: HarvestMine, DC: 14, Rarity: RarityUncommon, Type: "material", SellValue: 50, MaxCharges: 1, RequiresKill: "azer"}, + {ID: "fire_essence", Name: "Fire Essence", Action: HarvestEssence, DC: 15, Rarity: RarityUncommon, Type: "material", SellValue: 45, MaxCharges: 1}, + {ID: "mithral_trace", Name: "Mithral Trace", Action: HarvestMine, DC: 20, Rarity: RarityRare, Type: "material", SellValue: 280, MaxCharges: 1}, + {ID: "forge_core_fragment", Name: "Forge Core Fragment", Action: HarvestScavenge, DC: 17, Rarity: RarityRare, Type: "material", SellValue: 200, MaxCharges: 1, RequiresKill: "construct"}, + {ID: "salamander_oil", Name: "Salamander Oil", Action: HarvestEssence, DC: 16, Rarity: RarityRare, Type: "material", SellValue: 210, MaxCharges: 1, RequiresKill: "salamander"}, + }, + ZoneUnderdark: { + {ID: "ud_mushroom", Name: "Underdark Mushroom", Action: HarvestForage, DC: 10, Rarity: RarityCommon, Type: "material", SellValue: 10, MaxCharges: 2}, + {ID: "spider_silk", Name: "Cave Spider Silk", Action: HarvestForage, DC: 12, Rarity: RarityCommon, Type: "material", SellValue: 14, MaxCharges: 2}, + {ID: "drow_poison", Name: "Drow Poison (diluted)", Action: HarvestScavenge, DC: 14, Rarity: RarityUncommon, Type: "material", SellValue: 50, MaxCharges: 1, RequiresKill: "drow"}, + {ID: "faerzress_crystal", Name: "Faerzress Crystal", Action: HarvestMine, DC: 16, Rarity: RarityUncommon, Type: "material", SellValue: 55, MaxCharges: 1}, + {ID: "mind_flayer_brain", Name: "Mind Flayer Brain Matter", Action: HarvestEssence, DC: 19, Rarity: RarityRare, Type: "material", SellValue: 250, MaxCharges: 1, ClassRestrict: ClassMage, RequiresKill: "illithid"}, + {ID: "drow_adamantine", Name: "Drow Adamantine", Action: HarvestMine, DC: 21, Rarity: RarityRare, Type: "material", SellValue: 290, MaxCharges: 1}, + {ID: "deep_dragon_chip", Name: "Deep Dragon Scale Chip", Action: HarvestScavenge, DC: 23, Rarity: RarityVeryRare, Type: "material", SellValue: 800, MaxCharges: 1}, + }, + ZoneFeywildCrossing: { + {ID: "fey_dust", Name: "Fey Dust", Action: HarvestForage, DC: 11, Rarity: RarityCommon, Type: "material", SellValue: 12, MaxCharges: 2}, + {ID: "enchanted_petal", Name: "Enchanted Petal", Action: HarvestForage, DC: 12, Rarity: RarityCommon, Type: "material", SellValue: 13, MaxCharges: 2}, + {ID: "pixie_wing", Name: "Pixie Wing Fragments", Action: HarvestEssence, DC: 16, Rarity: RarityUncommon, Type: "material", SellValue: 55, MaxCharges: 1}, + {ID: "wisp_essence", Name: "Will-o-Wisp Essence", Action: HarvestEssence, DC: 17, Rarity: RarityUncommon, Type: "material", SellValue: 60, MaxCharges: 1, RequiresKill: "will_o_wisp"}, + {ID: "hag_hair", Name: "Hag Hair", Action: HarvestCommune, DC: 18, Rarity: RarityRare, Type: "material", SellValue: 220, MaxCharges: 1, ClassRestrict: ClassCleric, RequiresKill: "hag"}, + {ID: "timelock_amber", Name: "Timelock Amber", Action: HarvestForage, DC: 21, Rarity: RarityRare, Type: "material", SellValue: 290, MaxCharges: 1}, + {ID: "thornmother_thorn", Name: "Thornmother's Thorn", Action: HarvestScavenge, DC: 25, Rarity: RarityVeryRare, Type: "material", SellValue: 1000, MaxCharges: 1, RequiresKill: "thornmother"}, + }, + ZoneDragonsLair: { + {ID: "ancient_gold", Name: "Ancient Gold Coin", Action: HarvestScavenge, DC: 10, Rarity: RarityCommon, Type: "material", SellValue: 12, MaxCharges: 2}, + {ID: "volcanic_gem", Name: "Volcanic Gem", Action: HarvestMine, DC: 12, Rarity: RarityCommon, Type: "material", SellValue: 13, MaxCharges: 2}, + {ID: "drake_blood", Name: "Drake Blood", Action: HarvestEssence, DC: 14, Rarity: RarityUncommon, Type: "material", SellValue: 50, MaxCharges: 1, RequiresKill: "drake"}, + {ID: "dragon_scale_fragment", Name: "Dragon Scale Fragment", Action: HarvestScavenge, DC: 16, Rarity: RarityUncommon, Type: "material", SellValue: 55, MaxCharges: 1, RequiresKill: "drake"}, + {ID: "dragonfire_coal", Name: "Dragonfire Coal", Action: HarvestMine, DC: 19, Rarity: RarityRare, Type: "material", SellValue: 240, MaxCharges: 1}, + {ID: "kobold_trap", Name: "Kobold Trap Mechanism", Action: HarvestScavenge, DC: 18, Rarity: RarityRare, Type: "item", SellValue: 220, MaxCharges: 1}, + {ID: "infernax_scale", Name: "Infernax Scale", Action: HarvestScavenge, DC: 25, Rarity: RarityLegendary, Type: "material", SellValue: 6000, MaxCharges: 1, RequiresKill: "infernax"}, + }, + ZoneAbyssPortal: { + {ID: "brimstone_shard", Name: "Brimstone Shard", Action: HarvestMine, DC: 11, Rarity: RarityCommon, Type: "material", SellValue: 13, MaxCharges: 2}, + {ID: "demon_ichor", Name: "Demon Ichor", Action: HarvestScavenge, DC: 12, Rarity: RarityCommon, Type: "material", SellValue: 14, MaxCharges: 2, RequiresKill: "demon"}, + {ID: "planar_shard", Name: "Planar Shard", Action: HarvestMine, DC: 15, Rarity: RarityUncommon, Type: "material", SellValue: 50, MaxCharges: 1}, + {ID: "corrupted_metal", Name: "Corrupted Metal", Action: HarvestScavenge, DC: 16, Rarity: RarityUncommon, Type: "material", SellValue: 55, MaxCharges: 1}, + {ID: "void_essence", Name: "Void Essence", Action: HarvestEssence, DC: 20, Rarity: RarityRare, Type: "material", SellValue: 280, MaxCharges: 1}, + {ID: "abyssal_ichor", Name: "Abyssal Ichor (concentrated)", Action: HarvestEssence, DC: 22, Rarity: RarityRare, Type: "material", SellValue: 300, MaxCharges: 1, ClassRestrict: ClassMage, RequiresKill: "balor"}, + {ID: "portal_fragment", Name: "Portal Fragment", Action: HarvestScavenge, DC: 25, Rarity: RarityLegendary, Type: "material", SellValue: 6500, MaxCharges: 1, RequiresKill: "portal_boss"}, + }, +} + +// init backfills derived fields (ZoneID) so callers don't have to set them +// twice in the literal. +func init() { + for zid, list := range zoneResources { + for i := range list { + list[i].ZoneID = zid + } + zoneResources[zid] = list + } +} + +// ZoneResourcesByAction returns all resources in zoneID gathered by action. +// Returns nil if the zone has no entries (single-zone-not-yet-populated case). +func ZoneResourcesByAction(zoneID ZoneID, action HarvestAction) []ZoneResource { + all := zoneResources[zoneID] + out := make([]ZoneResource, 0, len(all)) + for _, r := range all { + if r.Action == action { + out = append(out, r) + } + } + return out +} + +// ZoneResourcesAll returns the full registry slice for zoneID. +func ZoneResourcesAll(zoneID ZoneID) []ZoneResource { + return zoneResources[zoneID] +} + +// FindZoneResource looks up a single resource by zone+id. Returns ok=false +// if no match. +func FindZoneResource(zoneID ZoneID, resourceID string) (ZoneResource, bool) { + for _, r := range zoneResources[zoneID] { + if r.ID == resourceID { + return r, true + } + } + return ZoneResource{}, false +} diff --git a/internal/plugin/dnd_subclass_combat_test.go b/internal/plugin/dnd_subclass_combat_test.go index 07e7af7..bd9100d 100644 --- a/internal/plugin/dnd_subclass_combat_test.go +++ b/internal/plugin/dnd_subclass_combat_test.go @@ -611,7 +611,12 @@ func plainBMPlayer() Combatant { // Probabilistic: a stiff +4 on the opening swing should noticeably lift // hit-rate against an enemy AC tuned to mostly-shrug the baseline player. func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) { - const trials = 1500 + // Probabilistic: a +4 on the opening swing should noticeably lift the + // player's win rate against a stiff enemy. The lift is small (a single + // hit's worth), so the trial count needs to be high enough that + // variance doesn't swamp the signal — at 1500 trials we were seeing + // ~14 wins of difference on bad seeds vs. the 25-win threshold. + const trials = 6000 hardEnemy := Combatant{ Name: "Wall", Stats: CombatStats{ @@ -629,7 +634,7 @@ func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) { bmWins++ } } - if bmWins-plainWins < 25 { + if bmWins-plainWins < 50 { t.Errorf("Precision Attack lift too small: plain=%d bm=%d", plainWins, bmWins) } } diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 0cccd2a..946c97f 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -357,6 +357,24 @@ func abandonZoneRun(userID id.UserID) error { return err } +// abandonZoneRunByID abandons a specific run regardless of its active +// status. Idempotent — exits cleanly if the row is already terminal. +// Used by the expedition layer to retire a region's run when the player +// travels onward, since the user-keyed abandonZoneRun would refuse to +// fire when the run is no longer "active" (e.g. boss defeated). +func abandonZoneRunByID(runID string) error { + if runID == "" { + return nil + } + _, err := db.Get().Exec(` + UPDATE dnd_zone_run + SET abandoned = 1, + completed_at = COALESCE(completed_at, CURRENT_TIMESTAMP), + last_action_at = CURRENT_TIMESTAMP + WHERE run_id = ?`, runID) + return err +} + // adjustGMMood clamps mood to [0, 100] and persists. Used by D1d when // nat-1/nat-20/zone-completion events fire. delta may be negative. func adjustGMMood(runID string, delta int) error {