mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Adv 2.0 D&D Phase 12 E4a: Region data model + zone registry
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.
This commit is contained in:
269
internal/plugin/dnd_expedition_region.go
Normal file
269
internal/plugin/dnd_expedition_region.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
121
internal/plugin/dnd_expedition_region_test.go
Normal file
121
internal/plugin/dnd_expedition_region_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user