A player who stops tending their adventurer doesn't stop having one. After
24h with no action against Adventure, the character gets restless and leaves
on an expedition by itself: the easiest zone its level band allows, the
cheapest supplies it can afford, and whatever gear was already on the rack.
Everything downstream of the start was already autonomous — the autopilot
walks rooms, drives elite and boss fights on the turn engine, camps, harvests
and picks forks. The only thing that ever needed a human was `!expedition
start`, so that's all this adds: a 30m ticker plus a clock.
It never buys or equips anything, and that is the whole mechanic: a neglected
adventurer grinds half-starved runs on rusting gear and comes home taxed. The
prodexercise killed an L4 mage four rooms in on its first run.
The clock is a new column. Every existing timestamp is unusable: last_active_at
is auto-bumped by saveAdvCharacter (the autopilot would refresh a bored
character's own idle clock), loadAdvDailyActivity counts the autopilot's own
expedition logs, and user_stats.updated_at is chat presence, not a game action.
last_player_action_at is written only by markPlayerAction, from a real player
action against Adventure — any interface, not just Matrix.
Raid zones (raidContentWarning: the party-tuned T5 bosses with a 0% solo clear)
are avoided while anything else is in band. At L13+ they're all that's left, and
the adventurer goes in anyway and loses. That's intended.
dnd_expedition.boredom + isBoredomDriven stop a run nobody asked for from
shielding an absent player from the idle reaper or holding their streak. A
manually-started expedition still holds it while the autopilot walks it.
Robbie visits and pays silently for idle players — he was going to file a daily
public bulletin about people who stopped playing weeks ago.
Note for anyone touching the time-scanning queries here: modernc.org/sqlite
rebuilds a time.Time from the column's declared type, and COALESCE()/MAX() erase
it. playerIsIdle fails open, so a broken scan there declares the whole server
idle. Both it and lastExpeditionByZone select declared columns and fold in Go,
and the tests seed real rows so the scan actually executes.
Three defects, all the same mistake, all found by sweep and not by tests: the
companion has no database row for a thing to persist onto, so the thing "arrives
fresh next time" — which for a resource means infinite.
1. His spell slots refilled every fight. The ledger went on his combat SEAT, and
a seat is per-session. A human rations one pool across a 30-room run and gets
it back at camp; rationing it IS the caster's game. Now on
expedition_party.companion_slots_used, refreshed at camp. (Worth ~0pp alone —
a run holds only ~2 real fights, so the pool never binds. I predicted this was
the whole answer. It was not.)
2. His BODY refilled every fight. buildFightSeats seated him at Stats.MaxHP and
the close-out skipped him — "he arrives fresh next time", said the comment.
That is an infinite body: he soaked a share of every fight's incoming and then
reset, while the humans beside him bled all the way to camp. THIS was the
carry. Now expedition_party.companion_hp; healed at camp; a dropped companion
returns on 1 HP rather than as a corpse, because there is no companion-death
rule and inventing one inside a bug fix would be a second feature.
3. No autopiloted caster had ever healed ITSELF. simPickAllyHeal skipped
`i == seat` and bailed on !IsParty(), so a solo cleric carried cure_wounds for
a whole run and never once cast it. Now simPickHeal: heal whoever is worst off,
which is sometimes you.
Measured, 640 runs/arm, like-for-like (the leaders whose role-fill gives Pete a
Cleric, against a human Cleric follower of the leader's own level):
solo 69.0%
+ a human cleric 77.6% (+8.6pp)
+ Pete 66.1% (-2.9pp)
The reference arm is the point. Against SOLO even a mace-only Pete looked like a
carry — but parties are designed to be safer, so solo is the wrong yardstick.
Against a human peer the real bug appeared: a gearless, level-penalized hireling
was out-clearing a fully-geared human cleric of the leader's own level by 15pp,
because he was the only combatant in the game who healed to full between fights.
With the free lunches gone he is honest, and honestly a net negative — which is
exactly the plan's §2 diagnosis, unmasked: a below-median seat cannot pay for its
own enemy scaling (+15% boss HP and 2.4 enemy actions a round instead of 1).
§2(a) is next, and the sweep now argues FOR it; before this commit it would have
made things worse.
Self-heal moved solo 66.1% -> 66.2%, so the balance corpus is undisturbed and no
re-baseline is owed. It is also NOT the answer to §6 — casters reach for a healing
consumable first and the sim stocks them, so a human rarely falls through to the
spell. Pete carries no consumables, so it is his only heal.
Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.
The party golden did not exist (§5)
Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
nothing. That is why a healer class that cannot heal shipped without a test
going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
N-body path can never quietly stop being a superset of the balance corpus.
Regenerate only on purpose: -update-party.
No action could target another seat (§1)
Every heal in the engine was self-scoped. A party cleric could not put one hit
point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
@user` and `--target @user` -- the latter has been advertised in !help and
silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
behave like competent ones. It will not raise the dead.
Corpses kept buffing the boss (§2b)
enemyActionsThisRound counted len(st.actors), dead included -- so a party that
lost a member kept paying for them, and the survivor faced a boss still swinging
at two-player cadence, alone. A death spiral with the arrow pointing the wrong
way. Now counts livingActors(). Party golden moved deliberately for this.
An engine-driven seat was a bool any command could clear (§3)
autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
a companion's own auto-played move arrived at beginCombatTurn looking like a
player returning to the keyboard and cleared the latch that was moving him. He
then stood in the fight doing nothing while the boss he had inflated killed
everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
command clears, and the driver calls driveEngineSeat instead of impersonating.
"The party" could be empty (§4)
A solo expedition has no expedition_party rows, so asking the roster who was in
the party answered "nobody" -- and every caller fell back to something plausible.
That is how the companion got hired at level 1 for exactly the player the feature
exists for. expeditionParty()/partyHumans() always include the owner.
The companion himself (!expedition hire [class] / !expedition dismiss)
Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
NPC seat and must never become a player: no player_meta, no dnd_character, no
inventory, no DM room -- mint him a player_meta row and
ensureDnDCharacterForCombat will auto-build the news bot a real character, and
he starts appearing in the graveyard and filing death notices about himself.
Mail and seats are different sets: he fights, he does not get written to.
Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.
The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.
Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.
Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
- emitZoneClearNews: claim realm-first before the name guard so an unnamed
straggler's first clear seeds news_realm_firsts (else the next named
clearer is mis-announced as first-ever). Mirrors backfillZoneFirsts.
- RunMaintenance: reap permanently-parked pete_emit_queue rows (unsent,
>30d) so a durable Pete outage can't accrete rows forever.
- emitDeathNews: gate on Enabled()/newsEmissionOn() before the char DB
reads; markAdventureDead fires per-member on party wipes + in the sim.
Closes the two deferred code-review follow-ups on the adventure-news
seam, plus folds in two pre-committed WIP fixes.
A. Privacy — the public GUID no longer leaks a stable per-player id.
Replaced userHash(userID)=sha256(userID)[:6] with
eventToken(userID, discriminator)=HMAC-SHA256(salt, userID‖disc).
The salt is 32 random bytes, auto-generated once and persisted in the
durable news_config table (cached via sync.Once). Because each event
uses a distinct HMAC message, tokens are a PRF output and are BOTH
uncomputable from a Matrix handle (no enumeration of a player's
events, incl. ones anonymized after !news optout) AND mutually
unlinkable (a named event can't be walked back to a player's other,
anonymized events). Updated all emit sites: pete.go zone, dnd_combat
death, adventure_duel rival, dnd_setup arrival, achievements
milestone, bootstrap x3.
B. Taxonomy — repeat zone clears were mislabeled zone_first. Now emit
zone_clear (bulletin) vs zone_first (realm-first, priority). Adopted
the invariant GUID-prefix == event_type, which also fixes latent
permalink mislabels (achv->milestone, rival->rival_result rendered a
neutral "Dispatch" on their permalink pages).
Folded-in WIP fixes: create the news_config table b42beec's
newsEmissionOn reads but never created; reap sent pete_emit_queue rows
in RunMaintenance; don't burn a retry attempt when shutdown cancels an
in-flight send.
Tests: TestEventToken (salted/stable/per-event/persisted),
TestEmitZoneClearTaxonomy (first->zone_first, repeat->zone_clear),
updated pete_test.go prefixes. Full internal suite + vet green.
Unshipped. Deploy Pete first (it must know zone_clear), then gogobee.
Overflow XP that grantDnDXP used to drop at L20 now accumulates as Renown
on player_meta.renown_xp (cumulative, atomic +=; renown_level derived as
renown_xp/25000). The reward is prestige-only: a derived rank ladder
(Renowned→…→Eternal), a cosmetic ✦N marker on the sheet and leaderboard,
a games-room shout on rank promotion, and !level progress once capped.
Renown perks are the two combat-neutral economy levers only — +loot / +XP,
capped at a streak-30 grant's economic half (+15% / +20%). combat_stats.go
reads DeathModifier/SuccessBonus/ExceptionalBonus (which map to Defense/
Attack/CritRate) but never LootQuality/XPMultiplier, so renown pays out even
through loadCombatBonuses without moving the golden or the balance corpus.
The plan's "-death penalty" perk is deliberately dropped (it would inflate
Defense).
The overflow→renown conversion and the character save commit in one
transaction (saveDnDCharacterExec now takes an executor), so a crash can
neither drop the overflow nor double-credit it on the next grant.
Schema: renown_xp column, DEFAULT 0 correct for every existing row, no
bootstrap (journal_pages/epilogue_cleared pattern).
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
`!duel @user [stake]` (+ `!adventure duel`; accept/decline/status). A staked
arena bout resolved through the auto-resolve combat engine.
The engine is asymmetric — N player-seats vs one monster-shaped enemy, no seat
for a second PC — so a duel builds each fighter as their real player Combatant
and synthesises the opponent as an enemy stat block from their sheet. That is
lopsided (player side = full kit, enemy = flat stat block), so the bout runs
BOTH orientations and decides on aggregate remaining-HP fraction, cancelling the
attacker-seat edge. To-hit stays faithful both ways (d20 + AttackBonus vs AC);
damage folds into one enemy Attack (per-hit × swings + companion-proc
expectation); DamageReduct ports over. Casters fight weapon-only in both
orientations — accepted, PvP class balance is out of scope per the plan.
Economics: both escrow the stake on accept (pool 2×stake); winner 70%, community
pot rakes 30% (a sink); exact-HP-tie draw refunds both. Stake capped at
level×€500. W/L reuses adventure_rival_records; shared 7-day pair cooldown.
Terminal fate is serialised by an atomic claim (DELETE … WHERE id=? gated on
RowsAffected==1, mirroring communityPotDebit): accept/decline/the expiry ticker
all claim first, only the winner moves euros — no stake can be both resolved and
refunded across the 24h boundary. issueDuel runs under advUserLock; accept
re-checks the challenger and builds both fighters before debiting the challenged.
Rides the 1-min eventTicker; combat golden byte-identical; suite green.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
The monthly communal "Siege": a named boss camps outside town for 72h with
a single shared HP pool. This lands the model and the automatic lifecycle;
the player-facing bout command is W2.
- New tables world_boss + world_boss_contrib (own tables, outside the
saveAdvCharacter fan-out, so a char save can't clobber the shared pool —
the isolation adventure_shadow earns). Absent active row == no event; no
bootstrap.
- Boss sized to the town it will fight: tier from the MEDIAN combined level
of any-chat-active players (feedback_presence_is_any_chat, off
daily_activity), pool HP = arena per-bout HP × ~2 bouts/active player,
clamped [4,60] bouts. Floored at T3.
- Lifecycle rides the 1-min eventTicker (no net-new goroutine): auto-spawn
on the 1st of each UTC month (JobCompleted dedup) + resolve a lapsed
window as a survival. Operator override + the daily bout are W2.
- Resolution: defeat mints a bounty scaled by fights fought (not damage —
accessibility) + a consumable cache + one low-rate treasure roll each;
survival debits 20% of the community pot as a tribute (a pot sink). Both
close-outs are guarded on status='active' so they fire once.
- Announcements post to the games room (no-op when GAMES_ROOM unset).
Pure logic (median, tier bucket, HP scaling, pool subtract/clamp, payout
split) is unit-tested; euro/DM emission left thin per the repo's no-client
-stub convention. Combat golden byte-identical (never touches SimulateCombat);
full plugin suite green.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
A per-player NPC rival who "runs" the same zone progression on a midnight
ticker at ~1.3x the player's own clear pace, staying just ahead so it's a
race you can always see and nearly catch. Pure theatre: no combat, no
punishment, only race pressure and two payoffs at each zone clear.
- New adventure_shadow table, deliberately OUTSIDE the player_meta save
fan-out so a character save can never clobber the ticker's advance (the
isolation journal_pages earns by being grant-only, made structural). No
bootstrap: absent row == no Shadow, minted lazily on first advance.
- midnightReset advances every player's Shadow once per UTC day (own
idempotency guard); lead-capped so it never runs >2.5 zones ahead. When it
clears a zone the player hasn't, it leaves a journal page waiting (D1 tie-in).
- Morning-briefing race-pressure one-liners (TwinBee voice, deterministic).
- Zone-clear payoff in finalizeExpeditionOnZoneClear: a bonus-XP crow when the
player got there first, or the Shadow's waiting page when it did.
- !adventure shadow status view.
Review fixes (3 finders + verify) folded in before commit:
- Crow XP is now set-once per zone (crowed_mask), so re-running a zone the
Shadow hasn't reached can't farm it.
- The waiting page is granted BEFORE the pending bit is retired, so a transient
grant failure leaves the debt for the next clear instead of swallowing a page.
- The crow line no longer claims "+XP" when the grant errored.
Combat golden byte-identical (Shadow never touches SimulateCombat); go
build/vet/test green repo-wide.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
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>