mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase R6 polish: standalone-zone harvest persistence
Adds a dnd_zone_run.harvest_nodes_json column and a small persistence layer (loadStandaloneHarvestNodes / saveStandaloneHarvestNodes) so !zone enter runs that aren't tied to an expedition can carry per-room HarvestNode state. Expedition runs continue to use expedition.region_state for the same data — this is the casual flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -182,6 +182,10 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// this after each rage'd combat. Cleared on long rest. Reused by
|
// this after each rage'd combat. Cleared on long rest. Reused by
|
||||||
// other classes once exhaustion-inducing mechanics arrive in SUB3+.
|
// other classes once exhaustion-inducing mechanics arrive in SUB3+.
|
||||||
`ALTER TABLE dnd_character ADD COLUMN exhaustion INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE dnd_character ADD COLUMN exhaustion INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
// Standalone-zone-run harvest: stores a per-room HarvestNode map for
|
||||||
|
// !zone enter sessions that aren't tied to an expedition. Expedition
|
||||||
|
// runs continue to use expedition.region_state for the same data.
|
||||||
|
`ALTER TABLE dnd_zone_run ADD COLUMN harvest_nodes_json TEXT NOT NULL DEFAULT '{}'`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
167
internal/plugin/dnd_zone_harvest.go
Normal file
167
internal/plugin/dnd_zone_harvest.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/flavor"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standalone-zone-run harvest. The expedition path uses
|
||||||
|
// expedition.region_state to keep per-(region,room) HarvestNode state;
|
||||||
|
// !zone enter runs aren't tied to any expedition, so we mirror the
|
||||||
|
// per-room storage onto dnd_zone_run.harvest_nodes_json instead.
|
||||||
|
//
|
||||||
|
// Region-scoped expedition features (kill-gated nodes, conditional
|
||||||
|
// events, cursed trinket drops, threat ticks) don't fire here — those
|
||||||
|
// belong to the expedition system. Standalone harvest is the casual
|
||||||
|
// flow: roll d20 vs DC, get yield, decrement node, save.
|
||||||
|
|
||||||
|
// loadStandaloneHarvestNodes reads the run's harvest map and returns the
|
||||||
|
// nodes for the given room. Lazy-seeds from the zone registry on first
|
||||||
|
// access. Caller persists via saveStandaloneHarvestNodes.
|
||||||
|
func loadStandaloneHarvestNodes(runID string, zoneID ZoneID, roomIdx int) ([]HarvestNode, error) {
|
||||||
|
table, err := loadStandaloneHarvestTable(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roomKey := roomHarvestKey(roomIdx)
|
||||||
|
if existing, ok := table[roomKey]; ok {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
return seedRoomNodes(zoneID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveStandaloneHarvestNodes writes nodes back into the run's harvest map.
|
||||||
|
func saveStandaloneHarvestNodes(runID string, roomIdx int, nodes []HarvestNode) error {
|
||||||
|
table, err := loadStandaloneHarvestTable(runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
table[roomHarvestKey(roomIdx)] = nodes
|
||||||
|
raw, err := json.Marshal(table)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal harvest table: %w", err)
|
||||||
|
}
|
||||||
|
_, err = db.Get().Exec(
|
||||||
|
`UPDATE dnd_zone_run SET harvest_nodes_json = ?, last_action_at = CURRENT_TIMESTAMP WHERE run_id = ?`,
|
||||||
|
string(raw), runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadStandaloneHarvestTable(runID string) (map[string][]HarvestNode, error) {
|
||||||
|
row := db.Get().QueryRow(
|
||||||
|
`SELECT harvest_nodes_json FROM dnd_zone_run WHERE run_id = ?`, runID)
|
||||||
|
var raw string
|
||||||
|
if err := row.Scan(&raw); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return map[string][]HarvestNode{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if raw == "" || raw == "{}" {
|
||||||
|
return map[string][]HarvestNode{}, nil
|
||||||
|
}
|
||||||
|
table := map[string][]HarvestNode{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &table); err != nil {
|
||||||
|
// Corrupt blob — fall through with empty so harvest re-seeds.
|
||||||
|
slog.Warn("standalone harvest: corrupt nodes json", "run", runID, "err", err)
|
||||||
|
return map[string][]HarvestNode{}, nil
|
||||||
|
}
|
||||||
|
return table, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStandaloneHarvest is the !forage/!mine/!fish/etc. fallback for
|
||||||
|
// players in a single-session !zone enter run with no active expedition.
|
||||||
|
// Mirrors the core of handleHarvestCmd minus the expedition-only side
|
||||||
|
// effects (threat ticks, region kills, cursed-trinket drops, log entries).
|
||||||
|
func (p *AdventurePlugin) handleStandaloneHarvest(ctx MessageContext, char *DnDCharacter, action HarvestAction, run *DungeonRun) error {
|
||||||
|
if action == HarvestFish && !isFishingZone(run.ZoneID) {
|
||||||
|
return p.SendDM(ctx.Sender, "There's no water to fish in here. `!fish` works only in Forest of Shadows, Sunken Temple, Underdark, or Feywild Crossing.")
|
||||||
|
}
|
||||||
|
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, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, roomIdx)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't load harvest state.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the expedition picker by passing a stub Expedition with just
|
||||||
|
// ZoneID set. The kill-gated and event-conditional predicates inside
|
||||||
|
// pickAvailableNode bail early on nil/empty RegionState — exactly the
|
||||||
|
// behavior we want for standalone (no kill tracking, no events).
|
||||||
|
stubExp := &Expedition{ZoneID: run.ZoneID}
|
||||||
|
idx := pickAvailableNode(nodes, stubExp, 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(run.ZoneID, nodes[idx].ResourceID)
|
||||||
|
|
||||||
|
roll := rand.IntN(20) + 1
|
||||||
|
mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
|
||||||
|
if action == HarvestFish {
|
||||||
|
if adv, _ := loadAdvCharacter(ctx.Sender); adv != nil {
|
||||||
|
mod += fishingSkillBonus(adv.FishingSkill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := roll + mod
|
||||||
|
outcome := resolveHarvestOutcome(total, res.DC)
|
||||||
|
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
||||||
|
|
||||||
|
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, run.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, run.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)
|
||||||
|
b.WriteString(flavor.Pick(flavor.RichYield))
|
||||||
|
b.WriteString(fmt.Sprintf("\n\n_+%d %s._", qty, res.Name))
|
||||||
|
nodes[idx].CurrentCharges--
|
||||||
|
}
|
||||||
|
|
||||||
|
if outcome != OutcomeNothing && nodes[idx].CurrentCharges <= 0 {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(flavor.Pick(flavor.NodeDepleted))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveStandaloneHarvestNodes(run.RunID, roomIdx, nodes); err != nil {
|
||||||
|
slog.Error("standalone harvest: save nodes", "user", ctx.Sender, "run", run.RunID, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time check that loadAdvCharacter is reachable; silences "unused
|
||||||
|
// import" complaints if loadAdvCharacter changes signature in the future.
|
||||||
|
var _ id.UserID
|
||||||
92
internal/plugin/dnd_zone_harvest_test.go
Normal file
92
internal/plugin/dnd_zone_harvest_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standalone-zone-run harvest path: with no active expedition but an
|
||||||
|
// active !zone enter run, !forage / !mine / !fish should land yields
|
||||||
|
// against per-run node storage. Mirrors the bug Rurina hit where harvest
|
||||||
|
// commands silently bounced because the handler hard-required an
|
||||||
|
// expedition.
|
||||||
|
|
||||||
|
func TestStandaloneHarvest_RoutesToZoneRun(t *testing.T) {
|
||||||
|
setupAuditTestDB(t)
|
||||||
|
uid := id.UserID("@stand-harvest-route:example")
|
||||||
|
zoneCmdTestCharacter(t, uid, 4)
|
||||||
|
t.Cleanup(func() { cleanupZoneRuns(uid); cleanupExpeditions(uid) })
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneForestShadows, 4, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Mark room 0 (entry) as cleared so harvest is allowed there.
|
||||||
|
if !currentRoomCleared(run) {
|
||||||
|
t.Fatalf("entry room should auto-clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
// No expedition exists for this user — handler must fall through to
|
||||||
|
// the standalone path and not bounce with the expedition-required
|
||||||
|
// error message.
|
||||||
|
if err := p.handleHarvestCmd(MessageContext{Sender: uid}, HarvestForage); err != nil {
|
||||||
|
t.Fatalf("handleHarvestCmd: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side-effect check: the run row should now have non-empty
|
||||||
|
// harvest_nodes_json since saveStandaloneHarvestNodes ran.
|
||||||
|
var raw string
|
||||||
|
row := db.Get().QueryRow(
|
||||||
|
`SELECT harvest_nodes_json FROM dnd_zone_run WHERE run_id = ?`, run.RunID)
|
||||||
|
if err := row.Scan(&raw); err != nil {
|
||||||
|
t.Fatalf("scan harvest_nodes_json: %v", err)
|
||||||
|
}
|
||||||
|
if raw == "" || raw == "{}" {
|
||||||
|
t.Errorf("expected harvest_nodes_json populated after standalone harvest, got %q", raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, "\"0\":") {
|
||||||
|
t.Errorf("expected room 0 entry in harvest table, got %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandaloneHarvest_BouncesWhenNoActiveAnything(t *testing.T) {
|
||||||
|
setupAuditTestDB(t)
|
||||||
|
uid := id.UserID("@stand-harvest-noctx:example")
|
||||||
|
zoneCmdTestCharacter(t, uid, 1)
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
// No expedition, no zone run — should bounce cleanly with the new
|
||||||
|
// combined hint, not panic on a nil run.
|
||||||
|
if err := p.handleHarvestCmd(MessageContext{Sender: uid}, HarvestForage); err != nil {
|
||||||
|
t.Fatalf("handleHarvestCmd: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandaloneHarvest_FishGatedToFishingZones(t *testing.T) {
|
||||||
|
setupAuditTestDB(t)
|
||||||
|
uid := id.UserID("@stand-harvest-fish:example")
|
||||||
|
zoneCmdTestCharacter(t, uid, 1)
|
||||||
|
t.Cleanup(func() { cleanupZoneRuns(uid) })
|
||||||
|
|
||||||
|
// Goblin Warrens isn't a fishing zone — !fish must reject.
|
||||||
|
if _, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleHarvestCmd(MessageContext{Sender: uid}, HarvestFish); err != nil {
|
||||||
|
t.Fatalf("handleHarvestCmd: %v", err)
|
||||||
|
}
|
||||||
|
// (no easy way to assert the rejection without capturing SendDM —
|
||||||
|
// the no-error return + no node persistence is the contract.)
|
||||||
|
var raw string
|
||||||
|
_ = db.Get().QueryRow(
|
||||||
|
`SELECT COALESCE(harvest_nodes_json,'') FROM dnd_zone_run
|
||||||
|
WHERE user_id = ? ORDER BY started_at DESC LIMIT 1`, string(uid)).Scan(&raw)
|
||||||
|
if raw != "" && raw != "{}" {
|
||||||
|
t.Errorf("fish in non-fishing zone should not seed nodes; got %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user