Closes the Hollow King arc with a solo one-shot boss fight, reached via
`!expedition start epilogue` (intercepted before the zone/loadout
machinery). Unlock: all 24 journal pages found + both Tier-5 zones cleared.
- Encounter: runZoneCombat + renderBossOutcome, the arena's own pattern —
no supplies, no walk, no party, no new mechanics. The stat block is
Belaxath's (the abyss boss whose gate "lets one thing come home")
re-dressed as the King returned in full, HP ×1.25 for a capstone; the
ability/AC/CR carry over unchanged.
- Reward-once: first clear grants a unique title ("Kingsbane") + one
Legendary + a games-room notice; later clears are a flavour-only
rematch. The flag is a dedicated player_meta.epilogue_cleared column,
latched by an atomic UPDATE (never the bulk save) so the monotonic
false→true can't be clobbered — same discipline as D1a's journal
bitmask. A loss costs a death, as any boss fight does.
- Title is written after a fresh character reload so runZoneCombat's
post-fight HP persist isn't overwritten (the survivalist-milestone
gotcha).
Golden byte-identical; go test ./... green.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
First slice of the N5 story layer. Adds a 24-page serialized campaign
threaded through the zones as collectible journal pages.
- Storage: one journal_pages INTEGER bitmask on player_meta (bit i ==
page i+1). DEFAULT 0 is correct for every existing row, so no bootstrap.
Grants are an atomic bitwise-OR (INSERT ... ON CONFLICT DO UPDATE SET
journal_pages = journal_pages | excluded.journal_pages) so a page found
mid-expedition can't be lost to a stale character save. Journal pages
are therefore grant-only + overlay-read, never in the bulk upsert.
- Drop seam: elite kills roll a page (22%, tunable) in dropZoneLoot,
gated on isElite — bosses get epilogues (D1b), not pages. Not on
SimulateCombat's path, so TestCombatCharacterization is byte-identical.
- Viewer: !adventure journal renders found pages in story order with runs
of missing pages collapsed to a single "…".
- Catalog + bitmask helpers + render live in the new
adventure_flavor_campaign.go; the pages are in-world found artifacts,
not TwinBee's voice.
Golden byte-identical; go test ./... green (incl. a real-DB round-trip
that pins the atomic OR + overlay read).
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
An Established (Tier-4) home can draw a second companion. Both pets show up on
the sheet and the town showcase, level via babysitting, and wear their own
barding (!thom pet2buy <tier>).
Combat: both pets contribute their attack/deflect/whiff procs at half weight —
DerivePlayerStats now averages over the active pets, so a pair reads as roughly
one full pet (flavor-forward, not a stat spike; difficulty-neutral). The average
reduces to identity over a single pet (x/1==x, 0.0+x==x, 3+(2L+1)/2==3+L), and
the engines still roll one proc per channel per round, so the single-pet path
and TestCombatCharacterization golden stay byte-identical. Pinned by
TestDerivePlayerStats_OnePetIsByteIdentical + the golden.
Scope kept deliberately flavor-forward: pet 2 carries identity/level/barding and
the three combat procs only. The morning-defense buff, death/ditch-recovery
save, and the level-10 supply-shop unlock stay pet-1 mechanics — no second
defensive multiplier stacks, and the one-pet path is untouched by construction.
Storage: parallel pet2_* columns on player_meta + Pet2* mirror on
AdventureCharacter (absent == no second pet, DEFAULT '' correct for every
pre-existing row, no backfill — the N2-temper / P4-party precedent). Pet-1 code
paths are untouched. Arrival reuses the chase/feed/type/name DM flow, slot-aware,
gated HouseTier>=4 with an established first pet.
Review fixes folded in (high-effort /code-review, 3 angles): pet-2 barding block
no longer hidden when pet-1 is chased away; showcase tie-break restored
(level desc, then name); petGrantXP collapsed onto the shared level-up helper;
dead-param armor-buy wrappers inlined; pet-2 barding tagged with its own euro
ledger reason.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
Hands a consumable or non-magic gear item to another adventurer. The sender
pays a 5% handling fee (of item value) to the community pot — the same civic
tax every payout pays — and is capped at 3 gifts/day (a persisted log doubling
as an anti-twink-funnel guard and audit trail). Recipient must have run !setup.
Magic items are deliberately non-giftable: attunement and the BiS economy stay
personal.
Built on the vault's inventory plumbing: a gift is transferInventoryItem
flipping the row's owner (owner-scoped, forces in_vault=0 so it lands in the
recipient's active pack). pickGiftableItem prefers a giftable match so a
non-giftable item can't shadow a giftable one and block the command.
go test ./... green; golden byte-identical.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
The Tier-4 "Established" home now unlocks a vault: `!adventure vault
[store|take] <item>` moves items in and out of a 10-slot pool that shelters
them from `!sell all`, crafting, and combat consumption. It is also E2
gifting's prerequisite plumbing.
Implemented as a single `in_vault` flag on adventure_inventory rather than a
parallel table: a stowed item keeps its identity (id, temper, everything) and
loadAdvInventory filters it out, so a vaulted item drops out of every play
surface with one flag change and comes back untouched. DEFAULT 0 = "in play",
correct for every pre-existing row, so no bootstrap backfill.
Golden byte-identical; go test ./... green.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
!expedition invite / accept / decline / party / leave. The invitee buys
their own loadout and it pools -- a party is a shared burden, not a free
ride.
The plan said invites close "before the first walk". That is not a window,
it is a race: autoRunMinExpeditionAge leaves a fresh expedition alone for
thirty minutes and then the autopilot starts walking it, and the leader's
own !expedition run can beat it there. Thirty minutes is not enough to ask
a friend who is asleep.
So two changes to what the plan specified:
- The window is all of Day 1, not the first step. Supplies burn at the
night rollover, so a companion who arrives three rooms in pays and
receives exactly what one who arrived at the gate does.
- An unanswered invite pins the autopilot: loadExpeditionsForAutoRun skips
any expedition somebody has been asked to join. The leader must not be
dragged into a boss room while their friend reads the DM. Bounded by
expeditionInviteTTL (2h) in the query itself, so a forgotten invite
costs an afternoon, not the expedition.
New table expedition_invite. Absent == nobody was asked, which is true of
every expedition predating N3 -- nothing to backfill, same reading that
let expedition_party and roster_size ship without one.
Details worth keeping:
- Outstanding invites count against expeditionPartyMax. Otherwise a leader
asks four people and three accept.
- Pooling raises Current *and* Max. supplyDepletion reads the ratio, so
folding in only Current would read as the party suddenly starving.
- A member's supplies stay in the pool when they !leave. They were spent
on the expedition, not lent to it; clawing them back would let someone
starve the party on their way out.
- assertNotAdventuring guards expeditions and rosters but not bare zone
runs, so accept checks getActiveZoneRun itself -- startExpedition does.
- A party is not a taxi: zoneOpenToLevel gates the invitee on the same
tier rule !expedition start applies to the leader.
- releaseParty now clears invites too, or someone could accept onto a
corpse.
- expeditionCmdStatus and the bare `!expedition` switched to
activeExpeditionFor, and a member typing `!expedition go 2` is told the
leader picks the path instead of falling through to `start` and being
told "2" is an unknown zone.
Combat still seats one player -- handleFightCmd is P6c. go test ./...
green, golden byte-identical.
The turn engine seats a party since P3, but only seat 0 survived a
suspend: seats 1+ reopened from their Mods on every step, rearming their
once-per-fight one-shots -- a party Halfling rerolled a nat 1 every round.
Split CombatStatuses into the fight-scoped half (the enemy's stance, the
round cursor) and the per-character half, ActorStatuses. The embed is
anonymous and untagged, so statuses_json stays the same flat object it
always was and every in-flight prod row decodes unchanged.
Seat 0 keeps living on combat_session. Seats 1+ get combat_participant
rows. That asymmetry is the point: a solo fight -- which is every fight
that has ever run, and the whole balance corpus -- writes exactly the
bytes it wrote before, and no participant rows at all. roster_size
guards the read, so the solo loader never issues the second query.
Parties commit their seats in the same transaction as the session row;
solo keeps its single unwrapped UPDATE.
expedition_party is the co-op roster. No party_id on dnd_expedition:
expedition_id already identifies the party, and a second key would be a
second answer to "who is in this party". Absent means solo, in both new
tables, so neither needs a bootstrap backfill.
The combat characterization golden is byte-identical.
Also seeds and re-powers the two statistical subclass tests. They drew
from the package-global RNG, so their verdict depended on how much
randomness every test declared before them happened to consume -- which
is why they flaked on a clean tree. Sweeping 40 seeds: Precision Attack
had a mean margin of +127 wins against a +50 threshold but a worst case
of -42, and Assassinate averaged +12.8 with two seeds outright negative.
Both effects are real; the trial counts were too low to see them. Seeded,
and raised to 24000/6000 trials, where all 40 seeds clear.
Backtracking (revisit R2) breaks the assumption every zone-run caller
quietly relied on: that CurrentRoom == len(VisitedNodes)-1. Position and
progress have been the same number only because navigation was
forward-only.
Split them. CurrentRoom is now the first-entry index of CurrentNode in
VisitedNodes -- the room number the player was shown on the way in, and
the salt that enemy/trap/harvest/encounter keys hash. A revisited room
therefore resolves to the same room. RoomsTraversed is a new monotonic
step counter, persisted, backfilled from visited_nodes for in-flight rows.
The audit found only two progress-shaped reads. narrationCadence moves to
RoomsTraversed so a backtracking player draws fresh flavor rather than
replaying the entry-room lines. The other was a latent bug: zoneCmdGo
labelled the room it moved into as CurrentRoom+1, which is only correct at
the frontier; advanceZoneRunNode now returns the true path index.
VisitedNodes becomes an ordered set and RoomsCleared becomes idempotent --
both no-ops while navigation is forward-only, both load-bearing after R2.
No player-visible behavior change. Nothing to route off RoomsTraversed for
threat/SU: verified at HEAD that movement charges neither. Threat comes
from combat, supplies burn per day. The revisit plan's cost model claimed
otherwise and has been corrected -- R2's "discount" is really a net-new
cost decision.
B1 — tempering. `!adventure temper` pushes an owned magic item one rung up
the rarity ladder at the blacksmith, capped at Legendary. Rarity lives on
the registry definition, so an upgraded instance records its progress as a
`temper` step count on its own row (adventure_inventory, magic_item_equipped)
and effective rarity is derived on read. The stored base rarity is never
written back, so an item cannot double-bump across a load/save round-trip.
Effects flow through the existing rarity scalars — no new combat math, and
the Legendary cap means this accelerates reaching the current ceiling rather
than raising it.
Costs mirror the crafting ladder (10k/25k/60k/150k, Foraging 15/20/25/30).
Only the Legendary rung consumes a T5 material; gating the lower rungs on
one would make tempering unreachable until a player already clears T5.
Two plan anchors were wrong: thyraks_core and portal_fragment are not
loot-note strings needing promotion — they are UniqueAlways slate entries
that already materialize as inventory rows on every T5 clear.
B4 — expedition achievement wing: first-clear and all-zones-cleared per tier
(10), a quiet-clear for keeping peak threat under 50, and temper_legendary.
The quiet-clear requires max_threat_seen to be *present*, not merely low:
recordMaxThreat samples only on day rollover and never stores a zero, so an
absent key means no threat history, not low threat. The plan's no-death-T4
achievement is not shipped — no per-expedition death counter exists — and
pet-saved-my-life already ships as combat_pet_save.
C4 — arena seasons. Standings are derived from arena_history.created_at per
calendar quarter rather than wiping arena_stats: same visible reset, but
lifetime totals survive for `!arena stats` and no destructive job can fire
twice. Crowns archive to arena_season_titles rather than player_meta.title,
which already carries the Survivalist milestone. Rollover rides the existing
midnight ticker and self-dedups on the season key, so a bot that was down on
the boundary catches up on its next wake.
One-shot MAS-migration helper. DMs a fixed target set of users with no
email on file in Authentik, collects an address, verifies inbox ownership
with an emailed 6-digit code (via Resend), and only writes it back to
Authentik after confirmation — so a mistyped/hostile address never becomes
a live recovery address. FSM state persists in email_nag_prompts so
restarts never re-nag anyone done or mid-flow.
- Per-user rolling-1h cap on verification emails (EMAIL_NAG_MAX_SENDS_PER_HOUR)
to prevent send-spam abuse.
- Misconfig disables only this plugin instead of aborting bot startup.
URL link previews now post the page's og:image as an inline m.image
thumbnail above the title/description reply. All outbound fetches in the
preview path (page scrape, image HEAD probe, image download) route through
a new SSRF/DoS-hardened safehttp client so a posted link can't steer
fetches at loopback/RFC1918/cloud-metadata IPs or OOM the parser.
Also flips the daily WOTD auto-post to opt-in (ENABLE_WOTD_POST=true)
instead of opt-out.
Tedium-removal pass driven by live play feedback. Three big threads:
Expedition autopilot Phase 4 — background auto-run + harvest-until-dry:
- New expeditionAutoRunTicker walks active expeditions every 15min
(5min tick, per-expedition CAS on new last_autorun_at column). Skips
combat sessions, briefing/recap quiet windows, expeditions <30min old.
- Walks up to 3 rooms/tick with compact narration; suppresses DMs when
0 rooms walked or just hitting the per-tick cap (no "stretch complete"
filler). Player only hears from the bot when a real decision is needed.
- Compact mode: one-line combat narration for trash/elite, auto-resolves
elite doorways via the forward-sim engine (boss still pauses). Threaded
via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom.
- Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8
attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses.
- Ambient ticker: anti-repeat by Kind via new last_ambient_kind column
(avoids two pack_rat DMs in a row when the pool only has 6 lines).
- !expedition go <n> now routes to the fork-choice handler when active +
numeric; fork footer rewritten to suggest !expedition go instead of
!zone go. Boss/Elite doorway: formatNextRoomMessage routes the action
hint by next room type and autopilot loop breaks via new nextRoomType
field so "Room X/Y — Boss" doesn't double-print with contradictory
hints (!zone advance vs !fight).
- runAutopilotWalk extracted from expeditionCmdRun so foreground and
background share the loop body.
Rogue Sneak Attack actually exists now:
- Audit found the rogue's "Sneak Attack" passive was AutoCritFirst +
5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this
with a small flat buff; the class's defining mechanic never matched
its tooltip or 5e identity.
- Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in
combat_primitives.go (same lane as DivineStrikePerHit). Scales with
level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20.
- AutoCritFirst + 5% rider retained as bonuses on top of working sneak
attack — preserves the opener-burst feel.
- L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes
from "16 rounds to kill, die in 8" to winnable.
TwinBee voice sweep (Phase B3):
- ~530 line changes across 24 files replacing third-person "TwinBee
notes/files/tracks/respects/..." constructions with first-person
inside flavor string literals. Code identifiers (TwinBeeLine,
twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names
(TwinBee's Bell), achievements, and game-title references in
fun.go (Konami TwinBee ship) left intact.
- Catches a real bug: Valdris fight rendered "TwinBee marks the
Legendary Resistance" mid-combat in third person, contradicting
the Phase B2 convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C2: wrap the 19-statement purge loop in a single BEGIN IMMEDIATE /
COMMIT so a mid-maintenance crash can't leave half the cache tables
purged. SetMaxOpenConns(1) guarantees the BEGIN, DELETEs, and COMMIT
all share one SQLite connection.
C3: run PRAGMA integrity_check at most once per week, gated on the
mtime of a sentinel file (.integrity_check_last) under dataPath. Any
result other than 'ok' is logged via slog.Error. The sentinel is
touched even on failure so a corrupted DB doesn't re-run the check
every cycle and spam logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit flagged C1 as "Backup() unwired" — actually
adventure_scheduler's dailyReset has been calling it every
midnight. The real gap was that Backup() always ran, always
to dataPath/backups, with no way for dev environments to opt
out.
Now: env var unset → no-op + debug log. Env var set → that
directory is the destination. Existing scheduler call site
unchanged; it cleanly no-ops in dev.
Active expeditions now tick on a 3-hour cadence between the 06:00
briefing and 21:00 recap. Each fire DMs the player a short TwinBee
moment — mostly pure flavor (dripping sounds, polite possums,
diplomatic moths) with a 50/50 chance of a small mechanical nudge
(±SU, +threat, 1d4 coins from a found purse, +1 HP if camped).
The dungeon is now louder while you're offline without being
balance-relevant. Multi-day expeditions feel populated by something
other than the player's calendar.
Mechanics:
- new last_ambient_at column with CAS idempotency (mirrors
briefing/recap pattern in dnd_expedition_cycle.go)
- ambientCooldown=3h, skipped if in turn-based combat session
- 60min politeness window around scheduled briefing/recap so we
don't pile DMs on the daily messages
- weighted event pool with per-event eligibility (camp_visitor
requires camped, pack_rat requires SU>=1, faction_whisper
requires threat>=30 and not siege)
- effects: applyThreatDelta, updateSupplies, euro.Credit +
coins_earned bump, SaveDnDCharacter HP nudge (clipped at max)
Files:
- internal/db/db.go: ALTER TABLE + CREATE TABLE update
- internal/flavor/twinbee_ambient_flavor.go: 7 humor-heavy pools
- internal/plugin/expedition_ambient.go: ticker + resolver
- internal/plugin/expedition_ambient_test.go: 8 pure-logic tests
- internal/plugin/adventure.go: wire goroutine in Start()
Magic items now reach players through three surfaces: zone loot drops,
Luigi's "Curios" shelf, and combat effects. Effects are formulaic
(Rarity scalar x Kind), mirroring the bestiary tuning pass, with an
empty magicItemEffectOverlay as the hand-authored refinement path.
- magic_items_gameplay.go: rarity index, magic_item_equipped persistence
(new table, DnDSlot-keyed), codified effect formula, applyMagicItemEffects
combat hook, potion/scroll -> ConsumableDef bridge, !adventure equip-magic
- dropZoneLoot: 15% magic-item substitution roll by tier rarity
- Luigi's Curios category: daily UTC-seeded 8-item rotation
- combat_bridge / combat_session_build: applyMagicItemEffects after passives
- consumableDefByName falls through so loot/shop potions auto-resolve
- renderDnDSheet: new Magic Items section
Equippable items live entirely in the DnDSlot scheme, separate from the
legacy tier-gear. Attunement items equip inert until attuned (3-slot cap).
Schema-only: persistent per-fight session table so manual elite/boss
combat can resume or be auto-finished by the timeout reaper from exact
mid-state. State machine and accessors land in a later PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle of uncommitted working-tree edits across combat engine, expedition
cycle, flavor pools, and TwinBee/zone narration. Includes new files:
combat_debug.go, dnd_boss_consumables.go, dnd_dex_floor.go, plus
CHANGES_24H.md and REBALANCE_NOTES.md scratch notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New optional plugin (FEATURE_SPACE_INVITER) that detects local homeserver
users who aren't in any Matrix Space and prompts the configured admin via
DM with yes/no/ignore. Persistent prompt log in space_inviter_prompts so
restarts and upgrades don't re-ping for users who already replied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final G9 step. The schema no longer creates current_room or
room_seq_json on fresh installs, and existing prod schemas drop them
via purgeLegacyZoneRunColumns() once the operator flips
GOGOBEE_BRANCHING_PURGE=1.
- CREATE TABLE dnd_zone_run loses both columns; comment updated to
point at G1's columnMigrations for the graph-mode columns.
- cleanup_g9.go mirrors the L5 idempotent-purge shape: ALTER TABLE
DROP COLUMN per legacy column, "no such column" treated as already
done, default-off.
- Wired into adventure Init alongside purgeLegacyAdvCharacterTable.
- Plan doc marks G9 complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
G1 — schema. Adds zone_node + zone_edge tables and three columns to
dnd_zone_run (current_node, visited_nodes, node_choices). Linear
columns stay during the migration; G9 retires them.
G2 — types + validator. New internal/plugin/zone_graph.go defines
ZoneNode/ZoneEdge/ZoneGraph + ZoneNodeKind/ZoneEdgeLockKind. BuildGraph
enforces: exactly one entry, exactly one boss, boss reachable via BFS,
no orphan nodes, no self-loops without explicit opt-in. BuildLinearGraph
synthesizes a chain for legacy zones.
G3 — legacy compiler + dual-mode loader. compileLegacyZoneGraph turns
a ZoneDefinition into a representative linear graph (MaxRooms shape).
loadZoneGraph returns the registered graph if hand-authored (G7+),
else the legacy fallback. compileRunGraph mirrors a per-run RoomSeq
exactly for hot-swap derivations.
G4 — run-state dual-write. DungeonRun gains CurrentNode / VisitedNodes
/ NodeChoices. scanZoneRun reads them and hot-swaps current_node from
current_room when a row predates the migration (deriveLegacyNodeID
matches BuildLinearGraph's "<zone>.r<n>" scheme). startZoneRun and
markRoomCleared write both columns.
No behavior change yet — navigation surface (forks, locked edges,
!zone go) lands in G5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewires loadAdvCharacter / createAdvCharacter to source from player_meta,
drops every legacy-table fallback in the loadXxxState helpers, and adds a
GOGOBEE_LEGACY_PURGE=1 gate to drop the now-cold adventure_characters
table. Schema CREATE removed and column migrations tolerate the missing
table so the purge stays sticky across reboots.
§7.4 of the migration plan is reconciled to document that
player_meta.combat_level stays — combat_stats.go consumes it via the
overlay and is shared infrastructure, not legacy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three player_meta columns (title TEXT, treasures_locked INT,
crafts_succeeded INT). MiscState struct + load/upsert/backfill/
projection helpers. Dual-write rides saveAdvCharacter alongside the L5e
death-state hook — Title is dormant, the other two already mutate at
sites that save.
Tests: TestPlayerMetaMiscStateBackfill_Idempotent,
TestLoadMiscState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaMiscState_RoundTrip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight player_meta columns: alive (default 1), dead_until,
death_reprieve_last, last_pardon_used (DATETIME), last_death_date,
grudge_location, death_source, death_location (TEXT).
DeathState struct + load/upsert/backfill/projection helpers. Dual-write
strategy switches: instead of per-site upserts, the dual-write rides
saveAdvCharacter itself (after the existing display_name dual-write).
Death-state mutation surface spans ~50 save sites; the saveAdvCharacter-
internal hook catches every one without scatter.
Backfill idempotent (only fills rows where every death field is still
default). Tests cover backfill, fallback, round-trip, and the
saveAdvCharacter dual-write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ten player_meta columns: current_streak, best_streak, last_action_date,
streak_decayed, action_taken_today, holiday_action_taken,
combat_actions_used, harvest_actions_used, created_at, last_active_at.
LifecycleState struct with HasLifecycle() marker + load/upsert/backfill/
projection helpers. New resetAllPlayerMetaDailyActions parallels the
existing resetAllAdvDailyActions for the bulk midnight reset.
createAdvCharacter now seeds created_at/last_active_at (CURRENT_TIMESTAMP)
so player_meta rows are fully formed at creation.
Mutation surface turned out to be small: only `rest` writes
ActionTakenToday (combat/harvest counters are dormant in current code);
plus streak halve + streak update in scheduler. Dual-writes wired at all
four sites + the bulk reset.
Tests: TestPlayerMetaLifecycleStateBackfill_Idempotent,
TestLoadLifecycleState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaLifecycleState_RoundTrip,
TestResetAllPlayerMetaDailyActions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five player_meta columns (babysit_active, babysit_expires_at,
babysit_skill_focus, auto_babysit, auto_babysit_focus). BabysitState
struct with IsActive() marker mirrors SkillState/HouseState shape.
loadBabysitState / upsertPlayerMetaBabysitState /
backfillPlayerMetaBabysitState / babysitStateFromAdvChar helpers.
Dual-writes wired at handleBabysitStart, handleBabysitCancel,
checkBabysitExpiry. Backfill is idempotent — only fills inactive rows
whose legacy counterpart is active.
AutoBabysit / AutoBabysitFocus / BabysitSkillFocus are dormant in
current code but preserved for the schema migration.
Tests: TestPlayerMetaBabysitStateBackfill_Idempotent,
TestLoadBabysitState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaBabysitState_RoundTrip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight player_meta columns (combat_level, combat_xp, mining_skill,
mining_xp, foraging_skill, foraging_xp, fishing_skill, fishing_xp).
SkillState struct with HasSkills() marker mirrors PetState/HouseState
shape. loadSkillState / upsertPlayerMetaSkillState /
backfillPlayerMetaSkillState / skillStateFromAdvChar helpers.
Dual-writes wired at every mutation site: consumables craft XP (both
success and failure branches), events.go XP grant across all skills,
arena death + session-complete combat XP. Backfill is idempotent (only
fills rows where every skill column is still zero AND the legacy row
has any non-zero value).
CombatLevel/CombatXP are transitional — dropped at L5g after the DnD
mass-backfill retires the legacy CombatLevel-derived fallback.
Tests: TestPlayerMetaSkillStateBackfill_Idempotent,
TestLoadSkillState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaSkillState_RoundTrip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight pet_* columns added to player_meta (flags packed into pet_flags_json).
PetState + load/upsert/backfill helpers; dual-writes wired at every pet
mutation site (arrival, naming, supply-shop unlock, babysit trickle,
morning defense, misty reactivation). Backfill idempotent, runs on Init.
L4 grep-empty exit criterion intentionally NOT met for adventure_pets.go:
helpers still take *AdventureCharacter because external callers (babysit,
scheduler, npcs, combat_bridge, combat_stats, dnd_sheet, arena, character)
read pet fields directly. Cross-file signature flip belongs after the
dual-write soak.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
player_meta.hospital_visits column added with idempotent backfill.
Hospital cost now DnDCharacter.Level x 50k (5x before insurance), falling
back to dndLevelFromCombatLevel when no D&D row exists. Revive paths also
restore DnDCharacter HP to full; char.Alive still dual-writes during soak.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds player_meta.display_name with idempotent backfill, dual-writes from
createAdvCharacter (in-tx) and saveAdvCharacter (post-commit), and a
loadDisplayName helper with AdvCharacter fallback. Readers are not flipped
yet — soak begins now; per-call-site swap follows in a later session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Creates the player_meta table (gogobee_legacy_migration.md §2.1) with the
three columns L2 needs: arena_wins, arena_losses, invasion_score. Future
phases ALTER in their own columns. Naming follows the §2.0 callout — no
dnd_ prefix on new tables.
Helpers in internal/plugin/player_meta.go: loadPlayerMeta,
upsertPlayerMetaArena, backfillPlayerMetaArena (INSERT OR IGNORE,
idempotent, logs row count per Phase R1 precedent). Plugin Init runs
the backfill once on startup.
Dual-write per §11: every char.ArenaWins++/ArenaLosses++ in
adventure_arena.go also calls upsertPlayerMetaArena. AdvCharacter
columns stay in place — reads will switch back to AdvCharacter trivially
if a rollback is needed before L5 teardown.
Read swap: handleArenaStats and renderDnDSheet now load player_meta and
prefer its values (falling back to AdvCharacter if the row is missing,
which only matters during the deploy window before backfill runs).
renderArenaPersonalStats's signature changed from (char, stats) to
(displayName, wins, losses, stats) so the render is DB-free.
Tests: TestPlayerMetaArenaBackfill_Idempotent verifies double-run
backfill is a no-op and doesn't clobber post-backfill dual-writes;
TestUpsertPlayerMetaArena_RoundTrip covers insert/update/missing-user
paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dnd_zone_run.harvest_nodes_json column and a small persistence
layer (loadStandaloneHarvestNodes / saveStandaloneHarvestNodes) so
!zone enter runs that aren't tied to an expedition can carry per-room
HarvestNode state. Expedition runs continue to use
expedition.region_state for the same data — this is the casual flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R3 — Combat Event Integration:
- dnd_expedition_combat.go: Combat Interrupt rolls (§4.2) with
threat-clock and Ranger-wilderness modifiers; Patrol Encounters
scaled by threat level; recordZoneKill writer with monsterKillTags.
- Interrupt gate at head of handleHarvestCmd; patrol gate before
resolveRoom in zoneCmdAdvance; kill writer wired into combat-win
paths.
R4a — Zone Loot Tables:
- dnd_zone_loot.go: §5 loot drop tables for all 10 zones × 5 tiers
with §8.1 sell-value bands. dropTierFromCR brackets + boss floor.
- Hooks on combat-win in resolveCombatRoom, resolveBossRoom,
runHarvestInterrupt, tryPatrolEncounter.
R4b — Fishing Integration:
- dnd_zone_fish.go: fishingZones allow-list, fishingSkillBonus,
rangerRareCatchUpgrade (§6.2), feywildFishDistortion narration.
- §6.1 fish entries added to resource registry for Forest, Sunken
Temple, Underdark, Feywild zones.
- !fish wired through handleHarvestCmd; zoneItemFlavor matrix
populated for all 10 zones × all loot items.
R5 — Economy Integration:
- dnd_economy.go: !sell (post-expedition gate, single CHA Persuasion
DC 17 → +15% bump), !craft (§8.2 4 exemplar recipes), !lore
(INT/Arcana DC 15 recipe discovery).
- dnd_known_recipe table for persistent recipe discovery.
Flavor reuse: HarvestInterrupt, PatrolEncounter, CombatVictory,
PlayerDeath, LootDrop*, FeywildTimeDistortion* — all existing pools.
No new flavor file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dnd_zone_run table (run_id, zone_id, room_seq_json, rooms_cleared,
boss_defeated, abandoned, loot_collected, gm_mood, started_at,
last_action_at, completed_at) added to db.go schema with active-run
index.
dnd_zone_run.go ships RoomType (entry|exploration|trap|elite|boss),
DungeonRun struct, generateRoomSequence (Entry → ExplorationxN1 → Trap
→ ExplorationxN2 → Elite → Boss within zone's [MinRooms, MaxRooms]),
and persistence helpers: startZoneRun (gates on level tier + active-run
exclusivity), getActiveZoneRun, getZoneRun, markRoomCleared
(advances and auto-completes on boss kill), abandonZoneRun,
adjustGMMood (clamped to [0,100]), addLoot.
8 new tests covering room-sequence shape (entry first, boss last,
exactly one trap/elite, ≥2 explorations), happy-path start,
concurrent-run rejection, unknown-zone rejection, full advance-to-boss
flow with completion, abandon idempotency, GM mood clamping at both
bounds, and loot accumulation order. Full repo test suite green.
Boss-room behavior, !zone command surface, and TwinBee narration
arrive in D1c/D1d.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First mechanical slice of the subclass system. Each subclass gets its L5
and L7 abilities wired through the existing combat engine + skill check
substrate; no new resource pools introduced.
Champion (L5/L7):
- Improved Critical: new Mods.CritThreshold lowers crit floor to nat 19+
in resolvePlayerAttack. Default 20 preserved for non-Champions.
- Remarkable Athlete: +½ proficiency bonus to STR/DEX/CON skill checks
via subclassSkillBonus, layered on raceSkillBonus.
Berserker (L5/L7):
- Rage: new !arm-able active ability gated to Berserker subclass via
DnDAbility.Subclass field. Reuses the Fighter stamina pool (3/long-rest
matches 5e's rage uses). On fire: +2 flat damage per hit, halve incoming
weapon damage, +50% damage approximation for Frenzy's bonus-attack-per-
turn (one-shot combat can't model that literally).
- Frenzy exhaustion: new exhaustion column on dnd_character; incremented
by persistDnDPostCombatSubclass after any rage'd combat. Decremented by
one per long rest.
- Mindless Rage: documented; charm/frighten conditions don't exist in the
combat engine yet, so no functional effect until P11 zone bosses land.
Thief (L5/L7):
- Sleight of Hand added to dndSkillTable.
- Fast Hands: +5 to Sleight of Hand checks via subclassSkillBonus.
- Supreme Sneak: advantage on Stealth (best of two d20s) via new
subclassSkillAdvantage hook in performSkillCheck. 5e's half-speed gate
dropped — we don't track movement speed.
Plumbing:
- applySubclassPassives layered after applyClassPassives in both arena
and dungeon combat bridges.
- DnDAbility.Subclass enables per-subclass gating; characterActiveAbilities
filters !arm and !abilities lists by both class and subclass.
- Existing !respec full-wipe also clears Exhaustion.
Tests: Champion CritThreshold gating across L4/L5/no-subclass; rage Apply
flag-set; rage probabilistic win-rate lift vs tougher enemy; exhaustion
increment only on raged combats; long-rest decrement; Champion bonus on
STR/DEX/CON only and only at L7+; Thief Fast Hands SoH-only; Thief
Supreme Sneak L7+ Stealth-only; statistical advantage roll lifts Thief
stealth average by ≥2 vs plain Rogue; !arm rage subclass gating; schema
roundtrip; characterActiveAbilities visibility.
Implements the identity layer of gogobee_subclass_system.md: 15 subclasses
(3 per class) selectable at L5 via !subclass.
- Schema: new subclass + last_subclass_respec_at columns on dnd_character.
- Registry (dnd_subclass.go): 15 entries with class, display, tagline, and
TwinBee flavor line; helpers for prompt rendering and input resolution
(number, id, or display-name; case/space/hyphen-insensitive).
- !subclass command: bare form prints prompt or current; !subclass <name>
selects (free first time) or changes (500 euros + 30-day cooldown via
separate timestamp from the full !respec wipe). Save-then-debit ordering
mirrors !respec audit fix B.
- Level-up DM at L5+ now embeds the real selection prompt instead of the
Phase 9 placeholder; suppressed once a subclass is set.
- Sheet shows the subclass below the class line, or nudges !subclass when
unchosen at L5+.
- Existing !respec full-wipe also clears subclass + cooldown timestamp.
Mechanical effects of L5/7/10/15 subclass abilities deferred to SUB2/SUB3.
- Adventure characters now record DeathSource ("adventure"|"arena") and
DeathLocation when killed. Threaded through Kill() and transitionDeath.
Daily report uses the recorded death location instead of misattributing
arena deaths to the day's adventure log: when DeathSource != "adventure",
the adventure block shows the alive icon and a separate "Later fell in
{location}." line. Standout-loss flavor uses DeathLocation. Empty
DeathSource (legacy rows) falls back to current behavior.
- calcDamage applies a ±15% per-hit jitter, so successive hits in a phase
no longer produce identical numbers. Previously every hit in a phase was
flat (e.g. 17, 17, 17, 17) and mirror-symmetric between player/enemy,
reading as scripted.
- assessThreat rewritten to use the engine's actual penetration formula:
damage-per-round each direction, then rounds-to-kill ratio. The old
additive HP+Attack*3 model ignored Defense, so even fights could be
rated trivial — players died after consumables were skipped with the
"threat assessed as manageable" message.
- SelectConsumables never skips on trivial when arenaRound > 0. Arena
losses cost real money and equipment, so the cost of a wrong assessment
is too high to gamble on; adventure-side fights still skip trivial
threats to avoid wasting items on chump enemies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crafting was a downstream consumer of Foraging skill but never gave any
XP back, so the feedback loop was one-directional — only gathering
contributed to the level that gates better recipes.
Now: each auto-craft attempt grants Foraging XP (success > failure).
Per-tier values:
tier 1: +12 / +3
tier 2: +25 / +5
tier 3: +40 / +8
tier 4: +60 / +12
tier 5: +90 / +18
Successful T1 ≈ 30% of a Foraging Success haul; T5 ≈ 40%. Failures get a
small consolation grant — the attempt still produced practical knowledge
(and lost ingredients).
Schema: adventure_characters.crafts_succeeded INT for lifetime tracking.
Migration entry included.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiple baskets (or multiple mimics) sent during the same day now share
one game-room post, one vote, one resolution. First-in becomes the stack
"lead"; subsequent same-type sends become followers that inherit the
lead's deadline and votes.
Behavior:
- Send a basket → new lead, new post, 6h timer starts
- Send another basket within the window → silently joins the stack,
edits lead's post to bump count
- At stack size 2, TwinBee adds a "this gift looks REALLY special" line
(one of 5 variants picked deterministically by lead id). No further
escalation as the stack grows
- One !coop giftvote on the lead's id covers the whole stack
- Resolution applies the same outcome to every gift in the stack;
modifier is N × ±6
- End-of-run gift log groups by stack with all senders attributed
Schema: coop_dungeon_gifts.stack_lead_id INTEGER (NULL for lead/standalone,
otherwise points at lead's id). Migration entry included.
Why first-in deadline (vs extending on each follower): exploitability.
Saboteurs could spam-extend a stack to delay resolution. Locked deadline
keeps senders honest about timing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single daily-tick gift resolution with independent per-gift
expiries. Each gift now has its own 6h voting window; once that elapses,
the votes are tallied, the sender gets DM feedback, and the live game-room
post is edited to reveal the type and resolved modifier. The modifier
sits on the run waiting for the next floor resolution to merge it in.
Effect:
- Gifts fire continuously throughout the day rather than all at once at
08:00 UTC, surfacing sender activity in real time.
- Senders get fast feedback (~6h instead of waiting for the next daily
tick).
- Floor resolution becomes purely "sum pending modifiers from already-
resolved gifts" — cleaner separation of concerns.
- Atomic per-gift apply via markCoopGiftApplied (UPDATE...WHERE applied_at
IS NULL) prevents double-application on retry.
Schema additions:
- coop_dungeon_gifts.expires_at (when voting closes)
- coop_dungeon_gifts.applied_at (when modifier merged into a floor)
- Migration entries provided.
Defensive backstop: floor resolution still force-resolves any gifts whose
expiry was missed (e.g., bot down during the timer window).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Base.PinEvent / Base.UnpinEvent helpers (m.room.pinned_events state)
that read-modify-write the existing pin list — idempotent on both sides.
The bot needs power level for state events; failures are logged but not
fatal.
Schema: add coop_dungeon_runs.invite_post_id to remember which event to
unpin. Migration entry included.
Wired:
- !coop start: pin the invite post in the games room
- !coop cancel: unpin before posting cancellation
- coopProcessLocks (auto-lock or auto-cancel): unpin before lock/expiry post
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit pass addressed concurrency races, crash-recovery double-pay risks, and
non-determinism in tie tallying.
CRITICAL — concurrency
- coopResolveFloor now acquires advUserLock for every party member (sorted by
UserID) before mutating state, so concurrent !coop fund / vote / giftvote
commands serialize against the scheduler.
CRITICAL — crash idempotency
- Add coop_dungeon_runs.last_resolved_day and coop_dungeon_members.member_payout
columns (with migration entries) for crash-resume tracking.
- Skip resolution only if last_resolved_day >= day AND status is terminal;
otherwise re-enter and finish via idempotent operations.
- On resume detection (event already has outcome), reuse the saved roll
instead of re-rolling — prevents different outcomes on retry.
- claimCoopMemberPayout / claimCoopBetPayout: atomic UPDATE...WHERE col IS NULL
pattern. Each member/bet can only be paid once; retries are silent no-ops.
- createCoopEvent now INSERT OR IGNORE — idempotent on retry.
- last_resolved_day is set as the FINAL write per floor.
HIGH — bugs
- Lazy-create floor event in resolver if missing — covers crash window
between lockCoopRun and the original createCoopEvent.
- coopTallyVote sorts tied winners alphabetically before fallback (was
non-deterministic via Go map iteration).
- Removed dead day-1 wipe combat-action refund block — refund check was
always false because midnight reset clears state before resolution.
LOW
- Log slog.Error on malformed JSON in parseCoopVotes / parseCoopFundingMap
instead of silently dropping data.
Tests
- Tally test extended to assert determinism across 50 runs (catches map-iteration
leaks).
- Added coverage for new tie-with-leader-vote-outside-winners case.
- Idempotency-guard sanity check on LastResolvedDay vs CurrentDay.
Race detector: clean. Full suite: green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-day party runs with funding decisions, TwinBee-narrated floor events,
spectator parimutuel betting, basket/mimic gift system, and weighted-roll
item distribution including masterwork drops at T4-T5.
Schema (5 tables, 2 fewer than spec by computing helpfulness on-the-fly and
skipping the loot-pending state):
- coop_dungeon_runs / _members (daily funding as JSON column)
- coop_dungeon_events (votes as JSON, used to derive TwinBee helpfulness)
- coop_dungeon_bets / _gifts
Mechanics:
- 2-4 player parties, 24h invite window, locks consume one combat action
- Per-floor success roll: base + sum(funding) + level/pet bonuses + event
vote modifier + active gift modifiers, clamped to 5..95
- Funding tiers (none/min/std/agg/all_in) with liability cap at +8% for
under-leveled players
- TwinBee narrates events from existing flavor pool; 20 authored events with
per-option modifiers and embedded recommendation
- Parimutuel betting with 10% rake; odds line shown on lock/daily/status posts
- Gift modifiers symmetric at 50/50 sender mix so no dominant strategy
- Item drops on success via weight-by-contribution roll; T4 25% / T5 100%
masterwork chance (random pick from existing T4/T5 defs)
Balance via Monte Carlo (coop_dungeon_balance_test.go):
- All tiers exceed 1.5x solo daily income at average party profile
- Optimal funding strategy walks up tiers correctly (Min/Std/Std/Mixed/Agg)
- All-In stays -EV at every tier (boost lever, not optimal play)
Tests: parsing, liability cap, JSON roundtrips, vote tally with leader
tiebreak, event meta consistency, parimutuel payouts, gift EV symmetry,
weighted-roll distribution (Monte Carlo), masterwork tier gates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>