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")