Commit Graph
542 Commits
Author SHA1 Message Date
prosolis 77dde5d133 dm: stop creating a duplicate DM room on every restart
GetDMRoom's lookup chain could never hit. The in-memory cache died with the
process, and the m.direct account data it fell back on was never written by
the bot -- room creation only ever populated the map. As an appservice user
there is no /sync to populate it either, so after each restart the first DM to
a user fell through both checks and created a fresh room.

Persist the mapping in a new dm_rooms table and read it before anything else.
Creation now also publishes m.direct, so clients label the room as a DM.

A stored room is checked for liveness before reuse, counting the user as
present while merely invited -- they often never accept, and treating that as
gone would recreate the room on every send, which is the original bug wearing
a different hat. The same check now guards the m.direct path, which previously
took the last entry on faith.

Two adoption paths keep existing rooms from being orphaned: user-initiated DM
invites are claimed on join, and a room the user is already talking in is
adopted on first message (negative-cached so group rooms cost one member
lookup, not one per message). For users predating the table, a one-shot sweep
of joined rooms runs only on the path that would otherwise create a duplicate.

The sweep cannot rank duplicates -- without /sync there are no timestamps, so
it takes the newest by room-list order. First message from the user corrects
any wrong pick.
2026-07-26 10:16:53 -07:00
prosolis 583616f9d0 llm: route every LLM caller through a shared backend client
Replaces the per-plugin Ollama HTTP calls with internal/llm, which picks a
backend from the environment (vLLM or Ollama) behind one Chat interface, plus
internal/plugin/llm_client.go as the plugin-facing wrapper.

Startup now logs llm_backend/llm_endpoint/llm_model instead of the two
OLLAMA_* vars, which no longer describe where inference actually goes.

These files were already running in prod from the vLLM migration but had never
been committed; this is that live state, byte-for-byte.
2026-07-26 10:16:33 -07:00
prosolis 3f9c338e67 adventure: give tier 6 a treasure rate, and stop it lying about near misses
advTreasureDropRates stopped at tier 5, so every Mythic-zone roll returned
nil before it reached a pool: postgame zones have dropped no treasure at
all since T6 shipped. Add the rate, deliberately above T5's rather than
continuing the downward curve — a Mythic run is gated behind L18 and both
T5 bosses, so the same declining rate would mean a postgame player never
sees one.

The rate alone drops nothing: there is no advAllTreasures[6] yet, and
writing those four items is content work, not a code fix. TODO in place
where the pool goes, and a test that goes red the day it lands.

An empty pool now reports a zero rate instead of the configured one. The
caller reads a close roll as "treasure: just missed" — with a rate but no
pool that DM fires on the rolls the player actually won, for a treasure
that cannot be won. Better to say nothing until there is something to win.
2026-07-25 10:33:59 -07:00
prosolis afa49b2d4c adventure: name the zone the treasure actually came from
Every Tier 5 treasure line said the Abyssal Maw, whichever zone the drop
came from. A find in Dragon's Lair announced itself as the Maw while
Pete's dispatch, which reads the real location off the fact, named the
Lair. Two tellings of one moment that disagreed about where it happened.

Substitute the location instead, in the room announce and the discovery
DM both. {location_mid} lowercases the leading article so a line reads
"found in the Abyssal Maw" after a preposition; {location} keeps the
display form for sentence-initial and "The {location}" positions, which
the T1-T4 pools already rely on.
2026-07-25 10:21:32 -07:00
prosolis c8878b095c flavor: rewrite rhythm-only lines, fix B2 voice-pass grammar scars, add lines
- Rewrite the LootDropCommon offenders and ~20 other lines that had the
  cadence but no concrete detail (per VOICE_CANON craft rules)
