From 9484dae1460118e396005796f8aacc3c8600a4c4 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 16:14:35 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E4a: Region data model + zone registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec: gogobee_expedition_system.md §11. Tier 4–5 zones (Underdark, Dragon's Lair, Abyss Portal) split into 4 regions each. ExpeditionRegion struct + per-zone registry; CurrentRegion(e) defaults to Order==1 when unset; RegionState helpers persist regions_cleared / regions_visited / base_camps as []string lists. Single-region zones return nil. Tests cover registry shape (4 per zone, last is IsZoneBoss), lookups, default current region, and round-trip persistence through DB. --- internal/plugin/dnd_expedition_region.go | 269 ++++++++++++++++++ internal/plugin/dnd_expedition_region_test.go | 121 ++++++++ 2 files changed, 390 insertions(+) create mode 100644 internal/plugin/dnd_expedition_region.go create mode 100644 internal/plugin/dnd_expedition_region_test.go diff --git a/internal/plugin/dnd_expedition_region.go b/internal/plugin/dnd_expedition_region.go new file mode 100644 index 0000000..043b562 --- /dev/null +++ b/internal/plugin/dnd_expedition_region.go @@ -0,0 +1,269 @@ +package plugin + +// Phase 12 E4a — Multi-Region Zone data model and registry. +// Spec: gogobee_expedition_system.md §11. +// +// Tier 4–5 zones (Underdark, Dragon's Lair, Abyss Portal) are too +// large for a single sequence of rooms; they are divided into Regions, +// each functioning as a sub-dungeon with its own room sequence, region +// boss, base-camp eligibility, enemy subset and loot table. +// +// E4a ships the data layer: ExpeditionRegion struct, the per-zone +// region registries, and persistence helpers that serialize cleared / +// boss-defeated state into Expedition.RegionState. +// +// E4b wires hooks for marking a region boss defeated / region cleared +// and surfaces the current-region label in !expedition status. E4c +// adds the !region travel command (1 in-game day + supply burn + +// wandering check). E4d flips !camp base from the deferred branch in +// dnd_expedition_camp.go to the per-region waypoint. + +// ExpeditionRegion is the in-memory shape of one sub-dungeon within +// a multi-region zone. (Spec §11.4.) Mirrors the spec struct verbatim +// except `LootTable` is intentionally omitted here — region loot is +// drawn from the parent zone's loot table for now; a region-specific +// override lands with the loot/encounter rebalance phase. +type ExpeditionRegion struct { + ID string // stable id, e.g. "underdark_drow_outpost" + ZoneID ZoneID // parent zone + Name string // display name, e.g. "Drow Outpost" + Order int // 1-indexed order within the zone + BaseCampSite bool // §11.1 — eligible for !camp base post-boss + RegionBoss string // descriptive name (§11.2 column 3) + IsZoneBoss bool // last region — boss is the zone boss itself + EnemySubset []string // bestiary IDs (subset of zone roster) +} + +// regionsByZone is the static registry. Only multi-region zones appear; +// single-region zones (everything else) return nil from regionsForZone. +var regionsByZone = map[ZoneID][]ExpeditionRegion{ + ZoneUnderdark: { + { + ID: "underdark_surface_tunnels", ZoneID: ZoneUnderdark, Order: 1, + Name: "Surface Tunnels", BaseCampSite: false, + RegionBoss: "Tunnel Predator (mini-boss)", + EnemySubset: []string{"hook_horror"}, + }, + { + ID: "underdark_drow_outpost", ZoneID: ZoneUnderdark, Order: 2, + Name: "Drow Outpost", BaseCampSite: true, + RegionBoss: "Drow Elite Captain", + EnemySubset: []string{"drow", "drow_elite_warrior", "drow_mage"}, + }, + { + ID: "underdark_illithid_warren", ZoneID: ZoneUnderdark, Order: 3, + Name: "Illithid Warren", BaseCampSite: true, + RegionBoss: "Mind Flayer Elder", + EnemySubset: []string{"mind_flayer", "roper"}, + }, + { + ID: "underdark_deep_throne", ZoneID: ZoneUnderdark, Order: 4, + Name: "The Deep Throne", BaseCampSite: false, + RegionBoss: "Ilvaras Xunyl (Zone Boss)", IsZoneBoss: true, + EnemySubset: []string{"drow", "drow_elite_warrior", "drow_mage"}, + }, + }, + ZoneDragonsLair: { + { + ID: "dragons_lair_kobold_warrens", ZoneID: ZoneDragonsLair, Order: 1, + Name: "Kobold Warrens", BaseCampSite: false, + RegionBoss: "Kobold Warchief", + EnemySubset: []string{"kobold", "kobold_scale_sorcerer"}, + }, + { + ID: "dragons_lair_drake_pens", ZoneID: ZoneDragonsLair, Order: 2, + Name: "Drake Pens", BaseCampSite: true, + RegionBoss: "Elder Drake", + EnemySubset: []string{"guard_drake", "dragonborn_cultist"}, + }, + { + ID: "dragons_lair_the_vault", ZoneID: ZoneDragonsLair, Order: 3, + Name: "The Vault", BaseCampSite: true, + RegionBoss: "Hoard Wyrmling Pair", + EnemySubset: []string{"young_red_dragon", "dragonborn_cultist"}, + }, + { + ID: "dragons_lair_infernax_chamber", ZoneID: ZoneDragonsLair, Order: 4, + Name: "Infernax's Chamber", BaseCampSite: false, + RegionBoss: "Infernax (Zone Boss)", IsZoneBoss: true, + EnemySubset: []string{"young_red_dragon"}, + }, + }, + ZoneAbyssPortal: { + { + ID: "abyss_outer_rift", ZoneID: ZoneAbyssPortal, Order: 1, + Name: "Outer Rift", BaseCampSite: false, + RegionBoss: "Vrock Commander", + EnemySubset: []string{"quasit", "vrock"}, + }, + { + ID: "abyss_demon_assembly", ZoneID: ZoneAbyssPortal, Order: 2, + Name: "Demon Assembly", BaseCampSite: true, + RegionBoss: "Nalfeshnee", + EnemySubset: []string{"vrock", "hezrou", "nalfeshnee"}, + }, + { + ID: "abyss_wardens_post", ZoneID: ZoneAbyssPortal, Order: 3, + Name: "The Warden's Post", BaseCampSite: true, + RegionBoss: "Marilith Warden", + EnemySubset: []string{"hezrou", "nalfeshnee", "marilith"}, + }, + { + ID: "abyss_the_tear", ZoneID: ZoneAbyssPortal, Order: 4, + Name: "The Tear", BaseCampSite: false, + RegionBoss: "Belaxath (Zone Boss)", IsZoneBoss: true, + EnemySubset: []string{"marilith"}, + }, + }, +} + +// regionsForZone returns the ordered region list for `z`, or nil if `z` +// is a single-region zone. +func regionsForZone(z ZoneID) []ExpeditionRegion { + rs, ok := regionsByZone[z] + if !ok { + return nil + } + out := make([]ExpeditionRegion, len(rs)) + copy(out, rs) + return out +} + +// IsMultiRegionZone reports whether `z` has a region structure. +func IsMultiRegionZone(z ZoneID) bool { + _, ok := regionsByZone[z] + return ok +} + +// findRegion looks up by ID within a zone. Returns the region and true, +// or zero-value and false. +func findRegion(z ZoneID, regionID string) (ExpeditionRegion, bool) { + for _, r := range regionsForZone(z) { + if r.ID == regionID { + return r, true + } + } + return ExpeditionRegion{}, false +} + +// regionByOrder returns the region at 1-indexed `order`, or zero+false. +func regionByOrder(z ZoneID, order int) (ExpeditionRegion, bool) { + for _, r := range regionsForZone(z) { + if r.Order == order { + return r, true + } + } + return ExpeditionRegion{}, false +} + +// firstRegion returns the entry region for a multi-region zone (Order +// == 1), or zero+false for single-region zones. +func firstRegion(z ZoneID) (ExpeditionRegion, bool) { + return regionByOrder(z, 1) +} + +// nextRegion returns the next-in-order region after `cur`, or zero+false +// if `cur` is the last region. +func nextRegion(z ZoneID, cur string) (ExpeditionRegion, bool) { + r, ok := findRegion(z, cur) + if !ok { + return ExpeditionRegion{}, false + } + return regionByOrder(z, r.Order+1) +} + +// CurrentRegion returns the player's current ExpeditionRegion, or +// zero+false if the zone is single-region or the stored CurrentRegion +// is empty / unrecognized. +func CurrentRegion(e *Expedition) (ExpeditionRegion, bool) { + if e == nil || !IsMultiRegionZone(e.ZoneID) { + return ExpeditionRegion{}, false + } + if e.CurrentRegion == "" { + return firstRegion(e.ZoneID) + } + return findRegion(e.ZoneID, e.CurrentRegion) +} + +// ── progression state stored in RegionState ──────────────────────── +// +// RegionState["regions_cleared"] []string — region IDs whose +// region boss has been +// defeated. +// RegionState["regions_visited"] []string — region IDs the player +// has set foot in. +// RegionState["base_camps"] []string — region IDs where a +// persistent base camp +// has been pitched. + +const ( + regionStateClearedKey = "regions_cleared" + regionStateVisitedKey = "regions_visited" + regionStateBaseCampKey = "base_camps" +) + +// regionListFromState reads a string-list slot from RegionState. JSON +// round-trips from sqlite turn []string into []any, so we accept both. +func regionListFromState(e *Expedition, key string) []string { + if e == nil || e.RegionState == nil { + return nil + } + raw, ok := e.RegionState[key] + if !ok { + return nil + } + switch v := raw.(type) { + case []string: + return append([]string(nil), v...) + case []any: + out := make([]string, 0, len(v)) + for _, x := range v { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} + +// regionListContains reports membership. +func regionListContains(list []string, id string) bool { + for _, x := range list { + if x == id { + return true + } + } + return false +} + +// addRegionListEntry adds `id` to the list at RegionState[key] if it +// isn't already there. Persists. Returns true if the list changed. +func addRegionListEntry(e *Expedition, key, id string) (bool, error) { + cur := regionListFromState(e, key) + if regionListContains(cur, id) { + return false, nil + } + cur = append(cur, id) + if e.RegionState == nil { + e.RegionState = map[string]any{} + } + e.RegionState[key] = cur + return true, persistRegionState(e) +} + +// IsRegionCleared reports whether the region's boss has been defeated. +func IsRegionCleared(e *Expedition, regionID string) bool { + return regionListContains(regionListFromState(e, regionStateClearedKey), regionID) +} + +// IsRegionVisited reports whether the player has entered the region. +func IsRegionVisited(e *Expedition, regionID string) bool { + return regionListContains(regionListFromState(e, regionStateVisitedKey), regionID) +} + +// HasBaseCampAt reports whether a persistent base camp exists in the +// region (§11.1: unlocked only after region boss is cleared). +func HasBaseCampAt(e *Expedition, regionID string) bool { + return regionListContains(regionListFromState(e, regionStateBaseCampKey), regionID) +} diff --git a/internal/plugin/dnd_expedition_region_test.go b/internal/plugin/dnd_expedition_region_test.go new file mode 100644 index 0000000..0dc7d50 --- /dev/null +++ b/internal/plugin/dnd_expedition_region_test.go @@ -0,0 +1,121 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +func TestRegionsForZone_OnlyMultiRegion(t *testing.T) { + for _, z := range []ZoneID{ZoneUnderdark, ZoneDragonsLair, ZoneAbyssPortal} { + rs := regionsForZone(z) + if len(rs) != 4 { + t.Errorf("zone %s: got %d regions, want 4", z, len(rs)) + } + if !IsMultiRegionZone(z) { + t.Errorf("IsMultiRegionZone(%s) = false", z) + } + // Verify last region marks IsZoneBoss; first does not. + if rs[0].IsZoneBoss { + t.Errorf("zone %s: first region marked IsZoneBoss", z) + } + if !rs[len(rs)-1].IsZoneBoss { + t.Errorf("zone %s: last region not marked IsZoneBoss", z) + } + // Verify Order is 1..N. + for i, r := range rs { + if r.Order != i+1 { + t.Errorf("zone %s: regions[%d].Order = %d", z, i, r.Order) + } + if r.ZoneID != z { + t.Errorf("zone %s: region %s has ZoneID %s", z, r.ID, r.ZoneID) + } + } + } + // Non-multi-region zone returns nil. + if rs := regionsForZone(ZoneSunkenTemple); rs != nil { + t.Errorf("ZoneSunkenTemple regions = %v, want nil", rs) + } + if IsMultiRegionZone(ZoneSunkenTemple) { + t.Error("ZoneSunkenTemple should not be multi-region") + } +} + +func TestRegionLookups(t *testing.T) { + if r, ok := firstRegion(ZoneUnderdark); !ok || r.ID != "underdark_surface_tunnels" { + t.Errorf("firstRegion underdark = %+v / %v", r, ok) + } + if r, ok := nextRegion(ZoneUnderdark, "underdark_surface_tunnels"); !ok || r.ID != "underdark_drow_outpost" { + t.Errorf("nextRegion = %+v / %v", r, ok) + } + if _, ok := nextRegion(ZoneUnderdark, "underdark_deep_throne"); ok { + t.Error("nextRegion past last should be false") + } + if _, ok := findRegion(ZoneUnderdark, "no_such_region"); ok { + t.Error("findRegion of missing id returned ok") + } +} + +func TestCurrentRegion_DefaultsToFirst(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@region-cur:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneUnderdark, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + r, ok := CurrentRegion(exp) + if !ok { + t.Fatal("CurrentRegion returned !ok") + } + if r.ID != "underdark_surface_tunnels" { + t.Errorf("CurrentRegion default = %s, want underdark_surface_tunnels", r.ID) + } + + // Single-region zone returns false. + cleanupExpeditions(uid) + exp2, err := startExpedition(uid, ZoneSunkenTemple, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + if _, ok := CurrentRegion(exp2); ok { + t.Error("CurrentRegion on single-region zone returned ok") + } +} + +func TestRegionStateLists_PersistAndRoundTrip(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@region-state:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneUnderdark, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + + if changed, err := addRegionListEntry(exp, regionStateClearedKey, "underdark_surface_tunnels"); err != nil || !changed { + t.Fatalf("first add: changed=%v err=%v", changed, err) + } + if changed, err := addRegionListEntry(exp, regionStateClearedKey, "underdark_surface_tunnels"); err != nil || changed { + t.Errorf("duplicate add: changed=%v err=%v (want false/nil)", changed, err) + } + if !IsRegionCleared(exp, "underdark_surface_tunnels") { + t.Error("IsRegionCleared after add = false") + } + + // Round-trip through DB. + loaded, err := getActiveExpedition(uid) + if err != nil || loaded == nil { + t.Fatalf("reload: %v / %v", loaded, err) + } + if !IsRegionCleared(loaded, "underdark_surface_tunnels") { + t.Error("IsRegionCleared after reload = false (state did not persist)") + } + if IsRegionCleared(loaded, "underdark_drow_outpost") { + t.Error("IsRegionCleared for unwritten region = true") + } + if HasBaseCampAt(loaded, "underdark_drow_outpost") { + t.Error("HasBaseCampAt unset = true") + } +}