mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Adv 2.0 D&D Phase R R2: Harvest nodes & per-room registry
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<Zone> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <spell> [--upcast N] [--target @user]", Category: "Games"},
|
||||
{Name: "spells", Description: "List your known spells, prepared spells, and spell slots", Usage: "!spells [learn <spell>]", Category: "Games"},
|
||||
{Name: "prepare", Description: "Cleric: prepare a spell for the day (cap = WIS mod + level)", Usage: "!prepare <spell> | !prepare clear <spell>", 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") {
|
||||
|
||||
@@ -64,6 +64,7 @@ const (
|
||||
RarityUncommon DnDRarity = "Uncommon"
|
||||
RarityRare DnDRarity = "Rare"
|
||||
RarityEpic DnDRarity = "Epic"
|
||||
RarityVeryRare DnDRarity = "VeryRare"
|
||||
RarityLegendary DnDRarity = "Legendary"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
732
internal/plugin/dnd_expedition_harvest.go
Normal file
732
internal/plugin/dnd_expedition_harvest.go
Normal file
@@ -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 <zone>`.")
|
||||
}
|
||||
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 <zone>`.")
|
||||
}
|
||||
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
|
||||
}
|
||||
300
internal/plugin/dnd_expedition_harvest_test.go
Normal file
300
internal/plugin/dnd_expedition_harvest_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
175
internal/plugin/dnd_resource_registry.go
Normal file
175
internal/plugin/dnd_resource_registry.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user