diff --git a/internal/db/db.go b/internal/db/db.go index 098f767..32ad278 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -182,6 +182,10 @@ func runMigrations(d *sql.DB) error { // this after each rage'd combat. Cleared on long rest. Reused by // other classes once exhaustion-inducing mechanics arrive in SUB3+. `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 { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/dnd_zone_harvest.go b/internal/plugin/dnd_zone_harvest.go new file mode 100644 index 0000000..6f8a41c --- /dev/null +++ b/internal/plugin/dnd_zone_harvest.go @@ -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 diff --git a/internal/plugin/dnd_zone_harvest_test.go b/internal/plugin/dnd_zone_harvest_test.go new file mode 100644 index 0000000..ec2aad4 --- /dev/null +++ b/internal/plugin/dnd_zone_harvest_test.go @@ -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) + } +}