Adv 2.0 Phase L1: Babysit pivot + legacy resolver retire

Babysit pivots from harvest service to pet-care + safe-rest perk:
- runBabysitDaily/runAutoBabysitDay/runBabysitAction deleted; replaced
  by runBabysitDailyTrickle (3 pet XP/day while subscribed).
- BabysitSafeRest predicate promotes Standard camps to Fortified-tier
  rest (HP+1d6, threat -5, resources refresh) and reduces wandering
  monster check campMod from 0 to -4. Hooks live in
  dnd_expedition_camp.go and dnd_expedition_night.go.
- !adventure babysit auto/focus subcommands removed; status/purchase
  DMs reflect the new perks.

Scheduler drops the auto-babysit fallback block (no more silent
debits + harvest-day for missed logins). Babysit subscribers still
skip the morning DM; trickle runs in the background.

TwinBee daily simulator self-contained: simulateTwinBeeOutcome +
simulateTwinBeeLoot replace the resolveAdvAction call against a
fake CombatLevel=35 character. Outcome distribution and reward
shape preserved; gold-share to active players unchanged.

Legacy activity resolver retired: resolveAdvAction,
advEligibleLocations, AdvEligibleLocation, resolveAdvEmptyOutcome
deleted from adventure_activities.go (zero production callers
post-babysit/twinbee). AdvActionResult kept — combat_bridge still
produces it for arena/dungeon flows; dies in L2/L3.

Migration plan doc gogobee_legacy_migration.md added: five-phase
L1-L5 plan covering arena (Adv 2.0 boss flow rebuild), co-op
deletion, perk-gate sweep, and final teardown of AdventureCharacter
+ CombatLevel + combat_engine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 00:10:21 -07:00
parent 45863bd5f3
commit 1953eec3b5
10 changed files with 743 additions and 781 deletions

441
gogobee_legacy_migration.md Normal file
View File

