mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
441
gogobee_legacy_migration.md
Normal file
441
gogobee_legacy_migration.md
Normal 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 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_<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 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.*
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <mining|fishing|foraging>` — 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 <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 {
|
||||
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++
|
||||
|
||||
108
internal/plugin/adventure_babysit_test.go
Normal file
108
internal/plugin/adventure_babysit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user