From c2bed4282d30cf55f08f852ec140ea2cf32c209f Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 16:16:24 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E4b: region progression hooks + status display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-region zones now default CurrentRegion to the entry region at startExpedition; that region is also written to regions_visited so the visited/cleared distinction is meaningful from Day 1. MarkRegionVisited / MarkRegionBossDefeated expose the combat-link hook surface. MarkRegionBossDefeated also flips Expedition.BossDefeated when the cleared region's IsZoneBoss is set, since the zone-boss kill is the same event from §7's POV (suppresses temporal accumulation, kills further awareness pulses, etc.). setCurrentRegion is the companion writer used by E4c's travel command. !expedition status now shows the region line ("🗺 Region: Drow Outpost (2/4)") with a ✓ marker when the region boss is down. --- internal/plugin/dnd_expedition.go | 41 +++++++----- internal/plugin/dnd_expedition_cmd.go | 9 +++ internal/plugin/dnd_expedition_region.go | 55 ++++++++++++++++ internal/plugin/dnd_expedition_region_test.go | 63 +++++++++++++++++++ 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index 53676cb..534bf27 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -132,19 +132,30 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp } now := time.Now().UTC() + // Multi-region zones (§11) start the player in the first region; + // single-region zones leave CurrentRegion empty. + currentRegion := "" + if first, ok := firstRegion(zoneID); ok { + currentRegion = first.ID + } + regionState := map[string]any{} + if currentRegion != "" { + regionState[regionStateVisitedKey] = []string{currentRegion} + } exp := &Expedition{ - ID: newExpeditionID(), - UserID: string(userID), - ZoneID: zoneID, - RunID: runID, - Status: ExpeditionStatusActive, - StartDate: now, - CurrentDay: 1, - Supplies: supplies, - ThreatEvents: []ThreatEvent{}, - RegionState: map[string]any{}, - GMMood: 50, - LastActivity: now, + ID: newExpeditionID(), + UserID: string(userID), + ZoneID: zoneID, + RunID: runID, + Status: ExpeditionStatusActive, + StartDate: now, + CurrentDay: 1, + CurrentRegion: currentRegion, + Supplies: supplies, + ThreatEvents: []ThreatEvent{}, + RegionState: regionState, + GMMood: 50, + LastActivity: now, } supJSON, _ := json.Marshal(supplies) regJSON, _ := json.Marshal(exp.RegionState) @@ -153,11 +164,11 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp if _, err := db.Get().Exec(` INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, - start_date, current_day, supplies_json, + start_date, current_day, current_region, supplies_json, threat_events, region_state, gm_mood, last_activity) - VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, 50, ?)`, + VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, ?, 50, ?)`, exp.ID, exp.UserID, string(zoneID), nullableString(runID), - now, string(supJSON), string(threatJSON), string(regJSON), now, + now, currentRegion, string(supJSON), string(threatJSON), string(regJSON), now, ); err != nil { return nil, fmt.Errorf("insert expedition: %w", err) } diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index ea86db4..2e71232 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -230,6 +230,15 @@ func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error { var b strings.Builder b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d**\n\n", exp.CurrentDay)) b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier))) + if r, ok := CurrentRegion(exp); ok { + cleared := IsRegionCleared(exp, r.ID) + marker := "" + if cleared { + marker = " ✓" + } + b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n", + r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker)) + } if c != nil { b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax)) } diff --git a/internal/plugin/dnd_expedition_region.go b/internal/plugin/dnd_expedition_region.go index 043b562..47c88e1 100644 --- a/internal/plugin/dnd_expedition_region.go +++ b/internal/plugin/dnd_expedition_region.go @@ -1,5 +1,7 @@ package plugin +import "gogobee/internal/db" + // Phase 12 E4a — Multi-Region Zone data model and registry. // Spec: gogobee_expedition_system.md §11. // @@ -267,3 +269,56 @@ func IsRegionVisited(e *Expedition, regionID string) bool { func HasBaseCampAt(e *Expedition, regionID string) bool { return regionListContains(regionListFromState(e, regionStateBaseCampKey), regionID) } + +// MarkRegionVisited records that the player has set foot in `regionID`. +// Idempotent. Returns true if state changed. +func MarkRegionVisited(e *Expedition, regionID string) (bool, error) { + if e == nil || regionID == "" { + return false, nil + } + return addRegionListEntry(e, regionStateVisitedKey, regionID) +} + +// MarkRegionBossDefeated records that the region boss for `regionID` +// has been killed. If the region's IsZoneBoss flag is set, the +// expedition's BossDefeated zone-level flag is also flipped (§7 +// suppression hooks read it). Idempotent. Returns true if state +// changed. +// +// Combat-link phase calls this from the per-region boss-kill resolver. +func MarkRegionBossDefeated(e *Expedition, regionID string) (bool, error) { + if e == nil || regionID == "" { + return false, nil + } + region, ok := findRegion(e.ZoneID, regionID) + if !ok { + return false, nil + } + changed, err := addRegionListEntry(e, regionStateClearedKey, regionID) + if err != nil { + return changed, err + } + if region.IsZoneBoss && !e.BossDefeated { + e.BossDefeated = true + if _, err := db.Get().Exec(` + UPDATE dnd_expedition + SET boss_defeated = 1, + last_activity = CURRENT_TIMESTAMP + WHERE expedition_id = ?`, e.ID); err != nil { + return changed, err + } + changed = true + } + return changed, nil +} + +// setCurrentRegion writes a new CurrentRegion. Used by E4c travel. +func setCurrentRegion(e *Expedition, regionID string) error { + e.CurrentRegion = regionID + _, err := db.Get().Exec(` + UPDATE dnd_expedition + SET current_region = ?, + last_activity = CURRENT_TIMESTAMP + WHERE expedition_id = ?`, regionID, e.ID) + return err +} diff --git a/internal/plugin/dnd_expedition_region_test.go b/internal/plugin/dnd_expedition_region_test.go index 0dc7d50..1fc3055 100644 --- a/internal/plugin/dnd_expedition_region_test.go +++ b/internal/plugin/dnd_expedition_region_test.go @@ -84,6 +84,69 @@ func TestCurrentRegion_DefaultsToFirst(t *testing.T) { } } +func TestMarkRegionBossDefeated_FlipsZoneBoss(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@region-boss:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneUnderdark, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + + // Non-zone-boss region: clears region, leaves zone BossDefeated. + if changed, err := MarkRegionBossDefeated(exp, "underdark_drow_outpost"); err != nil || !changed { + t.Fatalf("first mark: changed=%v err=%v", changed, err) + } + if !IsRegionCleared(exp, "underdark_drow_outpost") { + t.Error("region not cleared after Mark") + } + if exp.BossDefeated { + t.Error("BossDefeated flipped on non-zone-boss region") + } + + // Idempotent. + if changed, _ := MarkRegionBossDefeated(exp, "underdark_drow_outpost"); changed { + t.Error("idempotent mark reported change") + } + + // Zone-boss region: flips BossDefeated and persists. + if changed, err := MarkRegionBossDefeated(exp, "underdark_deep_throne"); err != nil || !changed { + t.Fatalf("zone-boss mark: changed=%v err=%v", changed, err) + } + if !exp.BossDefeated { + t.Error("BossDefeated not set after zone-boss kill") + } + loaded, _ := getActiveExpedition(uid) + if !loaded.BossDefeated { + t.Error("BossDefeated did not persist") + } + if !IsRegionCleared(loaded, "underdark_deep_throne") { + t.Error("zone-boss region not in cleared list after reload") + } +} + +func TestStartExpedition_DefaultsCurrentRegionForMultiRegionZone(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@region-default:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneAbyssPortal, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + if exp.CurrentRegion != "abyss_outer_rift" { + t.Errorf("CurrentRegion = %q, want abyss_outer_rift", exp.CurrentRegion) + } + if !IsRegionVisited(exp, "abyss_outer_rift") { + t.Error("entry region not in visited list") + } + loaded, _ := getActiveExpedition(uid) + if loaded.CurrentRegion != "abyss_outer_rift" { + t.Errorf("reload CurrentRegion = %q", loaded.CurrentRegion) + } +} + func TestRegionStateLists_PersistAndRoundTrip(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@region-state:example.org")