@@ -0,0 +1,441 @@
# GogoBee — Legacy Adventure → D&D Engine Migration Plan
> **Companion to:** `gogobee_dnd_design_doc.md`, `gogobee_expedition_system.md`, `gogobee_resource_combat_integration.md`
> **Version:** 0.1 (planning draft)
> **Status:** Pre-implementation — scoping
> **Branch:** `adv-2.0`
---
## 0. Why this doc exists
Phase R (resource/combat integration) shipped the new harvest/combat surface and gated the player-facing legacy daily-loop dispatch. The internal helpers (`resolveAdvAction`, `advEligibleLocations`, `AdvLocation` tier data, `AdventureCharacter`, `CombatLevel`, `combat_engine.go`) are still alive because non-deprecated subsystems consume them: arena, co-op dungeons, babysit/scheduler, hospital, rival, masterwork, pets, housing, mortgage, twinbee morning DM.
This doc inventories every surviving caller and lays out a phased deletion path. The endgame is: `AdventureCharacter` deleted, `CombatLevel` field gone, `combat_engine.go` removed, `combat_bridge.go` removed, branching dungeon paths become buildable on a clean schema.
**Non-goals.** This is not a redesign. We are not adding features during the migration. New mechanics (branching paths, multi-region expansions, new arena formats) are sequenced *after* L5 teardown lands.
---
## 1. Inventory — what's still on the legacy engine
| Subsystem | Files | LOC | AdvCharacter coupling | CombatLevel coupling | D&D parallel | Risk |
|---|---|---|---|---|---|---|
| Babysit/scheduler | `adventure_babysit.go`, `adventure_scheduler.go` | 1,169 | mutates: CombatXP, Alive, ActionTakenToday | reads | none (auto-actions) | low |
| Arena | `adventure_arena*.go` (4 files) | 1,228 | mutates: Alive, ArenaWins/Losses, CombatXP | gates tier, scales rewards | `dnd_zone_combat.go` | high |
| Co-op dungeons | `coop_dungeon*.go` (9 files) | 4,027 | reads-only (minLevel gate, liability) | gates participation | `dnd_expedition_combat.go` | medium |
| Hospital | `adventure_hospital.go` | 288 | mutates: Alive, HospitalVisits | cost = level × 25k/125k | none | low |
| Rival | `adventure_rival.go` | 866 | mutates: RivalPool, CombatXP | unlock at L5, stake calc | none | medium |
| Masterwork | `adventure_masterwork.go` | 543 | reads equip, no char mutation | none | none | low |
| Pets | `adventure_pets.go` | 448 | mutates pet fields on AdvCharacter | none | none | low |
| Housing/mortgage | `adventure_housing.go`, `adventure_mortgage.go` | 922 | mutates House* fields | none | none | low |
| TwinBee/render | `adventure_render.go`, `adventure_twinbee.go` | 1,472 | reads stats; simulator calls `resolveAdvAction` | reads | none | low |
| Combat bridge | `combat_bridge.go` | 363 | translation layer | — | — | (delete) |
**Source of truth:** `AdventureCharacter` is defined at `adventure_character.go:27` (76 fields). `DnDCharacter` at `dnd.go:152`.
**Live `resolveAdvAction` callers (post-R1):** `adventure_babysit.go:349`, `adventure_twinbee.go:135` (simulator).
---
## 2. Architectural decision — char unification strategy
Three options were considered:
**A. Big-bang field merge.** Move every surviving AdvCharacter field onto DnDCharacter, then s/AdventureCharacter/DnDCharacter/g. Rejected: unsafe, no incremental landing, breaks every test for one PR.
**B. Two-character coexistence forever.** Keep both, route by feature. Rejected: this is what we have now; doesn't actually retire anything.
**C. Phased subsystem migration with shared "extra" record.** ✅ Chosen. Each subsystem is migrated to read/write D&D state where there's a parallel, and to a new `player_meta` table for fields that don't fit on a character (HouseTier, RivalPool, ArenaWins, BabysitActive, MistyLastSeen, etc.). When all subsystems are migrated, AdvCharacter is deleted in a single L5 commit.
The `player_meta` table is the holding pen for non-stat per-user state that's currently squatting on AdvCharacter. It's not a redesign — it's a structural move so the deletion can happen cleanly.
### 2.1 `player_meta` schema (proposed)
> **Naming note.** New tables/types in this migration deliberately avoid the `dnd_` prefix used by older code (legal/trademark exposure flagged by user). Existing `dnd_*` symbols stay in place — only new surface uses neutral names.
```sql
CREATE TABLE player_meta (
user_id TEXT PRIMARY KEY,
-- arena
arena_wins INTEGER NOT NULL DEFAULT 0,
arena_losses INTEGER NOT NULL DEFAULT 0,
invasion_score INTEGER NOT NULL DEFAULT 0,
-- rival
rival_pool INTEGER NOT NULL DEFAULT 0,
rival_unlocked_notified INTEGER NOT NULL DEFAULT 0,
-- housing
house_tier INTEGER NOT NULL DEFAULT 0,
house_loan_balance INTEGER NOT NULL DEFAULT 0,
house_loan_frozen INTEGER NOT NULL DEFAULT 0,
house_missed_payments INTEGER NOT NULL DEFAULT 0,
house_autopay INTEGER NOT NULL DEFAULT 0,
house_current_rate REAL NOT NULL DEFAULT 0,
-- pets
pet_type TEXT NOT NULL DEFAULT '',
pet_name TEXT NOT NULL DEFAULT '',
pet_xp INTEGER NOT NULL DEFAULT 0,
pet_level INTEGER NOT NULL DEFAULT 0,
pet_armor_tier INTEGER NOT NULL DEFAULT 0,
pet_flags_json TEXT NOT NULL DEFAULT '{}', -- chased_away/reactivated/arrived/morning_defense/etc
-- npcs
misty_last_seen INTEGER,
arina_last_seen INTEGER,
misty_buff_expires INTEGER,
misty_debuff_expires INTEGER,
arina_buff_expires INTEGER,
misty_encounter_count INTEGER NOT NULL DEFAULT 0,
misty_donated_count INTEGER NOT NULL DEFAULT 0,
misty_roll_target INTEGER NOT NULL DEFAULT 0,
arina_roll_target INTEGER NOT NULL DEFAULT 0,
-- babysit
babysit_active INTEGER NOT NULL DEFAULT 0,
babysit_expires_at INTEGER,
babysit_skill_focus TEXT NOT NULL DEFAULT '',
auto_babysit INTEGER NOT NULL DEFAULT 0,
auto_babysit_focus TEXT NOT NULL DEFAULT '',
-- hospital / death
hospital_visits INTEGER NOT NULL DEFAULT 0,
last_death_date TEXT NOT NULL DEFAULT '',
death_source TEXT NOT NULL DEFAULT '',
death_location TEXT NOT NULL DEFAULT '',
last_pardon_used INTEGER,
death_reprieve_last INTEGER,
-- streaks / chores
current_streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
last_action_date TEXT NOT NULL DEFAULT '',
grudge_location TEXT NOT NULL DEFAULT '',
holiday_action_taken INTEGER NOT NULL DEFAULT 0,
streak_decayed INTEGER NOT NULL DEFAULT 0,
-- shop / drops
masterwork_drops_received INTEGER NOT NULL DEFAULT 0,
treasures_locked INTEGER NOT NULL DEFAULT 0,
pet_supply_shop_unlocked INTEGER NOT NULL DEFAULT 0,
pet_level_10_date TEXT NOT NULL DEFAULT '',
thom_animal_line_fired INTEGER NOT NULL DEFAULT 0,
-- chat / npc msg
npc_msg_count INTEGER NOT NULL DEFAULT 0,
npc_msg_count_date TEXT NOT NULL DEFAULT '',
robbie_visit_count INTEGER NOT NULL DEFAULT 0,
crafts_succeeded INTEGER NOT NULL DEFAULT 0
);
```
### 2.2 Stat mapping — AdvCharacter → DnDCharacter
**Decisions locked in by user 2026-05-08:**
- **CombatLevel→Level:** direct copy. CombatLevel doesn't carry semantic meaning beyond "starting Level for the new system." No compression formula. Players keep their numeric value as their D&D Level.
- **CombatXP→XP:** **discarded.** Players start fresh on the new XP track (CombatXP is not migrated). The Level set above is their floor; XP resets to 0 at that level's threshold.
- **AdvEquipment:** kept as-is. Stays at `AdvEquipment` type and `adventure_equipment` table. No rename to `dnd_*` (per naming guidance — see §2.0 callout above).
| AdvCharacter | DnDCharacter destination | Notes |
|---|---|---|
| `CombatLevel` | `Level` | Direct copy on backfill; no formula. |
| `CombatXP` | — | **Not migrated.** XP starts at the floor of the new Level. |
| `MiningSkill` / `ForagingSkill` / `FishingSkill` | retained as new fields on DnDCharacter | These are *skills*, not class levels. Add `MiningSkill/ForagingSkill/FishingSkill int` to DnDCharacter; keep XP fields too. |
| `Alive` / `DeadUntil` | DnDCharacter `HPCurrent==0` + `DeadUntil` | Need to add `DeadUntil *time.Time` to DnDCharacter. |
| `Title` | new field on DnDCharacter | Direct copy. |
| equipment (`AdvEquipment`) | stays at `AdvEquipment` / `adventure_equipment` | Type and table name unchanged. Read paths drop AdvCharacter unwrap; no rename pass. |
### 2.3 `CombatLevel` retuning
Legacy CombatLevel scaled 150ish through grindy XP. D&D Level scales 120. Hospital/rival/co-op gates that read CombatLevel will be re-tuned to D&D Level brackets:
| Legacy gate | New gate |
|---|---|
| Hospital cost = `CombatLevel × 25k` (or × 125k post-cap) | `Level × 50k` (or × 250k at L20+) |
| Rival unlock at CombatLevel ≥ 5 | Rival unlock at Level ≥ 3 |
| Arena tier brackets (every 10 CL) | Arena tier brackets (every 4 levels) |
| Co-op dungeon minLevel | Re-tune per dungeon (table in L3 section) |
These thresholds are placeholders — final tuning happens during each phase based on playtest.
---
## 3. Phase L1 — Babysit & scheduler
**Goal:** Kill the last live `resolveAdvAction` caller. Auto-actions become expedition-aware.
**Files touched:** `adventure_babysit.go`, `adventure_scheduler.go`, `adventure_twinbee.go` (simulator), `adventure_activities.go` (delete `resolveAdvAction` + `advEligibleLocations` after callers gone).
**Design:**
- Babysit currently picks one of three skills (mining/foraging/fishing) and runs `resolveAdvAction` once a day for the user. The new flow:
- If user has an active expedition → babysit attempts a single harvest action (`!forage`/`!mine`/`!fish` per skill focus) inside the user's current room. Goes through `handleHarvestCmd` headlessly with `babysit:true` flag so combat interrupts force-extract instead of running combat. (User isn't online; can't fight.)
- If user has no active expedition → babysit posts a TwinBee nudge (one per real day, rate-limited to once/3d) suggesting `!expedition start`. No XP awarded.
- `auto_babysit` becomes "auto-harvest while expedition active." `babysit_skill_focus` carries through unchanged.
- `adventure_twinbee.go:135` simulator (uses fake CombatLevel=35 character) — replace with a fixed flavor-only narration. The simulator was always fake; deleting the AdvCharacter dependency is mechanical.
**Steps:**
1. Add `auto_babysit` / `babysit_*` columns to `player_meta` (per §2.1) — migration + read/write helpers.
2. Add `babysit:true` flag to harvest dispatch path; on combat-interrupt branches Standard/Elite/Patrol, force-extract the region run instead of entering combat. Test.
3. Rewrite `babysitDo` to call new harvest path. Remove `resolveAdvAction` call. Test (covers: active expedition, no expedition, harvest yields → inventory, combat interrupt force-extract).
4. Rewrite scheduler entrypoint analogously.
5. Replace twinbee simulator with static flavor.
6. Delete `resolveAdvAction`, `advEligibleLocations`, `AdvActionResult`, `parseActivityLocation` from `adventure_activities.go`. Audit for last callers via grep.
7. `go test ./... && go vet`.
**Tests added:** `adventure_babysit_test.go` (currently absent — create). Cases: babysit-with-expedition harvests, babysit-without-expedition nudges, combat-interrupt during babysit force-extracts cleanly, daily rate-limit holds.
**Exit criteria:**
- `grep resolveAdvAction internal/plugin/` returns 0 hits.
- `go test ./...` clean.
- Production users with `auto_babysit=true` and an active expedition see harvest deposits, not legacy activity output.
**Risk:** Babysit users have a behavior change (now auto-harvests instead of always rolling). Announce in `ADVENTURE_2.0_ANNOUNCEMENT.md`.
---
## 4. Phase L2 — Arena (rebuild on Adventure 2.0 boss flow)
**Decision (2026-05-08):** Arena migrates to the **Adventure 2.0 boss flow** — the same staged narrative path zone bosses run through (`resolveBossRoom` in `dnd_zone_cmd.go:630`). Each arena fight is treated as a self-contained boss encounter: full combat log via `RenderCombatLog`, TwinBee mood lines on Nat20/Nat1, phase-two narration when the enemy crosses its `PhaseTwoAt` HP threshold, win/loss outcome blocks. Arena stops being "numbers vs numbers" and becomes a narrated event with the same texture as a zone boss room.
This is also the right shape because zone-boss plumbing (bestiary, mood events, phase transitions, kill records, loot drops) is already built and tested. We re-use it rather than re-implementing.
**Files touched:**
- `adventure_arena.go` — entrypoint, gating, payout. Heavy rewrite.
- `adventure_arena_combat.go` — round driver. Replaced by a call into `runZoneCombat` + a new arena-specific staged renderer (or direct re-use of the zone boss render — see step 4).
- `adventure_arena_render.go` — reads switch to DnDCharacter + player_meta.
- `adventure_arena_monsters.go`**deleted as standalone**. Arena monsters are folded into the existing bestiary as boss-shaped entries (with `PhaseTwoAt`).
- `adventure_arena_test.go` — ported.
- New (small): `adventure_arena_flavor.go` — only if zone TwinBee pools don't cover arena context. Check existing `adventure_flavor_*.go` first per the reuse-flavor feedback rule before creating this file.
**Design specifics:**
- **Arena monster definitions.** Each existing `ArenaMonster` becomes a `BestiaryEntry` shape: `{ID, Name, HP, AC, AB, DamageDie, CR, PhaseTwoAt, Tags}`. Arena IDs are namespaced `arena_<tier>_<slug>` so they don't collide with zone bestiary IDs. Stored in a new `arenaBosses` map (still in `adventure_arena_monsters.go` if the file is kept; otherwise inlined). Tier brackets controlled by Arena tier (see below).
- **Combat invocation.** Arena round driver builds `BossEncounter{Monster, PhaseTwoAt, ZoneIDForFlavor}` and calls a small new function `resolveArenaBoss(userID, encounter)` that wraps the same `runZoneCombat``RenderCombatLog` → mood/phase-two lines flow as `resolveBossRoom`. **Important:** arena is not inside a zone, so we factor the staged-narration body out of `resolveBossRoom` into a shared helper `renderBossOutcome(...)` that both call sites use. (`resolveBossRoom` keeps its zone-specific glue: zone loot drop, `applyBossDefeatThreat`, `abandonZoneRun`. Arena keeps its glue: Arena win/loss counters, payout, no zone loot.)
- **TwinBee flavor in arena.** `twinBeeLine(zoneID, ...)` is keyed by zone; Arena passes a synthetic `ZoneArena` ID. Add `ZoneArena` to the zone enum and populate a small TwinBee mood/Nat20/Nat1/BossDeath/PlayerDeath pool — reuse existing pools where possible (per `feedback_reuse_existing_flavor`); only add new lines if the existing pools genuinely don't read right in arena context.
- **Phase-two thresholds.** Arena bosses get `PhaseTwoAt` defaults: tier 12 = no phase two (numeric drama only); tier 3 = 50% HP; tier 45 = 50% HP + a flavor barb. This gives arena fights a structural beat without overengineering.
- **Tier gating.** `tierFromCombatLevel``tierFromLevel`. Brackets:
| DnDCharacter.Level | Arena tier |
|---|---|
| 13 | I (Pup) |
| 47 | II (Pit) |
| 812 | III (Crucible) |
| 1317 | IV (Coliseum) |
| 1820 | V (Apex) |
- **State.** `ArenaWins`, `ArenaLosses`, `InvasionScore` move to `player_meta` (per §2.1). XP awards go through whatever the existing D&D XP path is (single source of truth — verify in `dnd_sheet.go` during implementation).
- **Death.** Arena death now flows through the same `markAdventureDead(userID, "arena", arenaTierLabel)` hook the zone boss path uses. Hospital revive (post-L4a) heals from this same state.
**Steps:**
1. Factor `renderBossOutcome` helper out of `resolveBossRoom`. Two callers post-extraction: zone boss path and (TBD) arena path. Smoke-test the zone boss path is unchanged via `adventure_arena_test.go` parity once arena lands on it.
2. Add `ZoneArena` ID + minimal TwinBee mood pools (reuse where possible).
3. Convert arena monsters to boss-shaped bestiary entries with `PhaseTwoAt`.
4. Build `resolveArenaBoss(userID, encounter)` calling `runZoneCombat` + `renderBossOutcome`. Wire into existing arena entrypoint in `adventure_arena.go`.
5. Migrate `ArenaWins/Losses/InvasionScore` to `player_meta`. Dual-write per §11.
6. Swap render reads to DnDCharacter + player_meta.
7. Port `tierFromCombatLevel``tierFromLevel` with new brackets.
8. Port `adventure_arena_test.go`. Add a new test asserting arena win surfaces the staged combat log + TwinBee BossDeath line.
9. Drop AdvCharacter imports from arena files.
10. `go test ./... && go vet`.
**Feature flag.** Ship behind `ARENA_BOSS_FLOW=1` for one week of soak. Inside the flag the new flow runs; outside it falls back to the legacy path. Flip to default-on after the soak; remove the flag in the same commit that lands L4f (twinbee).
**Exit criteria:**
- `grep -l 'AdventureCharacter\|CombatLevel\|combat_engine' internal/plugin/adventure_arena*.go` empty.
- Arena win produces a staged combat log + TwinBee BossDeath line + payout, just like a zone boss.
- Arena loss produces a TwinBee PlayerDeath line + arena death flag, no zone-run side effects.
- Existing arena DB rows readable; ArenaWins backfill from AdvCharacter into `player_meta` (§8).
**Risk:**
- D&D combat is swingier than legacy CombatPower; pace by tuning HP/AC tables against legacy win-rate logs during the flag soak.
- Refactoring `resolveBossRoom` to extract `renderBossOutcome` could regress zone bosses. Mitigate: do the extraction in step 1 alone, ship it, run a full zone-boss test pass before continuing to step 2.
---
## 5. Phase L3 — Co-op dungeons (DELETION, not migration)
**Decision (2026-05-08):** Drop co-op dungeons entirely. Do **not** migrate. The user has a different design in mind for the future co-op slot; the current implementation is being retired wholesale.
**Files deleted:**
- `coop_dungeon.go`
- `coop_dungeon_db.go`
- `coop_dungeon_render.go`
- `coop_dungeon_scheduler.go`
- `coop_dungeon_balance.go`
- `coop_dungeon_balance_test.go`
- `coop_dungeon_betting.go`
- `coop_dungeon_gifts.go`
- `coop_dungeon_stats.go`
- `coop_dungeon_test.go`
- `coop_event_meta.go`
- `coop_flavor_twinbee.go`
**Steps:**
1. Find all co-op references outside the `coop_*` files (commands registered in `adventure.go`, scheduler hooks, render hooks, betting integration). Grep `coop` and `Coop` across the package.
2. Remove command handlers from `adventure.go::OnMessage` and `Commands()`.
3. Remove scheduler ticks that call coop entrypoints.
4. Drop the `coop_dungeon*` SQL tables in a separate `cleanup_l3.go` migration gated behind `GOGOBEE_COOP_PURGE=1`. Default off — keep historical rows around until manually flipped.
5. Delete the files.
6. Add a TwinBee announcement line for active players: "co-op dungeons closed for now; expedition is the way forward; better thing coming." Wire into morning DM for one week post-deploy.
7. `go test ./... && go vet`.
**Exit criteria:**
- `grep -rn 'coop_dungeon\|CoopDungeon' internal/plugin/ --include='*.go'` returns 0 (or only in archive/migration).
- `go test ./...` clean.
**Risk:** Players with active co-op state at deploy time. Mitigate: scheduler closes any in-flight session at L3 deploy with a refund of staked coins; no XP/loot awarded for cancelled sessions. Refund logic is one-shot in the L3 migration, not preserved code.
---
## 6. Phase L4 — Stat/perk gates
**Goal:** Hospital, rival, masterwork, pets, housing, mortgage all read DnDCharacter + `player_meta` exclusively. AdvCharacter still exists but is unreferenced outside its own file.
### 6.1 L4a — Hospital
- `adventure_hospital.go`: cost formula `CombatLevel × 25k``Level × 50k`. `HospitalVisits``player_meta`. Revive flips DnDCharacter HP from 0 → full instead of `Alive=true`.
### 6.2 L4b — Rival
- `adventure_rival.go`: unlock at Level ≥ 3 (was CombatLevel ≥ 5). RivalPool → `player_meta`. Duel combat already uses `combat_engine` — port to `simulateCombat` here too (small extra step).
- Move rival flavor into existing `adventure_flavor_rival.go` — no new file.
### 6.3 L4c — Masterwork
- `adventure_masterwork.go`: no AdvCharacter mutations today; only equipment reads. Port `advMasterworkSkillBonus` to take `*DnDCharacter` (skill fields added in §2.2). MasterworkDropsReceived → `player_meta`.
### 6.4 L4d — Pets
- `adventure_pets.go`: pet fields move from AdvCharacter to `player_meta` (see §2.1 `pet_*` columns). Pet combat hooks (PetMorningDefense, pet damage rolls) target DnDCharacter HP.
### 6.5 L4e — Housing & mortgage
- `adventure_housing.go`, `adventure_mortgage.go`: HouseTier/loan fields → `player_meta`. `houseHPBonus` → applied to `DnDCharacter.HPMax` calculation (already a parallel hook in `dnd_sheet.go`'s `recomputeHPMax`).
- Mortgage scheduler tick stays — only the field source changes.
### 6.6 L4f — TwinBee/render
- `adventure_render.go`: morning DM reads from DnDCharacter + `player_meta`. Coop teaser gating moves to DnDCharacter.Level.
- `adventure_twinbee.go`: simulator already addressed in L1. Remaining usage is read-only stat display — port to DnDCharacter.
**Steps (per sub-phase):** column add → backfill → swap reads → swap writes → port tests → grep check.
**Exit criteria L4 overall:** `grep -l 'AdventureCharacter\|CombatLevel' internal/plugin/adventure_{hospital,rival,masterwork,pets,housing,mortgage,render,twinbee}.go` empty.
**Risk:** Each sub-phase is independent — can interleave with playtest. Housing has the most fields; do it last in L4 since the column count is largest.
---
## 7. Phase L5 — Teardown
**Goal:** Delete legacy types, files, and `combat_engine`. Branching dungeon paths become buildable.
**Pre-conditions:**
- L1L4 all shipped.
- `grep -rn 'AdventureCharacter' internal/plugin/ --include='*.go' | grep -v adventure_character.go | grep -v _test.go` returns 0.
- `grep -rn 'CombatLevel' internal/plugin/ --include='*.go'` returns 0 (or only in archive/migration code).
**Files deleted:**
- `adventure_character.go`
- `adventure_activities.go` (already gutted in L1)
- `combat_engine.go`
- `combat_engine_test.go`
- `combat_bridge.go`
- `combat_bridge_test.go`
- `combat_stats.go` (if unused; verify)
- `combat_stats_test.go`
**DB cleanup:**
- `adventure_character` table: drop after a 30-day grace period in case of rollback. Schedule with a migration script `dnd_l5_cleanup.go` that runs only when env var `GOGOBEE_LEGACY_PURGE=1` is set, to gate the destructive op. Default off; manual flip after confirming.
- Equipment table stays — it was always per-user, never per-AdvCharacter.
**Code cleanup:**
- Remove `AdvLocation` tier data unless something else picked it up.
- Remove all `AdvBonusSummary` plumbing.
- Final `go vet ./... && go test ./...` clean.
**Branching dungeon paths:** unblocked. The `dnd_zone_run` schema is the only one we still own; rebuild/expand it without AdvCharacter pressure. Out of scope for this doc — see `project_branching_paths_post_legacy.md` memory note.
---
## 8. Cross-cutting — DB backfills
Every phase that moves a field from `adventure_character` to `player_meta` runs the same shape of backfill:
```go
// In Init() or a one-shot migration runner
func backfillPlayerMeta(db *sql.DB) error {
rows, _ := db.Query(`SELECT user_id, arena_wins, arena_losses, ... FROM adventure_character`)
for rows.Next() {
// INSERT OR IGNORE into player_meta
}
}
```
**Idempotency rules (per Phase R1's `archiveOrphanZoneRuns` precedent):**
- All backfills use `INSERT OR IGNORE`.
- All backfills are safe to re-run.
- Each backfill logs the row count it touched.
**Order of backfills:**
1. L1 — `babysit_*`, `auto_babysit*` columns.
2. L2 — `arena_wins`, `arena_losses`, `invasion_score`.
3. L3 — none (co-op didn't store on AdvCharacter).
4. L4a — `hospital_visits`, `last_death_date`, `death_source`, `death_location`, `last_pardon_used`, `death_reprieve_last`.
5. L4b — `rival_pool`, `rival_unlocked_notified`.
6. L4c — `masterwork_drops_received`, `treasures_locked`.
7. L4d — pet fields.
8. L4e — house fields.
9. L4f — streak/title/grudge fields, NPC msg counters, NPC seen/buff timestamps.
10. L5 — drop `adventure_character` table.
---
## 9. Testing strategy
- Each phase ships with new tests for the migrated subsystem and a `*_migration_test.go` covering backfill idempotency (matching R1's pattern).
- Existing tests are *ported*, not deleted — same test names, fixtures swap from AdvCharacter to DnDCharacter.
- A new `legacy_residue_test.go` runs at the package level and `grep`s the source tree for forbidden symbols once each phase lands; this guards against regressions.
```go
// legacy_residue_test.go skeleton
func TestNoCombatLevelReferencesAfterL5(t *testing.T) {
if !legacyMigrationDone { t.Skip() }
// walk filepath, fail on grep "CombatLevel"
}
```
---
## 10. Sequence & rough sizing
| Phase | Est. session count | Blocking on |
|---|---|---|
| L1 — Babysit/scheduler | 1 | — |
| L2 — Arena (boss flow rebuild) | 23 | feature flag in place; `renderBossOutcome` extraction lands first |
| L3 — Co-op dungeons (DELETE) | 0.5 | — (independent of L2) |
| L4a — Hospital | 0.5 | — |
| L4b — Rival | 1 | — |
| L4c — Masterwork | 0.5 | DnDCharacter skill fields exist (§2.2) |
| L4d — Pets | 1 | `player_meta` schema |
| L4e — Housing/mortgage | 1 | — |
| L4f — TwinBee/render | 0.5 | L1L4 done |
| L5 — Teardown | 1 | all above + 30-day grace |
**Total:** ~10 sessions over ~36 weeks of real time, including playtest soak between phases. Don't compress this into a single sprint — each phase needs production exposure before the next one builds on it.
---
## 11. Rollback strategy
Each phase is reversible until L5:
- L1L4 do not delete columns or tables. They add `player_meta` columns and dual-read briefly during the swap.
- If a phase regresses, revert the read-site swap; backfill data still flows back to `adventure_character` because L1L4 do **not** stop writing to AdvCharacter until §6 dual-write is dropped per phase.
**Dual-write rule:** for the duration of L1L4, every write to a migrated field goes to **both** AdvCharacter and `player_meta`. Reads come from `player_meta`. This is removed phase-by-phase only after one full real week with no rollback triggered.
L5 is the irreversible step. Gate on `GOGOBEE_LEGACY_PURGE=1`.
---
## 12. Decisions locked (2026-05-08)
All four pre-implementation questions answered:
1. **CombatLevel→Level:** direct copy. CombatLevel doesn't have semantic meaning — it's just "starting Level." No formula, no cap-compression.
2. **AdvEquipment:** kept as-is. No rename. Type stays `AdvEquipment`, table stays `adventure_equipment`. (Naming guidance: avoid new `dnd_*` symbols throughout this migration — see §2.1 callout.)
3. **CombatXP→XP:** discarded. Players start fresh; new XP accumulates from 0 at the floor of their migrated Level.
4. **Co-op dungeons:** dropped entirely (see §5). Not migrated. Different design coming later.
No outstanding pre-implementation questions. Tuning questions during each phase (HP/AC tables, threshold brackets) get decided in the moment.
---
*End of Legacy Migration document.*
*Pair with companion docs in Claude Code sessions when working any L-phase.*

View File

@@ -779,173 +779,6 @@ func advEquipmentMasteryBonus(equip map[EquipmentSlot]*AdvEquipment) float64 {
return total return total
} }
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
result := &AdvActionResult{
Location: loc,
XPSkill: advXPSkill(loc.Activity),
}
// Apply equipment-mastery loot bonus on a local copy so we don't mutate
// the caller's bonuses struct (it's reused across multiple actions).
localBonuses := *bonuses
localBonuses.LootQuality += advEquipmentMasteryBonus(equip)
bonuses = &localBonuses
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
// Overlevel penalty — reduces loot and XP for farming low-tier content
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
overlevelMult := advOverlevelMultiplier(skillLevel, loc)
// Roll outcome
roll := rand.Float64() * 100
switch {
case roll < probs.DeathPct:
result.Outcome = AdvOutcomeDeath
case roll < probs.DeathPct+probs.EmptyPct:
// Activity-specific empty outcomes
result.Outcome = resolveAdvEmptyOutcome(loc, roll)
case roll < probs.DeathPct+probs.EmptyPct+probs.SuccessPct:
result.Outcome = AdvOutcomeSuccess
default:
result.Outcome = AdvOutcomeExceptional
}
// Near-death check: survived within 2% of death threshold
if result.Outcome != AdvOutcomeDeath && roll < probs.DeathPct+2 && roll >= probs.DeathPct {
result.NearDeath = true
}
// Generate loot for success/exceptional
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
// Apply overlevel penalty to loot values
if overlevelMult < 1.0 {
for i := range result.LootItems {
result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult))
}
}
for _, item := range result.LootItems {
result.TotalLootValue += item.Value
}
}
// XP calculation
xp := advXPForOutcome(loc.Activity, loc.Tier, result.Outcome)
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
xp = advXPTable[loc.Activity][loc.Tier].Success
if result.Outcome == AdvOutcomeExceptional {
xp = advXPTable[loc.Activity][loc.Tier].Exceptional
}
}
xpResult := applyXPBonuses(XPBonusParams{
BaseXP: xp,
NearDeath: result.NearDeath,
BonusMult: bonuses.XPMultiplier,
Ironclad: advEquippedArenaSets(equip)["ironclad"],
OverlevelMult: overlevelMult,
})
result.XPGained = xpResult.Total
result.XPBreakdown = xpResult.Breakdown
// Equipment degradation on bad outcomes
if result.Outcome == AdvOutcomeDeath || result.Outcome == AdvOutcomeEmpty ||
result.Outcome == AdvOutcomeCaveIn || result.Outcome == AdvOutcomeBear ||
result.Outcome == AdvOutcomeRiver {
result.EquipDamage = applyAdvEquipDegradation(equip, result.Outcome)
result.EquipBroken = advCheckBrokenEquipment(equip)
}
// Increment actions_used for equipment mastery only on slots relevant
// to the activity — a foraging trip shouldn't master a sword, and a
// dungeon run shouldn't master a pickaxe. Detect threshold crossings
// so the caller can DM a celebration.
for slot, eq := range equip {
if eq == nil {
continue
}
if !advSlotRelevantToActivity(slot, loc.Activity) {
continue
}
before := eq.ActionsUsed
eq.ActionsUsed++
for _, t := range advMasteryThresholds {
if before < t && eq.ActionsUsed >= t {
result.MasteryCrossings = append(result.MasteryCrossings, AdvMasteryCrossing{
Slot: slot,
ItemName: eq.Name,
Threshold: t,
})
}
}
}
return result
}
// resolveAdvEmptyOutcome returns an activity-specific "empty" outcome.
func resolveAdvEmptyOutcome(loc *AdvLocation, _ float64) AdvOutcomeType {
switch loc.Activity {
case AdvActivityMining:
// 40% chance of cave-in on "empty" result
if rand.Float64() < 0.4 {
return AdvOutcomeCaveIn
}
return AdvOutcomeEmpty
case AdvActivityForaging:
// Split empty into specific outcomes
r := rand.Float64()
switch {
case r < 0.35:
return AdvOutcomeHornets
case r < 0.55:
return AdvOutcomeBear
case r < 0.70:
return AdvOutcomeRiver
default:
return AdvOutcomeEmpty
}
case AdvActivityFishing:
// Fishing empty is just empty — no sub-outcomes
return AdvOutcomeEmpty
default:
return AdvOutcomeEmpty
}
}
// ── Eligible Locations for DM Menu ───────────────────────────────────────────
type AdvEligibleLocation struct {
Location *AdvLocation
InPenaltyZone bool
DeathPct float64
ExceptionalPct float64
}
func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) []AdvEligibleLocation {
var eligible []AdvEligibleLocation
for _, loc := range allAdvLocations(activity) {
loc := loc
ok, penalty := advIsEligible(char, equip, &loc, bonuses)
if !ok {
continue
}
probs := calculateAdvProbabilities(char, equip, &loc, bonuses, penalty)
eligible = append(eligible, AdvEligibleLocation{
Location: &loc,
InPenaltyZone: penalty,
DeathPct: probs.DeathPct,
ExceptionalPct: probs.ExceptionalPct,
})
}
return eligible
}
// ── Party Bonus Check ──────────────────────────────────────────────────────── // ── Party Bonus Check ────────────────────────────────────────────────────────
// advCheckPartyBonus checks if other players visited the same location today. // advCheckPartyBonus checks if other players visited the same location today.

