Files
gogobee/internal/plugin/dnd_expedition_harvest_test.go
prosolis 0ca17628d4 WIP: DM window tiers, expedition activity dedup, harvest gating
- Split advDMResponseWindow into a 12h low-priority tier for passive
  overnight-tolerant prompts (pet arrival/type/name); bump pet arrival
  chance 15% -> 30%.
- Skip zone_run activity lines for runs that belong to an active
  expedition — the expedition rollup is the source of truth.
- Loosen currentRoomCleared: Entry/Exploration/Trap allow harvest
  unconditionally (risk is the combat-interrupt roll); Elite/Boss stay
  gated on RoomsCleared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:53:58 -07:00

414 lines
13 KiB
Go

package plugin
import (
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── pure-function tests (no DB) ────────────────────────────────────────────
// TestLegacyRoomIdxFromNodeID exercises the G6 migration parser:
// `<zone>.r<N+1>` round-trips, hand-authored ids return ok=false.
func TestLegacyRoomIdxFromNodeID(t *testing.T) {
cases := []struct {
nodeID string
zone ZoneID
want int
ok bool
}{
{"goblin_warrens.r1", ZoneGoblinWarrens, 0, true},
{"crypt_valdris.r5", ZoneCryptValdris, 4, true},
{"crypt_valdris.fork_a", ZoneCryptValdris, 0, false},
{"goblin_warrens.r1", ZoneCryptValdris, 0, false}, // wrong zone prefix
{"goblin_warrens.r0", ZoneGoblinWarrens, 0, false},
{"", ZoneGoblinWarrens, 0, false},
}
for _, c := range cases {
got, ok := legacyRoomIdxFromNodeID(c.nodeID, c.zone)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("legacyRoomIdxFromNodeID(%q, %s) = (%d, %t), want (%d, %t)",
c.nodeID, c.zone, got, ok, c.want, c.ok)
}
}
}
// TestLoadHarvestNodes_LegacyRoomKeyFallback confirms a row with the
// pre-G6 `strconv.Itoa(roomIdx)` storage key is read via the fallback
// when the new node-id key is queried.
func TestLoadHarvestNodes_LegacyRoomKeyFallback(t *testing.T) {
exp := &Expedition{
ZoneID: ZoneGoblinWarrens,
CurrentRegion: "",
RegionState: map[string]any{
regionStateHarvestKey: map[string]map[string][]HarvestNode{
"": {
"3": {{ResourceID: "iron_ore", CurrentCharges: 0, MaxCharges: 2}},
},
},
},
}
nodeID := deriveLegacyNodeID(ZoneGoblinWarrens, 3)
got := loadHarvestNodes(exp, nodeID)
if len(got) != 1 || got[0].ResourceID != "iron_ore" || got[0].CurrentCharges != 0 {
t.Fatalf("legacy fallback didn't return depleted node, got %+v", got)
}
// Authored (non-legacy) node id with no entry should lazy-seed fresh,
// not pick up the legacy "3" entry.
fresh := loadHarvestNodes(exp, "goblin_warrens.fork_a")
for _, n := range fresh {
if n.ResourceID == "iron_ore" && n.CurrentCharges == 0 {
t.Errorf("fork_a leaked depleted state from legacy room 3 entry")
}
}
}
// TestSaveHarvestNodes_DropsLegacyRoomKey verifies the migration-on-save
// drops the legacy `strconv.Itoa(roomIdx)` entry after persistence runs.
func TestSaveHarvestNodes_DropsLegacyRoomKey(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@harvest-migrate: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 the legacy "3" key directly so the load fallback fires.
exp.RegionState[regionStateHarvestKey] = map[string]map[string][]HarvestNode{
"": {
"3": {{ResourceID: "iron_ore", CurrentCharges: 0, MaxCharges: 2}},
},
}
nodeID := deriveLegacyNodeID(ZoneGoblinWarrens, 3)
got := loadHarvestNodes(exp, nodeID)
if err := saveHarvestNodes(exp, nodeID, got); err != nil {
t.Fatalf("saveHarvestNodes: %v", err)
}
table := loadHarvestTable(exp)
if _, exists := table[""]["3"]; exists {
t.Errorf("legacy room-idx key '3' should be dropped after migration save")
}
if _, exists := table[""][nodeID]; !exists {
t.Errorf("expected node-id key %q after migration save", nodeID)
}
}
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, RoomTrap, RoomElite, RoomBoss},
RoomsCleared: []int{},
}
if !currentRoomCleared(run) {
t.Error("entry room should auto-clear")
}
// Exploration always allowed — interrupt roll handles risk.
run.CurrentRoom = 1
if !currentRoomCleared(run) {
t.Error("exploration room should always allow harvest")
}
// Trap always allowed.
run.CurrentRoom = 2
if !currentRoomCleared(run) {
t.Error("trap room should always allow harvest")
}
// Elite gated until in RoomsCleared.
run.CurrentRoom = 3
if currentRoomCleared(run) {
t.Error("uncleared elite room should block harvest")
}
run.RoomsCleared = []int{3}
if !currentRoomCleared(run) {
t.Error("elite room in RoomsCleared should report cleared")
}
// Boss gated until in RoomsCleared.
run.CurrentRoom = 4
run.RoomsCleared = []int{3}
if currentRoomCleared(run) {
t.Error("uncleared boss room should block harvest")
}
run.RoomsCleared = []int{3, 4}
if !currentRoomCleared(run) {
t.Error("boss 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.
n0 := deriveLegacyNodeID(ZoneGoblinWarrens, 0)
n1 := deriveLegacyNodeID(ZoneGoblinWarrens, 1)
r0 := loadHarvestNodes(exp, n0)
r0[0].CurrentCharges = 0
if err := saveHarvestNodes(exp, n0, r0); err != nil {
t.Fatal(err)
}
r1 := loadHarvestNodes(exp, n1)
r1[0].CurrentCharges = 0
if err := saveHarvestNodes(exp, n1, r1); err != nil {
t.Fatal(err)
}
if err := ReplenishHarvestNodes(exp); err != nil {
t.Fatal(err)
}
for _, idx := range []int{0, 1} {
nodes := loadHarvestNodes(exp, deriveLegacyNodeID(ZoneGoblinWarrens, 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)
}
n2 := deriveLegacyNodeID(ZoneGoblinWarrens, 2)
n5 := deriveLegacyNodeID(ZoneGoblinWarrens, 5)
nodes := loadHarvestNodes(exp, n2)
nodes[0].CurrentCharges = 0
if err := saveHarvestNodes(exp, n2, nodes); err != nil {
t.Fatal(err)
}
// Reload from DB.
reloaded, err := getActiveExpedition(uid)
if err != nil {
t.Fatal(err)
}
got := loadHarvestNodes(reloaded, n2)
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, n5)
for _, n := range other {
if n.CurrentCharges != n.MaxCharges {
t.Errorf("room 5 leaked depletion: %+v", n)
}
}
}