- Fix 7 grammar scars from the third-person-to-first-person B2 pass
  ("...and am not worth picking up" where the subject wasn't TwinBee)
- Replace generic "every RPG" references with specific ones
- Add new lines across ambient/GM/housing/NPC pools, incl. funny-word
  register (kerfuffle, skedaddle, lollygagging) played straight
- New Thom Krooke line follows the pet-first arc
2026-07-25 09:52:36 -07:00
prosolis 96f2125944 Merge party-known-flag: tell Pete this build knows what a party seat is 2026-07-24 23:16:25 -07:00
prosolis 10d1e4adf5 adventure: tell Pete this build knows what a party seat is
The party list is omitempty, so a solo run and a game box too old to push
seats reach Pete as the same empty slice, and those two want opposite
buttons. Pete's page was reading the empty one as "solo, therefore leader"
and offering a party member the button that throws away everyone's day.

One boolean, set on every sheet a build makes — in town, solo, or seated
with three others. It says something about the sender, never about the
character, so it is set unconditionally: a conditional flag would read as
"this player is solo" and land straight back in the hole it closes. An
older build sends no key at all, which decodes to false, which is the
answer Pete wants when it cannot tell.

Pete's half shipped already and withholds the button until this arrives.

Verified end to end against a local Pete, not just in tests: a solo leader
on a live expedition gets "Call the whole thing off" back, a member still
gets "Turn back alone" and never the abandon, and stripping the flag from
the same push takes the solo button away again.
2026-07-24 23:03:58 -07:00
prosolis ba41dc2d1b Merge w9-verbs-and-polish: headless verb twins, realm-firsts repair, review fixes
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 22:08:00 -07:00
prosolis 34519c9145 adventure: give the summary long enough to wake the model up
The run summary had sixty seconds, which is a generation budget, and
this call almost never gets to just generate. Runs end hours apart,
Ollama drops an idle model after about five minutes, so the steady state
is weights on disk and a cold load before the first token. The old
budget expired during that load every time and filed the empty beat that
means "no summary, ever" — there is no retry, the beat itself is the row
that stops the sweep re-picking the run. Five minutes now, sized for
load-then-generate, so a timeout means what the comment always claimed
it meant: the box is down.

That can't sit on a two-minute ticker, so it doesn't. The sweep starts
beside the ticker behind a single-flight flag; ticks that land during a
load skip instead of queueing. Nothing reorders — the summary is written
to the local buffer with the next seq and still ships behind the run's
own log.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 22:07:47 -07:00
prosolis 509df7fadf adventure: stop the log claiming a win it didn't see
Four things the review found, all of them the code telling a player
something that isn't true.

A run that walks into a dead end filed its ending as "cleared" whatever
the caller said, so both the liveblog and the summary reported a clear
for a party that merely ran out of map — and the end beat is
first-writer-wins, so nothing later could take it back. It says
"cleared" only for a boss now.

The re-offer branches of babysit and resume returned a zero cost, so a
player who was in fact charged read "0 coins" in the verdict. Both
re-quote the price they actually took.

And the realm-firsts reseed retired its one-shot job even when the read
under it had failed, which on a transient fault at Init would have left
the ledger mis-dated permanently. The read now says whether it worked,
and the job stays open when it didn't.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 22:07:38 -07:00
prosolis c327cfc5f1 adventure: give the web the three doors it kept pointing at
The web's own refusals named commands it had no button for: "!expedition
abandon first", "!expedition leave to walk out alone", "no refund if you
cancel early". Three verbs, no arguments, no money.

Each Matrix handler becomes framing over a headless twin the order path
shares, so the web runs the game's real path: an abandon still disbands
the roster, still retires the region runs, still DMs the party, still
rolls the emergence pet.

The lock asymmetry is per verb and is the thing to be careful about.
performExpeditionAbandon and performExpeditionLeave cannot take
advUserLock — their Matrix caller holds it across the whole !expedition
switch — so their web wrappers do. performBabysitCancel takes it itself,
because its caller does not, so its wrapper must not. Both directions are
pinned by tests that hang rather than fail on regression.

Also flips the two W5a extract tests off setupZoneRunTestDB: it copies
data/gogobee.db and skips when it is missing, so they have been
green-by-skipping since they were written.
2026-07-24 21:42:40 -07:00
prosolis a27fb298af adventure: repair the realm-firsts ledger the backfill got wrong
The one-shot that seeded news_realm_firsts filtered on `abandoned = 0`, and
`abandoned` does not mean anybody gave up: abandonZoneRunByID exists to retire a
run whose boss is already dead when the expedition travels onward, which is how
30 of prod's 32 boss kills are stored. So the ledger holds 6 zones where the run
history knows 9, and it dates every row to the minute the job ran rather than to
the clear.

The ledger is what claimRealmFirst tiers live dispatches against, so each missed
zone is a spurious PRIORITY "realm first" waiting to fire the next time somebody
clears it, months late.

Fix the shared query, and add a re-seed (not a re-run) under its own job name: it
writes the ledger, backdated to the real earliest clear, and emits nothing at
all. It runs regardless of the news switches, because a ledger that is right only
while emission is on mis-tiers the first dispatch after somebody flips it. Kept
in place afterwards as an ordinary bootstrap for fresh deploys.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 20:35:47 -07:00
prosolis e5d4bd6dc5 adventure: tell Pete who else is on the expedition
Party seats ride the roster detail, beside the supply and threat numbers the
same expedition already publishes. Leader first, the hireling named without a
token he has no board row for, and an opted-out player's seat kept and
anonymised — dropping it would make a party of three read as a pair while the
burn rate and enemy scaling beside it still felt three bodies.

The board itself was wrong about this: it resolved an expedition with a lookup
keyed on dnd_expedition.user_id, which is blind to members, so for the whole
life of N3 parties a seated player has shown as idle in town while standing in
a dungeon.

Pets also send the engine's XP threshold for their current level band, so the
web can draw progress without keeping a copy of a curve that would drift.
2026-07-24 20:26:28 -07:00
prosolis f73ab56ac8 adventure: run the web's three new verbs through the game's own paths
The game-side half of setting out, going back in, and hiring the sitter from
the web. Each is the existing command minus its framing: performExpeditionStart,
performResume and performBabysitPurchase now hold the guards and the money, and
!expedition start, !resume and !adventure babysit are what is left over. So a
departure booked from a phone is the same departure - same eligibility chain,
same supply freebies, same opening log line - rather than a second one that
drifts.

Refusals travel as advRefusal, which wraps a sentinel AND carries the finished
sentence. That is what lets the commands keep the exact copy they always sent
while the web gets a machine-readable verdict.

All three spend coins on a retrying wire, so the debit is keyed to the order
guid and a re-offer cannot charge twice. The subtle half is what a re-offer
should ANSWER: a settled debit plus an already-started expedition means the
order worked and lost its ack, not that the player is busy, so it reports
applied instead of refusing the thing it did. Nothing refunds-then-retries -
after a refund the keyed debit will not charge again, so a retry would hand over
the goods for free, and every failure past the debit is therefore permanent.

Also fixes a deadlock that predates all of this: !expedition extract and
!expedition resume are aliases for two commands that take the per-user lock
themselves, and the alias dispatcher already held it. Since it is a plain
sync.Mutex the handler blocked forever and, because the deferred unlock never
ran, every later adventure command from that player wedged too. It does not
fail loudly on regression - it hangs - so the new test asserts with a timeout.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 19:47:45 -07:00
prosolis fff2aa79d0 adventure: drain the actions a player asked for from the web
Pete now records two verbs an owner can ask for: pull out of a run, and
take today's bout against the Siege. This is the game-side loop that
answers them, modelled on the equip poller down to the applied-order
ledger, because neither action is idempotent: replaying an extraction
would end a run they had resumed, and replaying a bout would spend a day
they never got back.

The web verbs run the game's own paths, not lookalikes. !extract and
!adventure worldboss fight are now thin command framing over
performExtraction and takeSiegeBout, which both take the per-user lock,
so a click and a command race each other exactly as two commands do. A
web extraction still DMs the whole party, still writes the log line, and
still resumes for seven days.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 18:55:52 -07:00
prosolis d6136d39d9 adventure: tell Pete what the realm is, not just what happened in it
Adds the realm snapshot behind Pete's map, board and hall of firsts: every
zone with its clear history and who is standing in it, the ledger of things
that have happened exactly once, and one line per adventurer.

Snapshot semantics like the roster and the Siege — pushed whole, replaces
Pete's copy, dropped rather than retried. It rides the roster ticker but at a
ten-minute stride: these are aggregate scans over the whole run history, and a
first clear does not move.

Two things the real data corrected, neither of which any unit test would have
caught:

`abandoned` does not mean the player gave up. It means the run row was
retired, and abandonZoneRunByID exists precisely to retire a run whose boss is
already dead when the expedition travels on. 30 of prod's 32 boss kills carry
abandoned = 1, so filtering on `abandoned = 0` — as this did, copying the
shipped backfill — drew a realm in which almost nothing had ever been beaten.
boss_defeated = 1 is the clear.

And news_realm_firsts is not a usable history book on its own. It only covers
what happened after the news seam went live, its one-shot seeder carried that
same wrong filter, and its first_at is when the claim was written down, not
when the thing happened — every backfilled row in prod shares one timestamp.
So the zone half of the hall is derived from the run history instead, which is
complete and correctly dated, and the ledger supplies the kinds the run
history knows nothing about. That also makes the hall agree with the board by
construction rather than by coincidence.

Opt-out follows the rule that fits each surface: a first-clearer is anonymised
(deleting the claim would redraw a conquered zone as unbeaten), a player is
dropped from the board outright, and presence is dropped entirely — who is in
a dungeon right now is the live-location fact the liveblog already refuses.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 17:55:05 -07:00
prosolis 7a5c8341f0 adventure: read a finished run back and tell Pete what it was about
Two halves of the same gap. A dispatch announcing a clear, a retreat or a death
said how an expedition ended and gave a reader no way to reach what happened in
it — so those three facts now carry the run they concluded, and Pete's permalink
can open the log behind the headline.

Finding that run is done by asking what is actually true at the moment a
dispatch is filed rather than by threading a run id through five call sites that
have already let go of it. Both guards on that lookup are load-bearing: a run
with no beats behind it would mint a link to a 404, and without the recency
window a campaign death at the Empty Throne would attach itself to whatever
dungeon that player last walked.

The other half is the summary. Every line of the liveblog is assembled by Pete
out of a beat's own nouns, which is right for a log — it has to be exactly what
happened, in order. But a report is read afterwards and the question it answers
is not "what happened", it is "what was that run", and that is a judgement. So
this is the one piece of prose on the channel, and it earns the model far better
than a dispatch headline does.

It runs on the roster ticker, not at the moment the run ends. A run ending is
already a chokepoint with a dispatch being authored against it, and a second
generation there would stall the command that killed the boss. One per tick, and
a run that can't be summarised is closed out with an empty beat rather than
retried forever — the row is what stops the sweep picking it up again, and a
report with no summary is still the log and the numbers.

The prompt states the one permitted name twice and forbids arithmetic: a model
asked to write warmly about a party will invent a second member of it, and a
total it works out itself will contradict the exact totals printed beside it.
Both were seen against the real box before this was tightened.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 17:11:21 -07:00
prosolis a5b1961486 adventure: send Pete the run, room by room
The engine narrates every fight, trap and haul to one Matrix DM and then
discards the shape underneath it. This records that shape as it happens so
Pete can retell the run to somebody who wasn't in the room.

Beats ride the roster ticker (one extra request per two minutes, not one per
room) but on their own table, never pete_emit_queue: they are high-volume and
low-stakes, and a chatty run must not be able to spend the retry budget a
death dispatch depends on. Unlike the snapshots they ARE retried — a dropped
beat is a hole in a story, not a stale number the next tick corrects.

Recording is a leaf everywhere it is called. If a beat can't be written the
walk carries on exactly as it did before this existed.

Privacy is stricter here than on the board. The board omits an opted-out
player from a snapshot; a liveblog would be an account of where they are and
what is happening to them, so their beats never leave the box at all — and a
run whose owner can't be resolved is refused rather than published.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 16:34:02 -07:00
prosolis 0570afc2e4 worldboss: push the Siege to Pete and file its three dispatches
The Siege had no web presence at all, and three dispatch templates
(siege_start / siege_win / siege_loss) have been sitting written and
unemitted in Pete's renderAdventure since the adventure section shipped.
Both halves fixed here.

The snapshot rides the existing roster ticker with the roster's rules:
pushed whole, replacing Pete's copy, dropped rather than retried on
failure. Retrying would be a lie about how much HP is left, and the next
tick carries the truth anyway.

The muster carries EVERY alive adventurer, not just contributors. The
zero-fight rows are the point: one bout per person per day means somebody
who hasn't swung today is a hit the town hasn't taken, and Pete can only
draw that column if the people in it are on the wire.

Opt-out departs from the board's rule on purpose. The board omits an
opted-out player outright — class + level + zone re-identifies them. A
contributor here is anonymised instead: the damage they did is on the
boss and is part of what the town accomplished, so deleting it would
understate the shared effort and stop the totals adding up. They keep
their damage and their rank, and carry no board token, so nothing links
back to a page that names them. An opted-out player who never fought is
still dropped — nothing to account for.

The three dispatches key their GUID on the boss row id, so a resolution
path re-entered (a redeploy mid-window, the ticker's safety net firing
after an inline kill) files the same guid and Pete dedupes it, rather
than announcing one Siege to the room twice.

Also retires the stale "deploy Pete first, an unknown event_type is a
400" note on emitBoredomDeparture. Pete now publishes an untemplated
type on a neutral fallback and counts it for the operator, so the
ordering is a property of the system rather than a rule to remember.
2026-07-24 15:21:36 -07:00
prosolis 2ce3e682ea zones: stop the tools/backtrack path losing rooms and inventory
Review fallout from the locked-doors commit. Five defects, all in the
seams that commit opened:

backtrackFromDeadFork stepped to VisitedNodes[idx-1]. That slice is a
first-entry ordered *set*, not a path stack (see appendVisited), so once
a run has doubled back once the entry before CurrentNode can sit on a
different branch entirely — the party teleports across the map to a room
no edge connects. `!revisit` refuses exactly that move via
adjacentNodes; the autopilot has no business doing what the player is
forbidden from doing. Now routed through backtrackTarget, which also
refuses to fall back into a corridor whose only exit is the sealed fork:
the lock rolls are seeded per (run, edge), so walking back in gets the
same answer every time, forever.

The autopilot spent the player's thieves' tools *before* committing the
move, so an advanceZoneRunNode failure left them charged and then had
the caller's backtrack clear the fork they had just paid to open. The
tools are now reported by autoPickWithTools and only removed once the
party is actually through the door.

`sell all` never learned the new "tool" type and turned a €600 set into
€300 of loot — the same silent deletion Robbie was taught to avoid two
commits ago. It was already doing this to "key" quest tokens, so both
now sit with the special gear.

The shop's tools branch asked "is the reply a substring of Thieves'
Tools", which is true for a bare "s", for "to", and for an empty message
body — and it runs ahead of the consumable list, so a stray keystroke in
the Supplies view bought a set. isThievesToolsReply matches on the
item's own words instead.

Plus two cleanups in the same code: Robbie built his haul gifts by
calling consumableCache(tier, 1) in a loop when it already takes a
count, and the unlock flow read the whole inventory three times per
command.
2026-07-21 00:20:24 -07:00
prosolis 6bcac41aa2 zones: make locked doors openable and stop autopilot stalling on them
Locks were fully implemented as pass/fail gates and nothing else. A
Perception or stat check rolls once per (run, edge), seeded so it can't
be reload-scummed — that half shipped in G5, the counterweight never did.
A bad roll simply deleted a branch of the graph for the rest of the run,
worst for a solo low-WIS character who quietly loses routes they never
learn existed. Three changes, one theme: a die roll should not be able to
permanently wall a player.

Party's best stat answers the check. evaluateEdgeLock read only the
acting character's mods, which made a party's rogue and its hired scout
decorative at every lock. Fold the whole roster — Pete included, since
excluding him would make hiring a scout worth less than the coins it
costs — and credit whoever got it open in the fork menu.

Thieves' tools as the escape hatch. A utility item (not a ConsumableDef,
or the fight engine would spend them for you) sold on Luigi's supplies
shelf, consumed by `!zone unlock <n>`. Deliberately not a skeleton key:
tools answer the two dice-driven locks only. A key lock is a quest token,
a level-min lock is progression, a region-clear lock is structure — none
of those are "you rolled badly", so none of them are pickable.

Autopilot picks a route instead of parking. The fork timeout was 8h,
which reads as "the player gets first say" and behaves as "the expedition
stops for a third of a day, at every fork" — a multi-day expedition
crosses a lot of forks. 30m keeps a genuine first say for anyone at the
keyboard. It now ranks by unvisited-then-edge-weight rather than taking
whatever the graph author happened to list first, spends tools when every
route is locked, and backtracks a room when it can't do even that, rather
than idling into the 24h reaper and losing the player days of progress to
a roll they never saw.

Sim A/B, same seeds, 90 runs/arm: 43.3% -> 42.2% clear, a single run
flipping and well inside the documented noise floor. A party+companion+pet
arm runs 31/32 clean through the new roster-folding path.
2026-07-20 23:52:20 -07:00
prosolis 71b97763ce robbie: pay for the haul, not just for showing up
The gift was one consumable every 10th visit, flat. A visit is a 40%
daily roll, so that works out to one item per ~25 real days — and it paid
exactly the same for a stockpile of sixty items as it did for one rock.
The player controls volume, not visit count, so volume is what the new
track pays on: one consumable per 15 items carried off, capped at 3,
stacking with the existing loyalty gift.

Also stop Robbie stealing thieves' tools. He skips keys already, for
exactly this reason — a key is bought to open something later, and a
bandit who pockets it between the purchase and the door has taken the
thing the player paid to still have. Tools are the same shape of promise.
2026-07-20 23:52:03 -07:00
prosolis 690ff758fe worldboss: spawn the monthly Siege without the day-1 deadlock
The Siege has never once spawned in prod. `select count(*) from
world_boss` is 0 and daily_prefetch has no worldboss_spawn row at all.

worldBossTick only auto-spawned when now.Day() == 1. The world boss
landed on main 2026-07-10..13 and the first deploy carrying it was after
July 1, so prod has never run a first-of-the-month tick with the code in
it. The feature has been live and unreachable for weeks, and the next
natural spawn would have been August 1.

The same gate also silently skipped any month where the bot happened to
be down or redeploying across the 1st, with no catch-up — one missed
minute costs the town a month.

The month key is already the whole dedup, so drop the day check and let
the rule be what it always read as: one Siege per calendar month, as
early as the process is up to run it. A missed 1st now self-heals on the
next tick.
2026-07-20 23:52:03 -07:00
prosolis ca2d1a8ea3 pets: earn XP from combat wins again
petGrantXP has been dead code since R1 deleted the legacy daily activity
loop it used to ride. Nothing replaced the call, so for the whole life of
Adventure 2.0 the only pet XP in the game came from a paid babysitter.
Prod bears it out: the one player who never subscribed has a pet sitting
at level 1 with 0 XP after months of play.

That is not cosmetic. DerivePlayerStats scales PetAttackProc,
PetDeflectProc and PetAttackDmg off pet level, so a frozen pet is a
permanently dead combat slot that the player has no way to revive.

Wire it into postCombatBookkeeping — the one seam all four combat
close-outs already meet, so a pet cannot level differently depending on
whether the fight auto-resolved or was played a round at a time. Both
slots earn on the same win, matching the babysit trickle: combat only
reads the two pets' averaged procs, so leveling both is not a spike.

Writes go through the narrow per-slot pet upserts rather than
saveAdvCharacter, because this runs on a path that does not hold the
per-user lock and a full-row write could clobber a concurrent save.

Verified against the sim: a level-3 pet finishes one L10 expedition at
level 4 with carryover, where before it finished exactly where it started.
2026-07-20 23:51:49 -07:00
prosolis 5a8d21f780 adventure: fix concurrency + dup hazards in web equip path
Code review of the Ask-7 web equipment-management path surfaced three
correctness issues, all fixed here:

- applyEquipOrder ran the poll-goroutine equip mutations without the
  per-user advUserLock that every Matrix-side mutation (!give, !equip,
  arena, …) holds, so the lock gave no mutual exclusion against the web
  path. A concurrent !give of the item being equipped could duplicate it.
  Now takes advUserLock(owner) for the whole apply, matching the DM path.

- applyMasterworkEquip evicted the displaced occupant to the pack BEFORE
  the destructive slot write, so a fault left the piece both worn and in
  the pack — and the 30s equip poll retry re-evicted it every tick. Now
  removes the incoming row, writes the slot, then re-packs the occupant
  last as a best-effort step: once the slot no longer references it, the
  re-pack cannot duplicate, and a failure is logged not aborted on (the
  DM confirm handler's tolerance).

- PlayerDetail.Balance dropped omitempty: a real €0 balance is an
  informative fact, not an absent one, and omitting it left the web
  confirm dialog with no balance to show.
2026-07-17 21:04:38 -07:00
prosolis 68c8cdff2d adventure: ask 7 — apply web equipment management (poll half)
Mirror of Pete's ask 7. gogobee polls the equip queue and applies the new
actions against the five standard gear slots:
  - equip: routes MasterworkGear/ArenaGear to applyMasterworkEquip (evicts
    any special occupant back to the pack; downgrade-blocked), else the
    existing applyMagicEquip.
  - unequip: EquipmentSlot vocabulary -> applyMasterworkUnequip (resets the
    slot to its tier-0 default, keeps the row), else applyMagicUnequip.
  - upgrade: purchaseEquipmentTier, euro-idempotent (DebitIdem keyed on the
    order GUID), downgrade + max-tier guarded.
  - repair: repair(), euro-idempotent, recomputes blacksmithRepairCost.

Detail push now carries Slots (EquipSlotView x5) + Balance; itemViews gives
masterwork/arena backpack rows an equip id; the compare decorator is guarded
to magic-only. buildDetailSnapshot is a method so it can read the euro balance.

Retry-safety: no CreditIdem refund on a later save fault (would double-pay a
guid-guarded retry) — we return retry=true and let the next poll re-run, since
the debit is guid-idempotent and the slot write is idempotent. Matches the
casino escrow precedent. Unit tests cover downgrade block, max-tier,
insufficient funds, idempotent replay, eviction, and take-off reset.

Deploy AFTER Pete: Pete's ingest must accept the new verdict strings before
this side emits them.
2026-07-17 20:34:56 -07:00
prosolis b29dcf4360 combat: relabel DamageReduct comments to match calcDamage player-defender direction
Review follow-up on the caster-floor rebaseline: the survival-half doc in
casterBlasterFloor described an HP add the code never makes (Defense only),
and the pre-existing Druid *0.95 rider still read as a damage cut when, for a
player-defender through calcDamage, DamageReduct<1 raises damage taken. Comment
-only; no runtime change, guardrail test unaffected.
2026-07-17 17:22:06 -07:00
prosolis 1f62a8e842 combat: revive the caster sustained-cantrip floor in the turn engine
The arcane-blaster "sustained floor" passives (CantripPerRound, DamageBonus,
FlatDmgStart from casterBlasterFloor) were built for the swing-based engine
(SimulateCombat, combat_engine.go:590). But every live expedition auto-resolves
through the turn engine (autoDriveCombat -> session -> combat_turn_engine),
where casters autocast every turn and never weapon-swing -- so CantripPerRound
never fired and DamageBonus was inert. Casters fought at bare cantrip dice
(~4d10~=22 at L20) instead of their intended floor, in sim AND in prod. This is
why every caster damage dial read as a dead lever across the whole rebaseline.

Fix (combat_cmd.go): bridge the already-computed CantripPerRound into the
turn-engine damage-cantrip cast, hit-gated (only lift a cast that already
connected, so the ~35% miss variance survives and the floor isn't a guaranteed
flat hammer). Self-targeting: only Mage/Sorcerer/Warlock carry a nonzero
CantripPerRound -- martials swing (untouched), cleric/bard/druid have floor 0.

Tuning (dnd_passives.go): casterCantripBase 9 -> 3, now a live, class-specific
lever. Mage/Sorcerer take base 3; Warlock passes 0 (its bare-dice cantrip plus
a structural edge already lands it mid-band, so an added floor overshoots).
Removed the dead casterHPPerLevel rider (it inflated the truncation-fraction
denominator without adding startable HP -- a bug).

Also lands the deterministic-seeding infra (sim_seed.go + simIntN/simFloat64
threading) used to read these deltas out of the process-seed noise; prod is
byte-identical (unseeded -> package rand).

Confirmation (expedition-sim, L20 T5 dragons_lair+abyss_portal, n=250):
casters now in the 35-45 floor -- sorcerer 39, mage 38, warlock 36; martial
leaders undisturbed (rogue 68, druid 66, ranger 65, fighter 64, ... paladin 55).
2026-07-17 16:17:41 -07:00
prosolis 6e2782ac48 adventure: honor death-cheats on a cantrip kill and narrate the cantrip
A caster's at-will cantrip that lands the killing blow returned via a raw
enemyHP<=0 read, skipping enemyDown -- so a survive_at_1 boss or T6 Valdris's
phylactery rebirth died instead of cheating death. Route it through enemyDown,
matching resolvePlayerAttack and the concentration-tick path.

Also add the missing renderEvent "cantrip" case: the CantripDesc narration
hook was set but never rendered, so cantrip damage dropped enemy HP with no
log line.
2026-07-17 10:51:33 -07:00
prosolis 7e59697754 adventure: tell Pete whether a backpack item beats what's worn
For each backpack magic item, compute the per-stat diff against whatever
occupies the slot it would equip into (mi.Slot — the same slot the web
Equip button targets, so the card describes the trade that actually
happens). The verdict is strict dominance: all-gain is an upgrade, all-loss
a downgrade, mixed a sidegrade with no winner claimed (the case the two
opaque effect strings could never show). Empty slot reads 'new'; an
attunement item with no free bond reads 'inert', which overrides the stat
verdict because wearing it does nothing.

The diff is over tempered effects on both sides and reuses the engine's
own magicItemEffectFor, so nothing here re-derives power math that could
drift from the game. Rides an additive Compare object on the private
backpack ItemView — no migration, no endpoint, no public surface.

Rings collapse to the same path: every ring equips to ring_1 (ring_2 is
declared but never assigned by any live code), so a backpack ring simply
compares against the ring_1 occupant.
2026-07-17 10:04:18 -07:00
prosolis 22b7949791 adventure: author Pete's dispatches in his voice with the local LLM
emitFact now runs the final fact through authorDispatch, which asks the local
Ollama model for a warm-reporter headline and lede and ships them on the Fact.
Pete guards and publishes them, falling back to its own templates on anything
it rejects — so authoring is best-effort by design: LLM off, a timeout, a
malformed generation, or an over-length pair all return an empty prose pair and
Pete templates the fact. The names allowed in the prose are the fact's Actors,
built from the post-opt-out subject/opponent, so what the model may say and what
Pete's guard permits are the same list. Synchronous like the holdem tip rewrite,
but on a tight 15s budget: news facts are infrequent and a template now beats a
voiced dispatch late. With no route for Pete to call back into this box, the
voice lives in the prompt here rather than in a Pete-owned inference endpoint.
2026-07-17 09:28:13 -07:00
prosolis fbed45fc96 adventure: tell Pete when a story-grade treasure is found
A treasure that earns a public room announce now also files a treasure_found
fact, so Pete can count it on the finder's trophy case. The emit rides inside
announceTreasureToRoom, reusing the RoomAnnounce != "" gate as the newsworthiness
filter — a copper-piece pickup never becomes news, and a reversed auto-swap never
emits, because the announce it shares is cancelled on undo.

The realm's first finder of a given treasure is a priority hoard; a later finder
of the same item is a bulletin, keyed on the treasure across the realm via
claimRealmFirst, the same first/repeat split zone_first uses. The item name rides
in stakes and the tier-derived rarity in outcome.

treasure_found is a new event_type, so Pete's ingest must deploy first or the
first finds park on the retry ladder forever.
2026-07-17 09:09:23 -07:00
prosolis 7960838b3f adventure: drain Pete's equip queue and apply it for real
Poll Pete for equip/unequip orders an owner placed on the web, run them through
the real magic-item equip path so bond caps and slot eviction still hold, and
file a verdict. Same reverse pipe as mischief, with one difference that matters:
the equip action is not idempotent, so a re-offered order after a lost ack would
double-move the item. An equip_applied_orders ledger keyed on the order guid is
the guard: applied once, re-offers only re-file the stored verdict.

The delicate remove-before-equip ordering that prevents item duplication is now
one shared applyMagicEquip/applyMagicUnequip core, called by both the DM
resolver and this poller, so the anti-dup ordering can't drift between two
copies. Items now carry their inventory row id to Pete as the equip handle.
2026-07-17 08:44:44 -07:00
prosolis 32520eb7ec adventure: send Pete the fog-of-war zone map beside the room count 2026-07-17 08:18:11 -07:00
prosolis b6d4e4ccec adventure: send Pete what an item actually is, and what's worn
The self-view listed a name, a tier and a price — everything except what a
player decides on. The facts were all there, just not on the wire.

Three things the contract spec got wrong, found by reading both sides:

Equipping *moves* the row out of adventure_inventory into
magic_item_equipped, so the two sets are disjoint. The spec's `attuned` on a
backpack item can never be true — bond state isn't false there, it's
undefined. The real gap was that worn items weren't sent at all: the panel
showed the backpack and hid the sword. Hence Equipped, where Attuned means
something and an inert item can be seen.

Stat modifiers ARE modeled. The spec said they weren't, and that shipping
them meant either an engine change or a display-only approximation that lies
the first time it disagrees with the engine. But magicItemEffectSummary is
the engine's own summary — the same function the game speaks with. Sending it
can't drift, because there's nothing to drift from.

SkillSource is two different things: "mining" on masterwork gear, and the
internal "magic_item:<id>" registry pointer on magic-item rows. Sending it
raw would put gogobee's IDs on a page, and Pete couldn't tell them apart to
filter them. Only the skill name goes out.

Desc and Effect resolve at the push site because an inventory row carries
neither — descriptions live on MagicItem/EquipmentDef, and the combat delta
is computed, never stored. Shop gear resolves by (slot, tier); Name is
decorative there.

All additive and omitempty on the private /api/ingest/detail push, so neither
side has to deploy first.
2026-07-17 06:44:45 -07:00
prosolis 479f77b9c5 adventure: T6 Inversion Stitch (Seamstress) + boss re-strengthen (P8)
Second in-combat Layer-2 mechanic, reusing Amendment's round-end seam. In the
Seamstress's phase 2 (<=35% HP) the room sews inside-out in a repeating
warn(1)->sting(2) cadence, telegraphed one round ahead: during a pulse player
heals (self + ally) invert to damage, floored at 1 HP. State
inversionActive/inversionTelegraph round-trips through CombatStatuses so a
suspend/resume can't drop or double a pulse.

Sim surfaced that unplace is reach-bound (only ~37% reach the boss, then cleared
her ~98%), so the mechanic alone is sub-noise. Re-strengthened the Seamstress,
the weakest T6 boss (over-softened by P7 for a zone lift that never came):
HP 385->460, Atk 39->45, Needle Rain proc 0.40->0.45. Sim-validated at
fighter zone 37.5% (in range), and a real boss fight now instead of a victory lap.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 12:14:41 -07:00
prosolis 189a44e1eb adventure: T6 Amendment (Custodian) + in-combat round-end boss seam (P8)
First in-combat Layer-2 mechanic. applyBossInCombatRoundEnd is a new
round-boundary seam called from the turn engine's stepRoundEnd, the
counterpart to the pre-combat applyBossRunModifiers. The Custodian snapshots
its HP at end of round 3 and rewinds to it once on the phase-2 crossing
(refunding front-loaded burst); a soft midnight timer past round 20 climbs
its Attack via the existing enemyAtkBuff. New state (EnemyRewindHP/Used)
round-trips through CombatStatuses so a suspend/resume can't replay the
rewind. Sim A/B (n=120 L20 party): last_meridian fighter 65->37.5% clear,
a clean -27.5pp swing attributable to the mechanic; shipped as-is per the
opt-in-endgame difficulty call.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 09:20:29 -07:00
prosolis db13ed75b9 adventure: route round-end concentration kill through enemyDown (P8)
The T6 Valdris phylactery rebirth (and the pre-existing survive_at_1
one-shot) live in enemyDown, on the premise that it is the single death
chokepoint. It isn't: the round-end concentration pulse ended the fight
on a raw enemyHP<=0 read, so a cleric's Spirit Guardians landing the
lethal blow robbed a revive-armed boss of its rebirth — exactly the
cleric-party arm P8 is tuned around. Route that win-check through
enemyDown so the boss gets its chance to stand back up. Regression test
covers both the armed (revives) and charge-less (still wins) paths.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 08:41:58 -07:00
prosolis 686434f8e3 adventure: T6 Phylactery Verses + stackable-rebirth engine primitive (P8)
The second Layer-2 postgame boss mechanic, and the first stateful in-combat one.
Valdris ("boss_valdris_ascendant", the Ossuary Ascendant) is bound to three
Verses hidden on the zone's secret nodes: every Verse the player finds and walks
before the fight unbinds one rebirth, every Verse they skip leaves it armed. A
full-clear explorer strips all three and fights a mortal lich; a speedrunner who
blows past the secrets fights a god who will not stay down.

New engine primitive: stackable rebirth. The stock survive_at_1 is a one-shot
1-HP stay; Valdris needs several rebirths that each restore a real pool.
combatState/CombatStatuses gain EnemyReviveCharges/EnemyReviveHP (round-tripped
through the turn engine so a suspend/resume keeps the live count), and enemyDown
consumes a charge after the survive_at_1 check, reviving to 25% of the
party-scaled max and emitting a phylactery_rebirth event. Zero for every
non-Valdris fight.

Unlike Greed Tax (a pure per-round recompute in applyBossRunModifiers), rebirths
are spent mid-fight, so the charge count is seeded ONCE at session creation
(seedBossRunStatuses, from unvisited secret Verses) and never re-derived on the
per-round enemy rebuild. Seeded from handleFightCmd after startPartyCombatSession.

Sim A/B (millenia, n=120 L20 party+Pete+pets, same binary, control neuters the
dispatch): mortal end (verses found) fighter 42.5% -- reproduces the deployed P7
ossuary baseline, confirming the mechanic doesn't touch the validated full-clear
path -- and the god end (0 verses) fighter 12.5%, a deadly-but-beatable flex arm.
The sim walks 0 verses (autopilot takes the first unlocked fork; Verses are
behind Perception locks), so the A/B brackets the whole player-agency gradient.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 08:04:19 -07:00
prosolis fc9e055083 adventure: T6 postgame Layer-2 seam + Aurvandryx's Greed Tax (P8)
Add the pre-combat boss-hook seam for Tier-6 postgame bosses and the first
bespoke mechanic on it.

Seam: applyBossRunModifiers(bossID, enemy, run) in postgame_boss_hooks.go —
a pure, idempotent, bestiary-ID-dispatched hook that folds run-state-derived
adjustments into the freshly-built boss Combatant. No-op for every non-hooked
enemy or nil run. The turn engine (which resolves both prod bosses and the
sim) rebuilds the enemy every round via partyCombatantsForSession, so the hook
must be a pure function of run state — fine at a terminal boss room, where the
route is frozen. Wired at both enemy-finalization points: the per-round rebuild
and buildFightSeats' initial HP persist (threaded a run param; the caller
already had it).

Greed Tax (boss_aurvandryx / first_hoard): her Attack rises with the richness
of the route walked to reach her — the summed excess LootBias (>1.0) over the
run's visited nodes. Note run.LootCollected is the wrong signal (BossOnly
signature manifest, empty at the boss); the gilded veins on the graph are.
Attack += min(2.0*richness, 12). Same-binary A/B sweep (n=150, L20 party +
Pete + pets): taxless 66.7% -> taxed 48.0%, landing first_hoard mid-band.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 07:44:10 -07:00
prosolis 85e5ba5fce adventure: validate race/class + clamp level in admin char-migrate
Code-review fixes on the stuck-adventurer minting path:
- AdminBuildConfirmedCharacter now runs race/class through parseRace/
  parseClass (same as !setup), so a typo or non-playable class errors
  instead of silently minting a 1-HP/AC-10/no-spell sheet; inputs are
  normalized too.
- Clamp level to dndMaxLevel to match the L20 cap enforced elsewhere.
- CLI parses/validates every spec before db.Init, so a malformed spec
  mid-batch no longer leaves earlier specs already committed.
2026-07-16 06:42:54 -07:00
prosolis d08a20a114 adventure: P7 sim tuning for Ossuary + Unplace T6 zones
Two sweep passes (n=120, L20, party=leader+cleric+Pete+pets on millenia)
landed both zones at the intended opt-in-endgame difficulty:

- Ossuary Ascendant: re-hardened after pass 2 overshot (party fighter
  63%->38%). Valdris 525/50->550/52; grave_cardinal 220/27->230/28;
  reliquary_knight atk 29->30; chorister 180/26->190/27.
- The Unplace: softened the multi-target elite AOE procs (the party-arm
  lever; boss flat HP barely moved it). the_unnumbered 20atk/.28 proc,
  echo_of_belaxath Rerun .25, seamstress 385/39.

Final party-fighter clears: ossuary 38 / unplace 37 (== accepted
drowned_star 38 standard); solo stays deadly 2-8%.
2026-07-16 06:34:37 -07:00
prosolis 2e73cae418 adventure: mint a confirmed sheet for a stuck adventurer
The boredom ticker needs a confirmed dnd_character, so two populations
never leave on their own: veteran legacy players with an
adventure_characters row but no dnd_character (auto-migration only fires
on active play, which an idle player never does), and players who
abandoned !setup at race pick (pending_setup=1). Both are correct
guards in tryBoredomStart, not bugs — the fix is to hand them a finished
sheet.

AdminBuildConfirmedCharacter forces a race/class/level and reuses the
same constructors as auto-migration and !setup confirm (class-tuned
standard array, racial mods, HP/AC, resource pool, caster spells+slots),
with auto_migrated=1 so the player can freely !setup-rebuild the class
that was chosen for them. cmd/char-migrate is the one-off that drives it
against a live gogobee.db.
2026-07-15 23:42:27 -07:00
prosolis d9541f07f1 adventure: Tier 6 postgame "Mythic" dungeons (P1–P7 calibration)
Five post-game dungeons above the T5 ceiling, gated on both T5 bosses beaten
+ level 18. Opt-in endgame: deadly solo, clearable by a party with Pete + pets.

- P1 gating: postgameUnlocked (T5 clears + level floor), zonesForLevel excludes
  T6 unconditionally; wired into startZoneRun, !zone/!expedition, party accept,
  boredom picker, and the list dividers.
- P2 bestiary: 15 elites + 5 signature bosses (Layer-1 stat blocks).
- P3 zone defs + 4-region registries; ZoneLootEntry.BossOnly.
- P4 five zone graphs on a shared builder (44–52 rooms, no soft-lock; Ossuary
  secret Verse nodes).
- P5 loot: BossOnly enforced; signature items are real registry magic items;
  five Thom pity recipes off the per-zone crafting anchors.
- P6 narration/flavor (5 files), T6 achievements, Pete stays zone-parametric.
- P7 (in progress): sim can now reach gated T6 (SimRunner.SeedPostgameUnlock +
  IsPostgameZone). First calibration pass on millenia — hardened ossuary +
  drowned_star, softened first_hoard + unplace; last_meridian in band.

Fix: party members were refused from every T6 zone because expeditionCmdAccept
ran the level gate (which excludes T6) before the postgame check — the intended
party endgame was unreachable. Route T6 through postgameUnlocked. Regression
tests added.
2026-07-15 23:17:07 -07:00
prosolis 27c2b48007 adventure: worn magic items bond when a slot frees, and can be taken off
An attunement item equipped while at the 3-bond cap sat permanently inert:
nothing re-bonded it when a slot opened, and the equip picker only lists
inventory, so a slotted item could never be reached again.

- reconcileMagicAttunements bonds worn-but-inert items whenever bond
  capacity is free (bonding is strictly beneficial; inert should only
  exist at the cap). Runs on equip-magic open and after any swap.
- New !adventure unequip-magic picker takes a worn item off and returns
  it to inventory at full value, freeing its bond slot (which then heals
  any straggler). Destructive-op-first ordering mirrors the equip path.
2026-07-15 22:41:01 -07:00
prosolis c9282cb18a mischief: retry web contract insert on transient failure, refund only on lost race 2026-07-15 00:20:34 -07:00
prosolis 11bfce780c adventure: gogobee pushes each adventurer's sheet to Pete's detail page
The board on Pete shows flat rows; this hands it two more channels so a name
can become a page. Public stats + equipped gear ride the roster snapshot
(RosterDetail on each entry, keyed by the anonymous token, no handle). The
private self-view — inventory, vault, house, pets — rides its own push keyed
by localpart, so Pete only ever serves it back to the one signed-in owner it
belongs to; the board token rides along so the ownership check is a join, never
a reversal of the one-way token. The private set skips no one for opt-out (that
governs the public board only) and skips the dead (no live page to own).
2026-07-14 22:22:39 -07:00
prosolis ba306f0b59 mischief: gogobee learns to fill orders placed on Pete's web board
The game-side half of Mischief Makers M3. gogobee polls Pete for storefront
orders and opens the contract itself — the money, the eligibility, the fight
are all its own, exactly as a Matrix !mischief buy.

- roster push now carries each buyer's advisory euro balance (keyed by localpart,
  a separate keyspace from the anonymous board token) and the live tier catalog,
  so the storefront renders gogobee's current prices and never hardcodes one
- placeWebMischief: the fulfilment path, debit-first-then-refund so the money
  state is a pure function of the order guid. DebitIdem + an order_guid stamp on
  the contract make a retried claim neither double-charge nor double-open
- resolveRosterToken recomputes the one-way board token per live player to name
  a mark; the buyer is @<username>:<homeserver> from Authentik's preferred_username
- a 30s poll loop drives it; a lost verdict just leaves the order pending to
  re-run, so the loop is its own retry and needs no durable queue
- euro.HasExternalTx lets the affordability gate tell a first attempt (no debt
  for a mischief buy, like Matrix) from a retry of one already paid

order_guid column added to mischief_contracts (schema + idempotent ALTER).
2026-07-14 21:12:37 -07:00
prosolis 6d402343e6 games: gogobee learns to poll Pete for money it has to move
Pete holds the chips; we hold the euros and are the only one who can move
them. This is the loop that turns an escrow row on games.parodia.dev into a
real balance change here.

It is a poll because Pete cannot call us and isn't going to be able to. Every
step is keyed on the escrow guid, because every step can be interrupted in the
worst possible place: die after DebitIdem and before the verdict is queued,
and Pete re-offers the row, we claim it again, the debit replays as a no-op
and reports the same answer. The player is charged once. That is the only
property here that really matters, and there is a test that kills us three
times to prove it.

The verdict rides the queue that already carries adventure facts — it wants
the same durability, backoff and parking, so the row now says where it's going
rather than the queue growing a twin. Flush sends it at once instead of after
a 15s sender tick, because there's a person at the other end watching a
spinner.

First GET gogobee has ever made to Pete.
2026-07-13 23:00:11 -07:00
prosolis ab2bcf0c59 euro: money that moves over a retrying wire needs a name
Every caller of Credit/Debit today is a Matrix message, and a Matrix message
arrives once. The Pete games escrow will claim buy-ins over a poll loop, where
a claim that succeeds but whose ack is lost gets retried — and the player pays
twice. euro_transactions had no external id and no unique constraint to stop it.

Adds external_id + a partial unique index, and CreditIdem/DebitIdem: balance
mutation and transaction log in one tx, keyed by the escrow GUID. A replay is a
no-op that still reports ok; a rejection leaves no trace, so the same GUID can
be retried once the player is good for it.
2026-07-13 22:36:21 -07:00