View File

@@ -3,7 +3,6 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand/v2"
"strings" "strings"
"time" "time"
@@ -18,44 +17,62 @@ func babysitDailyCost(combatLevel int) int {
return 100 + (combatLevel * 20) return 100 + (combatLevel * 20)
} }
// ── Weakest Skill ─────────────────────────────────────────────────────────── // ── Pet-care daily trickle ─────────────────────────────────────────────────
func babysitWeakestSkill(char *AdventureCharacter) string { // petXPPerBabysitDay is the daily pet XP awarded while a babysit subscription
skills := []struct { // is active. Picked to be a meaningful but not overwhelming push toward L10:
name string // roughly equivalent to a couple of player-driven actions per day.
level int const petXPPerBabysitDay = 3
}{
{"mining", char.MiningSkill}, // runBabysitDailyTrickle grants daily pet XP while a babysit subscription is
{"fishing", char.FishingSkill}, // active and logs an entry for the end-of-service summary. Caller is
{"foraging", char.ForagingSkill}, // responsible for saving the character afterwards.
func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
if !char.BabysitActive {
return
} }
minLevel := skills[0].level leveled := false
for _, s := range skills[1:] { if char.HasPet() && char.PetLevel < 10 {
if s.level < minLevel { // Bypass petGrantXP's per-action constant — we want a flat trickle.
minLevel = s.level char.PetXP += petXPPerBabysitDay * 100
for char.PetLevel < 10 {
needed := petXPToNextLevel(char.PetLevel) * 100
if char.PetXP < needed {
break
}
char.PetXP -= needed
char.PetLevel++
leveled = true
}
if char.PetLevel >= 10 && char.PetLevel10Date == "" {
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
} }
} }
// Collect ties outcome := "pet_care"
var tied []string if leveled {
for _, s := range skills { outcome = "pet_care_levelup"
if s.level == minLevel {
tied = append(tied, s.name)
} }
} logBabysitActivity(char.UserID, "pet_care", outcome, 0, petXPPerBabysitDay, "")
return tied[rand.IntN(len(tied))]
} }
// skillToActivity maps a skill name to its activity type. // BabysitSafeRest reports whether the user has an active babysit subscription
func skillToActivity(skill string) AdvActivityType { // that should let standard camps qualify for fortified-tier rest perks.
switch skill { // Returns false on any error (treat as no babysit). Safe to call from tests
case "mining": // where the global DB has not been initialized — the panic is swallowed.
return AdvActivityMining func BabysitSafeRest(userID id.UserID) (active bool) {
case "fishing": defer func() {
return AdvActivityFishing if r := recover(); r != nil {
case "foraging": active = false
return AdvActivityForaging
} }
return AdvActivityMining }()
char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.BabysitActive {
return false
}
if char.BabysitExpiresAt != nil && time.Now().UTC().After(*char.BabysitExpiresAt) {
return false
}
return true
} }
// ── Command Handlers ──────────────────────────────────────────────────────── // ── Command Handlers ────────────────────────────────────────────────────────
@@ -72,82 +89,19 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
return p.handleBabysitPurchase(ctx, 7) return p.handleBabysitPurchase(ctx, 7)
case lower == "month": case lower == "month":
return p.handleBabysitPurchase(ctx, 30) return p.handleBabysitPurchase(ctx, 30)
case lower == "auto":
return p.handleBabysitAutoToggle(ctx)
case strings.HasPrefix(lower, "focus"):
return p.handleBabysitFocus(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "focus")))
default: default:
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+ return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
" • Standard camps act like fortified ones — rest deeply, no need for boss-cleared rooms\n"+
" • Rival duels declined on your behalf\n\n"+
"`!adventure babysit week` — 7 days of service\n"+ "`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+ "`!adventure babysit month` — 30 days of service\n"+
"`!adventure babysit auto` — toggle auto-babysit on missed days\n"+
"`!adventure babysit focus <mining|fishing|foraging>` — pick the skill auto-babysit trains\n"+
"`!adventure babysit status` — check service status\n"+ "`!adventure babysit status` — check service status\n"+
"`!adventure babysit cancel` — cancel early (no refund)") "`!adventure babysit cancel` — cancel early (no refund)")
} }
} }
func (p *AdventurePlugin) handleBabysitFocus(ctx MessageContext, arg string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
}
switch arg {
case "":
current := char.AutoBabysitFocus
if current == "" {
current = "weakest skill (default)"
}
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🎯 **Auto-babysit focus:** %s\n\nSet with `!adventure babysit focus <mining|fishing|foraging>`. Use `!adventure babysit focus clear` to revert to the weakest-skill default.",
current))
case "clear", "default", "weakest", "off":
char.AutoBabysitFocus = ""
case "mining", "fishing", "foraging":
char.AutoBabysitFocus = arg
default:
return p.SendDM(ctx.Sender, "Pick one of `mining`, `fishing`, `foraging`, or `clear`.")
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save focus", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
}
if char.AutoBabysitFocus == "" {
return p.SendDM(ctx.Sender, "🎯 Auto-babysit focus cleared. The babysitter will train your weakest skill.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🎯 Auto-babysit focus set to **%s**. The babysitter will train this on missed days.", char.AutoBabysitFocus))
}
func (p *AdventurePlugin) handleBabysitAutoToggle(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
}
char.AutoBabysit = !char.AutoBabysit
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save auto-babysit toggle", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
}
daily := babysitDailyCost(char.CombatLevel)
if char.AutoBabysit {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 **Auto-babysit: ON**\n\nIf you miss a day, the babysitter steps in automatically (€%d/day). Your streak stays alive. Disable anytime with `!adventure babysit auto`.", daily))
}
return p.SendDM(ctx.Sender, "🍼 **Auto-babysit: OFF**\n\nYou're on your own. Miss a day and your streak takes the hit.")
}
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error { func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
userMu := p.advUserLock(ctx.Sender) userMu := p.advUserLock(ctx.Sender)
userMu.Lock() userMu.Lock()
@@ -173,24 +127,19 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance))) return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance)))
} }
// Debit gold
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") { if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.") return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
} }
// Clear any leftover logs from prior service so this period's summary is clean
clearBabysitLogs(char.UserID) clearBabysitLogs(char.UserID)
// Set babysit fields
skill := babysitWeakestSkill(char)
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
char.BabysitActive = true char.BabysitActive = true
char.BabysitExpiresAt = &expires char.BabysitExpiresAt = &expires
char.BabysitSkillFocus = skill char.BabysitSkillFocus = "" // legacy field; no longer used
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err) slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
// Refund
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund") p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.") return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
} }
@@ -201,13 +150,18 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
durLabel = "1 month" durLabel = "1 month"
} }
petLine := "No pet to tend yet — the babysitter will keep that in mind."
if char.HasPet() {
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
}
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+ text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
"Duration: %s (%d days)\n"+ "Duration: %s (%d days)\n"+
"Cost: €%d\n"+ "Cost: €%d\n"+
"Focus: %s (currently level %d)\n"+ "%s\n"+
"Camp safety: standard camps now rest like fortified ones\n"+
"Rival duels: declined on your behalf\n\n"+ "Rival duels: declined on your behalf\n\n"+
"Daily DMs are suspended until the service ends.\n\n"+ "_%s_", durLabel, days, totalCost, petLine, confirm)
"_%s_", durLabel, days, totalCost, titleCase(skill), babysitSkillLevel(char, skill), confirm)
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)
} }
@@ -218,13 +172,8 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "No adventurer found.") return p.SendDM(ctx.Sender, "No adventurer found.")
} }
autoLabel := "OFF"
if char.AutoBabysit {
autoLabel = "ON"
}
if !char.BabysitActive { if !char.BabysitActive {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 No active babysitting service.\nAuto-babysit: **%s** (`!adventure babysit auto` to toggle)\n\nUse `!adventure babysit week` or `!adventure babysit month` to start.", autoLabel)) return p.SendDM(ctx.Sender, "🍼 No active babysitting service.\n\nUse `!adventure babysit week` or `!adventure babysit month` to start. The babysitter tends your pet daily and lets you rest deeply at standard camps.")
} }
remaining := "unknown" remaining := "unknown"
@@ -237,23 +186,24 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
} }
} }
// Load log stats
logs, err := loadBabysitLogs(char.UserID) logs, err := loadBabysitLogs(char.UserID)
if err != nil { if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err) slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
} }
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs) totalXP, petDays, rivalsRefused := babysitLogStats(logs)
petLine := "No pet to tend"
if char.HasPet() {
petLine = fmt.Sprintf("%s (L%d)", char.PetName, char.PetLevel)
}
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+ text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
"Time remaining: %s\n"+ "Time remaining: %s\n"+
"Skill focus: %s\n"+ "Pet under care: %s\n"+
"Days completed: %d\n"+ "Days of pet care given: %d\n"+
"Gold earned: %d\n"+ "Pet XP trickled: %d\n"+
"XP gained: %d\n"+ "Rivals declined: %d",
"Items claimed by babysitter: %d\n"+ remaining, petLine, petDays, totalXP, rivalsRefused)
"Rivals declined: %d\n"+
"Auto-babysit: %s",
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel)
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)
} }
@@ -272,14 +222,12 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.") return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
} }
// Compile partial summary
logs, err := loadBabysitLogs(char.UserID) logs, err := loadBabysitLogs(char.UserID)
if err != nil { if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err) slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
} }
summary := renderBabysitSummary(char, logs) summary := renderBabysitSummary(char, logs)
// Clear babysit state
char.BabysitActive = false char.BabysitActive = false
char.BabysitExpiresAt = nil char.BabysitExpiresAt = nil
char.BabysitSkillFocus = "" char.BabysitSkillFocus = ""
@@ -290,177 +238,6 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary) return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
} }
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
return
}
isHol, _ := isHolidayToday()
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
bonuses := &AdvBonusSummary{}
focusActivity := skillToActivity(char.BabysitSkillFocus)
var totalGold int
var totalXP int
var allItems []string
// Use all harvest actions on the focused skill — no combat, too dangerous
for char.HarvestActionsUsed < harvestMax {
gold, xp, items := p.runBabysitAction(char, equip, focusActivity, bonuses)
totalGold += gold
totalXP += xp
allItems = append(allItems, items...)
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err)
}
// Log combined daily totals
itemsJSON := ""
if len(allItems) > 0 {
itemsJSON = strings.Join(allItems, ", ")
}
logBabysitActivity(char.UserID, string(focusActivity), "babysit_daily",
totalGold, totalXP, itemsJSON)
}
// runBabysitAction resolves a single action for the babysitter. Returns gold, xp, item names.
func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) (int, int, []string) {
eligible := advEligibleLocations(char, equip, activity, bonuses)
if len(eligible) == 0 {
return 0, 0, nil
}
loc := eligible[len(eligible)-1].Location
inPenalty := eligible[len(eligible)-1].InPenaltyZone
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
// Babysitter never lets the adventurer die
if result.Outcome == AdvOutcomeDeath {
result.Outcome = AdvOutcomeEmpty
result.LootItems = nil
result.TotalLootValue = 0
result.EquipDamage = nil
result.EquipBroken = nil
}
// Double XP/money boost
advApplyBoost(result)
// Apply XP
switch result.XPSkill {
case "combat":
char.CombatXP += result.XPGained
case "mining":
char.MiningXP += result.XPGained
case "foraging":
char.ForagingXP += result.XPGained
case "fishing":
char.FishingXP += result.XPGained
}
checkAdvLevelUp(char, result.XPSkill)
// Credit gold
if result.TotalLootValue > 0 {
net, _ := communityTax(char.UserID, float64(result.TotalLootValue), 0.05)
p.euro.Credit(char.UserID, net, "babysit_haul")
}
// No treasure drops during babysitting
result.TreasureFound = nil
var items []string
for _, item := range result.LootItems {
items = append(items, item.Name)
}
return int(result.TotalLootValue), result.XPGained, items
}
// AutoBabysitDayResult summarizes one day of auto-babysit work, surfaced in
// the morning DM so the babysitter feels like a companion that did
// something rather than a silent insurance product.
type AutoBabysitDayResult struct {
Skill string
Gold int
XP int
Items []string
Highlight bool // true if the day produced an unusually large single haul or multiple item finds
}
// runAutoBabysitDay runs a single day of babysit actions for auto-babysit.
// Called by the scheduler when a player with auto-babysit enabled misses a day.
func (p *AdventurePlugin) runAutoBabysitDay(char *AdventureCharacter) AutoBabysitDayResult {
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("auto-babysit: failed to load equipment", "user", char.UserID, "err", err)
return AutoBabysitDayResult{}
}
isHol, _ := isHolidayToday()
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
bonuses := &AdvBonusSummary{}
// Honor a player-set focus if it's a valid harvest skill; otherwise
// fall back to the weakest-skill default.
skill := char.AutoBabysitFocus
switch skill {
case "mining", "fishing", "foraging":
// player preference
default:
skill = babysitWeakestSkill(char)
}
activity := skillToActivity(skill)
var totalGold int
var totalXP int
var allItems []string
bestSingleHaul := 0
for char.HarvestActionsUsed < harvestMax {
gold, xp, items := p.runBabysitAction(char, equip, activity, bonuses)
totalGold += gold
totalXP += xp
allItems = append(allItems, items...)
if gold > bestSingleHaul {
bestSingleHaul = gold
}
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
itemsJSON := ""
if len(allItems) > 0 {
itemsJSON = strings.Join(allItems, ", ")
}
logBabysitActivity(char.UserID, string(activity), "auto_babysit_daily",
totalGold, totalXP, itemsJSON)
return AutoBabysitDayResult{
Skill: skill,
Gold: totalGold,
XP: totalXP,
Items: allItems,
Highlight: bestSingleHaul >= 200 || len(allItems) >= 2,
}
}
// ── Expiry Check ──────────────────────────────────────────────────────────── // ── Expiry Check ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) { func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
@@ -473,7 +250,6 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
continue continue
} }
// Service expired — compile summary and send DM
logs, err := loadBabysitLogs(char.UserID) logs, err := loadBabysitLogs(char.UserID)
if err != nil { if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err) slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
@@ -497,20 +273,20 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
// ── Summary Rendering ─────────────────────────────────────────────────────── // ── Summary Rendering ───────────────────────────────────────────────────────
func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string { func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string {
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs) totalXP, petDays, rivalsRefused := babysitLogStats(logs)
var sb strings.Builder var sb strings.Builder
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n") sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
sb.WriteString(fmt.Sprintf("Duration: %d days\n", len(logs))) sb.WriteString(fmt.Sprintf("Days of service: %d\n", petDays))
sb.WriteString(fmt.Sprintf("Tasks completed: %d\n", len(logs))) if char.HasPet() {
sb.WriteString(fmt.Sprintf("Skill focused: %s\n", titleCase(char.BabysitSkillFocus))) sb.WriteString(fmt.Sprintf("Pet looked after: %s (L%d)\n", char.PetName, char.PetLevel))
sb.WriteString(fmt.Sprintf("Gold earned from hauls: €%d\n", totalGold)) } else {
sb.WriteString(fmt.Sprintf("XP gained: %d\n", totalXP)) sb.WriteString("Pet looked after: none — the babysitter played solitaire.\n")
sb.WriteString(fmt.Sprintf("Items dropped: %d items. Claimed by the babysitter as per the terms.\n", itemsClaimed)) }
sb.WriteString(fmt.Sprintf("Pet XP trickled: %d\n", totalXP))
if rivalsRefused > 0 { if rivalsRefused > 0 {
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused)) sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
// Pick a rival refusal flavor (generic — no specific rival name available)
for _, log := range logs { for _, log := range logs {
if log.RivalRefused != "" { if log.RivalRefused != "" {
line := pickBabysitFlavor(babysitRivalRefusalLines) line := pickBabysitFlavor(babysitRivalRefusalLines)
@@ -519,11 +295,8 @@ func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) stri
} }
} }
// Diaper line
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines)) sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
sb.WriteString("\n\nYour adventurer is fed and rested. The pet is suspiciously well-trained.")
// Closing
sb.WriteString(fmt.Sprintf("\n\nYour adventurer is fed, rested, and slightly better at %s.", char.BabysitSkillFocus))
return sb.String() return sb.String()
} }
@@ -591,27 +364,13 @@ func clearBabysitLogs(userID id.UserID) {
} }
} }
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Stats helper ────────────────────────────────────────────────────────────
func babysitSkillLevel(char *AdventureCharacter, skill string) int { func babysitLogStats(logs []babysitLogEntry) (totalXP, petDays, rivalsRefused int) {
switch skill {
case "mining":
return char.MiningSkill
case "fishing":
return char.FishingSkill
case "foraging":
return char.ForagingSkill
}
return 0
}
func babysitLogStats(logs []babysitLogEntry) (totalGold, totalXP, itemsClaimed, rivalsRefused int) {
for _, l := range logs { for _, l := range logs {
totalGold += l.GoldEarned
totalXP += l.XPGained totalXP += l.XPGained
if l.ItemsDropped != "" { if l.Activity == "pet_care" {
// Count comma-separated items petDays++
itemsClaimed += len(strings.Split(l.ItemsDropped, ", "))
} }
if l.RivalRefused != "" { if l.RivalRefused != "" {
rivalsRefused++ rivalsRefused++

View File

@@ -0,0 +1,108 @@
package plugin
import (
"testing"
"time"
"maunium.net/go/mautrix/id"
)
// L1: babysit pivot from harvest to pet-care + safe-rest.
// These tests cover the pure pieces — DB-touching paths exercise via
// integration only.
func TestBabysitSafeRest_NoDB_ReturnsFalse(t *testing.T) {
// Tests run without db.Init(); BabysitSafeRest must recover and
// return false rather than panicking.
if BabysitSafeRest(id.UserID("@nodb:example")) {
t.Errorf("expected false for un-initialized DB")
}
}
func TestRunBabysitDailyTrickle_NoPetGrantsNothing(t *testing.T) {
// Without a pet, the trickle is a logging-only no-op for character
// state. Without DB, the log call panics inside db.Get; recover and
// assert that the in-memory char fields stay clean.
defer func() { _ = recover() }()
expires := time.Now().UTC().Add(7 * 24 * time.Hour)
char := &AdventureCharacter{
UserID: id.UserID("@nopet:example"),
BabysitActive: true,
BabysitExpiresAt: &expires,
}
p := &AdventurePlugin{}
p.runBabysitDailyTrickle(char)
if char.PetXP != 0 {
t.Errorf("char without pet should not gain PetXP, got %d", char.PetXP)
}
if char.PetLevel != 0 {
t.Errorf("char without pet should not change PetLevel, got %d", char.PetLevel)
}
}
func TestRunBabysitDailyTrickle_SkipsWhenInactive(t *testing.T) {
defer func() { _ = recover() }()
char := &AdventureCharacter{
UserID: id.UserID("@inactive:example"),
BabysitActive: false,
PetType: "dog",
PetName: "Rex",
PetLevel: 1,
PetXP: 0,
}
p := &AdventurePlugin{}
p.runBabysitDailyTrickle(char)
if char.PetXP != 0 {
t.Errorf("inactive babysit should grant no XP, got %d", char.PetXP)
}
}
func TestRunBabysitDailyTrickle_AccumulatesAndLevels(t *testing.T) {
// Pet at L9 with 49 XP needed → 1 day at +3 trickle should *not* level
// (49→52 in centi-XP / 100 = no, wait: petXPToNextLevel at L9 = 50 ×
// 100 = 5000 centi-XP). Verify we accumulate centi-XP correctly across
// multiple daily ticks and eventually level up.
defer func() { _ = recover() }()
char := &AdventureCharacter{
UserID: id.UserID("@accum:example"),
PetType: "dog",
PetName: "Rex",
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
PetXP: 900,
BabysitActive: true,
}
expires := time.Now().UTC().Add(48 * time.Hour)
char.BabysitExpiresAt = &expires
p := &AdventurePlugin{}
// One trickle adds 3*100 = 300 centi-XP. 900 + 300 = 1200 >= 1000 →
// level to 2, carry 200 centi-XP.
p.runBabysitDailyTrickle(char)
if char.PetLevel != 2 {
t.Errorf("expected level 2 after trickle, got %d (xp=%d)", char.PetLevel, char.PetXP)
}
if char.PetXP != 200 {
t.Errorf("expected 200 carryover centi-XP, got %d", char.PetXP)
}
}
func TestBabysitLogStats_CountsPetCareDays(t *testing.T) {
logs := []babysitLogEntry{
{Activity: "pet_care", XPGained: 3},
{Activity: "pet_care", XPGained: 3},
{Activity: "pet_care", XPGained: 3, RivalRefused: ""},
{Activity: "rival_refused", RivalRefused: "Garth"},
}
xp, days, rivals := babysitLogStats(logs)
if days != 3 {
t.Errorf("petDays = %d, want 3", days)
}
if xp != 9 {
t.Errorf("totalXP = %d, want 9", xp)
}
if rivals != 1 {
t.Errorf("rivalsRefused = %d, want 1", rivals)
}
}

View File

@@ -71,89 +71,6 @@ func TestAdvSlotRelevantToActivity(t *testing.T) {
} }
} }
func TestResolveAdvAction_MasteryOnlyCountsRelevantSlots(t *testing.T) {
// A foraging action should bump tool but leave weapon/armor/helmet/boots
// untouched. The reverse for combat.
char := &AdventureCharacter{
CombatLevel: 5, ForagingSkill: 5, MiningSkill: 5, FishingSkill: 5,
}
bonuses := &AdvBonusSummary{}
t.Run("foraging only bumps tool", func(t *testing.T) {
loc := &AdvLocation{Name: "T", Tier: 1, Activity: AdvActivityForaging,
MinLevel: 1, BaseDeathPct: 1, EmptyPct: 50}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 1, Condition: 100, ActionsUsed: 10},
SlotArmor: {Tier: 1, Condition: 100, ActionsUsed: 10},
SlotHelmet: {Tier: 1, Condition: 100, ActionsUsed: 10},
SlotBoots: {Tier: 1, Condition: 100, ActionsUsed: 10},
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: 10},
}
resolveAdvAction(char, equip, loc, bonuses, false)
if equip[SlotTool].ActionsUsed != 11 {
t.Errorf("tool should have advanced 10→11, got %d", equip[SlotTool].ActionsUsed)
}
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots} {
if equip[slot].ActionsUsed != 10 {
t.Errorf("%s should be unchanged at 10, got %d", slot, equip[slot].ActionsUsed)
}
}
})
t.Run("combat only bumps combat slots", func(t *testing.T) {
loc := &AdvLocation{Name: "T", Tier: 1, Activity: AdvActivityDungeon,
MinLevel: 1, BaseDeathPct: 1, EmptyPct: 50}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 1, Condition: 100, ActionsUsed: 10},
SlotArmor: {Tier: 1, Condition: 100, ActionsUsed: 10},
SlotHelmet: {Tier: 1, Condition: 100, ActionsUsed: 10},
SlotBoots: {Tier: 1, Condition: 100, ActionsUsed: 10},
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: 10},
}
resolveAdvAction(char, equip, loc, bonuses, false)
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots} {
if equip[slot].ActionsUsed != 11 {
t.Errorf("%s should have advanced 10→11, got %d", slot, equip[slot].ActionsUsed)
}
}
if equip[SlotTool].ActionsUsed != 10 {
t.Errorf("tool should be unchanged at 10, got %d", equip[SlotTool].ActionsUsed)
}
})
}
func TestResolveAdvAction_MasteryCrossingFiresOnceAtBoundary(t *testing.T) {
// Action 49→50 should report a single crossing at threshold 50.
// Action 50→51 should report none (idempotent past the boundary).
loc := &AdvLocation{
Name: "Test", Tier: 1, Activity: AdvActivityForaging,
MinLevel: 1, MinEquipTier: 0, BaseDeathPct: 1, EmptyPct: 50,
}
char := &AdventureCharacter{
CombatLevel: 5, ForagingSkill: 5, MiningSkill: 5, FishingSkill: 5,
}
bonuses := &AdvBonusSummary{}
mkEquip := func(used int) map[EquipmentSlot]*AdvEquipment {
return map[EquipmentSlot]*AdvEquipment{
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: used},
}
}
res1 := resolveAdvAction(char, mkEquip(49), loc, bonuses, false)
if got := len(res1.MasteryCrossings); got != 1 {
t.Fatalf("49→50: expected 1 crossing, got %d", got)
}
if res1.MasteryCrossings[0].Threshold != 50 {
t.Errorf("expected threshold 50, got %d", res1.MasteryCrossings[0].Threshold)
}
res2 := resolveAdvAction(char, mkEquip(50), loc, bonuses, false)
if got := len(res2.MasteryCrossings); got != 0 {
t.Errorf("50→51: expected 0 crossings, got %d", got)
}
}
func TestAdvMasteryRowSegment(t *testing.T) { func TestAdvMasteryRowSegment(t *testing.T) {
cases := []struct { cases := []struct {
used int used int

View File

@@ -208,28 +208,6 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
sb.WriteString(" — `!adventure rivals` for details\n") sb.WriteString(" — `!adventure rivals` for details\n")
} }
// Today's actions
isHolSheet, _ := isHolidayToday()
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHolSheet {
combatMax++
harvestMax++
}
combatRemaining := combatMax - char.CombatActionsUsed
if combatRemaining < 0 {
combatRemaining = 0
}
harvestRemaining := harvestMax - char.HarvestActionsUsed
if harvestRemaining < 0 {
harvestRemaining = 0
}
if char.HasActedToday() {
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions remaining", combatRemaining, harvestRemaining))
} else {
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions available", combatRemaining, harvestRemaining))
}
return sb.String() return sb.String()
} }
@@ -356,9 +334,11 @@ func renderRivalNudge(char *AdventureCharacter) string {
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string { func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
var sb strings.Builder var sb strings.Builder
// Holiday notice (before greeting) // Holiday notice (before greeting). Today's perks: TwinBee starts new
// runs in a slightly better mood (+5), expedition outfitting includes a
// complimentary standard pack, and every harvest yields one extra unit.
if holidayName != "" { if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you get an extra combat and harvest action today.\n\n", holidayName, holidayName)) sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
} }
// Pick a morning greeting // Pick a morning greeting
@@ -399,30 +379,17 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
// Action budget
isHol := holidayName != ""
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHol {
combatMax++
harvestMax++
}
combatLeft := combatMax - char.CombatActionsUsed
if combatLeft < 0 {
combatLeft = 0
}
harvestLeft := harvestMax - char.HarvestActionsUsed
// Co-op participants have combat locked for the duration of the run. // Co-op participants have combat locked for the duration of the run.
// Under Adv 2.0 the per-day combat/harvest action caps are no longer
// surfaced — harvesting is gated by per-room nodes, supplies, and the
// threat clock; combat is gated by zones/expeditions/arena themselves.
coopRun, _ := loadCoopRunForUser(char.UserID) coopRun, _ := loadCoopRunForUser(char.UserID)
inCoop := coopRun != nil && coopRun.Status == "active" inCoop := coopRun != nil && coopRun.Status == "active"
if inCoop { if inCoop {
sb.WriteString(fmt.Sprintf("📋 **Actions:** combat locked (in Co-op #%d, day %d/%d) · %d/%d harvest\n\n", sb.WriteString(fmt.Sprintf("📋 **Co-op #%d** — day %d/%d, combat locked for the duration of the run\n\n",
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays, harvestLeft, harvestMax)) coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays))
} else { } else {
sb.WriteString(fmt.Sprintf("📋 **Actions:** %d/%d combat · %d/%d harvest\n\n", combatLeft, combatMax, harvestLeft, harvestMax))
// Co-op teaser — show open runs the player could join. // Co-op teaser — show open runs the player could join.
if line := renderCoopTeaser(char); line != "" { if line := renderCoopTeaser(char); line != "" {
sb.WriteString(line) sb.WriteString(line)
@@ -758,31 +725,6 @@ func masteryBar(value, total int) string {
return strings.Repeat("▰", filled) + strings.Repeat("▱", 10-filled) return strings.Repeat("▰", filled) + strings.Repeat("▱", 10-filled)
} }
// ── Auto-Babysit DM ─────────────────────────────────────────────────────────
// renderAutoBabysitDM builds the morning notification when auto-babysit
// covered an idle day. Surfaces what the babysitter actually accomplished
// (skill focus, gold/XP, items) plus a flavor highlight on lucky days, so
// the system feels like an active companion instead of a silent debit.
func renderAutoBabysitDM(daily int, streak int, res AutoBabysitDayResult) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🍼 **Auto-babysit activated** — €%d deducted. Your streak is safe at %d days.\n", daily, streak))
if res.Skill != "" {
sb.WriteString(fmt.Sprintf("Focus: %s · €%d earned · %d XP", res.Skill, res.Gold, res.XP))
if len(res.Items) > 0 {
sb.WriteString(fmt.Sprintf(" · items: %s", strings.Join(res.Items, ", ")))
}
sb.WriteString("\n")
}
if res.Highlight && res.Skill != "" {
line := pickBabysitFlavor(babysitHighlightLines)
if line != "" {
sb.WriteString("\n_" + fmt.Sprintf(line, res.Skill) + "_")
}
}
return strings.TrimRight(sb.String(), "\n")
}
// ── Idle Shame DM ──────────────────────────────────────────────────────────── // ── Idle Shame DM ────────────────────────────────────────────────────────────
func renderAdvIdleShameDM(char *AdventureCharacter) string { func renderAdvIdleShameDM(char *AdventureCharacter) string {
@@ -828,7 +770,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date)) sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date))
if holidayName != "" { if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers had two actions today.\n\n", holidayName)) sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers ran with TwinBee's blessing today: +5 starting mood, a free standard pack at outfitting, and +1 to every harvest yield.\n\n", holidayName))
} }
// TwinBee section // TwinBee section
@@ -963,47 +905,6 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString("\n") sb.WriteString("\n")
} }
// Holiday stats
if holidayName != "" {
tookBoth := 0
totalActive := 0
for _, p := range players {
if p.IsDead || p.IsResting {
if p.HolidayActions > 0 {
totalActive++
}
if p.HolidayActions >= 2 {
tookBoth++
}
continue
}
if p.Activity != "" {
totalActive++
}
if p.HolidayActions >= 2 {
tookBoth++
}
}
if totalActive > 0 {
sb.WriteString(fmt.Sprintf("🎉 %s double-action day — %d of %d adventurers took both actions.\n\n", holidayName, tookBoth, totalActive))
}
// Note players who died before their second action
for _, d := range dead {
if d.HolidayActions == 1 {
sb.WriteString(fmt.Sprintf("• %s — died in %s before their second action. Rough holiday.\n", d.DisplayName, d.Location))
}
}
if len(dead) > 0 {
// Check if any had HolidayActions == 1
for _, d := range dead {
if d.HolidayActions == 1 {
sb.WriteString("\n")
break
}
}
}
}
// Standout // Standout
if bestPlayer != nil && bestPlayer.LootValue > 0 { if bestPlayer != nil && bestPlayer.LootValue > 0 {

View File

@@ -84,13 +84,17 @@ func (p *AdventurePlugin) sendMorningDMs() {
} }
} }
// Babysitting: auto-resolve daily action, skip DM // Babysitting: pet-care trickle (no harvest actions; safe-rest perk
// is consumed inside the expedition camp/rest path). Still skips the
// morning DM — the babysitter is handling things in the background.
if char.BabysitActive { if char.BabysitActive {
if !char.Alive { if !char.Alive {
// Dead and not yet ready to respawn — skip babysit action
continue continue
} }
p.runBabysitDaily(&char) p.runBabysitDailyTrickle(&char)
if err := saveAdvCharacter(&char); err != nil {
slog.Error("babysit: failed to save after daily trickle", "user", char.UserID, "err", err)
}
continue continue
} }
@@ -384,40 +388,6 @@ func (p *AdventurePlugin) midnightReset() error {
continue continue
} }
// Auto-babysit: if enabled, alive, and affordable, run a single babysit day instead of losing streak
autoBabysitShortfall := int64(0)
if char.AutoBabysit && char.Alive && !char.BabysitActive {
daily := babysitDailyCost(char.CombatLevel)
bal := p.euro.GetBalance(char.UserID)
if bal >= float64(daily) {
if p.euro.Debit(char.UserID, float64(daily), "auto_babysit") {
res := p.runAutoBabysitDay(&char)
if char.CurrentStreak > 0 {
char.CurrentStreak++
if char.CurrentStreak > char.BestStreak {
char.BestStreak = char.CurrentStreak
}
} else {
char.CurrentStreak = 1
}
_ = saveAdvCharacter(&char)
if p.achievements != nil {
p.achievements.GrantAchievement(char.UserID, "adv_auto_babysit")
}
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
dmsSent++
p.SendDM(char.UserID, renderAutoBabysitDM(daily, char.CurrentStreak, res))
continue
}
} else {
autoBabysitShortfall = int64(float64(daily) - bal)
}
}
// Jitter between DMs to avoid Matrix rate limits // Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 { if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
@@ -426,9 +396,6 @@ func (p *AdventurePlugin) midnightReset() error {
// Idle shame DM // Idle shame DM
text := renderAdvIdleShameDM(&char) text := renderAdvIdleShameDM(&char)
if autoBabysitShortfall > 0 {
text += fmt.Sprintf("\n\n💸 Auto-babysit was on but couldn't cover today (€%d short). Top up the wallet and TwinBee can step in next time.", autoBabysitShortfall)
}
if char.CurrentStreak > 0 { if char.CurrentStreak > 0 {
oldStreak := char.CurrentStreak oldStreak := char.CurrentStreak
char.CurrentStreak /= 2 char.CurrentStreak /= 2

View File

@@ -4,30 +4,13 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand/v2" "math/rand/v2"
"strings"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
// ── TwinBee Character (fixed stats) ──────────────────────────────────────────
var twinBeeChar = AdventureCharacter{
DisplayName: "TwinBee 🐝",
CombatLevel: 35,
MiningSkill: 28,
ForagingSkill: 22,
Alive: true,
}
var twinBeeEquip = map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Slot: SlotWeapon, Tier: 4, Condition: 100, Name: "The Spread Gun"},
SlotArmor: {Slot: SlotArmor, Tier: 4, Condition: 100, Name: "Enchanted Plate"},
SlotHelmet: {Slot: SlotHelmet, Tier: 4, Condition: 100, Name: "Guardian's Helm"},
SlotBoots: {Slot: SlotBoots, Tier: 4, Condition: 100, Name: "Ranger's Boots"},
SlotTool: {Slot: SlotTool, Tier: 4, Condition: 100, Name: "Mithril Pickaxe"},
}
// ── TwinBee Action Selection ───────────────────────────────────────────────── // ── TwinBee Action Selection ─────────────────────────────────────────────────
type twinBeeActionWeight struct { type twinBeeActionWeight struct {
@@ -117,6 +100,65 @@ type TwinBeeResult struct {
FlavorText string FlavorText string
} }
// simulateTwinBeeOutcome rolls a flat outcome distribution for TwinBee's
// off-screen daily run. TwinBee never dies — death rolls become empty.
// Distribution by tier roughly matches the legacy simulator's spread:
// tier 3 → 60% success / 25% exceptional / 15% empty; tier 4 → 55/20/25;
// tier 5 → 50/15/35 (deeper places are stingier even for TwinBee).
func simulateTwinBeeOutcome(tier int) AdvOutcomeType {
roll := rand.IntN(100)
var emptyPct, successPct int
switch tier {
case 5:
emptyPct, successPct = 35, 50
case 4:
emptyPct, successPct = 25, 55
default:
emptyPct, successPct = 15, 60
}
switch {
case roll < emptyPct:
return AdvOutcomeEmpty
case roll < emptyPct+successPct:
return AdvOutcomeSuccess
default:
return AdvOutcomeExceptional
}
}
// simulateTwinBeeLoot returns gold value + 13 themed item names for the
// rolled outcome. Empty → zero/no items. Exceptional ≈ 2.2× success base.
func simulateTwinBeeLoot(loc *AdvLocation, outcome AdvOutcomeType) (int64, []string) {
if outcome == AdvOutcomeEmpty || loc == nil {
return 0, nil
}
tier := loc.Tier
if tier < 1 {
tier = 1
}
// Base loot scales as tier^2 * 75 with ±25% jitter.
base := int64(tier*tier*75) + int64(rand.IntN(tier*tier*40))
if outcome == AdvOutcomeExceptional {
base = base * 22 / 10
}
// Item names borrow from the location's denizens string for flavor; no
// inventory is actually deposited for TwinBee — these are display only.
names := strings.Split(loc.Denizens, ", ")
picks := 1 + rand.IntN(2)
if outcome == AdvOutcomeExceptional {
picks = 2 + rand.IntN(2)
}
if picks > len(names) {
picks = len(names)
}
chosen := make([]string, 0, picks)
rand.Shuffle(len(names), func(i, j int) { names[i], names[j] = names[j], names[i] })
for i := 0; i < picks && i < len(names); i++ {
chosen = append(chosen, strings.TrimSpace(names[i]))
}
return base, chosen
}
func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult { func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult {
activity, loc := selectTwinBeeAction() activity, loc := selectTwinBeeAction()
if loc == nil { if loc == nil {
@@ -124,46 +166,19 @@ func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult {
return nil return nil
} }
// Copy equipment so mutations don't accumulate on the global template. outcome := simulateTwinBeeOutcome(loc.Tier)
equip := make(map[EquipmentSlot]*AdvEquipment, len(twinBeeEquip)) value, items := simulateTwinBeeLoot(loc, outcome)
for k, v := range twinBeeEquip {
copy := *v
equip[k] = &copy
}
bonuses := &AdvBonusSummary{} // TwinBee has no treasures/buffs
result := resolveAdvAction(&twinBeeChar, equip, loc, bonuses, false)
// TwinBee never dies — reroll death to empty
if result.Outcome == AdvOutcomeDeath {
result.Outcome = AdvOutcomeEmpty
result.LootItems = nil
result.TotalLootValue = 0
result.EquipDamage = nil
result.EquipBroken = nil
}
// No treasure drops for TwinBee
result.TreasureFound = nil
// Select TwinBee-specific flavor text
tbResult := &TwinBeeResult{ tbResult := &TwinBeeResult{
Activity: activity, Activity: activity,
Location: loc, Location: loc,
Outcome: result.Outcome, Outcome: outcome,
LootValue: result.TotalLootValue, LootValue: value,
}
if len(items) > 0 {
tbResult.LootDesc = joinAdvItems(items)
} }
// Build loot description
if len(result.LootItems) > 0 {
names := make([]string, len(result.LootItems))
for i, item := range result.LootItems {
names[i] = item.Name
}
tbResult.LootDesc = joinAdvItems(names)
}
// Select flavor
tbResult.FlavorText = p.selectTwinBeeFlavor(tbResult) tbResult.FlavorText = p.selectTwinBeeFlavor(tbResult)
return tbResult return tbResult

View File

@@ -242,7 +242,16 @@ func processOvernightCamp(e *Expedition) string {
_ = updateCamp(e.ID, nil) _ = updateCamp(e.ID, nil)
return "" return ""
} }
// Babysit safe-rest: an active subscription promotes a Standard camp
// to Fortified for rest purposes (no need for boss-cleared arrangements).
// Rough/Base are unchanged — Rough still implies no shelter, and Base
// already exceeds Fortified.
babysitUpgraded := false
kind := e.Camp.Type kind := e.Camp.Type
if kind == CampTypeStandard && BabysitSafeRest(uid) {
kind = CampTypeFortified
babysitUpgraded = true
}
prevHP := c.HPCurrent prevHP := c.HPCurrent
bonusHP := 0 bonusHP := 0
@@ -304,8 +313,12 @@ func processOvernightCamp(e *Expedition) string {
case CampTypeStandard: case CampTypeStandard:
return fmt.Sprintf("Long rest: HP %d → %d, spell slots & resources refreshed.", prevHP, c.HPCurrent) return fmt.Sprintf("Long rest: HP %d → %d, spell slots & resources refreshed.", prevHP, c.HPCurrent)
case CampTypeFortified, CampTypeBase: case CampTypeFortified, CampTypeBase:
summary := fmt.Sprintf("Fortified rest: HP %d → %d (+1d6 = %d bonus); threat clock 5; resources refreshed.", label := "Fortified rest"
prevHP, c.HPCurrent, bonusHP) if babysitUpgraded {
label = "Fortified rest (babysitter watching the camp)"
}
summary := fmt.Sprintf("%s: HP %d → %d (+1d6 = %d bonus); threat clock 5; resources refreshed.",
label, prevHP, c.HPCurrent, bonusHP)
if heatReduced > 0 { if heatReduced > 0 {
summary += fmt.Sprintf(" Heat stacks %d.", heatReduced) summary += fmt.Sprintf(" Heat stacks %d.", heatReduced)
} }

View File

@@ -6,6 +6,8 @@ import (
"strings" "strings"
"gogobee/internal/flavor" "gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
) )
// Phase 12 E2b — Wandering monster system & night phase resolution. // Phase 12 E2b — Wandering monster system & night phase resolution.
@@ -74,8 +76,14 @@ func resolveWanderingCheck(e *Expedition, charClass DnDClass, roll1d20 func() in
threatMod = (e.ThreatLevel - 30) / 10 threatMod = (e.ThreatLevel - 30) / 10
} }
// Babysit safe-rest: an active subscription gives Standard camps the
// fortified-tier night-risk reduction (the babysitter is watching).
effectiveCamp := e.Camp.Type
if effectiveCamp == CampTypeStandard && BabysitSafeRest(id.UserID(e.UserID)) {
effectiveCamp = CampTypeFortified
}
campMod := 0 campMod := 0
switch e.Camp.Type { switch effectiveCamp {
case CampTypeRough: case CampTypeRough:
campMod = 3 campMod = 3
case CampTypeFortified: case CampTypeFortified: