From 1953eec3b5e0ccfb5d73ccdca8c53b13049e05ba Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 00:10:21 -0700 Subject: [PATCH] Adv 2.0 Phase L1: Babysit pivot + legacy resolver retire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gogobee_legacy_migration.md | 441 ++++++++++++++++++++ internal/plugin/adventure_activities.go | 167 -------- internal/plugin/adventure_babysit.go | 413 ++++-------------- internal/plugin/adventure_babysit_test.go | 108 +++++ internal/plugin/adventure_followups_test.go | 83 ---- internal/plugin/adventure_render.go | 119 +----- internal/plugin/adventure_scheduler.go | 47 +-- internal/plugin/adventure_twinbee.go | 119 +++--- internal/plugin/dnd_expedition_camp.go | 17 +- internal/plugin/dnd_expedition_night.go | 10 +- 10 files changed, 743 insertions(+), 781 deletions(-) create mode 100644 gogobee_legacy_migration.md create mode 100644 internal/plugin/adventure_babysit_test.go diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md new file mode 100644 index 0000000..9c8fba8 --- /dev/null +++ b/gogobee_legacy_migration.md @@ -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 1–50ish through grindy XP. D&D Level scales 1–20. 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__` 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 1–2 = no phase two (numeric drama only); tier 3 = 50% HP; tier 4–5 = 50% HP + a flavor barb. This gives arena fights a structural beat without overengineering. +- **Tier gating.** `tierFromCombatLevel` → `tierFromLevel`. Brackets: + + | DnDCharacter.Level | Arena tier | + |---|---| + | 1–3 | I (Pup) | + | 4–7 | II (Pit) | + | 8–12 | III (Crucible) | + | 13–17 | IV (Coliseum) | + | 18–20 | 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:** +- L1–L4 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) | 2–3 | 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 | L1–L4 done | +| L5 — Teardown | 1 | all above + 30-day grace | + +**Total:** ~10 sessions over ~3–6 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: +- L1–L4 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 L1–L4 do **not** stop writing to AdvCharacter until §6 dual-write is dropped per phase. + +**Dual-write rule:** for the duration of L1–L4, 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.* diff --git a/internal/plugin/adventure_activities.go b/internal/plugin/adventure_activities.go index 36c6993..b2317c1 100644 --- a/internal/plugin/adventure_activities.go +++ b/internal/plugin/adventure_activities.go @@ -779,173 +779,6 @@ func advEquipmentMasteryBonus(equip map[EquipmentSlot]*AdvEquipment) float64 { 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 ──────────────────────────────────────────────────────── // advCheckPartyBonus checks if other players visited the same location today. diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index 414c016..d253e2f 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -3,7 +3,6 @@ package plugin import ( "fmt" "log/slog" - "math/rand/v2" "strings" "time" @@ -18,44 +17,62 @@ func babysitDailyCost(combatLevel int) int { return 100 + (combatLevel * 20) } -// ── Weakest Skill ─────────────────────────────────────────────────────────── +// ── Pet-care daily trickle ───────────────────────────────────────────────── -func babysitWeakestSkill(char *AdventureCharacter) string { - skills := []struct { - name string - level int - }{ - {"mining", char.MiningSkill}, - {"fishing", char.FishingSkill}, - {"foraging", char.ForagingSkill}, +// petXPPerBabysitDay is the daily pet XP awarded while a babysit subscription +// is active. Picked to be a meaningful but not overwhelming push toward L10: +// roughly equivalent to a couple of player-driven actions per day. +const petXPPerBabysitDay = 3 + +// runBabysitDailyTrickle grants daily pet XP while a babysit subscription is +// active and logs an entry for the end-of-service summary. Caller is +// responsible for saving the character afterwards. +func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) { + if !char.BabysitActive { + return } - minLevel := skills[0].level - for _, s := range skills[1:] { - if s.level < minLevel { - minLevel = s.level + leveled := false + if char.HasPet() && char.PetLevel < 10 { + // Bypass petGrantXP's per-action constant — we want a flat trickle. + 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 - var tied []string - for _, s := range skills { - if s.level == minLevel { - tied = append(tied, s.name) - } + outcome := "pet_care" + if leveled { + outcome = "pet_care_levelup" } - return tied[rand.IntN(len(tied))] + logBabysitActivity(char.UserID, "pet_care", outcome, 0, petXPPerBabysitDay, "") } -// skillToActivity maps a skill name to its activity type. -func skillToActivity(skill string) AdvActivityType { - switch skill { - case "mining": - return AdvActivityMining - case "fishing": - return AdvActivityFishing - case "foraging": - return AdvActivityForaging +// BabysitSafeRest reports whether the user has an active babysit subscription +// that should let standard camps qualify for fortified-tier rest perks. +// Returns false on any error (treat as no babysit). Safe to call from tests +// where the global DB has not been initialized — the panic is swallowed. +func BabysitSafeRest(userID id.UserID) (active bool) { + defer func() { + if r := recover(); r != nil { + active = false + } + }() + char, err := loadAdvCharacter(userID) + if err != nil || char == nil || !char.BabysitActive { + return false } - return AdvActivityMining + if char.BabysitExpiresAt != nil && time.Now().UTC().After(*char.BabysitExpiresAt) { + return false + } + return true } // ── Command Handlers ──────────────────────────────────────────────────────── @@ -72,82 +89,19 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro return p.handleBabysitPurchase(ctx, 7) case lower == "month": 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: 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 month` — 30 days of service\n"+ - "`!adventure babysit auto` — toggle auto-babysit on missed days\n"+ - "`!adventure babysit focus ` — pick the skill auto-babysit trains\n"+ "`!adventure babysit status` — check service status\n"+ "`!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 `. 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 { userMu := p.advUserLock(ctx.Sender) 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))) } - // Debit gold 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.") } - // Clear any leftover logs from prior service so this period's summary is clean clearBabysitLogs(char.UserID) - // Set babysit fields - skill := babysitWeakestSkill(char) expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) char.BabysitActive = true char.BabysitExpiresAt = &expires - char.BabysitSkillFocus = skill + char.BabysitSkillFocus = "" // legacy field; no longer used if err := saveAdvCharacter(char); err != nil { slog.Error("babysit: failed to save character", "user", char.UserID, "err", err) - // 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.") } @@ -201,13 +150,18 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er 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"+ "Duration: %s (%d days)\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"+ - "Daily DMs are suspended until the service ends.\n\n"+ - "_%s_", durLabel, days, totalCost, titleCase(skill), babysitSkillLevel(char, skill), confirm) + "_%s_", durLabel, days, totalCost, petLine, confirm) 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.") } - autoLabel := "OFF" - if char.AutoBabysit { - autoLabel = "ON" - } - 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" @@ -237,23 +186,24 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error { } } - // Load log stats logs, err := loadBabysitLogs(char.UserID) if err != nil { 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"+ "Time remaining: %s\n"+ - "Skill focus: %s\n"+ - "Days completed: %d\n"+ - "Gold earned: €%d\n"+ - "XP gained: %d\n"+ - "Items claimed by babysitter: %d\n"+ - "Rivals declined: %d\n"+ - "Auto-babysit: %s", - remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel) + "Pet under care: %s\n"+ + "Days of pet care given: %d\n"+ + "Pet XP trickled: %d\n"+ + "Rivals declined: %d", + remaining, petLine, petDays, totalXP, rivalsRefused) 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.") } - // Compile partial summary logs, err := loadBabysitLogs(char.UserID) if err != nil { slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err) } summary := renderBabysitSummary(char, logs) - // Clear babysit state char.BabysitActive = false char.BabysitExpiresAt = nil 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) } -// ── 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 ──────────────────────────────────────────────────────────── func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) { @@ -473,7 +250,6 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) { continue } - // Service expired — compile summary and send DM logs, err := loadBabysitLogs(char.UserID) if err != nil { slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err) @@ -497,20 +273,20 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) { // ── Summary Rendering ─────────────────────────────────────────────────────── func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string { - totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs) + totalXP, petDays, rivalsRefused := babysitLogStats(logs) var sb strings.Builder sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n") - sb.WriteString(fmt.Sprintf("Duration: %d days\n", len(logs))) - sb.WriteString(fmt.Sprintf("Tasks completed: %d\n", len(logs))) - sb.WriteString(fmt.Sprintf("Skill focused: %s\n", titleCase(char.BabysitSkillFocus))) - sb.WriteString(fmt.Sprintf("Gold earned from hauls: €%d\n", totalGold)) - sb.WriteString(fmt.Sprintf("XP gained: %d\n", totalXP)) - sb.WriteString(fmt.Sprintf("Items dropped: %d items. Claimed by the babysitter as per the terms.\n", itemsClaimed)) + sb.WriteString(fmt.Sprintf("Days of service: %d\n", petDays)) + if char.HasPet() { + sb.WriteString(fmt.Sprintf("Pet looked after: %s (L%d)\n", char.PetName, char.PetLevel)) + } else { + sb.WriteString("Pet looked after: none — the babysitter played solitaire.\n") + } + sb.WriteString(fmt.Sprintf("Pet XP trickled: %d\n", totalXP)) if rivalsRefused > 0 { 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 { if log.RivalRefused != "" { line := pickBabysitFlavor(babysitRivalRefusalLines) @@ -519,11 +295,8 @@ func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) stri } } - // Diaper line sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines)) - - // Closing - sb.WriteString(fmt.Sprintf("\n\nYour adventurer is fed, rested, and slightly better at %s.", char.BabysitSkillFocus)) + sb.WriteString("\n\nYour adventurer is fed and rested. The pet is suspiciously well-trained.") return sb.String() } @@ -591,27 +364,13 @@ func clearBabysitLogs(userID id.UserID) { } } -// ── Helpers ───────────────────────────────────────────────────────────────── +// ── Stats helper ──────────────────────────────────────────────────────────── -func babysitSkillLevel(char *AdventureCharacter, skill string) 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) { +func babysitLogStats(logs []babysitLogEntry) (totalXP, petDays, rivalsRefused int) { for _, l := range logs { - totalGold += l.GoldEarned totalXP += l.XPGained - if l.ItemsDropped != "" { - // Count comma-separated items - itemsClaimed += len(strings.Split(l.ItemsDropped, ", ")) + if l.Activity == "pet_care" { + petDays++ } if l.RivalRefused != "" { rivalsRefused++ diff --git a/internal/plugin/adventure_babysit_test.go b/internal/plugin/adventure_babysit_test.go new file mode 100644 index 0000000..96d8a1c --- /dev/null +++ b/internal/plugin/adventure_babysit_test.go @@ -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) + } +} diff --git a/internal/plugin/adventure_followups_test.go b/internal/plugin/adventure_followups_test.go index 4a9d851..1187864 100644 --- a/internal/plugin/adventure_followups_test.go +++ b/internal/plugin/adventure_followups_test.go @@ -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) { cases := []struct { used int diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index 232f9bd..e3abae3 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -208,28 +208,6 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* 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() } @@ -356,9 +334,11 @@ func renderRivalNudge(char *AdventureCharacter) string { func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string { 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 != "" { - 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 @@ -399,30 +379,17 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq 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. + // 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) inCoop := coopRun != nil && coopRun.Status == "active" if inCoop { - sb.WriteString(fmt.Sprintf("📋 **Actions:** combat locked (in Co-op #%d, day %d/%d) · %d/%d harvest\n\n", - coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays, harvestLeft, harvestMax)) + 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)) } 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. if line := renderCoopTeaser(char); line != "" { sb.WriteString(line) @@ -758,31 +725,6 @@ func masteryBar(value, total int) string { 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 ──────────────────────────────────────────────────────────── 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)) 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 @@ -963,47 +905,6 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa 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 if bestPlayer != nil && bestPlayer.LootValue > 0 { diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 7f71265..6e8f016 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -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.Alive { - // Dead and not yet ready to respawn — skip babysit action 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 } @@ -384,40 +388,6 @@ func (p *AdventurePlugin) midnightReset() error { 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 if dmsSent > 0 { time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) @@ -426,9 +396,6 @@ func (p *AdventurePlugin) midnightReset() error { // Idle shame DM 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 { oldStreak := char.CurrentStreak char.CurrentStreak /= 2 diff --git a/internal/plugin/adventure_twinbee.go b/internal/plugin/adventure_twinbee.go index ef92009..9fa3764 100644 --- a/internal/plugin/adventure_twinbee.go +++ b/internal/plugin/adventure_twinbee.go @@ -4,30 +4,13 @@ import ( "fmt" "log/slog" "math/rand/v2" + "strings" "time" "gogobee/internal/db" "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 ───────────────────────────────────────────────── type twinBeeActionWeight struct { @@ -117,6 +100,65 @@ type TwinBeeResult struct { 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 + 1–3 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 { activity, loc := selectTwinBeeAction() if loc == nil { @@ -124,46 +166,19 @@ func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult { return nil } - // Copy equipment so mutations don't accumulate on the global template. - equip := make(map[EquipmentSlot]*AdvEquipment, len(twinBeeEquip)) - for k, v := range twinBeeEquip { - copy := *v - equip[k] = © - } + outcome := simulateTwinBeeOutcome(loc.Tier) + value, items := simulateTwinBeeLoot(loc, outcome) - 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{ Activity: activity, Location: loc, - Outcome: result.Outcome, - LootValue: result.TotalLootValue, + Outcome: outcome, + 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) return tbResult diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 44f4cff..6031ee5 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -242,7 +242,16 @@ func processOvernightCamp(e *Expedition) string { _ = updateCamp(e.ID, nil) 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 + if kind == CampTypeStandard && BabysitSafeRest(uid) { + kind = CampTypeFortified + babysitUpgraded = true + } prevHP := c.HPCurrent bonusHP := 0 @@ -304,8 +313,12 @@ func processOvernightCamp(e *Expedition) string { case CampTypeStandard: return fmt.Sprintf("Long rest: HP %d → %d, spell slots & resources refreshed.", prevHP, c.HPCurrent) case CampTypeFortified, CampTypeBase: - summary := fmt.Sprintf("Fortified rest: HP %d → %d (+1d6 = %d bonus); threat clock −5; resources refreshed.", - prevHP, c.HPCurrent, bonusHP) + label := "Fortified rest" + 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 { summary += fmt.Sprintf(" Heat stacks −%d.", heatReduced) } diff --git a/internal/plugin/dnd_expedition_night.go b/internal/plugin/dnd_expedition_night.go index 1a98114..f251557 100644 --- a/internal/plugin/dnd_expedition_night.go +++ b/internal/plugin/dnd_expedition_night.go @@ -6,6 +6,8 @@ import ( "strings" "gogobee/internal/flavor" + + "maunium.net/go/mautrix/id" ) // 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 } + // 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 - switch e.Camp.Type { + switch effectiveCamp { case CampTypeRough: campMod = 3 case CampTypeFortified: