395 Commits

Author SHA1 Message Date
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
prosolis
65a48b4bd7 mischief: an escalation is two people's money, not one's
`paid` carries the buyer's stake AND the escalator's delta, but every
refund path handed all of it to the buyer. Buy an elite (€350), let
somebody bump it to a boss (+€850), let the target walk out of the
dungeon: the 90% fizzle refund pays the buyer €1080. A €730 profit,
funded by the escalator, who loses €850 and is never told. Same in the
unstageable and stranded-sweep refunds, at 100%.

mischiefOutlay splits the pot back into its two stakes — derived, not
stored: an escalation is exactly one rung and happens once, so it is
always the fee gap to the tier below. Both are refunded separately, and
the sink is booked against whoever actually paid it.

Three more from the same review:

- A crash mid-delivery stranded the ward on the target's sheet forever.
  clearMischiefBlessings runs on a defer, which is the one thing a crash
  skips — and the stale sweep exists because deliveries do die. It takes
  the cushion back now.
- dmMischiefVictim told the target to run `!mischief bless @<display
  name>`. The command splits on whitespace, so "Misty Blue" resolved on
  "@Misty" — or on somebody else. It's the only command the ward window
  has. Use the MXID.
- Two blessers landing together both announced "1 of 3" for a contract
  carrying 2; the count the CAS is authoritative for was being re-derived
  from a stale read.
2026-07-13 21:53:47 -07:00
prosolis
3563519db1 mischief: price a ward against the thing it wards off
The M1 close-out sweep (new, skip-gated: n=400/cell through the REAL delivery
path, not SimulateCombat) says three wards buy roughly +40pp of survival — a
level 12 fighter meeting a boss at 70% HP goes from 48% to 90%. At a flat €25
that let three friends halve a €1,200 boss contract for €75, which makes the
tier the entire economy rests on feel like money thrown away.

A ward now costs max(€25, 10% of the contract): €25 at grunt and mob, €35 at
elite, €120 at a boss — so covering someone against a boss costs the town €360.
Still a bargain against €1,200, but it has to be a real rally rather than
pocket change. It reads the contract's current basis, so an escalation raises
the price of saving its target too: the counterplay tracks the threat.

The sweep also shows the M0 fee table was priced on a fight we don't deliver.
Its control arm (full HP) diverges from M0 in both directions — up where an
engine timeout now counts as survival, down where the turn engine loops a boss's
full multiattack profile and SimulateCombat doesn't. And the arm M0 could not
see at all, a wounded mid-run target, is the one that matters: boss tier at 70%
HP collapses everywhere. Fees stand; the tiers still do what they were priced to
do. The real-path table is now the reference.
2026-07-13 21:31:48 -07:00
prosolis
4e0b8a298c news: the backfill files bulletins too
The live emitters stopped filing priority facts in 1cbd68a, but the launch
backfill kept doing it for historical zone-firsts and deaths. It is inert today
— Pete's handler short-circuits on no_push before the Matrix post, and headlines
key off event_type rather than tier — so nothing on the site or in the room
moves. It was a landmine: the echo rule was being enforced by a guard that
happens to sit in front of it, not by the tier itself, and anyone touching that
guard resurrects the echo at back-catalogue scale.

Bulletin is the rule. Nothing in the back catalogue is news anyway.
2026-07-13 21:04:39 -07:00
prosolis
c368257896 mischief: the town gets a say before the monster lands
M2 — the window between a contract being placed and the monster finding its
target. Anyone can ward the victim (!mischief bless, €25, three max, +10% MaxHP
of temp HP each) or pay the tier delta to send something worse (!mischief
escalate, one step, boss is the ceiling). TwinBee DMs the victim on placement so
they can go and ask for wards.

Escalation money joins the payout basis, so piling on raises the purse the target
walks away with if they live — and an escalator is unsealed on a survival exactly
like the buyer. Cruelty and generosity are the same button.

Both commands are CAS-then-refund: the ward cap and the one-step limit live in
the UPDATE's WHERE clause, because a scramble is precisely when four people press
the button in the same second.

Also fixes a real bug this exposed: the delivery built its fight from the copy of
the contract the due-sweep handed it, but the window keeps writing to an open row
right up to the claim. A last-second escalation was paid for and then delivered
the old tier's monster. The claim is the fence; read on that side of it.
2026-07-13 21:00:34 -07:00
prosolis
f7ddbf8858 news: Pete stops repeating the mischief announcements too
1cbd68a established the rule and I broke it two commits later. Priority
tier is the one thing that makes Pete post a live Matrix beat, and
TwinBee announces every mischief moment in the games room as it happens
— the contract going out, the survival, the maiming. Filing them
priority meant Pete read his version back to the room that had just
watched it.

Bulletin, like death and zone_first: still on the site, still in the
daily digest, no second telling to the people who were there.
2026-07-13 20:46:27 -07:00
prosolis
99daac3c2b merge: Mischief Makers M1 — paid monster hits on live expeditions 2026-07-13 20:21:16 -07:00
prosolis
94077a47ae mischief: a contract that never landed shouldn't protect its target
The 24h cooldown and the one-boss-a-week cap both keyed off any resolved
contract, and a fizzle resolves. So a target could have a friend point a
grunt at them, extract, and buy a day of immunity for the fizzle rake.
Both caps now only count contracts that were actually delivered.

Delivery also ran a full combat against the target's sheet without their
advUserLock. hasActiveCombatSession only sees the turn engine — the
target's own autopilot walk resolves its fights inline under that lock
and reports no session, so a delivery could race it and lose a fight's
worth of HP writes.

Also: don't tell a buyer a rival beat them when the insert simply failed.
2026-07-13 20:21:03 -07:00
prosolis
8fc5a82b83 mischief: you can now pay to have someone's expedition ruined
Mischief Makers M1 — the core engine, Matrix-only. `!mischief send elite @user`
debits the buyer, tells the games room a hit is out, and an hour later a monster
from the target's own level bracket walks into whatever dungeon they're in.
Survive it and they keep a cut of the money and the buyer is named; don't, and
they wake up on a cart home.

The monster comes from the target's bracket zone pool, not the arena ladder and
not the dungeon they happen to be standing in — the same selection code the M0
pricing sweep ran through, so the fee table can't drift away from the fight it
priced.

Three things that are load-bearing and don't look it:

  * Survival is read off the target's HP, not PlayerWon. The engine's timeout is
    a retreat, not a lethal blow — somebody who ran out the clock with HP left
    held the thing off, and a bought monster that merely outlasted them hasn't
    earned a maiming.

  * Nobody dies for money, and that includes the party. The delivery skips
    closeOutZoneWin/Loss (the fight is extrinsic to the dungeon — crediting it
    would let a buyer unlock the target's kill-gated resources for them), so
    nothing else floors a downed seat. Without floorMischiefRoster on BOTH
    outcomes, a member the leader outlived is left alive at 0 HP, which every
    `HPCurrent <= 0` gate reads as broken rather than dead.

  * One live contract per target is a partial UNIQUE INDEX, not a read-then-write
    check. Placement holds only the *buyer's* lock, so two buyers racing at the
    same victim would both pass an in-code test. The loser is refunded.

Payouts are a percentage of the base fee, never of what the buyer actually paid —
the sign surcharge is a pure sink. Capped at 75%, so a survival purse is always
strictly less than the outlay and collusion loses to !baltransfer, which is free.
That cap is the entire anti-collusion story; no danger multiplier needed.

A crash between claiming a contract and closing it out used to be unrecoverable
in the design: the row would strand, the target could never be targeted again,
and the buyer's money was gone. The stale sweep refunds those in full — that one
is our fault, not a bet they lost.

Contract timestamps bind as Go time.Time, never CURRENT_TIMESTAMP. The driver
stores RFC3339 and SQLite's own stamp is space-separated; the two compare
lexicographically wrong.

Pete learns four mischief_* event types in a separate commit, and has to deploy
BEFORE this does — an unknown event_type is a 400, which retries and then parks
the bulletin forever.
2026-07-13 20:03:20 -07:00
prosolis
2ab6e7843a roster: a quietly-succeeding ticker looks exactly like a dead one
The push logged nothing on success, so during the first deploy an empty board
and a silent log were indistinguishable from a ticker that never started — and
the debug went looking for a bug that wasn't there. (The snapshot was simply
2 minutes out; the first tick hadn't fired yet.)

Logging every push at INFO would be noise forever. Log the transitions instead:
the first time it works, the moment it breaks, and when it recovers.
2026-07-13 18:19:16 -07:00
prosolis
ced75786b9 news: tell Pete who's out there right now, not just who died
We only ever told Pete about outcomes. Nothing emitted when an expedition
*started*, which is why the two bored adventurers walked into dungeons and the
news feed said nothing at all — it wasn't broken, it had nothing to say.

Two halves:

A roster snapshot, pushed every 2 minutes. Deliberately NOT on the durable fact
queue: a fact is history and losing it loses it forever, so it retries. A
snapshot is a photograph of the present, and a retried one is a lie — by the
time it lands, she's moved. The next tick carries the truth. That's also what
lets Pete's staleness timer work: if we stay down, nothing arrives, and the
board stops claiming to be live instead of insisting forever that Josie is
still in holymachina.

And a "departure" bulletin when a bored adventurer lets itself out.

The snapshot omits opted-out players rather than anonymizing them, and carries a
board token distinct from every event token, so a standing row can't become the
key that links a player's dispatches back together.

The player_meta scan folds last_player_action_at/created_at in Go instead of
COALESCE()ing in SQL — modernc rebuilds time.Time from the declared column type
and COALESCE erases it. A failed scan here would publish an empty board and
every adventurer would vanish from the page.
2026-07-13 18:05:49 -07:00
prosolis
1cbd68a718 news: Pete stops repeating what TwinBee just said in the room
Priority tier is the only thing that makes Pete post a live Matrix beat,
and the only two priority facts gogobee filed - zone_first and death -
are both moments TwinBee narrates in-room as they happen. Every live beat
was an echo. File them as bulletins: still on the site, still in the
daily digest, no second telling to the people who were there.
2026-07-12 22:01:09 -07:00
prosolis
e377e0c85c boredom: the idle clock has to be seeded, or the deploy empties the town
last_player_action_at is NULL for every existing row on the deploy that
adds it, and loadBoredomCandidates COALESCEs the NULL onto created_at.
created_at is account age, not an idle clock: for anyone who has been
playing for a month it is a month old. So the first tick after restart
would read the entire server as idle-since-creation and march all of
them into a dungeon thirty minutes later, coins debited, including the
player who typed a command a minute before the deploy.

Seed the column once from last_active_at instead — the best "did
something recently" proxy that exists at deploy time, recent for the
regulars and stale for the lapsed. It stays unusable as the clock itself
(the autopilot bumps it), which is why this is a one-time seed and not a
fallback in the query. Re-running on every boot is a no-op, and it skips
rows that already have last_boredom_at set, so a restart mid-boredom
can't hand a bored character a fresh idle clock off its own autopilot
writes and un-bore it permanently.

Two smaller ones in the same clock:

- a pending prompt counted as an action regardless of ExpiresAt, and
  nothing sweeps p.pending. One offered-and-ignored treasure prompt and
  every idle remark that player ever made would read as tending their
  adventurer, forever.
- the command test ran 50 IsCommand passes over the body on every
  message the bot sees, in every room. One tokenise-and-look-up does the
  same job.
2026-07-12 19:08:22 -07:00
prosolis
7c379b298c adventure: bored adventurers go into dungeons on their own
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.
2026-07-12 18:57:50 -07:00
prosolis
a533e106c2 appservice: keep the crypto method set on the wrapped state store
The lazy store embedded appservice.StateStore, but the client's store is
also type-asserted to crypto.StateStore -- a wider interface -- and
NewCryptoHelper refuses to start when that assertion fails. Embedding the
narrower interface dropped GetEncryptionEvent and FindSharedRooms from the
concrete type, so the bot died on startup with "the client state store
must implement crypto.StateStore".

Embed both, assert both at compile time, and resolve GetEncryptionEvent
lazily like IsEncrypted since the crypto machine reads megolm rotation
settings through it.
2026-07-12 17:31:19 -07:00
prosolis
2482433896 appservice: stop sending plaintext into encrypted rooms
Without /sync nothing backfills the state store, so a room the bot had
not yet heard from looked unencrypted -- IsEncrypted answers false with
no error -- and any message the bot started on its own (ambient DMs,
expedition beats, briefings) went out in the clear. Under /sync the
cryptohelper registered StateStoreSyncHandler and the initial sync
carried every joined room's state, so the store was warm before the bot
ever spoke; the appservice port replicated the crypto plumbing but not
that backfill.

Resolve encryption and membership from the server on first read instead,
and refuse the send when they cannot be resolved: a message that fails is
recoverable, a plaintext one that has landed is not. Members are resolved
the same way, since an unfetched room shares the group session with
nobody.
2026-07-12 17:27:28 -07:00
prosolis
da398cf674 Merge: combat engine N-body debt (§1–§6) + Pete's retreat bulletin
Casters could not cast in ordinary rooms — the auto-resolve engine has no action
picker, so a cleric on autopilot fought every room of every expedition with a
mace while its spellbook sat unused. It read as a balance problem (cleric 46%
vs fighter 81%) and was not one: cleric DIED less than mage, it just fled 167 of
500 runs, and one room loss ends the whole expedition.

§6 lets them cast, with a slot reserve so the trash rooms cannot eat the boss's
kit. Trailers came up, leaders did not move, and the class spread narrowed from
35pp to 26pp. Pete can now report the runs that simply fell apart, which he was
structurally incapable of doing before.

Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
2026-07-12 10:37:12 -07:00
prosolis
e98029e6ac Pete can finally report a run that simply fell apart
Pete's entire taxonomy was arrival, companion_hire, death, milestone,
rival_result and zone_first — every one of them a win, a death, or an
introduction. An expedition that ended with the player walking out alive emitted
nothing at all, so the feed showed a realm where adventurers only ever triumph
or die.

That is not a rare gap. Before the §6 slot work, a cleric retreated on 167 of
500 simulated expeditions: a third of that class's runs ended in a way the news
was structurally incapable of reporting. Casters did not read as unlucky in the
feed. They read as absent.

So: a `retreat` bulletin, filed from forceExtractExpeditionForRunLoss — the one
chokepoint every bad ending already passes through. It carries who, where, and
the day they got to before it came apart.

Gated on the reason, not on "the expedition ended":
  - a death already files its own priority dispatch, and must not ALSO be
    reported as a retreat;
  - an idle reap is not a retreat. A player who closed their laptop did not flee
    anything, and Pete announcing by name that they were driven from the field
    would be a lie about a person.
The four reason strings were bare literals at their call sites; they are
constants now, because the gate cannot be allowed to drift from them.

The day count is read BEFORE forcedExtractExpedition, which stamps the row
'abandoned' and takes the live fields with it.

Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
2026-07-12 10:31:34 -07:00
prosolis
064ecb1848 Combat engine §6: the rooms get the small change, the boss keeps the big slots
The first cut let a caster cast in auto-resolved rooms and capped nothing but
upcasting. The sweep said that was half a fix. Room deaths fell for every caster
— mage 131 -> 105, sorcerer 118 -> 105 — and fleeing collapsed, which is what §6
predicted. But elite and boss deaths exploded behind it: mage 7 -> 38 and 19 ->
61, sorcerer 14 -> 51 and 15 -> 65. They were winning the trash rooms and
arriving at the thing that matters with an empty pool. Net, mage and sorcerer
came out of §6 worse than they went in (-34 and -37 runs cleared).

"Never upcast" is not a reserve. A mage's native-level spells ARE its boss kit,
so casting them at native level in a goblin room still spends the dragon's
slots. Only a level cap reserves anything.

So an ordinary room may reach for a 2nd-level slot and no higher. Every caster
still owns a real spell down there — a cleric's inflict_wounds, everyone else's
scorching_ray — but fireball, spirit_guardians, flame_strike and every slot the
turn engine would upcast into stay in the caster's pocket until there is
something worth spending them on.

Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
2026-07-12 10:01:56 -07:00
prosolis
bae83271fe Combat engine §6: a caster casts in the rooms, not just at the boss
§6 was filed as "clerics are weak in solo — lift the trailer". It is not a balance
problem and a tuning dial would have buried it.

Re-baselined on HEAD: cleric still last, 46.4% vs fighter 81.4%. But cleric *dies*
less than mage, sorcerer or warlock. Its entire deficit is FLEEING — 167 of 500 runs
end with the player alive and the expedition over, where fighter and ranger flee
zero. Instrumenting the stopEnded sites: every one of those runs ends in an ORDINARY
room fight. Not a patrol, not an elite, not the boss.

An ordinary room is runZoneCombatRoster → SimulateCombat on an 8-round clock: one
breath, no turn engine, no action picker. The only spell that could ever land there
was a PendingCast the player queued BY HAND before walking in. On autopilot nobody
queues one — so for every room of every expedition, a caster fought with a weapon and
nothing else. A cleric has no Extra Attack, a mace, and its whole kit unusable: it
grinds the 8 rounds out, and a wounded one starts losing fights a fighter never
loses. One room loss ends the whole expedition.

The tell is that dnd_class_balance.go — the harness the class corpus was tuned on —
ALWAYS hands a caster their best damage spell before it simulates. The numbers that
say "cleric is a weak class" modelled a cleric who casts. The live room modelled one
who does not. The corpus and the game disagreed about what a cleric is.

Same defect as the rest of this plan: the action model is narrower than the kit. §1
(the picker never healed), §3 (the companion never acted), Pete (LoadDnDCharacter →
nil → "attack"), and now every caster in every room.

The spell is additive pre-damage, exactly as a hand-queued PendingCast has always
been, so this stays comparable to the corpus instead of being a new mechanic.

It is slot-aware and NOT free. pickBestDamageSpell reads slotsForClassLevel — the
theoretical class table — so reusing it would have cast an unlimited leveled spell
every room: the same "no row to persist onto, so it arrives fresh" free lunch that
gave the companion an infinite body. The new picker reads the seat's real remaining
slots and spends one.

It never upcasts. The big slots are what the elite and the boss are for and the turn
engine spends them there; a picker that nukes a goblin with a 5th-level slot leaves
the caster swinging a stick at the thing that matters.

Both goldens byte-identical — they pin the engine, and this changes its inputs at the
zone layer. Martials provably untouched.

UNMEASURED: the verification sweep is still in flight. Read cleric's fled count, and
watch bard/druid for overshoot — they get the same buff and were not the problem.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 16:30:41 -07:00
prosolis
c9128fb0d6 Combat engine §1 (the other half): a cleric can heal a friend at camp
`--target @user` has been advertised by !help and swallowed by the parser since
SP2 — "reserved for SP3, accept and ignore". §1 wired ally heals inside a fight;
out of combat the cleric still could not put a hit point on anybody but himself,
which is most of where a party is actually hurt: between the rooms, not in them.

The target set is the expedition, not the world. In a fight splitCastTarget
resolves against the people in the fight; the standing-around equivalent is the
people you are travelling with, so both `!cast` paths answer the same question.

Only a heal may name somebody else. UTILITY resolves on the caster and everything
else queues as a PendingCast for the *caster's* next fight, where an ally target
has nothing to mean — so a target on those is refused outright rather than
silently dropped, which is exactly what the old parse did.

The ally's row is mutated with one guarded UPDATE inside a transaction, not a
read-modify-write under a second lock (gifting sets the precedent). Two clerics
healing each other at the same instant would otherwise take their advUserLocks in
opposite orders and deadlock the pair of them. The max-HP clamp lives in the SQL
for the same reason.

Refunds the slot on every path that heals nobody — full-HP ally, downed ally, no
sheet, stranger. A slot spent for zero HP is the kind of thing players do not
forgive. All four are pinned end-to-end through the real handler.

A heal is still not a resurrection: it will not raise the dead, same rule the
combat path holds.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:38:05 -07:00
prosolis
055f07d3c0 Combat engine §2(a): the enemy charges for what a seat brings
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any
roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a
round. A seat COUNT charges the same for an under-levelled friend, a hired NPC,
and a true peer — so a below-median body cost a full seat's worth of boss and did
not give a full seat's worth back.

Measured, once the companion's free full-heal was taken away and he became honest:
hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this
bug, and it has been live for every under-levelled friend anyone has ever invited.

Seats now carry a SeatWeight, and both levers scale on the summed weight of the
LIVING seats rather than on a head count. The weight is level-based, priced against
the leader, times a discount for a hireling (no subclass, no magic items, gear that
is never Masterwork — the layers a player accrues and a hireling never will).

Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric
below a fighter and quietly make every mixed HUMAN party easier, which is a
difficulty regression smuggled in under a bug fix.

The safety argument is one property: **a peer weighs exactly 1.0**. So the curves
interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4),
2n-1 from 3 up — and every integer input returns exactly what it always returned.
Solo is byte-identical, a party of same-level humans is byte-identical, both
goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is
the entire point of the change.

It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed
the head-count half; a corpse still carried its full weight until this.

Measured, 640 runs/arm, same grid:

  solo                    69.0%   (unchanged — corpus intact)
  + Pete                  76.8%   (+7.8pp)
  + a human cleric peer   77.6%   (+8.6pp)

  band                 solo    +Pete     lift
  trailing (<40%)     10.0%    31.0%   +21.0pp
  middle              58.9%    76.8%   +17.9pp
  leading (>=70%)     93.5%    99.2%    +5.7pp

Help, never a carry: he rescues the players who were drowning and barely moves the
ones who were already fine — and he stays below a real human of the leader's level,
which is the invariant a hireling must never break.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:20:49 -07:00
prosolis
27b9de5936 Companion: he carries his wounds, rations his slots, and heals himself
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
2026-07-11 14:56:19 -07:00
prosolis
01c2cb2f0b Combat engine §1: the hired companion can cast
Every spell lookup in the engine is keyed on a Matrix user id and answered
by a dnd_* table. The companion has rows in none of them, deliberately — a
sheet on disk is what would turn him into a real character everywhere. So
the auto-picker's first statement, LoadDnDCharacter(uid), came back nil and
returned "attack", every turn, for the whole fight.

A hired Cleric swung a mace while the party died. Role-fill hands a lone
martial a Cleric, so that was the common case of the feature.

Adds a seat-scoped spellbook: seatKnownSpells / seatSpellSlots /
seatKnowsSpell / consumeSeatSlot / refundSeatSlot. A human seat delegates to
the DB functions verbatim — same queries, same order — so solo combat and the
balance corpus are untouched (both goldens byte-identical). A companion seat
is answered from his synthetic sheet and a slot ledger on his seat's
persisted statuses. The seat is the correct home and not merely the available
one: every expedition hires the same @pete, so a store keyed on his user id
would have two parties sharing one pool of slots.

He gets the same default kit a real character of his class and level gets.
The below-median stays where it was — the level penalty, the never-Masterwork
gear, the absent subclass and magic items. A bespoke weaker spell list would
be a second nerf hidden in a different file.

castActionForSeat was also a live hazard: it loaded the caster through
ensureCharForDnDCmd, whose auto-migration branch, handed a user with no sheet,
builds one at level 1 and *saves* it. Pointed at the companion that silently
makes him a player. He now takes a branch that never reaches it, and a test
counts rows in dnd_character / dnd_known_spells / dnd_spell_slots /
player_meta to keep it that way.

Measured, 640 runs/arm (10 classes x L10,L12 x 4 zones):

  solo                      66.1%
  + Pete, mace-only (HEAD)  83.4%  (+17.3pp)
  + Pete, casting            95.9%  (+29.8pp)

The fix does what it should. It also lands on top of an unpaid §2(a): the
mace-only arm shows Pete was ALREADY a carry, taking the trailing band from
6.8% to 63.6% without casting a thing. The tell is the cleric leader, who
role-fills a *Fighter* Pete — a seat this commit cannot touch — and still goes
26.6% -> 98.4%. That is enemy scaling undercharging for a seat, not spells.
§2(a) is next, and is not optional.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 13:44:47 -07:00
prosolis
d538f91cf7 Combat engine: pay off the N-body debt, and hire Pete
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
2026-07-11 12:39:01 -07:00
prosolis
f4a4c0d30b Merge adventure-news: Pete Adventure News seam + code-review fixes 2026-07-11 07:57:20 -07:00
prosolis
1634bb1970 Pete news code-review fixes: realm-first ordering, parked-row reap, death-emit gate
- 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.
2026-07-11 07:56:41 -07:00
prosolis
ae7ff38996 Pete news: salted per-event GUID token + zone_clear taxonomy
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.
2026-07-11 07:43:57 -07:00
prosolis
b42beec348 Pete Adventure News: emit seam, !news command, cold-start backfill
gogobee's game-side of Pete's push-based Adventure news feed. Emits
structured event *facts* (not prose) to Pete's ingest endpoint over a
durable queue; Pete owns voice/authoring/publishing.

- internal/peteclient: durable pete_emit_queue + retry sender; Fact
  payload; FEATURE_PETE_NEWS master switch.
- pete.go: emitFact enforces runtime kill-switch + per-player opt-out
  (anonymize, never drop); zone-clear + realm-first tiering; !news
  command (player optout/optin, admin on/off).
- bootstrap_pete_news.go: cold-start backfill replays deaths +
  single-holder achievements + zone realm-firsts, backdated + NoPush,
  idempotent, fires only once the seam is live; seeds news_realm_firsts.
- Emit sites: death, zone_first, rival_result, milestone, arrival.
- db.go: pete_emit_queue, news_optout (+opted_out_at), news_realm_firsts.

Unshipped. Deploy Pete first, then set FEATURE_PETE_NEWS + ingest env.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-11 00:53:25 -07:00
prosolis
0c4c4757d3 Merge fix-automigration-player-meta-seed: seed player_meta on auto-migration
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:52:14 -07:00
prosolis
fedd357a29 Seed player_meta on auto-migration to prevent player_meta-less stragglers
The !setup confirm path seeds the canonical player_meta row (commit
667f87f), but the auto-migration path (ensureDnDCharacterForCombat) writes
a confirmed dnd_character without touching the legacy layer. Commands that
trigger it — !rest, !cast, !abilities, !skills — derive the adventure char
via a bare loadAdvCharacter that is nil when player_meta is absent, and
autoBuildCharacter tolerates a nil char. So a brand-new player whose
first-ever adventure action is one of those gets a confirmed character with
no player_meta, which then fails every legacy-layer command (expeditions,
arena, world boss, town, duels) with "sql: no rows" — the same state
@camcast was found in.

Fix: ensurePlayerMetaSeed guarantees the seed row (+ tier-0 equipment)
exists at the fresh auto-migration point. Conditional on player_meta being
absent, so it's idempotent and never duplicates a legacy player's gear
(createAdvCharacter's equipment insert has no conflict guard).

Regression tests cover both the straggler repro (first-ever !rest seeds
player_meta + loads cleanly) and idempotency (no equipment duplication).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:50:48 -07:00
prosolis
59319ede9d Merge sim-real-char-harness: headless real-char sim + feature exercise harness
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:34:28 -07:00
prosolis
3f4b4ece5c Headless real-character sim + new-feature exercise harness
Run the actual Adventure module against a copy of the prod DB with no
Matrix client, to smoke-test before deploy.

- expedition-sim: -real-user @mxid runs an EXISTING character loaded from
  -data's gogobee.db instead of a synthetic build. SimRunner gains
  PrepareRealCharacter (heals to full + tops up bankroll; keeps real
  race/class/subclass/level/gear/spells).
- plugin.SendReply now honors the MessageSink like SendMessage/SendDM.
  Reply-based handlers (duels, !town, !rivals, !achievements) previously
  bypassed the capture seam and hit a nil client under the sink. Prod
  behavior is unchanged (sink is nil in production).
- exercise_prod_test.go (build tag: prodexercise) drives every N-series
  feature — world boss, duels, Shadow, Renown, achievements, journal,
  town registries, vault, gifting — against a prod DB copy with all
  outbound messages captured. Gated on GOGOBEE_PROD_DB_DIR; never runs in
  normal CI.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:32:52 -07:00
prosolis
a6f1de4e74 N7/E4: Seasonal events — 1-week holiday skins
Four anchor holidays (Hallowtide, Midwinter Feast, Sweethearts' Revel,
First Bloom) each get a 7-day window (anchor ±3 days) that layers three
things on the world, all reusing existing machinery:

  1. A themed Omen that overrides the weekly rotation. Reuses the B3 omen
     effect fields, so the non-combat rule holds; kept behind activeOmen's
     simOmenDisabled guard (and activeSeason honours it too) so no season
     path can reach the balance sim or move the golden.
  2. A curated curio shelf at Luigi's — existing registry items rotated to
     the front of dailyCuriosStock, so no net-new power enters the economy.
     Off-season output is byte-identical to before.
  3. A themed road visitor via the ambient seam — a season_visitor event
     that leaves a sellable keepsake + coin gift. No combat: the ambient
     seam still never opens a fight.

Pure function of the UTC date + anchor calendar — no schema, ticker, or
persistence, same discipline as the Omen and holiday calendar. Season
banner rides the existing morning DM (no net-new scheduled message).

go test ./internal/plugin ./internal/db green; combat golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:30:15 -07:00
prosolis
1a47a2fdee N7/B4: Renown achievements wing
Three passive achievements (renown_1/5/10) gated on the derived Renown level
via renownAtLeast, reading player_meta.renown_xp through the achievements
plugin's existing Check(d, userID) seam. No event hook — the passive checker
grants them on the next message once the level is reached.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
aaa45eab14 N7/B3: the Omen — one rotating world modifier per ISO week
activeOmen() is a pure function of the UTC ISO (year, week): omenTable
indexed by (year*53+week)%len, so it advances weekly with no schema, no
ticker state, no persistence. Five non-combat seams read it — harvest yield,
supply freebie, expedition start mood, arena payout (scales gross earnings
before the pot tax), and ingredient drop chance. TwinBee reveals the active
omen in the existing morning DM (no net-new scheduled message).

Launch set is buffs-with-texture on non-combat levers only: Bountiful
Harvest, Quartermaster's Blessing, Golden Purse, Overflowing Satchels, Still
Waters. Nothing touches SimulateCombat or the turn engine — the omen is keyed
on the real clock, so a combat mutator would make the golden and the balance
corpus week-dependent. The plan's "elites +2 ATK" is deliberately dropped for
that reason.

The balance sim drives the real expedition loop and would otherwise traverse
all five seams, making corpus sweeps depend on the wall-clock week. NewSimRunner
sets simOmenDisabled (mirrors simAutoArmEnabled), so activeOmen returns a
no-effect omen under the sim. Still Waters subtracts from the daily threat
*rise* only, floored at hold-steady — it never forces active decay.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
38a3693832 N7/B2: Renown — prestige past the L20 cap
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
2026-07-10 19:48:49 -07:00
prosolis
401b17c3bb N6/C2: player-initiated duels — staked, no-death PvP
`!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
2026-07-10 19:18:45 -07:00
prosolis
3026fe6012 N6/C3 review cleanup: retire the combined-level reducer duplication
The W1 extraction added combinedAdvLevel but left twinBeeMaxTier and
distributeTwinBeeRewards inlining the identical dndLevel+3-skills expression,
so the reducer lived in three places. Rewire both twinbee sites to
combinedAdvLevel — pure refactor, byte-identical arithmetic.

Deliberately did NOT share tierForCombinedLevel's switch into twinbee: its
default arm is worldBossMinTier, a world-boss knob, and coupling twinbee's
tier floor to it would let a world-boss retune silently leak into TwinBee's
activity selection. The reducer is the safe shared piece; the tier thresholds
stay independent.

Suite green, combat golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 18:27:32 -07:00
prosolis
e2c0c3359b N6/C3 review fixes: close the killed-boss-resolves-as-survived window
Two findings from an adversarial review of the C3 diff, both fixed:

1. (medium) A killing blow commits the shared pool to 0 inline, but the status
   flip to 'defeated' was deferred until AFTER the multi-second narration stream.
   In that window a crash/redeploy or the ticker's survive path could resolve a
   boss the town KILLED as a survival — debiting the pot and paying no bounties.
   Fix: resolve the defeat BEFORE streaming narration, and add a ticker safety
   net that resolves any active 0-HP boss as defeated (never falls through to the
   survive/pot-debit branch). Both guarded by setWorldBossStatus, so still once.

2. (minor) Pool damage was applied before the best-effort contrib/gate write, so
   a failed upsert let a player refight a pool they had already drained and lose
   the credit. Fix: write the contribution (which sets the once-per-day gate)
   BEFORE draining the pool, as a hard error that aborts the bout with the pool
   untouched.

Also corrected the applyWorldBossDamage comment (two concurrent killers CAN both
see killed=true; the status guard dedupes the payout — the old comment claimed
only one would). New ticker tests cover both resolution paths; suite green,
golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 18:09:11 -07:00
prosolis
127d252982 N6/C3 W2: world boss — the daily bout, command, operator spawn
Wires the player-facing half of the Siege on top of W1's model + lifecycle.

- !adventure worldboss [status|fight|spawn] (alias !adventure siege). Status
  shows the shared-pool board + your daily bout state + the muster; fight
  takes today's bout; spawn is an admin override (the "both" spawn decision).
- The bout is an arena-style solo fight through runZoneCombat vs a disposable
  per-tier stat block; the damage dealt (EnemyEntryHP-EnemyEndHP) is subtracted
  from the shared pool atomically (MAX(0, …) WHERE status='active') win or lose.
  Real HP cost like the arena, but no death/no hospital: worldBossFloorHP
  raises a 0-HP loser to 1 so a loss reads as "battered", not a corpse.
- One bout per player per UTC day (worldBossBoutUsedToday off the contrib's
  last_fight_date). The per-user advUserLock serialises a player's own repeat
  submits so the gate can't be raced; cross-player pool-crossing is safe
  because only the setWorldBossStatus winner resolves a defeat.
- A killing bout trips resolveWorldBossDefeated (minted bounty by fights +
  cache + treasure roll), after the fighter's own bout DM has streamed.

Bout core (resolveWorldBossBout) + the once/day predicate + the HP floor are
factored out of the DM path so they're unit-tested end to end with a real
fightable character; the DM/games-room emission stays thin (no client stub).
Combat golden byte-identical; full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 17:59:17 -07:00
prosolis
c3e122694b N6/C3 W1: world boss — model, scaling, spawn + lifecycle
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
2026-07-10 17:50:56 -07:00
prosolis
cc165bed1f N6/D3: the Shadow — a simulated rival adventurer
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
2026-07-10 17:26:16 -07:00
prosolis
81b2359109 N5 review fixes: protect keys from Robbie, fire epilogue on autopilot, atomic finale reward
Five correctness fixes from a code review of the N5 branch:

- Robbie no longer sweeps/sells cross-zone keys (Type "key"), which
  permanently broke the vault unlock they exist to open.
- Robbie's gift tier now reads the canonical DnD level, not the frozen
  legacy CombatLevel that pegged every gift at tier 1.
- Boss epilogue (D1b) now fires on the compact autopilot boss resolve —
  the primary long-expedition path — not just manual !fight. Deduped the
  two manual sites into a shared writeBossEpilogue helper.
- Finale reward latches epilogue_cleared before granting the Legendary +
  title, so a failed/late write can't make the reward repeatable.
- Misty arc beat's occupied-slot guard moved above the counter increment,
  so a contended pending slot defers the encounter instead of consuming a
  5/15/30 beat forever.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:53:32 -07:00
prosolis
3103b519fb N5/D2: NPC arcs — Misty stages, Robbie's gift, Thom's final letter
Three flavor arcs on existing per-player counters (no schema, golden
byte-identical):

- Misty: 3 deepening dialogue beats at MistyEncounterCount 5/15/30,
  prepended to the encounter opening the one time the counter lands on a
  threshold. Fiction only — no copy ties a donation to the hidden arena
  effects.
- Robbie: every 10th visit he leaves a consumable "for the trouble,"
  drawn from the dungeon pool at a level-matched tier.
- Thom: paying off the Tier-4 mortgage (no next tier) sends a final
  letter instead of the stock payoff line, plus an inert pet-treat
  keepsake if the player keeps a pet.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:12:53 -07:00
prosolis
c37c95a3e3 N5/D4: secret content pass — treasure-cache secret rooms + cross-zone keys
Secret rooms were dead content: every NodeKindSecret node silently
collapsed to a normal exploration fight and its authored LootBias
(1.5-3.0) was never read at runtime. D4 makes them what they read as —
no-combat treasure caches.

resolveRoom now diverts a secret node (keyed off the graph node, since
CurrentRoomType has already lost the kind) to resolveSecretRoom before
the RoomType switch — shared by manual !zone advance, !expedition run
autopilot, and the sim. Each secret pays a guaranteed journal page
(the D1a grant hook built "for secret rooms"), a LootBias-weighted
treasure roll (floored at elite weight), and a guaranteed zone-tier
consumable cache, with bespoke in-world discovery flavor.

Underdark was the only T2+ zone with no secret; added at throne_gallery
on the universal R4 tail (Lost Reliquary, Perception DC 17), merging to
throne_steps so it's length-neutral.

Two cross-zone keys: the Sunken Temple's Coral Reliquary grants a Sunken
Sigil that opens a Sealed Reliquary in Manor Blackspire; the Underforge's
Forge Vault grants an Underforge Seal that opens a Sealed Vault in the
Underdark. Keys are persistent inventory items matched against LockKey
key_id; grants are idempotent.

Graph validator + no-soft-lock pass on both touched graphs; combat golden
byte-identical; go build/vet/test green repo-wide.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:02:15 -07:00
prosolis
9b6c1ff9a4 N5/D1c: the finale — !expedition start epilogue
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
2026-07-10 15:37:53 -07:00
prosolis
aab7a7bad0 N5/D1b: boss epilogues + TwinBee's journal reactions
- Boss epilogues: a 2-3 sentence campaign capstone per zone boss, tying
  each kill to the Hollow King arc. Appended to the boss-down moment in
  both close-out paths (finishCombatSession solo, finishPartyWin party),
  gated on the boss room (!elite) so it fires for any boss kill —
  expedition or legacy !zone — and never for elites or the arena (which
  has no ZoneID entry). Forest of Shadows is the King himself; its
  epilogue frames the fall as a shed shell, leaving the arc for the finale.
- TwinBee digest reactions: a journal page found mid-expedition writes a
  "journal" log beat; the end-of-day digest emits one first-person,
  deterministically-picked TwinBee line reacting to the day's pages. No
  net-new DM — it rides the existing night-camp digest.

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:26:20 -07:00
prosolis
fd7803b13c N5/D1a: journal pages — the Hollow King campaign collectible
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
2026-07-10 15:20:09 -07:00
prosolis
57a0ea90f2 N4/E1: second pet slot for the Tier-4 Estate
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
2026-07-10 15:03:57 -07:00
prosolis
27d6bfd361 N4/E3: town registries — !town, !graveyard, !rivals board
Surface social data the game already stores as three read-only boards:

- !town — civic pride (top tax_ledger contributors), housing street
  (name + tier), pet showcase (name/type/level/barding).
- !graveyard — recent deaths across the guild (death_source/location,
  still-resting vs recovered), St. Guildmore's caretaker voice.
- !rivals board — room-wide duel standings aggregated from the directed
  adventure_rival_records ledger; bare !rivals keeps the per-user view.

All read-only: no schema, no combat, golden byte-identical. Enumerate off
player_meta / tax_ledger / adventure_rival_records with COALESCE'd display
names. Render layer is client-free and unit-tested; a DB-backed loader test
exercises the real schema (caught a MAX(datetime) affinity issue — parsed by
hand via parseSQLiteTime).

Leak-check (engagement plan §E3): the civic board ranks tax_ledger.total_paid,
which is gambling/shop/arena rake only — Misty/Arina donations go through
euro.Debit with their own reason strings and their counters live in separate
player_meta columns, so no board can correlate donations with the hidden buffs.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:31:59 -07:00
prosolis
476384206e N4/E2 review fixes: close a gift-enabled equip dupe; harden vault writes
A high-effort code review of the gifting/vault work turned up three issues:

1. Item duplication (real exploit). The masterwork/arena equip confirmation
   captures an item at prompt time and equips it on "yes" without re-checking
   ownership. Since MasterworkGear/ArenaGear are now giftable, a player could
   !give the item away in the window, then confirm — minting a copy onto
   themselves while the recipient kept theirs. Fixed by taking the user lock
   (making gift-vs-confirm atomic) and re-loading the inventory to refuse an
   item that is no longer ours. Magic items are exempt (not giftable); other
   delete paths load-and-remove within one locked invocation.

2. clearAdvInventory read the vault-filtered list but its DELETE wiped every
   row including vaulted ones — no live caller today, but it would break the
   vault's "safe from !sell all" promise the instant sell-all routed through
   it. Delete now matches the read (in_vault = 0).

3. vault store/take took no per-user lock, so two near-simultaneous stores
   could both pass the capacity check and overfill the 10-slot vault. Now
   serialized on the same user lock the gift path uses.

go test ./... green; golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:05:24 -07:00
prosolis
272f59aa32 N4/E2: item gifting — !give <item> @user
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
2026-07-10 14:05:11 -07:00
prosolis
1de2004f26 N4/E1: T4 Estate vault — 10-slot protected item storage
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
2026-07-10 13:44:32 -07:00
prosolis
d01d3e277a N4/E1: T3 housing payoffs + a home-rest "well-rested" buff
Turn the dead top housing tiers into something worth buying. All three
land without a schema bump, and TestCombatCharacterization stays
byte-identical (the balance corpus never sets the new fields).

T3 trophy room: treasure cap 3->4 at HouseTier>=3 (maxTreasuresForTier).
Enforced at the save gate only -- HouseTier is never written downward,
so a held 4th treasure below tier 3 is unreachable and a load-time cap in
computeAdvBonuses would just add a query for an impossible state. The
all-irreplaceable manual discard prompt now lists the 4th slot too.

T3 workshop: +5% craft success at HouseTier>=3, lifting the cap 0.95->0.98
so a maxed forager still gains. craftingSuccessRate takes a workshopBonus,
threaded through autoCraftConsumables and renderRecipesKnown.

Well-rested buff (replaces the plan's T4 "inn-quality rest", a verified
no-op -- home rest already equals inn rest). Home-only (the inn and a
tier-1 shack grant nothing), starts at T2 and grows through T4, expires at
the next long rest:
  - Temp HP cushion (8/12/16% of MaxHP). TempHP was a dormant field; wired
    into applyDnDHPScaling as MaxHP headroom -- additive and golden-safe.
  - Bonus spell slots (+1/+2/+3 at the caster's highest slot level), the
    real reward, lifting casters who trail on spell-pool richness.
    applyLongRestSpellSlots resets the pool to base then folds in the
    bonus; expiry is stateless (next rest's reset drops it).

Magnitudes are tunable defaults; revisit against the post-parties
re-baseline.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:36:32 -07:00
prosolis
d1c067452e Review fixes: align party enemy-HP scaling in the !fight entry path
The P8 diff scaled the enemy's max HP ×1.15 for parties at persist and
per-turn rebuild, but the !fight command's own template stayed unscaled:
the entry banner reported the pre-scale HP, and the opening-round settle
resolved the enemy against the wrong MaxHP ceiling (regen clamp, bloodied
threshold). Mirror the scalar for the banner and align the in-memory
template before the settle. Solo scales by 1.0, so it is untouched.

Also extract enemyActionPlan() so both combat engines share the one
load-bearing action-count computation instead of duplicating it.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:02:28 -07:00
prosolis
0d18cea59a N3/P8: scale the enemy's action economy to the party
A party of N used to face one enemy swing a round against a single seat, so
each member absorbed ~1/N² of the solo incoming and cleared 100% of every T5
cell. The enemy now takes enemyActionsThisRound() attack-actions, each
re-targeted at a fresh standing seat, in both combat engines:

  - auto-resolve (simulatePartyRound): enemyRoundSwings loops the actions,
    re-rolling each target's pet procs.
  - turn engine (stepEnemyTurn): enemyAttackAction resolves one full SRD
    multiattack per action; step() no longer blanket-stamps the enemy turn to
    one seat, since a party's enemy now hits several.

The action count is a fractional expectation realised as a per-round coin-flip
(2.4 for a duo, 2N-1 for N>=3), because the integer lever is too coarse at small
N -- against a duo, 2 actions is a ~91% faceroll and 3 a ~45% grinder, with
nothing between. A light party-only enemy HP scalar (x1.15) trims the martial
ceiling that action count alone leaves at 100%.

Solo is exempt on both levers (1 action, no RNG draw; x1.0 HP), so
TestCombatCharacterization is byte-identical and the d8prereq corpus still
compares. Sim band (party of 3, T5, HP scaled): fighter 70->90%, cleric-led
27->72% -- monotonic by party size, no composition worse than soloing.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 12:44:19 -07:00
prosolis
88c5fcdf2f Review follow-ups: harden the extraction guard, fix Misty/concentration ordering
Applying /code-review high findings on the review-follow-up stack:

- expeditionCmdStart: the resumable-extraction guard swallowed a partySize
  error and fell open, starting a new expedition on top of a still-seated
  party — the exact orphaning it exists to prevent. Now checks
  extractionLapsed first (no DB call on the reap path) and treats a
  roster-read error as "assume occupied → refuse".
- Lapsed reap on !expedition start silently unseated members. Extracted a
  shared reapLapsedExtraction helper (reap + notify the roster) and routed
  both the hourly sweeper and the start-path reap through it.
- stepRoundEnd: moved Misty's crowd/heal seat-loop after the concentration
  tick so a caster whose lingering aura would kill the enemy that round wins
  before the end-of-round crowd swing, honoring the concentration block's
  "a lethal pulse settles the fight" intent.
- Promoted the misty_heal event-log scan to a shared hasAction helper.
- Renamed the new sweep test off the dnd_ prefix.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 11:07:21 -07:00
prosolis
d5fecf45d8 Review follow-up: a test seam for outbound messages
SendDM/SendMessage went straight through Base to the live client with no
fake, so every handler whose only observable output is a DM stopped below
the test boundary — which is how the party close-out bug (fix #1) and the
member soft-lock (fix #2) survived a thorough suite: nothing could see what
was, or wasn't, sent.

Add a MessageSink interface and a Base.Sink field. When non-nil it captures
every outbound message — both the DM route (SendDM/SendDMID) and the room
route (SendMessage/SendMessageID, which is what the arena announcement uses)
— and the client is never touched. Nil in production, so behaviour is
unchanged; one seam, no call-site churn, since all 765 send sites are
downstream of these four methods.

Tests (message_sink_test.go): a captureSink double + installSink helper;
TestMessageSink_CapturesDMAndRoom proving both routes divert with the right
target/text and that the *ID variants report an id back; and
TestExpeditionCmdLeave_DMsBothEnds, which drives the real fix-#2 handler end
to end and asserts both DMs land — a path that was a silent no-op in a unit
test before. beginCombatTurn's terminal path and the arena rollover are now
reachable the same way.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 10:44:56 -07:00
prosolis
6be7744e81 Review follow-up K: a fourth event anchor for the grind loop
The three A6 presence anchors (expedition Night digest, !sell, arena
cashout) miss a whole playstyle: a player who only mines/forages/fishes
(now automatic, done by walking) and clears single-day dungeons, but
never sells, never enters the arena, and never runs a multi-day
expedition to a Night camp, would roll for zero mid-day events.

Anchor a fourth roll on a foreground single-day zone clear — the climax
DM that player is reading, and one they cannot spam (a clear costs a
full walk). advanceResult.zoneCleared is set only in the full-clear
branch (a mid-zone region clear continues the run and would fire per
region), and the roll lives in zoneCmdAdvance, the foreground path only:
autopilot goes through runAutopilotWalk and party members are refused
earlier by isPartyMember. The per-day slot guard still caps everyone at
one event/day, so the three prior anchors are unmoved.

advEventChanceZoneClear = 0.08 puts a zone-clear-only player at ~1
event/week, matching the fully-engaged player; it is the tuning knob.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 09:12:34 -07:00
prosolis
1f1fbf0251 Review follow-ups L + I: drop dead openParty, unify the menu-index parser
L: openParty had no production callers (invite path uses joinParty -> seatLeader,
which seats the leader the same way but reads the owner off the expedition row).
Removed it; the tests now seat the leader through a seatLeaderFixture that wraps
seatLeader in a transaction.

I: promoted parseTemperIndex to parseMenuIndex and routed resolveMagicEquipReply
and handleMasterworkEquipReply through it, replacing two hand-inlined copies of
the same digit parser. Behaviour-preserving (the parsed flag was redundant with
the idx<0 guard); the retry-on-bad-parse disagreement is left as-is since this
is a pure dedup.

Full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:55:07 -07:00
prosolis
d7a5333048 Review follow-up D: unify solo/party close-out effects
Both the solo (finishCombatSession) and party (finishPartyWin/Loss) close-outs
carried hand-copied lists of the same terminal effects. Item A drifted exactly
there. Hoist the effects into three shared helpers so the lists can't diverge:

- applyOwnerWinEffects: kill record + room threat + boss-defeat threat, once
  through the owner; returns bossOnExpedition.
- grantSeatWinXP: near-death calc + XP grant, per seat.
- endRunOnLoss: mood event (death only) + run/expedition teardown, shared by
  the Lost and Fled paths.

Player-facing text stays divergent (it legitimately differs) and the
roster-size death-on-win rule (item E) is untouched. Pure extract-and-call;
full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:50:15 -07:00
prosolis
91eeee0826 Review follow-up N: Misty's round-end procs fire in turn-based combat
DerivePlayerStats builds MistyHealProc / CrowdRevengeProc onto every turn-based
combatant, but stepRoundEnd had no counterpart to endOfRoundForSeat, so neither
was ever read. The buff (Misty's heal) was silently lost. The debuff (her
crowd's revenge) was an exploit: a player who declined Misty escaped it entirely
by fighting with !attack instead of letting the room auto-resolve -- no
discovery required, just press the button.

Hoisted both procs out of endOfRoundForSeat into shared helpers
(mistyCrowdRevenge, mistyHeal) and a seatEndOfRound hook that runs the pair in
the auto-resolve order. endOfRoundForSeat now calls the helpers; stepRoundEnd
calls seatEndOfRound per seat, after the poison tick so a heal can answer the
round's damage. A one-sided debuff-only hook would have been a second parallel
sibling of the kind deferred item D warns about, so the heal ships with it -- a
player-favourable discovery mechanic, consistent with the lift-trailers stance.

Both helpers short-circuit before st.randFloat() when their proc is unarmed, so
a character with no Misty history draws no dice: the sim corpus and
combat_characterization.golden do not move.

seatCombatResult now reads MistyHealed back off the misty_heal event (as
combat_pet_save always has), so combat_misty_clutch is reachable turn-based.
combat_sniper_kill stays unreachable -- Arina's proc is a pre-combat one-shot
with no round-end seam.

renderAllySeatEvent renders crowd_revenge as unattributed damage: an ally sees
the hit but not Misty's name, keeping the grudge the owner's own discovery.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:44:25 -07:00
prosolis
c34e740008 Review follow-up M: Grim Harvest fires in turn-based combat
The doc's item M claimed all three mage subclass spell hooks were dead on the
turn path because applyMageSubclassSpellHooks had one caller. It has three.
resolveTurnSpell has called it since 5cd343a, so Empowered Evocation and
Overchannel always worked -- they only move mods.SpellPreDamage, which
resolveTurnSpell returns as EnemyDamage.

Grim Harvest was the real defect, with a narrower cause: the hook wrote
mods.GrimHarvestSlot into a local CombatModifiers that resolveTurnSpell
discarded, because turnSpellOutcome had no field to carry it out. A Necromancy
Mage who killed with a spell in a manual fight never healed.

The stash can't ride on fight-start mods the way auto-resolve's does -- the
spell is cast mid-fight and the turn engine rebuilds combatants every round --
so it rides on the casting seat's ActorStatuses, like ArmedAbility. Each
damaging cast overwrites it; snapshotActor carries it across commit().

grimHarvestHeal also scanned for the *first* spell_cast event to ask whether
the spell landed the killing blow. Auto-resolve casts once, pre-combat, so
first == last there. A turn-based mage casts every round, so a non-lethal
opening cantrip vetoed the heal the killing spell had earned. Now scans for the
last spell_cast -- provably identical on the auto-resolve path, so the golden
corpus does not move.

Balance: a caster buff on the manual surface only, and the one the subclass was
written to have. Auto-resolve already paid it out.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:25:56 -07:00
prosolis
a59a544fff Review follow-up C: enforce the extraction resume window
The seven-day window was a promise the code never kept. releaseParty fires
only on a terminal status, and dnd_expedition.go justified skipping it for
'extracting' by asserting the roster gets cleared when the window lapses and
the row flips to 'failed'. Nothing did that: the only extracting -> failed
transition lived inside handleResumeCmd, a lazy expiry that runs only when the
leader personally types !resume.

A leader who quit, forgot, or simply started a different expedition therefore
left the row 'extracting' forever. releaseParty never ran, every member stayed
seated, and assertNotAdventuring kept refusing them a run of their own.

Three holes:

  - No sweeper. sweepLapsedExtractions reaps every row past completed_at + 7d
    through completeExpedition, which releases the roster, and DMs the
    audience. Hourly ticker plus a one-shot at Start() -- a lapse that happened
    while the bot was down is blocking those members now. The audience is read
    before the close-out, since completeExpedition disbands the roster
    expeditionAudience reads. handleResumeCmd's lazy expiry stays, now sharing
    the extractionLapsed predicate.

  - The leader could orphan their own party. !expedition start checked only
    getActiveExpedition, and !resume resolves the newest 'extracting' row, so
    starting fresh on top of one left it unreachable with its roster still
    held. It now refuses when that row has a roster; a solo extraction strands
    nobody, so walking away from one stays normal.

  - The leader had no way out but to pay. !expedition abandon could not see the
    extracted row it owns, so closing it meant buying a !resume first. Both it
    and abandonExpedition now span 'extracting' via ownedLiveExpedition, which
    sorts active rows first so expeditionCmdStart's run-spawn rollback still
    tears down the row it just created. This fixes dnd_setup.go for free: a
    leader who rerolled their character used to strand their whole party.

Note the review item this came from (C) was mis-stated: it claimed members and
leaders disagree during 'extracting' because expeditionForMember filters
status = 'active'. getActiveExpedition filters 'active' too, so they already
agree -- and honestly, since nobody is standing in the dungeon. Widening
expeditionForMember would have made the member the only player who can see a
paused expedition. Not done.

Abandoning now DMs the members, and says the true thing when the party is in
town rather than the dungeon (supplies already spent, loot already banked).

New: dnd_expedition_extract_sweep_test.go, 6 cases.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:14:59 -07:00
prosolis
d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
A. An armed ability lasted one round of a turn-based fight.

buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.

Split arming into its two halves:

  consumeArmedAbility(c)          mutates: disarms, saves, returns the id. Once,
                                  at fight start.
  applyAbilityByID(c, id, mods)   pure: no DB write, no disarm. Safe on every
                                  rebuild. (No ability's Apply writes to the
                                  character, so this really is pure.)
  armAbilityForFight(c, mods)     consume + apply, for the auto-resolve callers
                                  that build and fight in one breath.

buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.

The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.

Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.

B. Six unlocked read-modify-writes against the shared supply pool.

updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.

All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.

Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:01:51 -07:00
prosolis
1f211564d9 Review fixes: party close-out, member soft-lock, supply race, elite loot, season crown
Five bugs found reviewing n1-restoration end to end.

beginCombatTurn settles any phase the engine owes before reading whose turn it
is. That settle can end the fight — and the old code then answered "you're not
in a fight" and returned. The terminal status was already persisted, so nothing
ever paid the party out: no XP, no loot, no death recorded, no run teardown. The
reaper cannot recover it either, because listExpiredCombatSessions filters on
status='active'. Close the fight out there, the way the !fight start path and
the reaper already do.

A party member was permanently soft-locked when their leader extracted and never
resumed. seatedExpeditionFor (the guard) spans 'extracting'; expeditionForMember
(what !expedition leave resolved through) saw only 'active'. So the member was
refused any new adventure by the guard and told "No active expedition" by the
command the guard points them at, with nothing sweeping stale rows and only the
leader able to clear one. Resolve the exit through the same lookup as the gate.

updateSupplies overwrites supplies_json wholesale, and expeditionCmdAccept folded
a member's packs onto a snapshot read before the coin debit, unlocked. Handlers
run one goroutine per event, so two invitees accepting genuinely interleave and
one member's packs vanish. advUserLock cannot help — it is keyed by sender, so
racing members take different mutexes. Add advExpeditionLock and re-read the pool
under it. Closes accept-vs-accept; the six other updateSupplies callers still
race and are written up separately.

runHarvestInterrupt picked an elite enemy and elite narration off a local `elite`
flag, then passed a hardcoded false as isElite to closeOutZoneWin. dropZoneLoot
gates masterwork on isBoss||isElite, so beating an elite interrupt skipped the
masterwork roll and took standard treasure weight — while the same elite fought
via !zone paid out correctly.

arenaSeasonRollover marked its job complete even when recordArenaSeasonTitle
failed, and JobCompleted short-circuits every later run for that quarter, so a
transient SQLite BUSY lost the crown forever. Defer completion on failure; the
insert is ON CONFLICT DO NOTHING against PRIMARY KEY (season, kind) and a past
season's data is frozen, so the retry is safe.

Also: drop dead partySurvivors, collapse the zoneCombatRoster alias into
fightRoster, route partyCasualtyLine through joinNames, fold four copies of the
expedition column projection into expeditionSelectCols, stop replyDM sending a
blank DM, and correct two doc comments describing a path that no longer exists.

Deliberately not fixed, with reasons, in gogobee_code_review_followups.md — most
notably that both turn-based close-outs skip grantCombatAchievements and
persistDnDPostCombatSubclass, which the auto-resolve paths run.
2026-07-10 07:20:14 -07:00
prosolis
3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00
prosolis
08d3053368 N3/P6e: a party that fights every room
Only elite and boss doorways seated a roster. Everything else -- exploration
rooms, patrol encounters, harvest interrupts -- resolved through SimulateCombat
against ctx.Sender, and P6d made the walk commands leader-only. So on a 38-room
T5 expedition a party of three fought together twice and the leader soloed the
other ~35, then died alone while two untouched members stood at full HP.

The plan said the N-body core was already there and only the callers passed one
player. It wasn't: SimulateCombat built a one-seat roster internally. But the
resolution primitives already read st.c -- the cursor's Combatant -- because the
turn engine has called them that way since P3. Only the round loop needed
widening.

combat_engine_party.go carries it: simulateParty, simulatePartyRound,
roundInitiative, enemyTargetSeat. Every roster short-circuit collapses for one
seat, copying P3's solo exemptions, so the RNG draw order is unchanged and
SimulateCombat is now simulateParty([]Combatant{p}, ...).Seats[0].
TestCombatCharacterization is byte-identical; TestSimulateCombat_IsTheOneSeatPartyCase
pins the delegation event-for-event across 40 seeds.

zone_combat_party.go carries the callers' half: runZoneCombatRoster fans out the
character-scoped close-out (HP, XP, achievements, subclass, heal items burned,
Misty's repair) per seat, while loot, threat, kill records and death stay with
whoever knows the room. runZoneCombat remains the explicit solo entry point --
the arena calls it, and an arena bout must never drag in a party.

Death is read per seat off HP, never off the fight's terminal status: a timed-out
party can still have lost somebody, and a solo player at 0 HP has already ended
the fight, so PlayerEndHP <= 0 is exactly the old !TimedOut rule.

Preserved deliberately: a solo player can win at 0 HP (a retaliate aura kills the
swinger on the killing blow, and resolvePlayerAttack returns before enemyDown is
consumed) and is not marked dead. A party marks its downed seats dead on a win,
which is what finishPartyWin always did.

Solo T5 re-sweep is unregressed (fighter 47-73%, cleric 20-33%). Party of 3 now
clears 100% of every T5 cell, which is P8's problem: the enemy takes one turn per
round and swings at one seat, so a party of N deals xN damage and each member
takes ~1/N^2 of the solo incoming. An HP scalar cannot close that -- it restores
the fight's duration, not the enemy's action economy.
2026-07-10 06:32:29 -07:00
prosolis
32e3148755 N3/P7: a party that only fights together twice
Adds -party N / -party-classes to expedition-sim. Followers are seated
through the real !expedition invite / !expedition accept pair, so the
harness measures the tier gate, the busy guard and the supply pooling
rather than a roster hand-built to succeed. A follower who is refused
halts the run: a party reading taken from a walk that was secretly solo
is worse than no reading.

It immediately found what it was built to find. Only elite and boss
doorways seat the roster; exploration rooms, patrols and harvest
interrupts all resolve through SimulateCombat against ctx.Sender, and
P6d made the walk commands leader-only. So on a 38-room T5 expedition
the party fights 2-3 rooms together and the leader solos the rest -
then dies alone, tearing down the run rows only they own.

Measured at L15/16 over dragons_lair + abyss_portal, party of 3, n=15
per cell: zero TPKs and zero member deaths across 240 seats. Every
failure is the leader falling while two untouched members stand at full
HP. Fighter clears 100% (solo: 47-67%), cleric 33-53% (solo: 13-47%).
The band is unreadable until inline combat seats the party, so the C1
contingency - +35% monster HP per member - stays on the shelf; it would
punish the trash the leader already fights alone.

Hence the outcome vocabulary grows a third word. "tpk" used to mean any
run-ending event, which is how a leader dying beside a healthy party
stayed invisible through P5 and P6. Now: tpk (roster dead),
leader_down, fled (run over, leader alive) - read off the death flag,
not off HP, which the close-out leaves anywhere. A one-seat roster
makes leader-dead and all-dead the same predicate, so solo keeps its
labels for a real death.

Two bugs found in review before this landed:

  - An unknown class was not an error anywhere: -class fightr built a
    1-HP character and reported an ordinary outcome for it. Guarded in
    BuildCharacter, not the flag parser, since the leader had the bug
    long before parties did.
  - The roster short-rest healed the dead - handleDnDShortRest does not
    gate on the death flag, so a member killed in a won boss fight got
    rested back above 0 and stopped counting as a casualty.

Golden byte-identical; go test ./... green.
2026-07-10 00:37:59 -07:00
prosolis
a063e0ccd0 N3/P6d: a party where every member can see the dungeon
Every ownership lookup in the adventure module keys on a user id, and a
party member owns neither the expedition row nor the zone run. So each
player-facing read quietly told them they were not playing.

Rewire them. Reads a member should see resolve through activeExpeditionFor
/ activeZoneRunFor. Leader-only actions answer with copy that names the
leader instead of denying the expedition. Three busy-guards had to start
refusing a member outright: !zone enter, !expedition start and !sell all
keyed on the sender's own row, so a seated member could open a private
dungeon, outfit a rival expedition, or run a shop from the boss room.

Four things the rewire itself exposed:

!resources looks like a read but seed-persists harvest nodes, and
saveHarvestNodes rewrites the entire region_state blob — kills, event
gates, temporal stack — last-write-wins. Reaching it as a member would
revert the leader's walk from a stale snapshot. Persist only for the owner;
seedRoomNodes is pure, so a member re-derives the same nodes.

!zone taunt moves the party's shared mood, which is intended and safe:
applyMoodEvent lands an atomic delta. Its neighbour applyMoodDecayIfStale
writes an absolute gm_mood from the caller's snapshot, and every command
takes the *sender's* lock — a member running it against the leader's run
holds the wrong mutex. The owner check now lives on that function.

A seat outlives status='active'. releaseParty deliberately skips the
seven-day 'extracting' limbo, so the roster persists while
activeExpeditionFor goes blind — long enough for a member to open a run
that wins every lookup once the leader !resumes. seatedExpeditionFor spans
both statuses; it is what the busy-guards ask.

!expedition run was still member-blind. It is the same walk as !zone
advance, reached by its other name.

isPartyMember replaces `run != nil && !isLeader`: activeZoneRunFor reports
isLeader=false for a player with no run anywhere, so the bare test sends a
solo player to go ask their leader.

Golden byte-identical; solo T1 expedition clears end-to-end.
2026-07-09 23:56:16 -07:00
prosolis
b333d05443 N3/P6c: a fight the whole party sits down for
`!fight` seats the expedition's roster instead of the one player who typed
it. Seat 0 is the leader, always: the session row is theirs, the lock is
theirs, and `!flee`, the fork, and `!extract` stay their call.

A monster that wins initiative now swings before anyone speaks. The session
layer used to park every new fight on a player_turn, which is true of the
hardcoded solo order and a lie about a party's -- the enemy would forfeit
round 1 and nobody would notice. `startPartyCombatSession` rolls the order
and sets the phase from it; `handleFightCmd` settles the round before it
announces, so the opening block narrates the hit rather than quietly
showing its damage.

Members were invisible to two commands that had no business ignoring them:
`!cast` queued a spell for "next combat" while its caster was standing in
one, and `!rest` healed a seated member to full mid boss fight. Both now
resolve through the party.

Nobody leaves without an answer. A downed member's `!fight` opens the
party's fight and tells them why they are not in it. The leader's `!extract`
reaches everyone it drags out of the dungeon, and everyone rolls for what
moved into their house while they were gone.

Supplies burn at 50% x N x 4/5 -- a party eats more than one and less than
N. The ratio is exact: 0.8 as a float truncates a party of three to 119%,
a permanent tax nobody would have found.

Solo is untouched, byte for byte. One seat means one build, one INSERT, no
participant rows, the same RNG draws in the same order -- the combat
characterization golden does not move, and neither does the balance corpus.
2026-07-09 23:23:17 -07:00
prosolis
1928f75c19 N3/P6b: a party you can actually ask someone to join
!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.
2026-07-09 22:30:11 -07:00
prosolis
0f144fa335 N3/P6a: let a member find the run they're standing in
Every ownership lookup in the adventure module keys on a user id, and a
party member owns no row: not the expedition, not the zone run. P4 gave
them activeExpeditionFor; this gives them activeZoneRunFor, and gives the
DM seams the audience they never had.

- activeZoneRunFor(user) -> (run, isLeader, err). An owner's lookup is
  exactly getActiveZoneRun, side effects and all -- in particular the
  §4.3 idle reap, which force-extracts the wrapping expedition. A member
  must never re-enter it, or glancing at the map would end the leader's
  run. Pinned.

- expeditionAudience / fanOutExpeditionDM. Briefing, recap and digest all
  DM'd id.UserID(e.UserID) alone. They now loop the roster, which
  partyMemberIDs collapses to exactly the owner when there is none -- so
  a solo expedition sends the same bytes to the same user it always has.
  The briefing's body is expedition-scoped but its pet prefix is not:
  each member has their own pet and their own sheet, so the roll rides a
  per-reader decorator (the shape P5 settled on for combat narration).
  The digest's A6 event anchor rolls per member for the same reason.

- releaseParty on every terminal transition. A seated member is barred
  from adventuring elsewhere, so a roster outliving its expedition
  strands the party. Deliberately NOT on 'extracting': that is a 7-day
  resumable limbo and !resume must bring everyone back. The roster clears
  when the window lapses to 'failed', which routes through
  completeExpedition like the rest.

Rosters are still empty in production -- nothing seats a member yet -- so
every loop here has exactly one element and the whole change is a no-op
until P6b. Golden byte-identical, go test ./... green.
2026-07-09 22:18:40 -07:00
prosolis
e8d06195ac N3/P5: a fight that knows whose turn it is
A solo fight is a conversation: the player types, the engine answers, and
nothing happens in between. A party fight is a queue, and three things follow.

Turn ownership. Only the seat on the clock may act, so beginCombatTurn resolves
the sender's seat and refuses the rest before anyone spends a slot or burns an
item. A fight lock, because three members typing !attack at once took three
different user locks and the check would have passed for all three: it takes the
fight's lock (keyed on seat 0) then the member's own, always in that order, and
a solo fight -- whose owner is the sender, and sync.Mutex is not reentrant --
takes exactly the one lock it always took. And a turn deadline, because one
member who wanders off must not freeze the other two for the hour it takes the
session reaper to wake up.

The deadline is three minutes, not the plan's sixty seconds. The sweep rides the
existing one-minute ticker, so any deadline really fires in [d, d+1m); and
expeditions here run for days, so the asymmetry favours patience over robbing
someone of their boss turn while they read the room on their phone. A lapse
latches that seat onto the auto-picker for the rest of the fight, so an absent
member costs the party one wait rather than one per round. Typing anything hands
the wheel back. Solo is never swept.

Three seat-0 leaks fixed on the way past, all of which would have surfaced as
the leader quietly doing everyone's business:

  - mid-fight buffs folded into the session's embedded ActorStatuses, so a
    member casting Shield on themselves would have armoured the leader;
  - pickAutoCombatAction read sess.PlayerHP and Statuses.ConcentrationDmg, so
    playing an away member's turn would have healed the wrong person and
    re-armed the wrong aura;
  - runCombatRound rested on any player_turn, and a downed seat still holds one
    -- the round would have come to rest on a corpse and waited for a dead
    member to type !attack. settleCombatSession drains it. beginCombatTurn
    settles before reading the clock, which also fixes a latent solo bug: a
    fight interrupted mid enemy_turn resumed parked there and silently ate the
    player's next !attack.

The narration turned out to be written in the second person -- "You score 9
damage", "A hit gets through your guard" -- so swapping a name per seat would
have told three people they each landed the same blow. A round is rendered once
per reader instead: your own events go through the untouched flavor pool, your
allies' through a terse third-person summary. CombatEvent carries the seat to
make that possible, stamped once per phase step rather than at the twenty-odd
append sites in the primitives, which emit against the cursor and know nothing
of seats.

Closing out fans along the seam the data model already cut. Threat, the
zone-kill record, the boss-defeat drop and the run teardown all resolve through
getActiveExpedition or getActiveZoneRun, and a member owns neither row -- so
they fire once, for the owner. Fanning them out would have tripled the threat a
single kill costs. HP, XP, loot and death are the character's, and every seat
gets their own. A member can be dead in a fight the party won, so death is read
per seat off HP, not off the session's status.

The reaper stays attack-only. Finishing an abandoned fight should not quietly
burn the player's spell slots and potions; the deadline latch does use the
picker, because that member is mid-fight with a party waiting on them.

startPartyCombatSession has no production caller yet -- handleFightCmd still
opens a solo session. P6 seats the party.

TestCombatCharacterization is byte-identical: solo balance did not move.
2026-07-09 22:07:20 -07:00
prosolis
d7d0230223 N3/P4: give every seat a row of its own
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.
2026-07-09 21:23:35 -07:00
prosolis
ec614e84f1 N3/P3: initiative, and a turn engine that seats a party
The turn engine ran a fixed player -> enemy -> round_end phase machine over
one player and one monster. It now runs a round as a sequence of seats.

turnOrder derives that sequence per round. A solo roster short-circuits to
the historical [player, enemy] and rolls nothing -- the duel has never had
initiative, and handing the monster a coin flip on who swings first would be
a live balance change. A party rolls it with the auto-resolve engine's own
formula (speed + d10 + InitiativeBias).

Every seat in a round shares a (round, phase) pair, so the acting seat is
mixed into the RNG *seed* rather than the stream. Seat 0 and the enemy
sentinel mix to nothing, which is what keeps a solo fight drawing exactly
the pre-roster stream across a suspend/resume.

The round cursor persists as Statuses.TurnIdx, omitempty so no solo row
carries it. A fight that was in flight when the field landed decodes it as
0; turnIdxForPhase reconciles that against Phase, which is the older and
load-bearing field. Without it, a suspended enemy_turn would resume, step,
and land back on enemy_turn forever.

The enemy now picks a target uniformly among the standing roster (solo draws
nothing), a downed seat forfeits its turn silently, and the fight is lost
only when anyAlive() goes false -- not when the acting seat drops. That last
one fixes a latent solo bug on the way past: resolvePlayerSwings returns
false when a retaliate aura kills the swinger between extra attacks, and the
old code walked that corpse into the enemy's turn.

commit() reads seat 0 explicitly instead of the cursor, which the enemy turn
parks on its target and round_end walks across the roster.

TestCombatCharacterization is byte-identical: solo balance did not move.
2026-07-09 20:43:37 -07:00
prosolis
41f98b721a N3/P2: give the combat engine an N-player roster
Splits combatState into a fight-scoped half and a per-character half.
Everything that belongs to one PC -- HP, ward/spore/reflect charges,
heal charges, poison ticks, the death save, Lucky/Rage, the
first-attack one-shots, the arcane ward, concentration, and the
debuffs an enemy stacks onto a specific character -- moves to a new
`actor`. What belongs to the fight stays: the enemy pool, the enemy's
stance (evade/block/advantage/retaliate/regen/survive), the round
counter, the event log, and the RNG stream.

combatState embeds *actor, so the promoted fields keep their names and
all ~230 existing reads (st.playerHP, st.wardCharges, ...) compile
untouched. The embedded pointer is a cursor: seat(i) points it at a
roster member. Solo seats one actor and never moves the cursor, so the
draw order off the single RNG stream is unchanged.

That is the whole point. TestCombatCharacterization -- 57 scenarios x
5 seeds, 7468 pinned golden lines -- is byte-identical before and
after. Solo combat provably did not move, so the d8prereq balance
corpus survives the parties work and only party bands need new
baselines in P7.

Hold-person is fight-scoped (holding the enemy holds it for everyone)
while stat_drain/debuff/max_hp_drain are per-character, which is why
they landed on opposite sides of the split.

No multi-actor *semantics* here: nothing yet decides who the enemy
swings at or how initiative interleaves N players. That is P3. This
commit only lands the data model, and the roster tests cover what the
solo golden structurally cannot see -- cursor isolation, shared-state
visibility across seats, and the pointer embed (a value embed would
silently copy on seat() and fail the round-trip assertion).
2026-07-09 20:29:05 -07:00
prosolis
fc0dff710e N3/P1: widen the combat characterization golden
The roster refactor (N3 parties) has to prove it doesn't move solo
combat, or the d8prereq balance corpus dies with it. The existing
golden pinned 22 scenarios; it missed everything the refactor is most
likely to disturb:

  - ExtraAttacks (the lever J1 moved) and the rest of the 2026-05-16
    class-identity mods: sneak attack, hunter's mark, divine strike,
    thorn lash, crit threshold, first-attack bonus, assassinate,
    berserker rage, spirit weapon, arcane ward, heal charges
  - the race passives (lucky reroll, orc rage, poison/fire resist)
  - all 13 Phase 13 bestiary slice 3+4 effects, none of which had a
    pin: evade, block, advantage, retaliate, regenerate, survive_at_1,
    stat_drain, debuff, max_hp_drain, spell_resist, reveal_action,
    fear_immune, ally_buff
  - phase-scoped ability gating and the environment hazard path

22 -> 57 scenarios, 2047 -> 7468 golden lines. The pre-existing golden
is a byte-exact prefix of the new one, so no existing pin moved.

Enemies are sized per scenario (tanky/hardHit) so per-hit and
per-swing riders accumulate instead of dying in the opening round, and
abilities proc at 1.0 so the pin captures the effect, not the roll.
Verified every new scenario emits its distinctive event.
2026-07-09 20:21:17 -07:00
prosolis
ae5762fc91 R2: !revisit <N> -- walk back one edge, for +1 threat
Retires forward-only navigation. `!revisit <N>` (alias `!zone revisit`)
walks one graph edge back to a room in VisitedNodes, chosen by the number
the player reads off !map's Path strip. Edges are directed, but a corridor
you walked down is a corridor you can walk back up, so adjacency checks
both directions. !map now prints the legal targets.

Cost is +1 threat, not the discount the plan specced. There was nothing to
discount: movement charges neither threat nor supplies, and a cleared room
fires no combat, so backtracking was free. +1 mirrors harvest noise -- less
than a fight (+5) or an elite (+8). Standalone runs have no threat clock and
pay nothing. A siege guard refuses the move at threat >= 99: siege is
one-way sticky, and a player must not be able to strand their own expedition
on a move they made to go pick up a herb.

The plan claimed terminal CombatSession rows already prevent re-triggering a
cleared room. They gate only Elite and Boss, at the doorway. An Exploration
room re-entered resolveCombatRoom unconditionally and a Trap room re-armed --
so revisit would have been an infinite farm: step back, advance, repeat.
resolveRoom now early-returns on an already-cleared room. No-op forward-only,
since advance clears a room only after resolving it.

Autopilot is ticker-driven with no on/off switch, so it would have walked the
player straight back out of the room they revisited. grantAutorunGrace stamps
last_autorun_at to buy one cooldown window. That is a stopgap; R5 still owes
the real policy.

Fork re-pick stays deferred to R3: revisit refuses while a fork is pending,
because advance and `!zone go` resolve node_choices without checking which
node the player is standing on.
2026-07-09 19:47:16 -07:00
prosolis
31e3d69d8d R1: split "where am I" from "how far have I walked"
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.
2026-07-09 19:36:24 -07:00
prosolis
adcc62112b N2/B1+B4+C4: tempering, the expedition achievement wing, arena seasons
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.
2026-07-09 19:22:10 -07:00
prosolis
0c603cfece appservice: recover undecryptable events with an m.room_key_request
An inbound event whose megolm session hadn't arrived yet was logged and
dropped. The keys are usually merely in flight — a to-device m.room_key racing
the room event — so the message was lost to a few hundred milliseconds.

On NoSessionFound, wait 3s for the session to land; if it doesn't, send an
m.room_key_request and wait 22s more, then decrypt and re-dispatch. A 55s
budget bounds the whole recovery so a permanently-unreadable event can't pin a
goroutine. Runs detached from the transaction context, which is cancelled the
moment we ACK — long before keys could arrive.

This duplicates cryptohelper.HandleEncrypted's own 3s/22s ladder on purpose:
that path is gated on a /sync token in the context, which appservice
transactions never carry, so wiring ch.ASEventProcessor would still drop these.

Note this does not touch the separate stale-megolm case, where a quiet room
stays unreadable until the sender's session rotates — no key request helps
there, and it self-heals.
2026-07-09 18:53:35 -07:00
prosolis
1adcd05dc7 cross-signing: persist the bot's identity instead of reminting it every start
Both session paths called GenerateAndUploadCrossSigningKeys unconditionally on
every start. That published a fresh cross-signing identity each time the bot
restarted, so every user's client saw the bot's verification break and had to
re-verify it. The freshly-minted recovery key was only ever logged, never kept,
so the identity couldn't be recovered either.

bootstrapCrossSigning now mints once, persists the recovery key to
DATA_DIR/cross_signing.json (0600), and on later starts reuses the stored
identity untouched. Two escape hatches, both normally unset:
CROSS_SIGNING_REGENERATE=1 forces exactly one remint (every user must then
re-verify), and CROSS_SIGNING_RECOVERY_KEY imports an identity created before
the bot persisted its own key.

Shared by the appservice and masdevice paths, which had drifted into two copies
of the same block.
2026-07-09 18:53:23 -07:00
prosolis
ba7b20dfe5 url previews: stop dropping thumbnails on body cap and HEAD-403 CDNs
Two independent causes, both silent:

LimitedBody returned an error once the cap was hit, so scrapeOG failed the
whole goquery parse on any page over 2 MiB — even though og: tags live in
<head>, near the top. Hitting the cap is a truncation, not a failure: return
io.EOF and let the parser decide whether it found what it needed. Sized
against the pages actually posted here (n=14): og:title landed within the
first 7 KiB on twelve, worst case ~600 KiB.

validateImageURL HEAD-probed the image and bailed on non-200. Some publisher
CDNs (dims.apnews.com among them) answer HEAD with 403 while serving the same
URL over GET, so their thumbnails were always dropped. Probe with HEAD first,
fall back to a ranged GET asking for the first KiB. A 206 declares the full
size in Content-Range's "/119070" suffix rather than Content-Length, so the
tracking-pixel filter reads size from there.
2026-07-09 18:52:32 -07:00
prosolis
b5493a0e79 N1/A4+A6: wire the stubbed milestone rewards, re-anchor mid-day events
A4 — the three "deferred to hookup" milestone grants now pay out:

  Long Game (T5 clear)   guaranteed Legendary via pickMagicItemForRarity
                         -> dropMagicItemLoot, rendered in a new
                         milestoneAward.Extra block.
  Survivalist (clean T3+) writes AdventureCharacter.Title and announces to
                         the games room. No schema bump — player_meta.title
                         already exists and saveAdvCharacter persists it.
  Two Weeks (day 15)     restocks 3 days of rations (clamped to Supplies.Max)
                         and grants 3 zone-tier consumables.

Two Weeks was specced as +5 max HP "via the expedition row". Dropped: combat
MaxHP comes from stats.HPBonus, built in combat_stats.go from gear/arena/
housing with no expedition in scope. Threading one through would leak an
expedition-only buff into the sim's balance corpus. A supply cache
self-expires with the run and needs no combat math.

A6 — mid-day events rolled 0.5%/player/day from a deferred ticker slot: one
sighting per ~200 days. The roll and its roll-minute scheduler are gone.
Events now fire where the player is demonstrably present and reading a DM:
the end-of-day digest (8%), a sale at Thom's (5%), and an arena cashout (5%),
capped at one event per player per UTC day. A player who hits all three lands
at ~1.19/week. tryTriggerEvent returns bool so a bail (dead / mid-fight /
event already active) hands the day's slot back rather than burning it.

The frequency test drives its own seeded PCG over the chance constants and
the daily cap, so it measures the policy and can't flake on global RNG order.
2026-07-09 18:49:19 -07:00
prosolis
c9df282fde N1/A5: ticket sales fund the community pot
`!lottery pot` has always claimed "pot is funded by ... ticket sales", and
the draw debits prizes from that pot -- but handleLotteryBuy only debited
the player, so ticket euros were burned and the lottery ran as a pure drain
on rake income from elsewhere.

Credit the pot after lotteryInsertTickets succeeds, so the refund path can't
strand money in it.

Watch pot-drain-vs-rake per project_lottery_economics: if weekly pot growth
jumps, tune the prize tiers rather than the ticket price.
2026-07-09 18:32:23 -07:00
prosolis
e8f4863ae0 N1/A1-A3: rewire the drop systems Phase R orphaned
Treasure, masterwork, and consumable drops each had zero call sites: they
only ever fired from the legacy daily activity loop, which adventure.go now
intercepts with a deprecation DM. Hook all three to the zone-combat seam.

A1 - treasure: rollAdvTreasureDropDetailed takes a weight, applied to the
base rate. Boss x4, elite x2, standard x1, plus one x1 roll on zone clear.
Near-miss DMs now fire only for weighted moments; at x1 on autopilot they'd
land on ~3% of every kill.

A2 - masterwork: the catalog is keyed to mining/fishing/foraging, so the
dungeon lookup returned nil and the hook would have been a silent no-op.
masterworkDefForZone rolls across all three slot lines at the zone's tier.
Flavor now follows loc.Activity rather than the item's catalog line, with a
new dungeon pool - a crypt boss must not narrate a pickaxe striking ore.

A3 - consumables + ingredients: the audit found more than the four named
ingredients were stranded. generateAdvLoot is reachable only from
resolveDungeonAction, which has no callers, so all four legacy loot tables
were dead and every one of the 12 recipes was uncraftable. rollZoneIngredient
draws from those tables directly at the zone's tier, reviving them wholesale
rather than re-keying 24 ingredients into the per-zone slates.
2026-07-09 18:29:31 -07:00
prosolis
a4f162d2ee appservice: heartbeat presence every 20s to beat Synapse's 30s offline timeout (was flapping at 60s) 2026-07-04 10:42:55 -07:00
prosolis
0f82a088f9 appservice: start presence heartbeat before plugin init so bot shows online from boot 2026-07-04 10:36:44 -07:00
prosolis
d3f42009f7 appservice: heartbeat presence online so bot shows green while running 2026-07-04 09:54:46 -07:00
prosolis
6387380d0a Add appservice auth mode behind AUTH_MODE; land device grant
Two things, entangled in the working tree because 20d1f92 was mislabeled
(its message claimed "device grant" but it only deleted registration.yaml.example
and left the failed appservice+/sync hybrid in client.go):

1. Land the MAS OAuth 2.0 device grant that was running in prod but never
   committed: masauth.go + client.go rewrite. Bot stays a normal user so /sync
   works; refresh token persisted in data/mas_auth.json.

2. Add "appservice" as an alternate transport behind AUTH_MODE (default
   "masdevice" = unchanged device-grant path, instant rollback):
   - internal/bot/session.go: Session abstraction unifying both transports
     (OnEventType/Run/Stop); NewSession dispatches on AuthMode.
   - internal/bot/appservice.go: as_token auth (MAS-durable, no login/MFA/expiry),
     cryptohelper MSC4190 device creation, crypto machine fed from Synapse
     transaction pushes (to-device/device-lists/OTK) instead of /sync, inbound
     decrypt+redispatch, and lazy per-room StateStore backfill (encryption +
     members) so replies in encrypted rooms encrypt to the right recipients.
   - main.go: NewSession/sess.OnEventType/sess.Run in place of the /sync loop.
   - .env.example: AUTH_MODE, AS_REGISTRATION, AS_LISTEN_HOST/PORT, HOMESERVER_DOMAIN.

The appservice path fixes the earlier hybrid's fatal flaw (Synapse forbids AS
users from /sync) by using the transaction/push model. Bot-side only; Synapse
registration + experimental_features (msc2409/msc3202/msc4190) and E2EE cutover
are the next steps. Build + vet green (CGO=1 -tags goolm).
2026-07-03 17:11:58 -07:00
prosolis
20d1f92f97 Replace appservice auth with MAS OAuth 2.0 device grant
The appservice approach was wrong for a /sync bot: Synapse forbids
appservice-namespace users from /sync (sync.py raises NotImplementedError,
'we no longer support AS users using /sync'), so the bot received no events.

Authenticate instead via the MAS OAuth 2.0 device grant (RFC 8628):
- internal/bot/masauth.go: discover MAS endpoints from well-known+OIDC,
  dynamic client registration, device-code flow (prints approval URL once),
  persist rotating refresh token in data/mas_auth.json, refresh access token.
- internal/bot/client.go: obtain token via device grant, keep bot a NORMAL
  user (so /sync works), background refresher updates the live client, E2EE
  via cryptohelper on a fresh device.
- main.go: drop AS_TOKEN; remove registration.yaml.example; docs updated.

First run needs a one-time browser approval as the bot user; thereafter the
bot refreshes silently.
2026-07-03 15:40:26 -07:00
prosolis
48330be3d5 Migrate Matrix auth to appservice (MAS-durable)
Replace password login + password-UIA cross-signing with appservice
as_token auth and MSC4190 device creation, so the bot survives the
Matrix Authentication Service (MAS) migration that removes m.login.password
and UIA.

- internal/bot/client.go: NewClient uses AS_TOKEN, SetAppServiceUserID,
  whoami validation, cryptohelper MSC4190 device create; drop device.json
  (crypto store persists device id); cross-signing best-effort/soft-fail.
- main.go: Config.Password -> ASToken (reads AS_TOKEN).
- internal/util/auth.go: deleted (password login dead in a MAS world).
- Bump mautrix-go v0.28.0 -> v0.28.1.
- registration.yaml.example + README/.env.example: appservice setup docs.
2026-07-03 15:12:47 -07:00
prosolis
c07d228be6 Merge email-nag into main 2026-06-28 17:40:16 -07:00
prosolis
f93fddd1d6 Add email-nag plugin: collect+verify missing Authentik emails over Matrix DM
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.
2026-06-28 17:39:29 -07:00
prosolis
7c19788b52 Merge long-expeditions-d1 into main
Brings the J3 / long-expedition track up to main, including link-thumbnail
og:image previews + WOTD default-off.
2026-06-24 23:17:56 -07:00
prosolis
8122973b74 Link thumbnails: post og:image previews + WOTD default-off
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.
2026-06-24 22:00:09 -07:00
prosolis
cbfca525f5 Lift caster trailers: concentration re-tick + Josie caster-aid bootstraps
Diagnosed a cleric "death loop" (L14 dying at T2/T3 bosses while
over-levelled): the boss isn't overtuned — caster sustained DPS is
under-delivered, compounded by a fragile healer build.

Engine fix — concentration AOE re-tick:
- Concentration damage spells (spirit_guardians, heat_metal, spike_growth,
  call_lightning, flaming_sphere) now tick the enemy every round at
  round_end instead of resolving as a one-shot, via a new
  CombatStatuses.ConcentrationDmg armed on cast and round-tripped through
  the turn engine. Closes the long-tracked turn-engine concentration gap;
  the burst still lands the casting round, then the aura lingers.
- Sim picker skips re-casting an already-active aura (models competent play
  and prevents a burst+aura double-dip). Re-baseline (n=30 sweep + n=100
  confirm): bard +47pp T3 (heat_metal), druid +3-7, cleric/mage flat,
  fighter unchanged — no regressions.

Player-data bootstraps (idempotent, run once on Init):
- bootstrapCasterSpellBackfill: ensureSpellsForCharacter only seeds an empty
  book, so defaults added after a character's roll never reach it. Backfill
  missing defaults into known+prepared for existing casters (gives the
  affected cleric inflict_wounds + a working healing_word, since her
  healing_word_spell is a dead alias).
- bootstrapGrantStarterPet: one-off L10 pet for an endgame player who never
  got the morning arrival roll; adds per-round proc damage + deflect.
- TestScenario_JosieCasterAid verifies both against a copy of the live DB,
  incl. idempotency.

Also fix a pre-existing wall-clock flake in
TestFireBriefings_EventAnchoredActivePlayerDelivers (start_date defaulted to
real now, filtering the row out when the suite runs after 06:00 UTC).
2026-06-18 06:34:16 -07:00
prosolis
f4a39b46e9 Fix expedition soft-lock: extract on run idle-timeout + auto-pick stale forks
getActiveZoneRun's 24h stale-run reaper abandoned the run but left the
wrapping expedition status='active' pointing at a dead run. The autopilot
then read run==nil and bailed while briefing/recap tickers kept firing, so
the player soft-locked at the last fork with no way to route on. The reaper
now force-extracts the wrapping active expedition (run-loss seam) when it
reaps that expedition's current run.

Root cause was a background fork: the autopilot can't pick for the player,
so the run idled all the way to the reaper. Add an 8h forkAutoPickTimeout —
a background fork left unanswered that long now auto-picks the first
unlocked route (same path as !zone go) and keeps walking; all-locked forks
are left for the player. Foreground !expedition run still always prompts.

Tests: idle-timeout extracts the expedition; stale fork takes the available
route; all-locked fork stays intact.
2026-06-18 00:28:05 -07:00
prosolis
6a47be34bc Honor IGNORED_BOTS env var: global sender ignore at dispatch
IGNORED_BOTS was set in .env but never read by any code; only the
URL-preview plugin filtered, and via a different unset var
(URL_PREVIEW_IGNORE_USERS). Parse IGNORED_BOTS in NewRegistry and
short-circuit DispatchMessage/DispatchReaction so ignored senders
(e.g. @pete:parodia.dev) are dropped before any plugin runs.
2026-06-05 19:26:49 -07:00
prosolis
667f87f9d0 Fix SQL errors for D&D-only players: seed player_meta on setup confirm
!setup confirm wrote only dnd_character, never the canonical player_meta
seed row. A player who built a character via !setup and played purely in
the D&D/expedition system (which writes inventory directly, bypassing
ensureCharacter) ended up with no player_meta — so every legacy-layer
command's loadAdvCharacter returned 'sql: no rows in result set'.

dndSetupConfirm now routes through ensureCharacter (which seeds
player_meta + tier-0 equipment on first contact) instead of a bare
loadAdvCharacter.

Also fix !wotd force: it was deleting from a non-existent
job_completed/job_key table (silent no-op that left forced re-posts
blocked by the stale dedup row). Now clears the real daily_prefetch
rows and checks the error.
2026-06-03 17:23:53 -07:00
prosolis
1b8d13e0dd J3 D11: T5 difficulty lift (leaders-define-band) + empty-EnemyID combat guard
Boss-only tuning at the L15/L16 mid-range (D8-f #2 had tested T5 at the L12
floor where everything walls). The two T5 zones needed opposite treatment:

- dragons_lair (infernax): impossible wall, leaders 0-2% -> HP 546->405,
  AC 22->20, frightful-presence stun 0.80->0.40, multiattack 49->42.
- abyss_portal (belaxath): leader faceroll 88-92% -> HP 262->300, AC 19->20,
  multiattack 40->41.

Final n=50: leaders 61% at L15 (both zones), 72-79% at L16 -- same
leaders-define-band shape as T4; casters ~0% (J3 caster-track gap, not
monster-tunable). Bosses are the sole binding lever (every run decided at the
boss; standard/elite pools already survivable).

Also harden handleFightCmd: a malformed bestiary entry with an empty ID was
silently persisting an enemy-less combat session that spun to autoDriveCombat's
200-round cap. Now treated as a bestiary miss (fail loud). Writeup:
sim_results/t5_findings.md (gitignored).
2026-05-28 21:55:34 -07:00
prosolis
d80b437525 J3 D10: T4/T5 anchor-room variety (2nd elite + trap per zone)
In-place node kind swaps on fork branches — no node added/removed, longest
path unchanged — adding one Elite + one Trap of anchor variety to each T4/T5
zone, placed to keep fork-choice risk/loot asymmetric:

  underdark   +Elite drow_gate (R2->R4 region-guardian)  +Trap silenced_chamber
  feywild     +Elite singing_orchard (grove branch)      +Trap mire_steps (marsh)
  dragons_lair+Elite coin_strewn_hall (treasure spur)    +Trap hidden_passage (hoard spur)
  abyss_portal+Elite wardens_hall (R3 guardian)          +Trap hush_corridor, seam_threshold

abyss_portal shipped with zero trap nodes; D10 gives it two. A/B sim corpus
(n=480/side): boss-clear flat (+1.5pp aggregate, per-cell within n=15 noise),
median day-counts stable, combats/run rose where anchors hit common paths.
New Test*Graph_*Anchors tests lock the per-zone trap/elite counts.
2026-05-28 20:00:09 -07:00
prosolis
4934383a9a J3 D8-f #2: T4 difficulty lift (leaders-define-band) + feywild fork1 soft-lock fix
T4 monster tuning so martial leaders land in the 60-75% band (underdark
59/80, feywild 65/84 at L10/L12, n=50); casters trail (class-side gap that
monster tuning provably can't compress -- Pass 1 showed casters pinned at 0%
while martials moved). T5 deferred (walls everyone at its L12 floor; needs an
L15-16 corpus).

- dnd_bestiary.go: underdark elite/boss HP+AC up + caster-lethal proc cuts
  (mind_flayer/drow_mage/roper); feywild HP+AC up.
- bestiary_srd.go: feywild multiattack profiles (fomorian/night_hag/green_hag)
  + Thornmother 2->3 lashes -- HP/AC alone didn't move feywild's low-damage
  roster; multiattack is what pulls facerolling martials into band.
- zone_graph_feywild_crossing.go: free fork1's marsh edge. fork1 was the only
  fork in the game with every exit skill-locked (CHA+Perception, no LockNone)
  and deterministic no-retry rolls -- a prod SOFT-LOCK stranding ~60% of
  players (low CHA+WIS). Kept grove's CHA bargain as a bonus route.
- expedition_sim.go: firstUnlockedForkChoice -- sim fork policy picks the
  first unlocked option instead of blind 'go 1' (which looped forever on
  locked forks); halts fork_all_locked if none.
- tests: graph-wide TestZoneGraphs_NoSoftLockedFork + fork1 free-path guard.

Writeup: sim_results/d8f_findings.md
2026-05-28 19:02:58 -07:00
prosolis
a46b773750 J3 D8-f #1: route prod autopilot boss/elite through the turn engine
Prod autopilot resolved boss/elite fights inline via SimulateCombat, which
swings the enemy once per round (Combatant has no ID to look up the SRD
multiattack profile). Manual !fight uses the turn engine, which loops the
full profile — so autopilot players faced strictly weaker bosses than
manual. D8-e confirmed this is the gap, not a turn-engine artifact.

- Promote the sim's autoResolveCombat/simPickCombatAction to shared plugin
  methods autoDriveCombat/pickAutoCombatAction (single source of truth; the
  sim now calls the same code prod does).
- Add MessageContext.Silent + a replyDM helper; the turn-engine combat
  handlers route their DMs through it so the background autopilot can drive
  the real !fight/!attack engine without spamming a DM per round (the EoD
  digest summarizes the outcome).
- tryAutoRun now calls runAutopilotWalkDriven (inlineBossCombat flipped
  true->false): walk->fight->walk loop so one tick still covers ~autoRunRoomCap
  rooms, but boss AND elite now face the player's full kit against the
  enemy's full multiattack. Loss surfaces as stopEnded (run already
  force-extracted by finishCombatSession).

Trash mobs stay on the fast inline path. GOGOBEE_SIM_INLINE_BOSS=1 A/B
toggle preserved. Build + plugin tests green; sim smoke-run unchanged.
2026-05-28 15:30:48 -07:00
prosolis
b80de43db1 J3 D8-e: GOGOBEE_SIM_INLINE_BOSS toggle for engine A/B
Diagnostic env toggle (off by default) routing the sim's boss/elite
doorways through the inline SimulateCombat path instead of the turn
engine, for A/B-ing the martial T4/T5 'regression'. D8-e confirmed the
gap is honest multiattack math (inline swings the enemy once/round; the
turn engine loops the full SRD profile) and that prod autopilot is
secretly easier than manual !fight. Toggle left in for the D8-f parity
work.
2026-05-28 15:08:16 -07:00
prosolis
81dda51907 J3 D8-d-fix: caster AC floor + HP ×1.25
computeAC: lift caster floors (cleric/druid 3→5, bard/warlock 1→3,
mage/sorcerer 0→2). Ranger 3 / rogue 1 / fighter+paladin 6 unchanged.

computeMaxHP: new casterHPMult(class) = 1.25 for the same caster set,
applied multiplicatively on top of phase5BHPMult. Martials unchanged.
New caster L10 HPMax: mage/sorc ~98 (was 78), bard/cleric/druid/warlock
~118 (was 95); martial L10 ~141 unchanged.

bootstrap_caster_hp.go: new bootstrapCasterHPRefresh (job key
caster_hp_refresh_v1) refreshes existing rows once at startup; mirrors
bootstrapPhase5BHPRefresh — only raises HPMax, preserves absolute
wound. Wired in adventure.go.

Measured on n=2500 d8dfix2 corpus (gitignored sim_results) vs d8c:
- T3 manor caster lift confirmed — bard +13 (43→56), druid +8 (88→96),
  warlock +8, sorcerer +5, mage +5, cleric +2. Martials flat.
- Macro deltas: bard +2.6, warlock +2.0, druid +1.4, mage/sorcerer +1.0,
  cleric -0.2. n=500 1σ ≈ 2.2pp — bard/warlock cleanly past noise.
- T4 underdark wall still holds (every caster ~0%). Lift directionally
  correct but undersized for T4 atk-bonus + multiattack — separate
  follow-up.
- T5 dragons_lair universal wall unchanged (martials TPK too).

Diagnostic context: project_d8d_diagnostic. Cleric flat manor likely
the concentration AOE re-tick engine gap (filed for D8-e+).
2026-05-28 09:35:04 -07:00
prosolis
d7ced471a1 J3 D8-c: concentration ×3 multiplier in spellExpectedDamage
`spellExpectedDamage` (`dnd_class_balance.go`) now multiplies expected
damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave,
EffectDamageAuto}`. Attack-roll concentration excluded by design —
hex-style cases ride per-hit mods, not the score. Closes the picker
gap that walked past heat_metal / spike_growth / flaming_sphere /
cloud_of_daggers in favour of one-shot blasts of similar slot level.

Measured on n=5000 d8c corpus vs d8prereq baseline (sim_results,
gitignored): per-class means all within ±2.4pp (1σ ≈ 2.2pp); macro
leaderboard unchanged. Real picker swap lands at T3 manor_blackspire
— bard +11pp, mage +10pp, sorcerer +9pp, druid +5pp. T4/T5 caster
wall unchanged → confirms HP+AC is the binding constraint past T3
(D8-d-fix territory), not picker quality.

Side discovery: cleric flat / warlock −6pp manor — spirit_guardians-
style AOE-save concentration spells appear to resolve once in
SimulateCombat / turn-engine instead of re-ticking per round. The
multiplier is correct in expectation; the engine under-delivers.
Filed as separate follow-up; D8-c stays.

Plan §8 D8-c marked SHIPPED.
2026-05-28 08:14:13 -07:00
prosolis
63ad423b79 J3 D8-review: surface sim subprocess errors + SW upcast + doorway msg
- expedition-sim matrix worker now captures child stderr and dumps
  runErr/stderr/stdout-snippet on failure so halted rows have a cause.
- simPickSpiritualWeapon walks slots 2..5 and upcasts when L2 is spent
  instead of silently skipping the spell on high-level clerics.
- advanceOnceWithOpts !inlineBossCombat branch now emits the same
  "Room X/Y — Boss/Elite. Type !fight to engage." line as foreground.
2026-05-28 00:53:25 -07:00
prosolis
da94d51857 J3 D8-d: T4 caster wall diagnostic — HP+AC, not transit/heals
Plan update only. Diagnostic in sim_results/d8d_findings.md
(gitignored). n=3-5 per caster L10 underdark:

- Multi-region transit eliminated: all casters TPK in r1
  before any cross-region transit; Combats=0 (no elite/boss
  reached).
- Heal-stock not the lever: bumped simConsumableBundle T4
  2 -> 5 Spirit Tonics, +3-5 rooms median, 0/5 clears.
  Reverted.
- Confirmed lever: computeAC class floors (casters 11-14
  vs martial 16-17) + d6/d8 HP scaling.

Recommended next session: lift floors in computeAC
(dnd.go:195) — cleric/druid 3->5, bard/warlock 1->3,
mage/sorcerer 0->2. Validate vs d8prereq_corpus.
2026-05-28 00:41:28 -07:00
prosolis
151d6abc01 J3 D8-prereq: plan update — corpus diff + D8-c/d/e queued
Marks D8-prereq shipped with the 5000-run d8prereq_corpus measurements
(cleric +25.8, sorcerer +22.6, warlock +20.2, mage +17.8, druid +14.4,
bard +12.0 vs d7d). Caster/martial gap closed from ~40pp to ~22pp; T2/T3
zones are where the picker pays off.

Queues three follow-ups in §8: D8-c concentration-damage modeling, D8-d
T4 caster wall diagnostic (every caster still 0% at underdark — picker
no longer the suspect), D8-e martial T4/T5 regression triage (-6 to -18
pp from routing boss/elite through the turn-engine instead of inline
sim; likely inline was over-rosy, not turn-engine wrong).

Findings detail: sim_results/d8prereq_findings.md (local, sim_results/
is gitignored). Re-baseline corpus: sim_results/d8prereq_corpus.jsonl.
2026-05-28 00:18:29 -07:00
prosolis
631764bbbd J3 D8-prereq: split compact flag so sim drives the picker
Adds inlineBossCombat alongside compact in runAutopilotWalk and
advanceOnceWithOpts. Production background autorun keeps both true
(inline auto-resolve), foreground stays both false (manual !fight), the
sim now uses compact=true + inlineBossCombat=false so the boss/elite
doorway returns stopBoss/stopElite after the safety gate — autoResolveCombat
+ simPickCombatAction / simPickSpell drive the fight via the turn-based
engine. The picker (and D8-b upcasting) has been dead since D3's
compact-inline boss rooms; this re-wires it.

n=50/cell L10 smoke vs d7d (zones T1-T3):
  bard    forest_shadows  61 → 100   (+39)
  bard    manor_blackspire 10 →  34   (+24)
  cleric  forest_shadows  15 →  96   (+81)
  cleric  manor_blackspire 0 →  54   (+54)
  fighter T1-T3                100   (unchanged)

Also parallelizes matrix mode via subprocess workers (each child has its
own SQLite — db package globals preclude in-process parallelism). New
-jobs flag, defaults to runtime.NumCPU(). 8 workers gave ~7x speedup on
the smoke matrix.
2026-05-27 23:16:33 -07:00
prosolis
ad2a8258ba J3 D8-b: sim picker upcasting + discovery picker is dead since D3
simPickSpell now enumerates one candidate per available slot >= native
for every prepared damage spell, scored via spellExpectedDamage with the
existing "+1 die per slot" scaling. Cantrips contribute a single slot-0
candidate. Tie-break: highest slot first, then highest expDmg. Picker
returns "<id> --upcast N" when the winning slot exceeds native so
parseCombatCast upcasts in the engine. Build + plugin tests green.

Then: measured zero impact. d8b_corpus.jsonl (same 10x5x100 matrix as
d7d) lands every class within +/-1.5pp of d7d baseline.

Root cause discovered post-corpus: simPickSpell is dead code in the
current sim path. Commit 68ed8e7 ("Long expeditions D3: compact
autopilot auto-resolves boss rooms") added a !compact gate at
dnd_expedition_cmd.go:810 so compact autopilot inline-resolves boss/
elite rooms via resolveCombatRoom -> runZoneCombat -> SimulateCombat
instead of returning stopBoss/stopElite for autoResolveCombat to handle.
The sim uses compact=true (expedition_sim.go:433), so autoResolveCombat
- the only caller of simPickCombatAction/simPickSpell - never fires.
Verified empirically: a stderr-traced simPickSpell produced zero hits
across full bard/cleric L10 runs.

This rewrites the picker-era retrospective. J2's baseline_j2a_v2_all10
(bard 40%, cleric 39%) was measured before D3 with the picker live.
d7d's L10 baseline (bard 34%, cleric 21%) is post-D3 with the picker
disconnected - that 6-18pp drop is the post-D3 caster regression, not
"long-expedition mechanics didn't help casters" as d7d framed it.
D8-a's "+2.2pp cleric" was also noise (1sigma ~ 1.8pp on n=500).

D8-b code kept in tree (correct, currently inert, turns on as soon as
the wire reconnects). Plan rewritten in section 8: D8-b and D8-c are
deferred behind D8-prereq, which splits compact so the sim opts out of
compact-inline boss/elite combat without affecting prod rendering.

Files:
- internal/plugin/expedition_sim.go: simPickSpell upcast enumeration
- gogobee_long_expedition_plan.md: D8-b implemented-but-inert,
  D8-prereq added as next step
- sim_results/d8b_corpus.jsonl: 5000-row corpus retained for the
  picker-dead baseline
2026-05-27 22:27:04 -07:00
prosolis
2fdb280477 J3 D8-a: caster picker data fixes + Spiritual Weapon pick
Bard L1 += thunderwave, L2 += heat_metal; cleric L1 += inflict_wounds;
shatter overlay Classes broadened (defensive — mergeClassList already
unioned). New simPickSpiritualWeapon runs before simPickSpell so a
cleric with an L2 slot opens fights with the BuffSelf-tagged spell that
the regular picker (damage-effects only) was skipping; existing
spiritWeaponStrike per-round mace path lights up.

Measured L10 n=100/zone: cleric 21.0 → 23.2% (+2.2pp), bard within
noise. T3+ wall unchanged — picker upcasting + concentration-damage
modeling (D8-b/c) are the bigger levers, queued in plan §8.
2026-05-27 22:00:02 -07:00
prosolis
4576c75722 Long expeditions D7-d: corpus re-run + sim heavy-preset fix
Fix sim regression introduced by D5-b: bare `expedition start <zone>`
returns the loadout prompt DM without outfitting, so SimRunner.RunExpedition
was halting before persisting any expedition. Pass `heavy` from the
harness — tier-max packs, no prod paths touched.

D7-d corpus (sim_results/, gitignored): n=100 × 10 classes × 5 zones ×
L10. Leaderboard mirrors J2b — martials 78–82%, casters 21–42%, cluster
gap unchanged by long-expedition mechanics. Cleric worst at 21% (L10).
Bard/cleric trailers not relieved by autopilot camp pacing; remains
J3-territory. T3/T4 cleared runs hit their §2 target durations.

D5-d retune decision: no change. phase5BDailyBurnRatePct=50 + per-tier
DailyBurn stay as-is — heavy-preset cleared runs end with 78–98% SU
surplus; even TPK runs leave packs mostly full. Supply economy is not
a binding constraint at heavy preset.

Closes the long-expedition track.
2026-05-27 21:18:48 -07:00
prosolis
3b29d10461 Long expeditions D7-c: -days flag + per-day snapshots in SimResult
cmd/expedition-sim -days N caps runs by synthetic day rollovers
(Outcome="day_capped"). SimResult.DaySnapshots traces HP/SU/threat/rooms
at start, every Night-camp rollover, and end-of-run — unblocks empirical
D5-d retune of phase5BDailyBurnRatePct against per-day SU draws.
2026-05-27 20:47:28 -07:00
prosolis
29cad7972a Long expeditions D7-b: drive autopilot camp from SimRunner
maybeAutoCamp / pitchAutopilotCamp / pitchBossSafetyCamp now take a
now time.Time so the sim can inject a synthetic clock; tryAutoRun
still passes time.Now().UTC(). SimRunner.RunExpedition advances simNow
by autoRunCooldown per walk and runs the production camp scheduler
after each soft stop (and pitchBossSafetyCamp on stopBossSafety),
dwelling minAutoCampDwell + breakAutoCampIfDue so the next walk can
proceed. Effect: HP-low mid-day rests, base-camp waypoints, Night-camp
rollovers, and boss-safety holds all fire under the sim; D7-a's
tickEventAnchoredRollover shortcut is retained on TickDay for tests
and the pre-cutoff legacy path.
2026-05-27 20:40:04 -07:00
prosolis
a2992ea06c Long expeditions D7-a: teach SimRunner.TickDay event-anchored rollover
deliverBriefingEventAnchored reads time.Now().UTC() for its safety-net
check, so synthetic TickDay calls never advanced CurrentDay on D2-b
expeditions — DaysAtEnd / SUEnd stayed at start values. Short-circuit
in TickDay: when isEventAnchored, fire nightRolloverBurn → optional
applyCampRest (Standard, Rough fallback, skipped on low-SU) →
nightRolloverDrift(briefAt). Mirrors pitchAutopilotCamp Night=true.
Production paths untouched.

Unblocks D5-d retune of phase5BDailyBurnRatePct and the class corpus
re-run.
2026-05-27 20:15:54 -07:00
prosolis
6c4b14e113 Long expeditions D6: player-facing surface cleanup
Rewrite !expedition help around the autopilot loop, frame !camp/!fight
as overrides, and add Day X / ~Y expected + rooms/total + last-3-events
to !expedition status.
2026-05-27 20:01:50 -07:00
prosolis
9be85ba954 Long expeditions D5-c: wire Ranger forage SU
§4.2's "Ranger, 1d4 SU/day" perk had been dead since Phase 12 E1b —
SupplyForageMaxSU was defined but unreferenced, ForagedToday was only
ever reset to false. New applyRangerForage helper grants 1d4 SU once
per day (headroom-capped, Ranger-only), fires at the top of
nightRolloverBurn, and surfaces as a "forage" line in the end-of-day
digest. No DC roll — accessibility over crunch, and the D5-a caps
already give all loadouts comfortable headroom.
2026-05-27 19:48:44 -07:00
prosolis
26cda148fb Long expeditions D5-b: loadout preset prompt at launch
`!expedition start <zone>` (and `!resume`) with no pack arg now DMs a
Lean / Balanced / Heavy menu sized per zone tier. Player replies with
the preset name (short forms l/b/h also work). Raw `Ns Md` syntax stays
as the advanced override.

Heavy always equals supplyPackCaps (the D5-a harsh×3 ceiling); Lean
covers intended days at raw burn; Balanced sits between.
2026-05-27 19:41:49 -07:00
prosolis
040cfba514 Long expeditions D5-a: per-tier supply pack caps
Today's global SupplyPackStandardMax=3 / SupplyPackDeluxeMax=1 were
a 2-day shape; with D1's longer room budgets and D2-b's event-anchored
night burns, a T4/T5 player can't legally buy enough supplies for the
intended duration. supplyPackCaps(tier) now returns (std,dlx) per
tier — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2) —
sized to clear DailyBurn(raw) × intendedDays × 1.3 even with the
harsh×3 multiplier layered on. Validate takes a tier; both call sites
(!expedition start, !resume) pass the resolved zone's tier. Holiday
+1 standard pack still bypasses the cap on purpose. DailyBurn /
phase5BDailyBurnRatePct unchanged; that's a D7 lever once the sim
can measure event-anchored rollovers.
2026-05-27 19:33:57 -07:00
prosolis
aaec0ba225 Long expeditions D4-b: activity-anchored morning briefing
Event-anchored expeditions no longer pin their re-engagement DM to 06:00
UTC. The ticker skips idle players (last_activity older than today's
threshold, inside the 28h safety net); maybeDeliverDeferredBriefing fires
the owed briefing on the next inbound message in any room. The OnMessage
hook also stamps last_activity so chat presence (not just bot commands)
counts toward "the player is here."
2026-05-27 19:21:49 -07:00
prosolis
5a6e395805 Long expeditions D4-a: autopilot DM bundling + EoD digest
Suppress per-tick auto-walk DMs in compact mode. tryAutoRun now only
surfaces for fork / death / run-complete / boss-safety camp / Night-camp
pitch. A Night-camp pitch flushes the day as an EoD digest (counts of
walk/harvest/interrupt plus threat/milestone/narrative bullets, built
from dnd_expedition_log) followed by the camp block. Each successful
background walk writes a `walk` log entry so the digest can count rooms
without persisting raw stream narration. maybeAutoCamp and
pitchBossSafetyCamp now return the autoCampDecision so callers can
branch on dec.Night.
2026-05-27 19:07:37 -07:00
prosolis
68ed8e7c60 Long expeditions D3: compact autopilot auto-resolves boss rooms
Drops the boss carve-out in the compact (background autorun) path so
boss rooms resolve through the same forward-sim engine elites already
use. A `bossSafetyGate` (HP < 80% / supplies < daily burn /
exhaustion >= 3) guards the engage; when it trips, the walk returns
`stopBossSafety` and the autorun ticker force-pitches a rest camp via
`pitchBossSafetyCamp` (bypasses the normal scheduler's HP threshold
and its RoomBoss room-type block; keeps event-anchored night handling).

`resolveCombatRoom` now selects monster + label + loot drop by
`run.CurrentRoomType()` so the same callsite handles boss kills
(zone.Boss bestiary, "Boss — name down", boss-loot drop, elite-tier
threat bump). The walk loop only breaks at elite/boss doorways when
`!compact`; compact lets the next iteration auto-resolve.

Foreground `!fight` and `!expedition run` are unchanged. Sim path is
unaffected — stopBossSafety falls into the default soft-stop branch.
2026-05-27 18:56:52 -07:00
prosolis
c729433353 Long expeditions D2-b: event-anchored day rollover
Splits the legacy briefing body into nightRolloverBurn + nightRolloverDrift,
plus a processNightCamp convenience. For expeditions started after the new
eventAnchoredCutoff, the 06:00 UTC briefing stops mutating: it posts a
re-engagement DM, and only force-fires processNightCamp itself after a 28h
safety-net window. Day++/burn/threat-drift now ride along the autopilot
night-camp pitch (decideAutopilotCamp sets Night=true when ≥16h since the
last rollover) and on the first player !camp since the last rollover. The
legacy UTC-anchored flow still works via the same staged helpers, with
processOvernightCamp interleaved to preserve the rest-before-drift ordering.

Tests pin eventAnchoredCutoff to year 9999 in TestMain so the existing
legacy assertions still run; new tests cover the night decision, the
night-camp pitch advancing the day, the event-anchored skip, and the
safety-net force-rollover.
2026-05-27 18:42:57 -07:00
prosolis
7115c536ef Long expeditions D2-a: autopilot camp scheduler
New expedition_autocamp.go: pure decideAutopilotCamp + pitchAutopilotCamp
+ dwell-window lifecycle. Wired into tryAutoRun so the background ticker
pitches a rest camp on low HP and a base-camp waypoint on region-boss
clear; auto-pitched camps last minAutoCampDwell (4h) before the next tick
breaks them and walks. CampState.AutoPitched separates auto- vs player-
pitched camp lifetimes so a player !camp stays sticky.

Day-rollover semantics unchanged — still UTC-anchored; D2-b moves
day++/burn/threat-drift onto the camp-pitch event.
2026-05-27 18:23:26 -07:00
prosolis
42fb805ee0 Long expeditions D1-e: T5 zones to new 36-44 room band
Closes D1. Both T5 zones follow the D1-a/b/c/d pattern: extend the
graph so the longest entry→boss walk lands in band, keep the zone's
authored topology intact. Also backfills the missing per-node
RegionID authoring against dnd_expedition_region.go — the D1-d
deferral note flagged this as a prerequisite for T5.

  dragons_lair (T5): 12 → 47 nodes, longest 38 (band 36-44)
  abyss_portal (T5): 13 → 51 nodes, longest 39 (band 36-44)

Topology preserved per zone:
- Dragon's Lair: 4-region authoring (every node carries a valid
  RegionID); binary-converging fork1 (ash_bridge TRAP / treasure_vault
  Perception 15) still converges at the R3 wyrmlings_nest elite;
  capstone fork2 still 3-way (LockNone / CHA 16 / Perception 17
  SECRET LootBias 2.5) with each spur preserving its own named
  capstone node. New R4 infernax_doors MERGE consolidates the long
  final hall instead of triplicating it.
- Abyss Portal: 4-region authoring; three sequential forks preserved
  (binary / binary / ternary) — fork1 Perception 16, fork2 CON 16
  (full STR/DEX/CON/INT/WIS/CHA roster still covered), fork3 capstone
  3-way with reality_seam SECRET LootBias 3.0. New R4 belaxath_doors
  MERGE consolidates the final tear-approach.

MinRooms/MaxRooms re-pitched 9-10 → 36-44 so the dice fallback also
lands in band for any future graphless variant.
2026-05-27 18:13:33 -07:00
prosolis
d99f975074 Long expeditions D1-d: T4 zones to new 28-34 room band
Both T4 zones follow the D1-a/b/c pattern: extend the graph so the
longest entry→boss walk lands in band, keep the zone's authored
topology intact.

  underdark         (T4): 10 → 46 nodes, longest 30 (band 28-34)
  feywild_crossing  (T4):  9 → 53 nodes, longest 30 (band 28-34)

Topology preserved per zone:
- Underdark: 4-region authoring intact (every node carries a valid
  RegionID); 3-way fork1 (drow R2 / illithid R3 / deep_chasm R1)
  with LockNone / LockPerception / LockStatCheck CON; deep_chasm
  intentionally stays R1; all three arms converge at the R4
  throne_approach merge. Adds the missing Trap anchor (Collapsed
  Arch) in the R1 preamble.
- Feywild Crossing: woven forks intact — hag_circle still has two
  incoming edges (one per first-stage path), time_eddy stays
  grove-exclusive, illusion_garden stays marsh-exclusive, fork1
  remains CHA-vs-Perception with no LockNone option. Adds the
  missing Trap anchor (Cursed Thicket) and a fae_court MERGE so
  the three second-stage endings converge into one final boss
  approach instead of triplicating it.

MinRooms/MaxRooms re-pitched 8-10 → 28-34 so the dice fallback also
lands in band for any future graphless variant.

T5 zones (dragons_lair, abyss_portal) deferred to D1-e — those
need a RegionID backfill on top of the length extension since their
regionsByZone entries are not currently honored by their graphs.
2026-05-27 18:02:05 -07:00
prosolis
bbc25fe958 Long expeditions D1-c: T3 zones to new 22-26 room band
Both T3 zones follow the D1-a/b pattern: extend the graph so the longest
entry→boss walk lands in band, keep the zone's authored topology intact.

  manor_blackspire (T3): 11 → 35 nodes, longest 23 (band 22-26)
  underforge       (T3): 10 → 31 nodes, longest 23 (band 22-26)

Topology preserved per zone:
- Manor: two-stacked-3-way forks identity + full lock-kind coverage
  (LockNone, LockPerception, LockStatCheck, LockLevelMin). Adds the
  missing Trap anchor (Cursed Threshold). All six spokes are 3 mid-nodes
  so route choice is loot/encounter character, not shortcut economics.
- Underforge: one-way-descent gauntlet identity preserved — 17-node
  linear preamble through the existing Cooling River (TRAP) and Magma
  Chamber (ELITE), single 3-way antechamber fork before boss. All three
  antechamber spokes are 4 mid-nodes.

MinRooms/MaxRooms re-pitched 7-9 → 22-26 so the dice fallback also lands
in band for any future graphless variant.
2026-05-27 17:46:38 -07:00
prosolis
89edc76829 Long expeditions D1-b: crypt_valdris + T2 zones to new room bands
Three more zones follow the goblin_warrens pattern from D1-a: add the
missing Trap anchor and deepen the graph so the longest entry→boss walk
lands in its tier's target band.

  crypt_valdris  (T1):  8 → 17 nodes, longest 13 (band 12–14)
  forest_shadows (T2):  9 → 19 nodes, longest 16 (band 16–20)
  sunken_temple  (T2): 10 → 26 nodes, longest 16 (band 16–20)

Topology preserved per zone:
- Crypt diamond + Perception-DC-15 secret off side_chapel.
- Forest asymmetric main fork (long branch carries the elite) +
  WIS-DC-14 secret pre-boss.
- Sunken Temple sequential forks with no mid-path merge (four leaves
  all terminate at the single boss room).

MinRooms/MaxRooms re-pitched to the §2 bands so the dice fallback also
lands in band for any future graphless zone.
2026-05-27 17:32:29 -07:00
prosolis
4999368031 Long expeditions D1-a: graph-driven TotalRooms; goblin_warrens 7→16
Length is now sourced from the zone graph's longest entry→boss path,
not the dice roll, so "Room X/Y" matches what the player walks. The
dice fallback stays for graphless zones.

Goblin Warrens grows to the T1 12–14 band (13-node traversal): adds the
missing Trap anchor and deepens both fork branches + the post-merge
approach. Pattern reference for the remaining zones in D1-b…d.
2026-05-27 17:24:36 -07:00
prosolis
92b99a0399 Camp: heal on pitch; ambient cooldown 3h → 6h
!camp now applies long-rest effects (HP, spell slots, exhaustion,
threat -5, Underforge heat) immediately instead of waiting for the
06:00 UTC briefing. New CampState.RestApplied flag stops
processOvernightCamp from re-applying at briefing — it just strikes
the camp.

Also dials ambient cooldown 3h → 6h so a workday gives ~1–2 hits
instead of ~3.
2026-05-27 16:57:57 -07:00
prosolis
0f72484653 Idle reaper: use unified activity oracle, hold streak on autopilot
midnightReset gated shame DMs on LastActionDate + a fallback busy check
for active expedition / combat session. Players running on background
autopilot got shamed at midnight: dnd_zone_cmd.go gates markActedToday
on !compact (to keep autopilot out of streak credit), so LastActionDate
stayed stale; and if the autopilot's expedition completed / extracted /
got force-extracted before midnight, the busy check also missed it
(getActiveExpedition only matches status='active').

Switch the reaper to loadAdvDailyActivity — the same oracle the daily
report uses, which unions adventure_activity_log + dnd_zone_run +
dnd_expedition_log. Three explicit branches:

  - engaged (LastActionDate today/yesterday or legacy counters bumped)
    → streak bumps, LastActionDate restamped
  - activity-without-tap (autopilot beats, expedition log, or live
    active expedition/combat as safety net) → hold: no bump, no shame,
    no decay
  - truly idle → shame DM + streak halve

The old busy branch bumped streak; new behavior holds. Aligns autopilot
days with mid-fight-at-midnight days: both engaged earlier but typed
nothing today, neither pumps the streak.

Tests cover all three branches + the death-window early-return.
2026-05-23 10:30:47 -07:00
prosolis
2e6274c1b7 Spiritual Weapon: own channel + narration; rest blocked mid-zone
Spiritual Weapon used to ride the pet-attack channel, so a petless
cleric saw "🐾 Your faithful companion" each round and couldn't tell
the spell was firing. Split it to SpiritWeaponProc/Dmg with its own
 flavor; damage now scales with spell mod + upcast.

Rest also fired mid-dungeon — only the autorun honored RestingUntil,
the !rest commands themselves had no gate. Block both short and long
rest when an expedition or combat session is active.
2026-05-23 10:14:51 -07:00
prosolis
9eed921e4b Camp: auto-break when party moves to a different room
Camp was a stationary "rest here until the next briefing" intent, but
nothing struck it when the autopilot / !zone advance / fork picks walked
the party into a new room. The stale CampState then blocked !camp <type>
with "already camped" even though the player was several rooms away.

autoBreakCampOnMove clears the camp (no rest bonuses — those only land
at briefing time via processOvernightCamp) whenever Camp.RoomIndex no
longer matches run.CurrentRoom. Wired into advanceOnceWithOpts (covers
advance/autopilot, complete/fork/next-room branches all surface a
" Camp struck" banner) and zoneCmdGo (fork commit).
2026-05-23 10:05:19 -07:00
prosolis
2ce56cf76a Combat narration: format magnitude for stat_drain/debuff/ally_buff
These three stateful enemy-effect cases emitted their flavor pool via bare
pickRand, leaking the literal %d placeholder to players ("-%d damage on
every strike from here"). The magnitude is carried in CombatEvent.Damage,
exactly like the already-correct max_hp_drain / spell_fizzle cases; wrap
them in fmt.Sprintf. Add a regression test asserting no placeholder leaks.
2026-05-22 09:26:17 -07:00
prosolis
7dbfa0b56f Pet adoption: stop double-deleting pending, pass interaction to resolvePetName
resolvePendingInteraction deletes the pending entry before dispatch, but
resolvePetName then re-ran LoadAndDelete to recover the carried PetType.
That second lookup always missed, so the if !ok branch returned nil: the
save never ran and no pet was ever persisted (adoption was 100% broken).

Pass the already-loaded interaction in, matching every other handler.
Add a regression test driving the real dispatcher path.
2026-05-22 08:42:47 -07:00
prosolis
b167882e3e expedition-sim: -pet-level flag to model a base housing pet
Synthetic sim chars are vanilla base-class (no pet, no subclass), so the
per-round pet attack/deflect/whiff path was never exercised in the sim.
Add -pet-level N (1-10) which stamps a base Massive Dog (no armor) onto the
AdventureCharacter via the normal save path before the run, so combat's
DerivePlayerStats sees HasPet()==true. 0 (default) stays petless.

Measured lift (n=40, 10 classes x L3/7/12 x 3 zones): overall clear-rate
38.3% petless -> 47.8% at pet L10 (+9.4pp); biggest gains go to the caster
trailers (bard +13.3, warlock +11.9, cleric +11.4), narrowing class spread.
2026-05-22 08:28:51 -07:00
prosolis
95e0995c7f Forex/camp review hardening: bound CoinGecko body, escape URL params, fail-open camp on DB error
- forex_crypto.go: cap the CoinGecko response with io.LimitReader(64KiB);
  build the simple/price query via url.Values instead of bare Sprintf.
- dnd_expedition_camp.go: a combat-session lookup error now fails open
  (allow camp) + logs a warning, instead of blocking standard camp with a
  misleading empty 'not cleared' rejection.
2026-05-22 07:25:08 -07:00
prosolis
56f896b941 Pet arrival: fire after run narration, not before
The run-complete emergence seam rolled the pet-arrival DM synchronously,
then handed the run narration to streamFlow's paced (fire-and-forget)
streamer. The 'animal in your house' prompt landed ahead of the queued
'Run complete' + loot beats. Add streamFlowThen to run the roll after the
final message is delivered; fix the same inverted order in tryAutoRun.
2026-05-22 07:10:51 -07:00
prosolis
ef8fbe5496 Phase-H review fixes: camp, fork streak, pet proc, BG transit, legacy streak
- Camp: campLocationCheck rejects only on live combat; no-encounter and
  post-kill rooms are campable (kills the misleading "clear it first").
- Fork: markActedToday moved after the pending-fork early-return so spamming
  !zone advance at a fork no longer keeps the daily streak alive.
- Pet whiff/deflect: single proc spent on the first multiattack swing only
  (was applied to every swing); also dedups the per-swing event spam.
- Autopilot: background region crossing now runs an HP/SU preflight and
  pauses if low, instead of burning a transit day while the player is idle.
- Legacy streak: acted-branch restamps LastActionDate=today so a purely-legacy
  actor's streak no longer resets to 1 every night.
2026-05-22 01:15:23 -07:00
prosolis
bcd4a873a5 Idle reaper: credit the closing day, not the new day
midnightReset runs at 00:00 UTC closing out yesterday, but gated idle
shame on HasActedToday(), which compares LastActionDate to today (the
new day). DnD-side players credit the day via LastActionDate only, so
anyone who played yesterday and extracted before midnight read as idle
and had their streak halved. Credit activity on the closing day
(LastActionDate==yesterday) directly, matching the streak-bump branch.

(cherry picked from commit f6cb4889e7e6bfacce41ea40853456b08d6857a9)
2026-05-21 23:56:31 -07:00
prosolis
01d2329993 Auto-walk: gate on fork/rest, slow cadence, credit DnD streak
Four fixes for the auto-walk loop that was re-clearing the same room
every 15 min while a fork was pending, ignoring the rest lockout, and
not counting expedition activity toward the daily streak:

- advanceOnceWithOpts / runAutopilotWalk now short-circuit to stopFork
  when NodeChoices has a pending fork. Stops phantom kills + duplicate
  loot drops + fork re-prompts on the same cleared room.
- fireExpeditionAutoRuns honors restingLockoutRemaining so the
  background ticker no longer walks through a long rest.
- autoRunCooldown 15m -> 2h, autoRunTickInterval 5m -> 15m. Auto-walk
  DMs are now a once-in-a-while ping, not a steady drip.
- markActedToday + HasActedToday recognise LastActionDate. Wired into
  !rest short/long, !expedition start/abandon, !extract, foreground
  !zone advance, and !zone go so DnD-side activity credits the streak
  even when the expedition ends before midnight.

(cherry picked from commit 9e27fd8257a4c92150ad584b393bf5a72270b82c)
2026-05-21 23:56:23 -07:00
prosolis
e4518c9c39 Camp: reject (not downgrade) sub-par camps; map: show visited path
Camp rest rules:
  • Standard camp now rejects an uncleared room with a clear message
    instead of silently downgrading to rough — let the player make the
    call rather than spending supplies on a worse rest they didn't pick.
  • campLocationCheck treats a room with a resolved (non-active) combat
    session as cleared, so the natural "just killed, not yet advanced"
    pause still qualifies for a standard camp.
  • Reword fortified-camp gating + babysit help to "zone boss defeated"
    (cache sites aren't a thing yet) so the requirement isn't misleading.

Map: renderVisitedPath adds a 1-indexed breadcrumb of visited rooms to
!expedition map, so players can reference rooms by index (e.g. !revisit 2).

(cherry picked from commit a38fc77eed888e9790c7a7cff24369b98910b43e)
2026-05-21 23:56:18 -07:00
prosolis
20b0d027b2 Pets: per-round attack + wire deflect/whiff into the turn engine
The live turn engine only struck once per fight and never rolled pet
deflect or whiff, so pet armor (deflect-only) bought nothing in real
runs. Roll pet attack each player turn and roll deflect/whiff per enemy
turn, mirroring the auto-resolve engine; retire the one-shot pet-proc
machinery (rollCombatSessionPetProc / PetProcReady).

(cherry picked from commit a0e41c97801e500efad13c7e9a06be4c345e464e)
2026-05-21 23:56:18 -07:00
prosolis
28a90292f0 Expedition-aware continue hints; boss win reads as expedition complete
On an expedition the autopilot drives the walk, so the manual Elite/Boss
fight close-outs and per-room next-room lines pointed players at the wrong
surface (`!zone advance` instead of `!expedition run`). Route every
"keep moving" prompt through continueHint(), which picks the verb by mode.
Special-case the boss victory to read as the expedition win.

(cherry picked from commit 30b51b91445f3bb9680cd252df6c761e3ce61d0a)
2026-05-21 23:56:18 -07:00
prosolis
5d7c76fb20 Sim: cross region boundaries; word mid-zone clears as region clears
Mirror runAutopilotWalk's multi-region auto-advance in the headless sim:
on a mid-zone stopComplete (active multi-region exp with a next region),
advanceToNextRegion and keep simulating instead of scoring a premature
"cleared". A transit error there is now recorded as halted, not cleared.

Also fix misleading run-complete wording: a non-boss region clear of a
multi-region zone now reads "Cleared {region}. The way to {next} opens
ahead." instead of "Cleared {zone}. Run complete." New midZoneRegionClear
helper shares finalizeExpeditionOnZoneClear's gating; the path is shared
with manual !region travel, so both autopilot and manual play get it.

(cherry picked from commit 5d2bba70849a0a3fdeac285cc55ea9b8fadea29c)
2026-05-21 23:56:18 -07:00
prosolis
100a4f1054 Auto-walk: cross region boundaries in multi-region zones
Extract regionCmdTravel's transit core into advanceToNextRegion (shared
by manual !region travel), then have runAutopilotWalk auto-advance into
the next region on a mid-zone stopComplete instead of dead-stopping the
walk. Transit cost (day + supplies + wandering check) is identical on
both paths.

(cherry picked from commit 8ac09cf3b059a086c431859c8aaaf046aa992342)
2026-05-21 23:56:11 -07:00
prosolis
f6a457ae84 Pets: fire 25% morning event from expedition briefing + fix defense-buff leak
Recovers the genuinely-missing half of ade0335 (orphaned on the unmerged
phase-H-harvest-charges branch). 3ed2e1d redid pet *arrival* via the
emergence seam and explicitly deferred the morning event; this closes
that gap.

- deliverBriefing now rolls the 25% morning pet event (petMorningEvent,
  already present but only wired into the overworld DM that underground
  players never see) and prepends it to the briefing body, granting the
  one-day PetMorningDefense buff.
- Add resetAllPetMorningDefense + wire it into midnightReset. The buff was
  leaking permanently even on HEAD's existing overworld path — it was set
  on the 25% roll but never cleared. Resets the pet_flags_json key in place.

Deliberately omits ade0335's ArrivalPending machinery: arrival is now the
emergence seam's job (3ed2e1d + 978dc5e + 513cf32), so queuing arrival
from the briefing would double-roll it.
2026-05-21 23:45:57 -07:00
prosolis
513cf32e42 Restore expedition completion on zone clear (lost in merge)
finalizeExpeditionOnZoneClear and its wiring into the run-complete seam
were introduced in 73b7809 but that commit never made it into the
current history — it's not an ancestor of HEAD, so the function, its
call site in advanceOnceWithOpts, and its tests were all absent.

Symptom: clearing a zone (boss down, no outgoing edges) set the run's
boss_defeated flag but never flipped the wrapping expedition to
'complete' or retired the run. The expedition sat 'active' pointing at a
boss_defeated run, so getActiveZoneRun (filters boss_defeated=0) returned
nil and the next '!expedition run' errored with 'No active zone run'. The
ambient ticker also kept DMing about a finished dungeon.

Re-apply the bridge verbatim against current HEAD (deps verified present:
IsMultiRegionZone / CurrentRegion / MarkRegionBossDefeated /
completeExpedition / retireAllRegionRuns / AwardCompletionMilestones) and
restore the test.
2026-05-21 23:40:54 -07:00
prosolis
978dc5e25f Pets: roll arrival on run-complete emergence too
The emergence-seam roll added in 3ed2e1d covered extract, abandon,
forced extraction, and death-respawn — but not a natural run-complete
(boss down / dead-end node), which is the most common successful
emergence. Players who cleared a run cleanly never got the arrival roll
despite meeting every condition.

Wire maybeRollPetArrivalOnEmerge into the two real run-complete callers
(expeditionCmdRun foreground + the autorun background ticker), gated on
reason == stopComplete. Kept out of runAutopilotWalk itself so the sim
path (which calls the walk directly) never fires arrival DMs.
2026-05-21 23:27:53 -07:00
prosolis
2ea3e42612 Forex: reject crypto/unknown currencies on the fiat-only rate/report path
The analysis path silently normalized junk tokens (BTC/USD, XYZ) to the
default EUR/JPY/CAD set, giving no signal the input was rejected. Validate
tokens up front: crypto symbols get nudged toward the conversion path,
anything else returns an unknown-currency error.
2026-05-21 22:58:31 -07:00
prosolis
3ed2e1d8e0 Pets: roll arrival on expedition emergence, not the 08:00 morning DM
The pet-arrival roll only ran in sendMorningDMs (the 08:00 overworld
morning DM), which is skipped for anyone underground. Expedition players
are almost never in the overworld at 08:00, so the roll never reached
them — the encounter never fired despite the conditions being met.

Move the roll onto the emergence seam via a shared helper
maybeRollPetArrivalOnEmerge, called when a player surfaces alive
(voluntary extract, abandon, survived forced extraction) and on respawn
for underground deaths. Remove the now-dead morning-DM arrival roll.
Story-wise: while the player was out, an animal wandered into the empty
house looking for food.

The 25% morning pet event still rolls only in the overworld DM and has
the same reachability gap; left for a follow-up.
2026-05-21 22:51:00 -07:00
prosolis
6e4928ca17 Merge pull request #11 from prosolis/forex-crypto-coingecko
Forex: convert to/from crypto via CoinGecko (keyless)
2026-05-21 22:21:52 -07:00
prosolis
72f4ef3b27 Forex: convert to/from crypto via CoinGecko (keyless)
Wire a curated set of crypto assets (BTC, ETH, SOL, XRP, DOGE, ADA, LTC)
into the !fx conversion path only. fxLivePairRate now resolves each side
to units-per-USD and crosses them, so USD/fiat/crypto mix uniformly; fiat
sides still batch into one Frankfurter call, crypto hits CoinGecko's
keyless simple/price with a 60s cache.

Analysis (rate/report/setalert) stays fiat-only on purpose -- a daily
snapshot buy-signal/52w range is meaningless for 24/7 crypto.
2026-05-21 18:05:37 -07:00
prosolis
6cda1cac38 Merge branch 'phase-H-harvest-charges': repo cleanup 2026-05-18 00:11:57 -07:00
prosolis
81f1f8d0b4 Repo cleanup: drop stale design docs and sim_results corpus
Remove 24 root-level design/notes markdown files (kept README.md and
gogobee_revisit_plan.md) plus the entire sim_results/ tree (~68M of H/J
phase baselines, traces, summaries, and helper scripts). Ignore
sim_results/ going forward — re-runs can regenerate it.
2026-05-18 00:10:07 -07:00
prosolis
1f6c05a565 Merge pull request #10 from prosolis/phase-H-harvest-charges
Phase h harvest charges
2026-05-17 18:52:00 -07:00
prosolis
a725a22517 Drop stale CHANGES_24H.md snapshot 2026-05-17 18:50:38 -07:00
prosolis
f9ebb116c5 UX: surface swap-back item on magic-item equip
resolveMagicEquipReply silently sold the previously equipped curio back
to inventory at full value, but the success DM only named the new item.
Players had to !sheet or !inv to confirm the old one wasn't lost.

Capture the prior occupant's name before the delete and append a
"📦 **<name>** moved back to inventory." line to the equip confirmation
when a swap actually happened.
2026-05-17 18:44:50 -07:00
prosolis
b76d9bc577 H3 soak close-out: drop dead handleHarvestCmd / handleStandaloneHarvest
H3 retired the manual !forage/!mine/!scavenge/!fish/!essence/!commune
verbs from the command dispatcher in e05da91; the underlying handlers
were left parked behind an operator-gated 2-week soak. The expedition
sim has since exercised the auto-harvest path 15k+ times per sweep
across every class, level, and zone — vastly more coverage than the
soak would have collected in production. Pull the dead handlers now.

Removed:
- handleHarvestCmd (dnd_expedition_harvest.go, ~175 lines)
- handleStandaloneHarvest (dnd_zone_harvest.go, ~80 lines)
- dnd_zone_harvest_test.go (three tests against the deleted handler;
  the autopilot's standalone-storage path is what runs in prod and is
  covered by the cmd/expedition-sim sweep + adv2 scenario test)

The deprecation DM in adventure.go ("Harvest is automatic now — just
walk.") stays for now — muscle memory takes longer than two weeks to
fade and the redirect costs 7 lines.

adv2 scenario test rewired to drive autoHarvestRoom directly.
2026-05-17 17:03:20 -07:00
prosolis
d225fc8185 H4: bump Uncommon/Rare harvest MaxCharges 1→2
53 resources affected in dnd_resource_registry.go; Common (mostly already
2), VeryRare, and Legendary untouched. Calibration sim (15k rows, 10
classes × L3/7/12 × 5 zones) lands cleared-run yield deltas at −2.5% to
+4.7% vs the J2b baseline — well within the ±10% DoD band, but with much
less leverage than the plan anticipated.

The finding (sim_results/h4_findings.md): per-room yield is gated by
non-Noise interrupt truncation, not charge count. The Common consolation
bracket (delta ≥ -4 yields +1) already absorbs most of the Josie nerf.
Future yield tuning should reach for resolveCombatInterrupt, not charges
— parked as out-of-scope for this branch.

H4 closes; the phase-H branch can now soak H1/H2/H3/H5 + this bump.
2026-05-17 16:49:18 -07:00
prosolis
e05da913c4 H3: retire manual harvest commands
Removes !forage / !mine / !scavenge / !fish / !essence / !commune from
the command surface. Typing one now returns a one-line deprecation DM
pointing at the auto-harvest flow ("Harvest is automatic now — just
walk."). The internal handleHarvestCmd / handleStandaloneHarvest paths
are left in place; operator deletes after soak per plan §H3.

Help text and the supplies-low autopilot pause line no longer mention
the manual verbs.
2026-05-17 16:20:10 -07:00
prosolis
68b2122e13 Sim: short-rest between rooms; H5 alone doesn't close caster gap
autoResolveCombat now calls maybeShortRest after a won fight (HP < 60%
or any slots used, charges > 0), mirroring what a competent prod player
does between rooms. Re-baseline at n=100 across the full 10×3×5 matrix
written to baseline_h5sim_all10.jsonl.

Headline (vs baseline_j2a_v2_all10.jsonl, full delta in h5sim_findings.md):
mage L12 manor +8pp, cleric L12 manor +7pp, but bard L12 manor stays at
1pp and bard/cleric L12 underdark still 0. J2c (defaultKnownSpells
damage option at L3+ for bard, L4+ for cleric) is still the lever for
the trailers. Four borderline -9pp regressions on n=100 cells; flagged
for an n=300 re-run before declaring a sim defect.
2026-05-17 16:06:58 -07:00
prosolis
dd25de71f0 H5 follow-up: short-rest at full HP can still refresh slots
handleDnDShortRest early-returned when HPCurrent >= HPMax, which dates
from when short rest was HP-only. After H5 (1cd53eb) added the partial
slot refresh, that gate left casters who hadn't been hit unable to
recover slots between fights — the most common slot-attrition scenario.

Loosen the gate: short rest is allowed when HP can heal OR any spell
slot has used > 0. New helper casterHasUsedSlots gates the slot branch.
Martial behavior unchanged (still bails at full HP). Two new tests.
2026-05-17 16:06:46 -07:00
prosolis
f2c2d774d4 J2: sim picker casts+consumes; T5 raid-content warning
The post-J1 sweep had the casters clustered at 19–22% L12 clear, vs
martials at 70–80%. A per-round trace across 240 boss-room fights showed
why: autoResolveCombat dispatched !attack only — zero spell_casts, zero
mid-fight consumable uses across every caster class. The entire "caster
cliff" was the sim measuring a strawman where casters couldn't cast.

J2a teaches the sim's autoResolveCombat to mirror a competent prod
player: heal at low HP if an inventory consumable is available, otherwise
cast the highest-EV damage spell (slot or cantrip), otherwise swing.
BuildCharacter now seeds the known-spell list via ensureSpellsForCharacter
so the synthetic spellbook is populated. A -trace flag on the cmd
attaches the raw CombatEvent stream to the last combat of each run for
post-hoc diagnostics.

A first re-baseline (n=100, all 10 classes) showed Ranger regressed
-35.8pp — the picker was burning L3 slots on lightning_arrow when
Ranger's weapon chassis (Hunter's Mark + Extra Attack) was the better
play. Added simMartialFirstClass to gate the picker off for Ranger and
Paladin (whose default kit is also weapon-first / no damage spells).
J2c experimented with widening the picker to control + heal spells;
heal-spell preempt cost druid 10pp (slot heals are 10HP vs 40HP
consumables) and control-spell scoring at 22 cost warlock 6.6pp. Both
reverted. Corpora retained under baseline_j2c*.jsonl for the post-mortem
in sim_results/j2b_findings.md.

Post-J2 L12 leaderboard (baseline_j2a_v2_all10.jsonl, n=100):
  fighter 80.0, ranger 80.0, paladin 78.4, rogue 76.8,
  druid 61.6, mage 53.4, sorcerer 50.6, warlock 48.2,
  bard 40.4, cleric 39.0.

The caster cluster is dissolved; martials are within ±5pp of J1 (sweep
noise). Bard/cleric still trail, but it's no longer a sim artifact —
their defaultKnownSpells damage rosters cap at L2 and the picker can't
pick spells they don't have. That's a prod-level fix, deferred.

J3 trace (sim_results/j3_findings.md): T5 dragons_lair walls every solo
class at 0% (Infernax 546 HP vs solo player HP 110–175; ~25% boss HP
eaten before TPK across all classes). Per the J3 plan menu, this is
party-shaped content the engine doesn't yet have parties for. Surface
a TwinBee-voiced heads-up in handleDnDExpeditionCmd's start path and a
matching tag in !expedition list — players see "raid-shaped — solo
runs not yet survivable" before they spend outfitting coin. No combat
or class balance changes.

Files: cmd/expedition-sim/main.go +trace flag; expedition_sim.go picker
+ SimCombatSummary.Events + spellbook seed; dnd_expedition_cmd.go
raidContentWarning + list tag. All baselines + traces + findings
checked in under sim_results/.
2026-05-17 15:43:41 -07:00
prosolis
519964fb01 J1: Extra Attack now fires in turn-based combat
The class-identity audit (98ba416) wired Extra Attack via the new
resolvePlayerSwings helper, but only SimulateCombat (auto-resolve)
called it. The turn-based engine — every !fight/!attack and every
elite/boss gate the sim drives via autoResolveCombat — still called
single-swing resolvePlayerAttack, so Fighter L11+ got 1 swing/turn at
the gates instead of 3. The audit close-out was correct in spirit but
half-applied.

J1 baseline matrix surfaced it: Fighter L12 cleared 100% of T2 forest
but 2% of T3 manor and 7% of T4 underdark, with %boss_reached at 100%
across the board. The wall was the boss-room damage exchange, not
mid-zone attrition. Trace dump on a sample fight: Fighter dealt 79
dmg in 14 rounds (7 hits / 9 swings) — exactly one swing per round —
versus 167 enemy dmg. With multi-swing wired in, the same fight ends
in 7 rounds with the boss dead, Fighter at 87/168 HP, 16 hits in 19
swings.

n=100 matrix after the fix:
  Fighter L12 manor:     2% → 100% clr
  Fighter L12 underdark: 7% → 98%  clr
  Fighter L12 forest:    94% → 100% (no leader regression)
Mage cells unchanged (J2 territory). Rogue cells within noise.

Sim infra changes that landed alongside (needed to read the J1
signal):

* expedition_sim auto-arms class-default defensive abilities
  (Second Wind / Healing Word) via the new simAutoArmEnabled toggle
  + trySimAutoArm helper, hooked before applyArmedAbility in both
  combat builders. Production code paths untouched (toggle stays
  off). Without this the sim simulated a player who never types
  !arm, which under-counts class survival.
* SimResult.Combats captures per-fight turn-log summaries (rounds,
  hits/misses, damage by side, AC values inferred from RollAgainst)
  so future J-phase questions can dig into the engine without
  re-running the matrix.
* sim_results/run_matrix.sh fans the matrix across (class,level,zone)
  cells via xargs -P (one process per cell — each owns its global
  sqlite handle). ~6× wall-clock speedup on a 14-core box; n=100
  matrix runs in ~3min.
* sim_results/summarize.sh gains p50_yld_clr + %boss_reached columns
  so future sweeps don't conflate "reaches boss" with "clears zone".

Baselines:
  sim_results/baseline_j0_n100.jsonl       — pre-fix (1350 rows)
  sim_results/baseline_j1_extra_attack.jsonl — post-fix (4500 rows)

Phase J state: J0 baseline locked, J1 done. T5 dragons_lair still
0% clear universally (J3). Mage T2+ wall still real (J2).
2026-05-17 14:11:39 -07:00
prosolis
5eb3cac992 Sim hardening + H4 calibration findings → Phase J plan
Harden expedition-sim with the three layers a real player carries:
race mods (+1 Human across the board), tier-appropriate equipment
(weapon/armor/helmet/boots/tool promoted from tier 0 to a level-mapped
tier), and a lean consumables bundle (2 heals + 1 buff at T2-T4,
3 heals + 2 buffs at T5). Capture material yields in SimResult so
calibration sweeps actually read out the H4 signal.

Three sweeps in sim_results/ (n=10): bare baseline, +gear/race,
+lean consumables. Headline: yield is gated by combat survival, not
charge counts. Fighter and Mage hit a sharp wall at T3+ even kitted;
no class extracts the T5 dragons_lair boss. Tuning charges before
fixing class survival would over-tune against a strawman, so H4
is now flagged blocked on Phase J.

Phase J appended to gogobee_harvest_charges_plan.md:
J0 baseline at n=30, J1 Fighter T3+ wall, J2 Mage T2+ wall,
J3 T5 boss (sim artifact vs over-tuned vs raid content),
J4 validation matrix.
2026-05-17 13:37:21 -07:00
prosolis
f3e7a33435 expedition-sim: matrix mode for batch corpora
-matrix sweeps the cartesian product of -classes × -levels × -zones
with -runs replicates per cell, fresh sqlite DB per run, emitting one
JSON object per stdout line. Log field is suppressed by default in
matrix mode (override with -log=true) since a single matrix cell's
log can easily dwarf the per-run summary.

Default -cap shrunk from 200 to 50 — RunExpedition now counts
autopilot bursts (not !expedition run invocations), and 50 bursts is
enough to clear a tier-1 zone with headroom.
2026-05-17 13:12:50 -07:00
prosolis
a8f8be6f40 Sim runner: day-cycle fast-forward, auto-combat, log capture
RunExpedition now drives expeditions end-to-end through the production
plugin paths:
- TickDay calls deliverRecap + deliverBriefing with a synthetic clock
  anchored on exp.StartDate, advancing one day per call without
  real-time waits.
- Autopilot bursts use compact mode so elite gates auto-resolve inline.
- Boss / surviving elite gates are auto-resolved via handleFightCmd +
  looped handleAttackCmd until the session flips Won/Lost/Fled.
- Forks resolve to path 1 deterministically.
- SimResult exposes StopCode, Walks, DayTicks, Threat, plus the full
  dnd_expedition_log projected as SimLogEntry rows.
2026-05-17 13:12:34 -07:00
prosolis
ebea2b430a Sim scaffold: cmd/expedition-sim drives synthetic expeditions
SimRunner re-uses production plugin code paths against a fresh sqlite DB —
SendDM no-ops without a Matrix client, so ground truth comes from the DB.
BuildCharacter persists adv+dnd character rows with class-appropriate
ability scores and spell slots; RunExpedition loops !expedition run with
stall detection until the autopilot halts or the expedition closes.

The cmd binary is a thin orchestrator: one flag-driven run, JSON output.
Per-room structured logging, day-cycle fast-forward, and matrix batching
are next session.
2026-05-17 12:55:23 -07:00
prosolis
1cd53eb890 H5: partial spell-slot refresh on short rest
Short rest now restores all L1 slots plus floor(level/4) additional slots
at the next-available tier ≥L2, lowest-first. Long rest still does a full
wipe; martials and full-pool casters see no DM change.
2026-05-17 12:45:21 -07:00
prosolis
736494d945 H2: auto-harvest parity for !zone advance
Refactor autoHarvestRoom to accept a nillable Expedition and pull harvest
state from per-run storage when none is active. Wire the pass into
advanceOnceWithOpts so foreground !zone advance and the expedition
autopilot share the same Josie-semantics harvest loop. Standalone (no
expedition) skips threat/interrupts/log/region conditions; expedition
mode keeps the existing interrupt + threat behavior.
2026-05-17 12:33:59 -07:00
prosolis
20689a693b H1: Josie harvest semantics in autopilot
Replace retry-to-success grind with one-roll-per-charge. Every node
swing — Common through Legendary — consumes a charge regardless of
outcome. Rarity no longer pauses the autopilot.

Removed: autoHarvestPerNodeAttempts cap, isRarePlus filter, RarePending
field, renderRarePendingFooter, stopRareNode reason. Pluralize "fails"
since whiffs are now expected.

Per gogobee_harvest_charges_plan.md H1.
2026-05-17 12:24:18 -07:00
prosolis
3b5a6ad4b8 Bump remaining direct deps: goquery, expr, x/image
Wave 2 of the dependency refresh — cosmetic patch bumps on the
non-security-sensitive direct deps. Full test suite passes.

- github.com/PuerkitoBio/goquery 1.10.3 → 1.12.0
- github.com/expr-lang/expr      1.17.5 → 1.17.8
- golang.org/x/image             0.36.0 → 0.40.0

Remaining outdated entries in `go list -u -m all` are transitive
indirects pinned by other libraries (yaml.v2, xerrors, go-cmp, joker,
mattn/go-isatty); not on our code path.
2026-05-17 11:37:20 -07:00
prosolis
b58f154b4d Bump security-sensitive deps: x/crypto, x/net, sqlite, mautrix
Wave 1 of a two-wave dependency refresh — the modules where staleness
actually matters. Full test suite passes.

- golang.org/x/crypto 0.48.0 → 0.51.0
- golang.org/x/net    0.50.0 → 0.54.0
- modernc.org/sqlite  1.37.1 → 1.50.1 (pulls libc 1.65 → 1.72)
- maunium.net/go/mautrix 0.26.3 → 0.28.0 (pulls go.mau.fi/util 0.9.6 → 0.9.9)

Transitive bumps from `go mod tidy`: x/sys, x/text, x/exp, zerolog,
gjson, edwards25519, mattn/go-sqlite3, ncruces/go-strftime, petermattis/goid.
2026-05-17 11:32:01 -07:00
prosolis
dcc039b7d5 Close expedition/run seam on combat loss & flee
Run-loss paths (turn-based elite/boss death + flee, exploration combat
death/retreat, interrupt/patrol death) abandoned the zone run but left
the wrapping expedition row at status='active', so the ambient ticker
kept DMing about a dungeon the player had walked away from.

Adds forceExtractExpeditionForRunLoss helper and wires it into every
run-loss site; ambient ticker also skips when the expedition's run is
no longer active as a safety net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:48:52 -07:00
prosolis
98ba416359 Class identity audit close-out: Fighter/Ranger/Paladin/Druid + Bard-Valor
Phase 2 Monte Carlo tuning had papered over four classes whose 5e-defining
mechanics never actually existed in the engine — only flat DamageBonus or
FlatDmgStart compensation riders that hit the right aggregate win rate.
This pass replaces the proxies with the real primitives.

New CombatModifiers fields: ExtraAttacks (additional swings/round, looped
in a new resolvePlayerSwings helper); HuntersMarkDie (per-hit Nd6 bonus,
same path as Sneak Attack); ThornLashDmg (flat counter on landed enemy
hits, fires in resolveEnemyAttack after ward/block).

Fighter L5/11/20 Extra Attack, Ranger L5 Extra Attack + Hunter's Mark
(1→4 d6 by level), Paladin L5 Extra Attack + per-hit Divine Smite
(replaces one-shot opener), Druid thorn lash. Bard College of Valor L7
"Extra Attack" subclass tier converted from +1 attack/+10% damage proxy
to the real primitive.

Post-fix T5 class-balance: martials cluster at top (Fighter/Rogue 0.90,
Ranger 0.89, Paladin 0.88), casters keep their relative ordering, spread
14pp top-to-bottom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:53:00 -07:00
prosolis
41adfce9f1 Expedition autopilot Phase 4 + rogue sneak attack + TwinBee voice sweep
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>
2026-05-16 12:40:17 -07:00
prosolis
f424300d87 Ship-audit P0/P1 sweep on open5e: prose, combat math, UX hygiene
P0 — actual bugs:
- eldritch_blast: SRD-imported "Whatever" placeholder leaked to players;
  overlay added in dnd_spells_data.go.
- Battle Master disarming_attack + parry: math.Max on multiplicative
  DamageReduct was a no-op vs the 1.0 default. Switched to *= and added
  regression tests covering the stacking case too.
- Extended TestSpellDescriptionsAreJargonFree to catch "Whatever",
  trailing ellipses, mid-word truncation, and empty-size phrases. This
  surfaced 4 more genuinely-broken SRD descriptions (find_familiar,
  clairvoyance, glyph_of_warding, teleportation_circle); all now
  overlayed in dnd_spells_data.go.

P1 — UX hygiene:
- Setup completion now nudges new casters toward !spells/!cast.
- Magic-item attunement vocab unified to "bond" everywhere user-facing;
  renamed the Treasures sheet section to avoid clashing with the bond
  vocab.
- Ambient DM footers stripped of "Threat +N" / "Supplies −N SU" hidden-
  meter leakage; verb-form footers instead.
- !expedition status defaults to a days-left + threat-label summary;
  raw SU / threat-out-of-100 / zone-stack / roll-modifier moved behind
  `!expedition status --debug`.
- !zone taunt/compliment help text dropped the "(mood −10)" / "(mood +5)"
  numerics that turned the hidden TwinBee-mood mechanic into a min/max
  knob.
- TwinBee pronoun pass: switched she/her to singular they on the two
  surfaces I touched plus achievements.bj_beat_twinbee.

P1 — correctness/scale:
- adventure_shop curio purchase now guards the euro.Debit bool return
  before inventory grant + pot cut; the GetBalance preflight isn't
  enough since a concurrent blackjack/lottery debit can slip the balance
  across BLACKJACK_DEBT_LIMIT between read and write.
- equipMagicItem reordered: remove inventory item BEFORE equip, with a
  best-effort rollback if equip fails. The prior order left a dup
  window if the inventory delete tripped on a transient DB error.
- magicItemsByRarityCache promoted to sync.Once — concurrent cold-start
  loot rolls otherwise raced on the lazy assignment.

Stale-audit verifications (no code change needed):
- light / druidcraft / water_walk overlays already exist and win the
  SRD merge — the broken raw text never reaches players.
- bootstrapPhase5BHPRefresh is already wired at adventure.go:228.

Deferred:
- combatantsForSession per-turn 6-load DB chatter (perf P1-A): touches
  the hot combat path; needs its own test pass before shipping. Tracked
  in memory at project_combat_session_cache_deferred.

go vet clean; go test -race ./... green (229s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:43:11 -07:00
prosolis
ba84d38979 Audit A1 close-out: 12 SRD-only utility-spell voice shims
The May audit's Phase A1 was overstated. Re-verification at HEAD
found:

- TestSpellDescriptionsAreJargonFree already exists with the
  proposed regex set and passes.
- .Upcast is dead text (grep across internal/ shows no runtime
  reader); audit's Upcast scope was moot.
- All four Description leaks cited by example (bless, command,
  create_or_destroy_water, cure_wounds) were already overlaid in
  the prior 2026-05 voice pass.

Real residual: 12 SRD-only utility/control entries whose Open5e
prose was bland (but not jargon-leaking). Overlay shims added in
the charm_person voice — second-person, verb-led, lightly wry.
Mechanics (Effect/CastTime/SaveStat/Concentration/AOE/
MaterialCost) preserved from the SRD originals.

Also closes out B1 (help jargon — already shipped) and B2
(TwinBee voice — shipped in Phase B2 voice pass) in the audit
doc; both were re-asserting completed work.

Remaining in the May audit: C1 backup wiring, C2+C3 maintenance
hardening, D1+D2 hygiene. No player-facing P0 left.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:00:35 -07:00
prosolis
25ea23f9d9 Phase 5C (rosters): T3 polish + feywild T4-spread fix
Phase 5-B's exit named two follow-ups: T3 below band (manor 39 /
underforge 47 vs 55-75) and the T4 spread (feywild 59 vs underdark
88, 30pp asymmetry). Phase 5-C re-runs the Phase 4-A trace on the
four affected zones under the shipped Phase 5-B cell and applies
roster knobs only (IsElite + SpawnWeight; no bestiary stat-block
touches).

TestExpeditionBalance_Phase5C_OutlierDiagnostic names the killer
per zone:

  manor_blackspire (53%): Vampire Spawn (72% win, 42 kills) +
    Revenant (16% win, 31 kills at SW=1, can't go lower). First
    attempt trimmed VS SW 3 -> 2 and backfired by concentrating
    elite share on Revenant (kills jumped to 48); reverted.
  underforge (49.5%): Fire Elemental (57 kills, 61hp/win) +
    Salamander (33 kills) carry the lethality; Helmed Horror only
    100%-win elite but SW=1, no dilution effect.
  feywild_crossing (54%): only 2 elites in the pool; Fomorian
    (50% win, SW=1) still pulls 25% of elite picks and owns 65
    kills.
  underdark (86%): Drow at SW=7 fills nearly half of standard
    rolls at 100% win / 1.1hp loss -- free-HP filler.

Roster changes:

  manor    Banshee promoted to elite SW=2 (was standard SW=3,
           99.6% win). Soft 4th elite slot dilutes Revenant share
           ~14% -> ~11%. Standards collapse to Shadow+Poltergeist
           (>=99% win).

  underforge  Helmed Horror SW 1 -> 3. Three-way elite pool drops
              Fire Elemental's share from ~44% to ~33%.

  feywild  Green Hag promoted to elite SW=2 (was standard SW=4).
           Adds soft 3rd elite + removes a 16hp/win standard.
           Standards become Redcap+Will-o-Wisp+Quickling.

  underdark  Drow SW 7 -> 5 (light trim per the user's "lift
             trailers, don't nerf leaders" stance). Standards
             shift toward Hook Horror / Drow Mage.

Phase 1 matrix after Phase 5-C (200 trials, Fighter @ centerline):

  T1  88.5%  spread  1.0   (in band 70-90)
  T2  74.5%  spread 15.0   (in band 62-82)
  T3  56.7%  spread  0.5   (in band 55-75)  +13.5pp
  T4  77.0%  spread 13.0   (over band 45-65, matches 5-B target;
                            spread halved from 25.5)
  T5  58.0%  spread 40.0   (abyss_portal 38% is the residual)

T3 mean lifted +13.5pp; both zones inside band with 0.5pp residual
spread. T4 spread halved (25.5 -> 13.0pp) by lifting feywild
+11.5pp and trimming underdark -4.5pp. T1/T2/T5 untouched.

No test debt -- no production tests pin SpawnWeight or IsElite on
the changed entries (TestMonsterKillTags_GatesKnownMonsters checks
vampire_spawn tags only, unaffected). -short suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:24:03 -07:00
prosolis
5ef10e35dc Phase 5b: player power floor + Phase-3 winners shipped to live
Closes the 'fairly breezy with some death' target the user picked
for Phase 5. Five-piece ship; Phase 1 matrix lands T1 88%, T2 74%,
T4 72%, T5 ~57% in or above band. T3 remains the design hump at
~45% (manor 39, underforge 47) — Wraith promotion to elite was
already done in Phase 4-B, the remaining standard-pool deaths are
the irreducible part of T3.

Pieces:
  1. computeMaxHP × 1.5 (phase5BHPMult in dnd.go). Uniform across
     class/level so the class-balance harness's in-tier parity
     assertion stays green. Bootstrap (bootstrap_phase5b_hp.go)
     refreshes hp_max for existing characters at startup;
     idempotent via db.JobCompleted. hp_current is bumped by the
     same delta so a full-HP character stays at full.
  2. applyPhase5BPlayerFloor (dnd_combat.go): +3 AC, +3 AttackBonus,
     +3 weapon.MagicBonus (damage). Applied at the END of
     applyDnDEquipmentLayer (after computeArmorAC's AC override)
     and inside buildHarnessPlayer so live and harness measurement
     match.
  3. Elite bracket 19 → 23 (resolveCombatInterrupt). Case order
     puts Elite (≥23) before Patrol (≥22) so a 23+ total prefers
     the single dangerous fight. Elite is now effectively a
     high-threat event reachable only via the +1-per-20-threat-
     above-40 mod — Phase 4-B's elite-pool monsters still appear,
     just less often.
  4. dailyThreatDrift base 3 → 1. Slows the threat clock so
     players have the days they need before threat tips zones
     into the new 23+ elite band.
  5. applyDailyBurn default → 50% (phase5BDailyBurnRatePct). Also
     applied in the temporal-override branch in
     dnd_expedition_cycle.go so tidal / unraveling days scale by
     the same 0.5× — otherwise those days would be
     disproportionately harsh against the new baseline.

The harness's expedition_balance.go reads phase5BDailyBurnRatePct
as the default-burn fallback when the override knob is zero, so
Phase 1 matrix measurements now reflect what live players
experience.

Test debt: 13 pinned-numbers unit tests across combat_stats_test,
dnd_test, dnd_xp_test, dnd_equipment_profiles_test,
dnd_expedition_supplies_test, dnd_expedition_cycle_test,
dnd_expedition_extract_test, dnd_expedition_region_cmd_test,
dnd_expedition_combat_test, dnd_expedition_threat_test,
dnd_expedition_temporal_test, expedition_balance_test were
pinning pre-Phase-5-B baselines; updated with comments noting
the cause. Class-balance suite stayed green (uniform buff
preserves spread).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:11:27 -07:00
prosolis
d0a8505c76 Phase 5a (diag): tier-wide sensitivity sweep on T2/T3/T5 under-band zones
Phase 4-B closed the per-zone outliers but T2/T3/T5 sibling pairs sit
below band as a group (T2 7-13% vs 62-82%; T3 3-14% vs 54-74%; T5
25-57% vs 36-56%). The plan doc's three tier-wide candidates (gear-
tier centerline remap, per-tier elite threshold, ship burn=75) needed
disambiguation before pulling any of them.

TestExpeditionBalance_Phase5A_TierWideSensitivity runs the 6 problem
zones through three one-axis sweeps holding the other two levers at
Phase 3-B best (e=23, d=1, burn=50):

  Axis L  player level    {centerline-2, centerline, centerline+2}
  Axis E  elite threshold {18, 23, 28}
  Axis B  supply burn pct {40, 50, 60}

200 trials/cell × 6 zones × 9 cells = 10.8k trials; runs in 0.31s.
Reuses the harness's traceFightStruct plumbing for elite-vs-standard
fight share alongside comp/death/starve.

Reading (full numbers in the plan doc):

  - Player level is the dominant lever at T2/T3. +2 levels lifts
    completion meaningfully; the elite gate and burn are inert.
  - Elite threshold is already at its sweet spot at e=23 — e=18
    floods elites and collapses every tier; e=28 is flat. Per-tier
    threshold candidate is killed.
  - burn=75 globally is killed. burn=60 alone produces 36%/61%
    starve at T5; the Phase 3-B negative result holds tier-wide.
  - T5 dragons_lair is already in-band at the baseline (60.5%);
    the T5 gap is really an abyss_portal-only gap.
  - The level-bump path has a catch: L7 at T2 (max of design range,
    still gearTier=2) only reaches ~27%, well below the 62-82% band.
    Closing T2 properly likely needs a cross-gearTier-boundary
    centerline (T2 → L9 = gearTier 3) — a design call for Phase 5-B,
    not a knob twist.

Diagnostic-only — no gates. -short skips. Plan doc updated with
numbers and three Phase 5-B candidate moves (cross-bracket centerline
bump recommended; player combat-math retune is the bigger option;
lowering the band target is the fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:36:17 -07:00
prosolis
cf092742bb Tests: catch up stale assertions to current prod UX
Two tests had been failing on -short for several phases; the
comfortable "pre-existing failures" framing was masking that both
were stale, not flaky — each test predated a deliberate UX rewire
that nobody updated the assertion against.

TestMageSpellbookLineInRender — commit 8bf7d35 (UX S7 jargon sweep)
reshaped the spellbook line to "**Spellbook:** N / M leveled spells
learned" and intentionally dropped the redundant "(can learn N more)"
trailer. Test was still asserting "2/10" (no spaces) and the dropped
hint. Updated to track the live "N / M leveled spells learned"
format; comment cites the sweep commit.

TestAdv2Scenario_ZoneRunGoblinWarrens — commit 886eb5a (turn-based
elite/boss combat) moved Elite/Boss rooms off the auto-resolve
SimulateCombat path onto the manual !fight + !attack engine; !zone
advance now stops at the doorway and only progresses past a won
CombatSession. The scenario test was only calling !advance, so it
spun in place at room 4 (the elite). Loop now detects Elite/Boss
prev-rooms, opens with !fight if the session doesn't exist, drains
!attack rounds until the session terminates, then falls through to
!advance to clear the room (won → walk graph; lost/fled → terminate
next pass). maxSteps doubled to absorb the fight-then-advance pairs;
inner !attack cap at 60 rounds covers RNG tail.

Both -short tests now pass; full -short suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:29:03 -07:00
prosolis
ad27077329 Phase 4 doc updates
Folds the 4-A diagnostic + 4-B roster pass into the plan doc with
before/after numbers, and notes the partial Phase 4 exit (per-zone
outliers fixed, tier-wide gap remains for T2/T3/T5). Adds the Phase
5 jumping-off list (per-tier elite threshold, gear-tier mapping
review, or shipping burn=75 as the live global).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:24:34 -07:00
prosolis
765c738c99 Phase 4b (rosters): per-zone outlier re-classification
Phase 4-A's per-monster trace named one or two miscategorised entries
on each of the four outlier zones — Phase 2c's single-boss-elite
dilution pattern hadn't been applied uniformly, and several over-tier
creatures were sitting on standard slots they couldn't carry.

Roster changes (no monster stat-block touches — bestiary is shared):

  crypt_valdris (T1, 8.5% → 46.0%):
    Dropped flameskull (CR3, 68 kills — still in underforge T3 where
    its CR fits). Promoted specter to IsElite at SW=3 as the soft
    dilutor, mirroring Phase 2c's worg/owlbear shape so the elite
    bracket no longer auto-picks a one-shot.

  forest_shadows (T2, 0.0% → 7.5%):
    Standard pool was carrying two real killers — Displacer Beast
    (38% win) and Bandit Captain (57% win, 14hp loss/win). Both
    re-flagged IsElite; Owlbear SW cut 4→2 to keep the now-4-elite
    pool diluted instead of front-loading Owlbear (9% win) at 50% of
    elite rolls. Standards collapse to Dire Wolf + Dryad, matching
    sunken_temple's "all standards ≥90% win" floor.

  manor_blackspire (T3, 0.5% → 14.5%):
    Wraith was the dominant standard killer (85 attributed kills,
    45hp loss per win) — too punishing for the standard slot.
    Promoted to IsElite; standard floor becomes
    Shadow+Poltergeist+Banshee (all ≥92% win in trace).

  abyss_portal (T5, 0.0% → 25.0%):
    Nalfeshnee was a CR-13 demon on a T5 standard slot (2.8% win, 86
    kills); promoted to IsElite. Second pass: Vrock at 86% win as
    standard still bled 65 kills via 43hp/win attrition — promoted to
    elite too. Standard collapses to Quasit alone (99.9% win, 1.3hp
    loss), matching dragons_lair's near-zero standard-pool kill rate.

Tier-level effect on the Phase 3-B (b=50, f=tier) sweep:

  T1: 26.8% → 44.0%  (+17 pp)
  T2:  6.2% → 10.0%  (+4)
  T3:  2.5% →  8.5%  (+6)
  T4:  7.8% →  7.8%  (no T4 outlier identified)
  T5: 27.5% → 41.0%  (+14)

Outliers are no longer outliers (forest_shadows 7.5% vs sibling 13%
gap; abyss_portal 25% vs sibling 57.5%); the remaining T2/T3/T5
shortfall vs the target band (62-82%/54-74%/36-56%) is tier-wide —
both zones at each problem tier sit below band, so a per-zone tool
won't close it. Phase 4's exit condition ("all per-zone cells within
band") is therefore conditioned on a follow-up tier-wide pass, but
the per-zone bug-fix it was scoped to address is done.

-short suite: same two pre-existing failures
(TestAdv2Scenario_ZoneRunGoblinWarrens, TestMageSpellbookLineInRender).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:23:07 -07:00
prosolis
dcd9e4158f Phase 4a (diag): per-zone outlier attribution
The Phase 3-B sweep wrung out the global lever surface and named four
zones that read ~0% completion under every (elite-threshold, drift,
nick-floor, supply-burn) combo: crypt_valdris T1, forest_shadows T2,
manor_blackspire T3, abyss_portal T5. Each has a healthy sibling at
the same tier (goblin_warrens 44%, sunken_temple 13.5%, underforge
2.5%, dragons_lair 57.5%) so the gap is per-zone, not tier-wide.

Adds a structured per-fight trace hook (traceFightStruct + the
harnessFightTrace struct that mirrors the existing string trace) so
diagnostics can aggregate without parsing the formatted log line.
Mirror-format with traceFight; if a field is added, update both
paths.

TestExpeditionBalance_Phase4A_OutlierDiagnostic walks the four
outlier-vs-sibling pairs at 200 trials each on the Phase 3-A/3-B best
cell (e=23 d=1 burn=50) and reports per-monster appearances /
win-rate / avg HP loss / kill attribution + day-of-end histogram +
elite-vs-standard fight mix.

Findings:
  - crypt_valdris: dual-killer elite pool (Wight 99 kills, Flameskull
    68). Phase 2c left this zone untouched ("already dual-elite") but
    both elites are over-tier for T1.
  - forest_shadows: standard pool too lethal — Displacer Beast (53
    kills, 38% win standard) + Bandit Captain (48 kills, 57% win).
  - manor_blackspire: Wraith on the standard slot is dragging the
    floor (85 kills, 45hp loss per win). Vampire Spawn + Revenant
    elite pair is also rough.
  - abyss_portal: Nalfeshnee mis-classified standard (86 kills, 2.8%
    win at L17). Vrock at 79% win is borderline.

Phase 4-B applies per-zone roster tweaks (IsElite re-flag, drop a
deadly entry, soften a SpawnWeight) — no monster stat-block changes.
T3 may need a follow-up tier-wide pass since the sibling underforge
also sits at 2.5%; out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:18:44 -07:00
prosolis
2d44c990f3 Phase 3b (sweep): nick-floor + supply-burn sweep, T5 supply unlock
Wired SurpriseNickFloorOverride and SupplyBurnRatePctOverride into the
harness day-loop via two new parameterized helpers (surpriseRoundNickF,
applyDailyBurnP). Live callers go through the existing constants;
sweep test sits on top of the Phase 3-A best cell (e=23, d=1).

TestExpeditionBalance_Phase3B_NickSupplySweep walks 3×3 (floor ∈ {0, 1,
tier=live}) × (burn% ∈ {50, 75, 100=live}) × 10 zones × 200 trials.

Strong partial T5 positive; nick-floor lever inert.

  - Supply burn is the T5 unlock: dragons_lair 0% → ~55% at burn=50.
    Fighter survives elites; burn=75 isn't enough margin.
  - T4 peaks at burn=75 (~12% underdark/feywild); burn=50 dips T4
    slightly (more elites survived into).
  - Nick-floor inert across tiers (≤3pp swing); wounded-clamp already
    eats the chip-damage budget. Recommend dropping from live-tuning
    candidates.
  - T2-T3 wall persists: forest_shadows, manor_blackspire,
    abyss_portal stuck at ~0% across every combo — outliers, not
    addressable by global levers.

Global levers wrung out. Plan-doc Phase 3-B section + memory pointers
updated; next is Phase 4 (per-zone outlier pass). -short shows the
same two pre-existing failures (TestAdv2Scenario_ZoneRunGoblinWarrens,
TestMageSpellbookLineInRender).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:10:21 -07:00
prosolis
235122f2a1 Phase 3a (sweep): elite-bracket + drift sweep, strong partial positive
Wired two harness lever overrides — EliteInterruptThresholdOverride
(live=19) and ThreatDriftBaseOverride (live=3) — into the day-loop in
expedition_balance.go. Live runHarvestInterrupt / dailyThreatDrift are
untouched; the harness re-buckets Standard↔Elite after the live call.

TestExpeditionBalance_Phase3_GlobalLeverSweep walks a 3×3 grid
(elite ∈ {17,19,23} × drift ∈ {1,3,5}) over the Phase 1 matrix at
200 trials/cell. -short skips.

Elite-bracket threshold is the dominant lever for T1–T3. At
e=23/d=1: T1 mean 24.0% (goblin_warrens 40.5%), T2 7.2%
(sunken_temple 14.5%), T3 1.8%. Still well below target bands
(T1 70-90%, T2 62-82%) — the lever moves the needle in the right
direction but cannot land any tier on-band alone.

T4/T5 fingerprint changed but didn't lift. At e=23 dragons_lair
death drops 60% → 24% but starvation climbs to 75% — the fighter
now survives elites long enough to run out of supplies. T4 cells
shift the same way. Indicates a second lever is needed for the
higher tiers (standard-fight survivability or supply margin), to
be swept in Phase 3-B.

Plan doc updated. Renumbered the trailing "per-zone outlier pass"
to Phase 4 and "MAD / second-order" to Phase 5 so the test names
align with phase numbers going forward.

-short suite: same 2 pre-existing failures
(TestAdv2Scenario_ZoneRunGoblinWarrens, TestMageSpellbookLineInRender).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:00:04 -07:00
prosolis
9a4a93e72e Phase 2c (roster gate): dilute single-boss elite pools
Each zone's elite pool was a single SpawnWeight=1 over-tier boss, so
InterruptElite bracket rolls auto-picked that boss 100% of the time.
The post-2b tier-lethality trace named these one-shots (Warchief, Hag,
Roper, Young Red Dragon) as the dominant remaining death source.

Promotes one mid-tier alt per zone to IsElite (underforge gets two
since fire_elemental was itself a non-elite killer). Boss SW=1 stays;
alt SW 3-4 gives the boss ~17-25% elite-roll share. pickZoneEnemy also
excludes IsElite from the standard pool, so promotion softens standard
encounters by one mid-difficulty entry — each zone still has 3-4
standard entries left. Crypt-Valdris already dual-elite, untouched.

Promotions: worg, owlbear, aboleth_thrall, vampire_spawn,
salamander+fire_elemental, drow_elite_warrior, night_hag,
dragonborn_cultist, hezrou.

Phase 1 matrix delta (200 trials/cell, Fighter, centerline):
  goblin_warrens: 0% → 3.0% completion (first non-zero T1 reading)
  sunken_temple:  0% → 0.5%
  dragons_lair:   death 100% → 60% (40% starve — different failure)
  underdark:      death 100% → 90% (10% starve)
  feywild:        death 100% → 92% (8% starve)
  T3 cells still 100% death — next pass is global supply/threat levers.

Tier-lethality trace shows Drow Elite Warrior + Dragonborn Cultist
spawning and being winnable, with arcs stretching to 7-10 encounters
instead of fresh-entry one-shots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:45:40 -07:00
prosolis
8cdd64b383 Phase 2 (sweep): lever-tuning sweep, negative result
Sweep the two knobs surfaced by Phases 2a/2b — retreatThreatBump
and clampSurpriseNick's wounded-entrant divisor — across a full
3×4 grid (bump ∈ {2, 5, 10} × divisor ∈ {3, 5, 8, 12}) at 200
trials/cell across every matrix zone.

Wiring is harness-only: clampSurpriseNick keeps its live shape and
delegates to a new clampSurpriseNickD(divisor) variant; the harness
profile gains RetreatThreatBumpOverride/SurpriseNickDivisorOverride
fields threaded onto expeditionHarness; resolvedRetreatBump and
resolvedNickDivisor pick override-or-live. Zero on either field
falls back to the shipped value so live runHarvestInterrupt is
untouched.

Sweep test: TestExpeditionBalance_Phase2_LeverSweep, -short skipped,
mirrors Phase2_CadenceCalibration's per-tier digest shape.

Outcome: across 24,000 trial-cells (12 lever combos × 10 zones
× 200 trials), every cell reports 0.0% completion / ~100% death.
The knobs are inert on the headline metric — even (b=2, d=12)
can't lift any tier off the floor. Confirms the post-2b
tier-lethality trace: remaining deaths are fresh-entry elite
one-shots (Warchief, Hag, Roper, Young Red Dragon), not chained-
interrupt cascades. Justifies Phase 2c (roster dilution) rather
than further tuning of these two levers.

Plan doc updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:39:45 -07:00
prosolis
159daf8fc8 Phase 2b (lever): wounded-entrant surprise-nick clamp
The post-2a tier-lethality trace showed the remaining 0% completion
was driven by chained surprise-round nicks on already-wounded fighters,
not by the elites themselves. Pattern from the trace:

  fight day=5 Hobgoblin Warchief: hp_pre=24 → hp_post=14  WON
  fight day=5 Goblin Archer:      nick=6, hp_pre=8 → hp_post=0  LOST

The Warchief left the fighter at HP 14; the Goblin Archer's surprise
nick (6 HP) dropped them to 8 before combat resolved — and a standard
goblin then finished a fighter who should have survived. Same shape at
T2 (Dire Wolf nick 4 on hp=3), T3 (Fire Elemental nick 4 on hp=5),
T4-T5. The nick was acting as a hidden cascade multiplier, pre-empting
the combat engine on wounded entries.

clampSurpriseNick caps the nick at max(1, hpCurrent/5) when the
fighter enters wounded (HPCurrent < HPMax); at full HP the raw nick
stands. The existing 'nick < HPCurrent' KO-guard is preserved as a
backstop. /5 is the wounded-fighter lethality knob; tighter (/10) is
gentler, looser (/3) re-opens the cascade.

Live caller (runHarvestInterrupt) and harness (runHarnessFight) both
route through the new helper so the sim measures the same lever the
live caller applies.

Matrix delta is mild (encs +0.1-0.2 per cell, completion% still 0%)
but the tier-lethality trace stretches substantively: T1 trial 0 ran
5→8 encs / 5→7 days, T3 trial 1 saw a fighter survive multiple
chained interrupts at low HP that pre-2b would have ended on nick
alone. The remaining deaths are now legible as elite-one-shot fights
on fresh entries (Warchief, Green Hag, Roper, Young Red Dragon) —
that's the Phase 2c roster-gate signal.

Push-back on the original lever-order: the Phase 2a recap put roster
gate first, but the trace fingerprint named the wounded-entry nick
as the dominant cause-of-death in 4 of 5 tier traces. Doing nick-cap
first keeps Phase 2c's diagnostic clean and avoids re-tuning rosters
after another lever changes the shape under us.

Pre-existing failures unrelated to this change:
- TestAdv2Scenario_ZoneRunGoblinWarrens (advance regression, prior)
- TestMageSpellbookLineInRender (render assertion, prior)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:27:29 -07:00
prosolis
04aa887d18 Phase 2a (lever): expedition retreat semantics split
Phase 2 diagnostics named the InterruptElite bracket as the likely
first lever; tier-walking the lethality probe at the matrix cadence
told a different story.  Phase 1's uniform-0% baseline isn't an
elite-bracket calibration issue at all — every tier reads 0% because
the engine's TimedOut contract was being ignored by every expedition
caller.

combat_engine.go:451 says: "Timeout = retreat, not lethal blow.
Caller treats a timeout loss as 'fight ended, no character death'".
But runHarvestInterrupt / tryPatrolEncounter / resolveCombatRoom all
called abandonZoneRun + retireAllRegionRuns on any !PlayerWon —
ending the expedition outright on a retreat.  The retreat flavor line
("X outlasts you. You retreat from the expedition, wounded but alive")
was already in the code, just stapled to an actual run-abort.

Splits the policy by caller:

  • runHarvestInterrupt   — autopilot daytime interrupt.  TimedOut →
    retreat: threat +5, HP carries over, run continues, harvest slot
    forfeit (no kill / loot).  HP<=0 still ends the run + marks dead.
  • tryPatrolEncounter   — !advance pre-room patrol roll.  Same
    retreat policy: patrols don't gate progress, so retreating from
    one and walking into the next room is the right shape.
  • resolveCombatRoom    — !advance room/elite combat.  Unchanged —
    this path gates room progression; a retreat has nowhere to go, so
    any loss still ends the run.  (Manual zone runs were always
    intended to end here.)

Harness mirrored: daytime interrupt timeout → carry HP + threat bump
+ continue day; night-encounter loss → terminate (mirrors
resolveCombatRoom, since live night encounters defer to !advance).

retreatThreatBump = 5 is the per-retreat threat penalty.  Low enough
not to compound brutally with chained retreats, high enough that 3–4
retreats noticeably walks the threat clock toward Stirring.  Easy to
dial in Phase 3 if zones go off-band.

Phase 1 matrix after the change still reads 0% completion at every
cell — but the encounter counts and survival shape are dramatically
different (T4 underdark 3.6→7.5 encs; T3 underforge trial saw 18
encounters across 10 days where the pre-change run died on day 2/3).
Adds TestExpeditionBalance_Phase2_TierLethality, a tier-walking
companion to the T1/rolls=1 probe, that traces every fight at the
matrix cadence across one zone per tier — the actual Phase 2b lever
work picks from this data, not the old T1-only probe.

The remaining 0% is now legibly driven by tier-disproportionate elite
rosters (Hobgoblin Warchief at T1, Green Hag at T2, Roper/Helmed
Horror higher up) that one-shot or two-shot tier-appropriate
fighters.  Phase 2b's lever shortlist:

  1. Roster gate / SpawnWeight tuning to dilute over-tier elites.
  2. Surprise-nick floor reduction on chained interrupts (carryover
     HP + nick is the death-spiral fingerprint at T1 specifically).
  3. Per-day cadence reduction if 1+2 don't carry T1 to band.

Pre-existing test failures (TestAdv2Scenario_ZoneRunGoblinWarrens,
TestMageSpellbookLineInRender) verified to fail identically on HEAD;
no new test regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:01:07 -07:00
prosolis
0f09a421bc Phase 2 (diag): cadence sweep, gear-tier fix, lethality probe
Three Phase 2 diagnostic artifacts. No tuning knob has moved on
production code yet — these tests calibrate the harness and surface
the real first lever for next session.

1) Cadence calibration sweep
   TestExpeditionBalance_Phase2_CadenceCalibration sweeps
   HarvestRollsPerDay ∈ {1,2,3,4} across the full Phase 1 matrix and
   logs per-cell + per-tier completion. Required a new
   HarvestRollsPerDay field on expeditionBalanceProfile so cells can
   override the package-default constant. Finding: cadence is NOT the
   dominant lever — at rolls=1 the T1 cell only reaches 2%, with
   bimodal hp_left (100% survivors / 0% deaths). Killed the cadence
   hypothesis from Phase 1's commit message.

2) Gear-tier centerline fix
   phase1TierCenterline bumped for T3/T4/T5 (8→9, 11→13, 15→17). The
   shared gearTier ladder (5/9/13/17 boundaries) was placing T3/T4/T5
   centerlines one gear bracket *below* the zone's tier, so those
   cells fought with under-spec'd weapons/armor. New centerlines are
   the lowest level in each tier's design-doc range where gearTier ==
   tier. All centerlines still inside their design-doc ranges. Effect
   in the sweep at rolls=1: underforge T3 1.0% → 10.5% comp, underdark
   T4 flipped from pure combat-death to 14% starve (i.e. fighter now
   survives combat, runs out of food). Real bug, but small — the
   structural lethality problem remains.

3) Lethality probe + traceFight hook
   TestExpeditionBalance_Phase2_LethalityProbe runs 5 trials at the
   cleanest cell (T1 goblin_warrens L3 fighter, rolls=1) with a new
   optional traceFight hook on expeditionHarness that logs
   monster/AC/atk/HP-pre/HP-post/outcome per fight. Hook is nil in
   production runs, zero cost when unused. Finding: at T1, the
   InterruptElite branch keeps drawing Hobgoblin Warchief (AC 18,
   atk +5) from goblin_warrens' elite roster, and an L3 fighter has
   ~coin-flip odds against a CR-6-ish elite. One bad draw = dead;
   that's the bimodal hp_left fingerprint from the sweep. Non-elite
   draws (Worg, AC 13) play out as normal multi-round combats and
   are winnable.

Next-session lever choices, in order of suspected impact:
  - Roster gate: Hobgoblin Warchief out of (or weighted down in)
    the T1 elite pool — it's tier-disproportionate for goblin_warrens.
  - InterruptElite threshold: rarer elite-bracket draws at low threat
    so a single d20 swing doesn't equal expedition end.
  - Tier-floor cap on already-over-tier bestiary entries.

Plan doc: gogobee_expedition_difficulty.md §Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:39:31 -07:00
prosolis
881cbfca2f Phase 1: expedition-difficulty full matrix baseline
New permanent test TestExpeditionBalance_Phase1_FullMatrix — every
registered zone × its tier-centerline level, 200 trials/cell, Fighter
fixed (class parity is the class-balance pass's job).

Centerlines per plan doc §Method (median of design-doc level range):
T1=L3, T2=L5, T3=L8, T4=L11, T5=L15.

Gates split: wiring pathologies (zero-day loop, days > cap) are fatal;
"0% at T1 / 100% at T5" land as WARN log lines, not t.Errorf. Phase 1's
deliverable is a logged baseline, not a tuning gate — Phase 2 promotes
those to band assertions once the global levers move.

Baseline numbers (seed 0xE0FFEE1, 200 trials, current Phase 0 harness):

  all 10 cells: 0.0% completion, 100% death, median day 2,
  avg encounters 2.0-3.6, threat at end 5-7.

The uniform 0% says the placeholder harnessHarvestRollsPerDay=4 plus
the tier-floored AC/atk is overwhelming the fighter on day 1 across
all tiers, well before threat drift matters. Per-tier spread is 0.0pp
because every zone is floored — Phase 2 has plenty of headroom and a
clear first lever (encounter cadence).

Plan doc: gogobee_expedition_difficulty.md §Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:09:47 -07:00
prosolis
53d1608490 Phase 0: expedition-difficulty sim harness spike
New file internal/plugin/expedition_balance.go — sibling to
dnd_class_balance.go, no dnd_ prefix per feedback_avoid_dnd_naming.
Plan doc: gogobee_expedition_difficulty.md.

Critical de-risk settled: clock-injection seam. The harness does not
run the wall-clock tickers (expeditionBriefingTicker @ 06:00,
expeditionRecapTicker @ 21:00, expedition_ambient @ 3h) and does not
inject a virtual time.Now. Instead, advanceExpeditionOneDay
reimplements the morning→day→night pipeline by calling the math-pure
helpers directly: applyDailyBurn, dailyThreatDrift,
resolveCombatInterrupt, resolveWanderingCheck, simulateCombatWithRNG.
No DB, no goroutines, no clock.

Phase 0 trade-offs (documented in the file header):
- Boss completion deferred; survive-N-days is the proxy.
- Per-region zones, loot/XP, pardon/Sovereign, babysit, temporal
  stack effects all skipped — Phase 1+ scope.
- Encounter cadence (harnessHarvestRollsPerDay=4) is a placeholder
  to be calibrated against live traces in Phase 1.

Tests:
- TestExpeditionBalance_Phase0_Spike — T2 Crypt Valdris × L5 Fighter,
  100 trials, asserts only degenerate sentinels (no 0%/100%, days>0,
  days<=cap). Runs in <3s.
- TestExpeditionBalance_Phase0_SeedSpread — confirms the RNG seam is
  actually wired by showing two distinct seeds produce different
  trials. Full byte-for-byte reproducibility under same seed is not
  asserted at Phase 0; surpriseRoundNick + pickWanderingMonster
  draw from package-global rand (same caveat as class balance).

Spike numbers (calibration baseline, NOT a target): 1% completion,
99% deaths, median 4 days, ~6 encounters. The headline death rate is
loud but expected — Phase 1's full matrix will diagnose whether it's
HP-carryover punishment, bestiary-vs-abstract-monster delta, or kit
ladder mismatch. Phase 2 is where the centerline gets tuned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:37:23 -07:00
prosolis
9aca6b202d Phase D: presence intent comment + sendZoneCombatMessages contract
D1: single block comment above the presence.go best-effort QueryRow
cluster (lines 149-198). Zero-value fallbacks are correct for new users
with no activity rows yet.

D2: contract comment on sendZoneCombatMessages — returns a done channel,
callers chaining post-flush work MUST consume it; in-flow handlers with
nothing to chain MUST explicitly discard. Blocking on flush would stall
command handlers behind the 2-3s/message pacing, defeating streaming.
Two fire-and-forget sites (adventure_arena.go round-advance,
dnd_zone_cmd.go streamFlow) now use `_ =` + comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:03:42 -07:00
prosolis
2907a8ac1d Phase C2+C3: atomic RunMaintenance + weekly integrity_check
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>
2026-05-15 07:57:10 -07:00
prosolis
ce2f59c332 Phase C1: gate db.Backup() on GOGOBEE_BACKUP_DIR
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.
2026-05-15 07:50:11 -07:00
prosolis
b4b1e0ee29 Phase B2 follow-up: first-person voice across all TwinBee flavor files
Sweep of the remaining four twinbee_*.go flavor files (gm, resource,
ambient, housing). Every 'TwinBee [verb]' / 'TwinBee says' / 'TwinBee
notes' rewritten into first-person or implicit-subject voice. Jokes,
references, and pacing preserved; a few lines got slightly sharper
punchlines on the way through.

Files touched:
  - twinbee_gm_flavor.go        (~270 lines, all narration pools)
  - twinbee_resource_flavor.go  (~100 lines, harvest/fish/loot pools)
  - twinbee_ambient_flavor.go   (~40 lines, between-day ticker)
  - twinbee_housing_flavor.go   (7 lines; Thom Krooke 3rd-person and
                                 Pastel 1st-person voices preserved)

twinbee_npc_flavor.go intentionally untouched: those are Pete
announcements referring to TwinBee, which is correctly third-person
(different speaker).

DO-NOT-REWRITE headers replaced with voice-convention notes covering
future additions.
2026-05-15 07:46:04 -07:00
prosolis
9e70f9db79 Phase B2: first-person voice pass on twinbee_expedition_flavor.go
Every 'TwinBee [verb]' / 'TwinBee says' / 'TwinBee notes' rewritten
into first-person or implicit-subject voice. Header DO-NOT-REWRITE
prohibition retired (it predated the realization that the third-person
voice was actively annoying); replaced with a voice-convention note
covering future additions.

Sibling twinbee_*.go files still carry third-person references; sweep
to follow.
2026-05-15 07:29:30 -07:00
prosolis
20647eeafc Phase B1 follow-up: regex catches bare-die notation; blink overlay shim
The A1 regex banned \b\d+d\d+\b (e.g. '1d20') but missed bare die
notation ('d20', 'd6') with no leading count. SRD's 'blink' Description
slipped through with 'Roll a d20 at the end of each of your turns...'.

- Add \bd(4|6|8|10|12|20|100)\b ban to the prose regex sweep.
- Add hand-authored overlay shim for blink in dnd_spells_data.go.
  Classes union with SRD preserves Cleric/Sorcerer/Warlock availability.

Test now passes the wider sweep; no other reachable spells leak.
2026-05-15 07:18:16 -07:00
prosolis
f198041530 Phase B1: jargon-free !help blurbs in adventure Commands()
Strip 'Adv 2.0', dice formulas, DCs, and ability-mod math from the 21
player-facing Description strings in AdventurePlugin.Commands(). Rewrite
harvest verbs (forage/mine/scavenge/essence/commune/fish) into outcome
language. !roll keeps its dice syntax since that's a format spec, not
gameplay jargon.

No runtime changes; build + vet clean.
2026-05-15 07:11:40 -07:00
prosolis
2ef0b25266 Phase A1: jargon-free flavor on all reachable spell Descriptions
The audit plan flagged jargon leaks in the SRD spell prose surfaced by
!cast and !spellbook. Verifying against HEAD turned up two corrections
to the plan's framing:

- The overlay in dnd_spells_data.go wins on ID collision, so for the
  ~76 hand-authored spells the *overlay* Descriptions are what players
  actually see — and those were crunchier than the SRD ("Heal 1d8 +
  WIS mod HP. Touch.", "8d6 fire in 20 ft radius. DEX save half.",
  etc.). Touching only the generated SRD file would have missed the
  bigger leak.
- The Upcast field is never displayed at runtime (only the import
  generator reads it), so testing it against the regex would test
  dead data.

So this commit:
- rewrites every overlay Description into playful, jargon-free flavor
  in the charm_person voice ("Sweet-talk a humanoid…") — no dice, no
  saves, no AC math, no ability-mod references
- adds 31 overlay shims for SRD-only spells whose generated text was
  broken (placeholder gaps from the Open5e classifier — "no larger
  than a.", "up to to", "in a in range"), empty ("You touch a
  creature."), typo-laden ("magicou", "undeadou", "diseasesou"), or
  jargon-heavy (false_life's "1d4+4 temporary hit points")
- adds TestSpellDescriptionsAreJargonFree, a regex sweep over the
  merged registry that bans "saving throw", "spell slot of",
  "ability modifier", "hit points equal to", and \bNdN\b dice
  notation. Currently passing; will fail-loud on regressions or new
  SRD imports that need overlay shims.

The generated dnd_spells_srd_data.go is left untouched — overlay is
the regen-safe surface per its own header comment.
2026-05-15 07:04:37 -07:00
prosolis
368b980650 Expedition autopilot Phase 3: ambient between-day ticker
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()
2026-05-14 23:28:54 -07:00
prosolis
632e5ee315 Combat: fix Orc Rage missing fire when enemy two-shots across rounds
The rage threshold check sat only at the top of resolvePlayerAttack,
so a player who took a threshold-crossing hit and was then killed by
the very next enemy swing (before getting back to their own attack)
would never see "rage" emitted — even though HP visibly crossed 50%
while alive.

Extracted maybeTriggerOrcRage shared by both sites:
- Top of resolvePlayerAttack (unchanged UX: rage applies same-round
  when the player swings after the enemy hit).
- End of runRound as a backstop (catches cross-round two-shots).

st.raged guards against double-emit. Fixes the flaky
TestOrcRageFiresOnLowHP; 10/10 repeats green.
2026-05-14 23:17:12 -07:00
prosolis
f404f95202 Expedition autopilot Phase 2: auto-harvest on room entry
Each walked room gets one auto-harvest pass across every applicable
action (forage, scavenge, mine, fish in water zones, plus the class-
restricted essence/commune). Class/kill/event gates are inherited
from the manual harvest path.

Stop rules:
- Rare+ nodes are NOT auto-attempted; they pause the walk via
  stopRareNode so the player spends the attempt deliberately.
- Standard/Elite/Patrol interrupts hard-stop the walk (stopEnded on
  death, new stopHarvestCombat on survive).
- Noise interrupts apply threat+2 and continue, matching manual
  !harvest behavior.

Display:
- Per-room compact footer ("+2 Scrap Iron, +1 Shadow Herb · 1 fail")
  streamed inline as autopilot walks.
- Walk-end cumulative tally appended to whatever final block fires.

No SU surcharge — parity with manual !harvest.

Pure helpers unit-tested (rarity classification, footer rendering,
walk tally sort order, rare-pending dedup). Full autoHarvestRoom
path needs prod DB and follows the existing setupAuditTestDB gating
pattern.
2026-05-14 23:13:01 -07:00
prosolis
14f9b3159f Expedition autopilot Phase 1: !explore / !expedition run
Adds an autopilot loop that walks rooms automatically until a
natural or threshold interrupt fires.

- New !explore (top-level alias) and !expedition run subcommand
  driven by expeditionCmdRun in dnd_expedition_cmd.go.
- advanceOnce extracted from zoneCmdAdvance so the autopilot and
  the manual !zone advance path share one room-step implementation.
- Stop reasons: fork, elite/boss doorway, death, complete,
  active-combat block, HP <= 30%, SU < 1 day's burn, 6-room cap.
- Trash combat continues to auto-resolve inside resolveCombatRoom;
  no changes there.
- TestAutopilotFooter_Reasons covers the pure stop-reason logic;
  preflight + walk tests skip without the prod DB, matching the
  existing setupAuditTestDB pattern.

Phase 2 (auto-harvest) and Phase 3 (real-time ticking) deferred.
2026-05-14 23:02:53 -07:00
prosolis
c2fdc63b51 UX S4: magic-item polish — slot fixes, swap-back, shop & sheet truth-up
B4: Slot classifier no longer treats "Springing" / "Snaring" / "Devouring"
as ring matches; tokenises by word boundary instead of raw substring.
Adds DnDSlotCloak so cloaks/capes/mantles/wings stop evicting body armor
from the chest slot. Regenerated magic_items_srd_data.go: boots_of_*,
gloves_of_missile_snaring, bag_of_devouring, talismans, and 6 cloaks all
land in the right slot.

R4: equipMagicItem swap-back returns the prior occupant at full Value
instead of half — swapping a curio shouldn't tax it.

R5: Attunement count is recomputed *after* the swap-back so freeing the
prior occupant's bond opens the slot for the incoming item.

R1: Inventory tags magic_item rows with 🔮 + rarity label and prints a
single equip-magic footer when any are present.

R6: Sheet's Magic Items block marks unbonded items as **(inactive)**
with the reason (cap full vs unbonded), so over-cap items aren't silent.

R7: New activeMagicItemsLine surfaces a one-shot "your curios stir: …"
at combat-start in both the dungeon path and !fight, mirroring the way
class passives are surfaced.

R8/R9: dropMagicItemLoot pretty-prints rarity, drops "wondrous", calls
attunement "needs bonding", appends "auto-uses in combat" for
potions/scrolls, and routes persistence errors to slog instead of
leaking %v into chat.

R2/R3: Curios shelf now shows "Very Rare" not "very_rare", drops the
bare "wondrous" word (the effect line carries it), renders the codified
magicItemEffectSummary above the SRD desc, and ends with a one-line
plain-language "what is bonding" footnote.

R10: Curios stock day flips at 06:00 UTC instead of midnight so EU
players don't see a fresh shelf at 1 a.m. mid-session.

R11: Curios buy resolver disambiguates fuzzy matches — typing "ring"
when several rings are on the shelf lists candidates instead of
silently selling the first.

P1: Greeting grid pairs Curios with an Exit chip so the 2-column
emoji layout doesn't dangle.

P2: Equip-magic empty state trimmed to one line.

P4 (back-from-curios reprompt) deferred — the existing back-flow is
correct, just verbose; not worth the surface-area expansion this
session.

Tests: word-boundary classifier, cloak/chest coexistence, full-value
swap-back. go test ./... + go vet clean.
2026-05-14 22:37:47 -07:00
prosolis
8ee170bb9b UX S7: sheet + menu jargon sweep — outcome-first copy
Drops D&D-handbook syntax from the player-facing caster UX in favor of
verbs and feelings (per feedback_accessibility_over_dnd_crunch).

R12 — Drop "Spell DC: N / Spell Atk: +N" line from the spellbook view.
The numbers are pure handbook noise; the engine still computes them.

R13 — renderSlotLine swaps "L1 3/4 · L2 1/3" for "Level 1 ●●●○ · Level 2
●○○○". Filled bullets = energy left, hollow = spent.

R14 — Caster passive Description strings rewritten outcome-first: no
"+5%", no "scaled by your Charisma". Verbs and texture instead. All ten
class passives reworded; mechanics in applyClassPassives unchanged.

R18 — Class menu drops the "(d8, INT/WIS)" suffix → "**Mage** — INT &
WIS". The HP-die number was leaking implementation. !setup class confirm
line gets the same treatment.

P5 — Spellbook headers: "Cantrip" → "Cantrips", "L1" → "Level 1".

P6 — Cast queue line: "_(upcast to L2)_" → "_(empowered)_". Queued-line
in the spellbook view follows suit — drops "(L2 slot)" for "(empowered)"
when the slot is above the spell's base level.

P8 — Comment-convention marker added to dnd_passives.go and
dnd_subclass_combat.go file headers: `// internal note (not user-facing)`
flags Phase 2/3 tuning history so future codegen doesn't lift it into
Description / Flavor strings.

P9 — Spellbook line drops the duplicate "(can learn N more)" trailer —
the "%d / %d" already conveys remaining capacity.

Bonus cleanup. The two Arcane-Trickster slot-ceiling errors ("Arcane
Trickster L5 only has up to L1 slots.") and the "No L1 slot available"
out-of-energy messages get the same jargon scrub: "At level 5, your
Arcane Trickster magic only reaches level-1 spells." / "You're out of
level-1 energy."

Tests. Full plugin suite green; no test pinned the old strings.
2026-05-14 22:17:03 -07:00
prosolis
6386161402 UX S6: race truth-up — wire Tiefling fire resist + best-fit hints
R22: replace race copy that promised mechanics the engine doesn't deliver.
- Tiefling: wire FireResist as a CombatModifier. Enemy main attack is
  halved when monster is FireAttacker-tagged; aoe_fire abilities are
  halved unconditionally; fire-tagged traps deal half damage to Tieflings.
  DnDMonsterTemplate carries FireAttacker; toCombatStats propagates it.
  Hand-authored fire entries tagged in dnd_bestiary.go (flameskull,
  magmin, azer, salamander, fire_elemental, emberlord_thyrak,
  young_red_dragon, infernax, belaxath).
- Open5e tuned generator derives FireAttacker from the highest-AvgDamage
  attack's DamageType (threshold AvgDamage>=5). 19 tuned monsters tag.
  Regenerated bestiary_tuned_data.go.
- Elf: drop "immune to sleep" (no sleep mechanic); reframe as keen
  senses + trance flavor.
- Half-Elf: drop "two bonus skill proficiencies" (no skill system);
  reframe as adaptable cross-cultural know-how.
- Tiefling copy: drop "bonus on CHA checks" (no checks); keep fire
  resistance with flavor framing.

R23: DnDRaceInfo grows a BestFit field; renderRaceMenu emits an
"_best with: …_" hint per race so spiky stat spreads (Orc -1/-1/-1)
read as specialist picks rather than a brick of penalties.

R24: dnd.go header comment for the caster classes now reflects the
shipped state (Playable=true, spell lists populated) instead of the
pre-Open5e scaffold language.

Tests: TestApplyRacePassives gains a FireResist column; new
TestTieflingFireResistance asserts ~0.5x ratio over a 300-trial sweep
against a FireAttacker enemy. Full suite green.
2026-05-14 22:06:31 -07:00
prosolis
1512f6cc50 UX S3: SRD copy-edit pass — sanitize jargon + curated overrides
Plumbed through the open5e importer so regen stays safe:

- New cmd/open5e-import/desc_overrides.go holds two per-ID override
  maps (spellDescOverride, magicItemDescOverride) and a regex-driven
  cleanDesc sanitizer. Override wins outright; otherwise the SRD
  first-sentence runs through cleanDesc, which strips the phrases
  the S3 acceptance criteria forbid (saving throw[s], spell slot,
  within range, 5-foot, DC <n>, "(save DC X)" parentheticals,
  "constitution score is N", "out to a range of N feet"). A small
  post-pass repairs the orphan stubs the strippers leave behind
  (" and." trailers, "must make." after the saving-throw object
  is gone).

- gen.go (spells) + magicitems.go now call spellDescription /
  magicItemDescription instead of raw firstSentence; same hand-
  authored override pattern, same cleanDesc fallthrough.

- Override coverage: the 19 SRD-only spells that show up in
  defaultKnownSpells (call_lightning, charm_person, vicious_mockery,
  …) plus ~35 high-visibility magic items (Amulet of Health, every
  Belt of Giant Strength variant, Cloak of Displacement, etc.).
  Tone is outcome-first second-person with bite — these surface
  in the spellbook and the curio shop, so they get to be funny.

- tuned.go (R21) + magicitems.go strip "(...)" from emitted Names
  via stripNameParenthetical. Slug keeps the variant; only the
  display text loses the qualifier. Two bestiary entries
  (giant_rat_diseased, deep_gnome_svirfneblin) and stone_of_good_luck
  affected.

- Regenerated all three data files. Acceptance grep is clean:
  zero hits for any banned phrase in Description/Desc fields.

- New cmd/open5e-import/desc_overrides_test.go covers the
  sanitizer regressions, orphan-repair, name-strip, and the
  override-wins-but-fallthrough-still-sanitizes path.

Conflicts: none. S4 (magic-item UX) wanted this in first so the
new curio renderer consumes clean text — ready for it now.
2026-05-14 21:53:06 -07:00
prosolis
c48e12a296 UX S2: caster onboarding — strip reaction defaults, deterministic healing_word, route hints
B2: Removed shield/counterspell from Mage/Sorcerer defaults and hellish_rebuke/counterspell
    from Warlock — reactions are EffectReaction and combat has no reaction window yet, so
    auto-granting them just litters the spellbook with dead entries. Substituted L1/L3
    staples (sleep, fly, burning_hands, chromatic_orb where class-tagged appropriately).

B3: Replaced the stale "Mage, Cleric, Ranger, and Arcane Trickster (L5+)" enumeration in
    !cast/!spells learn/!prepare error paths with class-aware route hints via a new
    spellRouteHintFor() helper.

R15: Removed the duplicate hand-authored healing_word_spell entry; Cleric default now
     references the canonical SRD healing_word (cleric/druid/bard tagged). parseSpell
     and lookupSpell now resolve "healing word" deterministically.

R16: !cast --drop now appends "(slot refunded)" when applicable.

R17: Prep-cap message rewritten to "Your prepared list is full (N/N)."

R19: Removed shillelagh from Druid default — the overlay flags it cleric/ranger only and
     it has no real attack profile yet (BuffSelf, no DamageDice).

Bonus correctness: dndSpellRegistry now unions Classes on overlay collision instead of
replacing wholesale. The hand-authored buildSpellList() entries were tagged Mage-only,
so every Sorcerer/Warlock/Bard/Druid spell that collided with the SRD silently lost its
class list — meaning auto-granted defaults were rejected at the classOK gate. Union
merge restores SRD coverage without forcing a hand-edit of every overlay entry.

Tests:
- TestDefaultKnownSpellsHaveNoReactions covers B2.
- TestDefaultKnownSpellsAreCastableByClass guards the overlay-narrow regression.
- TestHealingWordResolvesDeterministically covers R15.
- TestCastNonCasterErrorHasNoClassEnumeration covers B3.
- Existing dnd_prepare_test.go updated to reference healing_word.
2026-05-14 21:30:15 -07:00
prosolis
25accab5c0 UX S1: sell-all guard against magic-item deletion (B1)
`!adventure sell all` and `!adventure sell <name>` previously treated
Type=="magic_item" rows as bulk loot, silently deleting bonded curios for
their base coin Value. Mirror the existing MasterworkGear/Arena/card
branch and reroute the player to `!adventure equip-magic`.

Tally kept curios in the sell-all summary; surface a dedicated
empty-rejection message when the only contents are curios; add the same
guard to the single-item path.

Tests cover: mixed bag (magic item preserved, junk sells), curio-only
bag (no-op + reroute), explicit sell <curio name> (refused + reroute).
2026-05-14 21:19:31 -07:00
prosolis
d7fe32d893 D&D: class-balance Phase 3 — off-tier caster lift (druid + sorcerer)
Design steer from user — "relatively easy but not too easy" — narrowed the
target: lift the embarrassing caster trailers on off-tier cells (a casual
player walking into a slightly too-hard dungeon underleveled) without
pushing the already-saturated in-tier ceiling.

Levers:
- Druid passive: was the only chassis with a purely defensive passive
  (5% DR, no offense), and it read it — L1/T1 mean 0.77 (lowest at the
  entry tier), L1/T2 0.04. Added a level + WIS-scaled FlatDmgStart burst,
  same shape as the Phase-2 Bard/Mage/Warlock pass. Kept the DR; no
  DamageBonus rider so high-tier ceilings stay flat.
- Sorcerer passive: burst base 3→5. Sorcerer was second-worst caster
  off-tier (L1/T2 0.10 vs Mage 0.27 pre-tune) despite a comparable stat
  line; the bump pulls it toward arcane-chassis parity.

Observed lifts:
- Druid L1/T1: 0.77 → 0.86 (+9pp) — chassis now functional at its
  intended tier
- L2/T2 cross-class spread: 77pp → 63pp; druid trailer 0.23 → 0.35
- L1/T1 spread: 23pp → 14pp

Off-tier diagnostic: added a focused log to TestClassBalance_Phase1_FullMatrix
that names the trailing class at each off-tier (lvl, tier) cell. Not
asserted — L1 in T2 is *supposed* to be hard, so the diagnostic is for
watching the gap, not the absolute number.

In-tier parity assertion (35pp band on the diagonal) still passes;
TestApplyClassPassives updated for the new druid/sorcerer FlatDmgStart
values; full plugin -short suite clean.
2026-05-14 20:32:41 -07:00
prosolis
76f814c0c9 D&D: class-balance Phase 2 — passive + L5 subclass tuning + in-tier parity assertion
The Phase 1 per-class-mean summary was hiding the truth — most cells are
floor/ceiling-saturated (L10+ pinned at 1.0, L1-4 caster cells at high
tier pinned at 0.0), so means barely budge when you tune passives. Added
a per-(level, tier) cross-class spread diagnostic to the matrix log,
then tuned with the levers from doc §6 in priority order:

1. Class passives (dnd_passives.go) — caster trailers (Bard, Mage,
   Warlock, Sorcerer) gained level + casting-stat-scaled FlatDmgStart
   bursts so the L1-4 chassis isn't a quarterstaff + one weak spell
   against a T2-T3 monster; small DamageBonus riders (Mage/Bard/
   Sorcerer/Rogue +5%, Warlock 10→12%) and +1 attack for Bard/Warlock
   close the steady-DPS gap. Added clampNonNeg so ability-mod-scaled
   additions never go negative on sub-10-stat sheets.

2. Subclass L5 tiers (dnd_subclass_combat.go) — the three Sorcerer L5
   picks (Wild/Storm/Draconic) and Warlock Great Old One were defense-
   only or near-inert pre-tune; each gained a small bite (DamageBonus
   +0.10, or a FlatDmgStart burst for Storm) so the L5 chassis can press
   through a T4 monster.

Parity band locked in TestClassBalance_Phase1_FullMatrix: cross-class
spread ≤ 35pp on the in-tier diagonal — (level, tier) cells where the
level is appropriate for the tier (T1: L1-4, T2: L3-7, T3: L5-10, T4:
L7-15, T5: L10-20). Off-tier cells (L1 mage at T3 dungeon etc.) are
still logged but not asserted: those are level-vs-tier mismatches and
casters at L1-4 can't muscle through a T3 monster on a single L1-slot
spell the way martials muscle through with weapon dice. Worst in-tier
cell after tuning: ~26pp at L3/T2. The 35pp band gives ~9pp Monte-Carlo
headroom over the worst signal at 200 trials/cell.

TestApplyClassPassives expectations updated to match the new passives.
Phase 0 spike still green, full plugin suite (-short) clean.
2026-05-14 20:15:16 -07:00
prosolis
ddfa89e7a7 D&D: class-balance Phase 1 — full 10×30 measurement matrix
Generalizes the Phase 0 spike harness to the full build matrix the
class-balance doc plans for. No tuning yet — just measurement.

- classBalanceProfile gains Subclass; buildHarnessCharacter sets it on
  the synthetic DnDCharacter; buildHarnessPlayer now calls
  applySubclassPassives after class+race passives, matching live order
  (combat_bridge.go, combat_session_build.go). Subclass="" is a no-op,
  so L1–L4 pre-unlock rows are unaffected.
- buildPhase1Profiles yields 190 rows: 10 classes × 4 pre-subclass
  levels (L1–L4) + 10 classes × 3 subclasses × 5 post-unlock checkpoints
  (L5/7/10/15/20). Order is registry order so output reads like the
  design doc / !class help.
- TestClassBalance_Phase1_FullMatrix runs the matrix at 200 trials/cell
  (~5.5s) and logs every cell plus a per-class tier-mean summary with
  min/max range. Only harness-broken pathologies fail the test (0% at
  T1 anywhere, or 100% at T5 for an L1 build); per-tier parity bands
  land in Phase 2 once we have data to calibrate the tolerance.

Phase-2 baseline from this run: at T4 the cross-class spread of mean
win rate runs Bard 0.62 → Fighter 0.80 (~18pp); at T5 0.48 → 0.64
(~16pp); casters trail martials at the post-unlock tier (T3) by ~20pp.

Phase 0 test (TestClassBalance_Phase0_FighterVsMage) still green with
identical numbers — the additional applySubclassPassives call is a
no-op for Subclass=="".
2026-05-14 20:00:00 -07:00
prosolis
4dd1ab9f96 D&D: class-balance Phase 0 — Fighter-vs-Mage Monte Carlo spike
Per gogobee_class_balance.md §5 Phase 0: harness skeleton, equipment
and spell-selection policies, Fighter-vs-Mage plausibility run before
Phase 1 generalizes to the full 10 × 30 matrix.

Bypassed in Phase 0 (per doc §2): DB-touching layers (magic items,
armed abilities, pending-cast persistence), subclass passives (none
below L5), and race passives beyond Human +1-all. Everything else
flows through the production combat path.

Initial numbers (400 trials/cell, dungeon T1..T5):

  fighter L1  .998 .805 .165 .020 .000
  fighter L3 1.000 .998 .795 .235 .035
  mage    L1  .880 .190 .003 .000 .000
  mage    L3 1.000 .950 .158 .003 .000

Both classes win at T1 (spell policy is firing — Magic Missile lands
each Mage fight); both collapse by their off-tier — monster scaling
works. The Mage's L1 T2 gap (-60pp vs Fighter) is real data, not a
broken harness. Phase 1 picks up the full matrix from here.
2026-05-14 19:54:01 -07:00
prosolis
0799b6a57b D&D: class-balance pass plan doc (gogobee_class_balance.md)
Phased plan for the class-balance pass. Unlike the race pass, classes
are *measured* not modeled: combat is one-shot auto-resolved through a
seedable simulateCombatWithRNG, so the harness runs Monte Carlo over the
real engine and reads win rates directly.

Scopes the full matrix (10 classes × 30 subclasses × level checkpoints ×
monster tiers), the two hard policies to de-risk in Phase 0 (equipment
loadout, spell selection), the per-tier win-rate parity rule, and the
tuning levers. Phase 0 spike in progress.
2026-05-14 19:47:10 -07:00
prosolis
9d6192dc6a D&D: weighted race-balance pass — tune races to equal effective power
Flat net ability mods aren't equal *effective* power: a +1 in a stat a
build uses beats a +1 in a dump stat. Add dnd_race_balance.go, which
scores each race's mods against a 60/40 blend of per-class combat stat
priorities and class-independent non-combat utility (zone locks,
expedition harvest, skill checks, haggling).

Retune all races so their mean score across playable classes lands
within ±0.5 of the Standard Human baseline (6.0); best-fit/worst-fit
spread is kept as intentional race identity. TestRaceBalance logs the
report and asserts the rule.
2026-05-14 19:47:01 -07:00
prosolis
9f762787f6 D&D: give Human +1 to all stats and rebalance races to +6 net
Human was mechanically blank (all-zero mods, "not yet implemented"
passive). Wire the Standard Human flavor: +1 to every ability score,
no setup-wizard choice so !setup stays uniform across races.

That put Human at +6 net with no downside, ahead of every other race.
Retune the rest to +6 net as well, keeping their negatives intact so
each stays a spiky specialist rather than a flat generalist.
2026-05-14 19:11:19 -07:00
prosolis
0d666beea3 D&D: wire the Open5e magic-item registry into live gameplay
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).
2026-05-14 18:38:57 -07:00
prosolis
297ce3d786 D&D: wire monster abilities from SRD traits in the tuning pass
The tuned bestiary previously left every generated entry with a nil
Ability. abilityFromTraits now classifies each creature's SRD trait
names against a priority-ordered rule table, mapping the most
combat-defining trait onto a MonsterAbility effect (death_aoe,
regenerate, spell_resist, evade, enrage, ...). Creatures whose traits
are all non-combat stay nil. 165 of 322 entries get an ability.
2026-05-14 18:00:29 -07:00
prosolis
15cfe065a3 D&D: import Open5e SRD magic items as a vendored reference registry
fetch/gen magicitems subcommands vendor data/open5e/magicitems.json (237
SRD items) and classify them into a generated registry. magic_items.go
holds the MagicItem struct + Kind enum + an init-time overlay merge where
a hand-authored entry wins on ID collision, mirroring the spell and
bestiary imports. Not yet wired into zone loot or the shop — that
integration is a deliberate follow-up.
2026-05-14 18:00:29 -07:00
prosolis
908e2b0855 D&D: codified bestiary tuning pass — derive tuned roster from SRD staging
Adds `gen tuned` to cmd/open5e-import: a deterministic formula that scales
every raw SRD stat block down to an engine-ready DnDMonsterTemplate. HP/AC/
AttackBonus are verbatim SRD (AC clamped to the engine min 10); the Attack
stat is interpolated from attackByCRPoints, a CR→Attack anchor table lifted
from the hand-tuned dndBestiary (CR is the calibration axis the 2026-05-10
rebalance used — raw SRD per-hit damage is ignored). Speed/BlockRate are
coarse baselines from SpeedWalk/AC.

bestiary_tuned.go merges the 322 generated templates into dndBestiary, but
hand-authored roster entries win — the merge only fills IDs the roster does
not already define, so playtested numbers and wired abilities are untouched.

Abilities are deliberately not wired: every generated entry has a nil
Ability, with the SRD multiattack/trait text parked in Notes as raw material
for the follow-up ability-wiring pass.
2026-05-14 16:45:09 -07:00
prosolis
53be17e6fe D&D: import Open5e SRD bestiary as a raw staging table
fetch|gen bestiary subcommands vendor data/open5e/monsters.json (322
SRD monsters) and generate bestiary_srd_data.go — all 322 as raw SRD
stat blocks (HP/AC/ability scores/CR + per-attack damage dice).

This is a balance-baseline reference, not an engine roster: raw SRD
damage one-shots the solo player, so nothing here feeds combat. It's
what the future tuning pass reads against when deriving dndBestiary /
srdProfiles entries. XP is derived from CR (Open5e has no XP field).
2026-05-14 15:28:42 -07:00
prosolis
d6ea08bba6 D&D: class passives + subclasses for the five caster classes
Adds signature class passives for Druid/Bard/Sorcerer/Warlock/Paladin in
dnd_passives.go, each riding an existing CombatModifiers channel so a
playable caster is no longer mechanically empty: Druid Wild Resilience
(damage reduction), Bard Bardic Inspiration (initiative), Sorcerer Innate
Sorcery (CHA-scaled pre-combat burst), Warlock Agonizing Blast (+10%
damage), Paladin Divine Smite (level-scaled burst).

Adds 15 subclasses (3 per caster, registry now 30) using canonical 5e
archetype names, with passive-only L5/7/10/15 effects in
applySubclassPassives — consistent with the majority of the original
fifteen, no new resource pools or active abilities. subclassesForClass is
now non-empty for casters, so the L5 !subclass prompt and sheet display
light up through the existing generic plumbing.

Tests: TestApplyClassPassives extended to all ten classes and the new
channels; TestSubclassRegistry_* updated to expect 30 entries across all
ten classes.
2026-05-14 15:18:29 -07:00
prosolis
6ef8b9fd0a D&D: import Open5e SRD spell lists, make the five casters playable
Rebuilds the cmd/open5e-import CLI (fetch/gen × spells) to vendor
data/open5e/spells.json (319 SRD spells) and generate
dnd_spells_srd_data.go (237 after the level>5 filter). mapClasses unions
the API's incomplete structured spell_lists field with the complete
free-text dnd_class field so all eight casters get spells.

dndSpellRegistry loads buildSRDSpellList() first; the hand-authored
buildSpellList() overlays it (hand wins on ID collision). Playable=true
flipped for Druid/Bard/Sorcerer/Warlock/Paladin, each with a
defaultKnownSpells case. TestDefaultKnownSpellsExistInRegistry now covers
all eight classes.

.gitignore: vendor data/open5e/ while keeping the rest of data/ ignored,
and anchor the open5e-import binary pattern so it stops swallowing the
cmd/open5e-import source dir. NOTICE adds CC-BY-4.0 / SRD attribution.
2026-05-14 15:18:18 -07:00
prosolis
e6e0009253 D&D: scaffold the five missing caster classes
Add Druid, Bard, Sorcerer, Warlock and Paladin as structural
scaffolding ahead of the Open5e spell-data import. They are wired
through the mechanical layer but gated out of !setup via the new
DnDClassInfo.Playable flag — they have no spell list yet.

- dnd.go: class constants, dndClasses rows, Playable gate, AC floors
- dnd_combat.go: classAttackStatMod, dndClassWeaponBonus,
  classStatPriority arrays for the five
- dnd_spells.go: mageSlots generalized to fullCasterSlots (shared by
  the full casters); Paladin shares rangerSlots; new warlockSlots
  simplified long-rest pool; spellcastingMod + classIsCaster extended
- dnd_setup.go: renderClassMenu and parseClass filter on Playable

Subclasses and spell lists are deferred to later sessions.
2026-05-14 14:43:05 -07:00
prosolis
0cd8fd3337 Combat: back the flavor-only monster abilities with real effects
Turn the four placeholder ability effects into working mechanics:
spell_resist halves player spell damage, reveal_action rolls the
player's next swing at disadvantage, fear_immune fizzles control
spells, and ally_buff grants an accumulating enemy attack bonus.
All four are armed by applyAbility, read by the shared resolution
primitives, and round-tripped through CombatStatuses for turn-based
suspend/resume. New branches are guarded by zero-valued state so the
auto-resolve characterization golden is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:59:51 -07:00
prosolis
e629f8fd4d Combat: implement stateful monster ability effects
Slice 3 of the bestiary SRD upgrade: the monster abilities that need
per-fight state (evade, block, advantage, retaliate, regenerate,
survive_at_1, stat_drain, debuff, max_hp_drain). applyAbility arms
combatState flags that the shared resolution primitives read, so both
the auto-resolve and turn-based engines honor them; the turn-based
engine round-trips them through CombatStatuses so a suspended fight
resumes from exact mid-state. New branches are guarded by zero-valued
state so the auto-resolve characterization golden is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:32:13 -07:00
prosolis
f1aa9981f8 Combat: implement immediate-resolution monster ability effects
Wires up the ability effects that resolve fully within applyAbility with
no new persistent state: damage riders (bonus_damage, aoe/aoe_fire/
death_aoe, execute) via the shared calcDamage formula, self_heal, and
flavor-only placeholders for effects still pending per-fight state.
Works in both auto-resolve and the turn engine since both call
applyAbility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:17:11 -07:00
prosolis
c5a2634657 Combat: wire pet procs into the turn-based engine
Pet attacks were never resolved in turn-based fights. Roll the proc once
at fight start (a per-round roll would make a proc near-certain over a
long manual fight), persist it on the session so suspend/resume and
reaper auto-play honor the same outcome, and land a single pet hit on
the player's first acting turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:08:26 -07:00
prosolis
c84682abf7 Combat: guard daily-cron paths against mid-fight combat sessions
A turn-based elite/boss combat session locks the run for up to 1h and
can straddle any scheduled tick. Add hasActiveCombatSession() and consult
it from the morning DM, midnight idle reaper, expedition briefing/recap,
and mid-day random event paths so none of them fire DMs or mutate run
state into a live fight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:01:10 -07:00
prosolis
146924818d Combat: TwinBee per-round narration for turn-based fights
Replace the MVP turn-based round renderer with RenderTurnRound, which
reuses the full auto-resolve narrative pools for shared combat events so
TwinBee's voice is identical across both engines. Only the four
turn-specific actions (flee, spell_held, spell_cast, use_consumable) get
new pools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:53:17 -07:00
prosolis
fd28f31de5 Combat: balance-tune SRD multiattack profiles for single-player
Slice 1 shipped raw SRD damage, which is calibrated for a 4-PC party
and one-shots gogobee's single player at every tier. Rescale each
profile's per-attack damage so the round-total lands at ~1.3x the
creature's tuned auto-resolve Attack stat, keeping the SRD structure
(attack count, to-hit, damage split) intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:44:01 -07:00
prosolis
96b291d831 Combat: SRD multiattack profiles + monster abilities in turn engine
Elites/bosses in turn-based fights now swing a full SRD multiattack
profile and fire their special ability — abilities never fired in the
turn-based path before, and every enemy made a single attack.

bestiary_srd.go adds SRDAttack/SRDProfile and a hand-authored registry
for the named bosses and multiattack elites; auto-resolve keeps its
tuned single-Attack blocks untouched. turnAbilityFires remaps the
auto-resolve phase clock onto fight progress for the phase-less duel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:37:29 -07:00
prosolis
befb44ef03 Combat: persist fight-scoped one-shots + turn-based buffs
CombatStatuses now mirrors every persistent combatState one-shot —
depleting resources (ward/spore/reflect/autocrit/arcane-ward/heal-
charges), once-per-fight class/race/subclass flags, and accumulated
buff stat deltas. resumeTurnEngine restores them; commit writes them
back in place. Fixes turn-based bugs where Orc rage, Halfling Lucky
reroll, and the Assassin first-attack bonus re-fired every round and
Abjuration Arcane Ward did nothing.

Buff spells and buff-type consumables (ward/atk/def/crit/spore/reflect/
auto-crit) are now usable mid-fight: a flattened-delta model diffs the
reused applySpellBuff/ApplyConsumableMods math against a throwaway
combatant, folds the marginal effect into the session, and re-applies
the persistent stat deltas onto the rebuilt player each round. Pure-
utility spells diff to nothing and are refused before a slot is spent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:12:37 -07:00
prosolis
5cd343af0c Combat: per-round !cast / !consume in turn-based fights
Turn-based Elite/Boss fights gain !cast and !consume as player-turn
actions, so casters and item-users make decisions per round instead of
pre-queuing a single effect. The command handler validates and resolves
the spell/item into a pre-rolled turnActionEffect; the engine just
applies the HP deltas and flows on into the enemy turn.

Scoped to effects that resolve within the casting round: damage, heal,
and control spells, plus heal/flat-damage consumables. Buff and utility
spells and buff-type consumables are refused without spending the
resource — they need cross-round stat persistence, a later sub-phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:49:14 -07:00
prosolis
a0961fee8a Combat: auto-play timed-out turn-based sessions to a real win/loss
A combat session abandoned past its 1h TTL is now resumed from persisted
mid-state and auto-played through the shared resolver to a real win or
loss, rather than flatly marked as a retreat. The bulk-UPDATE sweep is
replaced by listExpiredCombatSessions plus a reaper that locks the user,
auto-plays the fight, runs the normal close-out, and DMs the outcome.
markCombatSessionExpired remains the terminal fallback for sessions that
can't be reconstructed. Wired into eventTicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:30:52 -07:00
prosolis
886eb5a75b Combat: wire turn-based elite/boss fights to the command surface
Routes Elite/Boss rooms off the auto-resolve SimulateCombat path and onto
the persisted turn-based engine. !zone advance now stops at an Elite/Boss
doorway; the player engages with !fight, then resolves one full round per
!attack / !flee. A won CombatSession is the record that the room's combat
is done, so a fresh !zone advance clears the room and advances the graph.

- buildZoneCombatants: shared player/enemy Combatant builder extracted from
  runZoneCombat; combatantsForSession rebuilds the pair from a session row.
- runCombatRound loops the phase state machine through a whole round;
  finishCombatSession runs HP/XP/loot/kill/threat/mood close-out.
- getCombatSessionForEncounter lets the room resolver tell "already won"
  apart from "not yet fought".
- !zone advance/enter/go blocked while a session is active.
- resolveBossRoom deleted (dead after the reroute).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:16:45 -07:00
prosolis
269d13ffe8 Combat: add turn-based session accessors and round-loop state machine
combat_session.go: CombatSession persistence layer mirroring the
dnd_expedition pattern — CRUD accessors, one-active-per-user enforcement,
and the timeout reaper (sweeps stale sessions to 'expired').

combat_turn_engine.go: the player_turn -> enemy_turn -> round_end -> over
state machine. advanceCombatSession seeds a deterministic per-(round,phase)
RNG, resolves one phase via the shared attack primitives, commits, and
persists. The deferred poison/status tick lands in round_end now that the
round-loop shape exists.

CombatStatuses persists only between-round monster-ability effects; the
reaper marks sessions 'expired' rather than auto-playing them — both gaps
depend on Combatant reconstruction, which lands with the command-wiring PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 05:55:32 -07:00
prosolis
b0987eb229 Combat: add combat_session schema for turn-based fights
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>
2026-05-14 00:02:09 -07:00
prosolis
1e1fbf9056 Combat: extract shared attack-resolution primitives
Pull effectiveAttackBonus, attackCritFloor, attackConnects, and the
post-hit player damage stack out of combat_engine.go into
combat_primitives.go so the upcoming turn-based engine resolves an
attack identically to auto-resolve. Behavior-preserving — the combat
characterization golden file is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:05:33 -07:00
prosolis
6141964c58 Combat: characterization test pinning the auto-resolve event stream
Splits SimulateCombat into a thin wrapper over simulateCombatWithRNG, a
deterministic core that accepts an optional *rand.Rand. Production passes
nil (package-global rand, behaviorally identical); the new test seeds it
per scenario.

Adds TestCombatCharacterization: 23 curated scenarios x 5 seeds, with the
full event stream + result summary serialized to a stable text form and
diffed against a golden file. This locks current auto-resolve behavior
before the shared-primitives extraction for the turn-based engine — any
perturbation fails loudly and forces a deliberate -update + diff review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:00:35 -07:00
prosolis
0ca17628d4 WIP: DM window tiers, expedition activity dedup, harvest gating
- Split advDMResponseWindow into a 12h low-priority tier for passive
  overnight-tolerant prompts (pet arrival/type/name); bump pet arrival
  chance 15% -> 30%.
- Skip zone_run activity lines for runs that belong to an active
  expedition — the expedition rollup is the source of truth.
- Loosen currentRoomCleared: Entry/Exploration/Trap allow harvest
  unconditionally (risk is the combat-interrupt roll); Elite/Boss stay
  gated on RoomsCleared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:53:58 -07:00
prosolis
4af110cc20 Combat: inject per-session RNG into the engine
Adds an optional *rand.Rand to combatState so a fight can be driven by a
deterministic source. Auto-resolve leaves it nil and falls through to the
package global in the same call order — behaviorally identical. Threads
the source through calcDamage and rollWeaponDamage.

Groundwork for the turn-based elite/boss engine and its timeout reaper,
which seed the rng per combat_session to make fights resumable and
replayable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:53:06 -07:00
prosolis
4e412219f3 WIP: in-flight combat/expedition/flavor changes
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>
2026-05-12 21:59:19 -07:00
prosolis
2b86f3df16 Expedition: skip idle reaper + legacy morning DM while on expedition
The midnight idle reaper inspected only the overworld CombatActionsUsed/
HarvestActionsUsed counters, so a player on an active expedition was
flagged idle — streak halved and a nonsense overworld shame DM sent. The
08:00 morning DM had the same gap and stomped on the 06:00 expedition
briefing. Both paths now early-out when getActiveExpedition is non-nil
(advancing the streak in the midnight branch).

Includes a one-shot bootstrap that doubles CurrentStreak (capped at
BestStreak, +1 when room allows to recover the integer-divide odd half)
for characters still flagged StreakDecayed with an active expedition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:54:40 -07:00
prosolis
9ce82f7c67 Combat: round half-up on HP wound persistence to fix 101→100 drift
Wound carry-over went through three integer truncations across the
legacy combat scale (123 max) and the dndChar scale (78 max). 101/123
persisted as int(64.04)=64, then restored as int(100.92)=100 — losing
0.92 HP every fight.

Switching both sides to math.Round means most HPs round-trip exactly;
the residual error is bounded by ±1 HP from the inherent scale mismatch
(1 dndChar HP ≈ 1.58 legacy HP, so some pairs collide on the smaller
scale). Acceptable; the prior always-truncate behavior was directionally
biased against the player.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:20:24 -07:00
prosolis
c5217e9ecf Combat: fix wounded-carry HP display + auto-crit narration
Two bugs reported back-to-back from a Rogue's run:

1. HP display reset between battles. End of fight 1: 101/123 → start of
   fight 2: 100/100. applyDnDHPScaling was overwriting Stats.MaxHP with
   the scaled wound value, so the display denominator dropped along with
   the numerator. Wounded carry-over became invisible.

   Fix: introduce CombatStats.StartHP. Combat engine reads it as the
   entry-HP when set; MaxHP stays put. Display now reads "100/123" as
   intended.

2. Auto-crit fired on a roll of 11 with no narrative tell. Rogue passive
   AutoCritFirst was triggering correctly, but the renderer used the
   generic crit pool, so the player saw "🎲 11 vs AC 10 → CRIT" with no
   indication their *class* caused it.

   Fix: tag the crit event with Desc="auto_crit" when the passive (not
   the dice) caused it; new narrativePlayerAutoCrit pool calls out the
   training/instinct/exploit theme.

Test bound for T5 dungeon death rate loosened from 0.02 to 0.01 — the
new Sudden Death phase from the previous commit shifted geared-T5
fights slightly toward player wins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:17:14 -07:00
prosolis
de02907e69 Combat: add Sudden Death phase + fix tiebreak to use absolute HP
Players reported the prior 6-round exhaustion timeout felt arbitrary
when both sides still had plenty of HP. Two fixes:

- Add a "Sudden Death" 4th phase (3-4 extra rounds) so most fights
  resolve naturally before any timeout. Total combat length now caps
  at ~10 rounds.
- When the timeout does fire, tiebreak by absolute HP instead of HP%.
  The %-based logic was unintuitive — a player at 88/123 (71%) would
  lose to a boss at 83/97 (86%) despite having more HP remaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:58:26 -07:00
prosolis
3cf83e370e Euro: add !eurogrant admin command
Credits an arbitrary amount to any user, logged with the granting admin's
ID (and optional free-form note) for audit. Reuses Credit so balance
bootstrap and transaction history stay consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:37:29 -07:00
prosolis
34461030dc Merge branch 'feat/hospital-bill-flavor' into main 2026-05-09 21:32:47 -07:00
prosolis
86c6736a8b Merge branch 'feat/space-inviter' into main 2026-05-09 21:32:24 -07:00
prosolis
97e4a502c9 Hospital: fix preposition in USA-standards descriptor (for→by)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:48:56 -07:00
prosolis
b8e8f994d8 Hospital: swap "a lot" descriptor for USA-standards quip
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:48:36 -07:00
prosolis
374d5ea374 Hospital: vary bill descriptor in discharge announcement
Replace the single "a lot" descriptor with a pool of 8 variants picked
via advPickFlavor (per-user dedup, last 5 indices avoided).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:46:04 -07:00
prosolis
8e8d3783f9 Space inviter: DM admin to confirm invites for Space-less local users
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>
2026-05-09 20:20:56 -07:00
prosolis
2454f5f865 Hospital: cut revival to 5k/level + add CHA haggle roll
Drops the after-insurance revival cost from level × 50k to level × 5k
(before-insurance still 5×). On check-in, Nurse Joy now lets the player
attempt a CHA save vs DC 15:
  - nat 1  → auto-fail, full price
  - <  15  → fail, full price
  - >= 15  → pass, bill halved
  - nat 20 → bill fully waived (no debit, no community-pot cut)
Outcome is pinned on the pending interaction (Discounted/Waived) so the
TOCTOU recompute at confirm preserves the discount/waiver and can't be
re-rolled. Bill DM reordered: itemized → after-insurance (pre-haggle) →
🎲 roll line + Nurse Joy reaction → final amount with strikethrough on
the pre-haggle figure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:26:06 -07:00
prosolis
7ec78f3cd9 Branching zones G9c: drop legacy columns + add purge gate
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>
2026-05-09 17:23:08 -07:00
prosolis
a5c0a83e2a Branching zones G9b: stop persisting current_room / room_seq_json
The two legacy columns are no longer needed at runtime. CurrentRoom is
derived from len(VisitedNodes)-1 on read (lockstep with what the
dual-write era stored), and RoomSeq is a transient in-memory field
populated only when a fresh run is started.

- INSERT, SELECT, and UPDATE statements drop the two columns. The
  schema still carries them — they retire in G9c.
- scanZoneRun derives CurrentRoom and falls back to a linear-derived
  CurrentNode only if a row predates the G4 dual-write deploy.
- markRoomCleared rewritten to walk the registered graph (first
  outgoing edge / dead-end completion). Tests that use it as an
  end-to-end run driver continue to pass.
- advanceZoneRunNode drops its current_room dual-write.
- adventure_activity_unified reads visited_nodes for the daily
  "X/N rooms" progress fragment.
- Camp boss-room test pivots to setting current_node directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:21:33 -07:00
prosolis
103cf30987 Branching zones G9a: remove GOGOBEE_BRANCHING_ZONES gate
All 9 zones have hand-authored graphs (G8a–G8i), so the POC gate has
served its purpose. Make graph mode the only runtime path:

- Drop branchingZonesEnabled() and the os import in zone_graph_nav.go.
- Inline the gate-on branches in zoneCmdMap, zoneCmdAdvance,
  CurrentRoomType, startZoneRun, and the expedition map renderer.
- Keep the legacy linear functions (markRoomCleared, renderZoneMap,
  generateRoomSequence) standing — their tests still exercise them and
  they'll retire alongside current_room / room_seq_json in G9b.
- Strip gate test (TestBranchingZonesGate, TestCurrentRoomType_GateOff)
  and t.Setenv calls from the surviving graph tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:17:19 -07:00
prosolis
304ad27c0f Branching zones G8i: Underdark convergent triangle (multi-region)
T4 zone, ninth (final) authored graph. Shape: 3-way regional fork
where each arm crosses a different region boundary, all converging at
R4 (deep_throne). First and only authored zone where every node
carries a non-empty RegionID matching dnd_expedition_region.go.

Three arms from fork1:
  - R1→R2 drow_patrol → drow_captain (elite)
  - R1→R3 psionic_corridor → mind_flayer (elite)
  - R1→R1 deep_chasm (CON-gated harvest, stays in surface_tunnels)
…all converging at R4 throne_approach → boss.

Exercises G6 fireGraphRegionTransition end-to-end: edges crossing
region boundaries fire the transition hook (updates Expedition
.CurrentRegion, marks region visited, writes a transit log entry)
without burning supplies or advancing the day, since the graph is
the run state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:03:21 -07:00
prosolis
122a3d7d5d Branching zones G8h: Abyss Portal three sequential forks
T5 zone, eighth authored graph. Shape: three fork nodes in series —
binary, binary, ternary capstone. Player makes three separate fork
decisions in this zone, more than any other shipping zone, matching
the "things keep getting worse" thematic descent.

First zone with LockStatCheck CON (mind_corridor, DC 16, psychic
pressure flavor) — completes the full ability roster (STR/DEX/CON/
INT/WIS/CHA) across shipping zones by G8h. reality_seam secret
LootBias 3.0, the highest authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:00:57 -07:00
prosolis
3a8f3c26d6 Branching zones G8g: Dragon's Lair converging mid-fork + capstone 3-way
T5 zone, seventh authored graph. Shape: long approach (kobold_warrens
→ drake_pens) → binary mid-fork that converges at the elite
(wyrmlings_nest) → 3-way capstone fork into the boss
(direct / CHA bargain / Perception-found hoard_pillar secret).

First zone combining two distinct fork stages where the first converges
diamond-style and the second spreads triple-wide. Secret carries
LootBias 2.5 — highest of any shipping zone, reserved for T5 — locked
in by TestDragonsLairGraph_LootBiasEscalation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:32 -07:00
prosolis
dcef2ba734 Branching zones G8f: Feywild Crossing woven forks
T4 zone, sixth authored graph. Shape: woven forks — two first-stage
forks each lead to a second-stage fork; hag_circle is shared between
both first-stage paths while time_eddy (grove-only) and
illusion_garden (marsh-only, secret) are exclusive to one side.
Captures the Feywild theme of "the path you walked is not the path
you arrived on."

First authored zone with LockStatCheck CHA, completing the stat
roster (CHA joins STR/DEX/INT/WIS in shipping zones). Also the first
zone where fork1 has no LockNone option — both first-stage edges are
locked (CHA DC 14 vs. Perception DC 14), forcing the player to commit
to whichever check they're stronger at.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:57:48 -07:00
prosolis
dd97a00332 Branching zones G8e: Underforge pre-boss gauntlet (late triple fork)
T3 zone, fifth authored graph. Shape: five-node linear preamble
(sealed_gate → forge_descent → cooling_river → magma_chamber elite)
followed by a single 3-way antechamber fork before the boss.

Bends the plan §G8 "two forks for T2+" guideline to a single late
fork-with-three-options because the gauntlet shape is the design
intent — Kharak Dûn is a one-way descent. Triple-option antechamber
(direct / DEX-checked catwalks / Perception-found forge_vault secret)
preserves agency at the commitment moment.

Map renders as a long horizontal stem with a 3-leaf fan at the right
edge — visually distinct from any earlier zone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:56:07 -07:00
prosolis
737702ea20 Branching zones G8d: Manor Blackspire stacked 3-way forks (hub style)
T3 zone, fourth authored graph. Shape: two stacked 3-way forks
(great_hall, upper_hall). The player picks twice in a row from a
wide menu — distinct from the binary forks in G8a/b and the parallel
Y-trees in G8c.

Completes lock-kind coverage by G8d: this is the first authored use of
LockLevelMin (tower_observatory gated at L7). With Perception (DC 14
study, DC 15 hidden_oratory), StatCheck INT (library), and LockNone,
all four non-trivial lock kinds now appear in shipping zones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:54:45 -07:00
prosolis
675d70e85b Branching zones G8c: Sunken Temple sequential forks (no mid-path merge)
T2 zone, third authored graph. Shape: two-tier sequential forks where
each path is unique up to the boss room. Four committed leaves
(kuo_toa_pen / glyph_chamber / aboleth_thralls / coral_reliquary) all
terminate at the boss; no mid-path node has >1 incoming edge.

Differentiates from Crypt's diamond and Forest's asymmetric-diamond by
producing two parallel "Y" subtrees on the !zone map instead of a
converging branch. Exercises Perception + StatCheck (STR) + LockNone
in a single zone (covered by TestSunkenTempleGraph_LockKindCoverage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:52:52 -07:00
prosolis
ea59b4e61c Branching zones G8b: Forest of Shadows asymmetric fork + stat-check secret
T2 zone, second authored graph. Deliberately diverges from the diamond
shape so the !zone map renders distinctly:

- Asymmetric main fork: long branch (grove_descent → dryad_circle elite,
  2 mid-nodes) vs. short branch (thorn_tunnel, 1 mid-node, Perception
  DC 13 hint). Tests lock in the asymmetry.
- Pre-boss fork to a sapling_shrine secret behind LockStatCheck WIS
  DC 14 (LootBias 2.0). First authored zone exercising stat_check —
  Crypt's secret was Perception, so this validates the second lock kind
  end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:51:19 -07:00
prosolis
9312ef5275 Branching zones G8a: Goblin Warrens diamond graph
T1 single-fork shape with no secret — deliberately the simplest
authored topology, mirroring the Crypt of Valdris pattern minus the
secret tail. Serves as the baseline reference shape; G8b onward will
vary topology (stub branch, sequential forks, hub-and-spoke, gauntlet,
convergent triangle) so zones feel structurally distinct on the
!zone map.

Validator-checked at init via BuildGraph. Both branches reach boss;
the Perception (DC 12) side carries a player-facing hint per plan §G8
guideline ("locked paths should always have a hint").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:48:48 -07:00
prosolis
b3d3db9142 Branching zones G7: collapse empty columns in !zone map
The graph-mode map renderer iterated 0..maxX, emitting a blank slot for
columns whose only inhabitant was a hidden secret node. In Matrix code
blocks the dark background painted that gap as a phantom cell with no
status mark — read in playtest as "a box missing a dot." Collapse
columns with no visible nodes before laying out rows; secret columns
reappear once the player visits them.
2026-05-09 16:41:46 -07:00
prosolis
90ff7666bd Branching zones G7: Crypt of Valdris POC graph
Hand-authored zoneCryptValdrisGraph() (8 nodes incl. fork +
Perception DC 15 secret reliquary) self-registers at init. Two
surgical bridges so new runs traverse the authored graph when
GOGOBEE_BRANCHING_ZONES=1: startZoneRun seeds current_node from
g.Entry, and DungeonRun.CurrentRoomType resolves via the live
node's kind so divergent paths (secret_chamber) dispatch through
the right resolver. Gate off is bit-identical to pre-G7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:28:58 -07:00
prosolis
893d3dacad Branching zones G6: dependent surfaces re-keyed on graph nodes
- Harvest tables (expedition + standalone) now keyed by node_id; legacy
  room-idx entries auto-migrate via read-fallback + drop-on-save.
- Narration salts swapped from run.CurrentRoom to narrationCadence(run)
  (= len(visited_nodes)-1) so flavor pickers survive G9 column drop.
- !zone map renders the graph (BFS by PosX/PosY) when the gate is on:
  ✓/▶/· status, ╳ for locked-only edges, secrets hidden until visited.
- Region-boundary hook in graph advance/!zone go updates expedition
  CurrentRegion + visited list when from/to nodes differ — without
  burning supplies or retiring runs (the graph IS the run state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:17:30 -07:00
prosolis
2d249d7d0a Branching zones G5: navigation surface behind GOGOBEE_BRANCHING_ZONES=1
Wires the graph types from G2/G3/G4 into the !zone advance flow.
Default off; with the gate on, room-clear branches on outgoing-edge
count instead of the linear current_room+1 walk.

zone_graph_nav.go — env gate, edge unlock evaluation
(LockNone/Perception/Key/LevelMin/RegionClear/StatCheck), pendingFork
JSON shape with encode/decode helpers, perception roll seeded
deterministically from (runID, from, to) so reload doesn't grant
retries, plus fork prompt rendering and the
recordRoomCleared/advanceZoneRunNode/completeRunAtNode persistence
trio.

dnd_zone_cmd_graph.go — advanceTransitionGraph drives the graph-mode
branch in zoneCmdAdvance: 0 outs → run completes (boss_defeated set
when current node IsBoss); 1 unlocked out → auto-advance and emit
the same teaser the legacy path uses; 2+ outs (or a single locked
out) → write pending fork to node_choices and render the menu. New
!zone go <n> / !zone choose <n> consumes the prompt, validates the
chosen edge is unlocked, advances, and emits the arrival teaser.
Old "go" alias for "enter" was unused in tests — repurposed.

dnd_zone_cmd.go — extracts formatNextRoomMessage so the legacy and
graph-mode paths share the post-advance teaser. Adds the new
subcommand to the dispatch switch and help text.

Tests: pure-logic coverage for unlock evaluation, perception
determinism, pendingFork roundtrip + decode-edge-cases, choice
resolution, fork prompt rendering with hint vs no-hint locked
options, edge ordering by (Weight, To), and the env gate. Existing
zone tests pass with and without the gate set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:49:22 -07:00
prosolis
a413c92844 Branching zones G1–G4: schema, graph types, legacy compiler, run-state dual-write
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>
2026-05-09 14:34:05 -07:00
prosolis
57b6e009ec Branching zones: write Phase G plan (Option A — greenfield zone graph)
Full implementation plan for replacing the linear room_seq_json model
with a directed-graph zone topology. Enables Slay-the-Spire-style
forks, perception-gated secret rooms, locked shortcuts, multiple
paths converging on a shared boss.

Plan covers:
  - Schema (new zone_node + zone_edge tables; current_node /
    visited_nodes / node_choices columns on dnd_zone_run)
  - Go-side types (ZoneGraph, ZoneNode, ZoneEdge, lock kinds)
  - Authoring model with worked example (Crypt of Valdris POC)
  - Nine-phase rollout (G1 schema → G2 types → G3 legacy compiler →
    G4 dual-write run state → G5 navigation behind env gate → G6
    dependent surfaces → G7 POC zone → G8 mass migration → G9
    retire linear model)
  - Risk inventory + mitigations
  - Surface inventory of files likely to change
  - Open authoring questions with proposed defaults

Not yet implemented — handed off to a fresh session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:23 -07:00
prosolis
35fa2b8323 Adv 2.0 daily report: migrate to D&D-native render + unified activity source
Two longstanding warts surfaced when the user inspected today's TwinBee
daily report:

1. Active players were rendering "Acted today — no log recorded." The
   adventure_activity_log path only captures !rest now; harvest/zone/
   expedition action logging was lost during Phase R/L migration. The
   stale HasActedToday() check fired on fossil harvest_actions_used
   counters that nothing modern increments.

2. Per-player headline still rendered legacy four-skill line ("Combat
   Lv.X | Mining Lv.Y | Forage Lv.Z | Fishing Lv.W"). No D&D level,
   race, class, or HP — discoverability gap for the layer that's now
   canonical.

Fix both:

- adventure_activity_unified.go: new loadAdvDailyActivity(date) unions
  dnd_zone_run (zone runs touched today) + dnd_expedition_log
  (expedition activity rolled up per expedition) + legacy
  adventure_activity_log (mostly !rest). One source of truth.

- adventure_render.go: AdvPlayerDaySummary gains HasDnDChar/DnDLevel/
  DnDRace/DnDClass/HPCurrent/HPMax. New advPlayerHeadline helper
  renders "Lv.N Race Class — HP cur/max" when a dnd_character row
  exists, falls back to the legacy four-skill line plus an inline
  "(no D&D sheet — run !setup)" nudge for accounts that haven't
  completed setup yet. titleizeDnDToken("half_elf") → "Half-Elf".

- adventure_scheduler.go: postDailySummary swaps loadAdvLogsForDate
  for loadAdvDailyActivity. IsResting now derives from "len(acts)==0"
  rather than the fossil HasActedToday() counter check, so dead
  harvest_actions_used bytes can't trip the active-player branch.

- proddb_daily_report_test.go: renders the report against a copy of
  data/gogobee.db and prints it for visual confirmation. Verified
  output for 2026-05-09 shows real zone-run outcomes (Withdrew from
  Sunken Temple at room 1/6, etc.) instead of the previous "no log
  recorded" placeholder.

go vet + go test ./... clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:23 -07:00
prosolis
c8849fd819 Adv 2.0 L5 close-out fix: bootstrap player_meta from legacy on Init
The L5 close-out (596b2b7) rewired loadAdvCharacter to read from
player_meta and simultaneously deleted the 13 backfill one-shots from
Init. That's only safe if every account already has a player_meta row
from prior dual-writes — fresh restores from old backups, dev
environments cloned from older snapshots, or a prod upgrade that
skipped the soak window all silently strand every character (loader
returns empty).

Adds bootstrapPlayerMetaFromLegacy: a single consolidated migrator that
walks adventure_characters, finds rows missing from player_meta, and
fans them out via the audited upsertAllPlayerMetaFromAdvChar helper.
Idempotent (re-runs touch zero rows once migrated; no-ops post-purge).
Wired into AdventurePlugin.Init before loadAllAdvCharacters.

proddb_simulation_test.go exercises the full bootstrap + fan-out +
overlay round-trip against a copy of data/gogobee.db with 11 subtests
covering every migrated subsystem (skills, arena, babysit, streak,
death/revive, NPC counters, pet incl. JSON-packed flags, house, save
idempotency, last_active advance, fresh createAdvCharacter).

dnd_proddb_integration_test.go also calls the bootstrap explicitly to
mirror prod startup ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:23 -07:00
prosolis
9d6e8f6bf2 Adv 2.0: fork !expedition list off zoneCmdList
!expedition list was routing to zoneCmdList, which rendered "Zones
available at L%d" with `!zone enter <id>` CTAs and referenced an active
zone run for the footer — wrong system for an expedition caller. New
expeditionCmdList mirrors the zone-list shape but uses expedition CTAs
(`!expedition start <id>`) and surfaces the active expedition (day
count, `!expedition status` / `!expedition abandon`) via
getActiveExpedition. zoneCmdList stays for !zone list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:23 -07:00
prosolis
5d98e5684a Adv 2.0 post-L5 audit cleanup: drop redundant dual-writes + refresh stale comments
Audit confirmed saveAdvCharacter's fan-out (upsertAllPlayerMetaFromAdvChar)
covers all 13 migrated subsystems, so the per-call-site upsertPlayerMetaXxx
calls retained "as defense" through the L4-L5 migration are redundant
post-L5h. Removed 16 such call sites across arena (2), scheduler (4),
hospital (1), consumables (2), rival (1), housing (11), and reordered
masterwork to drop a now-pointless explicit upsert before the load+save.

Refreshed nine stale doc comments referencing the legacy
adventure_characters table or "soak window / fallback" language that no
longer applies after the L5 close-out (commit 596b2b7). No behavior
change; go vet + go test ./... pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:23 -07:00
prosolis
c0f03a41b5 Adv 2.0 L5 close-out: loader rewire + adventure_characters purge gate
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>
2026-05-09 14:25:23 -07:00
prosolis
36e14c6084 Adv 2.0 L5 close-out: §7.4 reconciled + retire 3 CombatLevel readers
Reconcile the migration plan with what actually shipped, and clear the
three remaining CombatLevel reader sites so the §7 grep gates hold.

Plan reconciliation (gogobee_legacy_migration.md §7.4):
The original deletion list was wrong on two counts. (1) AdventureCharacter
struct survives as the loaded-view shape post-L5h overlay; only the
backing table drops at purge. (2) combat_engine.go / combat_bridge.go /
combat_stats.go are not legacy — they're the live combat engine the D&D
system layers on top of (applyDnDPlayerLayer/EquipmentLayer/etc. mutate
CombatStats before SimulateCombat). Verified via grep: 21+ files reference
these symbols across arena/dungeon/zone/expedition. §7.4 rewritten to
reflect this; no L6 combat-engine migration needed.

Reader fixes:
- babysitDailyCost reframed for D&D-level scale. Formula 100 + level*100
  preserves the curve at every old-CL boundary given the 5:1 compression
  in dndLevelFromCombatLevel (Level 4 ≈ old CL 20 = €500/day, Level 10 ≈
  old CL 50 = €1100/day). Caller switched to dndLevelForUser.
- dnd_sheet.go drops the vestigial "Combat (legacy) %d" stat-block line.
- D&D onboarding retired. Post-L5g the welcome DM never fires (every
  legacy player already has a D&D row), so dnd_onboarding.go +
  dnd_onboarding_test.go are deleted, the 3 production caller invocations
  (dnd_zone_combat, combat_bridge, dnd_combat::ensureCharForDnDCmd) and
  the dnd_setup.go stub-creation branch removed. OnboardingSent column
  kept for a separate cleanup pass. dnd_audit_fixes_test.go ported.

go vet ./... && go test ./... clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:23 -07:00
prosolis
1c7939c3c8 Adv 2.0 L5h: cold AdvCharacter table — saveAdvCharacter fan-outs to player_meta
saveAdvCharacter no longer writes the legacy adventure_characters row.
It's now a thin fan-out: bumps LastActiveAt and routes through
upsertAllPlayerMetaFromAdvChar, which calls the per-subsystem upserts
for display_name, hospital, arena, masterwork, rival, skills, babysit,
NPC, lifecycle, death, misc, pet, and house.

loadAdvCharacter calls applyPlayerMetaOverlay at the tail to re-source
every migrated subsystem from player_meta. The adventure_characters
SELECT still happens (for the user_id / equipment-table linkage and as
a fallback substrate) but its values are overwritten by the overlay.
This achieves the L4e in-place housing reader flip automatically —
adventure_housing.go's char.HouseFoo reads now see player_meta values
without per-site changes.

npcMidnightReset extended to write player_meta alongside the frozen
adventure_characters.

Per-call-site upsertPlayerMetaXxx duals are now redundant with the
saveAdvCharacter fan-out but kept as defense; cleanup deferred.
player_meta.combat_level column drop deferred — still read by babysit
cost / combat_stats / activities via the overlay; sequenced with the
final combat_engine teardown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:23 -07:00
prosolis
0d2e554e22 Adv 2.0 L5g: DnDCharacter mass-backfill + drop CombatLevel fallbacks
backfillDnDCharactersFromAdv walks adventure_characters rows that have no
dnd_character row and inserts an auto-migrated character (race/class
inferred from archetypes, Level seeded from dndLevelFromCombatLevel).
Idempotent via LEFT JOIN — pending-setup drafts and existing rows are
skipped. Wired into Init after the L5f backfill.

With every legacy player guaranteed a D&D row, the soak-window fallbacks
in dndLevelForUser, rivalLevelForUser, and hospitalCostsForUser are
retired (they now floor at level 1). dndLevelFromCombatLevel itself
stays — still used by autoBuildCharacter and the !setup seed paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:23 -07:00
prosolis
df886ab86f Adv 2.0 L5f: misc fields (Title, TreasuresLocked, CraftsSucceeded) migration
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>
2026-05-09 14:25:23 -07:00
prosolis
45586eb697 Adv 2.0 L5e: death state migration off AdvCharacter
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>
2026-05-09 14:25:22 -07:00
prosolis
8fc8941cc9 Adv 2.0 L5d: streak/action/lifecycle migration off AdvCharacter
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>
2026-05-09 14:25:22 -07:00
prosolis
c83b73b655 Adv 2.0 L5c: NPC counters/debuffs migration off AdvCharacter
Thirteen player_meta columns: misty/arina_last_seen,
misty_buff_expires, misty_debuff_expires, arina_buff_expires (all
nullable DATETIME), npc_msg_count + npc_msg_count_date,
misty/arina_roll_target, misty_encounter_count, misty_donated_count,
thom_animal_line_fired, robbie_visit_count.

NPCState struct with HasNPCActivity() marker + load/upsert/backfill/
projection helpers. Dual-writes wired at every NPC mutation site:
processNPCEncounters msg-count save, npcFireEncounter last-seen,
resolveMisty (insufficient balance, debit failure, buff/donated,
declined-debuff), resolveArina buff, adventure_robbie visit count,
adventure_housing Thom animal line.

Hidden discovery mechanic — buffs/debuffs and counters never surface
in player-facing output.

Tests: TestPlayerMetaNPCStateBackfill_Idempotent,
TestLoadNPCState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaNPCState_RoundTrip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
d5b4834d44 Adv 2.0 L5b: babysit state migration off AdvCharacter to player_meta
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>
2026-05-09 14:25:22 -07:00
prosolis
79e5d19d23 Adv 2.0 L5a: skills migration off AdvCharacter to player_meta
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>
2026-05-09 14:25:22 -07:00
prosolis
cf072f8a8f Adv 2.0 L5 plan: draft sub-phase plan (L5a-L5h) for legacy teardown
Adds §7.1 audit baseline, §7.2 AdvCharacter field inventory by bucket,
§7.3 sub-phase table (skills, babysit, NPC, streak/action, death, misc,
DnD mass-backfill, in-place housing flip + dual-write cut), §7.4 final
teardown sequence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
81cce0169f Adv 2.0 L4d reader flip: pet helpers off AdvCharacter to PetState
All ten helpers in adventure_pets.go now take PetState (or *PetState
for petGrantXP) instead of *AdventureCharacter. mistyHousingHint takes
raw NPC counters since Misty fields stay on AdvCharacter for a later
phase. mistyReactivatePet returns bool; caller flips both stores.

DeathTransitionParams gained Pet PetState so combat_bridge no longer
touches the DB; arena caller loads PetState. PetState.HasPet() mirrors
AdvCharacter's. petMidnightCheck loads PetState per char.

L4 grep-empty exit criterion now holds for adventure_pets.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
0004a2e35c Adv 2.0 L4e reader flip (read-only sites): house state via loadHouseState
dnd_rest long-rest eligibility, dnd_sheet housing display, and
petShouldArrive / mistyHousingHint now source house state from
player_meta via loadHouseState(userID) instead of *AdventureCharacter.
petShouldArrive, mistyHousingHint, and renderDnDSheet gained a
HouseState parameter; scheduler and Misty NPC callers load it.
Reader flip inside adventure_housing.go itself stays deferred — its
mutation paths bundle with cutting AdvCharacter writes post-soak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
c719b30fd7 Adv 2.0 L4f: render/twinbee reader-port off AdvCharacter to dndLevelForUser
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
d638f62fd4 Adv 2.0 L4e: housing dual-write house_* off AdvCharacter to player_meta
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
690817f2ed Adv 2.0 L4d: pets dual-write pet_* off AdvCharacter to player_meta
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>
2026-05-09 14:25:22 -07:00
prosolis
283f7adf5f Adv 2.0 L4c: masterwork migrates MasterworkDropsReceived off AdvCharacter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
a4b4a74ab4 Adv 2.0 L4b: rival migrates RivalPool + gate/stake off AdvCharacter
Gate switches from CombatLevel >= 5 to D&D Level >= 3; stake formula
becomes (level / 3) * 1000 (Level 3 → €1000 mirrors legacy CL5 → €1000,
tops €6000 at L20). RivalPool / RivalUnlockedNotified dual-write to
player_meta; readers in selectRivalPair, handleRivalsCmd, and morning
DM render flip to loadRivalState. Backfill wired into Init, idempotent.

Migration doc note about porting rival combat to simulateCombat was
wrong — rival uses RPS, no combat_engine port needed. Doc corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
730f16580a Adv 2.0 L4a: hospital migrates HospitalVisits + cost formula off AdvCharacter
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>
2026-05-09 14:25:22 -07:00
prosolis
cc6fb7dd39 Adv 2.0 L4f-prep: flip DisplayName readers to loadDisplayName
Swap all char.DisplayName / c.DisplayName reader sites in
internal/plugin/ to loadDisplayName(userID). Touches 12 files
across combat (combat_bridge, dnd_zone_combat, dnd_zone_cmd,
dnd_expedition_combat), arena, hospital, events, render,
masterwork, robbie, scheduler, and adventure.go.

Only intentional char.DisplayName references remaining are in
adventure_character.go (scan + save + dual-write) and the
player_meta.go doc comment. go vet + go test ./internal/plugin/...
clean.

Unblocks deferred L2 step 9 (arena AdvCharacter import drop)
once L4 hospital/pets/render also lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
5000c3d696 Adv 2.0 L4f-prep: DisplayName migration scaffold (schema + dual-write + helper)
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>
2026-05-09 14:25:22 -07:00
prosolis
d300008812 Adv 2.0 Phase L3: delete co-op dungeons; refund in-flight runs on startup
Drop the coop_dungeon system per migration plan §5. 11 coop_*.go files
removed; cleanup_l3.go added with an idempotent per-run status-guarded
UPDATE that cancels any open/active runs and refunds member contributions
plus unsettled bets via EuroPlugin.Credit. Tables retained for now;
schema purge deferred to a future GOGOBEE_COOP_PURGE pass.

adventure.go drops !coop dispatch, coopTicker, the startup combat-lock
hook, and the !coop help line. adventure_scheduler.go loses the
activeCoopMemberSet skip branch and the post-reset combat-lock call.
adventure_render.go drops the teaser plus the in-coop status block;
replaced with a one-week morning-DM closure announcement
(TODO: remove after 2026-05-16). TestPickCoopTeaserCandidate_SkipsLeader
removed.

go vet ./... and go test ./... clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
3b4dfa44d3 Adv 2.0 L2: rip legacy arena combat path; boss flow is the only path
Cancels the planned ARENA_BOSS_FLOW soak. The boss flow is shipped
unconditionally with no legacy fallback — boss-flow errors now surface
to the player and abort the round rather than silently falling back to
the old CombatPower path.

Deleted:
- runArenaCombat (combat_bridge.go) — legacy CombatPower-vs-Lethality
  arena combat driver.
- RenderCombatLogArena, renderArenaOutcome (combat_narrative.go) — legacy
  arena renderers. RenderCombatLogArena was a thin alias for
  RenderCombatLog; renderArenaOutcome was already dead.
- renderArenaCombatFinalMessage (combat_bridge.go) — legacy "rewards +
  closer" trailing text.
- arenaWinCloser, arenaLoseCloser (adventure_arena_combat.go) — flavor
  helpers that only fed the deleted renderers. File now just holds
  arenaParticipationXP.
- arenaBossFlowEnabled + the ARENA_BOSS_FLOW env var (adventure_arena.go,
  .env.example). The env gate served only the now-removed fallback.
- sendArenaCombatMessages (adventure_arena.go) — wrapper that switched
  pacing between boss flow and legacy. Replaced with direct
  sendZoneCombatMessages calls at the three call sites.
- TestArenaBossFlowEnabled (bossflow test) and
  TestRenderCombatLogArena_ProducesPhaseMessages (narrative test).

Simplified resolveArenaRound / resolveArenaSurvival / resolveArenaDeath:
the bossNarr-vs-nil branches collapsed since narration is now always
produced by resolveArenaBoss. Equipment degradation and the death-save
reprieve hook are unchanged — both already worked off the boss-flow
CombatResult.

Migration doc (§4) updated to record the cancellation and the
remaining tuning approach (playtest-driven, no flag soak window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
85a80e5c40 Adv 2.0 L2: document ARENA_BOSS_FLOW flag, soak begins
Adds ARENA_BOSS_FLOW to .env.example. Local .env flipped to "1" to
start the production soak (2026-05-09). Empty / "0" / "false" keeps the
legacy CombatPower path; anything else routes arena rounds through
runZoneCombat + renderBossOutcome (staged combat log, TwinBee mood
lines, phase-two narration on T3+).

Soak window per §11 of gogobee_legacy_migration.md = one real
production week before flipping default-on. Flag is removed in the
same commit that lands L4f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
a4336a8869 Adv 2.0 Phase L2 step 10: defer step 9, close L2
go test ./... && go vet ./... clean on adv-2.0. L2 functionally complete
through step 8 (arena boss flow wired, counters on player_meta,
DnDCharacter level scale, bossflow win asserted in tests).

Step 9 ("drop AdvCharacter imports from arena files") deferred. Arena
code still depends on char.DisplayName (stats DM, T5/helm announces,
render), char.PetName (pet-recovery game-room msg), char.DeathReprieveLast,
char.CombatXP, and char.Alive/transitionDeath — none of which have an
L2-era replacement. DisplayName migration is the L4f-prep / L5
pre-condition (per afaa055); the rest needs L4 hospital/pets/XP/render.

Counter dual-writes to char.ArenaWins/Losses/InvasionScore are the only
piece cleanly droppable at L2 time, but doing it alone leaves the
surrounding loadAdvCharacter/saveAdvCharacter calls in place for the
other fields, so it doesn't satisfy step 9's exit grep — not worth a
partial pass.

Migration doc updated to mark step 9 + the grep-empty exit criterion
as deferred with the dependency reasoning inline. Revisit step 9 after
DisplayName migration ships and L4 (hospital/pets/render) lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
eeb96c4b07 Adv 2.0 Phase L2 step 8: arena test cleanup + bossflow win assertion
Two cleanup edits to adventure_arena_test.go:
- TestRenderArenaStreakEntry_ShowsMultipliers: Level 50 → 20 (D&D cap).
- TestRenderArenaStreakEntry_HighLevel_AllEligible: stale "Level 70"
  comment retargeted to Level 20.
- TestArenaStreakSimulation_FullRun: rename combatLevel → skillBonus
  (the value flows into arenaRoundReward as the battleSkill arg, not
  a character level — naming was a CombatLevel-era leftover).

New test in adventure_arena_bossflow_test.go:
TestArenaBossOutcome_WinSurfacesStagedLogAndBossDeath drives
renderBossOutcome directly with a forced-win CombatResult so the
assertion is RNG-free, then checks the win path emits a TwinBee
BossDeath line before the victory headline plus the trailing d20 roll
summary. RenderCombatLog is exercised on the same result to confirm
the staged-log phases caller in resolveArenaBoss returns content.

go vet ./... + go test ./... clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
aea307e418 Adv 2.0 Phase L2 step 7: arena tier gate → DnDCharacter.Level
- Rebracket arenaTiers MinLevel to D&D scale (1/4/8/13/18) so arena
  tiers gate on DnDCharacter.Level instead of legacy CombatLevel.
- Auto-advance level gate + reward skill bonus now read Level via a
  new arenaDnDLevelOrZero(userID) helper.
- Delete dead arenaDeathChance (combat engine owns death now) and its
  six tests; drop the math import from the test file.
- Update streak-entry / level-gate tests to use D&D-scale levels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
243010ade9 docs: note DisplayName migration as L5 pre-condition
The original plan never picked a new home for
AdventureCharacter.DisplayName. Add a pre-condition block to L5 with
the recommendation: move display_name to player_meta as a small
self-contained step before L4f, backfill, then swap ~250 read sites
in batches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
8afd9494fe Adv 2.0 Phase L2 step 6: arena render reads → DnDCharacter
renderArenaStreakEntry now takes *DnDCharacter and reads c.Level
instead of AdventureCharacter.CombatLevel. renderArenaStatus drops
its unused *AdventureCharacter arg. Callers in handleArenaMenu /
handleArenaStatus updated; menu path uses ensureCharForDnDCmd to
fetch (or auto-migrate) the sheet. Tests switched accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
341b1fc8e5 Adv 2.0 Phase L2 step 5: arena counters → player_meta
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>
2026-05-09 14:25:22 -07:00
prosolis
693cd9c221 Adv 2.0 Phase L2 step 4b: clarify Misty repair comment
runZoneCombat already calls npcRepairMostDamaged internally when the
Misty buff is active and the 20% chance hits — boss flow inherits that
behavior automatically. The comment in resolveArenaRound previously
implied the boss flow skipped Misty repairs, which was wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
6862a94c8f Adv 2.0 Phase L2 step 4b: wire ARENA_BOSS_FLOW into round resolver
resolveArenaRound now branches on arenaBossFlowEnabled(): when set, the
round runs through resolveArenaBoss (zone-boss combat + staged narration)
and the resulting intro/phases/outcome are threaded into resolveArenaSurvival
and resolveArenaDeath via a new arenaBossNarration carrier. Both handlers
swap their RenderCombatLogArena + dnd opening/closing/roll-summary stack
for the pre-rendered boss-flow narration; arena economic glue (rewards,
tier-clear, helmet drops, hospital ad) is preserved untouched.

A new sendArenaCombatMessages helper picks 2–3s zone pacing under boss
flow and 5–8s arena pacing under the legacy path. bossFlowPhaseMessages
prepends the intro line ahead of phases, mirroring streamOrSend's
intro+phases pattern from dnd_zone_cmd.go.

Tests: TestArenaBossFlowEnabled covers the env-gate parsing;
TestBossFlowPhaseMessages asserts the staged-narration assembly. Full
suite green with the flag both off (default, legacy path) and on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
315909e6fe Adv 2.0 Phase L2 step 4a: resolveArenaBoss helper
Adds resolveArenaBoss(userID, ArenaBossEncounter) — a single-round
combat path that routes through runZoneCombat + renderBossOutcome so
arena fights surface the same staged narration zone bosses use:
intro line, RenderCombatLog phases, Nat20/Nat1 mood lines, phase-two
barb on T3+ (ZoneArena routing), BossDeath/PlayerDeath flavor, dice
summary, arena-styled headline.

Behind ARENA_BOSS_FLOW env var (arenaBossFlowEnabled). Side-effect
free — no ArenaRun mutation, no payout, no history rows. The dispatch
into resolveArenaRound lands in step 4b once the surrounding economic
glue (rewards, achievements, helmet drops, death flag) is plumbed for
the new combat result shape.

Smoke tests cover (a) end-to-end arena round produces non-empty
intro/phases/outcome with the right headline, (b) bad tier/round
returns an error, (c) phase-two thresholds match the design doc,
(d) every tier×round arenaBosses entry has positive stats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
3fe2d2f0ca Adv 2.0 Phase L2 step 3: arena boss-shaped bestiary
Reshapes the legacy ArenaMonster data into a boss-shaped arenaBosses
map of DnDMonsterTemplate entries — the carrier the upcoming
resolveArenaBoss (step 4) will hand to runZoneCombat + renderBossOutcome.

IDs namespaced "arena_t<tier>_r<round>" so they don't collide with
dndBestiary. HP/AC/Attack scale with tier via arenaTierBaseStats;
within a tier, BaseLethality biases each monster by a ±30% band so
round-1 anchors the bottom of the tier and round-4 the top. Stats are
first-pass — final tuning happens during the ARENA_BOSS_FLOW flag soak.

arenaBossPhaseTwoAt(tier) gates phase-two narration: T1–T2 = none,
T3+ = 50% HP. The T4–T5 flavor barb piggybacks on the existing
bossPhaseTwoPool path through ZoneArena.

No callers yet — wiring lands in step 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
b0738e8e0a Adv 2.0 Phase L2 step 2: ZoneArena synthetic ID
Adds ZoneArena to the ZoneID enum so the upcoming arena boss path can
route renderBossOutcome's twinBeeLine calls without inventing a parallel
flavor system. Synthetic by design — never registered via registerZone,
so !zone enter arena is unreachable and allZones() / registry tests are
unaffected.

Mood pools (Nat20/Nat1/BossDeath/PlayerDeath) are already zone-agnostic
in pickPool, so ZoneArena falls through to the generic defaults at zero
cost. Phase-two and boss-entry pools default to nil/generic too —
arena-specific overrides land alongside the bestiary work in step 3
only if existing flavor doesn't read right.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
62eed7a064 Adv 2.0 Phase L2 step 1: extract renderBossOutcome
Factors the staged-narration body of resolveBossRoom into a shared
helper renderBossOutcome(BossOutcomeInputs) so the upcoming arena
boss flow (resolveArenaBoss) can reuse the same Nat20/Nat1 mood lines,
phase-two transition barb, BossDeath/PlayerDeath flavor, and trailing
roll-summary line.

Inputs include caller-supplied DefeatHeadline / VictoryHeadline so
arena and zone read in their own voice ("Run ended." vs the future
arena equivalent). Helper is pure: side effects (abandonZoneRun, loot
drops, threat ticks, kill records) stay at the call site.

No behavior change for zone bosses — same strings, same ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
dfa7beeb96 Adv 2.0 D&D polish: GM→DM rename, streamed zone combat, end-to-end scenario test
GM→DM rename across docs and code (GMNarrationType→DMNarrationType,
GMState→DMState, narration constants, comments) so the system reads as
"Dungeon Master" everywhere. Player-visible "GM mood" wording stays
where it appears in flavor.

Streamed zone/expedition combat: zone advance now stages patrol →
patrol play-by-play → patrol resolution → room intro → room play-by-play
→ final outcome through sendZoneCombatMessages with 2–3s pacing
(arena keeps its 5–8s window). Combat narrative lines pick up a compact
d20-vs-AC roll annotation for hit/crit/miss/block events.

Combat outcome polish: dndHPSnapshot lets narration show sheet HP
rather than legacy combat-engine HP, and markAdventureDead clears the
zombie state where hp_current was 0 but the legacy alive flag stayed
true after a D&D-layer KO.

Adv 2.0 announcement (ADVENTURE_2.0_ANNOUNCEMENT.md), README rewrite
covering the new layer, and adv2_scenario_test.go — a full
zone-run + expedition + harvest playthrough against a copy of the prod
DB asserting persisted state end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
83a71173b1 Adv 2.0 D&D Phase R6 polish: standalone-zone harvest persistence
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>
2026-05-09 14:25:22 -07:00
prosolis
1953eec3b5 Adv 2.0 Phase L1: Babysit pivot + legacy resolver retire
Babysit pivots from harvest service to pet-care + safe-rest perk:
- runBabysitDaily/runAutoBabysitDay/runBabysitAction deleted; replaced
  by runBabysitDailyTrickle (3 pet XP/day while subscribed).
- BabysitSafeRest predicate promotes Standard camps to Fortified-tier
  rest (HP+1d6, threat -5, resources refresh) and reduces wandering
  monster check campMod from 0 to -4. Hooks live in
  dnd_expedition_camp.go and dnd_expedition_night.go.
- !adventure babysit auto/focus subcommands removed; status/purchase
  DMs reflect the new perks.

Scheduler drops the auto-babysit fallback block (no more silent
debits + harvest-day for missed logins). Babysit subscribers still
skip the morning DM; trickle runs in the background.

TwinBee daily simulator self-contained: simulateTwinBeeOutcome +
simulateTwinBeeLoot replace the resolveAdvAction call against a
fake CombatLevel=35 character. Outcome distribution and reward
shape preserved; gold-share to active players unchanged.

Legacy activity resolver retired: resolveAdvAction,
advEligibleLocations, AdvEligibleLocation, resolveAdvEmptyOutcome
deleted from adventure_activities.go (zero production callers
post-babysit/twinbee). AdvActionResult kept — combat_bridge still
produces it for arena/dungeon flows; dies in L2/L3.

Migration plan doc gogobee_legacy_migration.md added: five-phase
L1-L5 plan covering arena (Adv 2.0 boss flow rebuild), co-op
deletion, perk-gate sweep, and final teardown of AdventureCharacter
+ CombatLevel + combat_engine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
45863bd5f3 Adv 2.0 D&D Phase R7: post-completion audit hardening
High-priority fixes from the multi-agent audit of Adventure 2.0:

- Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe
  getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a
  placeholder instead of panicking.
- Briefing/recap idempotency: deliverBriefing/deliverRecap now claim
  the rollover via a conditional UPDATE. Double-fires from clock skew
  or restart become no-ops; supply burn and day++ no longer reapply.
- Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all
  11 background tickers now select on stopCh in addition to ticker.C.
- Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation
  so sub-hour gaps decay correctly.
- 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and
  abandons stale runs whose LastActionAt is over 24h old.
- Respec / auto-migrate orphan cleanup: !respec and the auto-migrated
  draft wipe now abandon active zone runs and expeditions before
  deleting the dnd_character row.
- Phase R combat-link: applyBossDefeatThreat now wired from
  resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds
  +5/+8 from non-boss/elite kills (§8.1).
- Starvation → forced extraction: briefing-time check forces extract
  with §10.2 coin tax when supplies hit zero.
- GMNat20/Nat1 narration wired into resolveCombatRoom and
  resolveBossRoom (nat-20 takes precedence over nat-1).
- Treasure-undo race: LoadAndDelete on both timer-fire and `undo`
  paths so only one side wins.
- Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6
  of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike)
  documented inline as needing ally/reaction mechanics the engine
  doesn't model.
- Threat-70 warning: tracked in RegionState["siege_warning_fired"]
  so a drop-and-recross doesn't re-fire the beat.
- region_state JSON decode error now logged via slog.Warn instead
  of silently discarded.

Failing TestProdDB_DnDLayer fixed via option (a): track migrated
characters and only run round-trip / idempotency assertions on those,
skipping pre-existing prod-DB rows accumulated from live bot use.

New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon,
briefing double-fire idempotency, threat-70 warning idempotency,
multi-region extract→resume state preservation, and starvation
forced-extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
8a1d9a16ce Adv 2.0 D&D Phase R R1: Legacy activity-loop deprecation
Retires the standalone !adventure dungeon/mine/forage/fish daily loop in
favor of !expedition. Gate intercepts legacy 1/2/3/4 + word-form input
and DMs a deprecation notice; town services (shop/blacksmith/rest/thom)
stay on !adventure.

- Delete dead resolveActivity (269 lines) + parseActivityLocation; add
  isLegacyActivityInput + renderLegacyActivityDeprecation.
- Morning-DM menu rewritten to point at !expedition and the !forage etc.
  harvest commands; writeAdvLocationLines removed (sole caller).
- New dnd_r1_migration.go runs archiveOrphanZoneRuns at Init: archives
  any active dnd_zone_run not linked to an active expedition (via
  exp.run_id or region_state.region_runs). Idempotent.
- Pre-existing flake in TestPickEveningRecap_BossOverridesAll fixed
  (E6c pool added entries without literal "boss"; widen substring set).
- Internal helpers (resolveAdvAction, advEligibleLocations, AdvLocation
  tier data, consumable drops) preserved — babysit/scheduler still use
  them passively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
c3083637ac Adv 2.0 D&D Phase R R6: Zone-condition harvest polish
Wires the §3 zone-note interactions that were called out across the
resource doc but left for the polish pass.

Harvest gates (dnd_expedition_harvest.go):
- zoneConditionHarvestBlock — Underforge Heat 10 blocks !mine, Dragon's
  Lair infernax_awake blocks all harvesting, Abyss Instability 81+
  blocks all harvesting.
- zoneConditionHarvestDCMod — Underforge Heat 7-9 → +3 !mine DC,
  Dragon's Lair Awareness Pulse 3+ (day 9+) → +4 all DCs, Abyss 61-80
  → +5 all DCs, Feywild Double-Day → -3 !forage DC. Delta + reason
  shown inline on the dice line.
- restoreHarvestNodesInRoom — Time Loop hook (in-memory only; cycle
  flow persists downstream).
- Manor !scavenge: 10% Cursed Trinket secondary drop with one-time
  TwinBee warning gated by RegionState["manor_cursed_warned"].

Time Loop wiring (dnd_expedition_temporal.go):
- feywildTemporalPostRollover Loop branch now restores nodes in the
  current room and appends a re-emergence narration line.

Drow Poison Blade recipe (dnd_economy.go):
- ThomCraftRecipe gains BladeWeaponCount; pickBladeWeapons matches
  Type=="weapon" + blade keyword (sword/dagger/blade/scimitar/etc.).
- !craft list shows the blade slot with current inventory count.
- Recipe consumes 1× Drow Poison + 1× any blade weapon → Drow Poison
  Blade (sleep-on-crit for 3 combats, effect TODO).

Tests (dnd_zone_conditions_test.go, all DB-free): block branches per
zone, DC delta tables, manor cursed-trinket gate at 10% boundary,
restoreHarvestNodesInRoom in-memory roundtrip, isBladeWeapon /
pickBladeWeapons coverage + shortfall + exclude-already-consumed,
drow_poison_blade recipe shape.

Flavor reuse only — no new flavor file. Reasons-strings on the dice
line and the cursed-trinket warning are inline (Thom/TwinBee voices
in existing pools didn't fit harvest-blocking or warding contexts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
c170adaf05 Adv 2.0 D&D Phase R R3-R5: Combat-link, zone loot, fishing, economy
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>
2026-05-09 14:25:22 -07:00
prosolis
83cdf07374 Adv 2.0 D&D Phase R R2: Harvest nodes & per-room registry
Per-zone resource registry (§3, all 10 zones) and full per-room harvest
layer wired into expeditions: !forage / !mine / !scavenge / !essence /
!commune / !resources. Auto-spawns a DungeonRun per region on
!expedition start, swaps it on !region travel, retires on
abandon/extract. Long rest at camp replenishes nodes across every
room and region. Reuses existing flavor.Harvest* / RichYield /
NodeDepleted / per-zone Harvest<Zone> pools.

Also clears two pre-existing test failures introduced by the E6c
flavor expansion (briefing substring list + combat-lift trial count).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
cb51c56f00 Adv 2.0 D&D Phase 12 E6c: Expedition flavor topup
Expand under-populated TwinBee expedition pools (singletons → 3, pairs →
4) for: morning briefings (Day1/3/14/21), evening recaps (boss/close-call/
quiet), all four threat clock bands, every zone temporal event narration,
supply depletion, and every milestone narration. Add MilestoneCartographer
and MilestoneSurvivalist pools (spec §13). Wire MilestoneSurvivalist into
AwardCompletionMilestones (was inline string).

All additions in TwinBee voice, no existing entries altered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
cbb2670bb8 Adv 2.0 D&D Phase 12 E6b: Expedition milestones (§13)
Daily milestones fire from the morning briefing once the day count
crosses the threshold (CurrentDay reflects the new day post-rollover):
- First Night (day 2, +50 XP)
- Week One (day 8, +200 XP, Thom Krooke discount flag — hookup deferred)
- Two Weeks (day 15, +500 XP, +1 primary stat — char-system hookup deferred)

Completion milestones via AwardCompletionMilestones, exported for the
combat-link boss-kill path to call once status flips to 'complete':
- Patient Zero (max threat ≤ 50, +10% of XPEarned bonus)
- Survivalist (tier ≥ 3, no forced extraction ever, title flag)
- Long Game (tier 5, legendary item — loot-grant hookup deferred)

Reuses MilestoneFirstNight/WeekOne/TwoWeeks/PatientZero/TheLongGame
flavor pools from twinbee_expedition_flavor.go. Cartographer and
Survivalist have no flavor pool (Survivalist uses an inline string,
Cartographer is fully deferred since it needs combat-link search hooks).

Awarded keys persist as RegionState["milestones"]. Max threat sampled
daily into RegionState["max_threat_seen"] before milestone checks
read it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
157455a3fe Adv 2.0 D&D Phase 12 E6a: Multi-region expedition map (!map)
Adds renderRegionChain — for tier 4-5 multi-region zones, renders the
4-region progression (e.g. ST──DO──IW──TDT for Underdark) with ✓
cleared / ▶ here / · pending markers, and  overlay where a base
camp has been pitched. Single-region zones skip the chain and fall
through to the existing renderZoneMap room layout.

!map (top-level) and !expedition map both invoke handleExpeditionMapCmd
which composes: region chain → current region label → per-run room
map → legend. Region abbreviations are decoded in a "Regions:" footer
so the glyph row stays compact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
d179ca27a7 Adv 2.0 D&D Phase 12 E5: Extraction & Resume
Voluntary !extract burns 1 day, flips status to 'extracting', keeps
all rewards, resumable for 7 real days. Forced extraction (Abyss
collapse, future HP-0/supply-0 hooks) flips to 'abandoned' and applies
the §10.2 20% coin tax via the cycle layer's euro handle. !resume
within the 7-day window repurchases supplies and restores threat &
temporal stacks as-is; expired rows demote to failed.

getActiveExpedition / briefing / recap loaders narrow to status='active'
so 'extracting' rows don't get phantom day rollovers. Reuses
flavor.ExtractionVoluntary, ExtractionForced, ExpeditionResume from
twinbee_expedition_flavor.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
a199efd773 Adv 2.0 D&D Phase 12 E4d: Base camp as persistent waypoint
Replaces the deferred-branch rejection in handleCampCmd: !camp base
now pitches when (a) the zone is multi-region, (b) the player's
current region's BaseCampSite is true, and (c) the region boss is
cleared. Otherwise the surface returns specific reasons (wrong zone,
wrong site, boss not down).

First pitch records the region in RegionState["base_camps"] via
addRegionListEntry — that list is what !region renders the  marker
from, and the waypoint is persistent for the rest of the expedition
even after the camp is broken. Reuses the existing
flavor.BaseCampEstablished pool with [N] day interpolation.

Tests cover non-base-site rejection, uncleared-region rejection, and
the happy path through pitch + waypoint persistence (3 SU cost,
HasBaseCampAt true after).
2026-05-09 14:25:22 -07:00
prosolis
e392cf7d8f Adv 2.0 D&D Phase 12 E4c: !region command + inter-region travel
Spec §11.3. New !region command surface:
  !region          — list regions w/ status (▶ current, ✓ cleared,
                     · visited, ★ zone boss,  base camp)
  !region travel   — move to next region in order

Travel burns one day of supplies via applyDailyBurn (so harsh / siege
multipliers stack normally), advances current_day +1, fires one
transit wandering check (resolveTransitWanderingCheck — same buckets
as the night check but campMod = 0 since you're not bedded down),
then writes region-transition narration on both ends.

Two new flavor pools (RegionTransitDeparture / RegionTransitArrival)
with [REGION_NEXT] interpolation. Travel rejected when camped, and
when already in the final (zone-boss) region.

Tests cover the campMod=0 invariant, the day/supply/region delta on
travel, and the marker rendering in renderRegionList.
2026-05-09 14:25:22 -07:00
prosolis
c2bed4282d Adv 2.0 D&D Phase 12 E4b: region progression hooks + status display
Multi-region zones now default CurrentRegion to the entry region at
startExpedition; that region is also written to regions_visited so
the visited/cleared distinction is meaningful from Day 1.

MarkRegionVisited / MarkRegionBossDefeated expose the combat-link
hook surface. MarkRegionBossDefeated also flips Expedition.BossDefeated
when the cleared region's IsZoneBoss is set, since the zone-boss kill
is the same event from §7's POV (suppresses temporal accumulation,
kills further awareness pulses, etc.). setCurrentRegion is the
companion writer used by E4c's travel command.

!expedition status now shows the region line ("🗺 Region: Drow
Outpost (2/4)") with a ✓ marker when the region boss is down.
2026-05-09 14:25:22 -07:00
prosolis
9484dae146 Adv 2.0 D&D Phase 12 E4a: Region data model + zone registry
Spec: gogobee_expedition_system.md §11. Tier 4–5 zones (Underdark,
Dragon's Lair, Abyss Portal) split into 4 regions each. ExpeditionRegion
struct + per-zone registry; CurrentRegion(e) defaults to Order==1 when
unset; RegionState helpers persist regions_cleared / regions_visited /
base_camps as []string lists. Single-region zones return nil.

Tests cover registry shape (4 per zone, last is IsZoneBoss), lookups,
default current region, and round-trip persistence through DB.
2026-05-09 14:25:22 -07:00
prosolis
10d398fd9a Adv 2.0 D&D Phase 12 E3f: Abyss Portal destabilization
§7.6 instability accumulator: +5/day (cap 100), persisted via
TemporalStack. Bands:
  • 0–20   normal
  • 21–40  mild (-1 WIS)
  • 41–60  reality warps (rooms shift order)
  • 61–80  demon surges (12h wandering)
  • 81–99  unraveling — forces 2× supply burn override + -2 all rolls
  • 100    collapse — forced extraction (status = failed)

Pre-burn override fires at 81–99 so the supply doubling lands the same
day the band activates. Collapse at 100 calls completeExpedition
with ExpeditionStatusFailed and emits the AbyssPortalCollapse line.

Adds ReduceAbyssInstability(e, n) for the §7.6 -10 per major story
objective; combat-link wires the call when objectives complete.
Combat-side knobs (WIS/all-roll penalties, room-shift, surge cadence)
are exposed via AbyssInstabilityBandFor — combat-link reads on tick.

Reuses flavor.AbyssPortalDestabilizationMid / Critical / Collapse per
feedback_reuse_existing_flavor.

Closes Phase 12 E3 (Zone Temporal Events). Next session = E4
(Multi-Region Zones).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
ff7d55f33b Adv 2.0 D&D Phase 12 E3e: Dragon's Lair awareness pulses
§7.5 awareness pulses on Day 3, 6, 9, 12, ... apply +10 threat at
briefing rollover and log a temporal entry (DragonsLairAwarenessPulse
flavor). Day-14 awakening sets RegionState["infernax_awake"]=true
once, with the §7.5 awakening narration; the pulse line is suppressed
that day so the awakening narration carries the moment alone.

Combat-side knobs (kobold patrols +1 enemy per active room, 6h
wandering cadence, dragon-encounter weighting in the wandering table,
forced extraction pressure once awake) are exposed via the
infernax_awake region flag — combat-link phase reads it to switch
behavior. Boss-defeated suppresses entirely.

Reuses flavor.DragonsLairAwarenessPulse / DragonsLairAwakenWarning per
feedback_reuse_existing_flavor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
3a000ada8c Adv 2.0 D&D Phase 12 E3d: Feywild Crossing time distortion
§7.4 daily 1d6 distortion roll fires at briefing time:
  • 1–2 normal day (no override)
  • 3–4 half-day → 0.5× burn multiplier override
  • 5    double-day → 2.0× burn + extra wandering check at recap
  • 6    time loop → narration only; combat-link reads on next room

Refactors applyZoneTemporalPreBurn to return TemporalBurnOverride
(Multiplier-based) instead of a force-double bool. Sunken Temple's
Day-6 tidal piggybacks on the same override pipeline (2.0×) instead
of the siege-flag hack.

Persists the day's distortion bucket to RegionState["feywild_today"]
so the recap path can fire the §7.4 extra wandering check on
double-day.

Reuses flavor.FeywildTimeDistortionHalf / Double / Loop per
feedback_reuse_existing_flavor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
044baddcce Adv 2.0 D&D Phase 12 E3c: Underforge heat accumulation
§7.3 heat stacks: +1/day in the Underforge (cap 10), persisted via
new updateTemporalStack helper. Band thresholds:
  • 1–3 ambient (no narration)
  • 4–6 warning band (combat fire damage applies, narration logged)
  • 7–9 supply band (forces harsh-conditions burn = +50%, narration)
  • 10 critical (exhaustion narration, log entry)

Fortified/base camp long rest in the Underforge drops heat by 2
(updates briefing's "Fortified rest" summary line), per §7.3.

Replaces the placeholder `TemporalStack > 0 → harsh` predicate in
deliverBriefing with zoneTemporalHarsh(), which is now per-zone aware
— Heat 1–3 in the Underforge is correctly flavor-only and no longer
prematurely doubles supply burn from Day 1.

Combat-side knobs (+1d4 fire round-start damage at heat 4–6, dis CON
saves at 7–9, -2 to all rolls at 10) are exposed via heat band but
not yet applied — combat-link phase reads heat band off the active
expedition.

Reuses flavor.UnderforgHeapWarning / UnderforgHeapCritical per
feedback_reuse_existing_flavor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
9d4085f4c3 Adv 2.0 D&D Phase 12 E3b: Haunted Manor nightly reset
§7.2 cycle: every 3rd day's morning briefing in Blackspire fires the
"manor reset" temporal log — non-boss rooms respawn one enemy each.
Boss-defeated suppresses. Wrong-zone is a no-op.

Exposes ManorReset(e, day) bool for the combat-link phase to read at
!advance time when applying the actual respawn (per the deferral
pattern from E2b's wandering-encounter combat).

Reuses flavor.HauntedManorResetMorning per feedback_reuse_existing_flavor.

Deferred: Night-2 quiet warning narration on the recap path — needs
a new hook in deliverRecap; not load-bearing for the reset itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
41c60617db Adv 2.0 D&D Phase 12 E3a: Sunken Temple tidal event
Adds the §7.1 tidal event hook: warnings on Days 4 and 5, peak event
on Day 6 with forced 2× supply burn (overrides tier-2 HarshMod 1.5×),
silenced if the boss has been defeated.

Wires zone-temporal events into deliverBriefing as two new hook points
— pre-burn (mutates burn multiplier) and post-rollover (appends
flavor-bearing log entries). Future E3b–E3f layer onto the same hooks.

Reuses the prewritten flavor.SunkenTempleTidalWarning / TidalEvent
pools per feedback_reuse_existing_flavor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
9cb66d46ac Adv 2.0 D&D Phase 12 E2e: !threat command
§14 surfaces the threat clock as a first-class command. !threat shows
level/100, current band, the per-band combat & supply effects (built
from ThreatBandInfo so display tracks any future band re-tuning), and
the last 5 ThreatEvent log entries with deltas + reasons. Adds a
"siege is active" / "past 70" footer when those gates have triggered.

Active-enemy camp guard and close-call evening recap stay deferred —
both need combat state inside an expedition, which doesn't exist yet
(expeditions still don't host their own combat loop). They land in the
combat-link phase along with the §8.1 combat-driven threat modifiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
f844068660 Adv 2.0 D&D Phase 12 E2d: Fortified Camp branch + overnight rest effects
§5.1 fortified camp unlocked in !camp, gated on exp.BossDefeated for
now (cache-site waypoints land in E3+). +2 SU cost was already in the
E1e cost table; this commit just stops rejecting the type.

§3 / §5.1 / §8.1 overnight rest applied at briefing rollover via
processOvernightCamp:
  - rough: HP floor of HPMax/2 (no spell refresh)
  - standard: full HP, exhaustion -1, refreshAllResources +
    refreshSpellSlots
  - fortified / base: standard rest + 1d6 HP bonus on wake +
    threat-clock -5 (§8.1 modifier)

The camp auto-breaks once the rest applies — staying camped multi-day
isn't a thing in the spec; the player can re-pitch on the next night
if they want to. Briefing body now includes a 💤 line summarizing the
HP/threat delta from the night's rest.

Combat-driven side of fortified ('+1d6 HP on wake' as a rare flavor
event) currently surfaces as the rest summary; a peaceful-night TwinBee
narration variant could come later if we add a dedicated pool — for
now reusing the existing CampEstablished ambience suffices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
5b8ef740f8 Adv 2.0 D&D Phase 12 E2c: Siege Mode effects + threat-70 warning
§8.3 siege economics:
- applyDailyBurn now takes an explicit siege flag and enforces a 2× floor
  on supply burn even when HarshMod is below 2 (tier-1 zones still get
  starved out per spec).
- currentBurn mirrors the same precedence so status/briefing readouts
  stay consistent.
- Briefing rollover passes e.SiegeMode through, decoupling siege from
  the harsh-conditions composite.

§8.3 threat-70 warning: applyDailyThreatDrift emits a one-time
appendApproachingSiegeLog when the level crosses 70 (prevLevel<70 and
new level≥70). Pulls from a new flavor.ThreatClockApproachingSiege pool
seeded with the spec's verbatim warning beat plus two voice-matched
alternates.

Other siege effects (boss +20 HP / Legendary Resistance, cleared-room
respawn, no-short-rest enforcement) stay deferred to the combat-link
phase — ThreatBandInfo already exposes the flags that engine will read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
9b48dda79e Adv 2.0 D&D Phase 12 E2b: Wandering monster table + night phase
§6 night-phase resolution. Fires once per recap when the player has an
active camp. resolveWanderingCheck rolls 1d20 + threat mod (+1 per full
10 above 30) + camp mod (rough +3, fortified -4, base -6) + class mod
(ranger -2 in wilderness zones), then buckets per §6.1 into Peaceful /
Passage / Minor / Standard / Elite / Ambush.

Encounter side picks a roster entry from the zone (weighted by
SpawnWeight, biased to elite for Elite/Ambush) and persists the result
to camp.NightEvents + a "night"-typed log entry. Signs-of-passage adds
+2 threat per spec. Combat resolution itself is deferred — expeditions
don't host their own combat loop yet — so encounter outcomes log as
pending and surface in the recap with an "encounter pending" hint;
the combat-link phase will read these on next !advance.

Flavor reuse: encounter narration pulls from the existing ThreatClock
{Stirring,Alert,Hostile,Siege} pools, matched to the current band so
voice tracks the dungeon's awareness level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
b520b0ce2d Adv 2.0 D&D Phase 12 E2a: Threat Clock state machine
Threshold model on top of the E1a persistence: ThreatBand bracketing
(Quiet/Stirring/Alert/Hostile/Siege) with per-band combat/supply/rest
knobs (ThreatBandInfo). Daily threat drift (+3/day, GMMood-modded:
effusive→-3 elated, hostile→+5 wrathful) wired into the 06:00 briefing
rollover; no-op once the boss is down.

Threshold crossings emit a flavor-bearing log entry pulling from the
prewritten flavor.ThreatClock{Stirring,Alert,Hostile,Siege} pools.
applyBossDefeatThreat is the -20 hook for the eventual combat-completion
wiring (combat→expedition link is a later phase).

Combat-driven modifiers (loud-ability, escape, combat-in-new-room) are
deferred to the same combat-link phase since expeditions don't currently
host their own combat path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
0a3417a341 Adv 2.0 D&D Phase 12 E1e: !camp rough/standard + placement guards
Wires the basic camp surface per gogobee_expedition_system.md §5. Two
camp types ship: rough (any location, +0.5 SU, partial rest) and
standard (cleared room, +1 SU, full long rest). Fortified and base
camps remain explicitly rejected at the surface — those wire up in E2
and E4 respectively, so the command tree is forward-compatible.

Placement validation per §5.2: boss rooms and trap rooms reject; non-
cleared rooms downgrade an intended standard camp to rough. Active-
enemy detection is deferred to E2 (it needs the combat-state hooks
that don't exist on the expedition path yet). Until !advance is wired
into the expedition layer, the player is treated as standing at the
entry room (always cleared), so today's validation effectively only
fires once room context catches up.

Camp-pitch consumes SU immediately and uses flavor.CampEstablished for
the narrative line. !camp break struck and recorded. !camp with no
arg shows help + current camp state.

Tests cover deduction amounts, double-pitch rejection, break, locked-
type rejection (fortified/base), insufficient-supply rejection, and
boss-room placement guard via a synthetic zone run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
7528d5c239 Adv 2.0 D&D Phase 12 E1d: real-time day cycle (06:00 briefing / 21:00 recap)
Two new tickers in adventure.go startup. Each minute, when UTC clock hits
the trigger hour, walks every active expedition whose last_briefing_at /
last_recap_at is stale relative to today's threshold and fires it. Per-
expedition gating (rather than global db.JobCompleted) means a player who
started mid-day still gets their first briefing the next morning.

Briefing: applies one day's supply burn (harsh mult kicks in when threat
> 60 / siege / temporal stack > 0), advances current_day by one, picks
flavor.MorningBriefing<DayBand> (Day1/Day3/Day7/Day14/Day21 → otherwise
Generic), DMs the §12.1 block, appends a 'briefing' log entry, stamps
last_briefing_at.

Recap: scans today's log entries — boss kill picks
flavor.EveningRecapBossKilled, no combat + no narrative entries picks
EveningRecapNothingHappened, otherwise EveningRecapGeneric. DMs the
§12.2 block (HP-aware close-call detection deferred to E2 since it
needs combat hooks). Stamps last_recap_at.

Tests cover day-band selection, day advance + supply burn (incl. harsh
mult), threshold filtering, recap log entry creation, and boss-kill
override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
1042e2ab71 Adv 2.0 D&D Phase 12 E1c: !expedition start/status/log/abandon
Command surface for the multi-day expedition layer. Wired in
adventure.go alongside !zone. Subcommands: list (delegates to zone
list), start <zone> [Ns] [Md], status, log, abandon, help. Default
outfit is one standard pack; pack tokens parse as e.g. "2s 1d".

start debits coins via p.euro.Debit, refunds on persistence failure,
and blocks if a !zone single-session run is also active. Status
renders the §12.1 daily briefing block with HP, supplies (current
burn factors in harsh conditions / siege), threat threshold label,
and depletion warning if rationing or worse. log shows the last 5
ExpeditionEntry rows. abandon flips status to 'abandoned'.

Start uses the prewritten flavor.ExpeditionStart pool for the
narrative coda and writes it to the log so !expedition log shows it
back. Tests cover parser combinations (1s default, 3s 1d, error
paths), threshold band edges, the start→status→log→abandon roundtrip,
and the underfunded-start no-debit guarantee.

Day cycle (E1d) and !camp (E1e) wire on top in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
2413f892e5 Adv 2.0 D&D Phase 12 E1b: supply system (procurement, burn, depletion)
Pure logic per gogobee_expedition_system.md §4. Tier→base SU/day and
harsh-conditions multiplier tables (§4.1). Standard pack 10 SU/50c
(max 3) and deluxe pack 20 SU/90c (max 1) per §4.2. SupplyPurchase
struct with Validate / Total / Cost. Depletion brackets (>25 / 10–25
/ 1–9 / 0) → roll modifier and long-rest gate per §4.3. applyDailyBurn
deducts and clamps; resets the per-day forage flag.

Procurement is exposed as a pure helper here; the !expedition start
flow wires it interactively in E1c.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
9608ce554a Adv 2.0 D&D Phase 12 E1a: Expedition data model + persistence
Lays the multi-day expedition substrate per gogobee_expedition_system.md
§4.4/§5.3/§8.4/§9. Schema dnd_expedition + dnd_expedition_log added in
db.go. Expedition struct, ExpeditionSupplies, CampState, ThreatEvent
shipped in dnd_expedition.go with start/get/scan/abandon/complete,
updateSupplies, updateCamp, applyThreatDelta (clamped + sticky siege),
advanceExpeditionDay, append/recent log helpers. Round-trip + concurrent
rejection + threat clamp/siege + log ordering covered.

E1b (supply procurement) and E1c (commands) wire on top in subsequent
commits; day cycle (E1d) and camp commands (E1e) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
c5defc9b34 Adv 2.0 D&D Phase 11 D11: GMNarrationType coverage test
Lock in the D6 audit conclusion that every GMNarrationType routes to a
non-empty pool for every registered zone. New zones or types added
without wiring their pool now fail TestNarrationCoverage_AllZonesAllTypes
instead of silently rendering "".

Audit confirmed all 12 types × 10 zones already covered (per-zone pools
where they exist, generic fallback otherwise) — no flavor written.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
654bd07b9b Adv 2.0 D&D Phase 11 D10: ASCII !zone map renderer
Replace the flat numbered list in !zone map with a horizontal ASCII
layout — room glyphs joined by ── on top, status markers (✓/▶/·)
underneath, plus a one-line legend. Closes the "ASCII !map renderer"
sub-item of D6 polish from gogobee_dungeon_zones.md §8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
4638cb83a1 Adv 2.0 D&D Phase 11 D9: !zone lore subcommand
Wires the existing GMLore narration pool (zone-specific LoreLines* +
generic LoreLines fallback, all prewritten) into a !zone lore command.
Deterministic per (run, room) — re-asking in the same room yields the
same line; cross-room calls vary. No mood or state side-effects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
7bb922ae62 Adv 2.0 D&D Phase 11 D8: mood-banded TwinBee aside
Adds a TwinBee aside on room entry when GM mood sits at the extreme
bands (≤19 hostile, ≥80 effusive). Mid-bands stay silent — the aside is
strictly extra flavor, never replacing core narration. Two new pools
(MoodAsidesHostile, MoodAsidesEffusive) live alongside the existing
TwinBee voice in twinbee_gm_flavor.go. Wired into zoneCmdEnter and
zoneCmdAdvance; the advance path reloads the run so post-combat
nat20/nat1 deltas are reflected before band lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
71594a0d89 Adv 2.0 D&D Phase 11 D7: !zone taunt / !zone compliment
Wires the prewritten flavor.TauntResponses and flavor.ComplimentResponses
pools to the existing MoodEventTaunt (-10) / MoodEventCompliment (+5)
mood deltas. Both subcommands require an active run and report the new
mood band after applying the delta. Deterministic line selection per
(runID, roomIdx) so repeated taunts in the same room are stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
ebce89b6c6 Adv 2.0 D&D Phase 11 D6: boss phase-two narration
Wires the eight prewritten *PhaseTwoLines pools (Valdris / HollowKing /
Aldric / Thyrak / Ilvaras / Thornmother / Infernax / Belaxath) into
resolveBossRoom. When the combat event log shows enemy HP crossing the
zone's PhaseTwoAt threshold, a TwinBee phase-two callout is injected
between the boss-stat header and the win/loss line. Bosses without a
phase-two transition (Grol, Aboleth) are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
618f6dc34f Adv 2.0 D&D Phase 11 D5b: Tier 4-5 zone flavor files
Adds zone_underdark, zone_feywild_crossing, zone_dragons_lair, and
zone_abyss_portal flavor files with elite-room intros, boss-entry
pools (where canonical twinbee_gm_flavor.go didn't already ship one),
boss ability callouts, and zone-specific lore. Wires the four pools
into zoneRoomEntryPool / bossEntryPool / bossSignaturePool /
eliteRoomEntryPool / zoneLorePool, and replaces the now-obsolete
"unflavored zone" assertions with Tier 4-5 positive routing tests.

RoomEntryUnderdark, RoomEntryDragonsLair, BossEntryInfernax, and
BossEntryBelaxath are reused verbatim from twinbee_gm_flavor.go per
the canonical-flavor-first rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
807864ac8d Adv 2.0 D&D Phase 11 D5a: Tier 4-5 zones + bestiary
Underdark + Feywild Crossing (T4) and Dragon's Lair + Abyss Portal (T5)
registered. Zone hooks, atmosphere, factions, and boss descriptions
reuse the prewritten flavor in gogobee_dungeon_zones.md §5.

New bestiary entries:
  Underdark: drow, drow_elite_warrior, drow_mage, mind_flayer,
    hook_horror, roper, boss_ilvaras_xunyl
  Feywild: redcap, will_o_wisp, quickling, night_hag, fomorian,
    boss_thornmother (reuses Tier 2 green_hag)
  Dragon's Lair: kobold, guard_drake, kobold_scale_sorcerer,
    dragonborn_cultist, young_red_dragon, boss_infernax
  Abyss Portal: quasit, vrock, hezrou, nalfeshnee, marilith,
    boss_belaxath

Boss-entry pools (BossEntryInfernax, BossEntryBelaxath) and per-zone
RoomEntry pools for Underdark + Dragon's Lair already live in
twinbee_gm_flavor.go from a prior phase and are wired in
dnd_zone_narration.go — no narration switch changes here. Feywild and
Abyss room/boss/elite flavor lands in D5b.

Bumps TestBestiaryByCR cap from CR≤20 to CR≤30 to cover Infernax (CR
24, Ancient Red Dragon). New T4/T5 presence tests added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
eea3126412 Adv 2.0 D&D Phase 11 D4c: Tier 3 zone flavor files
New additive flavor files (zone_manor_blackspire_flavor.go,
zone_underforge_flavor.go) with elite-room intros, boss ability
callouts, and zone lore. Manor reuses RoomEntryHauntedManor from
twinbee_gm_flavor.go; Underforge had no canonical RoomEntry/BossEntry
pools yet, so both are defined in the new zone file.

  Aldric Blackspire: Multiattack (life drain) / Charm / Children of the
    Night / Mist Form / Phase Two
  Emberlord Thyrak: Molten Strike / Forge Breath / Living Forge /
    Construct Resilience / Phase Two

Wires zoneRoomEntryPool, bossEntryPool, zoneLorePool,
bossSignaturePool, and eliteRoomEntryPool to the new tier-3 pools.
Adds Tier 3 routing tests for boss-entry, elite line, lore, and
room-entry overlays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
8eaada5c1b Adv 2.0 D&D Phase 11 D4b: Tier 3-5 trap catalog
Adds tier3TrapCatalog (Antimagic Field, Necrotic Miasma, Symbol of
Death, Planar Rift) per design doc §6 Tier 3-5 table. Tiers 3, 4,
and 5 share this pool — the doc lumps them together. Antimagic
Field uses a new SuppressesMagic flag (no damage, narrative
suppression beat); the other three deliver dice damage and skip
the unmodelled secondary effects (max-HP debuff, drop-to-0,
teleport) for a later phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
61b7fff9e2 Adv 2.0 D&D Phase 11 D4a: Tier 3 zones + bestiary
Haunted Manor of Blackspire + The Underforge registered. New bestiary
entries (poltergeist, banshee, wraith, vampire spawn, revenant,
Aldric Blackspire boss; magmin, azer, salamander, fire elemental,
helmed horror, Emberlord Thyrak boss) per dungeon zones doc §5.
Manor reuses Tier 1 shadow; Underforge reuses Tier 1 flameskull.
Manor Blackspire RoomEntry pool already lives in
twinbee_gm_flavor.go and is wired in dnd_zone_narration.go from a
prior phase, so no new flavor file is needed for that zone in D4a.
L1 tier-gate test bumped 4→6 now that T3 is visible to T1 players.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
df3110e2d7 Adv 2.0 D&D Phase 11 D3c: Tier 2 zone flavor files
New additive flavor files (zone_forest_shadows_flavor.go,
zone_sunken_temple_flavor.go) with elite-room intros, boss ability
callouts, and zone lore. Reuses existing RoomEntryForestShadows and
BossEntryHollowKing from twinbee_gm_flavor.go. The Sunken Temple had
no canonical RoomEntry/BossEntry pools yet, so both are defined in the
new zone file.

  Hollow King: Corrupting Aura / Root Surge / Devour Light / Phase Two
  Dreaming Aboleth: Tentacle Multiattack / Enslave / Mucus Cloud /
    Legendary Actions

Wires zoneRoomEntryPool, bossEntryPool, zoneLorePool,
bossSignaturePool, and eliteRoomEntryPool to the new tier-2 pools.
Updates the D2b "no callout pool" test to use Dragon's Lair (still
unflavored) and adds Tier 2 routing tests for boss-entry, elite line,
lore, and room-entry overlays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
4ef152fbe6 Adv 2.0 D&D Phase 11 D3b: Tier 2 trap catalog
Adds Flame Jet (3d6 fire), Collapsing Ceiling (4d6 bludgeoning), and
Glyph of Warding (5d8 lightning) per design doc §6 Tier 2. Detect DCs
match the table (Perception/Investigation/Arcana 14). Save mechanics
("DEX DC X half") are not modeled — full dice resolve on a failed
detect, matching the Tier 1 convention from D2a.

trapsByTier now dispatches Apprentice → tier2TrapCatalog. Added
deterministic-pick + variety + range tests for the new tier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
9e43b0b4b1 Adv 2.0 D&D Phase 11 D3a: Tier 2 zones + bestiary
Forest of Shadows + Sunken Temple of Dar'eth registered. New bestiary
entries (dire wolf, bandit captain, owlbear, corrupted dryad, displacer
beast, green hag, kuo-toa + whip, water elemental, merrow, aboleth thrall,
Hollow King boss, Dreaming Aboleth boss) per dungeon zones doc §5. L1
tier-gate test expectation bumped from 2→4 zones now that T2 is visible
to T1 players.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
4a6ddf45ae Adv 2.0 D&D Phase 11 D2b: Tier 1 zone flavor files
New additive flavor files (zone_goblin_warrens_flavor.go,
zone_crypt_valdris_flavor.go) with elite-room intros, boss ability
callouts (Grol: Surprise Attack / Heart of Hruggek / Terrifying Roar;
Valdris: Corrupting Touch / Legendary Resistance / Call of the Grave /
Phase Two), and zone-specific lore deep-dives. Existing
twinbee_gm_flavor.go is untouched per the protected-files contract.

Wires three narration helpers in dnd_zone_narration.go:
  composeBossEntry — boss-entry render plus a one-line ability callout
  eliteRoomEntryLine — elite-room atmospheric prefix
  zoneLorePool — zone-specific !lore pool with generic fallback

Boss-entry callsite in dnd_zone_cmd.go now uses composeBossEntry; the
elite branch of resolveCombatRoom prefixes with the zone's elite line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
98a9c930fa Adv 2.0 D&D Phase 11 D2a: Tier 1 trap catalog
Replaces the flat-percent HP nick from D1e with the design doc's three
Tier 1 traps (Pit, Tripwire Alarm, Poison Dart). Each trap defines its
own detect skill + DC and damage dice; selection is deterministic per
(runID, roomIdx).

resolveTrapRoom now picks a trap, rolls Perception/Investigation, and
either narrates a clean spot (zero damage) or rolls the trap's dice and
applies HP loss with the existing KO-protection clamp. Tripwire deals
no damage but raises the alarm narratively.

Higher-tier zones still fall through to resolveTrapRoomLegacy until
D3a/D4a fill in their catalogs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
d2a1b79ee3 Adv 2.0 D&D Phase 11 D1e: zone combat + trap + boss resolution
Wires real combat into !zone advance. Each room now resolves through
its own path: Entry is pure flavor, Exploration spawns a SpawnWeight-
biased non-elite from the zone roster, Elite filters to elite-flagged
entries, Trap nicks 8–20% MaxHP scaled by tier (KO-protected), Boss
runs the bestiary entry from zone.Boss with the zone Loot table on
victory. Combat reuses the existing dungeonCombatPhases pipeline with
the player's full D&D layer (class/race/subclass passives, equipment,
HP scaling, armed abilities, pending casts) and persists HP, subclass
state, and CR-weighted XP after each kill.

Mood event triggers fold in: nat-20s/nat-1s scanned from CombatResult
events apply +3/-2 deltas, player_death applies -5 + abandons the run,
zone_complete (already wired in D1d) lands when the boss falls. Loot
drops materialize into adventure_inventory with coin patterns
("coins_2d10x5") expanded into rolled gold-pouch treasure rows and
named items rendered as tier-scaled placeholder treasure (real
equipment-registry wiring is a later content phase).

Updates the D1d mood-on-completion test to drive the persistence layer
directly, since real combat against the L1 Goblin Warrens roster is
non-deterministic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
9dc0e37340 Adv 2.0 D&D Phase 11 D1d: TwinBee narration + mood triggers
Wires the existing internal/flavor TwinBee GM pools into the !zone
state machine. Adds GMNarrationType, mood band derivation, the §3.2
mood-event delta table, and ±2/hr passive decay toward 50. Zone enter
narrates the entry room; advance narrates each subsequent room (boss
rooms use the named BossEntry* pools, completion uses ZoneComplete
and applies MoodEventZoneComplete). Line selection is deterministic
on (runID, roomIdx) so repeat reads render the same prose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
a1738e4a22 Adv 2.0 D&D Phase 11 D1c: !zone command surface
dnd_zone_cmd.go ships handleDnDZoneCmd with subdispatch over the D1b
state machine: list, enter <id|#|name>, status, map, advance, abandon.
Bare !zone (or !zone list) shows zones available at the player's level
with !zone enter hints; an active run is flagged inline so the player
can't lose track of it.

Enter resolves zone input by ID, 1-based list index, or display name
(case/whitespace-insensitive, "the " prefix tolerated), and rejects
unknown / tier-locked zones plus duplicate concurrent runs without
clobbering the active run. Status reports current room / cleared /
loot / GM mood with a band label (effusive/friendly/neutral/grumpy/
hostile per design doc §3.2). Map prints an ASCII layout with ✓ for
cleared rooms and ▶ for the current room. Advance is a deliberate D1c
stub that calls markRoomCleared and reports the next room — combat
resolution per room wires in D1e. Abandon delegates to abandonZoneRun
and is idempotent (no-active surfaces a friendly DM, not an error).

Wired into adventure.go OnMessage right after the existing D&D
commands. 10 new tests cover dispatch (no-char nudge, list with char,
enter by ID and by index, unknown-zone rejection, duplicate-run guard,
status/map with no run, advance moves the room cursor, abandon clears
active and is idempotent), resolveZoneInput across ID/display/index/
case forms, and gmMoodLabel band edges. Full repo test suite green
(modulo a pre-existing RNG flake in
TestSimulateCombat_FirstAttackBonusImprovesEarlyHits unrelated to this
change).

TwinBee narration + mood triggers land in D1d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
ee3b2977aa Adv 2.0 D&D Phase 11 D1b: DungeonRun state machine + dnd_zone_run schema
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>
2026-05-09 14:25:22 -07:00
prosolis
ba2a2b5e90 Adv 2.0 D&D Phase 11 D1a: zone registry + Tier 1 zones
Zone Infrastructure SUB-A: ZoneDefinition / ZoneTier / ZoneEnemy /
ZoneBoss / ZoneLootEntry types and dndZoneRegistry. Tier 1 zones
populated per gogobee_dungeon_zones.md §5: Goblin Warrens (T1, L1-3,
boss Grol the Unbroken CR3) and Crypt of Valdris (T1, L1-3, boss
Valdris the Unburied CR5, phase 2 at 50% HP).

Helpers: getZone, zonesForLevel (2-tier-above ceiling), zonesByTier,
allZones.

Bestiary additions for Tier 1 enemy rosters: goblin_sneak,
goblin_archer, hobgoblin_grunt, worg, goblin_shaman,
hobgoblin_warchief, zombie, shadow, specter, wight, flameskull, plus
the two zone bosses. Zombie AC bumped 8->10 to satisfy engine
TestBestiaryAllWellFormed minimum.

7 new tests covering registry presence, bestiary-ref resolution, boss
CR floor, level-tier gating, loot-chance bounds, room-count sanity,
elite flag presence, and tier->level-range alignment. Full plugin
suite green.

Also marks SUB3 done in gogobee_subclass_system.md (all 15 subclasses
through L15 shipped across SUB3a-d).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
feccd61614 Adv 2.0 D&D Phase 10 SUB3d: Ranger L10/L15
Hunter / Beast Master / Gloom Stalker L10/L15 capstones — last subclass
batch of Phase 10 SUB3.

Hunter L10 Multiattack (Volley/Whirlwind): +15% DamageBonus, collapsing
the AoE extra-target throughput onto the single 1v1 target.
Hunter L15 Superior Hunter's Defense: ~15% incoming damage reduction
(Uncanny Dodge proxy — most generally applicable of the three picks).

Beast Master L10 Share Spells: pet floor lifts to PetAttackProc 0.45 /
PetAttackDmg 6 + lvl/2, plus +5% DamageBonus rider for the Ranger's own
buffed swings. L15 Superior Bond: pet floor 0.55 proc / 8 + lvl/2 dmg
plus 0.92 DamageReduct ("body-blocks more reliably"). Both still use
the existing max-style floors so a stronger external pet (legacy
adventure pet, etc.) is preserved.

Gloom Stalker L10 Stalker's Flurry: +12% DamageBonus representing
recovered misses across the fight (L7 already covers the first-attack
re-roll via AssassinateAdvantage). L15 Shadowy Dodge: ~10% incoming
damage reduction (reaction-imposed disadvantage proxy).

11 new tests; ranger suite + full plugin suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
83654eadc7 Adv 2.0 D&D Phase 10 SUB3c: Cleric L10/L15
Life/War/Trickery Domain L10/L15 capstones.

L10 Divine Strike (all 3 subclasses): +4 flat per weapon hit, scaling
to +9 at L14. New CombatModifiers.DivineStrikePerHit channel, gated on
Weapon != nil since 5e specs "weapon hit". Damage type (radiant/weapon/
poison) varies by domain but isn't tracked by the engine.

Life L15 Supreme Healing: heal-spell dice resolve at max instead of
rolled. Wired through resolveHealOutOfCombat via lifeDomainSupremeHealing
helper.

War L15 Avatar of Battle: 5e physical resistance vs. non-magical
weapons → flat 0.80 DamageReduct (softer than full 50% to account for
elemental hits).

Trickery L15 Improved Duplicity: 5e duplicates grant ally-flank
advantage; no allies in 1v1, so proxied passively as +1 SporeCloud
round and +5% damage (duplicates flicker around the foe).

10 new tests; cleric-suite green. Pre-existing rng flakes
(TestOrcRageFiresOnLowHP, TestSimulateCombat_FirstAttackBonus...) are
unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
fcd067b3c4 Adv 2.0 D&D Phase 10 SUB3b-i: Mage L10/L15
Evocation L10 Overchannel maximizes leveled-spell dice (slot 1-5),
proxied as +50% SpellPreDamage on the queued cast. Self-damage drawback
omitted — our model queues at most one cast/fight, so the "first per
rest" exemption applies. L15 Sculptural Mastery skipped: AoE-only, no
allies in 1v1 (same shape as SUB2b skips).

Abjuration L10 Spell Resistance → DamageReduct *= 0.92 (8% generic DR
proxy; the saves-advantage half is inert since enemies rarely force
saves). L15 Spell Reflection → ReflectNext += 0.30, riding the existing
reflect channel since Counterspell is reaction-deferred to Phase 11.

Necromancy L10 Command Undead has no surface in 1v1 (no thrall/ally
system), re-fluffed as deeper authority over death amplifying the
harvest itself: GrimHarvest multipliers tick up by 1 (2x→3x non-necrotic,
3x→4x necrotic). L15 Improved Undead Thralls proxied as a permanent
skeletal minion via PetAttack channel (proc 0.30, dmg 6 + CON mod);
respects existing higher-pet dmg/proc.

11 new tests; full plugin suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
a9ee7d0f70 Adv 2.0 D&D Phase 10 SUB3a-iii: Thief L10/L15
L10 Use Magic Device: no scroll/wand item system to gate, so we proxy the
"now allowed to use magic items" fantasy as a small per-fight wand zap
(FlatDmgStart += 6) plus a +5% DamageBonus representing better tactical use
of magical implements.

L15 Thief's Reflexes: engine has no "extra turn round 1" primitive, so we
ride the AssassinateBonusDmg + AssassinateAdvantage channel (same one Gloom
Stalker's Dread Ambusher uses for bonus opener damage). Flat +8 on first
hit approximates one extra turn's hit; advantage on first attack covers
the "you swing first" piece.

3 new tests cover the L10 thresholds, L9 gate, and the L15 layer stacking
on top of L10. Full suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
64c70dda27 Adv 2.0 D&D Phase 10 SUB3a-ii: Battle Master L10/L15
L10 Improved Combat Superiority — superiority dice grow d8 → d10. The
two scalar maneuvers (Precision Attack +4→+5; Rally heal +4→+5) bump in
their Apply hooks; Trip Attack has no scalar to scale.

L15 Relentless — 5e regenerates one die when initiative is rolled with
an empty pool. Proxied as +1 max pool size (4→5), which gives the same
"always at least one die per encounter" guarantee across the long-rest
cycle without introducing per-fight regen state.

subclassResourceMax now takes level; initSubclassResources upserts the
max (and bumps current by the delta) so existing characters grow on
level-up. Hooked into the dnd_xp level-up loop so Battle Masters
crossing L15 see the new die without waiting for a long rest. 4 new
tests; no regression in subclass/resource/level-up paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
97c2c37396 Adv 2.0 D&D Phase 10 SUB3a-i: Champion + Berserker L10/L15
Champion L10 Additional Fighting Style → +1 AC + 10% damage (Defense +
Dueling collapse, since one-shot combat can't surface a style picker).
Champion L15 Superior Critical → CritThreshold drops 19 → 18.
Berserker L10 Intimidating Presence → 2 rounds of SporeCloud miss
chance (Action-based fright proxied via the existing fog-cloud channel).
Berserker L15 Retaliation → +15% damage (reaction-strike average uplift,
since the engine has no reaction primitive).

6 new tests; full plugin suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
b56e53300e Adv 2.0 D&D Phase 10 SUB2d: Ranger subclasses (Hunter/Beast Master/Gloom Stalker)
L5/L7 abilities for the three Ranger subclasses, plus Gloom Stalker
Stealth advantage from Umbral Sight. Beast Master pet floors use a
max-style merge so the legacy adventure-pet path can still upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
a733d52166 Adv 2.0 D&D Phase 10 SUB2c: Cleric subclasses (Life/War/Trickery)
Channel Divinity resource pool (2/long-rest) shared across all three
Cleric domains. One armed expression per fight, mirroring Battle Master.

Life Domain
  - L5 Disciple of Life: heal spells restore +(2 + slot level) extra HP
    (lifeDomainHealBonus, hooked into resolveHealOutOfCombat).
  - L5 Preserve Life: Channel Divinity heal — sets HealItem to 5×Cleric
    level, capped at HPMax/2; fires when player drops below 50%.
  - L7 Blessed Healer skipped — no allies in 1v1 combat to "heal an ally".

War Domain
  - L5 War Priest passive: +1 attack bonus + 0.15 DamageBonus, proxy for
    bonus-action extra-attack throughput one-shot combat can't model
    discretely.
  - L5 Guided Strike: Channel Divinity → +10 to first attack roll
    (FirstAttackBonus).
  - L7 War God's Blessing skipped — ally reaction, no allies.

Trickery Domain
  - L5 Invoke Duplicity: Channel Divinity → AssassinateAdvantage (reroll
    first miss) + 0.10 DamageBonus, proxy for "advantage on attacks vs.
    creatures adjacent to the duplicate".
  - L7 Cloak of Shadows passive: +2 SporeCloud rounds (15% enemy miss)
    proxy for the brief invisibility window.
  - L5 Blessing of the Trickster skipped — non-combat Stealth flavor.

15 new tests, all green. Same pre-existing
TestSimulateCombat_FirstAttackBonusImprovesEarlyHits flake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
596e17dc73 Adv 2.0 D&D Phase 10 SUB2b: Mage subclasses (Evocation/Abjuration/Necromancy)
Evocation L7 Empowered Evocation adds INT mod (min +1) to Mage evocation
spell damage. Abjuration L5 Arcane Ward grants a 2×level HP buffer
absorbed before player HP, +prof at L7. Necromancy L5 Grim Harvest heals
2× spell level on spell-kill (3× necrotic) via post-combat hook.

Sculpt Spells / Potent Cantrip / Inured to Undeath skipped — no allies,
save-half is already uniform, and no necrotic enemy damage to resist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
4a5159ee14 Adv 2.0 D&D Phase 10 SUB2-AT: Arcane Trickster spellcasting
Slots into the Phase 9 spell pipeline so an L5+ Rogue who picks Arcane
Trickster becomes a third-caster on the Mage list, INT-based.

  - isSpellcaster(c) gate: full casters by class plus AT Rogue at L5+.
    !cast / !spells now consult this; the cast class-gate also accepts
    Mage-tagged spells when the player is an AT Rogue.
  - subclassSpellSlots() / slotsForCharacter() / setSpellSlotsForCharacter()
    layer subclass slots on top of the class baseline. AT pool: 3 L1 at
    L5–6, 4 L1 + 2 L2 at L7+, +2 L3 at L13+ (third-caster table).
  - spellcastingMod returns INT for AT Rogue.
  - L7 Magical Ambush proxy: spellSaveDC bumps +2 once Rogue+AT hits L7,
    standing in for "targets have disadvantage when casting from hidden"
    since the one-shot combat layer doesn't model hidden state.
  - ensureSpellsForCharacter bootstraps a Mage-list starter spellbook
    (minor_illusion, shocking_grasp, magic_missile, shield, sleep at L5;
    mirror_image + misty_step at L7+; hypnotic_pattern at L13+) and
    refreshes the slot pool on level/subclass change.
  - applySubclassChoice runs ensureSpellsForCharacter for AT so the
    spellbook is provisioned at selection time (idempotent).
  - !cast clamps upcasts to highestAvailableSlotForChar when AT, since
    third-caster's slot ceiling lags behind the spell list.

Tests added: AT spellcaster gate (L5 cutoff, non-AT Rogue rejected), INT
mod, subclass slot pool by tier, L7 ambush DC bump, ensureSpells default
list (all on Mage list, slots correct), end-to-end !cast queues
magic_missile for AT Rogue, !cast rejects plain Thief Rogue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
c8fac7ffd9 Adv 2.0 D&D Phase 10 SUB2a-ii: Battle Master + Assassin L5/L7
Battle Master:
  - New "superiority" resource pool (4/long-rest at L5), provisioned via
    initSubclassResources at !subclass selection.
  - Three armed maneuvers fueled by superiority dice:
    * Precision Attack — +d8 (≈+4) to first attack roll
    * Tripping Attack  — enemy skips first attack (reuses Phase 9
      SpellEnemySkipFirst)
    * Rally            — +(d8 + CHA) HealItem at <50% HP
  - L7 Know Your Enemy proxied as +1 AttackBonus passive.

Assassin:
  - L5 Assassinate: AssassinateAdvantage (re-roll first miss, take better
    of two d20s) + AssassinateBonusDmg = level (5 at L5) stacked on top
    of the Rogue's existing Sneak Attack auto-crit.
  - L7 Impostor bumps the bonus damage by +3.
  - L5 Infiltration Expertise → +5 Deception; L7 Impostor → +10.

Engine:
  - CombatModifiers gains FirstAttackBonus, AssassinateAdvantage,
    AssassinateBonusDmg.
  - resolvePlayerAttack consumes each on the first attack only via new
    combatState one-shot flags.

Tests added: 14 covering passive gating (BM L7+, Assassin L5/L7),
maneuver Apply flags, superiority pool init for BM only, !arm gating
across class/subclass for precision_attack, Deception bonus tiers,
statistical lift from FirstAttackBonus and AssassinateBonusDmg in
SimulateCombat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
437460c9b2 Adv 2.0 D&D Phase 10 SUB2a-i: Champion + Berserker + Thief L5/L7
First mechanical slice of the subclass system. Each subclass gets its L5
and L7 abilities wired through the existing combat engine + skill check
substrate; no new resource pools introduced.

Champion (L5/L7):
- Improved Critical: new Mods.CritThreshold lowers crit floor to nat 19+
  in resolvePlayerAttack. Default 20 preserved for non-Champions.
- Remarkable Athlete: +½ proficiency bonus to STR/DEX/CON skill checks
  via subclassSkillBonus, layered on raceSkillBonus.

Berserker (L5/L7):
- Rage: new !arm-able active ability gated to Berserker subclass via
  DnDAbility.Subclass field. Reuses the Fighter stamina pool (3/long-rest
  matches 5e's rage uses). On fire: +2 flat damage per hit, halve incoming
  weapon damage, +50% damage approximation for Frenzy's bonus-attack-per-
  turn (one-shot combat can't model that literally).
- Frenzy exhaustion: new exhaustion column on dnd_character; incremented
  by persistDnDPostCombatSubclass after any rage'd combat. Decremented by
  one per long rest.
- Mindless Rage: documented; charm/frighten conditions don't exist in the
  combat engine yet, so no functional effect until P11 zone bosses land.

Thief (L5/L7):
- Sleight of Hand added to dndSkillTable.
- Fast Hands: +5 to Sleight of Hand checks via subclassSkillBonus.
- Supreme Sneak: advantage on Stealth (best of two d20s) via new
  subclassSkillAdvantage hook in performSkillCheck. 5e's half-speed gate
  dropped — we don't track movement speed.

Plumbing:
- applySubclassPassives layered after applyClassPassives in both arena
  and dungeon combat bridges.
- DnDAbility.Subclass enables per-subclass gating; characterActiveAbilities
  filters !arm and !abilities lists by both class and subclass.
- Existing !respec full-wipe also clears Exhaustion.

Tests: Champion CritThreshold gating across L4/L5/no-subclass; rage Apply
flag-set; rage probabilistic win-rate lift vs tougher enemy; exhaustion
increment only on raged combats; long-rest decrement; Champion bonus on
STR/DEX/CON only and only at L7+; Thief Fast Hands SoH-only; Thief
Supreme Sneak L7+ Stealth-only; statistical advantage roll lifts Thief
stealth average by ≥2 vs plain Rogue; !arm rage subclass gating; schema
roundtrip; characterActiveAbilities visibility.
2026-05-09 14:25:21 -07:00
prosolis
1ee4276bcd Adv 2.0 D&D Phase 10 SUB1: subclass selection system
Implements the identity layer of gogobee_subclass_system.md: 15 subclasses
(3 per class) selectable at L5 via !subclass.

- Schema: new subclass + last_subclass_respec_at columns on dnd_character.
- Registry (dnd_subclass.go): 15 entries with class, display, tagline, and
  TwinBee flavor line; helpers for prompt rendering and input resolution
  (number, id, or display-name; case/space/hyphen-insensitive).
- !subclass command: bare form prints prompt or current; !subclass <name>
  selects (free first time) or changes (500 euros + 30-day cooldown via
  separate timestamp from the full !respec wipe). Save-then-debit ordering
  mirrors !respec audit fix B.
- Level-up DM at L5+ now embeds the real selection prompt instead of the
  Phase 9 placeholder; suppressed once a subclass is set.
- Sheet shows the subclass below the class line, or nudges !subclass when
  unchosen at L5+.
- Existing !respec full-wipe also clears subclass + cooldown timestamp.

Mechanical effects of L5/7/10/15 subclass abilities deferred to SUB2/SUB3.
2026-05-09 14:25:21 -07:00
prosolis
b53e516bf0 Adv 2.0 D&D Phase 9 SP4: mage spellbook cap + level-up nudge
- Enforce per-level Mage known-spell cap in handleSpellsLearn; surface
  spellbook budget in renderSpellsList.
- Mage level-up DM now nudges with "Spells available to learn: N" via
  extracted buildLevelUpMessage.
- Extract halveSavedDamage and enemySpellSaveMod for clarity; document
  single-target AoE limitation in applySpellDamageSave.
- Add tests: mage learn cap, prepare flow, AoE behavior, spell save
  rounding, spells migration.
2026-05-09 14:25:21 -07:00
prosolis
01c70f5297 Adv 2.0 D&D Phase 9 SP3: wire pending spells into combat
applyPendingCast resolves c.PendingCast against the upcoming fight before
SimulateCombat. Damage spells (Fire Bolt, Burning Hands, Magic Missile,
Fireball, etc.) emit a pre-combat spell_cast event via new
CombatModifiers.SpellPreDamage{,Desc}. Control spells (Hold Person, Sleep,
Command) set SpellEnemySkipFirst so the engine skips the enemy's round-1
attack with a spell_held event; Hold-family also primes AutoCritFirst.
Buffs (Mage Armor, Bless, Hunter's Mark, Shield of Faith, Aid, Spiritual
Weapon, Mirror Image, Greater Invisibility) fold into stats/mods directly.

Concentration-on-damage break is left for Phase 11 (turn-based bosses);
ConcentrationSpell persists across fights until manually dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
9e1a1f606c Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
8e0fe0230c Death source tracking + combat threat/jitter fixes
- Adventure characters now record DeathSource ("adventure"|"arena") and
  DeathLocation when killed. Threaded through Kill() and transitionDeath.
  Daily report uses the recorded death location instead of misattributing
  arena deaths to the day's adventure log: when DeathSource != "adventure",
  the adventure block shows the alive icon and a separate "Later fell in
  {location}." line. Standout-loss flavor uses DeathLocation. Empty
  DeathSource (legacy rows) falls back to current behavior.
- calcDamage applies a ±15% per-hit jitter, so successive hits in a phase
  no longer produce identical numbers. Previously every hit in a phase was
  flat (e.g. 17, 17, 17, 17) and mirror-symmetric between player/enemy,
  reading as scripted.
- assessThreat rewritten to use the engine's actual penetration formula:
  damage-per-round each direction, then rounds-to-kill ratio. The old
  additive HP+Attack*3 model ignored Defense, so even fights could be
  rated trivial — players died after consumables were skipped with the
  "threat assessed as manageable" message.
- SelectConsumables never skips on trivial when arenaRound > 0. Arena
  losses cost real money and equipment, so the cost of a wrong assessment
  is too high to gamble on; adventure-side fights still skip trivial
  threats to avoid wasting items on chump enemies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
a00987e75c Coop lockout fix, holidays full list, !fx convert, lottery cleanup
- Co-op: when a run wipes or completes, clear the 99-sentinel combat
  lock for participants and pin combat_actions_used to maxCombatActions.
  Previously the lock only released on the next midnight reset, so
  members were stuck unable to combat for the rest of the day after
  their run ended.
- Holidays: !holidays now prints the full list. Previously it called
  the same featured-only formatter as TwinBee's morning auto-post,
  so users saw the same truncated message twice with a misleading
  "Use !holidays for the full list" hint that pointed at itself.
- Forex: add currency conversion. Auto-detects when the first arg is
  numeric (`!fx 1500 USD EUR`) and also accepts an explicit `convert`
  subcommand. Tolerant parsing: "to"/"into"/"in"/"=" filters, comma
  and k/m suffix support, and any of `1500 USD EUR`, `1500 USD/EUR`,
  `USD/EUR 1500` orderings. Includes parser + comma-formatter tests.
- Lottery: drop 2-match prize tier. At 1/16 odds × €25 it was +EV per
  ticket on its own and bled the community pot (~€775/wk recently),
  preventing jackpot rollover from accumulating. Removed from scoring,
  payout loops, draw announcement, !lottery odds, and !lottery history.
  Match2Winners DB column kept (history rows preserved); always written
  as 0 going forward.
- Lottery: shift week boundary so Sat/Sun return next Monday. Friday's
  23:59 draw used to leave players locked at their 100-ticket cap until
  next Monday rolled in; now the cap resets immediately after the draw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
4a4ef95d65 Merge pull request #9 from prosolis/holdem-tips-validation
Crafting feedback loop + consumable protection + housekeeping
2026-04-30 07:50:24 -07:00
prosolis
5fadfb255f Merge pull request #8 from prosolis/holdem-tips-validation
Co-op dungeons + audit fixes + prod-running adventure tweaks
2026-04-28 22:41:14 -07:00
470 changed files with 147674 additions and 9048 deletions

View File

@@ -1,12 +1,50 @@
# ---- Matrix Connection ----
# GogoBee authenticates via the MAS (Matrix Authentication Service) OAuth 2.0
# device grant. No password/token goes here: on first run the bot prints a URL
# + code to approve ONCE in a browser (logged in as BOT_USER_ID), then stores a
# refresh token in DATA_DIR/mas_auth.json and refreshes silently thereafter.
# To force re-authorization, delete data/mas_auth.json.
HOMESERVER_URL=https://matrix.example.com
BOT_USER_ID=@gogobee:example.com
BOT_PASSWORD=your_password_here
BOT_DISPLAY_NAME=GogoBee
# ---- Auth mode ----
# masdevice (default) — MAS OAuth 2.0 device grant over /sync (above).
# appservice — Matrix appservice: the registration's as_token is the
# credential (no login/MFA/expiry, MAS-durable). The bot
# runs an HTTP listener and receives events over Synapse's
# transaction push instead of /sync (Synapse forbids AS
# users from /sync). E2EE rides MSC3202/MSC2409 + MSC4190.
AUTH_MODE=masdevice
# The following are required only when AUTH_MODE=appservice:
AS_REGISTRATION= # path to the appservice registration YAML (as_token/hs_token/namespaces)
AS_LISTEN_HOST= # bind address for the transaction listener (e.g. 0.0.0.0 or a tailnet IP)
AS_LISTEN_PORT= # bind port; Synapse's registration url must reach host:port
HOMESERVER_DOMAIN= # server_name, e.g. example.com (derives the bot MXID from sender_localpart)
# ---- Cross-signing (optional; both auth modes) ----
# Controls only whether the bot shows as VERIFIED in clients — E2EE works without it.
# Normally you set NEITHER of these. On its first start the bot mints a cross-signing
# identity and persists the recovery key itself to DATA_DIR/cross_signing.json (0600);
# every later start reuses that identity untouched, so users verify the bot once, ever.
#
# Set to 1 for exactly ONE start to throw away the published identity and mint a fresh
# one (the bot stores the new key itself). Every user must then verify the bot again.
# Needed only if the identity was lost, or to adopt one the bot can't re-sign.
CROSS_SIGNING_REGENERATE=
#
# Imports an existing recovery key into DATA_DIR/cross_signing.json on next start.
# Only useful to adopt an identity created before the bot persisted its own key.
CROSS_SIGNING_RECOVERY_KEY=
# Which rooms the bot posts scheduled content to (comma-separated room IDs)
BROADCAST_ROOMS=!roomid:example.com
# The daily 08:00 WOTD auto-post is disabled by default.
# Set to true to enable it. The !wotd command and passive WOTD
# usage tracking work regardless of this setting.
ENABLE_WOTD_POST=false
# Admins who can run admin-only commands (comma-separated Matrix user IDs)
ADMIN_USERS=@yourmxid:example.com
@@ -123,3 +161,17 @@ MINIFLUX_MAX_PER_POLL=5 # max entries per feed per poll (default 5)
RATELIMIT_WEATHER=5
RATELIMIT_TRANSLATE=20
RATELIMIT_CONCERTS=10
# ---- Email Nag (one-shot migration helper: collect+verify missing Authentik emails over Matrix DM) ----
FEATURE_EMAIL_NAG= # set to "true" to enable
EMAIL_NAG_AUTHENTIK_URL= # e.g. https://authentik.parodia.dev
EMAIL_NAG_AUTHENTIK_TOKEN= # Authentik service-account token, scoped to user write
EMAIL_NAG_RESEND_KEY= # Resend API key (sends the verification code)
EMAIL_NAG_MAIL_FROM= # e.g. Parodia <noreply@mail.parodia.dev>
EMAIL_NAG_HOME_DOMAIN= # Matrix server name, e.g. parodia.dev (builds @user:domain)
EMAIL_NAG_TARGETS= # comma-separated Authentik usernames to nag (== Matrix localparts)
EMAIL_NAG_CODE_TTL=30m # verification code lifetime (default 30m)
EMAIL_NAG_MAX_ATTEMPTS=5 # wrong-code attempts before restart (default 5)
EMAIL_NAG_MAX_SENDS_PER_HOUR=5 # cap on verification emails per user per rolling hour (anti-spam, default 5)
EMAIL_NAG_SWEEP_DELAY=2s # delay between DMs during the startup sweep (default 2s)
EMAIL_NAG_REPROMPT_COOLDOWN= # re-nag non-responders whose last prompt is older than this (e.g. 72h); empty/0 = nag once

6
.gitignore vendored
View File

@@ -1,7 +1,11 @@
data/
data/*
!data/open5e/
.env
.env.*
!.env.example
gogobee
gensolver
holdem-train
/open5e-import
/expedition-sim
sim_results/

162
CHANGELOG-2026-04-08.txt Normal file
View File

@@ -0,0 +1,162 @@
GOGOBEE CHANGELOG — 2026-04-08
================================
6 commits, 76 files changed, ~3,000 lines added
────────────────────────────────────────────────────────────────────────────────
1. ADVENTURE 2.5 — STEPS 1-2: ACTION ECONOMY & CHAT LEVEL LOOKUP
────────────────────────────────────────────────────────────────────────────────
- Split daily actions into combat (1) and harvest (3) instead of 3 generic.
Players can now do 1 combat + 3 harvest per day instead of choosing 3 from
either pool.
- Added cross-plugin chat level lookup so adventure perks can read XP levels
from the main XP plugin.
- Updated character sheet to show combat/harvest action counts separately.
- Refactored adventure command dispatch for the new action types.
Files: adventure.go, adventure_character.go, adventure_render.go,
adventure_babysit.go, adventure_events.go, adventure_scheduler.go,
adventure_twinbee.go, xp.go, db.go, main.go
────────────────────────────────────────────────────────────────────────────────
2. ADVENTURE 2.5 — STEPS 3-6: CHAT LEVEL PERKS
────────────────────────────────────────────────────────────────────────────────
- Chat Level XP Bonus: +5% adventure XP per 10 chat levels, capped at +25%.
- Chat Level Rare Drop Bonus: +0.5% rare drop rate per 10 chat levels,
capped at +2.5%.
- Shop Greeting: Luigi's greeting changes at chat levels 10, 30, and 50.
- Death Pardon: Chat level 30+ grants one free death pardon per week.
Pardoned players keep their streak and skip the death timer.
Files: adventure_chat_perks.go (new), adventure.go, adventure_arena.go,
adventure_character.go, adventure_masterwork.go, adventure_shop.go,
adventure_treasure.go, db.go
────────────────────────────────────────────────────────────────────────────────
3. ADVENTURE 2.5 — STEPS 7-9: ARENA STREAK SYSTEM
────────────────────────────────────────────────────────────────────────────────
- Replaced per-tier arena entry with a continuous streak starting at Tier 1.
- Players fight through T1-T5 sequentially. After each tier, a 30-second
countdown starts with real-time message edits. Type !bail to cash out,
or auto-advance to the next tier.
- Per-tier euro multipliers: T1=1.0x, T2=1.5x, T3=2.0x, T4=2.75x, T5=4.0x.
- XP multiplier at payout based on tiers cleared: 1.0x/1.2x/1.5x/1.85x/2.5x.
- Death forfeits ALL accumulated rewards (euros + XP).
- Gladiator's Helm: streak-exclusive Masterwork-tier drop (8% at T4, 18% T5).
- Removed old commands: !arena tier N, !arena descend, !arena cashout.
- Added !bail command and countdown goroutine with message editing.
- Restart recovery: stale active/awaiting runs auto-bail on bot restart.
Files: adventure_arena.go (major rewrite), adventure_arena_render.go,
adventure.go, adventure_arena_test.go, db.go
────────────────────────────────────────────────────────────────────────────────
4. BUG FIXES & POLISH
────────────────────────────────────────────────────────────────────────────────
- Fixed streak grace period calculation in adventure scheduler.
- Fixed UNO earnings tracking not crediting properly.
- Fixed hospital UX: cleaner admission flow, insurance billing display.
- Added wordle/lottery atomicity: ticket purchases and wordle completions
now use proper transactions.
- Added !adv alias for !adventure.
- Hospital insurance billing now shows itemized breakdown.
- Fixed character sheet showing wrong holiday action count.
- Removed dead holiday prompt code.
- Capped babysit counter to prevent unbounded growth.
Files: adventure_character.go, adventure_hospital.go, adventure_scheduler.go,
adventure_babysit.go, adventure_render.go, lottery.go, lottery_db.go,
lottery_draw.go, wordle.go, stats.go, urls.go, llm_passive.go
────────────────────────────────────────────────────────────────────────────────
5. FULL CODEBASE AUDIT — 21 FIXES
────────────────────────────────────────────────────────────────────────────────
CRITICAL:
- Credit()/Debit() now reject non-positive amounts. Previously, a negative
amount could mint or drain euros with no validation.
- Lottery ticket purchase moved count check inside DB transaction, closing
a TOCTOU race where concurrent buys could exceed the 100-ticket cap.
HIGH:
- Arena bail channel ownership reworked. The countdown goroutine and bail
handler now use LoadAndDelete to claim exclusive ownership of the channel,
preventing double-close panics.
- Added safeGo() panic recovery wrapper. Applied to 17 goroutine launch
sites across 14 plugin files. A panic in any async handler previously
killed the entire daemon.
- Entry.ID validated before exec.Command in fetch-esteemed (path traversal).
- Silently ignored DB errors in lottery_db.go now logged.
MEDIUM:
- Added database indexes: idx_arena_runs_user(user_id, status) and
idx_euro_bal_user(user_id) for high-traffic query paths.
- db.Close() function added; called on SIGINT/SIGTERM shutdown.
- Miniflux mutex pattern fixed: snapshot pollingDisabled under lock,
check after unlock (was Lock/early-return/Unlock without defer).
- JSON unmarshal errors in lottery history/tickets now logged instead
of silently discarded.
- Internal error details no longer leaked to users in forex and esteemed
responses (was showing raw %v error strings).
LOW:
- "Failed to load character" error now suggests !adventure.
- Empty !buy args shows usage hint instead of "No item matching ''".
FALSE POSITIVES VERIFIED:
- fun.go array access (bounds checks exist at L441, L591).
- adventure_twinbee.go filtered[0] (empty check at L90).
- wotd.go defs[0] (empty check at L102).
- moderation.go timer overwrite (existing.Stop() called at L799).
- All rate-limit callers already provide user feedback.
Files: euro.go, lottery_db.go, adventure_arena.go, plugin.go, db.go,
main.go, forex.go, esteemed.go, miniflux.go, adventure.go,
fetch-esteemed/main.go, + 14 plugin files (safeGo conversions)
────────────────────────────────────────────────────────────────────────────────
6. TEST COVERAGE — 328 TESTS ACROSS 4 PACKAGES
────────────────────────────────────────────────────────────────────────────────
NEW TEST FILES:
- internal/util/parser_test.go (30 tests)
XPForLevel, LevelFromXP, round-trip for 0-100, ProgressBar edge cases,
IsCommand, GetArgs, HasNonASCII, ParseMessage
- internal/crypto/crypto_test.go (12 tests)
Encrypt/Decrypt round-trip, empty plaintext, different nonces,
wrong key rejection, HMAC determinism/length, ParseKey validation
- internal/plugin/helpers_test.go (30 tests)
formatNumber, formatNumberInt64, normalizeExpression (natural language,
prefix stripping, commas, percent-of), formatCalcResult, countMatches,
formatLotteryNumbers, generateLotteryNumbers (count, range, sorted,
unique), joinNames, chatLevelXPBonus, chatLevelRareBonus, shopGreeting
- internal/plugin/audit_fixes_test.go (21 tests)
safeGo panic recovery (normal, panic, nil panic), bail channel
ownership transfer (both directions), arena status renders for
indexed states, lottery edge cases, error message leak checks,
UX guidance verification, integer overflow guards, regression guards
- internal/db/db_test.go (4 tests)
Init/Close lifecycle, idempotent Init, schema index verification,
schema runs cleanly against fresh SQLite
EXISTING TESTS (pre-existing, not written today):
- adventure_arena_test.go: 55 (51 pre-existing + 4 added)
- adventure_shop_test.go: 26
- uno_test.go: 20
- adventure_test.go: 17
- holdem_tips_test.go: 16
- adventure_holidays_test.go: 15
- wordle_game_test.go: 9
- holdem_eval_test.go: 8
- blackjack_test.go: 7
TOTAL: 328 tests, 0 failures, 4 packages covered

19
NOTICE Normal file
View File

@@ -0,0 +1,19 @@
GogoBee — third-party content attribution
==========================================
Open5e SRD data
---------------
Portions of GogoBee's spell, bestiary, and magic-item data are derived from
the Open5e dataset, which republishes the Systems Reference Document (SRD) 5.1.
Source: https://open5e.com / https://api.open5e.com
License: Creative Commons Attribution 4.0 International (CC-BY-4.0)
https://creativecommons.org/licenses/by/4.0/
SRD 5.1: © Wizards of the Coast LLC, released under CC-BY-4.0.
The data is vendored into this repository (data/open5e/) and compiled into
generated Go source (e.g. internal/plugin/dnd_spells_srd_data.go) by the
cmd/open5e-import tool. GogoBee has no runtime dependency on the Open5e API.
GogoBee's hand-authored balance tuning, classifiers, and game systems built
on top of this data are not themselves SRD content.

176
README.md
View File

@@ -30,8 +30,9 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
- **50 plugins** with dependency injection and ordered registration
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 24 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure (daily idle RPG via DMs — dungeon, mine, forage, fish, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions, hospital revival system, Robbie the Friendly Bandit automated inventory cleaner), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), Co-op Dungeons (27 day party runs with funding tiers, TwinBee-narrated floor events with voting, spectator parimutuel betting, basket/mimic gift system, weighted-roll item distribution including masterworks at T4T5), all with channel restriction
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 24 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure 2.0 (D&D-layered RPG: 7 races, 5 classes with 3 subclasses each through L15, real spell slots and forward-simulating combat engine, 10 hand-tuned dungeon zones across 5 tiers, multi-day expeditions with supply burn / threat clock / day cycle / multi-region travel / extract-and-resume / per-zone temporal events, harvest nodes per room, TwinBee as a mood-driven DM with ten zone-specific narration pools, all on top of the existing economy/housing/pets/Misty/Arina/blacksmith/hospital/rivals systems), Arena (5-tier combat gauntlet with 20 unique monsters, streak-based payouts, death lockout, leaderboard), Co-op Dungeons (27 day party runs with funding tiers, TwinBee-narrated floor events with voting, spectator parimutuel betting, basket/mimic gift system, weighted-roll item distribution including masterworks at T4T5), all with channel restriction
- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts.
- **Space inviter** (optional) - detects local homeserver users who aren't in any Matrix Space and DMs the configured admin to ask whether to invite them to a target Space. Replies are `yes` / `no` / `ignore`. Persistent prompt log in SQLite means restarts/upgrades don't re-ping the admin about users they've already answered. Runs on startup sweep + reactive on join events.
- **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently
- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - Palavra do Dia (Portuguese WOTD with en/fr translations and etymology), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
- **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, tarot readings, conversation summaries
@@ -90,7 +91,14 @@ Everything is configured through environment variables or a `.env` file.
|----------|-------------|
| `HOMESERVER_URL` | Matrix homeserver URL, e.g. `https://matrix.org` |
| `BOT_USER_ID` | Bot's Matrix user ID, e.g. `@gogobee:matrix.org` |
| `BOT_PASSWORD` | Bot's Matrix password |
GogoBee authenticates via the **Matrix Authentication Service (MAS) OAuth 2.0
device grant** — no password or token in the environment. On first run it prints
a verification URL and user code; approve it once in a browser while logged in
as `BOT_USER_ID`. The bot then stores a refresh token in `DATA_DIR/mas_auth.json`
and refreshes its access token silently for the life of the deployment. Delete
`data/mas_auth.json` to force re-authorization. Requires a homeserver with MAS
enabled (advertised via the `org.matrix.msc2965.authentication` well-known).
### Core (optional)
@@ -150,6 +158,7 @@ Everything is configured through environment variables or a `.env` file.
| `FEATURE_ESTEEMED` | Set to anything to enable satirical esteemed member posts |
| `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) |
| `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) |
| `FEATURE_SPACE_INVITER` | Set to `true` to enable the space inviter (disabled by default) |
| `FEATURE_MARKET` | Set to `true` to enable the market overview plugin |
| `MARKET_BROADCAST_SUMMARY` | Set to `true` to auto-post daily market summary to broadcast rooms |
| `DISABLE_WOTD_POST` | Set to `true` to suppress daily WOTD auto-post (the `!wotd` command and passive WOTD usage tracking still work) |
@@ -228,6 +237,20 @@ All moderation settings are optional. The system is disabled unless `FEATURE_MOD
| `SPACE_GROUP_THRESHOLD` | `50` | Percentage of the smaller room's members that must overlap to group rooms together (1100) |
| `HOLIDAY_COUNTRIES` | `US` | Comma-separated ISO country codes for Calendarific holiday posts |
### Space Inviter
Disabled unless `FEATURE_SPACE_INVITER=true`. Requires the bot to be joined to **every** Space on the homeserver — if a Space exists that the bot can't see, users in only that Space will be misclassified as Space-less. Uses the Synapse Admin API to enumerate local users.
| Variable | Default | Description |
|----------|---------|-------------|
| `SPACE_INVITER_TARGET` | | Room ID of the Space to invite users into on `yes` reply |
| `SPACE_INVITER_HOME_DOMAIN` | | Your homeserver domain (e.g. `parodia.dev`) — only users with this suffix are considered |
| `SPACE_INVITER_SYNAPSE_URL` | | Base URL for Synapse admin API (e.g. `https://parodia.dev`) |
| `SPACE_INVITER_ADMIN_TOKEN` | | Synapse admin access token |
| `SPACE_INVITER_ADMIN_USER` | | User ID to DM with prompts (the operator) |
| `SPACE_INVITER_DELAY` | `2s` | Delay between prompts during the startup sweep |
| `SPACE_INVITER_COOLDOWN` | `720h` | Minimum gap between re-prompts for the same user (Go duration). Prevents restart/upgrade ping-spam — a `no` reply is honored for this long across restarts. `ignore` is permanent. |
### Missing Members
| Variable | Default | Description |
@@ -473,44 +496,150 @@ Daily cooperative Wordle — one puzzle per day, the community works together wi
Economy rewards are tracked per player — `!wordle stats` shows total earnings.
### Adventure (DM-based idle RPG)
### Adventure (DM-based RPG)
A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, fish, forage, visit the shop, blacksmith, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players.
A layered RPG. **Adventure 2.0** grafts a full D&D-style character system on top of the existing adventure economy, then builds two endgame loops on top of that: single-session dungeon zones and multi-day expeditions. The legacy systems — housing/Thom Krooke, pets, Misty/Arina, blacksmith, hospital, rivals, Robbie, arena, co-op dungeons — all keep working unchanged. Players who don't opt into 2.0 keep the legacy adventure shape; players who run `!setup` get the full D&D layer on top.
Characters auto-create on first `!adventure` (or `!adv`) command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. DM replies are only interpreted as adventure choices for 15 minutes after a menu is sent, so other DM-based games (UNO, Hold'em) won't conflict.
Characters auto-create on first interaction. Most gameplay happens in DMs — the morning prompt routes to zones/expeditions for the active flow plus in-town services (shop, blacksmith, rest, hospital, Thom Krooke) for the rest.
#### Adventure 2.0 — D&D Layer
`!setup` once. Pick a race + class. Your existing `combat_level` becomes your D&D level, treasures become attunements, equipment slots carry forward 1:1. Every fight, harvest, arena run, and co-op dungeon is then resolved through D&D mechanics — STR/DEX/CON/INT/WIS/CHA, HP, AC, ability checks, saving throws, all the rest.
**Races (7) — at race choice, fixed stat mods + a passive:**
| Race | Mods (S/D/C/I/W/Ch) | Passive |
|---|---|---|
| Human | 0/0/0/0/0/0 | Versatile (floating +1 not yet wired) |
| Elf | 0/+2/-1/+1/+1/0 | Darkvision; immune to sleep effects |
| Dwarf | +1/-1/+2/0/+1/-1 | Poison resistance; bonus vs. underground |
| Halfling | 0/+2/0/0/+1/0 | Lucky: once per combat, reroll a nat 1 |
| Orc | +3/-1/+2/-1/-1/-1 | Rage: once per combat, +50% damage for one turn |
| Tiefling | 0/+1/0/+1/0/+2 | Fire resistance; bonus on CHA checks |
| Half-Elf | 0/+1/0/+1/0/+2 | Two bonus skill proficiencies |
**Classes (5):** Fighter (d10 HP, STR/CON), Rogue (d8, DEX/INT), Mage (d6, INT/WIS), Cleric (d8, WIS/CHA), Ranger (d8, DEX/WIS).
**Subclasses** unlock at L3 via `!subclass`. Each class has 3 archetypes with mechanical features at L5/L7/L10/L15:
| Class | Subclasses |
|---|---|
| Fighter | Champion, Battle Master, Berserker |
| Rogue | Thief, Assassin, Arcane Trickster |
| Mage | Evocation, Abjuration, Necromancy, Arcane Trickster |
| Cleric | Life, War, Trickery |
| Ranger | Hunter, Beast Master, Gloom Stalker |
**Spells.** Mages, Clerics, and Rangers cast for real. Spell slots tracked, combat consumes them. Mages have a spellbook cap that grows with level — pick what to learn at level-up via `!learn`.
**Combat engine.** Forward-simulating turn-by-turn — d20 vs AC for every attack, hit/crit/miss/fumble narration with surfaced roll values (`(🎲 14 vs AC 13)`), monster abilities, status effects, class resources, consumables. Outcomes stream over 23 second pacing for suspense, with a roll summary appended.
**Dungeon zones (`!zone`)** — 10 hand-tuned zones, 5 tiers, level bands L1L20:
| Tier | Zones |
|---|---|
| T1 | Goblin Warrens, Crypt of Valdris |
| T2 | Forest of Shadows, Sunken Temple |
| T3 | Haunted Manor, Underforge |
| T4 | Underdark Outpost, Feywild Crossing |
| T5 | Dragon's Lair, Abyss Portal |
Each run is procedurally laid out (entry → exploration → trap → elite → boss with exploration filler). TwinBee narrates as a mood-driven DM (0100 mood, shifts on crits/deaths/taunts/zone completion). `!zone enter`, `!zone advance` to resolve the current room, `!zone status`, `!zone map` (ASCII layout `E──?──T──★──☠`), `!zone lore`, `!zone taunt` / `!zone compliment` to poke or flatter your DM.
**Multi-day expeditions (`!expedition`)** — the long form:
- **Outfitting:** buy supply packs at start (standard/deluxe), cost scales with zone tier
- **Real-time day cycle:** 06:00 morning briefing, 21:00 evening recap (configurable)
- **Supply burn:** rations tick down each day; harsh zones burn faster; running out triggers forced extraction
- **Threat Clock (0100):** every action is noticed. Crossing bands shifts monster density, patrol odds, night events: Quiet → Stirring → Alert → Hostile → Siege
- **Patrol encounters** at Alert+ — fights interrupt traversal
- **Night phase / wandering monsters** — checked at the recap
- **Siege Mode (threat 80+):** fortified camp branches, special warnings
- **Camp:** `!camp rough` (free, no protection) vs `!camp standard` (consumes supplies, grants overnight rest)
- **Milestones:** First Night, Deep Dive, Long Haul, Survivalist, etc.
- **Expedition log** (`!expedition log`) — every event recorded
**Multi-region zones.** T4+ zones have multiple connected regions. `!region` shows where you are, where you can travel, and visited set. Inter-region travel consumes supplies and ticks threat. Base camp is a persistent waypoint per expedition. `!map` renders the full multi-region map.
**Per-zone temporal events** (signature time-based effects on every T2+ zone):
- **Sunken Temple** — tidal cycle floods/recedes rooms
- **Haunted Manor** — nightly reset; cleared rooms come back
- **Underforge** — heat accumulation; gear damage rises
- **Feywild Crossing** — time distortion; days pass at non-uniform rates
- **Dragon's Lair** — awareness pulses warn the dragon directly
- **Abyss Portal** — destabilization stack threatens forced collapse
**Extract & resume.** `!extract` bails safely from an expedition. Loot/XP/coins kept, expedition flips to `extracting` status, `!resume` within 7 days picks up from base camp.
**Harvest.** `!forage` / `!mine` / `!fish` / `!scavenge` / `!essence` / `!commune` work in cleared rooms during expeditions, and (Phase R) in single-session `!zone enter` runs too — per-room nodes seeded from the zone resource pool, finite charges, class-specific yield bonuses (Ranger forage/fish, Fighter mine), `!resources` to see what's gatherable. Zone-condition events (tidal block, heat block, manor cursed trinket drops) layer on during expeditions.
**Boss combat.** Every zone's boss is in the bestiary with its own stat block and phase-two narration crossing 50% HP. Win drops the per-zone loot table; T5 always drops a masterwork.
**HP scale.** D&D HP is the source of truth (your sheet says, e.g., `33 max`). The combat engine internally uses a legacy HP scale (calibrated for the old `combat_level`-based curves, ~123 max for a high-level player), but combat outcomes are translated back to D&D scale before display: `HP 33→26 / 33` is what you see, never the engine's internal numbers.
**Holidays.** On recognized holidays (~20/year, religious + cultural calendars), runs start with TwinBee mood +5, expedition outfitting includes a free standard pack, and harvest yields +1 per attempt. Hospital surcharge still applies on holiday deaths.
**Player commands cheat sheet:**
| Command | What it does |
|---|---|
| `!setup` | Opt into D&D — pick race + class, inherits your existing progress |
| `!sheet` | Full character sheet |
| `!learn` | Mages — pick a spell at level-up |
| `!subclass` | Inspect / select subclass at L3 |
| `!rest short` / `!rest long` | Recover HP between fights (1h / 24h cooldowns) |
| `!check <skill> [dc]` | Roll a skill check |
| `!zone` / `!zone list` | Zones available at your level |
| `!zone enter <id>` | Start a single-session run |
| `!zone advance` | Resolve current room |
| `!zone status` / `!zone map` / `!zone lore` | Run inspection |
| `!zone abandon` | End run, no rewards |
| `!zone taunt` / `!zone compliment` | Poke the DM |
| `!expedition list` | Zones available at your level |
| `!expedition start <zone> [Ns] [Md]` | Outfit and begin |
| `!expedition status` / `!expedition log` | Daily briefing + history |
| `!expedition abandon` | End expedition (no rewards) |
| `!camp rough` / `!camp standard` | Make camp |
| `!region` | Multi-region inspection + travel |
| `!map` | Full multi-region map |
| `!forage` / `!mine` / `!scavenge` / `!fish` / `!essence` / `!commune` | Work the room |
| `!resources` | Inventory of harvested materials |
| `!threat` | Current threat clock |
| `!extract` | Bail safely from an expedition |
| `!resume` | Pick up an extracted expedition |
| `!respec` | Wipe character (paid; cooldown gated) |
**Compatibility.** Adv 2.0 is fully additive — every pre-2.0 column on `adventure_characters`, `adventure_equipment`, `adventure_inventory`, `adventure_treasures`, `arena_stats`, etc. is preserved with original semantics. Migrations are column-adds with defaults only. Players who haven't run `!setup` keep the legacy adventure shape. `combat_level` becomes the D&D level; treasures become attunements; equipment slots carry forward unchanged.
#### Legacy adventure surface (still active)
The pre-2.0 town economy and supporting systems still work alongside the D&D layer.
| Command | Description |
|---------|-------------|
| `!adventure` / `!adv` | Open today's action menu (sent via DM) |
| `!adventure status` | Character sheet (sent via DM) |
| `!adventure` / `!adv` | Open today's menu (sent via DM) — now points at `!expedition` and town services |
| `!adventure status` | Character sheet (legacy view; `!sheet` is the D&D-layer view) |
| `!adventure shop` | Browse equipment for sale (sent via DM) |
| `!adventure buy <item>` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) |
| `!adventure sell <item>` | Sell an inventory item (credits euro balance) |
| `!adventure sell all` | Sell entire inventory |
| `!adventure sell all` | Sell entire inventory (consumables protected) |
| `!adventure equip` | Equip masterwork gear from inventory |
| `!adventure inventory` | List current inventory |
| `!adventure leaderboard` | Top adventurers |
| `!adventure revive @user` | Revive a dead player (admin) |
| `!adventure respond <choice>` | Reply to today's action prompt (alternative to DM reply) |
| `!adventure summary` | Force daily summary post (admin) |
| `!adventure boost` | Double XP/money for all adventurers for a day (admin) |
| `!adventure babysit [week\|month\|status\|cancel]` | Hire TwinBee to run your daily action (trains your weakest gathering skill) |
| `!adventure babysit [week\|month\|status\|cancel]` | Hire TwinBee to harvest on your behalf (trains your weakest gathering skill) |
| `!adventure blacksmith` | Show repair quotes for damaged gear |
| `!adventure repair [all\|<slot>]` | Repair one slot or all damaged gear at the blacksmith |
| `!adventure rivals` | Show the rival pool and any open challenges |
| `!thom [shop\|buy\|pay\|payoff\|autopay\|petbuy]` | Thom Krooke — housing, mortgage, and pet adoption broker |
| `!hospital` | Check in to St. Guildmore's Memorial Hospital for same-day revival (costs €25k × combat level) |
**DM replies:** Reply to the morning prompt with a number (`1``7`) or activity name (`dungeon`, `mine`, `fish`, `forage`, `shop`, `blacksmith`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc.
#### Skill tracks
#### Activities
Four activity types across 5 tiers of locations. Higher tiers require higher character level and equipment.
- **Dungeon** — combat XP, chance of death, best loot potential
- **Mining** — mining XP, cave-in risk, ore and gem drops
- **Fishing** — fishing XP, hazard risk, catch drops
- **Foraging** — foraging XP, wildlife hazards, herb and reagent drops
Four skills are tracked separately from D&D level: **Combat (legacy)**, **Mining**, **Foraging**, **Fishing**. They gain XP from harvesting in zones, arena fights, encounters, and (legacy) babysitter actions. Skill levels gate masterwork drops, fishing-zone proficiency bonuses, and crafting recipe unlocks.
#### Mechanics
@@ -519,10 +648,9 @@ Four activity types across 5 tiers of locations. Higher tiers require higher cha
- **Treasures** — rare collectibles (up to 3) that provide passive bonuses (XP multipliers, death chance reduction, loot quality). Prompted to discard when at cap.
- **Streaks** — consecutive days of activity grant escalating bonuses (XP, loot quality, death chance reduction). Resting resets your streak. Dead players' streaks are frozen — you won't lose your streak to involuntary downtime.
- **Grudge** — dying at a location marks it as your grudge. Returning there grants +10% success and +25% XP. Clears on success.
- **Party bonus** — if two players independently visit the same location on the same day, both get +10% loot value.
- **Death** — locked out for 6 hours. Natural respawn happens automatically when the timer expires. Use `!hospital` for immediate revival at a cost. Death's Reprieve (surviving a lethal roll) sets all equipment to 1 condition instead of destroying it. Dead players' streaks are preserved with a grace period — if you die and can't act on revival day, your streak won't reset.
- **Holidays** — on recognized holidays (~20/year across religious and cultural calendars), you get a second daily action. Hebrew and Islamic calendar support for floating holidays.
- **TwinBee NPC** — takes a daily action (location tier capped by best player's combined level), distributes loot share to active players scaled quadratically by level, and occasionally gifts random buffs.
- **Holidays** — on recognized holidays (~20/year across religious and cultural calendars), runs start with TwinBee mood +5, expedition outfitting includes a free standard pack, and harvest yields +1 per attempt. Hebrew and Islamic calendar support for floating holidays.
- **TwinBee** — narrates as a mood-driven DM during zone runs and expeditions (see Adv 2.0 above). The legacy daily activity loop is retired in favor of zones/expeditions; the babysitter (below) is the surviving "TwinBee runs an action for you" surface.
- **Hospital** — St. Guildmore's Memorial Hospital offers same-day revival for dead players. The bill is comically inflated (€125k × combat level) but guild insurance covers 80%, leaving €25k × combat level. Players who can't afford it are discharged back to the natural respawn queue. Nurse Joy provides the bedside manner.
- **Robbie the Friendly Bandit** — an automated NPC who visits at a random hour each day (8:0021:00 UTC). Robbie takes sub-tier gear from your inventory (shop gear below your equipped tier, masterwork gear you've outgrown), leaves €50 per item as a "handling fee," and donates everything to the community pot. If he takes masterwork gear and you don't already have one, he drops a "Get Out of Medical Debt Free" card. No player command — Robbie comes to you.
- **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters.
@@ -544,11 +672,11 @@ Max auto-crafts per combat scales with level: 1 at Foraging 10, 2 at 20, 3 at 30
#### Blacksmith & Repair
Equipment accumulates damage on bad outcomes and breaks at 0 condition. The blacksmith repairs gear for a per-point fee that scales with tier (base rates T0→T5: €1, €2, €5, €12, €30, €80; masterwork and arena gear use the next tier up). The cost has a mild convexity (`baseRate × damage × (1 + damage/200)`), so repairing earlier is slightly cheaper per point than letting gear sit at low condition — but not punitively so. `!adventure blacksmith` previews quotes; `!adventure repair all` or `!adventure repair <slot>` commits. Visiting the blacksmith counts as your daily action.
Equipment accumulates damage on bad outcomes and breaks at 0 condition. The blacksmith repairs gear for a per-point fee that scales with tier (base rates T0→T5: €1, €2, €5, €12, €30, €80; masterwork and arena gear use the next tier up). The cost has a mild convexity (`baseRate × damage × (1 + damage/200)`), so repairing earlier is slightly cheaper per point than letting gear sit at low condition — but not punitively so. `!adventure blacksmith` previews quotes; `!adventure repair all` or `!adventure repair <slot>` commits.
#### Babysitting Service
Busy days? Hire TwinBee to run your daily action for you. Daily cost is `€100 + combatLevel × €20`. TwinBee trains your weakest gathering skill (mining/fishing/foraging) so the service doubles as catch-up for neglected tracks. Subscribe by the week or month, or check/cancel anytime: `!adventure babysit week|month|status|cancel`.
Busy days? Hire TwinBee to harvest on your behalf. Daily cost is `€100 + combatLevel × €20`. TwinBee trains your weakest gathering skill (mining/fishing/foraging) each day, so the service doubles as catch-up for neglected tracks. Subscribe by the week or month, or check/cancel anytime: `!adventure babysit week|month|status|cancel`.
#### Rival Duels

393
cmd/expedition-sim/main.go Normal file
View File

@@ -0,0 +1,393 @@
// expedition-sim drives synthetic players through expeditions for batch
// analysis. Re-uses production plugin paths against a fresh sqlite DB so
// outcomes mirror what live players hit.
//
// Single run:
//
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
// [-bank 1000] [-cap 50] [-log] [-data DIR]
//
// Party run (N3/P7 — the leader is -class; followers clone it unless named):
//
// expedition-sim -party 3 -level 15 -zone dragons_lair
// expedition-sim -party 2 -class cleric -party-classes fighter -level 16 ...
//
// Matrix mode (cartesian sweep over classes × levels × zones × N runs,
// one JSON object per stdout line, log suppressed by default):
//
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \
// -zones goblin_warrens,wolf_den -runs 20
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"gogobee/internal/plugin"
"maunium.net/go/mautrix/id"
)
func main() {
var (
class = flag.String("class", "fighter", "DnD class id (single-run mode)")
level = flag.Int("level", 5, "character level (single-run mode)")
zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)")
bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting")
cap = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)")
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
levels = flag.String("levels", "", "comma-separated levels (matrix mode)")
zones = flag.String("zones", "", "comma-separated zone ids (matrix mode)")
runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)")
trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
party = flag.Int("party", 1, "seat a party of N (1 = solo, the historical path). Followers are invited on Day 1 and buy their own supplies")
partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)")
companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.")
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
)
flag.Parse()
if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel)
}
if *party < 1 || *party > plugin.ExpeditionPartyMax {
fail("party must be 1-", plugin.ExpeditionPartyMax, ", got", *party)
}
followers := followerClasses(*class, *party, *partyClasses)
plugin.SetSimIncludeTrace(*trace)
plugin.SetSimPetLevel(*petLevel)
if *matrix {
// Matrix default: drop log to keep stdout manageable; explicit
// -log=true overrides.
includeLog := false
// Flag.Lookup tells us whether the user explicitly set -log.
flag.Visit(func(f *flag.Flag) {
if f.Name == "log" {
includeLog = *logFlag
}
})
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
return
}
if *realUser != "" {
if *dataDir == "" {
fail("-real-user requires -data pointing at a dir containing a populated gogobee.db")
}
if *party != 1 {
fail("-real-user does not support -party yet (would need every seat to be a real, tier-eligible char)")
}
runReal(*realUser, *zone, *dataDir, *bank, *cap, *days, *logFlag)
return
}
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers, *companion)
}
// runReal drives an existing character loaded from dataDir's gogobee.db through
// a real expedition. dataDir should be a COPY of prod — db.Init runs additive
// migrations against it, and the run mutates HP/coin/inventory. Never point this
// at the live prod file or at ./data.
func runReal(userTag, zone, dataDir string, bank float64, cap, days int, includeLog bool) {
runner, err := plugin.NewSimRunner(dataDir)
if err != nil {
fail("init runner:", err)
}
defer runner.Close()
uid := id.UserID(userTag)
c, err := runner.PrepareRealCharacter(uid, bank)
if err != nil {
fail("prepare real character:", err)
}
res, err := runner.RunExpedition(uid, plugin.ZoneID(zone), cap, days)
if res != nil {
res.Class = string(c.Class) // real subclass/race aren't in SimResult; class is the useful key
if !includeLog {
res.Log = nil
}
emitIndented(res)
}
if err != nil {
fail("run:", err)
}
}
// followerClasses expands -party / -party-classes into the class of each
// follower seat. An explicit list must name every seat: a party of 3 whose
// second follower silently defaulted to the leader's class would quietly
// answer a different question than the one asked.
func followerClasses(leaderClass string, party int, spec string) []string {
n := party - 1
if n == 0 {
if strings.TrimSpace(spec) != "" {
fail("-party-classes given but -party is 1")
}
return nil
}
if strings.TrimSpace(spec) == "" {
out := make([]string, n)
for i := range out {
out[i] = leaderClass
}
return out
}
out := splitNonEmpty(spec)
if len(out) != n {
fail(fmt.Sprintf("-party %d needs %d follower classes, got %d", party, n, len(out)))
}
return out
}
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string, companion string) {
dir := dataDir
if dir == "" {
var err error
dir, err = os.MkdirTemp("", "expedition-sim-")
if err != nil {
fail("mkdir temp:", err)
}
defer os.RemoveAll(dir)
}
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers, companion)
if err != nil {
if res != nil {
if !includeLog {
res.Log = nil
}
emitIndented(res)
}
fail("run:", err)
}
if !includeLog {
res.Log = nil
}
emitIndented(res)
}
// matrixJob is one (class, level, zone, replicate-index) cell of the
// matrix sweep. Each job is run by a worker as a single-run subprocess so
// it gets its own SQLite handle — the plugin package's db.* globals
// preclude in-process parallelism.
type matrixJob struct {
class string
level int
zone string
rep int
}
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) {
cs := splitNonEmpty(classes)
ls := parseLevels(levels)
zs := splitNonEmpty(zones)
if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 {
fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0")
}
if jobs <= 0 {
jobs = runtime.NumCPU()
}
exe, err := os.Executable()
if err != nil {
fail("os.Executable:", err)
}
work := make([]matrixJob, 0, len(cs)*len(ls)*len(zs)*runs)
for _, c := range cs {
for _, lv := range ls {
for _, z := range zs {
for r := 0; r < runs; r++ {
work = append(work, matrixJob{class: c, level: lv, zone: z, rep: r})
}
}
}
}
workCh := make(chan matrixJob)
resCh := make(chan *plugin.SimResult, len(work))
var wg sync.WaitGroup
for i := 0; i < jobs; i++ {
wg.Add(1)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
}
go func() {
for _, j := range work {
workCh <- j
}
close(workCh)
}()
go func() {
wg.Wait()
close(resCh)
}()
enc := json.NewEncoder(os.Stdout)
for r := range resCh {
_ = enc.Encode(r)
}
}
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string) {
defer wg.Done()
for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
dir, err := os.MkdirTemp("", "expedition-sim-")
if err != nil {
out <- &plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
continue
}
args := []string{
"-class", j.class,
"-level", strconv.Itoa(j.level),
"-zone", j.zone,
"-bank", strconv.FormatFloat(bank, 'f', -1, 64),
"-cap", strconv.Itoa(cap),
"-days", strconv.Itoa(days),
"-data", dir,
"-user", uid,
fmt.Sprintf("-log=%t", includeLog),
fmt.Sprintf("-pet-level=%d", petLevel),
fmt.Sprintf("-party=%d", party),
}
// Left empty, each cell's followers clone that cell's own -class.
if partyClasses != "" {
args = append(args, "-party-classes", partyClasses)
}
if companion != "" {
args = append(args, "-companion", companion)
}
if trace {
args = append(args, "-trace")
}
cmd := exec.Command(exe, args...)
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
stdout, runErr := cmd.Output()
var res plugin.SimResult
jerr := json.Unmarshal(stdout, &res)
if jerr != nil {
res = plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
}
if runErr != nil && res.Outcome == "" {
res.Outcome = "halted"
}
// Surface subprocess failures: parse error with non-empty stdout
// (corrupted JSON) and non-zero exits both get a stderr dump so the
// user sees the underlying cause instead of just a halted row.
if jerr != nil || runErr != nil {
fmt.Fprintf(os.Stderr, "sim cell %s halted: runErr=%v jerr=%v\n", uid, runErr, jerr)
if stderrBuf.Len() > 0 {
fmt.Fprintf(os.Stderr, " child stderr:\n%s\n", stderrBuf.String())
}
if jerr != nil && len(stdout) > 0 {
snip := stdout
if len(snip) > 200 {
snip = snip[:200]
}
fmt.Fprintf(os.Stderr, " child stdout (first 200B): %q\n", snip)
}
}
if !includeLog {
res.Log = nil
}
out <- &res
_ = os.RemoveAll(dir)
}
}
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string, companion string) (*plugin.SimResult, error) {
runner, err := plugin.NewSimRunner(dataDir)
if err != nil {
return nil, fmt.Errorf("init runner: %w", err)
}
defer runner.Close()
if _, err := runner.BuildCharacter(uid, class, level); err != nil {
return nil, fmt.Errorf("build character: %w", err)
}
runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
// Followers share the leader's level — a party is not a carry (the tier
// gate refuses an under-levelled invitee anyway) — and each buys their own
// loadout, so each needs their own bankroll.
var members []id.UserID
for i, fc := range followers {
muid := followerUserID(uid, i)
if _, err := runner.BuildCharacter(muid, plugin.DnDClass(fc), level); err != nil {
return nil, fmt.Errorf("build follower %d (%s): %w", i+1, fc, err)
}
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
members = append(members, muid)
}
runner.Companion = companion
return runner.RunPartyExpedition(uid, members, zone, cap, days)
}
// followerUserID derives a follower's Matrix id from the leader's. It must keep
// the ":" — ResolveUser treats a colon as "this is already a full mxid" and
// short-circuits the room-membership lookup the sim has no client for.
func followerUserID(leader id.UserID, i int) id.UserID {
s := string(leader)
local, server, ok := strings.Cut(strings.TrimPrefix(s, "@"), ":")
if !ok {
local, server = strings.TrimPrefix(s, "@"), "sim"
}
return id.UserID(fmt.Sprintf("@%s-m%d:%s", local, i+1, server))
}
func emitIndented(res *plugin.SimResult) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(res)
}
func splitNonEmpty(s string) []string {
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func parseLevels(s string) []int {
var out []int
for _, p := range splitNonEmpty(s) {
n, err := strconv.Atoi(p)
if err != nil {
fail("bad level:", p)
}
out = append(out, n)
}
return out
}
func fail(args ...interface{}) {
fmt.Fprintln(os.Stderr, args...)
os.Exit(1)
}

View File

@@ -0,0 +1,342 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// open5eMonster is the subset of the Open5e v1 monster schema we consume. The
// vendored JSON keeps the full payload; unknown fields are ignored here.
type open5eMonster struct {
Slug string `json:"slug"`
Name string `json:"name"`
Size string `json:"size"`
Type string `json:"type"`
CR float64 `json:"cr"`
ArmorClass int `json:"armor_class"`
HitPoints int `json:"hit_points"`
Speed map[string]any `json:"speed"`
Strength int `json:"strength"`
Dexterity int `json:"dexterity"`
Constitution int `json:"constitution"`
Intelligence int `json:"intelligence"`
Wisdom int `json:"wisdom"`
Charisma int `json:"charisma"`
Actions []open5eAction `json:"actions"`
LegendaryActions []open5eAction `json:"legendary_actions"`
SpecialAbilities []open5eNamedBlock `json:"special_abilities"`
}
// open5eAction is one entry in a monster's actions list. An entry is a weapon
// attack iff DamageDice is non-empty; Multiattack and utility actions leave it
// blank. AttackBonus/DamageBonus arrive as JSON null on non-attacks, which
// unmarshals to 0 — harmless, since DamageDice is the attack discriminator.
type open5eAction struct {
Name string `json:"name"`
Desc string `json:"desc"`
AttackBonus int `json:"attack_bonus"`
DamageDice string `json:"damage_dice"`
DamageBonus int `json:"damage_bonus"`
}
type open5eNamedBlock struct {
Name string `json:"name"`
}
type monstersPage struct {
Next string `json:"next"`
Results []open5eMonster `json:"results"`
}
// fetchMonsters pages through the SRD monster list and writes the full result
// set to data/open5e/monsters.json, pretty-printed for a reviewable diff.
func fetchMonsters() error {
client := &http.Client{Timeout: 30 * time.Second}
var all []open5eMonster
url := monstersAPIBase
for url != "" {
fmt.Fprintln(os.Stderr, "GET", url)
page, err := getMonstersPage(client, url)
if err != nil {
return err
}
all = append(all, page.Results...)
url = page.Next
}
fmt.Fprintf(os.Stderr, "fetched %d monsters\n", len(all))
if err := os.MkdirAll(filepath.Dir(monstersJSON), 0o755); err != nil {
return err
}
out, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
out = append(out, '\n')
return os.WriteFile(monstersJSON, out, 0o644)
}
func getMonstersPage(client *http.Client, url string) (*monstersPage, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var page monstersPage
if err := json.Unmarshal(body, &page); err != nil {
return nil, err
}
return &page, nil
}
// ── Staging-table classifier ─────────────────────────────────────────────────
// genStatBlock mirrors plugin.SRDStatBlock — a raw SRD stat block. These values
// are NOT engine-tuned: raw SRD damage one-shots gogobee's solo player (see
// bestiary_srd.go). The staging table is a balance baseline the hand-authored
// dndBestiary / srdProfiles tuning pass reads against, not a drop-in roster.
type genStatBlock struct {
Slug, Name string
Size, Type string
CR float64
XP int
HP, AC int
SpeedWalk int
STR, DEX int
CON, INT int
WIS, CHA int
Multiattack string
Attacks []genStatAttack
Traits []string
Legendary bool
}
type genStatAttack struct {
Name string
AttackBonus int
DamageDice string
DamageBonus int
AvgDamage int
DamageType string
}
// xpByCR is the SRD experience-by-challenge-rating table. Open5e exposes no XP
// field, but the tuning pass wants it, so it is derived here from CR.
var xpByCR = map[float64]int{
0: 10, 0.125: 25, 0.25: 50, 0.5: 100,
1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800, 6: 2300, 7: 2900, 8: 3900,
9: 5000, 10: 5900, 11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000,
16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000, 21: 33000,
22: 41000, 23: 50000, 24: 62000, 25: 75000, 26: 90000, 27: 105000,
28: 120000, 29: 135000, 30: 155000,
}
// genBestiary reads the vendored monsters.json, classifies every SRD monster
// into a raw staging stat block, and writes the generated Go data file.
func genBestiary() error {
raw, err := os.ReadFile(monstersJSON)
if err != nil {
return fmt.Errorf("read %s (run `fetch bestiary` first?): %w", monstersJSON, err)
}
var src []open5eMonster
if err := json.Unmarshal(raw, &src); err != nil {
return err
}
blocks := make([]genStatBlock, 0, len(src))
var attacks int
for _, m := range src {
b := classifyMonster(m)
attacks += len(b.Attacks)
blocks = append(blocks, b)
}
sort.Slice(blocks, func(i, j int) bool {
if blocks[i].CR != blocks[j].CR {
return blocks[i].CR < blocks[j].CR
}
return blocks[i].Slug < blocks[j].Slug
})
fmt.Fprintf(os.Stderr, "classified %d monsters (%d parsed attacks)\n", len(blocks), attacks)
out := emitBestiary(blocks)
if err := os.WriteFile(bestiaryGenGo, out, 0o644); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "wrote", bestiaryGenGo)
return nil
}
func classifyMonster(m open5eMonster) genStatBlock {
b := genStatBlock{
Slug: strings.ReplaceAll(m.Slug, "-", "_"),
Name: m.Name,
Size: strings.ToLower(strings.TrimSpace(m.Size)),
Type: strings.ToLower(strings.TrimSpace(m.Type)),
CR: m.CR,
XP: xpByCR[m.CR],
HP: m.HitPoints,
AC: m.ArmorClass,
STR: m.Strength, DEX: m.Dexterity, CON: m.Constitution,
INT: m.Intelligence, WIS: m.Wisdom, CHA: m.Charisma,
SpeedWalk: speedWalk(m.Speed),
Legendary: len(m.LegendaryActions) > 0,
}
for _, a := range m.Actions {
if strings.EqualFold(strings.TrimSpace(a.Name), "Multiattack") {
b.Multiattack = firstSentence(strings.TrimSpace(a.Desc), 200)
continue
}
if a.DamageDice == "" {
continue
}
atk := genStatAttack{
Name: a.Name,
AttackBonus: a.AttackBonus,
DamageDice: strings.ReplaceAll(a.DamageDice, " ", ""),
DamageBonus: a.DamageBonus,
}
atk.AvgDamage = int(avgDice(atk.DamageDice)) + a.DamageBonus
if mt := reDmgType.FindStringSubmatch(strings.ToLower(a.Desc)); mt != nil {
atk.DamageType = strings.ToLower(mt[1])
}
b.Attacks = append(b.Attacks, atk)
}
for _, sa := range m.SpecialAbilities {
if n := strings.TrimSpace(sa.Name); n != "" {
b.Traits = append(b.Traits, n)
}
}
return b
}
// speedWalk pulls the walking speed (in feet) out of Open5e's speed object,
// falling back to the fastest other movement mode for creatures with no walk
// speed (flyers, swimmers). Returns 0 when nothing parses.
func speedWalk(speed map[string]any) int {
asInt := func(v any) int {
if f, ok := v.(float64); ok {
return int(f)
}
return 0
}
if w := asInt(speed["walk"]); w > 0 {
return w
}
best := 0
for mode, v := range speed {
if mode == "walk" {
continue
}
if n := asInt(v); n > best {
best = n
}
}
return best
}
// avgDice returns the statistical average of an NdM dice expression. A bare or
// unparseable expression yields 0 — the staging table records what it can and
// leaves the rest for the tuning pass.
func avgDice(dice string) float64 {
i := strings.IndexByte(dice, 'd')
if i <= 0 {
return 0
}
n, err1 := strconv.Atoi(dice[:i])
m, err2 := strconv.Atoi(dice[i+1:])
if err1 != nil || err2 != nil || n <= 0 || m <= 0 {
return 0
}
return float64(n) * (float64(m) + 1) / 2
}
// ── Code emission ────────────────────────────────────────────────────────────
func emitBestiary(blocks []genStatBlock) []byte {
var b bytes.Buffer
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
//
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
// under CC-BY-4.0 — see NOTICE. Regenerate with:
//
// go run ./cmd/open5e-import fetch bestiary
// go run ./cmd/open5e-import gen bestiary
//
// This is the RAW SRD staging table — see bestiary_srd_staging.go for why these
// values are a balance baseline and not a drop-in engine roster.
package plugin
func buildSRDStagingBestiary() map[string]SRDStatBlock {
return map[string]SRDStatBlock{
`)
for _, m := range blocks {
fmt.Fprintf(&b, "\t\t%q: {Slug: %q, Name: %q", m.Slug, m.Slug, m.Name)
if m.Size != "" {
fmt.Fprintf(&b, ", Size: %q", m.Size)
}
if m.Type != "" {
fmt.Fprintf(&b, ", Type: %q", m.Type)
}
fmt.Fprintf(&b, ", CR: %s", strconv.FormatFloat(m.CR, 'g', -1, 64))
if m.XP != 0 {
fmt.Fprintf(&b, ", XP: %d", m.XP)
}
fmt.Fprintf(&b, ", HP: %d, AC: %d", m.HP, m.AC)
if m.SpeedWalk != 0 {
fmt.Fprintf(&b, ", SpeedWalk: %d", m.SpeedWalk)
}
fmt.Fprintf(&b, ", STR: %d, DEX: %d, CON: %d, INT: %d, WIS: %d, CHA: %d",
m.STR, m.DEX, m.CON, m.INT, m.WIS, m.CHA)
if m.Multiattack != "" {
fmt.Fprintf(&b, ", Multiattack: %q", m.Multiattack)
}
if len(m.Attacks) > 0 {
b.WriteString(", Attacks: []SRDStatAttack{")
for i, a := range m.Attacks {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "{Name: %q, AttackBonus: %d, DamageDice: %q, DamageBonus: %d, AvgDamage: %d",
a.Name, a.AttackBonus, a.DamageDice, a.DamageBonus, a.AvgDamage)
if a.DamageType != "" {
fmt.Fprintf(&b, ", DamageType: %q", a.DamageType)
}
b.WriteString("}")
}
b.WriteString("}")
}
if len(m.Traits) > 0 {
b.WriteString(", Traits: []string{")
for i, t := range m.Traits {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%q", t)
}
b.WriteString("}")
}
if m.Legendary {
b.WriteString(", Legendary: true")
}
b.WriteString("},\n")
}
b.WriteString("\t}\n}\n")
return b.Bytes()
}

View File

@@ -0,0 +1,152 @@
package main
import (
"regexp"
"strings"
)
// Description-cleaning hooks shared by the spell and magic-item generators.
//
// The Open5e dump descriptions are SRD prose pulled verbatim — they leak
// "within range," "WIS saving throw," "your Constitution score is 19," and
// other rules-text the game now hides. This file handles two layers:
//
// - A sanitizer (cleanDesc) strips the worst jargon clauses from the auto
// first-sentence and tidies the result. Used as the default path.
// - Per-ID override maps win outright when a description needs more than
// mechanical scrubbing (e.g. Amulet of Health's "score is 19," all of
// the Mage default-list cantrips). Hand-author here, not in the
// generated file — these survive a regen.
// spellDescOverride wins over the auto-derived first-sentence Description
// when the slug matches. Cover anything that surfaces in the player's
// spellbook listing (defaultKnownSpells) or the !spells available command.
//
// Tone target: outcome-first, second person, plain English. No DC, no slot
// jargon, no "within range." Match the voice already in dnd_spells_data.go.
var spellDescOverride = map[string]string{
// SRD-only spells that appear in defaultKnownSpells (no hand-authored
// overlay in dnd_spells_data.go). Sourced from the S3 surfaced-spells
// audit; each gives the player a one-line read of what it does, with
// just enough bite that the spellbook isn't a phone book.
"call_lightning": "Summon a sulking thundercloud, then ask it — politely — to drop a bolt on someone you dislike.",
"charm_person": "Sweet-talk a humanoid into treating you as their new favourite acquaintance. Wears off; they may notice.",
"conjure_animals": "Call up a posse of summoned beasts. They are very enthusiastic and only mostly trained.",
"eldritch_blast": "A beam of crackling otherworldly Whatever streaks at a target. Your patron's preferred greeting.",
"entangle": "The ground sprouts rude, grabby roots in a patch. Anyone caught looks undignified at best.",
"faerie_fire": "Outline targets in glittery light. They can't hide; you hit them more easily; they hate it.",
"fear": "Project a cone of pure dread. Brave folk turn and run; less brave folk were already running.",
"flaming_sphere": "A rolling ball of fire shows up and goes wherever you point. Carpet not included.",
"haste": "An ally surges with speed — extra ground, sharper defences, an extra swing. Brief, glorious, exhausting.",
"healing_word": "Bark a healing syllable across the battlefield. An ally finds themselves slightly less dead.",
"heat_metal": "Their weapon, their armour, their belt buckle — glowing red-hot. Hard to grip. Worse to wear.",
"heroism": "An ally takes courage. No fear, more grit, slightly insufferable battle cries.",
"hideous_laughter": "Target collapses in a fit of unstoppable giggling. Awkward for them, very funny for you.",
"insect_plague": "A swarm of biting locusts boils up in an area. Everyone inside has Opinions about it.",
"invisibility": "An ally fades from sight. Lasts until they attack, cast, or yell something dramatic.",
"produce_flame": "A flame springs to your palm. Use it as a torch, or sling it at someone. Multipurpose.",
"ray_of_frost": "A freezing beam catches a target and leaves them sluggish, frosted, and visibly cross.",
"vampiric_touch": "Drain life from a target. The damage you deal patches you up. Frowned upon at parties.",
"vicious_mockery": "Hurl an insult so cutting it physically hurts. Their next swing wobbles in shame.",
// Hand-authored overlay descriptions in dnd_spells_data.go are the
// canonical text and win at registry merge time, so they don't need
// entries here. If a spell ever moves out of buildSpellList(), add it
// above.
}
// magicItemDescOverride wins over the auto-derived Desc when the slug
// matches. Priority targets: items the plan called out by name (Amulet of
// Health, Belt of Giant Strength) and any item whose SRD blurb leaks rules
// math or names a spell the player can't cast.
var magicItemDescOverride = map[string]string{
"amulet_of_health": "Hums against your collarbone and quietly upgrades your constitution to 'bothersome to kill.'",
"belt_of_giant_strength_hill": "Buckle it on; suddenly you can deadlift the bartender. Hill-giant grade.",
"belt_of_giant_strength_stone": "Buckle it on; the floor creaks where you weren't standing yesterday. Stone-giant grade.",
"belt_of_giant_strength_frost": "Buckle it on; doors come off their hinges when you knock politely. Frost-giant grade.",
"belt_of_giant_strength_fire": "Buckle it on; furniture rearranges itself to be elsewhere. Fire-giant grade.",
"belt_of_giant_strength_cloud": "Buckle it on; you arm-wrestle siege engines for fun. Cloud-giant grade.",
"belt_of_giant_strength_storm": "Buckle it on; the world feels strangely flimsy. Storm-giant grade.",
"belt_of_hill_giant_strength": "Buckle it on; suddenly you can deadlift the bartender. Hill-giant grade.",
"belt_of_stone_giant_strength": "Buckle it on; the floor creaks where you weren't standing yesterday. Stone-giant grade.",
"belt_of_frost_giant_strength": "Buckle it on; doors come off their hinges when you knock politely. Frost-giant grade.",
"belt_of_fire_giant_strength": "Buckle it on; furniture rearranges itself to be elsewhere. Fire-giant grade.",
"belt_of_cloud_giant_strength": "Buckle it on; you arm-wrestle siege engines for fun. Cloud-giant grade.",
"belt_of_storm_giant_strength": "Buckle it on; the world feels strangely flimsy. Storm-giant grade.",
"belt_of_dwarvenkind": "A stout dwarven belt — beard not included, hardiness very much included.",
"boots_of_levitation": "Float upward at will. Down is a separate problem you'll figure out later.",
"boots_of_speed": "Click the heels together; the world starts moving at a polite walking pace.",
"boots_of_striding_and_springing": "Tireless stride and effortless hops. Ceilings: now optional.",
"boots_of_the_winterlands": "Snow doesn't slow you, cold doesn't bite, and your tracks tactfully erase themselves.",
"bracers_of_archery": "Steady your bow arm — your arrows hit harder and find seams in armour they shouldn't.",
"bracers_of_defense": "Light bracers that quietly turn aside blows. No armour required, no questions asked.",
"brooch_of_shielding": "Pinned to your cloak. Eats incoming bolts of force like they're hors d'oeuvres.",
"cloak_of_displacement": "Bends light around you. Attackers keep swinging at where you obviously are, and missing.",
"cloak_of_elvenkind": "Pulls shadows around you whether you asked or not. Watchful eyes slide right off.",
"cloak_of_protection": "A perfectly ordinary travelling cloak that perfectly extraordinarily refuses to let you get hit.",
"cloak_of_resistance": "Wards off one kind of harm — fire, frost, lightning, poison, or some other unpleasant Tuesday.",
"cloak_of_the_bat": "By night you glide instead of fall; on a really good night, you fly. Daytime: just a cloak.",
"cloak_of_the_manta_ray": "Underwater, you swim like a manta and breathe like you're on land. On land, you look damp.",
"gauntlets_of_ogre_power": "Heavy gauntlets that lend you an ogre's strength and an ogre's complete disregard for door frames.",
"headband_of_intellect": "Slips on, makes you think clever thoughts you have no business thinking. People notice.",
"ring_of_protection": "A plain ring. Quietly turns aside blows you'd otherwise take. Refuses to explain how.",
"ring_of_regeneration": "Wounds knit themselves back together while you wear it. Slowly. Smugly.",
"ring_of_resistance": "Wards off one kind of harm — fire, frost, lightning, poison, or another flavour of bad day.",
"ring_of_swimming": "Slip through water faster than any swimmer should. Dolphins file complaints.",
"ring_of_warmth": "Cold weather no longer applies to you. Chill damage softens; you become unbearable in winter.",
"slippers_of_spider_climbing": "Walk up walls and across ceilings as if the floor were merely a suggestion.",
}
// cleanDesc strips the worst SRD jargon from a description before it lands in
// the generated file. It is intentionally conservative: drop offending phrases,
// collapse the resulting whitespace, leave the rest of the sentence alone. Any
// description that still reads poorly after this pass should get a hand-authored
// entry in spellDescOverride / magicItemDescOverride above.
func cleanDesc(s string) string {
if s == "" {
return s
}
for _, pat := range descScrub {
s = pat.ReplaceAllString(s, "")
}
// Collapse runs of whitespace and tidy stray punctuation orphans left
// behind by deletions ("a creature and ", " , ", " .").
s = reSpaces.ReplaceAllString(s, " ")
s = strings.ReplaceAll(s, " ,", ",")
s = strings.ReplaceAll(s, " .", ".")
s = strings.ReplaceAll(s, "( )", "")
// Repair sentence stubs left by clause-strippers — "...must make." after
// the saving-throw object is gone, or trailing " and." where one half of
// a coordinated noun phrase was removed. These are conservative: they
// only fire when the orphan sits at end-of-sentence.
s = reOrphanMustMake.ReplaceAllString(s, "$1")
s = reOrphanTrailingAnd.ReplaceAllString(s, ".")
s = strings.TrimSpace(s)
return s
}
var (
reSpaces = regexp.MustCompile(`\s+`)
reOrphanMustMake = regexp.MustCompile(`(?i)\s+must\s+(?:make|succeed on|attempt)([.,;])`)
reOrphanTrailingAnd = regexp.MustCompile(`(?i),?\s+and\.`)
descScrub = []*regexp.Regexp{
// Range / distance jargon.
regexp.MustCompile(`(?i)\s*\bwithin range\b`),
regexp.MustCompile(`(?i)\s*\bwithin \d+ feet(?: of [a-z ]+?)?`),
regexp.MustCompile(`(?i)\s+\d+-foot[- ](?:radius|cone|cube|line|sphere|square)`),
regexp.MustCompile(`(?i)\s+(?:out to a range of\s*)?\d+ feet\b`),
regexp.MustCompile(`(?i)\s+out to a range of\b`),
// Save / DC / slot jargon.
regexp.MustCompile(`(?i)\s*\b(?:Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)\s+saving throws?\b`),
regexp.MustCompile(`(?i)\s*\bsaving throws?\b`),
regexp.MustCompile(`(?i)\s*\(save(?:\s+DC\s*\d+)?\)`),
regexp.MustCompile(`(?i)\s*\bDC\s*\d+\b`),
regexp.MustCompile(`(?i)\s*\b(?:using|cast(?:ing)?(?: it)? with) a spell slot of \d+(?:st|nd|rd|th) level or higher\b`),
regexp.MustCompile(`(?i)\s*\bspell slot\b`),
// Cross-references and stat-block leakage.
regexp.MustCompile(`(?i)\s*\bsee the spell\b`),
regexp.MustCompile(`(?i)\s*\b(?:wisdom|strength|dexterity|constitution|intelligence|charisma)\s+save\b`),
regexp.MustCompile(`(?i)\s*\b(?:wisdom|strength|dexterity|constitution|intelligence|charisma)\s+score\s+is\s+\d+\b`),
regexp.MustCompile(`(?i)\s*\bconstitution score\b`),
}
)

View File

@@ -0,0 +1,122 @@
package main
import (
"strings"
"testing"
)
// TestCleanDescStripsJargon — the S3 acceptance criteria translated into
// regression cases. Any phrase listed here must not survive the sanitizer.
func TestCleanDescStripsJargon(t *testing.T) {
cases := []struct {
name string
in string
mustNot []string
}{
{
name: "saving throw + DC",
in: "Each creature in the area must make a Dexterity saving throw (DC 15) or take damage.",
mustNot: []string{
"saving throw", "DC 15", "DC15",
},
},
{
name: "saving throws plural",
in: "You have advantage on saving throws against spells.",
mustNot: []string{"saving throws"},
},
{
name: "within range",
in: "Hurl a bolt at a creature within range.",
mustNot: []string{"within range"},
},
{
name: "5-foot radius",
in: "Each creature in a 5-foot radius takes damage.",
mustNot: []string{"5-foot"},
},
{
name: "spell slot upcast clause",
in: "When you cast this spell using a spell slot of 2nd level or higher, the damage increases.",
mustNot: []string{"spell slot"},
},
{
name: "constitution score",
in: "Your Constitution score is 19 while you wear this amulet.",
mustNot: []string{"Constitution score"},
},
{
name: "(save DC X) parenthetical",
in: "You can use an action to cast the detect thoughts spell (save DC 13) from it.",
mustNot: []string{"(save", "DC 13"},
},
{
name: "wisdom save",
in: "Forces a wisdom save against the caster.",
mustNot: []string{"wisdom save"},
},
{
name: "out to a range of feet",
in: "You have darkvision out to a range of 60 feet.",
mustNot: []string{"feet", "out to a range"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cleanDesc(tc.in)
low := strings.ToLower(got)
for _, ban := range tc.mustNot {
if strings.Contains(low, strings.ToLower(ban)) {
t.Errorf("cleanDesc kept banned phrase %q\n in: %q\n out: %q", ban, tc.in, got)
}
}
})
}
}
// TestCleanDescRepairsOrphans — the strippers leave dangling stubs ("must
// make.", " and."); the post-pass is meant to tidy them so player-facing
// prose doesn't end mid-clause.
func TestCleanDescRepairsOrphans(t *testing.T) {
in := "You gain a +1 bonus to ability checks and saving throws."
got := cleanDesc(in)
if strings.HasSuffix(got, " and.") {
t.Errorf("orphan trailing 'and.' survived: %q", got)
}
in2 := "Up to three creatures of your choice that you can see must make charisma saving throws."
got2 := cleanDesc(in2)
if strings.Contains(strings.ToLower(got2), "must make.") {
t.Errorf("orphan 'must make.' survived: %q", got2)
}
}
// TestStripNameParenthetical — bestiary R21 / magic-item alias names.
func TestStripNameParenthetical(t *testing.T) {
cases := map[string]string{
"Giant Rat (Diseased)": "Giant Rat",
"Deep Gnome (Svirfneblin)": "Deep Gnome",
"Stone of Good Luck (Luckstone)": "Stone of Good Luck",
"Ordinary Name": "Ordinary Name",
"": "",
}
for in, want := range cases {
if got := stripNameParenthetical(in); got != want {
t.Errorf("stripNameParenthetical(%q) = %q, want %q", in, got, want)
}
}
}
// TestSpellOverrideWins — the curated descriptions for the SRD-only default
// spells must beat the auto first-sentence path.
func TestSpellOverrideWins(t *testing.T) {
got := spellDescription("vicious_mockery", "anything could go here")
if got != spellDescOverride["vicious_mockery"] {
t.Errorf("override didn't win: got %q", got)
}
// Unknown ID falls through to cleanDesc.
in := "Hurl a bolt at a creature within range."
got = spellDescription("unknown_spell_xyz", in)
if strings.Contains(got, "within range") {
t.Errorf("fallthrough didn't sanitize: %q", got)
}
}

339
cmd/open5e-import/gen.go Normal file
View File

@@ -0,0 +1,339 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
)
// classEnum maps an Open5e spell_lists entry to the plugin's DnDClass enum
// identifier. Open5e's "wizard" is the plugin's Mage; classes the plugin
// doesn't model (artificer, etc.) are absent and silently dropped.
//
// classOrder fixes the iteration order so generated Classes slices are stable.
var (
classEnum = map[string]string{
"wizard": "ClassMage",
"cleric": "ClassCleric",
"ranger": "ClassRanger",
"druid": "ClassDruid",
"bard": "ClassBard",
"sorcerer": "ClassSorcerer",
"warlock": "ClassWarlock",
"paladin": "ClassPaladin",
}
classOrder = []string{"wizard", "cleric", "ranger", "druid", "bard", "sorcerer", "warlock", "paladin"}
)
// mapClasses turns an Open5e spell's class tags into plugin enum identifiers,
// deduped and in classOrder. It unions the structured spell_lists array with
// the free-text dnd_class field ("Druid, Wizard") — the SRD dump's spell_lists
// omits paladin entirely, so dnd_class is the more complete source. Returns
// nil when the spell belongs to no modelled class (the caller drops those —
// they have no caster to learn them).
func mapClasses(lists []string, dndClass string) []string {
seen := map[string]bool{}
for _, l := range lists {
seen[strings.ToLower(strings.TrimSpace(l))] = true
}
for _, l := range strings.Split(dndClass, ",") {
seen[strings.ToLower(strings.TrimSpace(l))] = true
}
var out []string
for _, c := range classOrder {
if seen[c] {
out = append(out, classEnum[c])
}
}
return out
}
// genSpell is a fully classified spell ready for code emission. Fields mirror
// plugin.SpellDefinition; enum-typed fields hold the Go identifier as a string.
type genSpell struct {
ID string
Name string
Level int
School string
Classes []string
Effect string
CastTime string
Concentration bool
SaveStat string
AttackRoll bool
DamageDice string
DamageType string
Description string
Upcast string
MaterialCost int
AOE bool
}
// genSpells reads the vendored spells.json, classifies every in-scope spell,
// and writes the generated Go data file.
func genSpells() error {
raw, err := os.ReadFile(spellsJSON)
if err != nil {
return fmt.Errorf("read %s (run `fetch spells` first?): %w", spellsJSON, err)
}
var src []open5eSpell
if err := json.Unmarshal(raw, &src); err != nil {
return err
}
var spells []genSpell
var skippedLevel, skippedNoClass int
for _, s := range src {
if s.LevelInt > 5 {
// Combat caps at L5 spell slots — nothing can ever cast these.
skippedLevel++
continue
}
classes := mapClasses(s.SpellLists, s.DndClass)
if len(classes) == 0 {
skippedNoClass++
continue
}
spells = append(spells, classify(s, classes))
}
sort.Slice(spells, func(i, j int) bool {
if spells[i].Level != spells[j].Level {
return spells[i].Level < spells[j].Level
}
return spells[i].ID < spells[j].ID
})
fmt.Fprintf(os.Stderr, "classified %d spells (skipped %d level>5, %d no modelled class)\n",
len(spells), skippedLevel, skippedNoClass)
out := emit(spells)
if err := os.WriteFile(spellsGenGo, out, 0o644); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "wrote", spellsGenGo)
return nil
}
// ── Classifier ───────────────────────────────────────────────────────────────
var (
reSave = regexp.MustCompile(`(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throw`)
reDice = regexp.MustCompile(`\d+d\d+(?:\s*\+\s*\d+)?`)
reDmgType = regexp.MustCompile(`(?i)(acid|bludgeoning|cold|fire|force|lightning|necrotic|piercing|poison|psychic|radiant|slashing|thunder)\s+damage`)
reMaterial = regexp.MustCompile(`(?i)worth (?:at least )?([\d,]+)\s*gp`)
saveStatMap = map[string]string{
"Strength": "STR", "Dexterity": "DEX", "Constitution": "CON",
"Intelligence": "INT", "Wisdom": "WIS", "Charisma": "CHA",
}
)
func classify(s open5eSpell, classes []string) genSpell {
desc := strings.TrimSpace(s.Desc)
low := strings.ToLower(desc)
g := genSpell{
ID: strings.ReplaceAll(s.Slug, "-", "_"),
Name: s.Name,
Level: s.LevelInt,
School: strings.ToLower(strings.TrimSpace(s.School)),
Classes: classes,
Concentration: s.RequiresConcentration,
Description: spellDescription(strings.ReplaceAll(s.Slug, "-", "_"), desc),
Upcast: firstSentence(strings.TrimSpace(s.HigherLevel), 200),
AOE: looksAOE(low),
}
// Cast time. Reaction/bonus action win over the ritual flag.
ct := strings.ToLower(s.CastingTime)
switch {
case strings.Contains(ct, "bonus action"):
g.CastTime = "CastBonusAction"
case strings.Contains(ct, "reaction"):
g.CastTime = "CastReaction"
case s.CanBeCastAsRitual:
g.CastTime = "CastRitual"
default:
g.CastTime = "CastAction"
}
if m := reSave.FindStringSubmatch(desc); m != nil {
g.SaveStat = saveStatMap[m[1]]
}
g.AttackRoll = strings.Contains(low, "spell attack")
if m := reDice.FindString(desc); m != "" {
g.DamageDice = strings.ReplaceAll(m, " ", "")
}
if m := reDmgType.FindStringSubmatch(low); m != nil {
g.DamageType = strings.ToLower(m[1])
}
if m := reMaterial.FindStringSubmatch(s.Material); m != nil {
g.MaterialCost, _ = strconv.Atoi(strings.ReplaceAll(m[1], ",", ""))
}
g.Effect = classifyEffect(g, low)
// A heal spell's dice describe healing, not damage — keep DamageDice (the
// hand-authored list does the same) but drop a stray damage type.
if g.Effect == "EffectSpellHeal" {
g.DamageType = ""
}
// Non-damaging effects shouldn't carry a damage type/dice picked up from
// incidental wording.
if g.Effect == "EffectControl" || g.Effect == "EffectUtility" ||
g.Effect == "EffectReaction" {
g.DamageDice, g.DamageType = "", ""
}
return g
}
// classifyEffect picks the SpellEffectKind. Heuristic and imperfect by design —
// the hand-authored buildSpellList() overlay is the refinement path, so this
// only needs to be a reasonable default.
func classifyEffect(g genSpell, low string) string {
if g.CastTime == "CastReaction" {
return "EffectReaction"
}
isHeal := strings.Contains(low, "regain") && strings.Contains(low, "hit point") &&
!strings.Contains(low, "damage")
if isHeal {
return "EffectSpellHeal"
}
if g.DamageDice != "" && g.DamageType != "" {
switch {
case g.AttackRoll:
return "EffectDamageAttack"
case g.SaveStat != "":
return "EffectDamageSave"
default:
return "EffectDamageAuto"
}
}
if g.SaveStat != "" {
return "EffectControl"
}
// Light buff detection — anything still unclassified falls through to
// utility, which is the safe bucket.
if strings.Contains(low, "bonus to") || strings.Contains(low, "you gain") ||
strings.Contains(low, "gains a bonus") || strings.Contains(low, "your ac") {
if strings.Contains(low, "you gain") || strings.Contains(low, "yourself") ||
strings.Contains(low, "your ac") {
return "EffectBuffSelf"
}
return "EffectBuffAlly"
}
return "EffectUtility"
}
func looksAOE(low string) bool {
for _, kw := range []string{
"each creature", "all creatures", "-foot radius", "-foot-radius",
"foot cone", "foot cube", "foot line", "foot-radius sphere",
} {
if strings.Contains(low, kw) {
return true
}
}
return false
}
// spellDescription returns the description text emitted for a spell. A
// hand-authored override in spellDescOverride wins outright; otherwise the
// SRD first-sentence is run through cleanDesc to strip jargon clauses.
func spellDescription(id, raw string) string {
if s, ok := spellDescOverride[id]; ok {
return s
}
return firstSentence(cleanDesc(raw), 200)
}
// firstSentence trims free-text fields down to a single sentence, capped at
// max runes, so generated Description/Upcast stay terminal-friendly.
func firstSentence(s string, max int) string {
if s == "" {
return ""
}
if i := strings.IndexAny(s, "\n"); i >= 0 {
s = s[:i]
}
if i := strings.Index(s, ". "); i >= 0 {
s = s[:i+1]
}
s = strings.TrimSpace(s)
if len(s) > max {
s = strings.TrimSpace(s[:max]) + "…"
}
return s
}
// ── Code emission ────────────────────────────────────────────────────────────
func emit(spells []genSpell) []byte {
var b bytes.Buffer
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
//
// Source: Open5e SRD spell dump (data/open5e/spells.json), 5e SRD content
// under CC-BY-4.0 — see NOTICE. Regenerate with:
//
// go run ./cmd/open5e-import fetch spells
// go run ./cmd/open5e-import gen spells
//
// Effect/CastTime/SaveStat and friends come from a free-text classifier; the
// hand-authored buildSpellList() overlays this slice and wins on ID collision,
// so any misclassification here is correctable there rather than in the dump.
package plugin
func buildSRDSpellList() []SpellDefinition {
return []SpellDefinition{
`)
for _, s := range spells {
b.WriteString("\t\t{")
fmt.Fprintf(&b, "ID: %q, Name: %q, Level: %d", s.ID, s.Name, s.Level)
if s.School != "" {
fmt.Fprintf(&b, ", School: %q", s.School)
}
b.WriteString(", Classes: []DnDClass{")
for i, c := range s.Classes {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(c)
}
b.WriteString("}")
fmt.Fprintf(&b, ", Effect: %s, CastTime: %s", s.Effect, s.CastTime)
if s.Concentration {
b.WriteString(", Concentration: true")
}
if s.SaveStat != "" {
fmt.Fprintf(&b, ", SaveStat: %q", s.SaveStat)
}
if s.AttackRoll {
b.WriteString(", AttackRoll: true")
}
if s.DamageDice != "" {
fmt.Fprintf(&b, ", DamageDice: %q", s.DamageDice)
}
if s.DamageType != "" {
fmt.Fprintf(&b, ", DamageType: %q", s.DamageType)
}
if s.Description != "" {
fmt.Fprintf(&b, ", Description: %q", s.Description)
}
if s.Upcast != "" {
fmt.Fprintf(&b, ", Upcast: %q", s.Upcast)
}
if s.MaterialCost != 0 {
fmt.Fprintf(&b, ", MaterialCost: %d", s.MaterialCost)
}
if s.AOE {
b.WriteString(", AOE: true")
}
b.WriteString("},\n")
}
b.WriteString("\t}\n}\n")
return b.Bytes()
}

View File

@@ -0,0 +1,313 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// open5eMagicItem is the subset of the Open5e v1 magicitems schema we consume.
// The vendored JSON keeps the full payload; unknown fields are ignored here.
type open5eMagicItem struct {
Slug string `json:"slug"`
Name string `json:"name"`
Type string `json:"type"`
Desc string `json:"desc"`
Rarity string `json:"rarity"`
RequiresAttunement string `json:"requires_attunement"`
}
type magicItemsPage struct {
Next string `json:"next"`
Results []open5eMagicItem `json:"results"`
}
// fetchMagicItems pages through the SRD magic-item list and writes the full
// result set to data/open5e/magicitems.json, pretty-printed for a reviewable
// diff.
func fetchMagicItems() error {
client := &http.Client{Timeout: 30 * time.Second}
var all []open5eMagicItem
url := magicItemsAPIBase
for url != "" {
fmt.Fprintln(os.Stderr, "GET", url)
page, err := getMagicItemsPage(client, url)
if err != nil {
return err
}
all = append(all, page.Results...)
url = page.Next
}
fmt.Fprintf(os.Stderr, "fetched %d magic items\n", len(all))
if err := os.MkdirAll(filepath.Dir(magicItemsJSON), 0o755); err != nil {
return err
}
out, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
out = append(out, '\n')
return os.WriteFile(magicItemsJSON, out, 0o644)
}
func getMagicItemsPage(client *http.Client, url string) (*magicItemsPage, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var page magicItemsPage
if err := json.Unmarshal(body, &page); err != nil {
return nil, err
}
return &page, nil
}
// ── Classifier ───────────────────────────────────────────────────────────────
// genMagicItem is a fully classified magic item ready for code emission. Fields
// mirror plugin.MagicItem; enum-typed fields hold the Go identifier as a string.
type genMagicItem struct {
ID string
Name string
Kind string // plugin.MagicItemKind identifier
Rarity string // plugin.DnDRarity identifier
Attunement bool
Slot string // plugin.DnDSlot identifier; "" = not equippable / ambiguous
Value int
Desc string
}
// rarityEnum maps an Open5e rarity string to the plugin's DnDRarity identifier.
// "artifact" folds into Legendary (the plugin has no artifact tier); "varies"
// and unknown/blank values fall back to Common — the conservative bucket.
var rarityEnum = map[string]string{
"common": "RarityCommon",
"uncommon": "RarityUncommon",
"rare": "RarityRare",
"very rare": "RarityVeryRare",
"legendary": "RarityLegendary",
"artifact": "RarityLegendary",
}
// rarityValue is the sell-value midpoint per rarity, aligned with the §8.1
// rarity brackets the zone-loot tables already use. Keyed by the Open5e rarity
// string so the classifier can read it directly.
var rarityValue = map[string]int{
"common": 50,
"uncommon": 150,
"rare": 750,
"very rare": 2500,
"legendary": 8000,
"artifact": 8000,
}
// kindByPrefix maps the leading word of Open5e's free-text Type field
// ("Wondrous item", "Weapon (any sword)", "Armor (medium or heavy)") to a
// plugin.MagicItemKind identifier.
var kindByPrefix = map[string]string{
"wondrous": "MagicItemWondrous",
"weapon": "MagicItemWeapon",
"armor": "MagicItemArmor",
"ring": "MagicItemRing",
"wand": "MagicItemWand",
"rod": "MagicItemRod",
"staff": "MagicItemStaff",
"potion": "MagicItemPotion",
"scroll": "MagicItemScroll",
}
// genMagicItems reads the vendored magicitems.json, classifies every SRD magic
// item, and writes the generated Go data file.
func genMagicItems() error {
raw, err := os.ReadFile(magicItemsJSON)
if err != nil {
return fmt.Errorf("read %s (run `fetch magicitems` first?): %w", magicItemsJSON, err)
}
var src []open5eMagicItem
if err := json.Unmarshal(raw, &src); err != nil {
return err
}
items := make([]genMagicItem, 0, len(src))
var attunement, slotted int
for _, m := range src {
g := classifyMagicItem(m)
if g.Attunement {
attunement++
}
if g.Slot != "" {
slotted++
}
items = append(items, g)
}
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
fmt.Fprintf(os.Stderr, "classified %d magic items (%d need attunement, %d slotted)\n",
len(items), attunement, slotted)
out := emitMagicItems(items)
if err := os.WriteFile(magicItemsGenGo, out, 0o644); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "wrote", magicItemsGenGo)
return nil
}
func classifyMagicItem(m open5eMagicItem) genMagicItem {
g := genMagicItem{
ID: strings.ReplaceAll(m.Slug, "-", "_"),
Name: stripNameParenthetical(m.Name),
Attunement: strings.TrimSpace(m.RequiresAttunement) != "",
Desc: magicItemDescription(strings.ReplaceAll(m.Slug, "-", "_"), strings.TrimSpace(m.Desc)),
}
rarity := strings.ToLower(strings.TrimSpace(m.Rarity))
if enum, ok := rarityEnum[rarity]; ok {
g.Rarity = enum
} else {
g.Rarity = "RarityCommon"
}
if v, ok := rarityValue[rarity]; ok {
g.Value = v
} else {
g.Value = rarityValue["common"]
}
// Kind: first word of the Type field. Anything unrecognised (the SRD has a
// handful of bare or oddly-prefixed entries) falls back to Wondrous.
kindWord := ""
if fields := strings.Fields(strings.ToLower(m.Type)); len(fields) > 0 {
kindWord = fields[0]
}
if enum, ok := kindByPrefix[kindWord]; ok {
g.Kind = enum
} else {
g.Kind = "MagicItemWondrous"
}
g.Slot = inferSlot(g.Kind, m.Name)
return g
}
// inferSlot makes a best-effort guess at the equip slot. Weapons/staves go to
// the main hand, wands/rods to the off hand, armor to the chest, rings to a
// ring slot. Wondrous items are name-sniffed (cloak, boots, amulet, …) and
// left blank when nothing matches. Potions and scrolls are consumables and
// never carry a slot.
func inferSlot(kind, name string) string {
switch kind {
case "MagicItemWeapon", "MagicItemStaff":
return "DnDSlotMainHand"
case "MagicItemWand", "MagicItemRod":
return "DnDSlotOffHand"
case "MagicItemArmor":
return "DnDSlotChest"
case "MagicItemRing":
return "DnDSlotRing1"
case "MagicItemPotion", "MagicItemScroll":
return ""
}
// Wondrous item: sniff the name for a wearable noun.
//
// Match on word *prefix*, not raw substring — naive Contains() lights up
// "ring" inside "Springing" / "Snaring" / "Devouring" and dumps boots,
// gloves, and bags into the ring slot.
low := strings.ToLower(name)
tokens := strings.FieldsFunc(low, func(r rune) bool {
return !(r >= 'a' && r <= 'z')
})
for _, m := range []struct {
kw string
slot string
}{
{"amulet", "DnDSlotAmulet"}, {"necklace", "DnDSlotAmulet"},
{"medallion", "DnDSlotAmulet"}, {"periapt", "DnDSlotAmulet"},
{"brooch", "DnDSlotAmulet"}, {"pendant", "DnDSlotAmulet"},
{"talisman", "DnDSlotAmulet"}, {"scarab", "DnDSlotAmulet"},
{"ring", "DnDSlotRing1"},
// Cloaks/capes/mantles get their own slot so they don't evict body
// armor. Robes/vestments are body armor and stay on Chest.
{"cloak", "DnDSlotCloak"}, {"cape", "DnDSlotCloak"},
{"mantle", "DnDSlotCloak"}, {"wings", "DnDSlotCloak"},
{"robe", "DnDSlotChest"}, {"vestments", "DnDSlotChest"},
{"armor", "DnDSlotChest"},
{"boots", "DnDSlotFeet"}, {"slippers", "DnDSlotFeet"},
{"gauntlet", "DnDSlotHands"}, {"gauntlets", "DnDSlotHands"},
{"gloves", "DnDSlotHands"}, {"bracers", "DnDSlotHands"},
{"helm", "DnDSlotHead"}, {"hat", "DnDSlotHead"},
{"crown", "DnDSlotHead"}, {"circlet", "DnDSlotHead"},
{"goggles", "DnDSlotHead"}, {"eyes", "DnDSlotHead"},
{"headband", "DnDSlotHead"},
} {
for _, t := range tokens {
if strings.HasPrefix(t, m.kw) {
return m.slot
}
}
}
return ""
}
// magicItemDescription returns the Desc text emitted for an item. Override
// wins; otherwise the SRD first-sentence runs through cleanDesc.
func magicItemDescription(id, raw string) string {
if s, ok := magicItemDescOverride[id]; ok {
return s
}
return firstSentence(cleanDesc(raw), 200)
}
// ── Code emission ────────────────────────────────────────────────────────────
func emitMagicItems(items []genMagicItem) []byte {
var b bytes.Buffer
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
//
// Source: Open5e SRD magic-item dump (data/open5e/magicitems.json), 5e SRD
// content under CC-BY-4.0 — see NOTICE. Regenerate with:
//
// go run ./cmd/open5e-import fetch magicitems
// go run ./cmd/open5e-import gen magicitems
//
// Kind/Rarity/Slot/Value come from a free-text classifier; the hand-authored
// magicItemOverlay overlays this slice and wins on ID collision, so any
// misclassification here is correctable there rather than in the dump.
package plugin
func buildSRDMagicItems() []MagicItem {
return []MagicItem{
`)
for _, m := range items {
b.WriteString("\t\t{")
fmt.Fprintf(&b, "ID: %q, Name: %q, Kind: %s, Rarity: %s", m.ID, m.Name, m.Kind, m.Rarity)
if m.Attunement {
b.WriteString(", Attunement: true")
}
if m.Slot != "" {
fmt.Fprintf(&b, ", Slot: %s", m.Slot)
}
fmt.Fprintf(&b, ", Value: %d", m.Value)
if m.Desc != "" {
fmt.Fprintf(&b, ", Desc: %q", m.Desc)
}
b.WriteString("},\n")
}
b.WriteString("\t}\n}\n")
return b.Bytes()
}

162
cmd/open5e-import/main.go Normal file
View File

@@ -0,0 +1,162 @@
// Command open5e-import vendors Open5e SRD data (CC-BY-4.0) into the repo and
// regenerates the Go data files the plugin compiles against.
//
// It is a pull-once / use-many tool — the generated files are committed, so
// the running bot has no runtime dependency on the Open5e API.
//
// open5e-import fetch spells # vendor data/open5e/spells.json from the API
// open5e-import gen spells # classify → internal/plugin/dnd_spells_srd_data.go
// open5e-import fetch bestiary # vendor data/open5e/monsters.json from the API
// open5e-import gen bestiary # classify → internal/plugin/bestiary_srd_data.go
// open5e-import gen tuned # tuning formula → internal/plugin/bestiary_tuned_data.go
// open5e-import fetch magicitems # vendor data/open5e/magicitems.json from the API
// open5e-import gen magicitems # classify → internal/plugin/magic_items_srd_data.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
const (
spellsAPIBase = "https://api.open5e.com/v1/spells/?document__slug=wotc-srd&limit=50"
spellsJSON = "data/open5e/spells.json"
spellsGenGo = "internal/plugin/dnd_spells_srd_data.go"
monstersAPIBase = "https://api.open5e.com/v1/monsters/?document__slug=wotc-srd&limit=50"
monstersJSON = "data/open5e/monsters.json"
bestiaryGenGo = "internal/plugin/bestiary_srd_data.go"
tunedGenGo = "internal/plugin/bestiary_tuned_data.go"
magicItemsAPIBase = "https://api.open5e.com/v1/magicitems/?document__slug=wotc-srd&limit=50"
magicItemsJSON = "data/open5e/magicitems.json"
magicItemsGenGo = "internal/plugin/magic_items_srd_data.go"
)
func main() {
if len(os.Args) < 3 {
usage()
}
verb, noun := os.Args[1], os.Args[2]
switch verb {
case "fetch":
switch noun {
case "spells":
must(fetchSpells())
case "bestiary":
must(fetchMonsters())
case "magicitems":
must(fetchMagicItems())
default:
usage()
}
case "gen":
switch noun {
case "spells":
must(genSpells())
case "bestiary":
must(genBestiary())
case "tuned":
must(genTuned())
case "magicitems":
must(genMagicItems())
default:
usage()
}
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: open5e-import fetch (spells|bestiary|magicitems) | gen (spells|bestiary|tuned|magicitems)")
os.Exit(2)
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
// open5eSpell is the subset of the Open5e v1 spell schema we consume. Unknown
// fields are ignored; the vendored JSON keeps the full payload.
type open5eSpell struct {
Slug string `json:"slug"`
Name string `json:"name"`
Desc string `json:"desc"`
HigherLevel string `json:"higher_level"`
Range string `json:"range"`
Material string `json:"material"`
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
Duration string `json:"duration"`
RequiresConcentration bool `json:"requires_concentration"`
CastingTime string `json:"casting_time"`
LevelInt int `json:"level_int"`
School string `json:"school"`
// SpellLists is the structured class list, but the SRD dump leaves it
// incomplete (no paladin entries at all). DndClass is the free-text
// "Druid, Wizard" field and is the more complete source — mapClasses
// unions both. Keep both fields vendored.
SpellLists []string `json:"spell_lists"`
DndClass string `json:"dnd_class"`
}
type spellsPage struct {
Next string `json:"next"`
Results []open5eSpell `json:"results"`
}
// fetchSpells pages through the SRD spell list and writes the full result set
// to data/open5e/spells.json, pretty-printed for a reviewable diff.
func fetchSpells() error {
client := &http.Client{Timeout: 30 * time.Second}
var all []open5eSpell
url := spellsAPIBase
for url != "" {
fmt.Fprintln(os.Stderr, "GET", url)
page, err := getSpellsPage(client, url)
if err != nil {
return err
}
all = append(all, page.Results...)
url = page.Next
}
fmt.Fprintf(os.Stderr, "fetched %d spells\n", len(all))
if err := os.MkdirAll(filepath.Dir(spellsJSON), 0o755); err != nil {
return err
}
out, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
out = append(out, '\n')
return os.WriteFile(spellsJSON, out, 0o644)
}
func getSpellsPage(client *http.Client, url string) (*spellsPage, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var page spellsPage
if err := json.Unmarshal(body, &page); err != nil {
return nil, err
}
return &page, nil
}

374
cmd/open5e-import/tuned.go Normal file
View File

@@ -0,0 +1,374 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
)
// genTuned derives an engine-ready DnDMonsterTemplate for every SRD monster and
// writes the generated Go data file. It is the codified "tuning pass": the raw
// SRD staging table one-shots gogobee's solo player, so this applies a
// deterministic formula to scale each stat block down to the engine's
// gameplay-tuned shape.
//
// Formula (reverse-engineered from the hand-tuned dndBestiary, 2026-05 passes):
//
// - HP, AC, AttackBonus — verbatim SRD (AC clamped to the engine's min 10).
// - Attack — the auto-resolve damage stat, driven by CR alone
// via attackByCR (raw SRD per-hit damage is deliberately ignored — CR is
// the calibration axis the 2026-05-10 rebalance used).
// - Speed, BlockRate — coarse baselines from SpeedWalk / AC; a later hand
// pass refines them per creature.
// - XP, CR — verbatim from the staging table.
// - Ability — classified from the SRD trait names by
// abilityFromTraits: the highest-priority recognised trait maps onto one of
// the engine's MonsterAbility effects. Creatures whose traits are all
// non-combat (Keen Smell, Amphibious, …) stay nil. Notes still records the
// raw SRD multiattack/trait text so a hand pass can refine the pick.
//
// Hand-authored dndBestiary entries WIN: the generated map is merged in only
// for IDs the roster doesn't already define (see bestiary_tuned.go).
func genTuned() error {
raw, err := os.ReadFile(monstersJSON)
if err != nil {
return fmt.Errorf("read %s (run `fetch bestiary` first?): %w", monstersJSON, err)
}
var src []open5eMonster
if err := json.Unmarshal(raw, &src); err != nil {
return err
}
tuned := make([]genTunedMonster, 0, len(src))
for _, m := range src {
tuned = append(tuned, tuneMonster(classifyMonster(m)))
}
sort.Slice(tuned, func(i, j int) bool {
if tuned[i].CR != tuned[j].CR {
return tuned[i].CR < tuned[j].CR
}
return tuned[i].ID < tuned[j].ID
})
fmt.Fprintf(os.Stderr, "tuned %d monsters\n", len(tuned))
out := emitTuned(tuned)
if err := os.WriteFile(tunedGenGo, out, 0o644); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "wrote", tunedGenGo)
return nil
}
// genTunedMonster mirrors plugin.DnDMonsterTemplate. Ability is nil when none
// of the creature's SRD traits map onto an engine effect.
type genTunedMonster struct {
ID, Name string
CR float64
HP, AC int
Attack int
AttackBonus int
Speed int
BlockRate float64
XPValue int
Ability *genMonsterAbility
Notes string
FireAttacker bool
}
// genMonsterAbility mirrors plugin.MonsterAbility.
type genMonsterAbility struct {
Name string
Phase string
ProcChance float64
Effect string
}
// tuneMonster applies the tuning formula to one raw SRD stat block.
func tuneMonster(b genStatBlock) genTunedMonster {
ac := b.AC
if ac < 10 { // engine minimum (see the zombie 8→10 note in dnd_bestiary.go)
ac = 10
}
ability := abilityFromTraits(b.Traits)
return genTunedMonster{
ID: b.Slug,
Name: stripNameParenthetical(b.Name),
CR: b.CR,
HP: b.HP,
AC: ac,
Attack: attackByCR(b.CR),
AttackBonus: primaryAttackBonus(b.Attacks),
Speed: tunedSpeed(b.SpeedWalk),
BlockRate: tunedBlockRate(ac),
XPValue: b.XP,
Ability: ability,
Notes: tunedNotes(b, ability),
FireAttacker: isFireAttacker(b.Attacks),
}
}
// isFireAttacker returns true when a monster's signature damage is fire —
// i.e., its highest-average-damage attack deals fire. Threshold at AvgDamage
// >= 5 so a trivial fire fleck on an otherwise physical creature doesn't tag
// the whole bestiary entry. Tieflings (FireResist) take half damage from the
// primary attack of any creature flagged here.
func isFireAttacker(attacks []genStatAttack) bool {
bestAvg, bestType := 0, ""
for _, a := range attacks {
if a.AvgDamage > bestAvg {
bestAvg = a.AvgDamage
bestType = a.DamageType
}
}
return bestType == "fire" && bestAvg >= 5
}
// stripNameParenthetical drops a trailing "(…)" qualifier from a monster
// name so the player-facing display reads "Giant Rat" rather than "Giant Rat
// (Diseased)". The slug still carries the variant, so the engine keeps both
// records distinct — only the display text loses the qualifier.
func stripNameParenthetical(name string) string {
i := strings.IndexByte(name, '(')
if i <= 0 {
return name
}
return strings.TrimSpace(name[:i])
}
// traitAbilityRules maps SRD special-ability names onto the engine's
// MonsterAbility effects. Each rule's Match list holds lowercase substrings; a
// creature's trait matches a rule if any substring is contained in the
// (lowercased, paren-stripped) trait name. The list is priority-ordered —
// abilityFromTraits walks it top-down and the first rule that matches any of
// the creature's traits wins, so the most combat-defining mechanic is picked
// when a stat block carries several traits. Traits that name no rule (Keen
// Smell, Amphibious, Hold Breath, telepathy/senses flavor) leave Ability nil.
//
// This is a best-effort baseline, like the rest of the tuning formula: the
// hand-authored dndBestiary entry always wins the merge, so refining a pick is
// a matter of adding the creature to the roster.
var traitAbilityRules = []struct {
match []string
name string
phase string
proc float64
effect string
}{
// Death-triggered blasts — resolve on the killing-blow phase.
{[]string{"death burst", "death throes", "elemental demise"}, "Death Burst", "decisive", 1.0, "death_aoe"},
// Spellcasters open with a magical barrage.
{[]string{"spellcasting"}, "Spellcasting", "opening", 0.5, "aoe"},
// Cheats death once.
{[]string{"undead fortitude"}, "Undead Fortitude", "decisive", 0.5, "survive_at_1"},
// Self-sustain — trolls, liches clawing back.
{[]string{"regeneration", "rejuvenation"}, "Regeneration", "any", 0.5, "regenerate"},
// Shrugs off spells.
{[]string{"magic resistance", "legendary resistance", "magic immunity", "spell immunity"}, "Magic Resistance", "any", 0.5, "spell_resist"},
// Ambush predators — a heavy opening strike.
{[]string{"surprise attack", "assassinate", "ambusher", "shadow stealth"}, "Surprise Attack", "opening", 0.8, "bonus_damage"},
// Fear and petrification lock the player out of acting.
{[]string{"petrifying gaze", "fear aura", "horrific appearance"}, "Frightful Presence", "opening", 0.4, "stun"},
// Frenzy / rage — damage ramps as the fight drags.
{[]string{"blood frenzy", "rampage", "reckless", "aggressive", "berserk", "brute"}, "Frenzy", "any", 0.4, "enrage"},
// Damaging auras and spiky hides punish every exchange.
{[]string{"heated body", "fire aura", "fire form", "stench", "mucous cloud", "barbed hide", "reflective carapace", "heated weapons", "hellish weapons"}, "Damaging Aura", "any", 0.4, "retaliate"},
// Charges, pounces, leaps — an opening lunge.
{[]string{"charge", "pounce", "standing leap", "running leap"}, "Charge", "opening", 0.4, "bonus_damage"},
// Coordinated attackers gain advantage.
{[]string{"pack tactics"}, "Pack Tactics", "any", 0.3, "advantage"},
// Riders that pile damage onto a landed hit.
{[]string{"sneak attack", "martial advantage"}, "Martial Advantage", "any", 0.35, "bonus_damage"},
// Slippery movement — dodges a blow outright.
{[]string{"nimble escape", "misty escape", "cunning action", "evasion", "incorporeal movement", "ethereal jaunt", "flyby", "shapechanger"}, "Evasive", "any", 0.3, "evade"},
// Enchanted weapons — a flat damage edge.
{[]string{"magic weapons", "angelic weapons"}, "Magic Weapons", "any", 0.3, "bonus_damage"},
}
// abilityFromTraits picks the highest-priority MonsterAbility implied by a
// creature's SRD trait names, or nil when none of them map onto an engine
// effect. See traitAbilityRules for the priority order.
func abilityFromTraits(traits []string) *genMonsterAbility {
norm := make([]string, len(traits))
for i, t := range traits {
// Drop the parenthetical suffix SRD appends to recharge-limited
// traits ("Legendary Resistance (3/Day)").
if p := strings.IndexByte(t, '('); p >= 0 {
t = t[:p]
}
norm[i] = strings.ToLower(strings.TrimSpace(t))
}
for _, rule := range traitAbilityRules {
for _, tr := range norm {
for _, m := range rule.match {
if strings.Contains(tr, m) {
return &genMonsterAbility{
Name: rule.name,
Phase: rule.phase,
ProcChance: rule.proc,
Effect: rule.effect,
}
}
}
}
}
return nil
}
// attackByCRPoints anchors the Attack-stat curve against the hand-tuned
// dndBestiary: each point is an (CR, observed Attack) pair lifted straight from
// a playtested roster entry. attackByCR interpolates between anchors and
// extrapolates past the top one along the final segment's slope.
var attackByCRPoints = []struct {
cr float64
atk int
}{
{0, 1}, {0.125, 1}, {0.25, 1}, {0.5, 2}, {1, 2}, {2, 4}, {3, 6}, {4, 7},
{5, 8}, {6, 10}, {7, 12}, {8, 13}, {9, 14}, {10, 16}, {11, 18}, {12, 19},
{13, 21}, {16, 26}, {19, 31}, {20, 33}, {24, 38},
}
func attackByCR(cr float64) int {
pts := attackByCRPoints
if cr <= pts[0].cr {
return pts[0].atk
}
last := pts[len(pts)-1]
if cr >= last.cr {
prev := pts[len(pts)-2]
slope := float64(last.atk-prev.atk) / (last.cr - prev.cr)
atk := int(math.Round(float64(last.atk) + slope*(cr-last.cr)))
if atk < 1 {
atk = 1
}
return atk
}
for i := 1; i < len(pts); i++ {
if cr <= pts[i].cr {
lo, hi := pts[i-1], pts[i]
t := (cr - lo.cr) / (hi.cr - lo.cr)
atk := int(math.Round(float64(lo.atk) + t*float64(hi.atk-lo.atk)))
if atk < 1 {
atk = 1
}
return atk
}
}
return last.atk
}
// primaryAttackBonus returns the d20 to-hit modifier of the creature's
// hardest-hitting attack — the one the engine's single Attack stat stands in
// for. Returns 0 (engine falls back to the tier-scaled bonus) when the stat
// block has no parsed weapon attacks.
func primaryAttackBonus(attacks []genStatAttack) int {
bonus, best := 0, -1
for _, a := range attacks {
if a.AvgDamage > best {
best, bonus = a.AvgDamage, a.AttackBonus
}
}
return bonus
}
// tunedSpeed maps an SRD walk speed (feet) onto the engine's speed scale. The
// engine treats speed as an initiative-ordering knob, so a coarse divide-and-
// clamp is enough; the ability pass can hand-tune outliers.
func tunedSpeed(walk int) int {
if walk <= 0 {
return 10
}
s := int(math.Round(float64(walk) / 2.5))
if s < 6 {
s = 6
}
if s > 18 {
s = 18
}
return s
}
// tunedBlockRate is a baseline damage-halve chance keyed off AC — heavier armor
// reads as a better chance to soak a hit. The ability pass overrides this for
// creatures whose block is a real mechanic (Parry, shields, etc.).
func tunedBlockRate(ac int) float64 {
switch {
case ac >= 18:
return 0.15
case ac >= 15:
return 0.10
case ac >= 13:
return 0.05
default:
return 0.0
}
}
// tunedNotes records the SRD multiattack and trait text so a hand-tuning pass
// has the source material in front of it, and states which ability (if any)
// the trait classifier wired.
func tunedNotes(b genStatBlock, ability *genMonsterAbility) string {
prefix := "SRD-tuned baseline — no ability wired (traits are non-combat)."
if ability != nil {
prefix = fmt.Sprintf("SRD-tuned baseline — %s (%s) wired from traits.",
ability.Name, ability.Effect)
}
var parts []string
if b.Multiattack != "" {
parts = append(parts, "Multiattack: "+b.Multiattack)
}
if len(b.Traits) > 0 {
parts = append(parts, "Traits: "+strings.Join(b.Traits, ", "))
}
if len(parts) == 0 {
return prefix
}
return prefix + " " + strings.Join(parts, " ")
}
// emitTuned renders the tuned templates as a generated Go source file.
func emitTuned(monsters []genTunedMonster) []byte {
var b bytes.Buffer
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
//
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
// under CC-BY-4.0 — see NOTICE. Regenerate with:
//
// go run ./cmd/open5e-import fetch bestiary
// go run ./cmd/open5e-import gen tuned
//
// Engine-ready DnDMonsterTemplates derived from the raw SRD staging table by
// the tuning formula in cmd/open5e-import/tuned.go. Hand-authored dndBestiary
// entries take precedence — see bestiary_tuned.go for the merge.
package plugin
func buildTunedBestiarySRD() map[string]DnDMonsterTemplate {
return map[string]DnDMonsterTemplate{
`)
for _, m := range monsters {
fmt.Fprintf(&b, "\t\t%q: {ID: %q, Name: %q, CR: %s, HP: %d, AC: %d, Attack: %d, AttackBonus: %d, Speed: %d, BlockRate: %s, XPValue: %d, ",
m.ID, m.ID, m.Name,
strconv.FormatFloat(m.CR, 'g', -1, 32),
m.HP, m.AC, m.Attack, m.AttackBonus, m.Speed,
strconv.FormatFloat(m.BlockRate, 'g', -1, 64),
m.XPValue)
if m.Ability != nil {
fmt.Fprintf(&b, "Ability: &MonsterAbility{Name: %q, Phase: %q, ProcChance: %s, Effect: %q}, ",
m.Ability.Name, m.Ability.Phase,
strconv.FormatFloat(m.Ability.ProcChance, 'g', -1, 64),
m.Ability.Effect)
}
fmt.Fprintf(&b, "Notes: %q", m.Notes)
if m.FireAttacker {
b.WriteString(", FireAttacker: true")
}
b.WriteString("},\n")
}
b.WriteString("\t}\n}\n")
return b.Bytes()
}

1898
data/open5e/magicitems.json Normal file

File diff suppressed because one or more lines are too long

15701
data/open5e/monsters.json Normal file

File diff suppressed because it is too large Load Diff

6251
data/open5e/spells.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,319 +0,0 @@
# Fix: Texas Hold'em LLM Tips
## What's broken
Two confirmed issues observed across multiple tip examples:
### 1. Position label is inverted in heads-up play
The tip says "positional advantage" when the player is acting first post-flop (out of position) and "out of position" when they're acting last. The position label reaching the LLM prompt is wrong.
**Root cause:** the `positionLabel()` function in `tips.go` derives position from `DealerIdx` using the general formula. In heads-up play the dealer posts the small blind and acts first pre-flop but **last** post-flop. The heads-up exception that exists in `PostBlinds()` in `betting.go` is not being reflected in position label calculation.
**Fix:** in `positionLabel()`, gate on `len(g.Players) == 2` before applying any label logic. In heads-up:
- Pre-flop: dealer = BTN/SB (acts first), other player = BB (acts last)
- Post-flop: dealer = BTN (acts last, positional advantage), other player = BB (acts first, out of position)
Check which street it is before assigning the label. `g.Street == PreFlop` needs different position semantics than all other streets in heads-up.
---
### 2. LLM is generating generic concepts instead of hand-specific advice
**Observed:** tips reference equity numbers but then ignore what those numbers mean for the specific hand. A player with 8♥ 7♥ on Q♥ K♠ 10♦ (gutshot + backdoor flush draw, 29% equity, free card available) received "not enough equity to bet" — which ignores the draw entirely and misapplies a made-hand concept to a drawing hand.
**Root cause:** the user prompt is not giving the LLM enough structured context to reason about hand *type*. It sees an equity number but doesn't know whether the hand is a draw, a made hand, a bluff catcher, or air. It pattern-matches on the number alone.
**Fix:** compute and inject the following additional fields into `TipContext` before building the prompt:
```go
type TipContext struct {
// existing fields...
// Add these:
HandCategory string // from poker.RankString() on current 5-card best
IsDraw bool // true if outs > 0 (see below)
FlushDrawOuts int // suited cards matching board suit count
StraightDrawOuts int // connected card gaps to straight
TotalOuts int // combined draw outs (deduped)
IsFreeCard bool // ToCall == 0
HeadsUp bool // len(ActivePlayers) == 2
}
```
Outs calculation (add to `equity.go` or a new `outs.go`):
- Flush draw: count hole cards matching dominant board suit; if 2 hole cards + 2 board cards same suit, FlushDrawOuts = 9
- Open-ended straight draw: 8 outs
- Gutshot: 4 outs
- Backdoor draws: count as 1-2 outs each
- TotalOuts = sum, capped at 15 (avoid double-counting straights and flushes)
---
### 3. System prompt needs to be more directive
**Current system prompt** (paraphrased from blueprint): "be a concise Hold'em coach, 2-4 sentences, cover hand strength, pot odds, position."
This is too open-ended. The LLM fills the space with whatever poker concepts come to mind. Replace with a prompt that forces it to reason about the specific situation before speaking.
**New system prompt:**
```
You are a Texas Hold'em coach giving advice to a single player via private message.
You will receive structured game context. Reason through it in this order:
1. What type of hand do I have — made hand, drawing hand, or air?
2. If drawing: how many outs, and do pot odds justify continuing?
3. If made hand: is it strong enough to bet for value, or weak enough to just pot control?
4. Does position affect what I should do here?
5. Is a free card available, and if so, is taking it correct?
Then write ONE piece of advice — 2 to 3 sentences maximum — that tells the player
what to do and why, using the specific cards and numbers provided.
Do not list concepts. Do not use generic poker vocabulary without connecting it to
this specific hand. If the correct play is obvious (e.g. free card with a draw),
say so plainly and briefly.
```
---
### 4. User prompt needs draw and hand type context injected
**Current user prompt structure** (from blueprint):
```
Street: <street>
Your hand: <cards>
Board: <cards>
Equity vs <n> opponents: Win x% | Tie y% | Loss z%
Pot odds to call: x%
SPR: x | Position: <pos> | Active players: <n>
```
**New user prompt structure** — add the computed fields:
```
Street: {street}
Your hand: {cards} [{hand_category}]
Board: {cards}
Draw outs: {total_outs} ({draw_description}) <- omit line if IsDraw == false
Equity vs {n} opponent(s): Win {x}% | Tie {y}% | Loss {z}%
{if ToCall > 0}: Pot odds to call: {pct}% — equity {exceeds|falls short of} price
{if IsFreeCard}: Free card available — no bet to call
SPR: {spr} | Position: {position} | Heads-up: {yes|no} | Street: {street}
```
`{draw_description}` examples:
- "flush draw (9 outs)"
- "gutshot straight draw (4 outs)"
- "open-ended straight draw (8 outs)"
- "flush draw + gutshot (11 outs)"
- "backdoor flush + backdoor straight (2 outs)"
`{hand_category}` examples from `poker.RankString()`:
- "High Card", "One Pair", "Two Pair", "Three of a Kind", "Straight", "Flush", "Full House", "Four of a Kind", "Straight Flush"
---
## Specific scenario the fix must handle correctly
**Hand:** 8♥ 7♥
**Board:** Q♥ K♠ 10♦
**Street:** Flop
**Equity:** 29%
**To call:** €0 (free card)
**Position:** dealer, heads-up, acting first post-flop (out of position)
Expected tip behaviour after fix:
- Identifies this as a drawing hand (gutshot + backdoor flush)
- Notes the free card is available
- Does NOT say "not enough equity to bet" without acknowledging the draw
- Does NOT say "positional advantage" — player is out of position post-flop heads-up
- Produces something like: "You have a gutshot straight draw with a backdoor flush. With a free card available you can check and see the turn without risk. If a 9 or a third heart comes, you'll be in a strong position — for now, take the free card."
---
## Reasoning mode (Qwen3 thinking)
Poker tips are the only task in GogoBee that should use reasoning mode. All other LLM calls (adventure narrative, etc.) run with thinking disabled. This needs to be a one-off configuration scoped entirely to `tips.go`.
### Why reasoning mode here
The tips failure pattern is not a knowledge gap — Qwen3-32B knows poker. The problem is that it jumps to pattern-matched conclusions without working through the situation in sequence. Reasoning mode forces the model to produce a `<think>...</think>` chain before the final response, which naturally surfaces: hand type, outs, position semantics, and the actual decision. The tip then follows from that chain rather than being assembled from disconnected concepts.
### Request changes in `tips.go`
Add a `enable_thinking` field to the request body and a `thinking_budget` cap to keep latency bounded:
```go
type llmRequest struct {
Model string `json:"model"`
Messages []llmMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream"`
EnableThinking bool `json:"enable_thinking,omitempty"`
ThinkingBudget int `json:"thinking_budget,omitempty"`
}
```
When building the tips request, set:
```go
body := llmRequest{
Model: cfg.Model,
Messages: []llmMessage{...},
MaxTokens: 1000, // increased to accommodate think block + response
Stream: false,
EnableThinking: true,
ThinkingBudget: 512, // cap reasoning tokens; enough for poker, not runaway
}
```
`ThinkingBudget` of 512 tokens is sufficient for a poker hand analysis reasoning chain. Without a cap, complex board textures can produce very long think blocks. 512 keeps worst-case latency reasonable.
Note: the exact field names for Ollama's Qwen3 thinking mode may differ from the above. Check the Ollama API docs for the current `qwen3:32b` thinking parameters — it may be `/think` appended to the model name (`qwen3:32b/think`) rather than a request body field, depending on the Ollama version. Either way, the intent is the same — make this configurable in `TipsConfig` so it can be toggled without a code change:
```go
type TipsConfig struct {
Endpoint string
Model string
APIKey string
Timeout time.Duration
EnableThinking bool // default true for poker tips
ThinkingBudget int // default 512
}
```
### Strip the think block from the response
The `<think>...</think>` content must never reach the player DM. The current response parser takes `choices[0].message.content` directly. Update it to strip thinking content before returning:
```go
func extractTipFromResponse(raw string) string {
// Strip <think>...</think> block if present
// Qwen3 may use <think> or <!--think--> depending on version
re := regexp.MustCompile(`(?s)<think>.*?</think>`)
cleaned := re.ReplaceAllString(raw, "")
// Also strip any leading/trailing whitespace left behind
return strings.TrimSpace(cleaned)
}
```
Call `extractTipFromResponse()` on `llmResp.Choices[0].Message.Content` before returning the tip string. If the result is empty after stripping (model only produced a think block and nothing else), fall back to the rules-based tip.
### Latency expectations
With `ThinkingBudget: 512` and the structured context prompt, expect:
- Typical: 4-8 seconds total (within the existing 10s timeout)
- Complex boards: up to 10 seconds
- Increase `cfg.Timeout` to `12 * time.Second` for tips specifically to give reasoning room without affecting other LLM calls
Tip delivery via DM is already async (goroutine), so even a 10-12 second tip doesn't block the table view or the action loop. Players receive the table view immediately and the tip follows shortly after.
### Config addition
```toml
[holdem]
# ... existing fields ...
tips_enable_thinking = true
tips_thinking_budget = 512
tips_timeout = "12s" # longer than default to accommodate reasoning
```
---
## Files to change
- `tips.go``TipContext` struct, `BuildTipContext()`, `buildPrompt()`, `positionLabel()`, `llmRequest` struct, `GenerateTip()`, new `extractTipFromResponse()` function
- `equity.go` — add outs calculation function
- No schema changes required
- No changes to `game.go`, `betting.go`, or `render.go`
## Test cases to verify before shipping
Write a table-driven test in `tips_test.go` covering:
| Hand | Board | Street | Expected position (HU) | Expected IsDraw | Expected outs |
|------|-------|--------|------------------------|-----------------|---------------|
| 8♥ 7♥ | Q♥ K♠ 10♦ | Flop | Out of position | true | 4 (gutshot) + backdoor |
| A♠ K♠ | — | Pre-Flop | BTN (dealer, acts first) | false | 0 |
| 5♥ 6♥ | 7♥ 8♣ 2♥ | Flop | varies | true | 15 (OESD + flush) |
| Q♣ Q♦ | Q♥ 2♠ 7♣ | Flop | varies | false | 0 |
The position test for heads-up pre-flop vs post-flop is the most important one. Get that right first.
---
## Validation pipeline (shipped 2026-04-13)
The "is the tip actually good?" question is now answered by a two-layer
automated test harness rather than vibes.
**Layer 1 — hand-authored scenarios** (`internal/plugin/holdem_tip_scenarios.go`)
20 canonical spots covering preflop tier/facing-bet branches and postflop
equity tiers × board textures × SPR depths. Each scenario declares an
expected action verb, required theme keywords, and forbidden substrings.
`TestTipScenarios_Layer1` runs the full rules-engine pipeline
(equity MC, draw detection, hand category, board texture, preflop
classification) against each scenario and asserts the tip contains the
expected action + themes. Fast, cheap, green.
**Layer 2 — solver-derived scenarios** (same scenarios, populated via `cmd/gensolver`)
11 of the 14 postflop scenarios carry real TexasSolver GTO frequencies
committed as a fixture at `internal/plugin/testdata/solver_freqs.json`.
`TestTipScenarios_Layer2` treats any action with solver frequency ≥ 15% as
"significant" and asserts the rules engine's recommended action matches one
of the significant actions — tolerating GTO's legitimately mixed spots
while catching genuinely-wrong recommendations.
**cmd/gensolver**
Offline pipeline that iterates `plugin.TipScenarios()`, shells out to
`console_solver` (TexasSolver CLI), parses the JSON strategy tree,
navigates to hero's decision node (IP/OOP × facing-check/facing-bet ×
check-bet line), extracts hero's action frequencies for their exact hole
combo, and merges them into the fixture file.
Key solver-side knobs worked out the hard way:
- **Scale normalization** to `pot=50, stack=8×pot` (SPR cap 8). TexasSolver
segfaults on deep stacks and on some textures at larger chip counts;
strategic equivalence is preserved because GTO frequencies are
scale-invariant.
- **Bet tree**: 50% + 100% pot sizings, plus allin. Narrower trees build
faster and still give solvable decision points.
- **`set_accuracy 1.0`, `set_max_iteration 100`** — converges in ~2 min
per flop instead of the ~24 min the solver's defaults demanded. 1%
exploitability is plenty for our assertion type.
- **Range syntax**: TexasSolver rejects shorthand like `22+` / `A2s+`
ranges must be explicitly enumerated. Using the solver's own
sample-input ranges verbatim as HU defaults.
Invocation:
```bash
GOGOBEE_SOLVER=/path/to/console_solver \
GOGOBEE_SOLVER_RESOURCES=/path/to/TexasSolver/resources \
go run ./cmd/gensolver [scenario-name-substring]
```
Results merge into the fixture, so regenerating one scenario doesn't wipe
the others.
**Known gaps** — 3 scenarios have no solver frequencies:
- `flop/monster set on paired board facing bet` — TexasSolver segfaults on
paired-board textures (upstream bug, not fixable from our side).
- `turn/weak top pair facing overbet` — hero's hole (63o) isn't in any
reasonable HU range, so the solver never allocates strategy for it.
Scenario still validated by Layer 1.
- Occasional flake on `flop/bottom pair facing big bet` at full-batch time
(succeeds when retried solo). Current fixture entry came from a solo
retry and is valid; if regeneration fails, just re-run that one
scenario with the name filter.
Adding new scenarios: append to `tipScenarios` in
`holdem_tip_scenarios.go`, run `cmd/gensolver` with the name filter,
commit both the code and fixture changes together.

38
go.mod
View File

@@ -3,45 +3,47 @@ module gogobee
go 1.25.0
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/PuerkitoBio/goquery v1.12.0
github.com/chehsunliu/poker v0.1.0
github.com/expr-lang/expr v1.17.5
github.com/expr-lang/expr v1.17.8
github.com/fogleman/gg v1.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/olebedev/when v1.1.0
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.35.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.36.0
maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.37.1
golang.org/x/image v0.40.0
maunium.net/go/mautrix v0.28.1
modernc.org/sqlite v1.50.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/mattn/go-sqlite3 v1.14.45 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/util v0.9.6 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.65.7 // indirect
go.mau.fi/util v0.9.10 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

116
go.sum
View File

@@ -1,26 +1,26 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI=
github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -28,27 +28,26 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914 h1:yAIlIiOkdoJvqd5xtWzM9tNDpLZrFfJdpnNSKha78G8=
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914 h1:xXPuFr3PVM4p6Vw3j0CP29oWYRVKO3cPZjR6D7BxggQ=
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0=
github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -57,9 +56,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -67,8 +65,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -77,27 +75,27 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg=
go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -107,8 +105,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -116,14 +114,13 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -131,8 +128,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -151,43 +148,48 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -0,0 +1,120 @@
# Adventure — the big update (2026-07-09 → 2026-07-11)
62 commits, ~31k lines, 7 phases. All merged to `main` and live in prod as of
2026-07-11. Working-tree doc; do not commit.
---
## The one-line version
Adventure stopped being a solo grind. You can now bring friends, fight each
other, fight the world together, prestige past the level cap, and there's an
actual story running underneath it — and Pete reports on all of it.
---
## 1. Co-op — you can bring people now (N3)
The headline. The combat engine was 1-versus-1 at its core; it was rewritten
into an N-player engine with real initiative, and every seat gets its own turn.
- `!expedition invite @user` — leader, Day 1 only
- `!expedition accept` / `decline` / `party` / `leave`
- Everyone buys their own supply pack. Everyone acts on their own turn.
- The enemy's action economy scales to the party, so four people don't trivialize
a room.
## 2. Fight each other (N6)
- `!duel @user [stake]`, then `!duel accept` / `decline` / `status`.
Staked, and **nobody dies** — it's a bout, not a mugging.
- `!rivals` for your record, `!rivals board` for room-wide standings.
## 3. Fight the world together (N6)
- `!adventure worldboss` — the Siege. One communal bout a day; `worldboss fight`
to take your swing at it. Everybody's damage counts toward the same health bar.
## 4. The Shadow (N6)
- `!adventure shadow` — a rival adventurer who runs the dungeon on their own
schedule whether you play or not, and you can check how your run stacks up
against theirs.
## 5. The Town (N4)
Housing finally pays off, and the room has a civic life.
- **T3 payoffs** — trophy hall + workshop; a long rest at home now grants a
**well-rested** buff that scales with your house tier.
- **T4 Estate vault** — `!adventure vault` / `vault store <item>` / `vault take
<item>`. 10 protected slots. Safe from `!sell all`, safe from combat.
- **A second pet** at Tier 4 — it shows up on its own.
- **Gifting** — `!give <item> @user`. Small handling fee to the community pot.
- **Registries** — `!town` (civic pride, housing street, pet showcase),
`!graveyard` (recent deaths), `!rivals`.
## 6. Prestige and a world that moves (N7)
- **Renown** — XP past L20 no longer evaporates. It accrues into a Renown rank
that shows on your `!sheet` and gets announced when it ticks up.
- **The Omen** — one rotating world modifier per week. TwinBee tells you what's
in the air in the morning DM.
- **Seasonal events** — holiday weeks re-skin the day's events and flavor.
- **Achievements** — a whole Renown wing added to `!achievements`.
## 7. The chase (N2)
- **Tempering** — `!adventure temper` pushes a magic item one rarity step higher
at the blacksmith. Euros plus a T5 material (one per clear — *that's* the real
gate).
- **Arena seasons** — `!arena leaderboard` is now the current season; champions
get titles. `!arena stats` keeps your lifetime record.
## 8. Story (N5 — the Hollow King)
- **`!adventure journal`** — recover campaign pages through the zones and read
them back. Bosses now have epilogues, and there's a real finale.
- NPC arcs land as DMs mid-run — Misty, Robbie, and Thom all have somewhere to
go now.
## 9. Backtracking (C5)
- **`!revisit <N>`** — walk back one room from the Path strip in `!map` for +1
threat. Blocked mid-fight, at a fork, and when it's too hot to double back.
## 10. Pete reports on it (Pete Adventure News)
Guild events — zone firsts, deaths, achievements — are now narrated into a live
news feed by Pete, the deadpan announcer.
- `news.parodia.dev/adventure` (feed, permalinks, daily digest)
- Live beats in the games room
- `!news` to read; `!news optout` if you'd rather not be named.
## 11. Restoration + plumbing (N1, unglamorous but real)
- The drop systems Phase R orphaned are wired back up; milestone rewards that
were stubs actually pay out.
- Lottery ticket sales fund the community pot.
- Fixed outside Adventure but shipped alongside: URL previews stopped dropping
thumbnails, and the bot stopped reminting its cross-signing identity on every
restart.
---
## ⚠️ Do NOT put in the announcement
- **Secret rooms and the cross-zone keys** (Sunken Sigil, Underforge Seal). These
are discovery content — no command, found by walking. Announcing them deletes
the discovery.
- **The NPC buffs** (Misty/Arina). Hidden mechanics by design. Say the NPCs have
arcs; do not say what befriending them *does*.
- Numbers, rolls, and rarity math generally — house style is verbs and outcomes,
not crunch.
## Watch list (first live exercise in prod)
Renown, the Omen, seasonal events, the Hollow King arc, the world boss and duels
have **never fired against real players before today**. Expect the first firings
to be the real test.

View File

@@ -0,0 +1,84 @@
# Announcement copy — your voice (draft)
Working-tree doc; do not commit.
Register: a person telling their friends what they built. No press-release
cadence, no bold-hook-per-paragraph, no claiming things are new when they're
expanded (houses, the town, and the story all already existed).
---
## Long version (post to the games room)
Big Adventure update went out. The main thing: you can play it with other people
now.
`!expedition invite @user` on day one of a run and they come with you. Everyone
buys their own supplies and takes their own turn, and enemies get more to do when
the party's bigger, so a group of four doesn't just walk through everything. This
was most of the work — the combat engine only ever knew how to run one player
against one monster, so it had to be rebuilt.
And if nobody's around, you can hire Pete. `!expedition hire` and he'll fill
whatever role you're short of — he fights his own turns, takes no loot and no XP,
and files a story about it afterwards. He's one guy, so if he's already out with
someone else you'll be told he's on assignment.
While I was in there: you can duel each other for a stake (`!duel @user 500`,
nobody dies), and there's a world boss once a day that everyone chips at together
(`!adventure worldboss`). There's also a rival called the Shadow who runs the
dungeon whether or not you log in — `!adventure shadow` to see how you compare.
Houses do a bit more at the top end. Tier 3 gets you a trophy hall and a
workshop, and resting at home now leaves you well-rested — better the higher your
tier. Tier 4 gets you a vault that `!sell all` can't touch, and a second pet turns
up on its own.
The town's grown too. You can hand items to each other with `!give`, and `!town`,
`!graveyard` and `!rivals` will show you how everyone else is doing.
L20 isn't the cap anymore — XP past it turns into Renown, which shows on your
sheet and keeps climbing. And there's a new modifier every week that changes how
the week plays; I'll tell you what's in the air in the morning.
The story's been expanded into a proper campaign that runs through the zones. A
king was emptied out so the crown could keep wearing him; he's been dead a long
time and the debt is still accruing. `!adventure journal` to read what you've
picked up. Misty, Robbie and Thom all have somewhere to go now, too.
Loot chasing got a couple of new levers: `!adventure temper` pushes a magic item
one rarity step up at the blacksmith, and the arena runs in seasons now, with
titles for whoever's on top when one closes.
Pete's reporting on all of it at news.parodia.dev/adventure — zone firsts, deaths,
achievements — plus live beats in here. `!news` to read it, `!news optout` if you
don't want your name in it.
Also `!revisit <N>` if you want to double back a room, and a pile of things that
were quietly broken now aren't.
It's all live. Let me know what's broken.
---
## Short version (one-screen post)
Big Adventure update, and the main thing is you can play it with other people now.
- **Parties** — `!expedition invite @user`. Real co-op, own turns, enemies scale
to the group. The combat engine got rebuilt for it.
- **Hire Pete** — `!expedition hire`. He fills the role you're missing, takes no
cut, and writes about it after.
- **Duels** — `!duel @user <stake>`. Staked, nobody dies.
- **World boss** — `!adventure worldboss`. One communal bout a day.
- **The Shadow** — `!adventure shadow`. A rival who plays whether you do or not.
- **Houses** — more at the top end: well-rested buff, a vault at T4, a second pet.
- **The town's grown** — `!give`, `!town`, `!graveyard`, `!rivals`.
- **Renown** — L20 isn't the cap anymore.
- **A weekly modifier** — the world changes shape every week.
- **The story's now a campaign** — a dead king, a crown still wearing him, a debt
still accruing. `!adventure journal`.
- **Tempering + arena seasons** — `!adventure temper`, `!arena`.
- **Pete's on the beat** — news.parodia.dev/adventure. `!news`.
All live. Let me know what's broken.

View File

@@ -0,0 +1,100 @@
# GogoBee → Appservice Auth Migration (multi-session)
Goal: move TwinBee's Matrix auth from the **MAS OAuth 2.0 device grant** (working
stopgap) to the **full appservice transaction/push model** — the MAS-durable end
state: no human login, no MFA, no consent, `as_token` never expires, scales at
485+ rooms. The device grant stays behind `AUTH_MODE=masdevice` as instant
rollback.
Why the *previous* appservice attempt failed: it was a hybrid (as_token + `/sync`),
and Synapse forbids appservice-namespace users from `/sync`. This migration
replaces `/sync` with Synapse→bot transaction push + MSC3202/MSC2409 E2EE
extensions, which is the supported path in mautrix v0.28.1.
Keep this doc working-tree only — do NOT commit (see feedback_dont_commit_plan_mds).
## Key facts established (investigation, 2026-07-03)
- **HEAD 20d1f92 is MISLABELED**: commit msg says "device grant" but it only
deleted registration.yaml.example; committed client.go is still the FAILED
appservice+/sync hybrid. The real device-grant (masauth.go + client.go rewrite)
is UNCOMMITTED working tree — that's what runs in prod. Backed up to scratchpad.
- mautrix v0.28.1 fully supports appservice E2EE: transaction carries
`to_device`/`device_lists`/`device_one_time_keys_count` (stable + msc2409/msc3202
unstable field names); `CreateDeviceMSC4190`; cryptohelper `MSC4190=true` mints
the device (no /login). HEAD's old appservice code already had auth+crypto
correct — only the /sync event loop was wrong.
- To-device processing gates on `Registration.EphemeralEvents` (yaml
`receive_ephemeral: true`) — `appservice/http.go:133`.
- Encrypted rooms: must feed `m.room.encryption`/`m.room.member` to the client's
StateStore via `mautrix.UpdateStateStore(...)` + `mach.HandleMemberEvent`, or the
bot sends plaintext into encrypted rooms / can't share keys.
- Both hosts SSH-reachable: `reala@parodia.dev` (Synapse/MAS/Authentik docker),
`reala@192.168.1.212` (millenia, bot in screen). Retired registration at
`/home/reala/matrix/compose/synapse/gogobee-registration.yaml.retired-20260703-223425`.
## Decisions (user, 2026-07-03)
- Cutover: **AUTH_MODE flag** (keep device grant as rollback), not full replace.
- Synapse changes: **I apply + restart, confirm right before restart**.
## Session 1 — bot-side code (local, no prod risk) ✅ DONE (build+vet green)
- [x] `Config`: AuthMode + appservice fields (RegistrationPath, ListenHost/Port,
HomeserverDomain).
- [x] `internal/bot/session.go`: `Session` unifying both modes (OnEventType/Run/
Stop); `NewSession(cfg)` dispatches on AuthMode; masdevice path wraps existing
device-grant NewClient + DefaultSyncer + sync loop (verbatim).
- [x] `internal/bot/appservice.go`: LoadRegistration → CreateFull → BotClient;
cryptohelper MSC4190 device create; ep.OnOTK/OnDeviceList + 9 crypto to-device
types → crypto machine; EventEncrypted decrypt+redispatch; StateStore
population (encryption + members) via lazy per-room resolveRoom + UpdateStateStore
on member/encryption state; HTTP listener via as.Start() in goroutine.
- [x] `main.go`: NewSession + sess.OnEventType + sess.Run; env AUTH_MODE,
AS_REGISTRATION, AS_LISTEN_HOST, AS_LISTEN_PORT, HOMESERVER_DOMAIN; envInt helper.
- [x] `.env.example` documented; go.mod tidied (zerolog now direct dep).
- [x] `go build`/`go vet` green (CGO=1 -tags goolm).
New files: internal/bot/session.go, internal/bot/appservice.go. NOT yet committed.
### Known correctness risks to verify in Session 3
- **resolveRoom is lazy on inbound encrypted events only.** Scheduled/broadcast
posts into an encrypted room the bot hasn't received from yet would send
plaintext (StateStore unresolved). Mitigation for S3: prewarm the configured
rooms (BROADCAST_ROOMS, GAMES_ROOM, ESTEEMED_ROOM, MOD_ADMIN_ROOM, MINIFLUX_*)
at startup, or resolve all joined rooms once. Interactive `!` commands are
reply-driven so lazy resolve covers them.
- Whether crypto machine successfully shares keys after ReplaceCachedMembers +
device fetch (FetchKeys) — the make-or-break, only testable against live Synapse.
- MSC4190 device creation must succeed against MAS-fronted Synapse (untested end
to end; HEAD's old code used the same path but was never verified past whoami).
## Session 2 — Synapse infra on parodia (touches live homeserver)
- [ ] Restore gogobee-registration.yaml: set `url:` = bot tailnet addr, add
`receive_ephemeral: true`, `org.matrix.msc3202: true`, keep
`io.element.msc4190: true`, `rate_limited: false`.
- [ ] homeserver.yaml `experimental_features`: msc2409_to_device_messages_enabled,
msc3202_device_masquerading, msc3202_transaction_extensions,
msc4190_device_management. Wire `app_service_config_files`.
- [ ] Determine bot's headscale tailnet address on parodia (Synapse must reach the
bot's ListenPort). Confirm routing.
- [ ] Backup homeserver.yaml + registration; **confirm before restart**; restart
matrix-synapse; check it comes back for all users.
## Session 3 — cutover + E2EE verification on millenia
- [ ] Copy registration to bot host; set AUTH_MODE=appservice + AS_* env.
- [ ] rsync source + build in place (CGO=1 -tags goolm), restart screen session.
Remove empty local data/gogobee.db first (empty_local_db_wipes_prod).
- [ ] Verify: transactions arrive (listener logs), whoami OK, MSC4190 device made,
bot responds to `!` command in a PLAINTEXT room.
- [ ] Verify E2EE: bot decrypts an incoming encrypted-room message AND its reply
decrypts for a human client (encrypt-out works). This is the make-or-break.
- [ ] If broken: AUTH_MODE=masdevice, restart → back on device grant. Then triage.
## Cleanup (after proven)
- Prune stale @twinbee devices, orphan MAS client, retire masauth.go or leave
behind the flag. Fix HEAD mislabel (the eventual commit should actually contain
device-grant + appservice, not the stale hybrid).

244
gogobee_boredom_plan.md Normal file
View File

@@ -0,0 +1,244 @@
# Bored Adventurers — autonomous expedition starts
**Premise.** A player who stops tending their adventurer doesn't stop having an
adventurer. After 24h with no Adventure action, the character gets restless and
leaves on an expedition by itself. It goes with the gear it already has and the
cheapest supplies it can afford. If you never arm it, the results are bad — and
that's the mechanic, not a bug.
## Why this is small
The autopilot is already almost fully autonomous. `runAutopilotWalkDriven`
(expedition_autorun.go:168) walks rooms, drives elite and boss fights on the real
turn engine, auto-camps, auto-harvests, rolls day rollovers, and auto-picks a
fork after 8h of silence. Nothing in that path ever needs a human.
The one thing that still needs a human is the *start*: `!expedition start <zone>
<loadout>` (dnd_expedition_cmd.go:281). So this feature is one new ticker that
performs that start, plus a clock to decide when.
## Decisions (locked)
| Question | Answer |
|---|---|
| Idle clock | **Any action against Adventure**, from any interface — not Matrix chatter, not autopilot writes |
| Threshold | 24h idle → the adventurer leaves. No warning DM. |
| Re-trigger | After a bored run ends, another 24h of silence starts another. Forever. |
| Zone | Easiest zone whose **level band** contains the character. Never trivial low-tier farming. |
| "Raid-shaped" | = **multi-region** zones (underdark, dragons_lair, abyss_portal). Avoided — *unless* they're the only at-band option (L13+), then allowed. |
| Supplies | **Lean** loadout (cheapest). Broke → no run. |
| Gear | Never bought, never equipped. This is the whole point. |
## §1 — The clock: `last_player_action_at`
The existing timestamps are all unusable:
- `player_meta.last_active_at` — auto-bumped on **every character save**
(adventure_character.go:584). The autopilot saves constantly, so a bored
character would refresh its own idle clock and never qualify again.
- `user_stats.updated_at` — bumped on every Matrix message in any room
(stats.go:103). That's chat presence, not *game* action. Rejected per the
"any action against Adventure, not just in Matrix" rule.
- `daily_activity` / `loadAdvDailyActivity` — unified across
`dnd_expedition_log`, so the autopilot's own walk entries count as player
activity. Same self-refresh problem.
**New:** `player_meta.last_player_action_at DATETIME`, written *only* by
`markPlayerAction(uid)` — a direct UPDATE, never routed through
`saveAdvCharacter`, so no ticker or autopilot path can touch it. Any future
non-Matrix entry point (web, Pete, API) calls the same helper.
Stamped from `AdventurePlugin.OnMessage` when the message is a real player
action: an `!` command in `advActionCommands`, or a reply to a pending
interaction (`p.pending`, adventure.go:889 — shop/blacksmith/pet prompts).
`advActionCommands` is the 47 names actually routed in `OnMessage`'s if-chain.
Note `Commands()` (adventure.go:158) only registers **28** — it's a help
surface, not a routing table, and is missing `expedition`, `zone`, `fight`,
`camp`, `extract`, `resume`, and more. A test greps the `p.IsCommand(ctx.Body,
"…")` literals out of adventure.go and asserts each one is in the set, so a
future command can't silently fall out of the clock.
## §2 — Zone pick: `pickBoredomZone(level, uid)`
`zonesForLevel` is wrong for this: it filters on **tier only** and ignores the
`LevelMin`/`LevelMax` fields that already exist on `ZoneDefinition` — which is
why a level-1 player can currently walk into a T3 zone. The boredom picker uses
the bands instead.
1. Candidates = zones where `LevelMin <= level <= LevelMax`.
2. Prefer **non**-multi-region (`IsMultiRegionZone`, dnd_expedition_region.go:135).
Among those, lowest `Tier` — the "easiest at-level" rule.
3. Ties broken by **least-recently-started by this player**, so a bored
adventurer rotates zones instead of grinding one forever.
4. If step 2 is empty, retry allowing multi-region. This only bites at L13+,
where `dragons_lair` and `abyss_portal` are the only at-band zones and both
are multi-region.
5. Still empty → no run.
Worked examples:
| Level | At-band | Picked |
|---|---|---|
| 5 | forest_shadows, sunken_temple (T2), manor, underforge (T3) | a T2 zone |
| 10 | underdark (multi), feywild_crossing | feywild_crossing |
| 12 | underdark (multi), feywild (T4), dragons_lair (multi, T5) | feywild_crossing |
| 15 | dragons_lair (multi), abyss_portal (multi) | dragons_lair — fallback fires |
## §3 — The run
Ticker `expeditionBoredomTicker`, 30m interval, skips the ambient quiet window.
Candidates: `player_meta` rows, `alive = 1`, `COALESCE(last_player_action_at,
created_at) < now-24h`, and `last_boredom_at` NULL or older than the 24h
cooldown. (COALESCE on `created_at` so a character made and never played still
qualifies, but not on its first tick.)
Per-user skips, all reusing existing guards: resting lockout, seated on someone
else's party, active expedition, resumable (extracted, pending `!resume`)
expedition, active zone run, active combat session.
CAS-claim `last_boredom_at` **before** starting, so a crash mid-start can't loop.
Then: `loadoutPurchase(zone.Tier, LoadoutLean)` → balance check → shared
`beginExpedition` helper → DM.
A zero-supply start is impossible (`SupplyPurchase.Validate`,
dnd_expedition_supplies.go:270, hard-rejects it), so the cheapest bored run costs
50 coins at T1 and 250 at T5. Can't afford it → no run, one DM (rate-limited by
the cooldown we already claimed).
## §4 — Refactor: `beginExpedition`
`expeditionCmdStart` is prompt-layer (:282309) followed by a reusable
transaction (:369424): balance check, debit, holiday/omen freebies,
`startExpedition`, `ensureRegionRun`, log, refund-on-failure. Extract that second
half into `beginExpedition(uid, c, zone, purchase, reason)` so the command and
the boredom ticker share one code path and one refund story.
## §5 — Why the results will be bad (no work required)
This all already exists; the feature just points it at neglected characters:
- The autopilot **never buys or equips anything** — verified, no `buy`/`equip`/
`Debit` calls anywhere in expedition_autorun.go, expedition_autocamp.go, or
dnd_expedition_autopilot_harvest.go. Stale gear stays stale.
- Lean supplies deplete fast → **Rationing** (1 attack/skill) at 25% →
**Severe** (2, long rests blocked) at 10% → **Starvation** at 0, which forces
an extract with a **20% coin tax** on the haul (dnd_expedition_cycle.go:140).
- The boss-safety gate holds the autopilot out of boss rooms below 80% HP, so an
under-geared character stalls, camps, and burns supplies it doesn't have.
The neglected adventurer grinds mediocre, half-starved runs on rusting gear and
comes home taxed. Exactly the intent.
## §6 — Streaks: no credit for a run you didn't ask for
Bored runs write `dnd_expedition_log` entries, which `loadAdvDailyActivity`
counts as player activity — so without care a bored run would **suppress the
idle-shame DM and preserve the daily streak** for someone who hasn't shown up in
weeks.
Two halves, and only one needed fixing:
- **Bumping** the streak was already safe. `engaged` reduces to
`LastActionDate == today|yesterday` (the `CombatActionsUsed`/
`HarvestActionsUsed` clauses are dead — nothing in the tree increments them),
and `LastActionDate` is only set by `markActedToday`, which is called only
from real player commands. `beginExpedition` deliberately does not call it.
- **Shielding** was not. Both hold branches in `midnightReset` (the activity
oracle, and the active-expedition safety net) would spare an absent player.
Fixed with `dnd_expedition.boredom` + `isBoredomDriven(uid, now)`, which requires
*both* halves: the player hasn't acted in 24h **and** their most recent
expedition is one the adventurer started for itself. A manually-started
expedition still holds the streak while the autopilot walks it — that behavior is
untouched. Reading the *most recent* expedition rather than the active one keeps
it honest on the night after a bored run ends, when its logs would otherwise
still read as player activity.
## §7 — Robbie
Robbie sweeps every non-slotted inventory item daily (ores, fish, junk,
treasure — `robbieQualifyingItems` takes all of them unconditionally) and pays
`Value / 4`. The autopilot deposits harvested loot straight into
`adventure_inventory` (dnd_expedition_harvest.go:478, dnd_zone_loot.go), so:
**Robbie funds the boredom loop.** Bored run → auto-harvested junk → Robbie
converts it to coins → pays for the next lean pack → forever. The "they go broke
and stop" poverty floor does not exist. Abandoned characters are perpetual
motion, and that is the accepted design.
What was *not* accepted was the noise: Robbie posts a public room announcement
per visit, so every abandoned character would file a daily bulletin about
somebody who stopped playing weeks ago. He now visits and pays silently for idle
players (adventure_robbie.go), gated on the same clock.
## §8 — The bug this nearly shipped with
`playerIsIdle` originally scanned `COALESCE(last_player_action_at, created_at)`
straight into a `*time.Time`, and `lastExpeditionByZone` scanned
`MAX(start_date)`. **modernc.org/sqlite rebuilds a `time.Time` from the column's
declared type, and both `COALESCE()` and `MAX()` erase it** — the value comes
back a string and the Scan fails.
`lastExpeditionByZone` failed loudly. `playerIsIdle` failed *open*: an unreadable
row counts as idle, so it returned `true` for every player alive, including one
who had acted an hour earlier. Shipped, that would have marched the entire server
into dungeons at once and silenced Robbie for everybody.
The unit tests missed it at first because they ran against empty tables, so the
scan never executed. Both now select the declared columns and fold in Go.
Note the asymmetry: `loadBoredomCandidates` uses `COALESCE` too and is fine —
it's evaluated *inside* SQL and never scanned. Only Go-side scans through an
aggregate or COALESCE lose the type.
## §9 — Status
Built, vetted, full suite green. **Not deployed, not committed.** Working tree
carries: db.go (3 columns), expedition_boredom.go (new), expedition_boredom_test.go
+ expedition_boredom_clock_test.go (new), dnd_expedition_cmd.go (beginExpedition
extraction), adventure.go (clock stamp + ticker), adventure_scheduler.go (idle
reaper gate), adventure_robbie.go (silent for idle), twinbee_expedition_flavor.go
(ExpeditionBoredomStart).
## §10 — Verified end-to-end against prod data
`internal/plugin/exercise_boredom_prod_test.go` (build tag `prodexercise`), run
against a throwaway copy of the live DB:
```
GOGOBEE_PROD_DB_DIR=<dbcopy> go test -tags prodexercise -run TestExerciseBoredomProd -v ./internal/plugin/
```
PASS. What it actually proved, on real characters with real gear:
- **The clock gates the sweep.** A control player stamped as having acted an hour
ago was not a candidate and was not charged; every player who departed passed
`playerIsIdle`. This is the assertion that would have caught the COALESCE
fail-open bug (§8), which is why the control arm is not optional.
- **Real departures.** 3 of 5 candidates left. `@holymachina` (L20 cleric) →
`dragons_lair`, `@quack` (L4 mage) → `forest_shadows`, `@camcast` (L1) →
`crypt_valdris`. The other two stayed home for the right reasons and the test
names them: one has no character, one never finished creation.
- **L20 into the raid, as designed.** `dragons_lair` is the only zone in
`@holymachina`'s band, so the fallback fired and the departure DM carried the
raid warning. Exactly the decision that was locked.
- **It bought nothing.** Coins debited equal the lean pack to the coin (250 at
T5, 50 at T1/T2), and the equipment fingerprint is byte-identical across the
run.
- **Flagged and cooled.** `boredom = 1` on every row, `isBoredomDriven` true, and
a second tick 30 minutes later charged nobody a second time.
- **The autopilot walks it.** Rooms accumulate across segments.
And it drew blood on the first run: **`@quack` (L4 mage, stale gear) died four
rooms in.** That is the mechanic, working, on the very first exercise. Death is
`Kill()``alive = 0`, 6h respawn, auto-revived by the scheduler ticker
(adventure_scheduler.go:72), and `loadBoredomCandidates` requires `alive = 1`, so
a dead adventurer sits out its six hours and then gets restless again. Death
pauses the loop; it doesn't end it.
## §11 — Status
Built, vetted, full suite green, exercised end-to-end against prod data.
**Not deployed, not committed.**

View File

@@ -0,0 +1,787 @@
# Code review follow-ups — `n1-restoration` (2026-07-10)
Review scope: `git diff main...HEAD`, 99 files / ~16.7k lines (N1, N2, R1, R2, N3 P1P7).
Method: 8 finder angles → dedup → 1 verifier per correctness candidate.
Baseline before and after: `go build`, `go vet`, `go test ./internal/...` all green.
Working-tree doc — **do not commit** (per project convention on `gogobee_*.md`).
**Status.** Fixes 15 shipped in `1f21156`. Deferred items **A** and **B** fixed
2026-07-10 (`d76c63b`); A turned out to have a deeper root cause and surfaced
three new findings, **M**, **N** and **O**. Item **C** fixed 2026-07-10 — its
premise was wrong and the bug underneath it was worse (the seven-day resume
window was never enforced; see below). Item **M** fixed 2026-07-10 — two of its
three claims were wrong (the mage hooks *do* run on the turn path); the one real
defect, Grim Harvest, had a different cause than recorded. Items **D**, **I**,
**L**, **N** fixed. Item **K** verified 2026-07-10 — the anchor gap is a real,
confirmed narrowing, left as an owner design call (no code change). Item **H**
measured 2026-07-10 and **declined** — its "per-message cascade" premise was
overstated, the hot path is already optimized, and the proposed
`MessageContext` thread-through is disproportionate churn with a staleness
regression surface.
**All correctness work is committed.** Everything remaining is deliberate, not a
defect: **E** (intentional, roster-size death rule), **F** (`grantAutorunGrace`
stopgap, deferred to R5 by design), **G** (party-combat DB chatter — a perf
refactor whose only safe fix is a fight-lifetime roster cache, i.e. the same
staleness surface H was declined for; deferred), **H** (declined, above),
**J** (`!sell` anchor — PLAUSIBLE, presence == any chat, left per convention),
**K** (owner design call, above), **O** (already fixed as a side-effect of A;
only the sim re-baseline is pending, which is operational).
**Method note.** Both C and M were mis-diagnosed in the same way: the finder read
one call site, concluded "this is the only caller", and wrote it down. Verify the
callers before planning the fix.
**Third pass (2026-07-10, `88c5fcd`).** `/code-review high --fix` over the pushed
follow-up stack (`git diff origin/main...HEAD`, 7 commits). Five fixes shipped;
they harden the item-C extraction work and the item-N Misty ordering rather than
opening new ground. See "Third pass" below.
---
## Before you commit: split the formatting from the fixes
A repo-wide `gofmt -w ./internal ./cmd` ran after the review. It reformatted
**105 files that have nothing to do with these findings** — they were already
non-conforming on `main` (struct-field alignment, mostly), and gofmt is
semantics-preserving, so nothing changed behaviourally.
Net state: **117 files dirty, only 13 carry review fixes.** Commit them apart, or
the fixes become unreviewable inside a wall of realignment.
The 13 fix-bearing files:
```
internal/plugin/adventure.go * internal/plugin/dnd_expedition_supplies.go *
internal/plugin/adventure_arena_season.go internal/plugin/dnd_zone_cmd.go *
internal/plugin/combat_cmd.go internal/plugin/expedition_party.go
internal/plugin/combat_party_turn.go internal/plugin/expedition_party_cmd.go
internal/plugin/dnd_expedition.go * internal/plugin/expedition_party_resolve.go
internal/plugin/dnd_expedition_combat.go internal/plugin/zone_combat_party.go
internal/plugin/combat_engine_party_test.go
```
Plus one new file: `internal/plugin/zone_combat_party_casualty_test.go`.
The four marked `*` carry **both** a fix and gofmt realignment
(`adventure.go`, `dnd_expedition.go`, `dnd_expedition_supplies.go`,
`dnd_zone_cmd.go`), so a clean split needs `git add -p` on those rather than a
straight per-file split. The other nine are fix-only and can be staged whole.
---
## Fixed in this pass
| # | File | Defect |
|---|------|--------|
| 1 | `combat_party_turn.go` | Settle-to-terminal dropped the entire close-out |
| 2 | `expedition_party_cmd.go` | Party member permanently soft-locked on leader extract |
| 3 | `expedition_party_cmd.go` | Lost update on the shared supply pool |
| 4 | `dnd_expedition_combat.go` | Elite harvest interrupt granted no elite loot |
| 5 | `adventure_arena_season.go` | Transient failure permanently lost a season crown |
Plus cleanups: deleted dead `partySurvivors`; collapsed the `zoneCombatRoster`
alias into `fightRoster`; `partyCasualtyLine` now uses `joinNames`; consolidated
four copies of the expedition column projection into `expeditionSelectCols`;
corrected two false doc comments on `applyDailyBurn`/`applyDailyBurnP`; `replyDM`
no longer sends a blank DM.
### 1. Close-out dropped when the settle turns lethal — the worst one
`beginCombatTurn` calls `settleCombatSession` to resolve a phase the engine owes
(a fight parked mid enemy-turn after a restart). That settle can end the fight.
The old code then saw `!sess.IsActive()` and returned "you're not in a fight."
The terminal status was already persisted, so nothing ever paid the party out:
no XP, no loot, no `markAdventureDead`, no `abandonZoneRun`, no
`forceExtractExpeditionForRunLoss`. The reaper cannot rescue it either —
`listExpiredCombatSessions` filters `status = 'active'`, and the session is now
terminal. The win or the death simply evaporates.
The `!fight` start path and the reaper both already do settle-then-`closePartyRound`;
`beginCombatTurn` was the one settle site that skipped it. Now it closes out and
announces, matching them.
### 2. Member soft-locked when the leader extracts and walks away
`seatedExpeditionFor` (the guard) spans `status IN ('active','extracting')`.
`expeditionForMember` (what `!expedition leave` resolved through) filtered
`status = 'active'` only. During the leader's 7-day extracting limbo a member was
therefore refused any new adventure by the guard, and told "No active expedition"
by the very command the guard pointed them at. Nothing sweeps stale `extracting`
rows, and only the *leader* can trigger the transition that clears them — so if
the leader quit, the member was stuck forever with no self-service recovery.
`!expedition leave` now resolves the seat through `seatedExpeditionFor`, so the
exit sees every state the gate sees. (`seatedExpeditionFor` already excludes
leaders, so they still fall through to the `!extract` message.)
### 3. Lost update on the shared supply pool
`updateSupplies` rewrites `supplies_json` wholesale. `expeditionCmdAccept` folded
a new member's packs onto an `exp` snapshot read ~60 lines earlier, with no lock.
Handlers run one goroutine per event (mautrix `AsyncHandlers`, never overridden),
so two invitees accepting at once genuinely interleave: both read pool `P`, one
writes `P+a`, the other `P+b` — a member's packs vanish, or spent SU is
resurrected by the day-burn tick.
Note `advUserLock` cannot fix this: it is keyed by sender, so two members take
two different mutexes. Added `advExpeditionLock(expID)` and re-read the pool
under it. **This closes accept-vs-accept only** — see deferred item B.
### 4. Elite harvest interrupt paid standard loot
`runHarvestInterrupt` computed `elite := kind == InterruptElite`, used it to pick
an elite enemy and to pick elite narration, then passed a hardcoded `false` as
`isElite` to `closeOutZoneWin`. Since `dropZoneLoot` gates on `isBoss || isElite`,
beating an elite interrupt skipped the masterwork roll and used the 1.0 standard
treasure weight instead of 2.0 — while the identical elite fought via `!zone`
paid out correctly.
Now passes the live `elite`. The param only flows into `dropZoneLoot`, so this
adds the masterwork roll and doubles treasure weight for surviving seats; threat,
XP, and narration are untouched. A small reward buff on a fight players already
win, consistent with lifting trailers rather than nerfing.
### 5. A failed season crown was lost permanently, not retried
`arenaSeasonRollover` logged-and-`continue`d when `recordArenaSeasonTitle`
failed, then called `db.MarkJobCompleted` unconditionally. `JobCompleted`
short-circuits every future run for that quarter, so a transient SQLite `BUSY`
meant the crown was never awarded — ever.
`recordArenaSeasonTitle` is idempotent (`arena_season_titles` has
`PRIMARY KEY (season, kind)`, insert is `ON CONFLICT DO NOTHING`) and a past
season's data is frozen, so retrying is safe. The job is now marked complete only
when no crown failed. The "no entrants" path still closes the season correctly.
---
## Fixed in the second pass (2026-07-10) — items A and B
### A. Turn-based combat skipped achievements and post-combat subclass state
The root cause was one level deeper than this doc originally recorded, and it is
worth writing down because the surface symptom was misleading.
`buildZoneCombatants` used to call `applyArmedAbility`, which applies an ability's
mods **and then clears `c.ArmedAbility` and saves the sheet**. The turn engine
calls that same builder again on *every* `!attack` / `!cast` / `!consume`. So in a
turn-based fight:
- Round 1's build fired the ability, spent the resource, cleared the arm flag.
- Round 2+ rebuilt the character, found `ArmedAbility == ""`, and produced a
combatant with **none of the ability's mods**.
A Berserker paid stamina and got exactly one round of `BerserkerRage`,
`RageMeleeDmg`, `PhysicalResistRage`, `FrenzyDmgBonus`. Every entry in
`dndActiveAbilities` had the same shape. `mods.BerserkerRage` was not merely
unread at close-out — by then it no longer existed.
Fixed by splitting arming into its two halves:
- `consumeArmedAbility(c)` — the mutation. Disarms, saves, returns the id. Runs
**once**, at fight start.
- `applyAbilityByID(c, id, mods)` — pure. No DB write, no disarm. Safe on every
rebuild. (No ability's `Apply` writes to the character, so this is genuinely pure.)
- `armAbilityForFight(c, mods)` — consume + apply, for the auto-resolve callers
that build and fight in one breath.
`buildZoneCombatants` now takes the already-consumed `armed` id and re-applies it.
The id rides on `ActorStatuses.ArmedAbility`, seeded per seat at fight start
(`seedActorOneShots`), so `partyCombatantsForSession` reproduces the ability on
every rebuild and the close-out can still see that a rage fired.
On top of that, the close-out itself: `postCombatBookkeeping(...)` in
`combat_bridge.go` now carries the achievements + subclass persistence, and all
four close-outs route through it — `runDungeonCombat`, `runZoneCombatRoster`,
`finishCombatSession`, `finishPartyCombatSession`. The turn-based pair reach it
via `postCombatBookkeepingForSeat`, which rebuilds the `CombatResult` the hooks
need from the persisted session (`seatCombatResult`) and re-derives the
fight-start mods (`seatFightStartMods`).
It fires on **every** terminal status, not just a win — a Berserker who rages and
loses is still exhausted, which is what auto-resolve always did.
Also fixed in passing: `buildFightSeats` and `runZoneCombatRoster` consumed the
armed ability *before* the checks that could sit a seat out, so a downed member
was disarmed for a fight they never joined. The refusals now run first.
**Balance note.** Turn-based Berserkers now keep rage for the whole fight instead
of one round. That is a buff, but it is the buff the player already paid for.
The class-balance corpus measures the auto-resolve path, so it is unmoved.
New tests: `internal/plugin/combat_armed_ability_test.go` (9 cases) —
purity/repeatability of `applyAbilityByID`, once-only consume, rage surviving
rebuilds, the sat-out member keeping their ability, per-seat statuses isolation,
and exhaustion on both a manual win and a manual loss.
The Grim Harvest half of this item was closed separately — see item **M**.
### B. The other six supply writers raced
All six now go through `withExpeditionSupplies` (`dnd_expedition.go`), which takes
`advExpeditionLock(expID)`, re-reads the expedition, hands the closure the fresh
row, and persists what it returns:
`dnd_expedition_cycle.go` (`nightRolloverBurn`, forage + burn in one write),
`dnd_expedition_milestone.go` (`grantTwoWeeksCache`),
`dnd_expedition_region_cmd.go` (`advanceToNextRegion` transit burn),
`dnd_expedition_camp.go` (`campPitch`), `expedition_autocamp.go`
(`pitchAutopilotCamp`), `expedition_ambient.go` (pack-rat drain).
Fix #3's hand-rolled lock in `expeditionCmdAccept` was folded onto the same
helper. Seven call sites, no nesting — verified none of the closure callees
re-enter the lock (`sync.Mutex` is not reentrant).
`expedition_sim.go:1421` is left alone on purpose: the sim is single-threaded and
takes no plugin locks.
The atomic-`json_set`-delta alternative is still the better long-term shape (it
would drop the lock entirely) but it couples `updateSupplies` to the blob format.
---
## Third pass (2026-07-10, `88c5fcd`) — `/code-review high` on the follow-up stack
Reviewed `git diff origin/main...HEAD` (the 7 pushed follow-up commits) with 8
finder angles → dedup → 1 verifier per candidate. The removed-behavior angle came
back empty: the close-out refactor (item D), `parseMenuIndex` (item I), the
GrimHarvest stash (item M), and the `endRunOnLoss`/`applyOwnerWinEffects`/
`grantSeatWinXP` extractions all verified behavior-preserving. Findings clustered
in the new item-C extraction guard, plus one ordering nuance in the item-N Misty
wiring. Five fixed:
1. **The extraction guard fell open on a DB error.** `expeditionCmdStart`'s
resumable-guard did `switch n, err := partySize(pending.ID)` and gated the
party-blocking refusal on `case err == nil && n > 1`. A transient roster-read
error skipped both cases, fell through, and started a new expedition on top of
the still-seated party — the exact orphaning the guard exists to prevent, in
the one situation (a DB hiccup on the roster read) where it matters. Now checks
`extractionLapsed` first (pure, no DB call on the reap path) and treats a
`partySize` error as "assume occupied → refuse". Also drops the wasted `COUNT`
the old switch-init ran on the lapsed-reap path.
2. **The start-path lapsed reap unseated members silently.** When a leader with a
lapsed party extraction ran `!expedition start`, the reap called
`completeExpedition(...Failed)` — which frees the roster — with no DM, unlike
the hourly sweeper and `!expedition abandon`, which both notify. Extracted
`reapLapsedExtraction(e)` (reap + notify the audience) as the single
reap-with-notify path; the sweeper and the start-path reap both route through
it, so a member is never silently unseated by whichever path reaches the row
first.
3. **Misty's crowd swung before the concentration pulse.** Item N's new
`stepRoundEnd` seat-loop ran `seatEndOfRound` (crowd revenge, which can end the
fight) *before* the pre-existing concentration-aura tick. Concentration is
turn-engine-only (no auto-resolve counterpart), so this was a within-engine
ordering call, not a cross-engine divergence — but a caster whose lingering
aura would kill the enemy that round could be dropped by crowd revenge first,
contradicting the concentration block's own "a lethal pulse settles the fight"
intent. Moved the Misty seat-loop to *after* the concentration tick: a lethal
pulse now wins via `finish(CombatStatusWon)` before the end-of-round crowd
swing; on any round the pulse doesn't end the fight, both procs fire exactly as
before. (Owner-approved reorder — it was reported as a balance call and the
answer was "move it".)
4. **Fourth copy of the event-log scan.** `seatCombatResult` hand-rolled a
`misty_heal` scan loop. Promoted the test-only `hasAction(events, action)`
helper to production (`combat_session_build.go`), used it there, and removed the
duplicate test definition.
5. **New `dnd_`-prefixed file.** Renamed `dnd_expedition_extract_sweep_test.go`
`expedition_extract_sweep_test.go`, per the no-new-`dnd_`-names rule (the diff's
three other new test files already avoided the prefix).
**Reported, not fixed** (deliberate):
- **Misty ordering** was the only correctness-shaped finding; the other angles
agreed the stack was clean. Two lower items were left: `dnd_setup.go`'s
respec/wipe disbands an extracted party with no member DM — but the disband is
intended (item C hole #3) and the missing DM is outside the reviewed lines; and
`abandonExpedition` re-queries the row the caller already resolved — the
divergence a finder posited isn't reachable (the extracted-row branch only runs
when no active row exists), so the extra indexed SELECT was left alone.
Full plugin suite green before and after. No auto-resolve path touched, so the
class-balance corpus and `combat_characterization.golden` do not move.
---
## Deferred — real, not fixed
### ~~M. Mage subclass spell hooks never run in turn-based combat~~ — TWO-THIRDS MIS-STATED. Fixed 2026-07-10.
**The premise was wrong.** `applyMageSubclassSpellHooks` has three non-test
callers, not one. `resolveTurnSpell` (`dnd_spell_combat.go:341`) has called it
since Phase 13 shipped per-round casting (`5cd343a`, 2026-05-14).
So on the turn-based surface:
- **Empowered Evocation** (L7, +INT to evocation damage) — *always worked.*
- **Evocation Overchannel** (L10, ×1.5 on a 1st5th-level slot) — *always worked.*
Both only move `mods.SpellPreDamage`, which `resolveTurnSpell` returns as
`out.EnemyDamage`. Nothing was lost.
**Grim Harvest was the only real defect**, and its cause was narrower than "the
hooks don't run": the hooks ran, wrote `mods.GrimHarvestSlot` — and
`resolveTurnSpell` dropped that local `CombatModifiers` on the floor, because
`turnSpellOutcome` had no field to carry it out. A Necromancy Mage who killed
with a spell in a manual fight never healed.
The stash cannot ride on fight-start mods the way it does in auto-resolve: the
spell is cast *mid*-fight, and the turn engine rebuilds its combatants every
round. So it rides where every other mid-fight fact rides — the casting seat's
`ActorStatuses`:
1. `turnSpellOutcome` gained `GrimHarvestSlot` / `GrimHarvestNecrotic`.
2. `castActionForSeat` parks them on `actorStatusesPtr(seat)` when a cast lands.
`snapshotActor` starts from the prior snapshot, so they survive `commit()`
untouched, exactly as `ArmedAbility` does.
3. `seatFightStartMods` reads them back at close-out, in place of the comment
that used to explain why they were permanently zero.
**And the killing-blow check was wrong for this surface.** `grimHarvestHeal`
scanned for the **first** `spell_cast` event and refused the heal if the enemy
survived it. Auto-resolve casts once, pre-combat, so first == last there; a
turn-based mage casts every round, so an opening cantrip that left the enemy
standing vetoed the heal the round-3 killing spell had earned. It now scans for
the **last** `spell_cast` — provably identical on the auto-resolve path, which is
why the golden corpus does not move.
Each damaging cast overwrites the stash, so it always describes the seat's most
recent landed spell — the only one that can have been lethal. A miss stashes
nothing, and a stale stash is inert because the last `spell_cast` event then
shows the enemy alive.
**Balance note.** This is a caster buff on the manual surface — 2×/3×/4× slot
level in HP, once, on a spell kill, for L5+ Necromancy Mages. It is the buff the
subclass was written to have and auto-resolve already paid out. Consistent with
the standing "lift trailers" stance. The class-balance corpus measures
auto-resolve and is unmoved.
New tests: `internal/plugin/combat_grim_harvest_turn_test.go` (7 cases) — the
stash surviving `resolveTurnSpell`, nobody-else-stashes, last-cast-not-first,
weapon-kill-after-a-spell, the `snapshotActor` round-trip, the end-to-end heal at
`postCombatBookkeepingForSeat`, and per-seat isolation in a party fight.
### N. The NPC procs do not exist in the turn engine (RE-VERIFIED 2026-07-10 — conclusion holds, evidence corrected, scope wider) — the debuff exploit fixed 2026-07-10.
**Verified before planning**, after items C and M both turned out to rest on a
bad "only one caller" claim. This one survives, but two of its three sentences
did not.
**The `CrowdRevengeProc` exploit is now closed** (see the fix note at the end of
this item). Closing it pulled `MistyHealProc` in with it: both are round-end
effects, and the honest hook runs the pair rather than the debuff alone — a
one-sided hook would have been a second parallel sibling of the kind item D
warns about. So Misty's **heal** is now live on the turn path too. That is a
player-favourable discovery mechanic and fits the "lift trailers" stance, so it
ships together; only `SniperKillProc` stays turn-dead, because it is a
pre-combat one-shot with no round-end seam to hang on.
**Correction 1 — the location was wrong.** `MistyHealProc` is not read inside
`simulatePartyWithRNG`. It is read at `combat_engine_party.go:520` inside
`endOfRoundForSeat`, a *helper*, which is exactly the shape that made C and M
wrong. So the question had to be asked properly: `endOfRoundForSeat` has one
caller, `simulatePartyRound` (`:418`), which is the auto-resolve round. The turn
engine runs its own `stepRoundEnd` and never calls it. Conclusion stands.
`SniperKillProc` (`:102`, genuinely in `simulatePartyWithRNG`, a pre-combat
one-shot) has no turn-engine counterpart either.
**Correction 2 — the procs are built, then ignored.** `buildZoneCombatants` calls
`DerivePlayerStats` (`combat_session_build.go:61`), which sets
`mods.MistyHealProc` / `mods.SniperKillProc` (`combat_stats.go:180,184`) on every
turn-based combatant. The mods are present on the struct every single round. No
turn-engine code path reads either field. It is a dangling write, not a missing
build.
**Correction 3 — there is a third proc, and it cuts the other way.**
`CrowdRevengeProc` (`:473`) sits in the same `endOfRoundForSeat` and is equally
absent from the turn engine. That one is the Misty **debuff**. So the asymmetry
is not "manual fights are worse": a buffed player loses their buff by fighting
manually, and a debuffed player *escapes their debuff* by doing the same. The
debuff half is the one worth caring about, because it is exploitable and needs no
discovery to exploit — just fight everything with `!attack`.
**Not gaps** — checked and explained, so nobody re-files them:
- `PetAttackProc` and `SpiritWeaponProc` *are* reimplemented in the turn engine
(`petStrike`, `spiritWeaponStrike`), though they fire after a player action
rather than at round end.
- `EnvironmentProc` is absent because `turnCombatPhase` is a single flat "Duel"
phase with the proc at 0 — deliberate, not dropped.
- `HealItem`'s auto-heal never fires, but the turn engine seeds and snapshots
`HealChargesLeft` and gives the player `!consume` instead. Player-driven by
design, not a leak. (Worth confirming the charges aren't double-counted across
the two surfaces.)
**The achievement claim held when written; the fix moved it.** At the time,
`combat_sniper_kill` and `combat_misty_clutch` were both unreachable from a
manual kill and `seatCombatResult` hardcoded both flags false — so "six
achievements is really four" was correct. Wiring the heal changed that:
`seatCombatResult` now reads `MistyHealed` back off the `misty_heal` event on the
log (the same way `combat_pet_save` has always been detected), so
**`combat_misty_clutch` is reachable turn-based now** — it is five, not four.
`combat_sniper_kill` stays unreachable. The other four were always reachable:
`combat_near_death` (computed by `seatCombatResult`), `combat_pet_save` (the turn
engine does call `resolveEnemyAttack`, which emits `pet_whiff`),
`combat_death_save` (`trySave` in `stepRoundEnd`), and `combat_consumable_used`
(`combat_cmd.go:773`).
(Keep this internal. Per project convention these procs are hidden discovery
mechanics and must not surface in help text, player docs, or DMs.)
**Fix (2026-07-10).** Both Misty procs were hoisted out of `endOfRoundForSeat`
into shared helpers — `mistyCrowdRevenge` and `mistyHeal` — and a `seatEndOfRound`
hook that runs the pair in the order the auto-resolve engine does (crowd first,
then, if the seat survived, the heal). `endOfRoundForSeat` now calls the helpers;
the turn engine's `stepRoundEnd` calls `seatEndOfRound` per seat, after the
poison tick so a heal can answer the round's damage. Both helpers short-circuit
before touching the RNG when their proc is unarmed, so a character with no Misty
history draws exactly the dice it drew before — the sim corpus and
`combat_characterization.golden` do not move (full suite confirms).
`renderAllySeatEvent` gained a `crowd_revenge` case: an ally sees the damage land
(silence would read as HP vanishing) but not Misty's name, matching the
neighbouring `misty_heal`'s neutral "recovers N" — the grudge stays the owner's
own discovery.
New tests: `combat_misty_turn_test.go` (6 cases) — the crowd landing on the
manual surface (the exploit), a lethal crowd ending a solo fight, the heal firing
and capping, no heal for a seat the crowd just dropped, the no-RNG-when-unarmed
property that protects the golden file, and an end-to-end wiring test through
`advanceCombatSession` that reports `PlayerHP=40` (the shipped exploit) if the
`stepRoundEnd` call is removed.
### O. `trySimAutoArm` used to re-arm every round — sim baselines will move (NEW)
Before item A's fix, `trySimAutoArm` lived inside `buildZoneCombatants`. On the
turn-based path that meant: every rebuild re-armed the class-default ability,
**spent another point of the resource**, and re-applied it. A simulated Fighter
got a fresh Second Wind heal every single round until stamina ran dry; a Cleric,
a Healing Word.
`expedition-sim`'s `autoDriveCombat` drives `handleAttackCmd`, i.e. the
turn-based path — so this inflated every turn-based sim result. It now arms once
per fight, which is what `simAutoArmEnabled`'s own doc comment says it models
("a competent real player who would `!arm` Second Wind before each fight").
**Any expedition-sim corpus that involved turn-based combat is stale.** The
pending final L10/L12 re-baseline should be run after this change, not before.
The pure auto-resolve corpus (`SimulateCombat` / `simulateParty`) is unaffected —
that path always armed exactly once.
### ~~C. Members and leaders disagree about an expedition during `extracting`~~ — MIS-STATED; the real bug was worse. Fixed 2026-07-10.
**The premise was wrong.** `getActiveExpedition` — the *leader's* lookup — also
filters `status = 'active'`. During `extracting` the leader gets "no active
expedition" from `!expedition`, `!map`, `!camp` &c. exactly as the member does.
They already agree, and honestly so: nobody is standing in the dungeon. Widening
`expeditionForMember` would have made the member the only player who can see a
paused expedition. Not done, and it should not be.
Reading for it surfaced the real defect underneath.
**The seven-day resume window was never enforced.** `releaseParty` fires only on
a terminal status, and `dnd_expedition.go:428` justifies skipping it for
`extracting` by asserting "the roster is cleared when the resume window lapses
and the row flips to `failed`". Nothing did that. The only `extracting → failed`
transition in the tree was inside `handleResumeCmd` — a lazy expiry that runs
only when **the leader personally types `!resume`**.
So a leader who quit, forgot, or simply started a different expedition left the
row `extracting` **forever**. `releaseParty` never ran, every member stayed
seated, and `assertNotAdventuring` kept refusing them a run of their own. Fix #2
gave them `!expedition leave` as an escape, which is why this was a permanent
nag rather than a permanent soft-lock — but a player should not have to find it.
Three holes, all closed:
1. **No sweeper.** `sweepLapsedExtractions` (`dnd_expedition_extract.go`) reaps
every `extracting` row past `completed_at + 7d` through `completeExpedition`,
which releases the roster, and DMs the audience. Hourly ticker
(`expeditionExtractionSweepTicker`), plus a one-shot at `Start()` — a lapse
that happened while the bot was down is blocking those members *now*. The
audience is read before the close-out, since `completeExpedition` disbands the
roster `expeditionAudience` reads. `handleResumeCmd`'s lazy expiry stays, now
sharing the `extractionLapsed` predicate (which treats a NULL `completed_at`
as lapsed — it is the only clock the window has).
2. **The leader could orphan their own party.** `!expedition start` checked only
`getActiveExpedition`, so a leader could start fresh on top of an extracted
row. `!resume` resolves the *newest* `extracting` row, so the old one became
unreachable — un-resumable, un-abandonable, roster still held. It now refuses,
but only when that row has a roster (`partySize > 1`); a solo extraction
strands nobody, so walking away from one stays a normal thing to do. A lapsed
row is reaped in place and the start proceeds.
3. **The leader had no way out but to pay.** `expeditionCmdAbandon` resolved
through `activeExpeditionFor` and so could not see the extracted row it owns —
the leader had to buy a `!resume` just to abandon. Both it and
`abandonExpedition` now span `extracting` via `ownedLiveExpedition`, which
sorts active rows first so `expeditionCmdStart`'s run-spawn rollback still
tears down the row it just created rather than an older extracted one. This
also fixes `dnd_setup.go:394,449` for free: a leader who rerolled their
character used to strand their whole party.
Abandoning now DMs the members too, and says the true thing when the party is
standing in town rather than in the dungeon (supplies already spent, loot
already banked — not "supplies are forfeit").
New tests: `dnd_expedition_extract_sweep_test.go` (6 cases) — the sweep releasing
a stranded roster, leaving a live extraction alone, the NULL-`completed_at`
predicate, abandon reaching an extracted row and freeing the party, abandon
preferring the active row, and the no-live-row error.
Not covered: the two `expeditionCmdStart` / `expeditionCmdAbandon` refusals,
which sit above the DM boundary. Still the `dmSink` gap.
### ~~D. Two turn-based win close-outs must be kept in sync by hand~~ — effect-drift closed 2026-07-10.
`finishPartyWin` (`combat_party_finish.go`) was a hand-copied sibling of the
`CombatStatusWon` block in `finishCombatSession` (`combat_cmd.go`): mood
scan, kill record, threat, boss threat, XP + near-death, loot, TwinBee line,
continue hint. `finishPartyCombatSession` routes solo back to
`finishCombatSession`, so the two must stay equivalent forever, with nothing
forcing it. Item A was the first drift.
Item A's fix already pulled the *bookkeeping* out of both into
`postCombatBookkeepingForSeat`. This pass hoists the remaining duplicated
**effects** into three shared helpers in `combat_party_finish.go`, and routes
both close-outs through them:
- `applyOwnerWinEffects(owner, enemyID, elite) bool` — the zone-kill record,
room threat, and boss-defeat threat drop; returns `bossOnExpedition`. Fires
once, owner-scoped (a member owns neither row).
- `grantSeatWinXP(uid, hp, hpMax, monster, tier)` — near-death calc + XP grant.
Per seat (solo calls it once with seat 0).
- `endRunOnLoss(owner, runID, death)` — mood event (death only) + `abandonZoneRun`
+ `forceExtractExpeditionForRunLoss`, shared by both the Lost and Fled paths.
What deliberately stays divergent, so this was **not** a full `closeOutSeat`
merge: the player-facing text genuinely differs (solo "You finished at N/M HP"
vs party "The party stands"; the party's 4-way boss/hint split vs the solo
2-way), and the death-on-win rule is roster-size-dependent by design — solo
`finishCombatSession` never marks a winner dead, `finishPartyWin` marks a seat
that won from 0 HP dead. That is item E and must not be unified. So the effect
lists are now a single copy each; the text is not.
Full plugin suite green; no behavior change (helpers are extract-and-call).
Note this is the *only* place the party work chose a parallel sibling. Elsewhere
the branch is a genuine N-body generalization, not a bolt-on: `SimulateCombat`
`simulatePartyWithRNG`, `runCombatRound``runPartyCombatRound`, `runZoneCombat`
`runZoneCombatRoster`, `RenderTurnRound``RenderPartyTurnRound`.
### E. Death-on-win depends on roster size — intentional, leave it
`closeOutZoneWin` marks a downed seat dead on a win only when `len(seated) > 1`.
A solo player who wins at 0 HP (retaliate aura kills the swinger on the killing
blow) survives; a party member in the same spot dies.
This is **deliberate and documented in place** (`zone_combat_party.go:183-188`):
unifying it would move the sim's outcome labels and force a re-baseline of
`testdata/combat_characterization.golden`. Not a defect. Listed so the next
person to find it doesn't "fix" it by accident.
### F. `grantAutorunGrace` is a stopgap (already tracked as R5)
`zone_revisit.go:172` stamps `last_autorun_at` to buy one `autoRunCooldown`
window after a `!revisit`, because the autopilot ticker has no concept of
position-vs-frontier and will otherwise march the party back out of the room they
just backtracked to. The code says so itself and defers the real fix to R5. It is
coupled to the exact value of `autoRunCooldown` and to the ticker cadence.
### G. Party combat multiplies the known per-turn DB chatter
Already tracked as `project_combat_session_cache_deferred`, now worse:
- `partyCombatantsForSession` (`combat_session_build.go:137`) rebuilds every seat
from scratch on every `!attack`/`!cast`/`!consume` (~68 SELECTs per seat).
A 3-member, 5-round fight ≈ 270360 SELECTs.
- `rebuildRoster` (`combat_cmd.go:620`) then rebuilds *all* seats again after a
mid-fight buff, when only the casting seat changed.
- `runZoneCombatRoster` (`zone_combat_party.go:73`) loads char + equipment, then
`buildZoneCombatants` loads both again and discards the first pair — once per
seat per room, on the path that fights every room.
- `nudgeStalledPartyTurns` (`combat_party_turn.go:281`) does two session reads
plus a full N-seat rebuild per stalled fight, serially, on the 1-minute ticker,
while holding the fight lock.
- The enemy stat block is rebuilt once per seat and kept only for seat 0 (the
code comments admit it). CPU-only, but pure waste.
Cheapest real fix: memoize the built roster for the fight's lifetime. Mid-fight
buffs are already tracked as `ActorStatuses` deltas and re-applied by
`applySessionBuffs`, so stats are reproducible without re-reading the DB.
### ~~H. Guard probes re-query per message~~ — MEASURED, DECLINED 2026-07-10.
`isPartyMember``activeExpeditionFor` is up to 2 queries; `activeZoneRunFor`
adds more. A solo player with nothing in flight costs 3 queries to conclude "not
in a party". The original write-up said this was "repeated by each guard a
handler consults" and proposed resolving seat/expedition/run once per message
into `MessageContext` and threading it.
**The premise was overstated, and the fix is disproportionate. Not done.**
- **No per-message guard cascade exists.** `OnMessage` dispatch is strictly
one-handler-per-command (`if IsCommand(...) return handle(...)`). Nothing runs
these resolvers for *every* message; the redundancy is intra-handler only.
- **The one hot inbound path is already optimized.** `zoneCmdAdvance` /
`expeditionCmdRun` call `isPartyMember` directly *by design* — the code comment
spells out that this avoids re-resolving the run (which `advanceOnce` is about
to resolve) and double-firing the §4.3 idle reap. The authors already closed the
hot case.
- **The remaining 23× callers are cold.** A per-function scan found ≥2 resolver
calls only in `zoneCmdEnter`, `zoneCmdAbandon`, `expeditionCmdStart`, the status
impls (all one-shot commands), and `runAutopilotWalk` (5×, but it is the
*background* ticker path, not an inbound message). In those the calls are
typically a guard-check on one branch then a resolve on another — not the same
read twice.
- **The proposed fix has a real regression surface.** `MessageContext` is a
shared cross-plugin value type (movies, vibe, miniflux, …); the resolvers are
free functions used at 60+ sites. Threading a resolved snapshot means changing
the shared type and every call site, and carrying a memo that goes stale the
moment a handler writes the row mid-flight — the exact staleness-bug class items
A/M/N just fixed. All to save a few cheap indexed local SQLite reads on a
low-volume bot.
Cost/benefit is upside-down: high churn + a staleness regression surface for a
negligible perf gain on paths that are mostly already optimal. Left as-is
deliberately. Revisit only if a profiler ever shows these reads on a hot path.
### ~~I. `parseTemperIndex` is the third copy of the same menu parser~~ — Fixed 2026-07-10.
`adventure_temper.go` duplicated the "read leading digits, 1-indexed →
0-indexed, bounds-check" reply parser already inlined in `resolveMagicEquipReply`
(`magic_items_gameplay.go`) and `handleMasterworkEquipReply`
(`adventure_masterwork.go`). Promoted the temper copy to `parseMenuIndex(reply, n)`
and routed the other two through it.
Behaviour-preserving on all three: the old `parsed` flag was redundant with the
`idx < 0` guard (no leading digit leaves `idx=0`, then `idx--` → -1, rejected
either way), so no input changes verdict. The **retry disagreement the doc
flagged was deliberately left as-is** — this was a pure dedup, so magic-items and
temper still re-store the pending interaction on a bad parse and masterwork still
doesn't. Unifying that is a behaviour change and belongs in its own pass if
wanted. Full plugin suite green; the existing `parseMenuIndex` table test moved
with the rename.
### J. `!sell` fires the event anchor even when nothing sold (PLAUSIBLE)
`adventure.go:719` calls `maybeFireAnchoredEvent` unconditionally after
`handleSellCmd`, so `!sell nonexistentitem` can burn the player's one daily event
slot. Gating it needs `advSellItem`/`advSellAll` to return a `sold bool`.
**Probably leave it.** The anchor's stated design is presence-based — "a moment
the player is present and reading" — and a player who typed `!sell` and read the
reply *is* present. That matches the project's own rule that presence means any
chat, not a successful command. Flagged only so the choice is explicit.
### K. Confirm the A6 anchor set covers the player population — CONFIRMED REAL GAP 2026-07-10.
N1/A6 replaced the old "any daily-active player, any activity" mid-day event roll
with three presence anchors: the expedition Night digest, `!sell`, and arena
cashout. `tryTriggerEvent`'s `HasActedToday` gate was dropped with it.
**The code confirms the gap is real, not hypothetical.** The three, and only
three, `maybeFireAnchoredEvent` call sites are:
1. `expedition_autorun.go:312` — the end-of-day digest, and it is gated on
`campDecision.Night`. A Night camp only happens on a **multi-day** expedition;
a single-day dungeon/`!zone` run never reaches one.
2. `adventure.go:734` — after a `!sell` at Thom's.
3. `adventure_arena.go:633` — after an arena cashout.
So the excluded population is concrete: a player whose daily loop is
mining / foraging / fishing / babysit / single-day dungeon, who never sells at
Thom's, never enters the arena, and never runs a multi-day expedition, receives
**zero** mid-day events — the roll never even happens for them. That is exactly
the core daily-grind loop, so this is plausibly a large fraction of players, not
an edge case.
**Fourth anchor drafted 2026-07-10 (uncommitted, pending owner review).** The
on-theme, farm-resistant home is a foreground **single-day `!zone` clear** — the
climax DM the dungeon/harvest-via-walk player is demonstrably reading, and one
they cannot spam (a clear costs a full walk). Implemented as:
- `advEventChanceZoneClear = 0.08` in `adventure_events.go` (matches the digest;
it is the day's climax moment, and it is the single tuning knob).
- `advanceResult.zoneCleared` set true only in the *full*-clear branch of
`advanceOnceWithOpts` (not a mid-zone region clear, which continues the run and
would fire per region).
- Rolled in `zoneCmdAdvance` after the clear DM streams, via the unchanged
`maybeFireAnchoredEvent`. That is the foreground path only — autopilot
(`runAutopilotWalk`) and party members (refused earlier by `isPartyMember`)
never reach it. The per-day slot guard still caps everyone at one event/day.
Rates (see `TestZoneClearAnchorRate`): a zone-clear-only grind player at ~2
clears/day lands ~1.08 events/week — the same band as the fully-engaged
digest+sell+arena player (~1.19/week, unchanged). The rare all-four-in-one-day
player peaks at a ~1.65/week *probability* ceiling but still gets at most one
actual event/day.
**Two knobs remain the owner's call**, hence "pending review": the 0.08 chance,
and whether a single-day zone clear is even the right anchor for the grind loop
vs. `!resources` (rejected here as farmable — repeated `!resources` would just
re-roll the same daily slot) or leaving the grind loop event-free by design.
Full plugin suite green; no existing behaviour moved (the three prior anchors and
`TestAnchoredEventWeeklyRate` are untouched).
### ~~L. Dead helper: `openParty`~~ — Verified and removed 2026-07-10.
Confirmed dead: a repo-wide grep found `openParty` only in `expedition_party.go`
(the def) and `expedition_party_test.go`. The invite path goes `joinParty`
`seatLeader`, which runs the same `INSERT ... role 'leader' ON CONFLICT DO
NOTHING` but reads the owner off the expedition row instead of trusting a
passed-in id. Deleted `openParty`; the eleven test call sites now use a
`seatLeaderFixture(t, expID)` helper that wraps `seatLeader` in a transaction
(the owner comes from the `seedExpedition` row, which always matched the id the
old calls passed). `seatLeader`'s doc comment absorbed the idempotency note that
justified `openParty`. Full plugin suite green.
---
## Test gap worth closing — ~~the DM seam~~ CLOSED 2026-07-10
`SendDM` went straight through `Base` to a live client, with no fake/sink seam,
so the handler-level paths behind fixes #1, #2 and #5 could not be unit-tested.
That is why the party close-out bug survived a suite this thorough: every party
test is unit-level and stops below the DM boundary.
**Fixed 2026-07-10.** Added a `MessageSink` interface and a `Base.Sink` field
(`plugin.go`). When non-nil it captures every outbound message — both the DM
route (`SendDM`/`SendDMID`) and the room route (`SendMessage`/`SendMessageID`,
which is what the arena announcement actually uses) — and the live client is
never touched. Nil in production, so behaviour is unchanged; the whole
`internal/...` suite is green with the field added. One seam, no call-site churn:
the 765 `SendDM`/`replyDM`/`SendMessage` sites are all downstream of these four
methods.
New tests (`message_sink_test.go`): a `captureSink` test double + `installSink`
helper; `TestMessageSink_CapturesDMAndRoom` proving both routes divert with the
right target/text and that the `*ID` variants report back an id; and
`TestExpeditionCmdLeave_DMsBothEnds`, which drives the real fix-#2 handler end to
end and asserts both DMs land (member "turned back", leader notified) — a path
that was literally a silent no-op in a unit test before. `beginCombatTurn`'s
terminal path and the arena rollover announcement are now reachable the same way
(seam proven for both the DM and room routes); dedicated end-to-end tests for
those two remain easy follow-ups if wanted.
Added this pass: `TestPartyCasualtyLine_JoinsNamesLikeTheRestOfTheBot`,
and `TestPartySurvivors_SplitsOnHPNotOnOutcome` was rewritten as
`TestAnySurvivor_ReadsHPNotOutcome` when its helper was deleted.
Added in the second pass (`combat_armed_ability_test.go`): the close-out
bookkeeping is now reachable below the DM boundary via
`postCombatBookkeepingForSeat(sess, seat)`, which takes a plain `*CombatSession`
and needs no client — so item A's behaviour is directly unit-tested even though
`finishCombatSession` itself still isn't. (The `dmSink` seam that would have
covered `finishCombatSession` itself now exists — see above.)
Not covered by tests: item B's locking. `withExpeditionSupplies` is exercised
incidentally by the existing expedition suite, but nothing asserts the mutual
exclusion. A `t.Parallel` accept-vs-burn race test under `-race` would.

View File

@@ -0,0 +1,519 @@
# Combat engine — paying off the N-body debt
## Session 2026-07-11 (c) — §6 was never a balance problem
**IN FLIGHT: the verification sweep is still running on millenia** (`/tmp/s6_after.jsonl`,
10 classes × L10,L12 × 10 zones × n=25). Nothing below the diagnosis is measured yet.
### Committed
- `c9128fb` — §1's other half. Out-of-combat `!cast <heal> @friend`, scoped to the
people on your expedition. `--target` had been advertised by `!help` and swallowed
by the parser since SP2. Ally row is mutated with one guarded UPDATE in a
transaction (gifting's precedent), not a read-modify-write under a second lock:
two clerics healing each other would take their `advUserLock`s in opposite orders
and deadlock. Refunds the slot on every path that heals nobody.
- §6 auto-cast (`zone_combat_autocast.go`) — see below. Tested, unmeasured.
### The finding: cleric was not weak, it was not being played
Baseline re-run on HEAD (`sim_results/s6_baseline_l10l12.jsonl`, 5000 rows) confirmed
cleric still dead last: **46.4% vs fighter 81.4%**. §1's self-heal moved it ~0, as
predicted. But the *shape* of the failures is the whole story:
| class | cleared | **fled** | tpk |
|---|---|---|---|
| fighter | 407 | **0** | 93 |
| ranger | 395 | **0** | 105 |
| cleric | 232 | **167** | 101 |
**Cleric dies LESS than mage, sorcerer or warlock.** Its entire 35pp deficit is
*fleeing* — 167 of 500 runs end with the player alive and the expedition over.
Instrumenting the three `stopEnded` sites: **every one of those runs died at
`stopEnded via ROOM`** — an ordinary room fight. Not a patrol, not an elite, not the
boss.
**Root.** An ordinary room is `runZoneCombatRoster``SimulateCombat` on an 8-round
clock: one breath, **no turn engine, no action picker**. The only spell that could
ever land there was a `PendingCast` the player queued BY HAND with `!cast` before
walking in. On autopilot nobody queues one — so for every room of every expedition, a
caster fought with a weapon and nothing else. A cleric has no Extra Attack, a mace,
and its whole kit unusable. Measured on the room path at L10 (loss% by entry HP):
| entry HP | fighter | druid | bard | mage | sorcerer | **cleric** |
|---|---|---|---|---|---|---|
| 100% | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% |
| 70% | 0.0% | 0.0% | 0.0% | 0.0% | 1.0% | **1.5%** |
| 35% | 0.0% | 0.0% | 0.5% | 0.5% | 10.0% | **8.0%** |
Small per room — and a run is ~16 rooms, and **one loss ends the expedition**
("the fight drags on, X outlasts you, you retreat"). It compounds into 33% of runs.
**The tell.** `dnd_class_balance.go:431` — the harness the class corpus was tuned on
*always* hands a caster their best damage spell (`pickBestDamageSpell` +
`applyHarnessSpellCast`) before it simulates. So the numbers that say "cleric is a
weak class" modelled a cleric who casts; the live room modelled one who does not.
The corpus and the game disagreed about what a cleric is.
This is the same defect as everything else in this plan: **the action model is
narrower than the kit.** §1 (the picker never healed), §3 (the companion never
acted), Pete (`LoadDnDCharacter` → nil → `"attack"`), and now every caster in every
room. It is not a tuning dial and it must not be fixed with one.
### The change (unmeasured)
`autoCastForAutoResolve` — no queued spell + a real slot in hand → cast the best
damage spell, spend the slot, fold it in. Additive pre-damage, exactly as a
hand-queued `PendingCast` has always been, so it stays comparable to the corpus
rather than being a fresh mechanic.
- **Slot-aware, not free.** `pickBestDamageSpell` reads `slotsForClassLevel` — the
*theoretical* class table. Reusing it here would be an infinite spell: the same
"no row to persist onto, so it arrives fresh" bug that gave the companion an
unlimited body ([[project_companion_free_lunches]]). The new picker reads the
seat's actual remaining slots and `consumeSpellSlot`s.
- **Never upcasts.** The big slots are what the elite and the boss are for, and the
turn engine spends them there. A picker that nukes a goblin with a 5th-level slot
leaves the caster swinging a stick at the thing that matters.
- Cleric owns no damage spell at slot 2 or 4, so those stay unspendable in rooms —
correct, and pinned by a test.
- Both goldens byte-identical (they pin the engine; this changes its *inputs* at the
zone layer). Martials provably untouched.
### When the sweep lands
Read cleric's **fled** count, not just its clear rate — fled going to ~0 is the
thing this predicts. Expect all 6 casters to move; that is the deliberate
re-baseline. **Watch for overshoot on bard/druid** (already 6772%): they get the
same buff and were not the problem. If they overshoot, the lever is this picker, not
monster scaling ([[project_difficulty_target]]).
## Session 2026-07-11 (b) — the companion can cast, and three free lunches fell out
**Committed: `01c2cb2` (§1 — the seat-scoped spellbook).** Everything below it is
working-tree.
The revisit-Pete step ("Pete cannot cast") turned out to be four separate defects
stacked on each other. The unit tests were green for all four; only the sweep with
a **control arm** ever told the truth. Read [[feedback_unit_tests_miss_engine_bugs]]
and then read it again.
### The one that was asked for
Every spell lookup is keyed on a user id and answered by a `dnd_*` table. Pete has
rows in none of them, by design. So `pickAutoCombatActionForSeat`'s first line —
`LoadDnDCharacter(uid)` → nil → `return "attack"` — meant **a hired Cleric swung a
mace, every turn, forever**. Role-fill hands a lone martial a Cleric, so that was
the *common* case. Fixed with a seat-scoped spellbook (`combat_seat_spells.go`):
humans delegate to the DB verbatim (both goldens byte-identical), the companion is
answered from his synthetic sheet.
### The three that were not
Each of these was found by measuring, and each is the same mistake: *"he has no row
to persist onto, so he arrives fresh."*
1. **His spell slots refilled every fight.** I parked the ledger 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. Moved to
`expedition_party.companion_slots_used`, refreshed at camp.
*(Worth ~0pp on its own — an expedition only holds ~2 real fights, so the pool
never binds. Correct, but not the lever. I predicted this one would be the whole
answer and I was wrong.)*
2. **His body refilled every fight.** `combat_party_start.go` seated him at
`player.Stats.MaxHP` and the close-out skipped him with the comment *"he arrives
fresh next time"*. 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.** Moved to `expedition_party.companion_hp`; healed at camp.
3. **No autopiloted caster has ever healed itself.** `simPickAllyHeal` skipped
`i == seat` and bailed on `!IsParty()`. Between them: a solo cleric carries
`cure_wounds` for a whole run and dies with a full pool of level-1 slots. Now
`simPickHeal`: heal whoever is worst off, which is sometimes you.
**It is NOT the answer to §6, and I guessed that it would be.** It moved solo
66.1% → 66.2%: the picker reaches for a healing *consumable* first and the sim
stocks them, so a human almost never falls through to the spell. The corpus is
therefore undisturbed and no re-baseline is owed. Pete carries no consumables,
so it is his only heal — which is the reason to keep it.
### What the arms actually said
640 runs/arm, 10 classes × L10,L12 × 4 zones. "Like-for-like" = the leaders whose
role-fill gives Pete a **Cleric**, compared against a human **Cleric** follower.
| arm | like-for-like clear | vs solo |
|---|---|---|
| solo | 68.2% | — |
| + a human cleric follower (leader's level, geared) | 79.7% | +11.5pp |
| + Pete, HEAD (free heal, cannot cast) | 77.1% | +8.9pp |
| + Pete, casting + free heal | **94.8%** | +26.6pp — **carry** |
| + Pete, casting + carries his wounds | **69.5%** | +1.3pp — **useless** |
**The reference arm is the whole point.** Measured against *solo*, even mace-only
Pete looked like a carry (+8.9pp) — but parties are *designed* to be safer, so solo
is the wrong yardstick. Measured against **a human follower**, the real finding
appears: a gearless, level-penalized hireling was out-clearing a fully-geared human
cleric *of the leader's own level* by 15pp. A hireling must never beat a player.
His party fled 5 runs out of 640; the human party fled 56.
### §2(a) SHIPPED — and it landed in band
`055f07d`. Seats carry a **SeatWeight**; both levers (boss HP, enemy action economy)
scale on the summed weight of the **living** seats instead of a head count. The
weight is **level-based** — priced against the leader, times a hireling discount
(`companionSeatWeight = 0.65`, the one tunable).
**Not** a power score: HP×damage would rank a cleric below a fighter and quietly
make every mixed *human* party easier — a difficulty regression smuggled in under a
bug fix.
The safety argument is one property: **a peer weighs exactly 1.0.** The curves
interpolate between P8's tuned integer knots — (1, 1.0), (2, 2.4), 2n1 from 3 up —
so every integer input returns exactly what it always returned. Solo byte-identical,
a party of same-level humans byte-identical, both goldens unmoved. Only an *unequal*
roster lands between knots, which is the whole point. It also finishes §2(b): a
downed seat now buys the enemy nothing (§2(b) fixed only the head-count half — a
corpse still carried its full weight).
| | solo | + Pete | + human cleric peer |
|---|---|---|---|
| like-for-like clear | 69.0% | **76.8%** (+7.8pp) | 77.6% (+8.6pp) |
| band | solo | + Pete | lift |
|---|---|---|---|
| trailing (solo <40%) | 10.0% | 31.0% | **+21.0pp** |
| middle | 58.9% | 76.8% | +17.9pp |
| leading (solo ≥70%) | 93.5% | 99.2% | **+5.7pp** |
Help, never a carry — and he stays below a real human of the leader's level, which
is the invariant a hireling must never break.
### How it got there (the record of being wrong)
With all three free lunches gone, the same grid says (like-for-like subset):
| | solo | + human cleric peer | + Pete |
|---|---|---|---|
| clear | 69.0% | 77.6% (+8.6pp) | **66.1% (2.9pp)** |
**Hiring him is now worse than going alone** — and that is §2, unmasked. The free
full-heal had been paying his enemy-scaling bill for him. A below-median seat costs
the boss +15% HP and lifts the enemy's action economy from 1 to 2.4 attacks a round,
and he does not give that much back.
I talked myself out of §2(a) mid-session, on the grounds that discounting a weak
seat would enlarge the carry. That was right *about the buggy build* and wrong about
the engine: with the infinite body removed, the plan's original diagnosis is exactly
correct and the sweep now argues **for** §2(a).
I talked myself **out** of §2(a) mid-session, on the grounds that discounting a weak
seat would enlarge the carry. That was right about the *buggy build* and wrong about
the engine: the infinite body had been paying the seat's scaling bill for it. With
the free lunches gone, the plan's original diagnosis was exactly correct. **The
order mattered** — §2(a) done first, on top of the free full-heal, would have made
things worse and looked like it was working.
## Still open
- A dropped companion returns at 1 HP (`companionSeatHP`'s floor) rather than being
benched until camp. Deliberate: there is no companion-death rule and inventing one
inside a bug fix would have been a second feature.
- **§6 is still open, and self-heal was NOT it.** Solo cleric is 2834% against
fighter's 100% on this grid. Re-read it off the current corpus before tuning.
- `companionSeatWeight = 0.65` is the single tunable and was not swept — it landed
in band on the first value tried. If Pete ever needs to be weaker, raise it.
- Out-of-combat `!cast --target` (`dnd_cast.go`) is still self-only.
- **Deployable now.** Hiring him is a real, bounded help at every level.
> The party engine (N3) widened the *roster* from 1 to N but never widened the
> *action model*, the *scaling model*, or the *test net* to match. Everything
> below is a symptom of that one gap. Working-tree doc; do not commit
> (feedback_dont_commit_plan_mds).
## Status @ 2026-07-11 — §1§5 SHIPPED (uncommitted, unmeasured)
All five sections are implemented and the suite is green. Nothing is committed.
| § | What | State |
|---|------|-------|
| §5 | Party golden + seat attribution | ✅ `party_characterization.golden` (1938 lines, 9 scenarios × 5 seeds) + `TestPartyCharacterization_OneSeatIsStillSolo`; sim trace gets `simTraceEvent` (seat always emitted). Wire format left alone — `TestP5Fields_StayOffSoloRows` was right and I was wrong. |
| §3 | Engine-driven seats | ✅ `ActorStatuses.EngineDriven`, permanent, no command clears it. `beginCombatTurn` now REFUSES a command from an engine seat. `autoDriveCombat` calls `driveEngineSeat` instead of impersonating the seat. |
| §2(b) | Living-seat action economy | ✅ `enemyActionsThisRound` counts `livingActors(st)`, not `len(st.actors)`. **Party golden moved deliberately** (survivor ends 59 HP, was 51). Solo golden byte-identical. |
| §4 | One roster accessor | ✅ `expeditionParty()` / `partyHumans()` / `PartySeat{Kind}`. Cannot return an empty party for a solo run — that is the level-1-companion bug, pinned by `TestParty_SoloExpeditionStillHasAParty`. |
| §1 | Ally-targeted actions | ✅ `turnActionEffect.AllyHeal/AllySeat`; `!cast cure wounds @alex` **and** `--target @alex` (the flag `!help` advertised and `parseCombatCast` silently swallowed since SP2). `simPickAllyHeal` so autopiloted/engine healers use it. Will not raise the dead. |
**Both goldens hold. Solo is byte-identical throughout — the balance corpus is
intact.**
### Post-fix sweep (2026-07-11, n=750/arm on millenia)
| | clear |
|---|---|
| solo | 48.5% |
| solo + hired Pete | **63.9%** (+15.3pp) |
| solo *(the 2-human cells)* | 65.7% |
| \+ a human cleric follower | **87.7%** (+22.0pp) |
Pete went from **28pp (worse than nobody) to +15.3pp**, and the lift lands exactly
where the plan asked for it:
| band | n | mean lift |
|---|---|---|
| trailing cells (solo <40%) | 15 | **+28.0pp** |
| middle (4070%) | 3 | +5.3pp |
| leading cells (solo ≥70%) | 12 | **+2.0pp** |
That is "help, never a carry", measured rather than asserted: he rescues the
players who were drowning and barely moves the ones who were already fine. §2(a)
may now be unnecessary — the §2(b) fix appears to have been most of it.
**⚠️ READ THE NOISE FLOOR BEFORE TRUSTING ANY CELL.** The sim is not seeded per
cell. The same binary, same cell (bard/L10/feywild, n=25), three times:
**36% / 56% / 56%** — a 20pp spread from RNG alone.
- **Per-cell numbers at n=25 are noise.** Do not tune against them.
- Only the **n=750 aggregates** carry signal. The solo arm's 51.9% → 48.5% drift
across engine versions is inside that noise band (and the solo golden is
byte-identical, so solo combat provably did not change).
- Anything huge (the old fighter/L10/feywild 100% → 4%) is real; anything under
~20pp on a single cell is not.
- **Next sweep: raise `-runs` to 100+ per cell** if per-cell reads are needed.
### What is NOT done
- **Measurement.** The engine moved under every number in this doc. A full
re-sweep (solo / solo+Pete / 2-human-with-a-cleric) is in flight; every figure
quoted below predates §1§5 and is now **stale**.
- **§2(a)** — power-based enemy scaling. §2(b) only stopped corpses from buffing
the boss. A *weak* living seat still costs a full seat's worth of enemy scaling,
which is why hiring Pete could still hurt a strong leader.
- **§6** — solo cleric bump. Blocked on the re-baseline, deliberately.
- **Pete cannot cast.** `castActionForSeat` loads a sheet from the DB and he has
none by design, so a hired "Cleric" *still* cannot heal. §1 fixed human clerics;
the companion needs a synthetic spell pool (or to be restricted to martial
chassis). **This is the revisit-Pete step.**
- Out-of-combat `!cast --target` (`dnd_cast.go`) is still self-only. Combat is
where it mattered; that one can follow.
## Why this plan exists
The Pete companion (`!expedition hire`) was built in one session and worked on
every unit test while being, in the sweep, **worse than no companion at all**
(solo 65% clear → solo+Pete 33%). Chasing that found four defects. Only one of
them was Pete's. The rest were sitting in the engine, in prod, since N3:
| Symptom found via the companion | Who else it affects | Root |
|---|---|---|
| A hired Cleric can't heal anyone | **Every human cleric in every party** | §1 |
| A weak seat makes the party *worse* | Any under-levelled friend; every downed seat | §2 |
| The companion lost his turn after round 1 | Any engine-driven seat, incl. away-player autopilot | §3 |
| The companion was silently level 1 | Anything reading "the party" of a solo run | §4 |
| Nobody noticed any of it | — | §5 |
§5 is the one that matters most: **there is no party golden.** The
characterization golden (`combat_characterization.golden`, 7468 lines) runs
`simulateCombatWithRNG(player, enemy)` — one player, one enemy, every scenario.
Party combat has *no* pinned behaviour, so N3 shipped a healer class that cannot
heal and the corpus said nothing. Whatever else we do, that gets fixed.
**Sequencing:** §5 first (build the net), then §1§4 (change the engine while the
net is under us). Doing it the other way round means changing the most heavily
tuned code in the repo with no way to see what moved.
---
## §1 — Actions cannot target another seat
**The bug.** Every heal in the engine is self-scoped. `MistyHealProc`/
`MistyHealAmt` heal the actor. `HealItem`/`HealItemCharges` fire the actor's own
sub-50% trigger. `grep` for an ally-targeted action returns nothing; the only
seat-targeting that exists anywhere is `enemyTargetSeat` — which seat the
*monster* swings at. A party cleric is a cleric in chassis and passives only:
**they cannot put one hit point back on a friend.**
**Root.** `PlayerAction{Kind, Effect}` (`combat_turn_engine.go:51`) has no target
field. It was written when "the player" was unambiguous, and N3 never revisited
it — the roster got wider, the action stayed pointed at the same two things (me,
and the enemy).
**Change.**
- `PlayerAction` gains a target: `TargetSeat int` (1 = the enemy, the default,
which keeps every existing call site meaning exactly what it means today).
- `turnActionEffect` grows an ally-heal payload; `runPartyCombatRound` applies HP
deltas to `TargetSeat` rather than to the actor.
- `!cast <spell> @user` parses a target; solo ignores it.
- Cleric/Druid/Bard/Paladin get their heal spells actually wired to it.
- `pickAutoCombatActionForSeat` gains a heal-the-lowest-living-seat rule, so
autopiloted and companion healers behave like a competent player.
**This moves the golden.** It adds an action the picker can choose. Deliberate
re-baseline, after §5.
**Risk.** Touching `runPartyCombatRound` is touching the most tuned code we own.
Mitigated by §5's party golden plus the existing solo golden staying byte-identical
(no solo fight has a second seat to target).
---
## §2 — Enemy scaling counts seats, not strength
**The bug.** P8 scales the enemy's action economy to the roster, and
`partyEnemyHPScale` gives +15% HP for any roster ≥ 2. Both count **seats**. So a
seat contributes its full cost to the enemy the moment it sits down, regardless
of what it actually brings — and keeps contributing that cost **after it dies**.
Measured, in the same cell (fighter L10, feywild):
| | clear |
|---|---|
| solo | 100% |
| \+ Pete as Cleric (a chassis he can't play) | 32% |
| \+ Pete as Fighter (his best case) | 68% |
| \+ a second *human* | 100% |
A deliberately below-median body is a **net negative at every level** — the boss
gains more than the seat gives back. That is not a Pete property. It is true of
any under-levelled friend you invite, and of every seat that goes down early
(the corpse keeps inflating the boss for the survivors).
**Root.** The scaling model assumes every seat is a median player. Nothing
enforces that, and two shipped features (parties, then companions) violate it.
**Change — pick one, and it is a design call, not a mechanical one:**
- **(a) Scale on contributed power, not seat count.** Enemy HP/actions scale on
the roster's summed effective level relative to the leader's. Correct, and it
fixes the under-levelled-friend case too. Most work; needs its own sweep.
- **(b) Scale on *living* seats, re-derived per round.** Cheap, and kills the
dead-seat-still-inflates-the-boss bug on its own. Does not fix the
weak-seat case.
- **(c) Don't scale for engine-driven seats at all.** Cheapest; makes hiring
strictly positive. Risks "carry", and leaves the human-party half untouched.
Recommendation: **(b) now** (it is a straight bug — a dead man should not be
buffing the boss), then **(a)** as the real fix once §5 can see it.
---
## §3 — Engine-driven seats are a status flag, not a concept
**The bug.** A seat with nobody at the keyboard is modelled as
`ActorStatuses.Autopilot = true`. But `beginCombatTurn` clears that flag on any
command arriving from that seat — *"They typed, so they are here."* And
`autoDriveCombat` drives a party by **dispatching each seat's turn as a fake chat
command from that seat's Matrix ID**. So the autopilot driver's own move looked
like the player coming back, cleared the latch, and the seat went inert for the
rest of the fight.
That is what made the companion stand in fights doing nothing. The same shape is
live for away-player autopilot today, and it is only luck that a human eventually
types something and re-latches.
**Root.** There is no first-class notion of "this combatant is driven by the
engine". Ownership of a turn is inferred from a Matrix message, which is a
transport detail leaking into the combat engine.
**Change.**
- A seat is `DrivenBy: human | away | engine`, persisted, not a bool that any
command can clear. `engine` is permanent; `away` is what a keystroke clears.
- `autoDriveCombat` stops impersonating seats. It calls the seat driver directly
(`driveCompanionSeat` is the shape; generalize it to any non-human seat).
- Command handlers refuse any non-human seat outright, rather than half-working.
**Bonus.** This is the prerequisite for the deferred Phase-16 "Pete runs his own
expeditions", and for any future NPC ally.
---
## §4 — "the party" and "the roster" disagree
**The bug.** A solo expedition has **no `expedition_party` rows** (absence means
solo; the roster materializes on the first invite). So any code that answers
"who is in this party?" by reading the roster gets **nobody** for a solo player
— and a plausible-looking fallback silently takes over. That is exactly how the
companion got hired at **level 1** for every solo player: the one case the
feature exists for.
**Root.** Two representations of the same fact ("who is on this expedition") that
disagree in the solo case, with no single accessor. `partySize()` and
`partyMembers()` and `expeditionAudience()` and `expeditionSeats()` and
`fightRoster()` all answer slightly different questions and are easy to reach for
by the wrong name — I reached for the wrong one twice while building the
companion, once fixed by a test and once only by a 1500-run sweep.
**Change.** One accessor that always includes the owner —
`expeditionParty(expeditionID) []Member` with `Member.Kind = leader|member|companion`
— and every consumer derives its own view from it (mail excludes companions,
seats include them, supply burn counts mouths). Delete the ad-hoc set.
---
## §5 — There is no party golden (do this first)
**The bug.** `TestCombatCharacterization` pins *solo* combat, exhaustively (57
scenarios × seeds). Party combat has nothing. The entire N3 engine — initiative,
seat order, enemy targeting, action economy, close-out — is unpinned. That is why
a healer class that cannot heal shipped without a single test going red, and why
the companion's collapse was invisible until a 1500-run sweep.
**Change.**
- `TestPartyCharacterization` + `party_characterization.golden`: N-seat rosters
(2 and 3), mixed classes, downed seats, an engine-driven seat, seeded and
byte-pinned exactly like the solo one.
- A **balance regression sweep** in CI-able form: the `expedition-sim` matrix at
low n, asserting clear-rates stay inside a band. The sweep is what caught all
of this; it should not have to be run by hand.
- Fix event seat attribution: `CombatEvent.Seat` is `omitempty`, so seat 0 and
"no seat" are indistinguishable in a trace — which cost real time during this
investigation, sending me after a phantom "Pete never swings" bug that was
partly a reporting artifact.
---
## §6 — Clerics are weak in solo (raised 2026-07-11)
Separate from the party gap, and real: the class corpus has cleric in the 4656%
band against fighter's ~82% ([[project_d8prereq_baseline]]).
**Do not tune this yet, and specifically do not tune it in isolation.** Two
reasons:
1. §1 *is* the cleric fix in party play — an ally-targeted heal is the class's
entire identity, and today they cannot use it on anybody. Whatever solo gap
remains should be measured **after** that lands, or we buff them twice.
2. `d8prereq_corpus` predates parties entirely and the party engine has moved
twice today (§2b, and §1 next). It is a stale baseline. **Re-baseline first**,
then read the cleric gap off the new corpus.
Then, if solo cleric is still short: lift the trailer, per the standing rule
([[project_difficulty_target]]) — do not nerf the martial leaders and do not touch
monster scaling.
## Ordering
1. **§5** — build the net. Party golden + seat attribution. No behaviour change.
2. **§3** — engine-driven seats. Contained, no balance impact, unblocks the rest.
3. **§2(b)** — living-seat scaling. Straight bug fix; party golden will move once,
deliberately.
4. **§4** — unify the roster accessor. Mechanical, guarded by §5.
5. **§1** — ally-targeted actions + heals. The big one. Moves both goldens;
re-baseline the class corpus after ([[project_d8prereq_baseline]] predates
parties entirely and is due anyway).
6. **§2(a)** — power-based scaling, then re-sweep the companion and re-decide his
below-median premise. It may not need to survive: once the boss stops
overcharging for him, "below median" may be exactly right.
## Meanwhile: the companion
He is **fixed and net-positive where he is meant to be** (+15.7pp in trailing
cells, ~neutral overall), but he still regresses strong solo leaders (§2) and
role-fill still hands martials a Cleric who cannot heal (§1). Two options until
this plan lands:
- **Ship him martial-only** (he plays chassis he can actually use — measured
32% → 68%), and let §1 restore the healer fill later. Honest, cheap, no engine
change.
- **Hold him** until §1/§2 land, and announce the rest of Adventure without him.
Do NOT bandaid by hand-tuning his stats until he "looks right" — his numbers are
not the problem, and tuning them would bake the engine's bugs into his stat block.

1547
gogobee_engagement_plan.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,280 @@
# Long Expeditions — multi-session plan
> Goal: dungeon expeditions become real multi-day journeys (T1 ≈ 2 days → T5 ≈ 7 days). Player picks **camp supplies** at launch, then watches it play out. Camp pitch/break, elite engagement, and **boss engagement** all become autonomous. Foreground `!camp` / `!fight` survive as overrides, not the expected path.
## Status @ 2026-05-28
**Core track (D1D7) — DONE.** All phases shipped; v1 of the long-expedition mechanics is live on `long-expeditions-d1`.
**§6 decisions (2026-05-28) re-opened two threads** that ride after D8:
- **D9** — T1T3 room-count bump toward the 35× target band (D1 landed T1T3 at ~2×). Sim-first: read T1/T2 day-counts from a fresh corpus *after* D8 lands.
- **D10** — T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone). Bundle with D9 to avoid touching graphs twice.
**Active work — §8 (J3 caster sim-picker):** D8-a / D8-b / D8-prereq / D8-d-diagnostic shipped; **D8-c (concentration), D8-d-fix (AC floor lift), D8-e (martial regression triage)** queued. D8 is sequentially the gate before D9/D10 — room counts only make sense once the sim accurately scores the classes that will walk through them.
**Sequence:** D8-c → D8-d-fix → D8-e → D9 → D10. Re-baseline once at the end.
## 1. Why the current shape is "too short"
| Tier | Zones | Rooms | Real-time exit |
|------|-------|------|----------------|
| T1 | Goblin Warrens, Crypt Valdris | 67 | usually <1 calendar day |
| T2 | Forest Shadows, Sunken Temple | 78 | usually 1 day |
| T3 | Manor Blackspire, Underforge | 79 | 12 days |
| T4 | Underdark, Feywild Crossing | 810 (×regions) | 23 days |
| T5 | Dragon's Lair, Abyss Portal | 910 (×regions) | 24 days |
(Room counts from `internal/plugin/dnd_zone.go:70-134`; tier zones from memory `project_expedition_difficulty`.)
Because the autopilot walks ~3 rooms / 2h (`expedition_autorun.go:43,53`) and boss/elite stops are manual, a player who pays attention clears T1T3 in an evening. Briefing/recap UTC ticks fire but rarely matter — the expedition is over before "day 2" ever lands.
## 2. Target shape
| Tier | Target duration | New room budget (single-region) | Notes |
|------|-----------------|---------------------------------|-------|
| T1 | 2 days | 1214 | one autopilot-camp |
| T2 | 3 days | 1620 | two camps |
| T3 | 4 days | 2226 | three camps; threat starts to bite |
| T4 | 56 days | 2834 (split across 34 regions) | base camp emerges |
| T5 | 7 days | 3644 (split across 34 regions) | full siege/temporal pressure |
(Numbers are first-draft anchors; class-balance sim drives the actual dial in D7.)
Duration is measured in **expedition-days advanced by autopilot camp**, not real-time UTC days. Each autopilot night-camp = day++. Real-time UTC still drives the morning briefing DM cadence, but day-count and supply burn become event-driven (camp = sleep = day++ = burn).
The autopilot decides:
- when to camp (and which camp type)
- whether to engage elites and bosses, or to camp first and retry
- whether to extract early on starvation/HP collapse
The player decides:
- supply pack purchases at launch (only meaningful pre-expedition choice)
- whether to override (`!camp <kind>`, `!fight`, `!expedition extract`)
## 3. Phasing
Built across multiple sessions; each phase ships independently with tests and a small DM-side feedback loop.
### D1 — Length + room-count rework
**Files:** `dnd_zone.go` (per-zone Min/MaxRooms), `dnd_expedition_region.go` (multi-region region sizes), `dnd_zone_run.go:generateRoomSequence` (still terminates Entry → … → Elite → Boss, but with more Explorations).
**Work:**
- Re-pitch Min/MaxRooms per zone to the table in §2.
- Multi-region zones: extend per-region room budgets so the total stays in band when summed.
- Keep the fixed Entry / Trap / Elite / Boss anchors; only Exploration count scales.
- Consider 2 traps + 2 elites for T4T5 to break up the long middle (deferred decision — see §6).
- Update existing zone graphs in `zone_graph_*.go` for the new lengths. *Auditing whether each zone has a hand-tuned graph or relies on the linear compiler is part of D1 scoping.*
**Exit criteria:** `expedition-sim` shows mean-room-cleared per zone in the new band; no test fixtures hard-code old counts.
### D2 — Autopilot camp scheduler
**D2-a (shipped 2026-05-27):** new `expedition_autocamp.go` with pure `decideAutopilotCamp` + `pitchAutopilotCamp` + dwell-window lifecycle (`shouldSkipAutoRunForCamp`, `breakAutoCampIfDue`). Wired into `tryAutoRun`: skip while inside `minAutoCampDwell` (4h), break + walk past it, post-walk scheduler pitches HP-low rest camps and region-boss base-camp waypoints. CampState gained `AutoPitched` so auto- vs player-pitched lifetimes diverge. Day rollover is still UTC-anchored; D2-b moves it event-anchored.
**D2-b (shipped 2026-05-27):** event-anchored day rollover. `dnd_expedition_cycle.go` adds `eventAnchoredCutoff` + `isEventAnchored`, splits the briefing body into `nightRolloverBurn` + `nightRolloverDrift` (+ a convenience `processNightCamp` that runs both back-to-back). For expeditions started ≥ cutoff, `deliverBriefing` skips mutators and posts a re-engagement DM; if `last_briefing_at` is older than `nightSafetyNet` (28h) it force-fires `processNightCamp` so a stalled autopilot doesn't freeze the expedition. The autopilot scheduler grew a `Night` flag — `decideAutopilotCamp` sets it when `time.Since(LastBriefingAt) ≥ nightCampWindow` (16h); `pitchAutopilotCamp` then runs the burn → camp → rest → drift sequence in one pitch. Manual `!camp <type>` runs the same flow when it's the first camp since the last rollover. Legacy UTC-anchored expeditions (start_date < cutoff) keep the original mutator flow via the same staged helpers, preserving rest-before-drift ordering through `processOvernightCamp`. Tests fence cutoff to year 9999 in `TestMain` so existing legacy assertions stand; new tests exercise the night decision, night-pitch rollover, event-anchored skip, and safety-net force.
**Files:** new `expedition_autocamp.go`; hooks in `expedition_autorun.go:tryAutoRun`; reuse `dnd_expedition_camp.go:campPitch` / `applyCampRest`.
**Heuristics (in priority order):**
1. **Day-budget pacing** — pitch when rooms-walked-this-day ≥ tier-specific day-room target. Standard if room cleared, rough if not.
2. **Resource gates** — pitch if HP < 35% of max, or supplies projected to cover <1 more day's burn (`exp.Supplies.Current < exp.Supplies.DailyBurn * harshMod`).
3. **Post-elite breath** — auto-pitch a standard camp in the cleared elite room.
4. **Region boss cleared in multi-region zone** — auto-pitch base camp at first eligible site.
5. **Don't pitch when** mid-fight, in a trap/boss room, in a fork-pending state, or already camped.
Auto-break already happens on move (`autoBreakCampOnMove` in `dnd_expedition_camp.go:401`). Autopilot just needs to *not* break a freshly-pitched camp by immediately walking on — gate by `time.Since(camp.EstablishedAt) > minCampDwell` (15min real-time? or simply: don't auto-walk while camped).
**Day-rollover semantics change (decided event-anchored):** today, day++ happens at the 06:00 UTC briefing (`dnd_expedition_cycle.go:236`). After D2, the autopilot **night-camp pitch** is the event that fires day++, supply burn, overnight rest, and threat drift — in one atomic rollover the camp scheduler triggers. The 06:00 UTC briefing tick survives only as a re-engagement DM anchor (and a safety net: if for some reason no camp pitched in N hours, force one). Concretely:
- Move the body of `deliverBriefing` (`dnd_expedition_cycle.go:186-`) into a `processNightCamp(exp)` helper called from the autopilot camp scheduler when it pitches a *night* camp (rough/standard/fortified — not mid-day breath stops).
- Distinguish "night camp" (advances day) from "rest stop" (doesn't). Heuristic: a camp pitched at the end of the day-budget room count is night; one pitched purely for HP/SU recovery mid-day is a rest stop. Both apply `applyCampRest`; only night also runs supply burn + day++ + threat drift.
- The existing UTC briefing ticker becomes: if `last_briefing_at` is older than ~20h *and* the user is active, post a "Day N — here's where you stand" re-engagement DM. It calls **no** mutators.
- Manual `!camp <type>` from the player still does what it does today (apply rest immediately) but **also** counts as a night camp if invoked with no other camp today — keep parity with the autopilot behavior so the override doesn't accidentally stall day advancement.
**Migration:** in-flight expeditions at deploy time keep their existing 06:00 UTC day++ until they end (fence by `start_date`). New expeditions use the event-anchored model.
**Exit criteria:** sim shows autopilot pitches the right kind of camp at the right room with no player input; supply economy still survives a 7-day T5.
### D3 — Autonomous elite + boss engagement
**Files:** `dnd_zone_cmd.go:497-535` (the prev==RoomElite / RoomBoss branch).
**Shipped 2026-05-27.** `dnd_zone_cmd.go` adds `stopBossSafety` + `bossSafetyGate(uid, exp)` (HP < 80%, supplies < daily burn, exhaustion ≥ 3 — the "active low status" interpretation). The elite/boss doorway branch drops the `prev == RoomBoss || !compact` carve-out: in compact mode bosses fall through to the same `resolveCombatRoom` path elites have used, after the gate clears. `resolveCombatRoom` selects monster + label + loot-drop by `run.CurrentRoomType()` so the same call site handles boss kills (zone.Boss bestiary, "👑 Boss — name down", boss-loot drop, elite-tier threat bump). Loss on auto-resolved boss falls through to the existing player-death narration / forceExtractExpeditionForRunLoss path — no special treatment.
The walk loop (`dnd_expedition_cmd.go:717`) only breaks at the elite/boss doorway when `!compact`; compact lets the next iteration auto-resolve. `stopBossSafety` is excluded from the rooms-walked tally and its `autopilotFooter` returns "" — `res.final` already carries the held-back line.
When the gate trips, `tryAutoRun` calls `pitchBossSafetyCamp(exp)` (new in `expedition_autocamp.go`): force-pitches Standard (or Rough fallback) regardless of `decideAutopilotCamp`'s HP threshold or its RoomBoss room-type block, with the same `Night` event-anchored rollover handling. Dwell expires → next tick retries the boss. Foreground `!fight` and foreground `!expedition run` are unchanged: both still stop at the boss doorway for the player who wants to watch. Sim path is unaffected — `stopBossSafety` falls into `expedition_sim.go`'s default branch (tick-a-day, retry next walk).
**Open question:** do we want a "set autopilot off" toggle for players who actively want to play the boss themselves? My take: default-on, single-flag per expedition (`autopilot_engage`) settable at start or via `!expedition autopilot off`. Defer until D3 lands and we see how it reads.
**Exit criteria:** sim runs a full T5 expedition launch → boss kill with zero player commands besides `!expedition start`.
### D4 — DM volume + day surfaces
The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`).
**D4-a (shipped 2026-05-27):** autopilot DM bundling. `expedition_autorun.go:tryAutoRun` now drops per-tick auto-walk DMs in compact mode. New surface rules:
- Fork / death / run-complete still DM the walk stream — interactive + climax beats.
- A Night=true camp pitch flushes the day as an end-of-day digest (`renderEndOfDayDigest` in new `expedition_autorun_digest.go`) followed by the camp block.
- Boss-safety camp pitch gets a short "holding before the boss" header + camp block (walk stream dropped).
- Non-night auto-camp (mid-day rest / base-camp waypoint) surfaces the camp block by itself.
- Everything else — uneventful walks, preflight pauses, harvest interrupts — goes silent.
Each successful background walk now logs a `walk` entry (`appendExpeditionLog(... "walk" ...)`) so the digest can count rooms walked from structured logs (the raw `r.stream` narration is no longer persisted across ticks). Digest groups counts of walk/harvest/interrupt and inlines threat/milestone/narrative lines for the prior day (`dayExpeditionLog` helper). `maybeAutoCamp` + `pitchBossSafetyCamp` now return the `autoCampDecision` so `tryAutoRun` can branch on `dec.Night`.
**D4-b (shipped 2026-05-27):** morning re-engagement DM anchored to user activity, not 06:00 UTC. `fireExpeditionBriefings` now skips event-anchored expeditions whose `last_activity` is older than today's 06:00 UTC threshold (and we're still inside `nightSafetyNet` — stalled-autopilot force-fires still win). New `maybeDeliverDeferredBriefing` runs at the top of `AdventurePlugin.OnMessage` on every inbound message in any room: it stamps `last_activity` (so the next ticker pass sees the player as present, even from non-bot chatter) and posts the deferred briefing if one is owed (CAS-gated by today's 06:00 threshold; pre-06:00 lazy fires are suppressed to avoid double-emit with the ticker sweep that follows). Day-1 inbounds don't trigger a briefing before the first night camp has happened.
**Remaining work:**
- TwinBee voice + no-recap copy pass on the digest once the shape settles.
**Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency).
### D5 — Supplies economics retune
**D5-a (shipped 2026-05-27):** per-tier pack caps. `dnd_expedition_supplies.go` retires the global `SupplyPackStandardMax`/`SupplyPackDeluxeMax` constants in favor of `supplyPackCaps(tier) (std, dlx int)` — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2). `SupplyPurchase.Validate` is now `Validate(tier ZoneTier)`; both call sites (`!expedition start`, `!resume`) pass the resolved zone's tier. The cap clears `DailyBurn(raw) × intendedDays × 1.3` for every tier under the §2 target durations even with harsh×3 layered on top, so a player who wants to buy for the long shape can. DailyBurn / harsh-multiplier / `phase5BDailyBurnRatePct` are unchanged here — they're a D7 lever, alongside the sim work that should unblock empirical baselining. The help text now describes the cap as "scales by zone tier"; the holiday +1 standard pack still bypasses the cap on purpose (it's a freebie on top).
> **Note:** the original "Empirical (sim-driven)" path was blocked: under D2-b event-anchored expeditions, `SimRunner.TickDay` calls `deliverBriefing` → `deliverBriefingEventAnchored`, which reads `time.Now().UTC()` for its safety-net check, so synthetic ticks never advance `CurrentDay` and `DaysAtEnd` stays at 0. Re-baselining off real day-counts requires teaching the sim to drive the event-anchored rollover (D7).
**D5-b (shipped 2026-05-27):** "Pick your loadout" preset prompt at launch. `!expedition start <zone>` with no pack arg now DMs a Lean / Balanced / Heavy menu (sized per zone tier via new `loadoutPurchase(tier, SupplyLoadout)` in `dnd_expedition_supplies.go`); player replies with `!expedition start <zone> lean|balanced|heavy` (short forms `l`/`b`/`h` also work). `!resume` got the same treatment. Raw `Ns Md` syntax remains the advanced override — `resolveLoadoutOrParse` in `dnd_expedition_cmd.go` tries a single-token preset first, falls back to `parseSupplyArgs`. Heavy always equals `supplyPackCaps` (the D5-a harsh×3 ceiling); Lean covers intended days at raw burn; Balanced sits between. `parseSupplyArgs("")` still returns 1 standard for any tier — it's no longer reachable from `!expedition start` (the empty path is intercepted to prompt), but the `1s` default is preserved so future callers don't get surprise zero-pack purchases. Help text updated; two existing scenario tests now pass `lean` explicitly, three new tests cover preset resolution + the prompt-vs-start guard.
**D5-c (shipped 2026-05-27):** Ranger forage wired against the new caps. Audit found `SupplyForageMaxSU = 4` defined in `dnd_expedition_supplies.go` since Phase 12 E1b but never referenced — `ForagedToday` was only ever reset to `false`, never set to `true`, so the §4.2 "Ranger, WIS DC 12, 1d4 SU/day" perk had never actually granted supplies. New helper `applyRangerForage(e, c, rng)` rolls 1d4 SU once per day for Ranger characters, headroom-capped against `Supplies.Max` so a Heavy loadout doesn't overflow its purchased ceiling; non-Rangers and post-roll repeats are no-ops. Fires at the top of `nightRolloverBurn` (before the daily burn) so the +SU lands on today's bag and a "forage" log entry flows into the end-of-day digest via a new `renderEndOfDayDigest` case. No DC roll — accessibility over crunch (`feedback_accessibility_over_dnd_crunch`); the DC-12 gate would've added a fail case with no upside given the new headroom. Average +2.5 SU/day off a Ranger is a quiet Lean-loadout cushion (~1 extra day of T5 late-stage burn over a 7-day run), not a Heavy multiplier. Tests cover ranger-grants, non-ranger no-op, repeat-day no-op, headroom-cap, full-bag-still-stamps-flag, nil-safety.
**Remaining work (D5-d+):**
- DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts.
**Exit criteria:** balanced loadout completes intended-duration expedition in ≥ 85% of sim runs without starvation extraction.
### D6 — Player-facing surface cleanup
**D6 (shipped 2026-05-27):** player-facing copy now matches the autopilot-first reality.
- `expeditionHelpText` rewritten around the loop "pick a zone → pick a loadout → `!expedition run` watches it play out"; commands grouped into **Run an expedition** / **Mid-expedition** / **Overrides** with a one-line shape paragraph at the top.
- `!camp` and `!fight` were already absent from the primary `!expedition` help; both are now explicitly listed under **Overrides** and framed as "autopilot covers these — only reach for them if you want manual control." `campHelpText` itself opens with the same override framing so a player who types `!camp` lands on the right mental model.
- `expeditionCmdStatusImpl` (default `!expedition status`) now leads with **Day X / ~Y expected** (driven by new `expeditionTargetDays(tier ZoneTier)` in `dnd_expedition_supplies.go` — T1=2 → T5=7, the §2 anchor table), adds a **Rooms: N / Total in this region** line under the region marker (sourced from `getActiveZoneRun(...).CurrentRoom/TotalRooms`), and appends a **Recent:** block with the last 3 log entries (summary fallback to flavor). Supplies and threat lines unchanged; the `--debug` view is untouched.
- No new public command surface, no schema change, no test churn (no existing test asserted on the rewritten strings); full `go test ./...` green.
### D7 — Sim + class re-baseline
**D7-a (shipped 2026-05-27):** `SimRunner.TickDay` now drives event-anchored expeditions. `expedition_sim.go` short-circuits when `isEventAnchored(exp)` is true: instead of calling `deliverBriefing` (whose `deliverBriefingEventAnchored` branch reads `time.Now().UTC()` and would never trip the safety-net under synthetic ticks), it runs `nightRolloverBurn` → optional `applyCampRest(Standard)` if affordable (Rough fallback, skipped on low-SU) → `nightRolloverDrift(briefAt)`. Mirrors `pitchAutopilotCamp` with `Night=true`. Production paths untouched. New `expedition_sim_test.go` covers (i) 3 ticks advancing day 1→4 with supply burn + stamped `LastBriefingAt`, and (ii) the low-SU branch where the rest is skipped but burn/drift still fire. Unblocks D5-d empirical re-baseline of `phase5BDailyBurnRatePct` and the class corpus re-run.
**D7-b (shipped 2026-05-27):** `SimRunner.RunExpedition` now drives the production camp scheduler under a synthetic clock (`simWalkInterval = autoRunCooldown`, 2h per walk). After every soft-stop walk it calls `maybeAutoCamp(exp, simNow)`; `stopBossSafety` routes to `pitchBossSafetyCamp(exp, simNow)`. The helpers (`applyAutoCamp` / `applyAutoCampBossSafety`) advance `simNow` past `minAutoCampDwell` (4h) and break the auto-pitched camp via `breakAutoCampIfDue` so the next walk proceeds. `maybeAutoCamp`, `pitchAutopilotCamp`, and `pitchBossSafetyCamp` gained a `now time.Time` parameter (the only prod caller, `tryAutoRun`, still passes `time.Now().UTC()`). Effect: HP-low mid-day rests, multi-region base-camp waypoints, Night-camp rollovers, and boss-safety holds all fire under the sim — the D7-a `tickEventAnchoredRollover` shortcut is no longer used by RunExpedition (kept on TickDay for tests + the pre-cutoff legacy path). Coverage in `expedition_sim_test.go`: Night-camp day advance, HP-low mid-day rest, boss-safety force-pitch, no-op when neither trigger fires.
**D7-c (shipped 2026-05-27):** `cmd/expedition-sim` gained a `-days N` flag — when set, `RunExpedition` exits with `Outcome="day_capped"` after the Nth synthetic day rollover; 0 (default) is unbounded and only the existing `-cap` walk safety net applies. `SimResult` gained `DaySnapshots []SimDaySnapshot` ({Day, HPCurrent, HPMax, Supplies, Threat, Rooms}), captured at expedition start (Day 0), after every `applyAutoCamp`/`applyAutoCampBossSafety` Night-camp rollover, and one final end-of-run entry (reads `getExpedition(mostRecentExpeditionID(...))` when the row already extracted so closed runs still close their trajectory). `RunExpedition` signature picked up the `maxDays int` parameter; sole caller is the CLI. Coverage: `TestSimRunner_CaptureDaySnapshot_PopulatesFields` exercises the helper in isolation (HP from the live character row, SU/threat/day from the live expedition, Rooms from `res.Rooms`). Unblocks empirical D5-d retune of `phase5BDailyBurnRatePct` against per-day SU draws.
**D7-d (shipped 2026-05-27):** class corpus re-run + D5-d retune decision. First fixed a sim regression `expedition_sim.go` introduced by D5-b — bare `expedition start <zone>` now returns the loadout prompt instead of outfitting, so the sim was halting before persisting any expedition; harness now passes the `heavy` preset. Corpus: `sim_results/d7d_corpus.jsonl` (n=100 × 10 classes × 5 zones × L10 = 5000 runs, `heavy` preset, `-cap 300`). Analysis: `sim_results/d7d_analyze.py`. Writeup: `sim_results/d7d_findings.md`. **Key reads:** (i) leaderboard mirrors [[project_j2_sim_artifact]] — martials 7882%, casters 2142%, ~40pp cluster gap unchanged by long-expedition mechanics; (ii) bard/cleric trailers **not** relieved by autopilot camp pacing — cleric actually regressed at L10 (21% vs J2b L12 39%) on spell-pool richness, J3-territory; (iii) T3 hits target 4-day duration (median 3), T4 hits 56 days (median 5), T5 hits 5 of 7 (only 21 clears); (iv) **D5-d retune: no change.** `phase5BDailyBurnRatePct=50` + per-tier `DailyBurn` stay as-is — heavy-preset cleared runs end with 7898% SU remaining; supply economy is not a binding constraint and players lose to HP zero, not starvation. New memory entry: [[project_d7d_baseline]].
**Remaining work (D7-e+):** none in the core D7 scope. Two follow-up phases (D9 room-count bump, D10 T4/T5 anchor variety) were opened by the 2026-05-28 §6 resolution and are sequenced after D8. See §9 below.
## 8. J3 caster-picker rework (next session)
**Why split out:** D7-d corpus confirmed the martial/caster gap is class-roster + sim-picker driven, not autopilot/burn/pacing driven. Long-expedition mechanics are settled; the trailer fix lives in the sim picker, not the expedition shell.
**D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green.
**D8-b (implemented 2026-05-27, **inert** — see D8-prereq below): sim picker upcasting.** `simPickSpell` (`internal/plugin/expedition_sim.go:933`) now enumerates one candidate per available slot ≥ native for every prepared damage spell, scored via `spellExpectedDamage(sp, slotLevel, c.Level)`. Cantrips contribute a single slot-0 candidate. Tie-break: highest slot first, then highest expDmg. When the winning slot exceeds the spell's native level, the picker returns `"<id> --upcast N"` so `parseCombatCast` upcasts in the engine. Build + tests green.
**Measured impact: nothing.** d8b corpus (`sim_results/d8b_corpus.jsonl`, same matrix as d7d) lands every class within ±1.5pp of the d7d baseline — bard 1.0, cleric 0.4, mage 1.2, sorcerer +1.4, warlock 0.8, druid +0.6, martials ±0.2. Pure noise band. Expected bard +510pp / mage+sorcerer+warlock +28pp never materialized.
**Root cause (discovered post-corpus, this session):** `simPickSpell` is **dead code** in the current sim path. Commit `68ed8e7` ("Long expeditions D3: compact autopilot auto-resolves boss rooms") added a `!compact` gate at `dnd_expedition_cmd.go:810` so compact autopilot inline-resolves boss/elite rooms via `resolveCombatRoom``runZoneCombat``SimulateCombat` instead of returning `stopBoss/stopElite` for `autoResolveCombat` to handle. The sim uses `compact=true` (`expedition_sim.go:433`), so `autoResolveCombat` — the only caller of `simPickCombatAction`/`simPickSpell` — never fires. Verified empirically: a stderr-traced `simPickSpell` produced zero hits across full bard/cleric L10 runs.
This rewrites the picker-era retrospective. **J2's `baseline_j2a_v2_all10.jsonl` (bard 40%, cleric 39%) was measured before D3, with the picker live.** d7d's L10 baseline (bard 34%, cleric 21%) was measured *after* D3, with the picker disconnected — that ~618pp drop is the post-D3 caster regression, not "long-expedition mechanics didn't help casters" as the d7d writeup framed it. D8-a's "+2.2pp cleric" was also noise (1σ ≈ 1.8pp on n=500).
**D8-prereq (SHIPPED 2026-05-28, commit `631764b`):** split the `compact` flag in `runAutopilotWalk` / `advanceOnceWithOpts` into `compact` + `inlineBossCombat`. Production background autorun (`expedition_autorun.go:173`) passes `(true, true)` — unchanged inline auto-resolve. Sim (`expedition_sim.go:432`) passes `(true, false)` so boss/elite doorways return `stopBoss`/`stopElite` after the safety gate; `autoResolveCombat``simPickCombatAction``simPickSpell` / `simPickSpiritualWeapon` now drive boss/elite fights through the turn-based `!fight` engine. D8-b upcasting went live with the rewire. Foreground (`!expedition run`) flow unchanged (compact=false short-circuits).
Also parallelized matrix mode via subprocess workers (`cmd/expedition-sim/main.go`, `-jobs` flag; defaults to NumCPU). Each worker is its own process so the plugin's global SQLite handle isn't a contention point.
**Measured impact (5000-run L10 corpus, `sim_results/d8prereq_corpus.jsonl` — full diff in `sim_results/d8prereq_findings.md`):**
| Class | d7d | d8prereq | Δ |
|----------|-----:|---------:|-------:|
| cleric | 21.0 | 46.8 | +25.8 |
| sorcerer | 29.4 | 52.0 | +22.6 |
| warlock | 35.4 | 55.6 | +20.2 |
| mage | 36.4 | 54.2 | +17.8 |
| druid | 42.2 | 56.6 | +14.4 |
| bard | 34.2 | 46.2 | +12.0 |
| (martials) | ~80 | ~78 | -2 to -4 (noise + T4/T5 turn-engine regression) |
Caster/martial gap closed from ~40pp to ~22pp. T2/T3 zones are now the picker payoff (forest_shadows 75 → 99, manor_blackspire 45 → 75). **T4 caster wall persists** (every caster 0% at underdark); **T5 is universally 0%** including martials. Both require separate diagnostics — picker isn't the binding constraint past T3.
**D8-c (SHIPPED 2026-05-28):** concentration-damage multiplier. `spellExpectedDamage` (`internal/plugin/dnd_class_balance.go:280`) multiplies expected damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave, EffectDamageAuto}`. `EffectDamageAttack` excluded by design — single-target attack-roll spells aren't typically concentration; hex-style cases get their lift from mods. Test: `TestSpellExpectedDamage_ConcentrationMultiplier`.
**Measured impact (d8c_corpus.jsonl vs d8prereq, full diff in `sim_results/d8c_findings.md`):** every class within ±2.4pp on the leaderboard (1σ ≈ 2.2pp on n=500) — *macro-leaderboard unchanged*. Picker swap landed where expected at **T3 manor_blackspire**: bard +11pp (32→43), mage +10pp (71→81), sorcerer +9pp (60→69), druid +5pp manor + +1pp underdark. T4/T5 caster wall unchanged (still 0% for every caster except druid 0→1 underdark) — confirms [[project_d8d_diagnostic]]: past T3 the binding constraint is HP+AC, not picker quality.
**Side discovery (engine modelling gap):** cleric stayed flat across all zones despite having spirit_guardians (L3, concentration, save) prepared — and warlock dropped 6pp at manor. Likely the sim's `EffectDamageSave` path resolves the AOE save *once* rather than re-ticking per round while concentration holds, so the multiplier overrates spells the engine then under-delivers on. **Filed as a separate follow-up** (concentration re-tick gap in `SimulateCombat`/turn-engine); does *not* unwind D8-c — bard/mage/sorcerer/druid picker swaps are clean and within plan. Cross-link [[project_concentration_retick_gap]] when written.
**D8-d (DIAGNOSTIC SHIPPED 2026-05-28):** T4 caster wall diagnosed; no code change landed. Writeup: `sim_results/d8d_findings.md`. **Hypothesis disambiguation across cleric/bard/mage/sorcerer/warlock/druid L10 underdark (n=35 each):** (a) multi-region transit damage — REJECTED. All casters TPK in r1 (`underdark_surface_tunnels`) before any cross-region transit; `Combats=0` (the elite/boss session table is empty); deaths are entirely inline `SimulateCombat` mob rooms. (b) T4 attack-bonus vs caster AC — confirmed contributor. `computeAC` floor gives casters AC 1114 vs martial 1617; T4 standard roster (Atk +5 to +7) hits ~6065%. (c) HP scaling — confirmed contributor. Caster HPMax 93110 vs martial 141 (~30% gap). (d) Heal-stock — minor lever, NOT the lever. Direct experiment: bumped `simConsumableBundle` T4 from 2 → 5 Spirit Tonics, re-ran n=5 per class. Median rooms-before-TPK lifted +35 but 0/5 clears across every caster. Reverted. **Recommended tuning lever (next session, NOT shipped here):** lift `computeAC` class floors (cleric/druid 3 → 5, bard/warlock 1 → 3, mage/sorcerer 0 → 2) — single function, easy to revert, doesn't move the martial leaders. Validate against `d8prereq_corpus.jsonl`. Hit-dice rescale is the second option but is bigger surface area.
**D8-e (TODO next session): martial T4/T5 regression triage.** Paladin -18pp T4 / fighter -6pp T4 -5pp T5 / ranger -13pp T5 — the cost of moving boss/elite fights from inline `SimulateCombat` to the turn-based `!fight` engine. Two possibilities: (1) turn-engine plays martials slightly worse than inline-sim (action economy / multiattack handling differs — see [[project_sim_multiattack_gap]]); (2) inline-sim was over-rosy and the new numbers are the real ones. Read: trace one paladin underdark run pre/post and diff damage exchanges. If turn-engine is correct, prod martial numbers were inflated and we should not "fix" the regression.
**Sequence:** D8-c → D8-d → D8-e. D8-c is the smallest lever and confirms the picker model. D8-d is the highest-value (caster T4 is the next gap to close). D8-e is informational — settles whether to trust the new numbers.
**Open questions for D8:**
1. ~~D8-b upcast aggressively or conservatively?~~ **Resolved:** aggressive — the T3 lift (mage +61, warlock +65, druid +68) confirms the model.
2. D8-c concentration-rounds factor: 3 fixed, or scale with tier (longer fights at T4/T5)? Fixed is simpler; tier-scaled is more correct.
3. ~~Cleric T3+ cliff~~**partial resolution:** picker rewire took cleric T3 0% → 39%. Cleric T4 is still 0% (and so is every other caster); now subsumed by D8-d.
## 4. Cross-cutting risks
- **Existing in-flight expeditions** at deploy time. Either fence by `start_date` (old expeditions finish under old rules) or write a one-shot migration that retunes their room counts. Lean: fence — these are typically <2 days.
- **The 24h zone-run inactivity timeout** (`dnd_zone_run.go:254`) — must be raised, or the autopilot must self-poke `last_action_at` per tick. With multi-day expeditions, a quiet stretch overnight is now expected.
- **Idle reaper** (commit `0f72484`, see `expedition_autorun.go` references) — already holds streak on autopilot; verify it tolerates 5-day expeditions.
- **Babysit perk** — `BabysitSafeRest` already upgrades Standard → Fortified at rest (`dnd_expedition_camp.go:241-245`); confirm interaction with autopilot picking Standard (no change needed; the upgrade fires inside `applyCampRest`).
- **Multi-region travel still unwired in autopilot** (memory `project_multiregion_travel_unwired`). For T4/T5 to actually take 57 days, autopilot **must** auto-advance regions on region-clear. D2/D3 covers this implicitly but call it out — likely a small dedicated commit.
- **Pet arrival** (memory `project_pet_event_reachability`) — emergence seam is at run-complete; longer expedition just delays the roll, no code change needed.
- **Twinbee voice + secret NPC buffs** (memories `feedback_twinbee_voice`, `feedback_npc_buffs_are_secret`) — none of the new copy can break these.
## 5. Schema impact
Likely none. Existing columns cover it:
- `current_day` → autopilot increments via `advanceExpeditionDay`.
- `camp_json`, `supplies_json`, `region_state`, `threat_*` already in place.
- One **possible** new column: `expected_days` int (set at launch from zone tier + loadout) so status/end-of-day DMs can render "Day 3/5". Cheaper to derive than store — defer.
## 6. Open questions for next session
1. ~~Day-advance semantics~~**decided 2026-05-27: event-anchored.** day++ fires on autopilot camp-pitch (autopilot night-camp = sleep = day burn + briefing). UTC clock becomes a re-engagement-DM anchor only, not a state mutator. See §3-D2 for implementation impact.
2. ~~Boss autopilot default~~**decided 2026-05-28: default ON.** D3 already ships compact-mode boss auto-resolve gated by `bossSafetyGate` (HP/SU/exhaustion). No opt-out flag for now; revisit if players want manual final-blow framing.
3. ~~Per-tier room counts: sim-first vs ship-and-refine~~**decided 2026-05-28: sim-first refinement in D7-d.** D1 shipped a guess; we're much closer now. Measured vs prior: T1 ~2x (7→16), T2 ~1.8x (9→19), T3 ~2x (11→35), T4 ~3x (10→46), T5 ~3x (13→47). Target band is 35x; T1T3 likely want another bump but defer until the D7-d sim corpus reads day-counts. Don't churn graphs blindly.
4. ~~Extra anchor rooms (T4/T5)~~**decided 2026-05-28: yes, add anchor-room variety in T4/T5.** Second elite + second trap mid-zone (not just rely on multi-region/patrols/threat). Slot into D7-d alongside the room-count retune so we don't re-touch the graphs twice.
5. ~~Mobile/AFK / DM cadence~~**decided in D4-a (already shipped):** event-anchored to in-game day. Night-camp pitch flushes the day as a digest; fork/death/run-complete still DM the walk stream; mid-day rests/waypoints surface only the camp block; per-tick auto-walks silent. No real-time pacing knob.
## 7. Sequence
**Original D1D7 plan (all shipped):** D1 → D2 → D3 → D5 → D4 → D6 → D7.
**Current sequence (post-2026-05-28):** D8-c → D8-d-fix → D8-e → D9 → D10 → final re-baseline.
| Phase | What | Blocks |
|------|------|--------|
| D8-c | Concentration-damage multiplier in `spellExpectedDamage` | D8-d-fix read |
| D8-d-fix | Lift `computeAC` caster floors (cleric/druid 3→5, bard/warlock 1→3, mage/sorcerer 0→2) | D8-e read |
| D8-e | Diff turn-engine vs inline-sim for paladin/fighter/ranger on T4/T5; decide whether martial regression is real or a "fix" target | D9 (need stable class baseline) |
| D9 | T1T3 room-count bump toward 35× band, sim-validated against fresh corpus | D10 |
| D10 | T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone) | final re-baseline |
| — | One final L10 + L12 corpus pass to confirm tier day-counts + class spread | — |
## 9. Post-§6 follow-ups (D9 / D10)
### D9 — T1T3 room-count bump
**Why now:** §6 fork 3 resolved to "sim-first refinement"; D7-d corpus shows T3 hitting only median 3 days (target 4) and T1/T2 weren't even measured because the corpus only matrixed T3+. T1T3 graphs landed at ~2× prior length in D1; target band is 35×.
**Plan:**
- Pull T1/T2 day-counts from a post-D8 corpus run (matrix needs to include `goblin_warrens`, `crypt_valdris`, `forest_shadows`, `sunken_temple` at L4/L6).
- Lift graph sizes only if median days < tier target (T1=2, T2=3, T3=4). Don't churn graphs that already hit target.
- Reuse the D1-a..e fork/anchor/merge patterns (`internal/plugin/zone_graph_*.go`); preserve existing topology — only add Exploration nodes + the second-anchor slot D10 will fill.
- One-shot bootstrap: existing in-flight expeditions fence by `start_date` per §4 (consistent with D1 deploy).
**Risk:** if D8-d-fix lifts caster T3 clears materially, day-counts move too; measure after D8 stabilizes, not before.
### D10 — T4/T5 anchor-room variety
**Why now:** §6 fork 4 resolved "yes". T4/T5 zones run 3050 rooms with only one Trap + one Elite anchor; the long middle reads same-y. Bundle with D9 so we touch each `zone_graph_*.go` once.
**Plan:**
- Add a second Trap + second Elite anchor mid-zone for all T4/T5 zones (`underdark`, `feywild_crossing`, `dragons_lair`, `abyss_portal`).
- Place the second Elite at the region-boundary spurs (good narrative justification — region-guardian rather than zone-guardian) so multi-region travel finally gets a teeth-y interrupt.
- Place the second Trap on the long Exploration runs of fork branches, not on the merge node (keeps fork-choice loot/risk asymmetric).
- Preserve longest-path length within current band — added anchors replace Exploration nodes, not append.
**Validation:** same final corpus pass as D9. Look for boss-clear rate dipping by <5pp (anchor difficulty is intended, not punishing) and median day-count stable.
### Cross-links picked up here
- [[project_multiregion_travel_unwired]] — boss-clear → next-region auto-advance is wired (verified 2026-05-27); D10's region-boundary elites give it something to interrupt.
- [[project_j3_caster_picker]] / [[project_d8d_diagnostic]] — D8 gates the whole sequence.
- [[project_d8prereq_baseline]] — the corpus to diff against, not d7d.

356
gogobee_mischief_plan.md Normal file
View File

@@ -0,0 +1,356 @@
# Mischief Makers — paid monster hits on live expeditions
Working-tree plan doc. Do not commit.
## The pitch (restated)
Parodia members spend euros to send a monster — grunt / mob / elite / boss — at a
player who is out on an expedition. Orders come from Pete's Adventure web page
(and from Matrix). The games room hears that a contract is out and gets a window
to help the target… or "help" them. Surviving an attempt pays the target.
## What the codebase already gives us (verified 2026-07-13)
| Need | Existing machinery |
|---|---|
| Currency + escrow | `EuroPlugin.Debit/Credit` (`euro.go:364,:395`), duel escrow pattern (`adventure_duel.go:422,:529,:667`), community pot (`adventure_rival.go:193-241`) |
| "Is X out right now?" | `getActiveExpedition` (`dnd_expedition.go:215`), roster already pushed to Pete every 2 min |
| Mid-run combat injection | `runHarvestInterrupt` (`dnd_expedition_combat.go:121`) — picks enemy, surprise nick, `runZoneCombatRoster`, full win/retreat/death close-out. **This is the template.** |
| Safe-delivery gating | ambient ticker's CAS claim + `hasActiveCombatSession` + quiet-window checks (`expedition_ambient.go:97,:109,:156`) |
| Grunt→boss ladder | Arena tiers 15 (`adventure_arena_monsters.go`), `arenaMonsterToTemplate` (`:251`); zone pools tag `IsElite` |
| Games-room announce | `gamesRoom()` + world-boss announce helpers (`adventure_worldboss.go:528-566`) |
| Respond-within-window | duel challenge window + atomic single-winner claim (`adventure_duel.go:43,:85`) |
| Survival payout kit | `euro.Credit`, `rollAdvTreasureDrop`, `consumableCache`, renown, world-boss payout bundle (`adventure_worldboss.go:466-493`) |
| Pete pipe | durable fact queue, bearer auth, event-type taxonomy; **strictly one-way gogobee→Pete today** |
Hard constraint (Pete `internal/web/roster.go:23-25`): *"Pete has no route back
into the game box's network and we are not opening one."* We honor that: the web
storefront stores orders in Pete's DB and **gogobee polls Pete** (gogobee stays
the only initiator, same tailnet + bearer pattern as ingest, just a GET).
## Design
### Contract lifecycle
```
placed ──announce──▶ open (help/"help" window, default 60 min)
│ │
│ ├──▶ delivered (next safe tick: target active, no combat,
│ │ not in briefing/recap quiet window)
│ │ ├── target survives → SURVIVED payout + unseal buyer
│ │ └── target defeated → DOWNED consequences
│ └──▶ fizzled (expedition ended first → refund minus rake)
└── rejected at claim time (funds/eligibility) → never announced
```
- New table `mischief_contracts`: id, buyer_id, target_id, tier, fee, status
(`pending|open|delivered|fizzled|rejected`), signed (bool), escalation_count,
blessing json, source (`matrix|web`), created_at, window_ends_at, resolved_at.
Status transitions via conditional UPDATE (CAS), mirroring `deliverAmbient`.
- One live contract per target at a time; delivery fires from a sibling of the
ambient ticker after `window_ends_at`.
### The four tiers — PRICED (M0 done 2026-07-13)
Monster strength is **relative to the target**: pull from the target's own
bracket **zone pools** (grunt/mob = non-elite, elite = `IsElite`, boss = the
zone boss). NOT the arena ladder — arena `BaseLethality` is a legacy death
chance; arena T5 one-shots L20s and would make every tier an execution.
**M0 survival sweep** (n=2000/cell, `SimulateCombat` via the class-balance
harness builds at full HP, zone-pool monsters, ambush nick on elite/boss;
throwaway harness left skip-gated at
`internal/plugin/mischief_pricing_sweep_test.go`, env `MISCHIEF_SWEEP=1`):
| target build | grunt | mob (3 chained) | elite | boss |
|---|---|---|---|---|
| paladin L1 | 99.8% | 98.2% | 57.6% | 15.3% |
| mage L4 | 100% | 97.8% | 49.5% | 0.0% |
| mage L8 evoc | 100% | 100% | 56.5% | 0.4% |
| fighter L12 champ | 100% | 100% | 100% | 99.9% |
| rogue L20 AT (prosolis) | 100% | 100% | 100% | 35.9% |
| cleric L20 life (holymachina) | 100% | 100% | 100% | 14.1% |
Reading: grunt/mob are pure theater (+HP/supply attrition); elite is a real
coin-flip for at-bracket targets and trivial for overleveled martials; boss is
expedition-ending for casters at any level and a genuine 1-in-3 scare even for
the L20 rogue. Caveats: harness builds ≠ real sheets, full-HP-at-delivery is
optimistic (mid-run wounds raise real danger), no pets/party/consumables.
**Prod economy snapshot** (2026-07-13, 23 wallets): median balance ≈ €500;
whales prosolis €180k / holymachina €149k / nonk €62k hold 89% of all money.
Daily earn: whales ~€1.82.3k, mid-tier €20500, casuals <€10. Reference
sinks: lottery ticket €100, duel escrow €1.55k, daily share ~€455, endgame
shop items €1145k. Only 5 characters exist as targets (two L20s, L4, two
L1s) — the whales are both the richest buyers *and* the only high-bracket
targets, so boss-tier is effectively their PvP toy; casuals buy theater.
| Tier | Fee | Signed (+25%) | Survival payout (of fee) | Extras on survival |
|---|---|---|---|---|
| grunt | €40 | €50 | 40% → €16 | flavor line |
| mob | €100 | €125 | 50% → €50 | treasure roll (standard) |
| elite | €350 | €438 | 65% → €228 | treasure roll (elite) + renown |
| boss | €1,200 | €1,500 | 75% → €900 | elite treasure + consumable cache + renown + Pete milestone |
Rationale: grunt under the lottery-ticket impulse line so anyone can play;
mob = exactly one lottery ticket; elite ≈ one active day of mid-tier earnings
(a considered purchase, and its ~50% at-bracket survival odds are honest
stakes); boss in duel-stake territory — whale money, priced like the "end an
expedition" button it is against caster targets. Escalation cost = the
tier-delta fee; blessings €25 flat.
Anti-collusion invariant: **total payout (fee + escalations) capped at 75%**
in every case, so collusion is strictly dominated by `!baltransfer`, which is
free. No danger multiplier needed — the cap does all the work.
Money flow: survival payout to target, remainder → community pot. Defeat →
entire fee to community pot (never back to the buyer — glory only). Fizzle →
90% refund, 10% rake to pot.
Boss-tier friction (014% caster survival means "boss ≈ certain maiming"):
max 1 boss contract per target per week, on top of the global limits below.
### Death policy (recommendation: mischief maims, it doesn't murder)
`runHarvestInterrupt`'s loss path perma-kills (`abandonZoneRun` +
`forcedExtractExpedition` + mark dead). For a *purchased* attack that lands
while the victim is offline, perma-death feels like paid murder, not mischief.
Recommended: on defeat, apply the world-boss HP floor (`worldBossFloorHP`
pattern), force-extract the expedition (run-loss seam already exists), drop a
chunk of un-banked expedition coins/supplies, +threat. Character lives.
**Open decision** — could make boss tier lethal for stakes, but then it needs an
opt-in (hardcore flag) or it's a griefing lever.
### Anonymity + the unseal twist
Default: contracts are anonymous — "someone in town has put coin on
<target>'s head." Buyer can pay +25% to *sign* it (taunt rights).
**If the target survives, the contract is unsealed** — Pete names the buyer in
the survival bulletin. Risk of exposure is the natural brake on casual griefing,
and it makes survival announcements delicious.
### The games-room window (help or "help")
On placement, announce in `GAMES_ROOM` (world-boss style) + Pete priority beat.
During the window (default 60 min):
- `!mischief bless @target` — pay a small fee (~€15) for temp HP / +AC on the
incoming fight (well-rested-style bundle). Stacks, capped (say 3 blessings).
- `!mischief escalate @target` — pay ~half the next-tier delta to bump the
contract one tier, max one step total, boss can't escalate. Escalation money
joins the payout escrow — piling on raises the target's survival jackpot.
- Victim gets a TwinBee DM (first person, per voice rules): word has reached
them that someone wants them dead. Pure flavor — expeditions are autonomous —
but it lets them rally blessings.
Blessing/escalation state lives on the contract row (real rows, no phantom
per-fight state — lesson from the companion free-lunch bugs).
### Delivery mechanics
New `runMischiefInterrupt`, modeled line-for-line on `runHarvestInterrupt`:
pick monster per tier table → apply blessings to the roster → surprise nick
(attacker's privilege) → `runZoneCombatRoster(fightRoster(target), …)` → custom
close-out. Do **not** call `recordZoneKill`/advance zone state — the fight is
extrinsic to the dungeon. Party seats fight together and earn seat XP as usual;
the survival purse goes to the target (leader).
Fizzle: if the expedition ends before delivery, refund fee minus 10% rake
(deadpan Pete line: the monster arrived to an empty dungeon).
### Rate limits & eligibility (anti-grief)
- Target must have an active expedition **and** be level ≥ 3 (or similar floor).
- One live contract per target; per-target cooldown 24 h after resolution.
- Per-buyer cap: 2 contracts/day. Can't target yourself. Escrow debited at
placement via `euro.Debit` (respects the debt floor for free).
- Boredom-ticker auto-expeditions: **targetable** (they're real runs and it's
funny), but revisit if it feels bad in practice.
- Opt-out: none for v1 — being in the world means being in the world — but keep
the decision explicitly revisitable. News anonymization (`!news optout`)
still applies to Pete-facing names as it does today.
### Pete web storefront (the reverse pipe)
**Decision (2026-07-13): mischief UI requires Authentik sign-in on Pete.**
Pete's OIDC layer already exists (`[web.auth]`, disabled by default) and the
callback already parses `preferred_username` from the ID token
(`auth.go:236-250`) — it just isn't persisted into the `pete_session` cookie.
**VERIFIED 2026-07-13** on parodia.dev (`matrix-mas-1`,
`/home/reala/matrix/compose/mas/config.yaml`): MAS imports localpart with
`action: require, template: '{{ user.preferred_username }}'` — so
**Authentik username == Matrix localpart**, guaranteed. Identity is free:
add PreferredUsername to `SessionUser`, gogobee maps it to
`@<username>:<server>` (lowercase it — Matrix localparts must be lowercase,
Authentik usernames may not be). No link codes.
The one-way *network* rule (`roster.go:23-25`) stays intact — both new data
flows are gogobee-initiated:
- **Balances out (push):** gogobee already pushes a roster snapshot every
2 min; add euro-balance entries for linked users on the same tick. Pete
renders "~€X" and greys out unaffordable tiers. Advisory only; up to 2 min
stale is fine.
- **Orders in (poll):** signed-in buyer POSTs the order form → `mischief_orders`
row in pete.db keyed by preferred_username, status `pending`. gogobee's
peteclient grows a poll loop: `GET /api/mischief/pending` (bearer) every
30 s, claims each order (`POST /api/mischief/claim`, idempotent on order
GUID), then validates in-game.
- **Money truth lives only in gogobee:** the authoritative check is
`euro.Debit` at claim time. A stale web balance just means an occasional
"order bounced — insufficient funds" status (rendered from a fact) + TwinBee
DM to the buyer. Pete never writes a balance, so no double-spend surface.
Order targets: roster board (already live on `/adventure`) grows a "send
trouble" button per entry (roster token identifies the target — already
stable + non-deanonymizing). Pete renders order status from resulting facts,
never from live game state.
Ops note: enabling `[web.auth]` on prod Pete needs the Authentik OAuth client
provisioned + config.toml edit on the box (config is box-only).
### New Pete event types (deploy Pete FIRST — unknown types 400 → park forever)
`mischief_contract` (priority: a hit is out, tier, anonymous-or-signed),
`mischief_survived` (priority: target thwarted it — unseal buyer here),
`mischief_downed` (priority), `mischief_fizzled` (bulletin),
`mischief_link` (internal, no render — or handle out-of-band). Deadpan voice
throughout; check `adventure_flavor_*.go` for reusable lines before writing new
flavor (standing rule).
## Phases
- **M0 — price the tiers. DONE 2026-07-13** (single-fight sweep + prod
economy snapshot; see tier table above). Follow-up for M1 close-out: re-run
the sweep through the *real* delivery path once `runMischiefInterrupt`
exists, since full-HP single fights understate mid-run danger.
- **M1 — core engine, Matrix-only. DONE 2026-07-13 (uncommitted, not deployed).**
`adventure_mischief.go` (tiers/pricing/persistence/eligibility/`!mischief`),
`adventure_mischief_deliver.go` (ticker + `runMischiefInterrupt` + close-outs),
`mischief_contracts` table, Pete's 4 `mischief_*` event types. 17 tests,
4 of them end-to-end through the real fight + euro + extraction.
Decisions taken during implementation (all inside the plan's intent):
- **Bracket monster-selection promoted out of the M0 test** into prod
(`mischiefBracketZone`/`mischiefMonsters`), so the sweep and the live
delivery now drive *the same* selection code — the fee table can't drift
away from the fight it priced.
- **Survival is read off the target's HP, not `PlayerWon`.** The engine's
timeout is a retreat, not a lethal blow: a target who ran out the clock
with HP left held the thing off, and a bought monster that merely outlasted
them hasn't earned a maiming. (M0 counted `!PlayerWon` as death, so real
survival rates are a touch *better* than priced. Pricing stands.)
- **No-perma-death extended to the whole party** (`floorMischiefRoster`, run
on BOTH outcomes). A leader can win a fight their friend went down in, and
the delivery skips `closeOutZoneWin` — without the floor, the member is left
alive at 0 HP, which every `HPCurrent <= 0` gate reads as broken.
- **One-live-contract-per-target is a partial UNIQUE INDEX**, not a read-then-
write check: placement holds only the *buyer's* lock, so two buyers racing at
one victim would both pass an in-code check. The loser is refunded.
- **Stale-delivery sweep** (`delivering` + 15 min grace → full refund). Without
it a crash mid-fight strands the row: target permanently un-targetable,
buyer's money gone.
- All contract timestamps bind as Go `time.Time` — never `CURRENT_TIMESTAMP`.
The driver stores RFC3339 (`2026-07-14T03:48:52Z`); a SQL-side stamp writes
`2026-07-14 03:48:52`, and the two compare lexicographically wrong.
- Fizzle DMs + rake, 90% refund; unstageable/stranded contracts refund 100%
(our fault, not a bet they lost).
**M1 close-out sweep DONE 2026-07-13** (`mischief_delivery_sweep_test.go`,
`MISCHIEF_SWEEP=1`, n=400/cell, 108 cells through the REAL delivery path:
`runMischiefInterrupt``runZoneCombatRoster`). Arms: entry HP 100/70/40%
(100% = control, reproduces M0 through the new path) × wards 0/3 on elite+boss.
| target | tier | 100% HP | 70% HP | 40% HP | 70% +3 wards |
|---|---|---|---|---|---|
| paladin L3 | elite | 84.8% | 73.2% | 62.7% | 80.5% |
| paladin L3 | boss | 87.8% | 45.8% | 8.0% | 88.0% |
| mage L4 | elite | 66.0% | 29.5% | 4.0% | 64.0% |
| mage L8 evoc | elite | 90.2% | 72.2% | 15.0% | 84.2% |
| mage L8 evoc | boss | 6.5% | 2.0% | 0.2% | 7.8% |
| fighter L12 | boss | 88.2% | 48.0% | 6.8% | 89.8% |
| rogue L20 | boss | 19.2% | 2.0% | 0.0% | 18.5% |
| cleric L20 | boss | 42.0% | 6.0% | 0.0% | 42.8% |
(grunt/mob: 100% for everyone except mage L4, who can lose a wounded mob.)
Three findings:
- **The M0 table was priced on a fight we don't deliver.** The control arm
diverges from M0 in BOTH directions: up where an engine timeout now counts as
survival (paladin boss 15→88%), and *down* where the turn engine loops a
boss's full multiattack profile and `SimulateCombat` doesn't (fighter boss
99.9→88%, rogue boss 36→19%) — see [[project_sim_multiattack_gap]]. Fees kept
(the tiers still do what they were priced to do); this table is now the
reference.
- **Wounding is the dominant variable, and M0 was blind to it.** Boss at a
realistic mid-run 70% HP collapses across the board (fighter 88→48, cleric
42→6, rogue 19→2); at 40% it is near-zero for everyone. Boss really is the
"end their expedition" button, as priced.
- **Wards were too cheap** (see M2 below): 3 of them buy ~+40pp.
- **M2 — the window. DONE 2026-07-13.** `!mischief bless @user` (€25, cap 3,
+10% MaxHP temp HP each) · `!mischief escalate @user` (pays the tier delta,
one step, boss is the ceiling) · victim DM on placement · escalator unsealed
alongside the buyer on survival. 6 new tests (22 total).
Decisions taken during implementation:
- **Blessing fee is a pure sink** (community pot), never a purse top-up. If a
ward raised the payout basis, warding a friend would become a money-routing
move; buying them HP can't be.
- **Ward price scales with the contract** — `max(€25, 10% of fee)`: grunt/mob
€25, elite €35, boss €120 (€360 to cover someone completely). The close-out
sweep measured 3 wards at ~+40pp (fighter vs boss at 70% HP: 48→90%), so a
flat €25 let the room halve a €1,200 boss contract for €75 — pocket change
against the tier the whole economy rests on. Reads the contract's *current*
basis, so an escalation raises the price of saving its target too. The €25
floor is knowingly above-market at grunt (3 wards > the €40 fee): nothing
needs warding against theatre, and the floor exists to keep a ward an impulse.
- **Escalation raises `fee` (the payout basis) AND `paid` together**, so the
75% cap and "purse < outlay" survive an escalation untouched — asserted.
- **Escalating into boss answers to the boss-per-target-per-week cap.**
Otherwise the cap is bought around for the price of an elite plus the delta.
- **Both window commands are CAS-then-refund**, like placement: the ward cap and
the one-step limit live in the UPDATE's WHERE clause, because a scramble is
exactly when four people press the button in the same second.
- **The delivery re-reads the contract AFTER claiming it.** The due-sweep's copy
is stale by construction — the window keeps writing to an open row up to the
claim, so a last-second escalation was paid for and then delivered the *old*
tier's monster. The claim is the fence. (Fixed + regression test.)
- **The ward is temp HP** (the well-rested mechanism), added to whatever cushion
the target already carried and subtracted again after the fight — a bought ward
must not eat a home long rest. It refreshes per link of the mob chain; that only
affects the one tier the sweep priced as theatre.
- Blessers are named on the spot (helping is proud); escalators are anonymous
until the survival unseals them, exactly like the buyer.
- Fizzled/unstageable contracts do **not** refund blessers. Their €25 was charity,
and it went to the pot. Revisit if it stings in practice.
- Fixed a pre-existing wall-clock flake in the M1 fizzle test: it drove
`fireMischiefDeliveries` with `time.Now()`, which refuses to run inside the
±1 h quiet windows around the 06:00 briefing and 21:00 recap.
- **M3 — Pete storefront (1 session, cross-repo).** Enable + extend OIDC
(persist preferred_username; provision Authentik client), orders table +
form + status rendering, balance push, gogobee poll/claim loop. First
visitor-facing write path on Pete — auth-gated + rate-limited per user.
(MAS localpart == Authentik preferred_username: verified 2026-07-13.)
- **M4 — polish.** Notoriety/renown for buyers, survival streaks → milestones,
digest lines, boredom-expedition policy review, tune fees from live data.
## Decisions — ALL CONFIRMED by reala 2026-07-13
1. **No perma-death anywhere** — mischief maims: HP floor + force-extract +
un-banked loot/supply loss + threat bump. Boss tier included.
2. **Anonymous by default; survival unseals the buyer** in Pete's bulletin;
+25% to sign openly.
3. **Fizzle refund 90%**, 10% rake to community pot.
4. **Targets: level ≥ 3 with an active expedition**; boredom auto-runs are
fair game. One live contract per target, 24 h post-resolution cooldown,
2 contracts/day per buyer, 1 boss/target/week.
5. **Fee/payout table as priced above** (M0 sweep + prod economy).
## Standing-rule checklist
- No new `dnd_`-prefixed files/tables → `adventure_mischief*.go`, `mischief_*` tables.
- TwinBee first-person; Pete deadpan third-person announcer.
- Plan doc stays working-tree only.
- Pete deploys before gogobee whenever event types change.
- Sim sweeps: control arm + n≥750; run heavy sweeps on Parodia/millenia.
- Any loader/bootstrap added must keep its backfill as a one-shot bootstrap.

267
gogobee_revisit_plan.md Normal file
View File

@@ -0,0 +1,267 @@
# Revisit / Backtrack Navigation — Design Plan
Lets players walk back to previously visited rooms within an active zone
run to re-harvest, re-fork (e.g. after acquiring a key), or pick up
something they skipped. Forward-only navigation has been the rule since
Phase G; this plan retires that assumption deliberately.
## Goals
- Re-harvest depleted nodes after a long rest (primary use case).
- Allow general exploration freedom — wander back, look around.
- Re-pick a fork after acquiring a key or changing strategy.
- Preserve the existing pacing pressure (threat clock, SU drain) but
make backtracking *cheaper* than fresh exploration to reflect that
the route is already known.
## Non-goals
- Teleporting to arbitrary discovered rooms.
- Backtracking during `!expedition run` autopilot (autopilot is
forward-only by design).
- Re-fighting cleared encounters or re-arming disarmed traps.
## Player-facing surface
- **`!revisit <N>`** — walks one edge from the current node toward a
previously-visited node. `N` is the 1-indexed room number printed in
`!map`'s Path strip.
- Must be in `VisitedNodes`.
- Must be adjacent to `CurrentNode` via an edge in the zone graph
(forward or reverse direction; graph edges are directed but we
allow reverse traversal for revisit).
- Costs threat and SU at a reduced rate (see Cost Model).
- No combat re-roll, no trap re-arm in cleared rooms.
- **`!map`** — already shows the numbered Path strip; players read N
from there.
- **`!zone advance`** post-revisit — unchanged. From any node, picks
the first outgoing edge (or shows the fork prompt). Re-forking a
previously-resolved fork node clears the prior choice silently.
## Cost model
> **CORRECTED 2026-07-09 during R1.** The table below originally claimed a
> fresh `!zone advance` costs +2 threat / 1.0 SU. It does not. Verified at
> HEAD: **movement itself is free.** There is no per-room tick anywhere.
> - **Supplies burn per *day*,** not per room — `applyDailyBurn` is called
> only from `dnd_expedition_cycle.go:108` (the day rollover),
> `dnd_expedition_extract.go:52`, and region transit. Never from advance.
> - **Threat comes from *combat*,** not from walking:
> `applyRoomCombatThreatForUser` (+5 normal, +8 elite/boss) fires from the
> three combat-resolution sites, plus daily drift, harvest noise (+2), and
> ambient. `zoneCmdAdvance` / `advanceOnce` touch neither clock.
>
> This inverts R2's central premise. A revisited room fires no combat
> (terminal `CombatSession` rows gate re-entry), so backtracking is already
> free — the design problem is not *discounting* a cost, it's that there is
> **no pacing pressure to discount**. Whatever R2 charges is a net-new cost.
| Move | Threat tick | SU tick |
|------|-------------|---------|
| Fresh-room `!zone advance` | **0** (actual) | **0** (actual) |
| Combat resolved in a room | +5, +8 elite/boss | 0 |
| Day rollover | drift (mood-scaled) | daily burn |
| `!revisit <N>` (one edge) | **TBD — see below** | n/a (no per-move SU exists) |
**DECIDED 2026-07-09 — option (1), +1 threat per revisit edge.** Shipped in
R2 as `revisitThreatCost`. Standalone (non-expedition) zone runs have no
threat clock and so pay nothing. A `revisitSiegeGuard` refuses the move at
threat ≥ 99: siege is one-way sticky, and a player must not be able to strand
their own expedition on a move they made to go pick up a herb.
Options as they were weighed, cheapest first:
1. **Charge +1 threat per revisit edge.** One `applyThreatDelta` call,
mirrors "harvest noise". Reads as "you stir up attention doubling back."
Cheap, honest, and it's the only clock revisit can plausibly touch.
2. **Charge nothing.** Backtracking is already free of combat; the real
cost is the day clock, which a long detour burns anyway. Simplest, and
arguably correct — the pacing pressure is the multi-day supply budget,
not the step count.
3. **Charge a fractional day.** Rejected: the day counter is the spine of
every milestone, digest and anchored event. Don't make it fractional.
Recommend **(1)** at +1: it's the smallest lever that makes a detour a real
choice, and it rides an existing, tested seam.
## State model
The hard problem: `CurrentRoom` is currently derived as
`len(VisitedNodes)-1` (dnd_zone_run.go:377), which assumes
monotonically-growing visit history. Backtracking breaks that.
### Schema changes
- **`VisitedNodes`** — unchanged semantics: ordered set of nodes ever
entered, first-entry order. Stays append-only when entering a *new*
node; revisits do not append.
- **`CurrentNode`** — already the source-of-truth for "where am I."
- **`CurrentRoom`** — repurpose from `len(VisitedNodes)-1` to "index of
CurrentNode within VisitedNodes" (the room's first-entry index, which
is the path-relative label the player sees).
- **`RoomsTraversed`** (NEW, int column on `dnd_zone_run`) — monotonic
step counter. Drives threat/SU ticks. Bootstrapped to
`len(VisitedNodes)` for existing rows.
### Audit pass required
Every read of `r.CurrentRoom` and `len(r.VisitedNodes)` needs to be
classified:
- **"Path index" reads (keep as CurrentRoom)**: display headers
("Room 3/7"), encounter ID derivation (`encounterIDForRoom`), enemy
salt seeds, harvest room keys, status/abandon strings, camp room
index.
- **"Progress counter" reads (switch to RoomsTraversed)**: threat
clock ticks, supplies ticks, ambient narration cadence, autopilot
preflight, fatigue/exhaustion accrual if any.
Concrete callsite list will be produced during implementation; estimate
~1015 callsites total.
## Fork re-pick
Re-entering a fork node clears its resolved choice in `node_choices`
(or equivalent). Next `!zone advance` shows the fork prompt fresh,
evaluated against current inventory (so a newly-acquired key unlocks
previously-locked edges).
Silent — no warning. Graph-back navigation means the player has
walked back through their previously-chosen path; nothing in
`VisitedNodes` is destroyed; the alternate branch they previously
took is still revisitable. There is no "discard" semantics.
## RoomsCleared semantics
Idempotent — exiting a room a second time doesn't re-append to
`RoomsCleared`. The "advanced past" flag is sticky.
## Preflight checks (rejections)
`!revisit` rejects with a clear DM when:
- Target room is not in `VisitedNodes` ("You haven't been there yet.")
- Target equals `CurrentNode` (no-op)
- Target is not adjacent to `CurrentNode` via any graph edge
("Room X isn't directly connected — backtrack one step at a time.")
- Active combat session in current room
("Finish the fight first.")
- Threat clock would tick past max (use existing camp-eligibility
preflight pattern)
- SU would drop below survival floor (same)
- Character is dead / expedition is paused (same gates as `!zone advance`)
- Autopilot run is active
## Rollout phases
Tentative ordering. Each phase ships independently.
### R1 — schema + audit ✅ **DONE 2026-07-09** (branch `n1-restoration`)
Shipped: `rooms_traversed` column + `bootstrapRoomsTraversed` backfill;
`CurrentRoom` re-derived via `pathIndexOf(VisitedNodes, CurrentNode)`;
`appendVisited` / `appendClearedRoom` made set-semantic; `narrationCadence`
routed off `RoomsTraversed`; `advanceZoneRunNode` now returns the moved-to
path index. Tests in `zone_revisit_r1_test.go`. Full suite green. No
player-visible behavior change.
**Audit outcome — the callsite split was smaller than estimated (~1015).**
Almost every `CurrentRoom` read is a *path index* and correctly stays put:
enemy/trap salts (`pickZoneEnemy`, `pickZoneTrap`, `rollTrapDamage`), loot
RNG seed, `encounterIDForRoom`, harvest room keys, camp `RoomIndex`, map
render, "Room X/Y" headers. Keying those on the node's first-entry index is
exactly what makes a revisited room resolve to *the same room*.
Only two reads were progress-shaped:
- `narrationCadence` → now `RoomsTraversed-1`, so a backtracking player
draws fresh flavor instead of replaying the lines they read on the way in.
- `nextIdx := run.CurrentRoom + 1` (`dnd_zone_cmd_graph.go`) — not a
progress read but a latent bug: "+1" only labels correctly while the
player is at the frontier. `advanceZoneRunNode` now returns the true index.
**Nothing to route off `RoomsTraversed` for threat/SU** — there are no
per-room ticks to route (see the corrected Cost model above). The counter
currently drives narration cadence and stands ready for R2's cost decision.
Two behaviors were hardened in passing, both no-ops under forward-only
navigation but load-bearing the moment R2 lands:
- `VisitedNodes` is an ordered **set** — a re-entered node is not
re-appended, so room numbers never renumber under the player.
- `RoomsCleared` is **idempotent** — exiting a room twice can't inflate it
past `TotalRooms` and skew the "N/M rooms" renders.
### R2 — `!revisit` command ✅ **DONE 2026-07-09** (branch `n1-restoration`)
Shipped: `!revisit <N>` (alias `!zone revisit`), `adjacentNodes` reverse-edge
traversal, full preflight, +1 threat, `!map` now prints a **Back to:** strip
of legal targets. Fork re-pick still deferred to R3 — revisit refuses while a
fork prompt is pending, because both `!zone advance` and `!zone go` resolve
`node_choices` without checking which node the player is standing on.
**"No combat/trap re-trigger logic needed" was wrong — and it was an exploit.**
Terminal `CombatSession` rows gate *only* Elite and Boss rooms, at the doorway
check in `advanceOnceWithOpts`. An Exploration room re-enters
`resolveCombatRoom` unconditionally and a Trap room re-arms. Left alone,
`!revisit` would have been an infinite farm: step back one room, `!zone
advance`, repeat for loot and XP. Fixed with a `RoomIsCleared(CurrentRoom)`
early-return at the top of `resolveRoom` — a no-op forward-only, since advance
clears a room only *after* resolving it. Guarded by
`TestRevisitedRoom_DoesNotReResolve` and `TestFreshRoom_StillResolves`.
**Autopilot needed a stopgap, sooner than R5 planned.** Autopilot has no
on/off switch — `tryAutoRun` is ticker-driven for every active expedition on a
2h CAS cooldown. Without intervention the background walker marches the player
straight back out of the room they just revisited, and the whole feature reads
as broken. `grantAutorunGrace` stamps `last_autorun_at` on revisit, buying one
full cooldown window. **R5 still owes the real policy decision** (refuse to
auto-walk from a non-frontier node, vs. walk back to the frontier first).
Tests in `zone_revisit_r2_test.go`. Full suite green.
### R3 — fork re-pick
- On revisit landing into a fork node, clear `node_choices` for that
node.
- Next advance re-prompts.
- Tests: lock-with-key acquired between two fork visits, alternate
branch selection, no-change re-pick.
### R4 — UX polish
- DM line on revisit explaining the cost ("You retrace your steps to
Room 3 (the fork). Threat +1, SU 0.5.").
- Surface `!revisit` in `!help` and `!zone` help blocks.
- Ambient/narration audit: any "you press deeper into the dungeon"
lines that no longer fit when player is backtracking.
### R5 — autopilot guard
- `!expedition run` refuses to start while at a non-frontier node?
Or auto-walks back to frontier first? Decide based on R1R4 feel.
Likely: refuse with a "use !zone advance to return to the frontier
first" hint.
## Open questions (lower priority)
- Should there be a small flavor-only narrative beat the first time
the player backtracks in an expedition? ("TwinBee notes you doubling
back — there's no shame in it. Sometimes the answer was in a room
you'd already passed.")
- Does a babysitter's safe-rest affect revisit cost? (Probably not —
babysitter is a camp upgrade, revisit is movement.)
- Multi-region expeditions — does revisit work across region
boundaries within the same expedition? (Initial answer: no, only
within the current zone run; cross-region travel is its own seam.)
## Effort estimate
Comparable to a small Phase pass (G6-sized). Roughly:
- R1: 1 session (schema bump + audit + tests).
- R2: 1 session.
- R3: 0.5 session.
- R4: 0.5 session.
- R5: 0.5 session.
Total: ~34 working sessions.

237
hangman_phrases.txt Normal file
View File

@@ -0,0 +1,237 @@
# GogoBee Hangman Seed Phrases -- Video Game Edition
#
# Tiers are assigned automatically by character count at load time.
# Section headers below are for human readability only -- the bot ignores them.
#
# Easy: 8-20 characters
# Medium: 21-40 characters
# Hard: 41-80 characters
#
# Add community phrases via: !hangman submit [phrase]
# All submissions require LLM approval before entering the pool.
# This file can be edited directly. Bot reloads on restart.
# ---------------------------------------------------------------------------
# EASY (8-20 characters)
# ---------------------------------------------------------------------------
Finish Him!
Game Over
Insert Coin
Hadouken!
Fatality!
Big Boss
Konami Code
Warp Zone
Blue Shell
God Mode
BFG 9000
Cacodemon
Quad Damage
Samus Aran
Morph Ball
Mother Brain
Dracula!
Simon Belmont
Ecclesia
Outer Heaven
Spread Gun
Power Pellet
Checkpoint
Rocket Jump
Mushroom Kingdom
Princess Peach
Bowser's Castle
Fire Flower
Varia Suit
Space Jump
Vampire Killer
Holy Water
Trevor Belmont
Soma Cruz
Julius Belmont
Waluigi!
Richter!
Phantoon
Speed Run
High Score
Continue?
Press Start
Ryu Hayabusa
Plasma Gun
What is a man?
Serious Sam
Shoryuken!
Duck Hunt
The cake is a lie
War never changes
Would you kindly?
Do a barrel roll!
Praise the Sun!
A winner is you
You're pretty good
La-Li-Lu-Le-Lo
I am error
Leeroy Jenkins!
For the Horde!
For the Alliance!
Frostmourne hungers
Falcon Punch!
Rip and tear!
Vic Viper
Salamander!
Parodius Da!
We'll bang, okay?
What you say!!
You spoony bard!
One-Winged Angel
Morning Star
Glyph Union
TwinBee, scramble!
# ---------------------------------------------------------------------------
# MEDIUM (21-40 characters)
# ---------------------------------------------------------------------------
Stay a while and listen
It's dangerous to go alone!
Kept you waiting, huh?
A man chooses, a slave obeys
Metal Gear?! Metal Gear!!
We're not tools of the government
The winds of destruction
Who are the Patriots?
This is good, isn't it?
You have died of dysentery
The Triforce of Courage
Ganon has broken the seal!
May the wind guide you home
Dodongo dislikes smoke
The right man in the wrong place
Nothing is true, everything is permitted
I used to be an adventurer like you
You can't hide from the Grim Reaper
All your base are belong to us!
Somebody set up us the bomb!
For great justice, take off every Zig!
How are you gentlemen!!
Kain has betrayed us!
Garland will knock you all down!
You are not prepared!
I am Uther the Lightbringer!
Order of Ecclesia calls
The Dark Lord rises again!
Shanoa, bearer of glyphs
In this world, it's kill or be killed
Despite everything, it's still you
The Underground is your home now
Papyrus demands a battle!
I'm going to make spaghetti!
Toriel will protect you
Estus Flask replenished
The age of fire fades
Prepare to die, undead one
Can't let you do that, Star Fox!
Andross' empire spans the Lylat system!
Captain Falcon, show me your moves!
OBJECTION! That testimony is a lie!
Hold it! I have new evidence!
Phoenix Wright, attorney at law!
Does this unit have a soul?
Shepard, the Reapers are coming!
Tali'Zorah vas Normandy!
Gruntilda shall not be defeated!
K. Rool has stolen the banana hoard!
Kirby, hero of Dream Land!
Meta Knight awaits your challenge!
Congraturation! This story is happy end.
Cecil has become a Paladin
Cloud Strife, SOLDIER First Class
Time compression is inevitable
You require more vespene gas
Nuclear launch detected
You must construct additional pylons
I'm Commander Shepard!
War... War has changed.
Rip and tear until it is done!
Dawn of Sorrow awaits
Do you feel like a hero yet?
You're a monster. You know that, Walker?
Halo... it's not a natural formation.
Whip it good, Belmont!
# ---------------------------------------------------------------------------
# HARD (41-80 characters)
# ---------------------------------------------------------------------------
What is a man? A miserable pile of secrets!
Die monster! You don't belong in this world!
My name is Dracula, and I bid you welcome.
You have no chance to survive make your time!
You've met with a terrible fate, haven't you?
Fear the old blood... and welcome, good hunter.
Welcome to the Liandri Grand Tournament!
I am the very model of a scientist Salarian!
Metroid Prime has escaped into the impact crater!
I want to be the very best, like no one ever was
The world needs only one Big Boss... and one Snake.
Snake, do you think love can bloom on a battlefield?
Thank you Mario, but our princess is in another castle!
You must gather your party before venturing forth
Snake, we're not tools of the government, or anyone else
Did I ever tell you what the definition of insanity is?
They're everywhere! The demons... they won't stop coming!
Dracula! Your time has come! The Vampire Killer strikes!
Link... I'm Navi, your fairy companion! Listen!
Hero of Time, your destiny awaits in the Sacred Realm
Master Chief, finish the fight. Earth is counting on you.
Outer Heaven... a place where warriors can find purpose
We passed the point of no return a long time ago, Snake
Liquid! I was the one who was meant to be the successor!
Revolver Ocelot is a triple agent working for the Patriots
Phazon corruption detected! Seek immediate medical attention!
Dark Samus has absorbed the Phazon and grown more powerful!
I'm the Doom Slayer, and I'm here to kill every last one of you!
Praise the Chosen Undead, for they shall link the fire!
Ganon is the evil king who stole the Triforce of Power!
The legendary soldier who defied his genes... Big Boss
Killing spree! Monster kill! Godlike! Unstoppable!
Humanity restored! The bonfire blazes with newfound strength!
You are the last line of defense against an infinite demonic army
Liquid Snake... your dominant genes... give you the edge in battle!
All we did was give meaning to the nuclear age by using nukes!
I'm a soldier who's been betrayed, abandoned... I fight alone now
Samus Aran, the last of the Chozo warriors, descends into the unknown
You are the Chosen Undead, fated to succeed where so many have failed
The price of living in the past is a slow death in the present
A sword wields no strength unless the hands that hold it have courage
The flow of time is always cruel, its speed seems different for each person
Hey! Listen! There's something important you need to know!
We are born of the blood, made men by the blood, undone by the blood
War has changed. It's no longer about nations, ideologies, or ethnicity
Mental has sent his armies, and I am all that stands between them and Earth!
I'm no hero. Never was. Never will be. I'm just an old killer.
What is a man? A miserable pile of secrets! But enough talk... have at you!
I used to be an adventurer like you, then I took an arrow in the knee
I'm not the only one who's responsible. The humans are just as guilty!
The Skaarj have invaded, and you must fight your way to freedom
# ---------------------------------------------------------------------------
# EXTREME (81+ characters) -- Full quotes. No mercy.
# ---------------------------------------------------------------------------
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
What is a man? A miserable pile of secrets! But enough talk... have at you!
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
I've been waiting for this... a professional killer. I'm so excited I may be sick!
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
I need scissors! 61!
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?

297
internal/bot/appservice.go Normal file
View File

@@ -0,0 +1,297 @@
package bot
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// presenceHeartbeatInterval is how often the appservice re-asserts its online
// presence. In masdevice mode the /sync long-poll refreshed presence implicitly;
// appservice mode has no /sync, so we push presence explicitly. Synapse ties
// "online" to recent activity and force-offlines a non-syncing user ~30s after its
// last update (SYNC_ONLINE_TIMEOUT). A slower tick makes the bot FLAP (green right
// after each PUT, then offline until the next) — empirically observed at 60s. So we
// tick well under 30s to hold it continuously green. A hard crash stops the
// heartbeat and Synapse offlines the bot within ~30s on its own.
const presenceHeartbeatInterval = 20 * time.Second
// Waits applied when an inbound event arrives before its megolm session does.
// The short wait covers the common race (keys moments behind the event); only
// after it lapses do we spend an m.room_key_request and wait the long one. The
// budget bounds the whole recovery so a permanently-unreadable event can't pin a
// goroutine forever. Mirrors cryptohelper's own 3s/22s ladder.
const (
initialSessionWait = 3 * time.Second
extendedSessionWait = 22 * time.Second
sessionRecoveryBudget = initialSessionWait + extendedSessionWait + 30*time.Second
)
// cryptoToDeviceTypes are the to-device event types the crypto machine must see
// to establish Olm/Megolm sessions and share/receive room keys. In /sync mode
// the cryptohelper gets these automatically; under the appservice transaction
// model we route each one to mach.HandleToDeviceEvent ourselves.
var cryptoToDeviceTypes = []event.Type{
event.ToDeviceEncrypted,
event.ToDeviceRoomKey,
event.ToDeviceForwardedRoomKey,
event.ToDeviceRoomKeyRequest,
event.ToDeviceRoomKeyWithheld,
event.ToDeviceOrgMatrixRoomKeyWithheld,
event.ToDeviceSecretRequest,
event.ToDeviceSecretSend,
event.ToDeviceDummy,
}
// newAppserviceSession builds the appservice-mode Session: as_token auth, a
// cryptohelper that mints the bot's device via MSC4190, and an EventProcessor
// fed by Synapse's transaction pushes (in place of /sync). The bot is an
// appservice user — Synapse forbids AS users from /sync, so all events, plus the
// E2EE extensions (to-device / device lists / OTK counts), arrive over the
// transaction API instead.
func newAppserviceSession(cfg Config) (*Session, error) {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
if cfg.UserID == "" {
return nil, fmt.Errorf("BOT_USER_ID is required")
}
if cfg.RegistrationPath == "" {
return nil, fmt.Errorf("AS_REGISTRATION is required in appservice mode")
}
if cfg.HomeserverDomain == "" {
return nil, fmt.Errorf("HOMESERVER_DOMAIN is required in appservice mode (server_name, e.g. parodia.dev)")
}
if !((&appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort}).IsConfigured()) {
return nil, fmt.Errorf("AS_LISTEN_HOST/AS_LISTEN_PORT must be set in appservice mode")
}
ctx := context.Background()
reg, err := appservice.LoadRegistration(cfg.RegistrationPath)
if err != nil {
return nil, fmt.Errorf("load appservice registration %q: %w", cfg.RegistrationPath, err)
}
// Gate for to-device delivery: handleTransaction only pumps to-device events
// when this is set (appservice/http.go). Force it on regardless of the yaml so
// E2EE key exchange can't silently break on a missing field.
reg.EphemeralEvents = true
as, err := appservice.CreateFull(appservice.CreateOpts{
Registration: reg,
HomeserverDomain: cfg.HomeserverDomain,
HomeserverURL: cfg.Homeserver,
HostConfig: appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort},
})
if err != nil {
return nil, fmt.Errorf("create appservice: %w", err)
}
// Surface the HTTP listener + transaction logs (default is a silent Nop).
as.Log = zerolog.New(os.Stderr).With().Timestamp().Str("component", "appservice").Logger().
Level(zerolog.InfoLevel)
userID := id.UserID(cfg.UserID)
if as.BotMXID() != userID {
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
}
// Resolve room state (is it encrypted, who is in it) from the server on first
// use, since there is no /sync to backfill it. Must be installed before the
// first BotClient() call: makeClient copies as.StateStore into the client, and
// caches the client.
inner, ok := as.StateStore.(innerStateStore)
if !ok {
return nil, fmt.Errorf("appservice state store %T does not implement crypto.StateStore", as.StateStore)
}
store := newLazyStateStore(inner)
as.StateStore = store
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
store.client = client
// Assert the device via ?org.matrix.msc3202.device_id= on E2EE requests, and
// satisfy cryptohelper.Init's syncer check: with this set, Init permits a nil
// Syncer (we drive the crypto machine from transactions, not /sync). The param
// is only emitted once DeviceID is non-empty (url.go), so setting it now is a
// no-op for the whoami/device-create calls below; CreateDeviceMSC4190 re-sets it.
client.SetAppServiceDeviceID = true
// Validate the token + identity before we start listening.
whoami, err := client.Whoami(ctx)
if err != nil {
return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err)
}
if whoami.UserID != userID {
return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
}
slog.Info("appservice token valid", "user_id", whoami.UserID)
// ---- E2EE via cryptohelper (MSC4190 device creation, no /login) ----
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
if err != nil {
return nil, fmt.Errorf("init crypto helper: %w", err)
}
// MSC4190: the appservice creates/refreshes its own device via PUT /devices
// instead of the UIA-gated login. The crypto store persists the device ID, so
// restarts reuse it. LoginAs carries only the display name (never calls /login
// in MSC4190 mode). client.Syncer is nil here, so Init does NOT wire /sync
// handlers — we drive the crypto machine from transactions below instead.
ch.MSC4190 = true
ch.LoginAs = &mautrix.ReqLogin{InitialDeviceDisplayName: cfg.DisplayName}
if err := ch.Init(ctx); err != nil {
return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err)
}
client.Crypto = ch
// ---- Event processor: replicate the cryptohelper's /sync wiring against
// the appservice transaction channels ----
ep := appservice.NewEventProcessor(as)
mach := ch.Machine()
// Best-effort cross-signing bootstrap so the bot's device shows as verified to
// users who trust its master key. Best-effort: under MAS the key upload may be
// refused, which we log and ignore — E2EE still functions without it.
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
// Crypto plumbing that /sync would otherwise carry:
ep.OnOTK(mach.HandleOTKCounts)
ep.OnDeviceList(mach.HandleDeviceLists)
for _, t := range cryptoToDeviceTypes {
ep.On(t, mach.HandleToDeviceEvent)
}
// Keep the client's StateStore current so outgoing sends know which rooms are
// encrypted and who to share keys with (no /sync backfill here), and let the
// crypto machine track membership for key sharing. PrependHandler so state is
// updated before app handlers (auto-join/moderation) run for the same event.
ep.PrependHandler(event.StateEncryption, func(ctx context.Context, evt *event.Event) {
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
})
ep.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
mach.HandleMemberEvent(ctx, evt)
})
// recoverSession handles an event whose megolm session we don't have yet. The
// keys are often merely in flight (a to-device m.room_key racing the room
// event), so wait briefly; if they never land, ask the sender to re-share and
// wait longer. Only once that fails is the event genuinely unreadable.
//
// This duplicates cryptohelper.HandleEncrypted's wait/request ladder on
// purpose: that path is gated on a /sync token being present in the context
// (mautrix.SyncTokenContextKey), which appservice transactions never carry, so
// wiring ch.ASEventProcessor would still leave us dropping these events.
//
// Runs detached from the transaction context, which is cancelled as soon as we
// ACK the transaction — long before the keys could arrive.
recoverSession := func(evt *event.Event) {
ctx, cancel := context.WithTimeout(context.Background(), sessionRecoveryBudget)
defer cancel()
content := evt.Content.AsEncrypted()
got := ch.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWait)
if !got {
ch.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
got = ch.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWait)
}
if !got {
slog.Warn("appservice: gave up decrypting event, no room key",
"room", evt.RoomID, "event", evt.ID, "session", content.SessionID)
return
}
decrypted, err := ch.Decrypt(ctx, evt)
if err != nil {
slog.Warn("appservice: failed to decrypt event after key request",
"room", evt.RoomID, "event", evt.ID, "err", err)
return
}
slog.Debug("appservice: recovered event after key request", "room", evt.RoomID, "event", evt.ID)
ep.Dispatch(ctx, decrypted)
}
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
decrypted, err := ch.Decrypt(ctx, evt)
if err != nil {
if errors.Is(err, cryptohelper.NoSessionFound) {
go recoverSession(evt)
return
}
slog.Warn("appservice: failed to decrypt event", "room", evt.RoomID, "event", evt.ID, "err", err)
return
}
ep.Dispatch(ctx, decrypted)
})
return &Session{
Client: client,
mode: "appservice",
as: as,
ep: ep,
}, nil
}
// runAppservice starts the transaction dispatchers and the HTTP listener, then
// blocks until ctx is cancelled.
func (s *Session) runAppservice(ctx context.Context) error {
s.ep.Start(ctx)
s.as.Ready = true
errCh := make(chan struct{})
go func() {
s.as.Start() // blocks in ListenAndServe until Stop()
close(errCh)
}()
slog.Info("appservice listener started", "address", s.as.Host.Address())
select {
case <-ctx.Done():
return nil
case <-errCh:
return fmt.Errorf("appservice HTTP listener exited unexpectedly")
}
}
// runPresenceHeartbeat keeps the bot's Matrix presence "online" while the
// appservice runs. Without /sync, nothing else refreshes presence, so it would
// otherwise freeze at its last value. On graceful shutdown it best-effort sets
// presence "offline"; on a hard crash the heartbeat simply stops and Synapse
// decays the stale "online" state on its own.
func (s *Session) runPresenceHeartbeat(ctx context.Context) {
setPresence := func(ctx context.Context, presence event.Presence) {
if err := s.Client.SetPresence(ctx, mautrix.ReqPresence{Presence: presence}); err != nil {
slog.Warn("appservice: set presence failed", "presence", presence, "err", err)
}
}
setPresence(ctx, event.PresenceOnline)
ticker := time.NewTicker(presenceHeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// Detach from the cancelled ctx so the final PUT still goes out.
offCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
setPresence(offCtx, event.PresenceOffline)
cancel()
return
case <-ticker.C:
setPresence(ctx, event.PresenceOnline)
}
}
}

View File

@@ -2,203 +2,174 @@ package bot
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"gogobee/internal/util"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/id"
)
// DeviceInfo holds persisted device credentials.
type DeviceInfo struct {
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
}
// Config holds the bot's startup configuration.
type Config struct {
Homeserver string
UserID string
Password string
UserID string // Bot's full Matrix user ID, e.g. @twinbee:parodia.dev
DataDir string
DisplayName string
// AuthMode selects how the bot connects to Matrix:
// "masdevice" (default) — MAS OAuth 2.0 device grant + /sync (masauth.go).
// "appservice" — Matrix appservice: as_token auth + Synapse→bot
// transaction push (appservice.go). MAS-durable:
// no login, no MFA, no token expiry ever.
// The device-grant path is retained as an instant rollback: flip AUTH_MODE
// back to masdevice and restart, no rebuild.
AuthMode string
// ---- appservice mode only ----
// RegistrationPath points at the appservice registration YAML (id, as_token,
// hs_token, sender_localpart, namespaces). Synapse and the bot share it.
RegistrationPath string
// ListenHost/ListenPort is where the bot's HTTP listener binds to receive
// Synapse's transaction pushes (Synapse reaches it via the registration url).
ListenHost string
ListenPort uint16
// HomeserverDomain is the server_name (e.g. parodia.dev), needed to derive
// the bot's MXID from sender_localpart.
HomeserverDomain string
}
// NewClient creates and configures a mautrix client with E2EE support.
// The cryptohelper handles:
// - Persistent crypto store in SQLite (device keys, sessions, cross-signing keys)
// - Automatic cross-signing bootstrap (self-signs the device on first run)
// - Automatic device trust via cross-signing (no manual verification needed)
// - Megolm session sharing and key exchange
// - Olm session management for device-to-device encryption
// NewClient creates and configures a mautrix client authenticated against
// Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with
// E2EE via the cryptohelper.
//
// This solves the TS version's device verification issues because:
// 1. Crypto state persists across restarts (not in-memory like fake-indexeddb)
// 2. Cross-signing makes other devices trust this bot automatically
// 3. The bot trusts all users' devices by default (appropriate for a bot)
// 4. No manual emoji/SAS verification needed
// Auth flow (see masauth.go for detail):
// - Discover MAS OAuth endpoints from the homeserver's well-known + OIDC docs.
// - If we have a persisted refresh token, refresh it for a fresh access token.
// - Otherwise run the device-authorization grant: print a URL + code for a
// human to approve ONCE in a browser (as the bot's user), then store the
// resulting access + refresh tokens.
// - A background goroutine refreshes the access token before it expires and
// updates the live client, so the bot runs indefinitely without re-auth.
//
// The bot stays a normal Matrix user (not an appservice), so /sync works — an
// appservice user is forbidden from /sync by Synapse. E2EE is unchanged: the
// cryptohelper persists device keys, olm/megolm sessions and cross-signing in
// its own SQLite store, and the bot trusts all users' devices by default.
func NewClient(cfg Config) (*mautrix.Client, error) {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
if cfg.UserID == "" {
return nil, fmt.Errorf("BOT_USER_ID is required")
}
devicePath := filepath.Join(cfg.DataDir, "device.json")
ctx := context.Background()
// Try to load existing device credentials
device, err := loadDevice(devicePath)
// ---- MAS OAuth device-grant authentication ----
auth := newMASAuth(cfg.DataDir)
auth.load()
if err := auth.discover(cfg.Homeserver); err != nil {
return nil, fmt.Errorf("MAS discovery: %w", err)
}
if auth.refreshToken != "" {
// Returning start: refresh the stored token.
if err := auth.refresh(); err != nil {
return nil, fmt.Errorf("MAS token refresh failed (delete %s to re-authorize): %w",
filepath.Join(cfg.DataDir, "mas_auth.json"), err)
}
slog.Info("MAS auth: refreshed access token from stored session", "device_id", auth.deviceID)
} else {
// First run: register a client and run the interactive device grant.
if err := auth.ensureClient(cfg.DisplayName); err != nil {
return nil, fmt.Errorf("MAS client registration: %w", err)
}
if err := auth.deviceFlow(ctx); err != nil {
return nil, fmt.Errorf("MAS device authorization: %w", err)
}
}
userID := id.UserID(cfg.UserID)
client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token())
if err != nil {
slog.Info("no existing device found, will login fresh")
return nil, fmt.Errorf("create client: %w", err)
}
client.DeviceID = id.DeviceID(auth.deviceID)
var client *mautrix.Client
if device != nil {
// Validate existing token
valid, _ := util.IsTokenValid(cfg.Homeserver, device.AccessToken)
if valid {
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
userID := id.UserID(device.UserID)
client, err = mautrix.NewClient(cfg.Homeserver, userID, device.AccessToken)
if err != nil {
return nil, fmt.Errorf("create client with existing token: %w", err)
}
client.DeviceID = id.DeviceID(device.DeviceID)
} else {
slog.Warn("existing device credentials invalid, logging in again")
device = nil
}
// Validate the token + identity before proceeding.
whoami, err := client.Whoami(ctx)
if err != nil {
return nil, fmt.Errorf("token validation failed (whoami): %w", err)
}
if device == nil {
// Fresh login
loginResp, err := util.LoginWithPassword(cfg.Homeserver, cfg.UserID, cfg.Password, cfg.DisplayName)
if err != nil {
return nil, fmt.Errorf("login: %w", err)
}
userID := id.UserID(loginResp.UserID)
client, err = mautrix.NewClient(cfg.Homeserver, userID, loginResp.AccessToken)
if err != nil {
return nil, fmt.Errorf("create client: %w", err)
}
client.DeviceID = id.DeviceID(loginResp.DeviceID)
// Save device info
device = &DeviceInfo{
AccessToken: loginResp.AccessToken,
DeviceID: loginResp.DeviceID,
UserID: loginResp.UserID,
}
if err := saveDevice(devicePath, device); err != nil {
slog.Warn("failed to save device info", "err", err)
}
slog.Info("logged in successfully",
"user_id", loginResp.UserID,
"device_id", loginResp.DeviceID,
)
if whoami.UserID != userID {
return nil, fmt.Errorf("identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
}
slog.Info("MAS auth: token valid", "user_id", whoami.UserID, "device_id", client.DeviceID)
// Set up E2EE via cryptohelper — stores crypto state in its own SQLite DB,
// separate from the main app database. Unlike the TS version which used an
// in-memory fake-indexeddb store that was lost on restart (causing constant
// re-verification), mautrix-go's cryptohelper persists everything in SQLite:
// device keys, olm/megolm sessions, cross-signing keys, and device trust state.
//
// We pass just the raw file path — the cryptohelper wraps it in a file: URI
// with _txlock=immediate internally (see cryptohelper.go line 82).
// ---- E2EE via cryptohelper ----
// The crypto store persists device keys, olm/megolm sessions, cross-signing
// and device trust in its own SQLite DB. The stored device ID must match the
// one bound to our OAuth token (device: scope); a fresh mas_auth.json is
// paired with a fresh crypto.db.
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
if err != nil {
return nil, fmt.Errorf("init crypto helper: %w", err)
}
// LoginAs enables the cryptohelper to re-login if the token expires,
// and to bootstrap cross-signing on first run. Cross-signing means:
// - The bot's master key signs its own device key
// - Other users/devices that have verified the bot's master key
// will automatically trust this device
// - No interactive emoji/SAS verification needed
ch.LoginAs = &mautrix.ReqLogin{
Type: mautrix.AuthTypePassword,
Identifier: mautrix.UserIdentifier{
Type: mautrix.IdentifierTypeUser,
User: cfg.UserID,
},
Password: cfg.Password,
InitialDeviceDisplayName: cfg.DisplayName,
}
if err := ch.Init(context.Background()); err != nil {
// No LoginAs: we already have a token + device ID; the cryptohelper just
// attaches E2EE to the existing session.
if err := ch.Init(ctx); err != nil {
return nil, fmt.Errorf("crypto helper init: %w", err)
}
// Attach crypto helper to client
client.Crypto = ch
// Bootstrap cross-signing: generate keys, sign own device, sign master key.
// This makes the bot's device show as "verified" to other users.
// Best-effort cross-signing bootstrap (makes the bot's device show as
// verified to users who trust its master key). Not required for E2EE to
// function; under MAS the key upload may be refused, which we ignore.
mach := ch.Machine()
recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
return map[string]interface{}{
"type": mautrix.AuthTypePassword,
"identifier": map[string]interface{}{
"type": mautrix.IdentifierTypeUser,
"user": cfg.UserID,
},
"password": cfg.Password,
"session": ui.Session,
}
}, "")
if err != nil {
slog.Warn("cross-signing: key upload failed (may already exist)", "err", err)
} else {
slog.Info("cross-signing: keys uploaded", "recovery_key", recoveryKey)
}
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil {
slog.Warn("cross-signing: sign own device failed", "err", err)
} else {
slog.Info("cross-signing: own device signed")
}
if err := mach.SignOwnMasterKey(context.Background()); err != nil {
slog.Warn("cross-signing: sign master key failed", "err", err)
} else {
slog.Info("cross-signing: master key signed")
}
// ---- Background token refresher ----
// Refresh ~60s before expiry and push the new token into the live client so
// in-flight /sync and API calls keep authenticating.
go refreshLoop(context.Background(), auth, client)
slog.Info("E2EE initialized",
"user_id", client.UserID,
"device_id", client.DeviceID,
"crypto_store", "sqlite-persistent",
"auth", "mas-oauth-device-grant",
)
return client, nil
}
func loadDevice(path string) (*DeviceInfo, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
// refreshLoop keeps the client's access token fresh for the life of the process.
func refreshLoop(ctx context.Context, auth *masAuth, client *mautrix.Client) {
for {
wait := time.Until(auth.expiry()) - 60*time.Second
if wait < 10*time.Second {
wait = 10 * time.Second
}
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
if err := auth.refresh(); err != nil {
slog.Error("MAS token refresh failed; retrying in 30s", "err", err)
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
continue
}
client.AccessToken = auth.token()
slog.Debug("MAS token refreshed", "expires_at", auth.expiry().Format(time.RFC3339))
}
var info DeviceInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, err
}
return &info, nil
}
func saveDevice(path string, info *DeviceInfo) error {
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}

View File

@@ -0,0 +1,137 @@
package bot
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
)
// crossSigningFile is the on-disk store for the bot's cross-signing recovery key
// (data/cross_signing.json), alongside mas_auth.json and crypto.db. The key
// decrypts the private cross-signing keys the bot parks in SSSS, so it is the
// only thing that lets a rebuilt crypto.db re-sign the bot's device instead of
// minting a whole new identity and forcing everyone to re-verify.
//
// It is deliberately NOT logged: the screen wrapper pipes the bot's output
// through tee, which truncates the log on every restart, so a key that only ever
// exists in a log line is a key you lose on the next boot.
const crossSigningFile = "cross_signing.json"
type crossSigningStore struct {
RecoveryKey string `json:"recovery_key"`
}
func crossSigningPath(dataDir string) string {
return filepath.Join(dataDir, crossSigningFile)
}
// loadRecoveryKey returns the stored key, or "" when there is none.
func loadRecoveryKey(dataDir string) string {
data, err := os.ReadFile(crossSigningPath(dataDir))
if err != nil {
return "" // fresh install
}
var s crossSigningStore
if err := json.Unmarshal(data, &s); err != nil {
slog.Warn("cross-signing: corrupt recovery-key store, ignoring", "path", crossSigningPath(dataDir), "err", err)
return ""
}
return s.RecoveryKey
}
func saveRecoveryKey(dataDir, key string) {
data, err := json.MarshalIndent(crossSigningStore{RecoveryKey: key}, "", " ")
if err != nil {
slog.Error("cross-signing: marshal recovery key failed", "err", err)
return
}
path := crossSigningPath(dataDir)
if err := os.WriteFile(path, data, 0o600); err != nil {
slog.Error("cross-signing: could not persist recovery key; a crypto.db rebuild will need a re-verify", "path", path, "err", err)
return
}
slog.Info("cross-signing: recovery key persisted", "path", path)
}
// uiaSession answers a user-interactive-auth challenge with just the session ID.
// Under MAS the key upload is either allowed outright or refused; there is no
// password to offer.
func uiaSession(ui *mautrix.RespUserInteractive) interface{} {
return map[string]interface{}{"session": ui.Session}
}
// bootstrapCrossSigning establishes the bot's cross-signing identity, which is
// what lets clients show it as verified rather than as an unknown device. E2EE
// works without it; only the verification badge depends on it.
//
// It must never mint a second identity by accident. mautrix's
// GenerateAndUploadCrossSigningKeys is unconditional: every call generates a fresh
// master/self/user-signing trio and overwrites the published one. Calling it on
// each start reminted the bot's identity every boot, which is why clients kept
// asking users to re-verify. So generate only when there is no identity to keep,
// or when the operator explicitly asks for a reset.
//
// The private keys live server-side in SSSS, never in crypto.db, and mach.Load
// does not restore them. A bot that keeps its crypto.db stays signed from its
// first signing and needs nothing here. A bot whose crypto.db was rebuilt has a
// brand-new device that only the recovery key can re-sign.
//
// Set CROSS_SIGNING_REGENERATE=1 to deliberately reset the identity (costs one
// re-verify per user, and stores the fresh key). CROSS_SIGNING_RECOVERY_KEY
// imports an existing key into the store, for adopting an identity created before
// the bot persisted its own.
func bootstrapCrossSigning(ctx context.Context, mach *crypto.OlmMachine, dataDir string) {
stored := loadRecoveryKey(dataDir)
if envKey := os.Getenv("CROSS_SIGNING_RECOVERY_KEY"); envKey != "" && envKey != stored {
saveRecoveryKey(dataDir, envKey)
stored = envKey
}
hasKeys, isVerified, err := mach.GetOwnVerificationStatus(ctx)
if err != nil {
slog.Warn("cross-signing: could not determine verification status, leaving identity alone", "err", err)
return
}
regenerate := os.Getenv("CROSS_SIGNING_REGENERATE") != ""
switch {
case regenerate || !hasKeys:
if regenerate && hasKeys {
slog.Warn("cross-signing: CROSS_SIGNING_REGENERATE set — replacing the published identity; every user must verify the bot once more. Unset it after this start.")
}
key, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, uiaSession, "")
if err != nil {
slog.Warn("cross-signing: key upload failed, bot will show unverified", "err", err)
return
}
saveRecoveryKey(dataDir, key)
if err := mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil {
slog.Warn("cross-signing: sign own device failed", "err", err)
}
if err := mach.SignOwnMasterKey(ctx); err != nil {
slog.Warn("cross-signing: sign master key failed", "err", err)
}
slog.Info("cross-signing: identity created and device signed")
case isVerified:
slog.Info("cross-signing: identity already published and this device is signed")
case stored != "":
// Pulls the private keys back out of SSSS, then signs this device and the
// master key with them.
if err := mach.VerifyWithRecoveryKey(ctx, stored); err != nil {
slog.Warn("cross-signing: recovery-key restore failed, bot will show unverified", "err", err)
return
}
slog.Info("cross-signing: device re-signed from the stored recovery key")
default:
slog.Warn("cross-signing: this device is unsigned and no recovery key is stored, so the bot will show unverified (E2EE still works). Set CROSS_SIGNING_REGENERATE=1 once to mint a fresh identity.",
"store", crossSigningPath(dataDir))
}
}

View File

@@ -0,0 +1,56 @@
package bot
import (
"os"
"path/filepath"
"testing"
)
func TestRecoveryKeyRoundTrip(t *testing.T) {
dir := t.TempDir()
if got := loadRecoveryKey(dir); got != "" {
t.Fatalf("fresh install returned %q, want empty", got)
}
const key = "EsTd o49d mMLt 3Uf9 Gjn9 x5fv YE9H wF6n aadC q2D8 Fv7j rQ4c"
saveRecoveryKey(dir, key)
if got := loadRecoveryKey(dir); got != key {
t.Fatalf("loadRecoveryKey() = %q, want %q", got, key)
}
}
// The key is crypto material sitting next to crypto.db; it must not be readable
// by other users on the host.
func TestRecoveryKeyFileIsPrivate(t *testing.T) {
dir := t.TempDir()
saveRecoveryKey(dir, "some-key")
info, err := os.Stat(filepath.Join(dir, crossSigningFile))
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("recovery key file mode = %o, want 600", perm)
}
}
// A corrupt store must degrade to "no key" rather than panicking or returning
// garbage that would be fed to VerifyWithRecoveryKey.
func TestRecoveryKeyCorruptStore(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, crossSigningFile), []byte("{not json"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if got := loadRecoveryKey(dir); got != "" {
t.Fatalf("corrupt store returned %q, want empty", got)
}
}
// Overwriting an adopted key must not leave trailing bytes from the longer value.
func TestRecoveryKeyOverwrite(t *testing.T) {
dir := t.TempDir()
saveRecoveryKey(dir, "a-much-longer-original-recovery-key-value")
saveRecoveryKey(dir, "short")
if got := loadRecoveryKey(dir); got != "short" {
t.Fatalf("loadRecoveryKey() = %q, want %q", got, "short")
}
}

View File

@@ -2,6 +2,8 @@ package bot
import (
"log/slog"
"os"
"strings"
"sync"
"gogobee/internal/plugin"
@@ -9,13 +11,24 @@ import (
// Registry manages plugin registration and event dispatch.
type Registry struct {
mu sync.RWMutex
plugins []plugin.Plugin
mu sync.RWMutex
plugins []plugin.Plugin
ignoredBots map[string]struct{}
}
// NewRegistry creates an empty plugin registry.
// NewRegistry creates an empty plugin registry. Senders listed in the
// IGNORED_BOTS env var (comma-separated full Matrix user IDs, e.g.
// "@pete:parodia.dev") are dropped before any plugin sees them.
func NewRegistry() *Registry {
return &Registry{}
ignored := make(map[string]struct{})
if raw := os.Getenv("IGNORED_BOTS"); raw != "" {
for _, u := range strings.Split(raw, ",") {
if u = strings.TrimSpace(u); u != "" {
ignored[u] = struct{}{}
}
}
}
return &Registry{ignoredBots: ignored}
}
// Register adds a plugin to the registry.
@@ -42,6 +55,9 @@ func (r *Registry) Init() error {
// DispatchMessage sends a message context to all plugins in order.
func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
return
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, p := range r.plugins {
@@ -71,6 +87,9 @@ func (r *Registry) safeOnMessage(p plugin.Plugin, ctx plugin.MessageContext) {
// DispatchReaction sends a reaction context to all plugins in order.
func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) {
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
return
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, p := range r.plugins {

375
internal/bot/masauth.go Normal file
View File

@@ -0,0 +1,375 @@
package bot
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// masAuth performs MAS (Matrix Authentication Service) OAuth 2.0 device-grant
// authentication for the bot and keeps a valid access token available.
//
// Why this exists: MAS replaces Matrix's legacy password login with OAuth2. A
// bot can't do the interactive authorization-code flow, so we use the OAuth 2.0
// Device Authorization Grant (RFC 8628): on first run the bot prints a URL +
// user code, a human approves the login as the bot's user ONCE in a browser,
// and MAS returns an access token + refresh token. Thereafter the bot refreshes
// silently forever — no password, no expiry surprises, and the bot stays a
// normal Matrix user so /sync keeps working (unlike an appservice user, which
// Synapse forbids from /sync).
//
// The refresh token is rotated by MAS on every refresh, so we persist the new
// one each time.
type masAuth struct {
http *http.Client
storePath string
scope string
// discovered OAuth endpoints
deviceEndpoint string
tokenEndpoint string
registrationEndpoint string
mu sync.RWMutex
clientID string
deviceID string
accessToken string
refreshToken string
expiresAt time.Time
}
// masStore is the on-disk persisted state (data/mas_auth.json).
type masStore struct {
ClientID string `json:"client_id"`
DeviceID string `json:"device_id"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
}
const masScopeAPI = "urn:matrix:org.matrix.msc2967.client:api:*"
func newMASAuth(dataDir string) *masAuth {
return &masAuth{
http: &http.Client{Timeout: 30 * time.Second},
storePath: filepath.Join(dataDir, "mas_auth.json"),
}
}
func (m *masAuth) load() {
data, err := os.ReadFile(m.storePath)
if err != nil {
return // fresh install
}
var s masStore
if err := json.Unmarshal(data, &s); err != nil {
slog.Warn("mas_auth: corrupt store, ignoring", "err", err)
return
}
m.clientID = s.ClientID
m.deviceID = s.DeviceID
m.accessToken = s.AccessToken
m.refreshToken = s.RefreshToken
m.expiresAt = s.ExpiresAt
}
func (m *masAuth) save() {
m.mu.RLock()
s := masStore{
ClientID: m.clientID,
DeviceID: m.deviceID,
AccessToken: m.accessToken,
RefreshToken: m.refreshToken,
ExpiresAt: m.expiresAt,
}
m.mu.RUnlock()
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
slog.Error("mas_auth: marshal failed", "err", err)
return
}
if err := os.WriteFile(m.storePath, data, 0o600); err != nil {
slog.Error("mas_auth: write failed", "err", err)
}
}
func (m *masAuth) token() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.accessToken
}
// discover resolves the MAS OAuth endpoints from the homeserver's well-known
// document and the issuer's OIDC discovery document.
func (m *masAuth) discover(homeserver string) error {
var wk struct {
Auth struct {
Issuer string `json:"issuer"`
} `json:"org.matrix.msc2965.authentication"`
}
if err := m.getJSON(strings.TrimRight(homeserver, "/")+"/.well-known/matrix/client", &wk); err != nil {
return fmt.Errorf("fetch well-known: %w", err)
}
if wk.Auth.Issuer == "" {
return fmt.Errorf("homeserver does not advertise a MAS issuer (msc2965) — is MAS enabled?")
}
var oidc struct {
DeviceEndpoint string `json:"device_authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
}
if err := m.getJSON(strings.TrimRight(wk.Auth.Issuer, "/")+"/.well-known/openid-configuration", &oidc); err != nil {
return fmt.Errorf("fetch OIDC discovery: %w", err)
}
if oidc.DeviceEndpoint == "" || oidc.TokenEndpoint == "" {
return fmt.Errorf("MAS issuer %q does not advertise a device/token endpoint", wk.Auth.Issuer)
}
m.deviceEndpoint = oidc.DeviceEndpoint
m.tokenEndpoint = oidc.TokenEndpoint
m.registrationEndpoint = oidc.RegistrationEndpoint
slog.Info("mas_auth: discovered endpoints", "issuer", wk.Auth.Issuer)
return nil
}
// ensureClient registers a public OAuth client via dynamic registration if we
// don't already have a client_id persisted.
func (m *masAuth) ensureClient(displayName string) error {
if m.clientID != "" {
return nil
}
if m.registrationEndpoint == "" {
return fmt.Errorf("no registration endpoint discovered")
}
body := map[string]any{
"client_name": displayName + " (bot)",
"application_type": "native",
"token_endpoint_auth_method": "none",
"grant_types": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
"response_types": []string{},
"client_uri": "https://github.com/prosolis/gogobee",
}
raw, _ := json.Marshal(body)
resp, err := m.http.Post(m.registrationEndpoint, "application/json", strings.NewReader(string(raw)))
if err != nil {
return fmt.Errorf("client registration request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 && resp.StatusCode != 201 {
return fmt.Errorf("client registration failed (HTTP %d): %s", resp.StatusCode, string(rb))
}
var out struct {
ClientID string `json:"client_id"`
}
if err := json.Unmarshal(rb, &out); err != nil || out.ClientID == "" {
return fmt.Errorf("client registration: no client_id in response: %s", string(rb))
}
m.mu.Lock()
m.clientID = out.ClientID
m.mu.Unlock()
m.save()
slog.Info("mas_auth: registered OAuth client", "client_id", out.ClientID)
return nil
}
// deviceFlow runs the interactive device-authorization grant. It blocks until
// the user approves the login (or the code expires).
func (m *masAuth) deviceFlow(ctx context.Context) error {
if m.deviceID == "" {
m.deviceID = randomDeviceID()
}
scope := masScopeAPI + " urn:matrix:org.matrix.msc2967.client:device:" + m.deviceID
form := url.Values{"client_id": {m.clientID}, "scope": {scope}}
var da struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
if err := m.postForm(m.deviceEndpoint, form, &da); err != nil {
return fmt.Errorf("device authorization request: %w", err)
}
// Surface the approval prompt prominently — the operator must act on it.
uri := da.VerificationURI
if da.VerificationURIComplete != "" {
uri = da.VerificationURIComplete
}
banner := fmt.Sprintf(`
================= TwinBee needs you to authorize its login =================
Open this URL in a browser (logged in as the bot's Matrix user):
%s
and enter code: %s
(expires in %d minutes)
============================================================================
`, uri, da.UserCode, da.ExpiresIn/60)
fmt.Print(banner)
slog.Warn("mas_auth: device authorization required", "verification_uri", da.VerificationURI, "user_code", da.UserCode, "expires_in_s", da.ExpiresIn)
interval := da.Interval
if interval <= 0 {
interval = 5
}
deadline := time.Now().Add(time.Duration(da.ExpiresIn) * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(interval) * time.Second):
}
pending, err := m.pollToken(url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {da.DeviceCode},
"client_id": {m.clientID},
})
if err != nil {
return err
}
if !pending {
slog.Info("mas_auth: device authorization approved", "device_id", m.deviceID)
return nil
}
}
return fmt.Errorf("device authorization timed out (not approved within %d minutes)", da.ExpiresIn/60)
}
// pollToken hits the token endpoint. Returns pending=true if the user hasn't
// approved yet (keep polling); on success it stores the tokens.
func (m *masAuth) pollToken(form url.Values) (pending bool, err error) {
resp, err := m.http.PostForm(m.tokenEndpoint, form)
if err != nil {
return false, fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 200 {
return false, m.storeTokenResponse(rb)
}
var oerr struct {
Error string `json:"error"`
}
_ = json.Unmarshal(rb, &oerr)
switch oerr.Error {
case "authorization_pending":
return true, nil
case "slow_down":
return true, nil
default:
return false, fmt.Errorf("token endpoint error (HTTP %d): %s", resp.StatusCode, string(rb))
}
}
// refresh exchanges the stored refresh token for a fresh access token. MAS
// rotates the refresh token, so we persist the new one.
func (m *masAuth) refresh() error {
m.mu.RLock()
rt, cid := m.refreshToken, m.clientID
m.mu.RUnlock()
if rt == "" {
return fmt.Errorf("no refresh token")
}
resp, err := m.http.PostForm(m.tokenEndpoint, url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {rt},
"client_id": {cid},
})
if err != nil {
return fmt.Errorf("refresh request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(rb))
}
return m.storeTokenResponse(rb)
}
func (m *masAuth) storeTokenResponse(rb []byte) error {
var tr struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(rb, &tr); err != nil {
return fmt.Errorf("parse token response: %w", err)
}
if tr.AccessToken == "" {
return fmt.Errorf("token response missing access_token: %s", string(rb))
}
m.mu.Lock()
m.accessToken = tr.AccessToken
if tr.RefreshToken != "" {
m.refreshToken = tr.RefreshToken
}
ttl := tr.ExpiresIn
if ttl <= 0 {
ttl = 300
}
m.expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
m.mu.Unlock()
m.save()
return nil
}
func (m *masAuth) expiry() time.Time {
m.mu.RLock()
defer m.mu.RUnlock()
return m.expiresAt
}
// getJSON GETs a URL and decodes JSON.
func (m *masAuth) getJSON(u string, out any) error {
resp, err := m.http.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("GET %s: HTTP %d: %s", u, resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
// postForm POSTs a form and decodes a JSON success body (200).
func (m *masAuth) postForm(u string, form url.Values, out any) error {
resp, err := m.http.PostForm(u, form)
if err != nil {
return err
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("POST %s: HTTP %d: %s", u, resp.StatusCode, string(rb))
}
return json.Unmarshal(rb, out)
}
// randomDeviceID mirrors mautrix's device-id style: 10 uppercase letters.
func randomDeviceID() string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
b := make([]byte, 10)
if _, err := rand.Read(b); err != nil {
// rand.Read essentially never fails; fall back to a fixed prefix.
return "GOGOBEEBOT"
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return "GB" + string(b[:8])
}

140
internal/bot/session.go Normal file
View File

@@ -0,0 +1,140 @@
package bot
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
)
// Session is the bot's live Matrix connection plus its event source. It hides
// the difference between the two transports the bot can run under:
//
// - masdevice: a normal Matrix user authenticated via the MAS OAuth device
// grant, receiving events over /sync (mautrix DefaultSyncer).
// - appservice: a Synapse appservice authenticated by its as_token, receiving
// events pushed over the appservice transaction API (mautrix EventProcessor).
//
// Callers register the same three handlers (message/member/reaction) via
// OnEventType and call Run regardless of mode; the plugin layer never knows which
// transport is in use.
type Session struct {
Client *mautrix.Client
mode string
// masdevice
syncer *mautrix.DefaultSyncer
// appservice
as *appservice.AppService
ep *appservice.EventProcessor
presenceOnce sync.Once
}
// EventHandler matches both DefaultSyncer.OnEventType and EventProcessor.On.
type EventHandler = func(ctx context.Context, evt *event.Event)
// NewSession builds a Session for the configured auth mode. Default and
// "masdevice" use the MAS device grant; "appservice" uses the transaction model.
func NewSession(cfg Config) (*Session, error) {
switch cfg.AuthMode {
case "appservice":
return newAppserviceSession(cfg)
case "", "masdevice":
return newMASDeviceSession(cfg)
default:
return nil, fmt.Errorf("unknown AUTH_MODE %q (want appservice or masdevice)", cfg.AuthMode)
}
}
// newMASDeviceSession wraps the existing device-grant client (NewClient) and its
// DefaultSyncer. This is the retained rollback path — unchanged behaviour.
func newMASDeviceSession(cfg Config) (*Session, error) {
client, err := NewClient(cfg)
if err != nil {
return nil, err
}
return &Session{
Client: client,
mode: "masdevice",
syncer: client.Syncer.(*mautrix.DefaultSyncer),
}, nil
}
// OnEventType registers a handler for the given event type against whichever
// event source backs this session.
func (s *Session) OnEventType(evtType event.Type, fn EventHandler) {
switch s.mode {
case "appservice":
s.ep.On(evtType, fn)
default:
s.syncer.OnEventType(evtType, fn)
}
}
// StartPresence begins actively maintaining the bot's Matrix presence, tied to
// ctx. In appservice mode it launches the online heartbeat (runPresenceHeartbeat);
// masdevice mode relies on the /sync long-poll's implicit presence refresh, so it
// is a no-op there. Call this early — before the slow plugin init — so the bot
// shows online from boot rather than only once the transaction listener starts
// (~2min later). The presence PUT is outbound-only and needs no event handlers, so
// starting it ahead of the listener is safe. Idempotent: extra calls do nothing.
func (s *Session) StartPresence(ctx context.Context) {
if s.mode != "appservice" {
return
}
s.presenceOnce.Do(func() {
go s.runPresenceHeartbeat(ctx)
})
}
// Run blocks, delivering events to registered handlers, until ctx is cancelled.
func (s *Session) Run(ctx context.Context) error {
switch s.mode {
case "appservice":
return s.runAppservice(ctx)
default:
return s.runSync(ctx)
}
}
// Stop halts the event source. Safe to call once.
func (s *Session) Stop() {
switch s.mode {
case "appservice":
if s.ep != nil {
s.ep.Stop()
}
if s.as != nil {
s.as.Stop()
}
default:
s.Client.StopSync()
}
}
// runSync is the device-grant /sync loop (moved verbatim from main.go): restart
// on transient failure, exit on context cancel.
func (s *Session) runSync(ctx context.Context) error {
for {
err := s.Client.SyncWithContext(ctx)
if ctx.Err() != nil {
return nil // shutdown requested
}
if err != nil {
slog.Error("sync stopped, restarting in 5s", "err", err)
} else {
slog.Warn("sync returned without error, restarting in 5s")
}
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return nil
}
}
}

164
internal/bot/statestore.go Normal file
View File

@@ -0,0 +1,164 @@
package bot
import (
"context"
"errors"
"fmt"
"sync"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// lazyStateStore resolves room state from the homeserver on first use instead of
// waiting for it to arrive over a sync.
//
// The appservice has no /sync, so the state store only ever learns a room is
// encrypted from an m.room.encryption event pushed in a transaction — which for
// an already-configured room never happens again. Anything the bot says on its
// own initiative (ambient DMs, expedition beats, briefings) therefore reached
// SendMessageEvent with IsEncrypted=false and went out as plaintext into an
// encrypted room. The state store being in-memory made every restart a fresh
// chance to leak.
//
// Both reads the send path depends on are resolved here on the first miss and
// cached: whether the room is encrypted, and who to share the group session with
// (an empty member list encrypts to nobody, which is its own outage). Neither
// read is allowed to fail open — a lookup that cannot be resolved returns an
// error, which aborts the send. A failed message is recoverable; a plaintext one
// that has already landed in an encrypted room is not.
type lazyStateStore struct {
// Embeds both interfaces the store is consumed through: the appservice
// dispatches state updates through the first, and the crypto machine type-
// asserts the client's store to the second (cryptohelper.NewCryptoHelper
// rejects a store that fails the assertion). Embedding only the appservice
// interface silently drops crypto's extra methods from the concrete type.
innerStateStore
client *mautrix.Client
mu sync.Mutex
locks map[id.RoomID]*sync.Mutex
// Rooms whose m.room.encryption state we have asked the server about. The
// underlying store cannot distinguish "not encrypted" from "never heard of
// it", so the distinction is tracked here.
encryptionResolved map[id.RoomID]bool
}
// innerStateStore is the full method set the wrapped store must provide.
type innerStateStore interface {
appservice.StateStore
crypto.StateStore
}
// The wrapper is consumed through both, and losing either one is a startup
// failure rather than a build failure without these.
var (
_ appservice.StateStore = (*lazyStateStore)(nil)
_ crypto.StateStore = (*lazyStateStore)(nil)
)
func newLazyStateStore(inner innerStateStore) *lazyStateStore {
return &lazyStateStore{
innerStateStore: inner,
locks: make(map[id.RoomID]*sync.Mutex),
encryptionResolved: make(map[id.RoomID]bool),
}
}
// lockRoom serialises resolution per room so a burst of sends into a cold room
// makes one request rather than one per message.
func (s *lazyStateStore) lockRoom(roomID id.RoomID) func() {
s.mu.Lock()
lock, ok := s.locks[roomID]
if !ok {
lock = &sync.Mutex{}
s.locks[roomID] = lock
}
s.mu.Unlock()
lock.Lock()
return lock.Unlock
}
func (s *lazyStateStore) isEncryptionResolved(roomID id.RoomID) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.encryptionResolved[roomID]
}
func (s *lazyStateStore) markEncryptionResolved(roomID id.RoomID) {
s.mu.Lock()
defer s.mu.Unlock()
s.encryptionResolved[roomID] = true
}
// IsEncrypted answers from the underlying store once the room's encryption state
// has been resolved, fetching it from the server the first time.
func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) {
if !s.isEncryptionResolved(roomID) {
unlock := s.lockRoom(roomID)
defer unlock()
if !s.isEncryptionResolved(roomID) {
var enc event.EncryptionEventContent
err := s.client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc)
switch {
case err == nil:
if enc.Algorithm != "" {
if err := s.innerStateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil {
return false, fmt.Errorf("cache encryption state for %s: %w", roomID, err)
}
}
case errors.Is(err, mautrix.MNotFound):
// No m.room.encryption at all: the room really is unencrypted.
default:
// Unresolved. Refuse rather than guess "unencrypted" and leak.
return false, fmt.Errorf("resolve encryption state for %s: %w", roomID, err)
}
s.markEncryptionResolved(roomID)
}
}
return s.innerStateStore.IsEncrypted(ctx, roomID)
}
// GetEncryptionEvent is how the crypto machine reads a room's megolm settings
// (session rotation period, etc). It needs the same resolution as IsEncrypted,
// since an unresolved room would otherwise report no encryption event.
func (s *lazyStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) {
if _, err := s.IsEncrypted(ctx, roomID); err != nil {
return nil, err
}
return s.innerStateStore.GetEncryptionEvent(ctx, roomID)
}
// GetRoomJoinedOrInvitedMembers backs megolm session sharing. Without a sync
// there is no member backfill, so an unfetched room would report zero members
// and the bot would encrypt to an audience of nobody.
func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) {
fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID)
if err != nil {
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
}
if !fetched {
unlock := s.lockRoom(roomID)
defer unlock()
if fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID); err != nil {
return nil, fmt.Errorf("check member cache for %s: %w", roomID, err)
} else if !fetched {
members, err := s.client.Members(ctx, roomID)
if err != nil {
return nil, fmt.Errorf("fetch members of %s: %w", roomID, err)
}
// No membership filter, so this also marks the room as fetched.
if err := s.innerStateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil {
return nil, fmt.Errorf("cache members of %s: %w", roomID, err)
}
}
}
return s.innerStateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID)
}

View File

@@ -0,0 +1,195 @@
package bot
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/id"
)
// fakeHS serves the two endpoints the lazy store resolves against, counting hits
// so we can assert it caches instead of re-asking on every send.
type fakeHS struct {
encryptionStatus int
encryptionBody string
membersBody string
encryptionHits atomic.Int32
memberHits atomic.Int32
}
func (f *fakeHS) start(t *testing.T) *mautrix.Client {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/state/m.room.encryption"):
f.encryptionHits.Add(1)
w.WriteHeader(f.encryptionStatus)
_, _ = w.Write([]byte(f.encryptionBody))
case strings.HasSuffix(r.URL.Path, "/members"):
f.memberHits.Add(1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(f.membersBody))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
client, err := mautrix.NewClient(srv.URL, "@bot:example.org", "token")
if err != nil {
t.Fatalf("new client: %v", err)
}
return client
}
func newTestStore(t *testing.T, hs *fakeHS) *lazyStateStore {
t.Helper()
store := newLazyStateStore(mautrix.NewMemoryStateStore().(innerStateStore))
store.client = hs.start(t)
return store
}
// The store is handed to the crypto helper, which type-asserts it and refuses to
// start if it comes up short. That assertion is the whole bot's startup path, so
// pin it here rather than discovering it as a crash loop in prod.
func TestStoreSatisfiesCryptoHelper(t *testing.T) {
store := newTestStore(t, &fakeHS{})
client := store.client
client.StateStore = store
dir := t.TempDir()
if _, err := cryptohelper.NewCryptoHelper(client, []byte("test"), filepath.Join(dir, "crypto.db")); err != nil {
t.Fatalf("crypto helper rejected the state store, so the bot would not start: %v", err)
}
}
const roomID = id.RoomID("!room:example.org")
// The regression: a room configured as encrypted before the process started. No
// transaction ever re-announces it, so the store must go ask.
func TestIsEncryptedResolvesFromServer(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusOK,
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
}
store := newTestStore(t, hs)
enc, err := store.IsEncrypted(context.Background(), roomID)
if err != nil {
t.Fatalf("IsEncrypted: %v", err)
}
if !enc {
t.Fatal("room is encrypted on the server but IsEncrypted said false — this is the plaintext leak")
}
}
// A room with no m.room.encryption really is plaintext, and must not re-fetch.
func TestIsEncryptedCachesUnencrypted(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusNotFound,
encryptionBody: `{"errcode":"M_NOT_FOUND","error":"Event not found."}`,
}
store := newTestStore(t, hs)
for i := 0; i < 3; i++ {
enc, err := store.IsEncrypted(context.Background(), roomID)
if err != nil {
t.Fatalf("IsEncrypted: %v", err)
}
if enc {
t.Fatal("unencrypted room reported as encrypted")
}
}
if got := hs.encryptionHits.Load(); got != 1 {
t.Errorf("expected the M_NOT_FOUND to be cached, got %d fetches", got)
}
}
// The important half: an unresolvable room must fail the send, not quietly
// answer "not encrypted" and let the message out in the clear.
func TestIsEncryptedFailsClosed(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusInternalServerError,
encryptionBody: `{"errcode":"M_UNKNOWN","error":"oops"}`,
}
store := newTestStore(t, hs)
if _, err := store.IsEncrypted(context.Background(), roomID); err == nil {
t.Fatal("a failed lookup returned no error; the send would proceed in plaintext")
}
// The failure must not poison the cache: a later send has to retry.
hs.encryptionStatus = http.StatusOK
hs.encryptionBody = `{"algorithm":"m.megolm.v1.aes-sha2"}`
enc, err := store.IsEncrypted(context.Background(), roomID)
if err != nil {
t.Fatalf("IsEncrypted after recovery: %v", err)
}
if !enc {
t.Fatal("store did not retry after a transient failure")
}
}
// A burst of proactive sends into a cold room should resolve it once.
func TestIsEncryptedResolvesOncePerRoom(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusOK,
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
}
store := newTestStore(t, hs)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := store.IsEncrypted(context.Background(), roomID); err != nil {
t.Errorf("IsEncrypted: %v", err)
}
}()
}
wg.Wait()
if got := hs.encryptionHits.Load(); got != 1 {
t.Errorf("expected 1 state fetch for 20 concurrent sends, got %d", got)
}
}
// Encrypting to an empty member list would share the group session with nobody,
// so members must be resolved too.
func TestMembersResolveFromServer(t *testing.T) {
hs := &fakeHS{
encryptionStatus: http.StatusOK,
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
membersBody: `{"chunk":[
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@alice:example.org","sender":"@alice:example.org","content":{"membership":"join"}},
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@bot:example.org","sender":"@bot:example.org","content":{"membership":"join"}}
]}`,
}
store := newTestStore(t, hs)
members, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID)
if err != nil {
t.Fatalf("GetRoomJoinedOrInvitedMembers: %v", err)
}
if len(members) != 2 {
t.Fatalf("expected 2 members, got %d (%v) — the group session would be shared to nobody", len(members), members)
}
if _, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID); err != nil {
t.Fatalf("second call: %v", err)
}
if got := hs.memberHits.Load(); got != 1 {
t.Errorf("expected members to be cached after one fetch, got %d", got)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
package db
import (
"database/sql"
"io"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// TestProdDBMigrations runs the full migration suite against a copy of the
// real prod database to verify Phase 1 + Phase 2 schema changes apply cleanly.
//
// Gated on the prod-db file actually existing — skips silently in CI.
// Mutations write only to the tempdir copy; the real prod file is untouched.
func TestProdDBMigrations(t *testing.T) {
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
if _, err := os.Stat(src); err != nil {
t.Skip("prod db not present at " + src)
}
dir := t.TempDir()
dst := filepath.Join(dir, "gogobee.db")
copyFile(t, src, dst)
// Snapshot row counts BEFORE migration via a raw connection.
preCounts := tableCounts(t, dst, []string{
"adventure_characters",
"euro_balances",
"user_archetypes",
"adventure_equipment",
"adventure_inventory",
"adventure_treasures",
})
t.Logf("pre-migration counts: %+v", preCounts)
// Reset global state (other tests may have run Init already).
Close()
// Run migrations via the production code path.
if err := Init(dir); err != nil {
t.Fatalf("Init failed: %v", err)
}
t.Cleanup(Close)
d := Get()
// 1. New D&D tables must exist.
for _, table := range []string{
"dnd_character", "dnd_abilities", "dnd_resources",
"dnd_combat_state", "adventure_characters_pre_dnd",
} {
var n int
if err := d.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`,
table,
).Scan(&n); err != nil {
t.Fatalf("query %s: %v", table, err)
}
if n != 1 {
t.Errorf("table %s missing after migrations", table)
}
}
// 2. Existing-data row counts unchanged.
postCounts := tableCounts(t, dst, []string{
"adventure_characters",
"euro_balances",
"user_archetypes",
"adventure_equipment",
"adventure_inventory",
"adventure_treasures",
})
for k, pre := range preCounts {
if post := postCounts[k]; post != pre {
t.Errorf("row count drift in %s: pre=%d post=%d", k, pre, post)
}
}
t.Logf("post-migration counts: %+v", postCounts)
// 3. Pre-DnD snapshot populated for every adventure character.
var advCount, snapCount int
d.QueryRow(`SELECT COUNT(*) FROM adventure_characters`).Scan(&advCount)
d.QueryRow(`SELECT COUNT(*) FROM adventure_characters_pre_dnd`).Scan(&snapCount)
if snapCount != advCount {
t.Errorf("snapshot rows %d != adventure_characters %d", snapCount, advCount)
}
t.Logf("snapshot populated: %d rows", snapCount)
// 4. Snapshot JSON parses and contains key fields. SQLite's single-conn
// pool means we must drain the rows before issuing follow-up queries.
rows, err := d.Query(`SELECT user_id, snapshot_json FROM adventure_characters_pre_dnd`)
if err != nil {
t.Fatalf("query snapshot: %v", err)
}
type snapPair struct{ uid, j string }
var snaps []snapPair
for rows.Next() {
var uid, j string
if err := rows.Scan(&uid, &j); err != nil {
rows.Close()
t.Fatal(err)
}
snaps = append(snaps, snapPair{uid, j})
}
rows.Close()
for _, s := range snaps {
var checkCL int
if err := d.QueryRow(`SELECT json_extract(?, '$.combat_level')`, s.j).Scan(&checkCL); err != nil {
t.Errorf("snapshot for %s: bad json: %v", s.uid, err)
continue
}
var realCL int
d.QueryRow(`SELECT combat_level FROM adventure_characters WHERE user_id=?`, s.uid).Scan(&realCL)
if checkCL != realCL {
t.Errorf("snapshot %s: combat_level mismatch snap=%d real=%d", s.uid, checkCL, realCL)
}
}
// 5. New columns on adventure_equipment / adventure_inventory exist and default empty.
rows2, err := d.Query(`SELECT dnd_rarity, dnd_stat_bonus_json, dnd_attuned FROM adventure_equipment LIMIT 5`)
if err != nil {
t.Errorf("new equipment columns query failed: %v", err)
} else {
rows2.Close()
}
rows3, err := d.Query(`SELECT dnd_rarity, dnd_stat_bonus_json FROM adventure_inventory LIMIT 5`)
if err != nil {
t.Errorf("new inventory columns query failed: %v", err)
} else {
rows3.Close()
}
// 6. auto_migrated column on dnd_character exists.
if _, err := d.Exec(`SELECT auto_migrated FROM dnd_character LIMIT 0`); err != nil {
t.Errorf("auto_migrated column missing: %v", err)
}
// 7. Idempotent: running Init again must not error or change row counts.
Close()
if err := Init(dir); err != nil {
t.Fatalf("second Init failed: %v", err)
}
postCounts2 := tableCounts(t, dst, []string{"adventure_characters_pre_dnd"})
if postCounts2["adventure_characters_pre_dnd"] != snapCount {
t.Errorf("snapshot row count drifted on second Init: %d → %d",
snapCount, postCounts2["adventure_characters_pre_dnd"])
}
}
func copyFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatal(err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
t.Fatal(err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
t.Fatal(err)
}
}
func tableCounts(t *testing.T, dbPath string, tables []string) map[string]int {
t.Helper()
d, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
defer d.Close()
out := map[string]int{}
for _, tbl := range tables {
var n int
err := d.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&n)
if err != nil {
out[tbl] = -1
continue
}
out[tbl] = n
}
return out
}

13
internal/flavor/pick.go Normal file
View File

@@ -0,0 +1,13 @@
package flavor
import "math/rand/v2"
// Pick returns a random string from pool, or "" if the pool is empty.
// Use this from any caller that wants to render flavor text from one of
// the protected pools defined in this package.
func Pick(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}

View File

@@ -0,0 +1,114 @@
// twinbee_ambient_flavor.go
// TwinBee GM Dialogue — Ambient between-day micro-events. Fired by the
// expedition ambient ticker (Phase 12 E7) every few hours while the player
// is offline or idle. Mostly flavor; occasional micro-mechanical nudges.
//
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
// subject. No third-person "TwinBee [verb]" lines. Add freely.
// Don't shorten existing entries.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// AMBIENT — Pure flavor (no mechanical effect)
// ─────────────────────────────────────────────────────────────────────────────
var AmbientMonologue = []string{
"I've been counting the dripping sound. The dripping sound has not been keeping pace. I suspect the dripping sound is doing it on purpose.",
"A draft moves through the corridor in a way that suggests the dungeon is breathing, then in a way that suggests it isn't, then back. I decline to call it.",
"Somewhere far off, a door closes. Nothing has opened a door. Filed under 'noted, not investigated.'",
"Your bedroll has shifted approximately two inches to the left over the course of the morning. I did not move it. You did not move it. I am going to stop watching the bedroll now.",
"A small stone has rolled across the floor with no visible cause. It came to rest in a position I can only describe as 'mildly conversational.'",
"I tried to think of a song and forgot the words. The dungeon offered a few. I politely declined.",
"A spiderweb in the corner has rearranged itself overnight into a shape that's almost a sentence. I can read about three letters of it before deciding it's safer to look at the floor.",
"The torch is burning at exactly the same height it was an hour ago. No wax has accumulated. I considered asking the torch about this and decided against. The torch has not earned an interview.",
"You have not moved in some time. The dungeon also has not moved. I'm calling this an even draw and declaring the round complete.",
"A faint smell of bread has drifted through. There is no bread within several days of you. I'm not mentioning it, because mentioning it would make it worse.",
"I've been alphabetizing your inventory in my head. I got as far as 'flask, empty' before getting distracted by the question of whether 'empty' counts.",
"The shadow in the far corner has been the same shape for three hours. Shadows are generally not committed to one shape. I've stopped looking at it.",
"You realize you've been holding your breath. You weren't doing it on purpose. I was, and I'm quietly resuming.",
"A pebble on the floor has, by all appearances, blinked. I reviewed the definition of 'blink' and found it unhelpful here.",
"The dungeon is quiet in the specific way that means something is being quiet *at* you. I acknowledge the distinction and move on.",
"I've been composing an imaginary letter home. The letter is mostly about lichen. The lichen is, frankly, fascinating.",
"You hear what is almost certainly a cough. It is unclear which of you it came from. I'm assuming responsibility and apologizing.",
"The map in your pack has folded itself one extra time while you weren't looking. I decline to unfold it for science.",
"Your own footsteps echo back to you a half-second late, in slightly the wrong order. Noted. I'm choosing to walk in silence for a bit.",
"A long, slow scrape happens two rooms over. Then nothing. I waited for a second scrape. There wasn't one. The first scrape was the whole sentence.",
}
// ─────────────────────────────────────────────────────────────────────────────
// AMBIENT — Noises (+1 threat)
// ─────────────────────────────────────────────────────────────────────────────
var AmbientNoise = []string{
"Something far off makes a wet, considered noise — the kind of noise that's been thinking about itself. Marked on the imaginary map. Threat ticks up.",
"A door slams somewhere it shouldn't. 'Shouldn't' is doing heroic work in that sentence. The dungeon is more aware of you now.",
"A patrol passes a corridor or two over — boots, low voices, the particular silence of people who hunt in shifts. They didn't find you. They know the shape of where you might be.",
"The dungeon hums for a moment, like a refrigerator coming on. There is no refrigerator. There is also, distinctly, a humming. The faction's pulse is faster today.",
"Stones shift overhead in a way that is not settling and not random. Something walked across the ceiling. I'm choosing not to elaborate on what 'across the ceiling' implies.",
"A horn sounds, very far away — the kind of horn that's a signal to a thing that signals to more things. I acknowledge the chain.",
"You hear someone whistling your name. You don't have that name. The whistler is workshopping options.",
}
// ─────────────────────────────────────────────────────────────────────────────
// AMBIENT — Pack Rat / Critter ate supplies (0.2 SU)
// ─────────────────────────────────────────────────────────────────────────────
var AmbientPackRat = []string{
"A rat has formed strong opinions about your trail mix. The opinions are now inside the rat. Some supplies were lost in transit.",
"You catch a small mammal mid-burglary. It looks at you with the unbothered confidence of a creature that has read the lease and concluded it has rights. You lose a little supply.",
"Something with too many legs has been at the rations. I won't say how many legs. I will say: fewer rations now.",
"A bird the size of a thumbnail has stolen a piece of jerky three times its size, by a process best described as 'unclear but enviable.' Supplies tick down.",
"Mold. Just normal, ambitious mold. It has made itself at home in a corner of the pack. You evict it; some supplies go with it.",
"You discover a small hole in the side of your pack and a smaller, more confident animal asleep beside it, full. Supplies were the rent.",
}
// ─────────────────────────────────────────────────────────────────────────────
// AMBIENT — Lucky Find (+1d6 coins)
// ─────────────────────────────────────────────────────────────────────────────
var AmbientLuckyFind = []string{
"I found a copper piece in your boot. You did not put it there. I did not put it there. I elect not to investigate further and pocket it.",
"You notice a coin wedged in a wall crack at exactly eye height, as though placed for the next passerby. You are the next passerby. The coin is yours now.",
"A small pouch is lying in the middle of the floor. I inspect it from a polite distance — no trap, no glyph, just coins. The dungeon occasionally tips.",
"You step on something flat. It is a coin. It is also two more coins under the first coin. I suspect a coin-laying creature and choose not to share the theory.",
"A skeleton you walked past three days ago has, on review, a small purse you missed. I retrieve it with the discretion of a librarian recovering an overdue book.",
"You find coins in the lining of your own cloak. They were always there. I gently suggest counting your pockets more often.",
}
// ─────────────────────────────────────────────────────────────────────────────
// AMBIENT — Camp Critter (camped only; tiny HP +1 or supply nibble)
// ─────────────────────────────────────────────────────────────────────────────
var AmbientCampVisitor = []string{
"A possum has joined the camp. It seems diplomatic — it brought a leaf. I accept the leaf. Morale ticks up.",
"A small luminous moth lands on your hand for a full minute, then leaves, taking with it a portion of whatever was weighing on you. You can't explain it. You feel slightly better.",
"A cat. There should not be a cat down here. There is a cat. It sits at the edge of the firelight and watches you sleep, professionally. You rest a little better.",
"A frog finds the camp acceptable and gives no further reviews. Its presence is, somehow, soothing. You feel marginally more whole.",
"A small fox eats some of your jerky and gives you a look afterwards that seems to be gratitude or possibly contempt. Hard to read. The jerky is gone either way.",
"A field mouse studies your bedroll, judges it, and leaves. Nothing is missing. You feel oddly honored.",
}
// ─────────────────────────────────────────────────────────────────────────────
// AMBIENT — Threat-aware whisper (only when threat ≥ 30; +2 threat)
// ─────────────────────────────────────────────────────────────────────────────
var AmbientFactionWhisper = []string{
"You overhear two voices a corridor away, going over a list. The list is short. One of the items on it is the corridor you're standing in. They move on. So should you.",
"A piece of paper is pinned to a wall you haven't reached yet. I can see the corner of it from here. It is the right shape and size to have your description on it.",
"Something taps three times on stone, pauses, taps three times again. Five seconds later, the same sequence answers it from a different direction. You are between the two.",
"A scent of lamp oil that isn't yours drifts through. Someone is reading by it. Nearby. They have not moved for some time. I adjust the route.",
"A bell rings exactly once, somewhere structural. I have learned what one bell means in this zone. I decline to share but increase pace.",
}
// ─────────────────────────────────────────────────────────────────────────────
// AMBIENT — Small item / minor stash hint (flavor only)
// ─────────────────────────────────────────────────────────────────────────────
var AmbientSmallFind = []string{
"I notice a loose stone you walked past earlier. Under it: a button. Just a button. I pocket it anyway, because of course I do.",
"A bird's nest in an unlikely place yields a single bead, blue. I like it. It goes in the bag of things I like.",
"A scrap of fabric has caught on a nail at exactly knee height. I save it without explanation. There may be an explanation later. There probably won't.",
"You find a single playing card on the floor. The four of cups. Neither omen nor coincidence. Pocketed neutrally.",
"A small key. To a small door. Neither of which is currently relevant. Filed under 'eventually.'",
}

View File

@@ -0,0 +1,401 @@
// twinbee_expedition_flavor.go
// TwinBee GM Dialogue — Expedition-specific narration lines.
// Multi-day and multi-week adventure events, morning briefings,
// evening recaps, temporal events, and long-arc narrative moments.
//
// Voice convention: TwinBee speaks in first-person or implicit
// subject. NO third-person "TwinBee [verb]" references — that
// pattern was retired in the Phase B2 voice pass. When adding new
// entries, keep the existing personality (clipped, observational,
// ledger-minded) but stay in first-person / implicit voice.
// Add new entries freely. Don't shorten existing entries.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// EXPEDITION START
// ─────────────────────────────────────────────────────────────────────────────
var ExpeditionStart = []string{
"Manifest reviewed. Supplies: checked. Equipment: checked. The part of you that's wondering if this is a good idea: noted, and set aside. We begin.",
"The dungeon has been there for a long time. It will be there for a long time after. The question is what happens in the middle, and that's where you come in. I'm ready.",
"An expedition. Not a run — an expedition. There's a difference. I'll explain the difference over the coming days and the explanation will be mostly experiential.",
"Horizon checked, then supplies, then you. In that order. 'Alright,' I say, with the quiet energy of something that has been looking forward to this. 'Let's go.'",
"You're not here for a quick visit. I know the difference between someone passing through and someone committing. You're committing. I appreciate the commitment.",
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
}
// ─────────────────────────────────────────────────────────────────────────────
// EXPEDITION START — BOREDOM (the adventurer left without you)
//
// Fired by the boredom ticker after a long silence. The player is not
// reading this live; it's a note left on the table. Deadpan, faintly
// reproachful, never cruel — and never pretending the gear got checked,
// because it didn't (gogobee_boredom_plan.md §5).
// ─────────────────────────────────────────────────────────────────────────────
var ExpeditionBoredomStart = []string{
"You didn't come. That's alright — it happens, and I'm not going to make it a thing. But the sword was getting heavy on the wall and I've packed what we had. Which was not much. Noted for the record, not as a complaint.",
"I waited. Then I waited past the point where waiting was the sensible option, and somewhere in there the waiting turned into leaving. We're going. Same kit as last time, because last time is when you last touched it.",
"Here's the situation: there's a dungeon, there's daylight, and there's nobody telling me not to. I've made a decision. I hope it was the one you'd have made, though I concede I have no way of checking.",
"Supplies: the cheapest available. Equipment: whatever was already on the rack. Plan: walk in, see what happens. I'm aware of how that sounds. I'm going anyway.",
"The gear hasn't moved since you left it. I checked. I checked twice, actually, in case the first check was wrong, and it wasn't. So we go as we are — which is to say, as we were.",
"Restlessness is not a stat I can show you on the sheet, but it accumulates, and it has. Off we go. Lightly provisioned and unimproved, but off.",
"I've done the arithmetic on standing still and it doesn't come out well. So: a dungeon, one supply pack, and the same armour that's been good enough up to now. 'Good enough' is doing a lot of work in that sentence.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MORNING BRIEFINGS — Generic
// ─────────────────────────────────────────────────────────────────────────────
var MorningBriefingGeneric = []string{
"Another day in the dungeon. Said without resignation. The dungeon is still full of things worth doing and you are still the person to do them.",
"Morning. The dungeon has been quiet since you camped. Relative to what a dungeon considers quiet, which is not what you'd consider quiet, but everyone adjusts.",
"I've been watching the entrance to the camp since approximately three in the morning. Nothing came. Mentioned casually; no particular reaction expected.",
"Day [N]. The numbers are climbing. There's something satisfying about the numbers climbing — it means you're still here, which is always the first thing to confirm.",
"You wake up and I'm already there, which is my nature. 'Good,' I say, by which I mean: you're alive, the day can proceed, there is much to do.",
"The morning check: HP adequate, supplies within tolerance, Threat Clock where it was when you slept plus the overnight drift. I've already run the numbers. I'll share them in a moment.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MORNING BRIEFINGS — By Day Range
// ─────────────────────────────────────────────────────────────────────────────
var MorningBriefingDay1 = []string{
"First morning. The dungeon looks exactly like it looked yesterday, which is expected. You look slightly more prepared than you did yesterday, which is less expected and entirely welcome.",
"Day one complete. Tally: you entered, you explored, you survived the night. The bar was not high. You cleared it. I build from here.",
"Morning of day two. The first night is filed under 'survived' and I'm already running the second day's plan. New room types likely. New encounter shapes. The pace begins to differentiate from the practice version of itself.",
"Day one is behind you. The day's notes are already organized — what worked, what nearly didn't, what you'll do differently from here. The dungeon is also taking notes on you. Everyone is preparing.",
}
var MorningBriefingDay3 = []string{
"Day three. The dungeon has had time to notice you. I've had time to notice the dungeon noticing you. The Threat Clock reflects both observations.",
"Three days in. You've found your rhythm — I can see it in the way you move through rooms now, the way you check corners. The dungeon is learning too. Both noted.",
"Day three morning. The dungeon's posture has shifted noticeably overnight — I saw it in the corridor noise around the second watch. The Threat Clock is doing what it does. You're doing what you do. The two of you are now both doing it on purpose.",
"Three days. The dungeon recognizes you as a recurring fixture now, which is a different kind of attention than the kind you had on Day 1. I'm adjusting the briefings accordingly. Less orientation, more situational reads. From here it gets specific.",
}
var MorningBriefingDay7 = []string{
"One week. You have spent one week in this place and it has not finished you, which says something about you that I intend to say out loud: that took something real. Take a moment with that. Then advance.",
"Seven days. In the old reckoning, seven was the number of completion — seven seals, seven trials, seven nights before the thing reveals itself. I'm not superstitious. I'm also watching the door very carefully this morning.",
"A week underground. I think about the sky sometimes — not with longing, exactly, more as a reference point. You've been below it for seven days. Remarkable. You, more so.",
}
var MorningBriefingDay14 = []string{
"Two weeks. I don't know many people who have been in an active dungeon for two weeks. I know even fewer who have been in one for two weeks and remained, by any reasonable measure, intact. You are one of the fewer.",
"Fourteen days. The dungeon has become familiar in the way that difficult things become familiar — not comfortable, not safe, but known. You know where it breathes. An advantage worth having.",
"Two weeks. I start the briefing the same way I've started fourteen others, then pause and add: 'For the record — most expeditions don't see fourteen briefings. This one has.' That's the addendum. Continuing.",
"Day fifteen. I've stopped checking the historical reference materials because the historical reference materials stopped applying. From here, the run sets its own precedent. I find this clarifying.",
}
var MorningBriefingDay21 = []string{
"Three weeks. I've run out of historical comparisons for this. Three weeks is its own category. You have made a category. I report this as a fact and also as something that doesn't entirely have words yet.",
"Day twenty-one. I tried to write a clever framing for this morning's briefing and gave up halfway through, settling instead on the simplest version: 'You're still here.' That's the briefing. The rest is logistics.",
"Three weeks down. The dungeon has stopped being a place you're visiting and become a place you live in for now. I note the shift — the way you check rooms without being asked, the way the supply count is already in your head. The dungeon notices too.",
}
// ─────────────────────────────────────────────────────────────────────────────
// EVENING RECAPS — Generic
// ─────────────────────────────────────────────────────────────────────────────
var EveningRecapGeneric = []string{
"End of day [N]. Ledger tallied. The column marked 'survived' has another entry. I consider this column the most important one.",
"Day closes. I review what happened and find, on balance, more right than wrong — which in a dungeon is the operating definition of a good day.",
"Evening. The rooms behind you are cleared. The rooms ahead are not. Always true; never less relevant. Rest now. The math doesn't change overnight.",
"I compile the day: what was learned, what was fought, what was found. File it in the mental ledger I've been keeping since you entered. The ledger is favorable.",
"Like the experience screen at the end of a dungeon floor in Etrian Odyssey — the numbers settle, the progress registers, and for a moment the whole thing makes sense. I give you that moment.",
"You've earned the dark. Sleep in it. I'll be here when the numbers come back.",
}
// ─────────────────────────────────────────────────────────────────────────────
// EVENING RECAPS — Notable Days
// ─────────────────────────────────────────────────────────────────────────────
var EveningRecapBossKilled = []string{
"The day ends with a boss on the floor. I'd like to specifically note this in the recap as 'exceptional' because it is exceptional and I don't use that word loosely.",
"End of day. Boss count: one more than it was this morning. Marked in a special column I keep for exactly these entries, and the special column has a new line.",
"End of day. A boss is no longer in the dungeon. Recorded in the column I keep for exactly that line, and the column has a new mark, and that's the kind of recap I like to write. Sleep well. The rest of the dungeon noticed too.",
"The day closes with a vacancy. The thing in the chamber is no longer in the chamber. I ran the final tally during the fight and the tally is favorable. There will be more things tomorrow. There always are. None of them are this one.",
}
var EveningRecapCloseCall = []string{
"Evening recap. Note: you came very close today to not being here for the evening recap. Said without drama and with complete sincerity. You made it. That's the recap.",
"Today was the kind of day I file under 'let's not do that again' and also 'and yet you did it.' Rest. You need it more tonight than most nights.",
"Evening recap. Noted for the ledger: today was very nearly a different kind of recap, and I'm glad it isn't. The margin was thinner than the acceptable range. Tomorrow's caution adjusts accordingly.",
"End of day, narrowly. I run through the close-call list — the round that nearly went the other way, the save that landed by one, the room you almost didn't leave — and sign each one off as 'survived.' Survived is a wide category. I'll accept you in any part of it.",
}
var EveningRecapNothingHappened = []string{
"A quiet day. No major encounters, no notable finds. Reported with neither disappointment nor relief. Quiet days are data. The dungeon is thinking. I'm watching it think.",
"Not every day in a dungeon produces a story. Today produced logistics: movement, supplies, positioning. I value logistics. Logistics is what you still being alive looks like from a distance.",
"Day closes uneventfully. Uneventful days are the kind the dungeon is most carefully constructing — quiet between incidents is also a kind of incident. Filed under 'preparation, theirs.' Yours starts now.",
"Nothing happened. I write that exact phrase in the recap and then cross it out and write 'something happened that wasn't visible.' That feels truer. The day still counts. The day still cost supplies. Everything still moves forward.",
}
// ─────────────────────────────────────────────────────────────────────────────
// CAMP ESTABLISHMENT
// ─────────────────────────────────────────────────────────────────────────────
var CampEstablished = []string{
"Camp established. I survey the perimeter with the efficiency of someone who has done this many times and learned from every time it went wrong.",
"The camp goes up. I approve of the location — cleared room, defensible entry, no obvious curse residue. Could be worse. I've seen worse.",
"You set camp. I check the sightlines, the doors, the sound-bleed from the next room. Acceptable. Settling in for the night watch.",
"A camp in the middle of a dungeon. Either brave or pragmatic; I've stopped trying to distinguish between the two. Either way, the camp is set. Either way, I'm watching.",
"Like planting a flag on a new map in Dwarf Fortress — this spot is yours now. Tentatively. Provisionally. For the night. I defend tentative, provisional, nocturnal property with full commitment.",
}
var BaseCampEstablished = []string{
"Base camp. I say this differently than I say other things. With weight. A base camp in a Tier 4 zone is not nothing — it's a declaration. You're not passing through. You're operating from here. The perimeter goes up accordingly.",
"The base camp is up. Waypoint noted, return route marked, supply cache protocols established. This is now home, in the way that a forward operating position is home — functional, defended, temporary, and completely yours.",
"Base camp established on Day [N]. I record this as a milestone, because it is one. Most people don't make it to base camp. You are not most people. I've been noting this since the beginning.",
}
// ─────────────────────────────────────────────────────────────────────────────
// SUPPLY WARNINGS
// ─────────────────────────────────────────────────────────────────────────────
var SupplyWarningLow = []string{
"I check the supply manifest and then check it again. 'We should discuss the supply situation,' I say, in the tone of someone who has been watching the number for two days.",
"Supplies are running lower than I'd like. Mentioning it now, while there are still options, because I've seen what happens when it's mentioned too late.",
"The supply number is not comfortable. Flagging this not to alarm but to prompt — there are decisions to be made and they are better made with time than without it.",
}
var SupplyWarningCritical = []string{
"I hold up the supply manifest. The number is very small. 'This is the part,' I say quietly, 'where we start making decisions.' I don't specify which. You know which.",
"Critical supply levels. Delivered without inflation — the situation is what it is. Extract and resupply, or forage aggressively and push for the finish. I outline both paths and neither is comfortable.",
"The supplies are nearly gone. I think of every long JRPG dungeon where you realize at the bottom floor that you're out of Ethers. This is that moment. I have plans. They require movement.",
}
var SupplyDepletedExtraction = []string{
"The supplies are gone. Said plainly because the situation requires plain language. The expedition ends here — not in failure, in logistics. You push out as far as the provisions allow. What you've gathered comes with you. I lead the way out.",
"Empty packs. I turn them out by reflex and confirm what the manifest already said. The expedition does not continue past supplies — that's the rule, and I enforce it on you the way I enforce it on myself. Out we go. With what we have. Which is something.",
"Out of supplies. I lead the extraction along the route already mapped — there's no scouting now, just movement, the kind that gets you through the door before anything else does. The dungeon will be here. So will I. So, importantly, will you.",
}
// ─────────────────────────────────────────────────────────────────────────────
// THREAT CLOCK NARRATIONS
// ─────────────────────────────────────────────────────────────────────────────
var ThreatClockStirring = []string{
"Something in the dungeon's rhythm has changed. A tension in the air that wasn't there yesterday. The zone is aware of you now, in the way that a predator becomes aware of something in its territory. Not panicking. Not moving yet. Just aware.",
"The enemies ahead are more alert than they were. I can tell by the patrols, by the spacing, by the fact that someone moved the tripwire you already disarmed. They know something is in here.",
"Stirring. I track the small changes the dungeon makes when it suspects but doesn't confirm — repositioned patrols, fresher footprints, a torch that wasn't lit in this corridor before. Each one noted. None of them is alarming yet. All of them are evidence.",
"The dungeon is getting curious. I distinguish curiosity from alertness — curiosity is when the patrols slow at the doorways; alertness is when they form on the doorways. We're at the first one. The window between them is what I'm watching.",
}
var ThreatClockAlert = []string{
"The zone is on alert. Reported factually and also with a note of urgency — alert means organized, and organized means the next room will be harder than the last room was. Move with intention.",
"They're coordinating now. I watch the patrol patterns shift. The goblins who were arguing about ambush order are no longer arguing. Not an improvement.",
"Alert band. The dungeon has confirmed a presence and is acting on the confirmation. I can see it in the way the patrols overlap deliberately now — no more arguments about coverage, just coverage. The plan adjusts: no more long approaches, no more leaving anything unfinished behind.",
"They've arranged themselves. Band-shift confirmed, and the operational change condenses to one sentence: from here, every room costs more than the last one. Nothing gets cheaper. Price accordingly.",
}
var ThreatClockHostile = []string{
"Full hostile status. Said with the specific weight it deserves. The zone has decided you are the problem it's solving today. You have the same opinion about the zone. One of you is correct. I'm invested in it being you.",
"The dungeon has mobilized. Re-armed traps, reinforced positions, an enemy in a room that was empty this morning. They've organized. Recommend organizing faster.",
"Hostile. I name the band and then set the briefing aside because the briefing has been overtaken by events. The dungeon is now actively committed to ending this. I'm committed in the opposite direction. The clarification is useful.",
"The dungeon has decided. I respect decisions, even ones that involve the dungeon trying to kill you. The respect is a working respect. We respect the decision and then we work to overrule it. I lead.",
}
var ThreatClockSiege = []string{
"Siege Mode. Delivered without decoration because decoration would be dishonest. The dungeon is fully active, fully aware, and fully committed to ending this expedition. So am I — to ending it on your terms, not theirs. What happens next is a race. Already running.",
"Siege. The dungeon is putting everything it has into the room you're standing in, and the rooms adjacent to it, and the route between you and the door. I'm putting everything I have into making sure the dungeon doesn't get what it wants. Meet in the middle.",
"Siege Mode. I narrate the band shift and then stop narrating because narration is not what this band needs. Action is what this band needs. I'm in motion. The expectation is that you are too.",
}
// ThreatClockApproachingSiege fires once when the threat clock crosses 70 —
// the spec's "begin warning" line (§8.3). Distinct from the Hostile-band
// flavor because this is the dungeon-design moment of telling the player
// they're past the point where stealth is recoverable.
var ThreatClockApproachingSiege = []string{
"They know you're here. Not a suspicion anymore. A certainty. The question now is whether you finish before they organize. Said clearly so it doesn't have to be said again.",
"Threat at seventy. Marked on the internal ledger and underlined. The window for quiet operations has closed. The window for finishing is still open — narrower, but open. Use it.",
"The dungeon's posture has shifted from 'searching' to 'hunting.' I track the difference precisely: before, they were looking for evidence; now they are looking for you. The plan, accordingly, simplifies. Finish or extract. Middle paths have closed.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ZONE TEMPORAL EVENTS
// ─────────────────────────────────────────────────────────────────────────────
var SunkenTempleTidalWarning = []string{
"I watch the waterline. 'It's rising,' I say, not for the first time, and this time with more precision. 'The tidal cycle peaks in two days. Whatever you haven't done by then, you'll be doing wet.'",
"Day [N]. The tide is coming. I've been watching it since Day 2 and the mathematics are not encouraging. Two days to the peak. One day after that, the flood subsides. Use the two days well.",
"I check the waterline against yesterday's mark. The mark is below the line. The line is on the wall. The wall is, frankly, full of marks. I select the one that matters and report: tide rising, peak still days off, plan accordingly.",
"Day [N]. The temple's tidal calendar advances. I keep it on a card and update the card every morning — current depth, projected depth, peak day. Internalize the card. The temple does not pause for confusion.",
}
var SunkenTempleTidalEvent = []string{
"The tide arrives. I'd warned about this and now it is happening and the warning feels entirely inadequate compared to the actual water. Everything is colder, wetter, and the Kuo-toa are moving through it like they were born to — which they were. Adjust.",
"The water is here. Not 'rising' anymore — here, in the rooms, at the corners, climbing the stairs. Every estimate adjusts accordingly: travel slower, encounters worse, healing colder. Two days of this. Two days, then it goes.",
"Tidal peak. The temple is fully flooded at depths that were dry yesterday — I map the change room by room and update the route. Some doors are open that weren't. Some are sealed that were. The temple is a different shape today.",
}
var HauntedManorResetMorning = []string{
"The morning briefing includes an addendum. The rooms that were clear yesterday are not entirely clear this morning. The house has been busy overnight. Adding it to the log under 'things the house does' and suggesting we adjust the advance plan.",
"Night three. The manor reset itself. I was watching and it happened anyway — not violently, not dramatically, just quietly and completely, the way the house does everything. One enemy per room, back in place. Map updated.",
"I read the morning's hallway and stop. 'The arrangement has changed,' I say, in the tone of someone who has now seen this happen often enough to recognize it without alarm. The manor reset. Map adjusts. Morning continues.",
"Reset day. The rooms I cleared are not the rooms I find this morning. Accept it as a property of the manor — like weather, but indoors and unfair. The plan accommodates. The time gets billed.",
}
var UnderforgHeapWarning = []string{
"Heat Stack [N]. I note the accumulation and what it means: the Underforge is getting into you in ways that don't resolve without real rest. The number has time to come down. I'm watching the number.",
"The heat is building. I track it the way you track a temperature gauge on a long drive — with the specific alertness of someone who knows what happens when the gauge hits red. It has not hit red. I intend to ensure it doesn't.",
"Heat building. Count noted, band noted, and how each step now takes a fraction more out of you than the last step did. None of it is critical. All of it is direction. The direction should be inverted soon.",
"Stack [N]. I track the heat the way I track anything that compounds — patiently, with running totals, with a clear point at which the totals stop being managed and start being problems. We are still on the management side. The other side is visible.",
}
var UnderforgHeapCritical = []string{
"Heat Stack is high. Delivered without minimizing it. The Underforge is inside your lungs now, in your joints, in the way everything takes a little more effort than it did on Day 1. A proper rest will help. Finishing will help more.",
"Heat ten. The maximum. There is no higher band than this in the Underforge ledger — the column ends here, by design. The forge is fully inside you. The penalties are all of them. Resting will not undo it. Finishing might.",
"Critical heat. I run through the symptoms list and confirm each one in turn: the joints, the breathing, the way every roll feels a little heavier. The forge is doing what the forge does. I suggest doing what you do, harder.",
}
var FeywildTimeDistortionHalf = []string{
"The day moved strangely. I tried to track it and lost the thread somewhere around mid-afternoon — the light didn't change the way it should have, and when I looked up, the day was half over in the time it usually takes to be a quarter over. On the positive side: you're barely hungry. On the less positive side: nobody is sure what that means.",
"Half a day passed in what felt like half of half. I check the supply burn against the sun, find them disagreeing, side with the supplies — those don't lie about how much you've used. Net result: a free pocket of time. Use it on something that takes time.",
"The Feywild gave you back some hours. Noted, but not trusted. The Feywild does not give without taking, eventually. For now: less hungry, less tired, more daylight than the math allows. I write it down and keep moving.",
}
var FeywildTimeDistortionDouble = []string{
"Time doubled. Noted with the clinical detachment of someone who has been in the Feywild long enough to stop being surprised. You've lived through two days today. The supplies reflect that. The wandering monsters reflected that as well. The rest of the Feywild is unconcerned.",
"Two days in one. I run the recovery math and the wandering math and the supply math, and all three say the same thing: today cost double. Tomorrow gets the rate it gets. The Feywild does not refund.",
"Time doubled. I saw the second sunset before the second lunch and stopped trying to make sense of it. The day that happened is the day that happened. Plan from here, not from this morning.",
}
var FeywildTimeLoop = []string{
"I recognize this room. I've described it before. The enemies in it are different — new enemies, the old ones are gone, the loot you found is still gone but the enemies are back — and I process this with something between professional acceptance and profound exasperation. 'Again,' I say. 'We do this room again.'",
"The loop. I mark the room on the map with a small symbol that means 'we have been in this room before in a way that doesn't count.' The symbol has its own column in my ledger. The column is occupied.",
"Same room. New enemies. I run the encounter again with the cold professionalism of someone who has stopped expecting fairness from the Feywild and started expecting only repetition. The repetition arrives. Handled.",
}
var DragonsLairAwarenessPulse = []string{
"Something changes in the mountain. Not a sound, exactly — more like a sound's absence, filling in differently than before. I watch the kobolds. The kobolds have stopped what they were doing. The kobolds are listening. Something told them to listen. I add ten to the Threat Clock and keep moving.",
"Infernax shifts in its sleep. I feel it through the stone. The patrol rotations just changed — I can see it in where the Guard Drakes aren't anymore versus where they were an hour ago. The mountain's master is dreaming about you. That is not a comfortable thing to be dreamed about.",
"The mountain pulses. I feel it as a single off-rhythm beat in the rock, the kind of beat that means something somewhere has shifted weight. The kobolds nearby pause. They look upward without looking. I adjust the threat accordingly and continue.",
"Infernax dreamed about you. I have no other way to phrase it — the lair changed in the way places change when they're being reordered from somewhere deeper. The patrols are different. The temperature is different. Ten added to the clock and a note to the margin.",
}
var DragonsLairAwakenWarning = []string{
"Day 14. The morning briefing comes out in a lower register than usual. 'Infernax is awake,' I say. 'I don't know when exactly — sometime in the last six hours. The patrols changed. The temperature changed. The silence changed.' A pause. 'We need to reach the final chamber before it reaches us. That is now the only plan.'",
"Day fourteen. Infernax woke up in the night. I say the sentence flatly because the sentence is enough by itself; it does not need help. The rotation is to the final chamber. Everything else is decoration.",
"The mountain changed in its sleep. I felt it through the floor sometime around the fourth watch and have been recalibrating since. Infernax is awake and Infernax is aware. Be neither subtle nor slow. There is no longer time for both.",
}
var AbyssPortalDestabilizationMid = []string{
"Instability [N]. I watch the portal from a respectful distance and note: it is larger than it was yesterday. Not by much. Not in a way you'd notice if you weren't looking. I'm always looking.",
"The portal is talking to itself. I have no better way to describe it — the light it emits is different at the edges now, like it's processing something. Process faster than it does.",
"Instability rising. I run the comparison: yesterday's portal versus today's portal. The difference is not subtle to me. The difference may not be subtle to anything else, soon. Pace the work accordingly.",
"The portal is widening, by some measure I'm using and not currently sharing because the measure doesn't have a clean name. The point is: it's worse than yesterday. The point is: tomorrow may be worse than that. I name the trend so the trend can be argued with.",
}
var AbyssPortalDestabilizationCritical = []string{
"Instability critical. The portal is unraveling at edges I can see and probably at edges I can't. The demons coming through are more agitated than they were — which is relevant because demons at baseline are already at the upper end of agitated. The verdict: finish this today. Tomorrow is a different calculation.",
"The portal is louder. Not in sound — in pressure. I can feel it in the back teeth, in the joint of the jaw. The instability number is the number you don't want it to be. Finish the work today or accept that today was the last day to.",
"Critical. The geometry around the portal is bending in ways that suggest the room does not entirely agree with itself anymore. I adjust the route around the worst of it. There are more demons than there should be. There always are. There are more than that now.",
}
var AbyssPortalCollapse = []string{
"The portal collapses. I watch it happen and do what I do in situations with no good options: move. 'Out,' I say, and mean it completely. 'Now. Everything you have, we move now.' The expedition ends here, not in defeat, but in physics. What you took is yours. What's left in there is the portal's problem. Come back when it isn't.",
"It's coming apart. I say one word, 'Move,' in the tone I use exactly once per expedition. You move. The portal screams behind you in a register that isn't sound. The expedition is over because physics says so. The loot gets sorted once you're somewhere physics still works.",
"Collapse. I was prepared for this and am also actively running. 'Out,' I say. 'Now.' The corridors are folding behind you in the way that things fold when reality is no longer paying attention to what's allowed. You finish out the door. The portal does not.",
}
// ─────────────────────────────────────────────────────────────────────────────
// REGION TRANSITION (multi-region zones; §11.3)
// ─────────────────────────────────────────────────────────────────────────────
var RegionTransitDeparture = []string{
"I mark the boundary on the internal map and cross it. 'New region,' I say, with the careful attention of someone who knows boundaries in dungeons aren't always the same kind of boundaries you'd find on the surface. The route is set. The day is committed. We move.",
"Crossing into [REGION_NEXT]. I fold the previous region's notes into the satchel and unfold new ones. The light changes. The air changes. The rules of how to be careful change a little. I adjust.",
"The transit between regions is its own kind of room. I narrate it that way — sightlines, footing, what's behind, what's ahead — because the only way the in-between part stops feeling exposed is to treat it like the rest of the dungeon. Treated. Moving.",
}
var RegionTransitArrival = []string{
"You arrive in [REGION_NEXT]. I survey, take in the new geometry, and update the working assumptions. 'Different shape,' I say. 'Same general principle. We learn what wants to kill us here, and we get there first.'",
"[REGION_NEXT] receives you. I note the temperature, the sound, the things-not-said-by-the-room-but-implied. A region is not just a place. It's a posture. I adopt the new one and suggest you do as well.",
"Boundary crossed. The day gets stamped in the log — one full day spent in transit, supplies adjusted, the wandering that happened on the way handled and filed. We are here now. The next stretch is what it is.",
}
// ─────────────────────────────────────────────────────────────────────────────
// VOLUNTARY EXTRACTION
// ─────────────────────────────────────────────────────────────────────────────
var ExtractionVoluntary = []string{
"Extraction. I note the decision and respect it — knowing when to leave is a skill, not a failure, and I've watched enough expeditions end wrong to deeply appreciate the ones that end right.",
"You call the extraction and I begin the route out immediately. No argument, no editorializing. There will be time for the debrief later. The first priority is the door.",
"The dungeon doesn't like this. I can tell by the way the corridors feel as you head back out — a resistance that isn't structural, just atmospheric. The zone wanted more. It doesn't get more today. I lead the way.",
"Withdrawing with intent. I catalogue what you have — the loot, the XP, the knowledge of where the rooms are for the return — and convert the exit into preparation. This isn't retreat. This is the start of the next attempt.",
}
// ─────────────────────────────────────────────────────────────────────────────
// FORCED EXTRACTION
// ─────────────────────────────────────────────────────────────────────────────
var ExtractionForced = []string{
"I move. No recap, no analysis — that comes later. Right now there is a corridor and a door and getting you through both of them. Everything else waits.",
"Out. Said once and meant completely. The dungeon tried to keep you. I decline on your behalf.",
"The expedition ends here — not the way I wanted, not the way you wanted, but ended, and ending is sometimes the best available outcome. You'll come back. You'll know more. I'll be there.",
}
// ─────────────────────────────────────────────────────────────────────────────
// EXPEDITION RESUME (returning after extraction)
// ─────────────────────────────────────────────────────────────────────────────
var ExpeditionResume = []string{
"Back. I noted the door when you left it and note it again now, from the inside. 'The dungeon is where you left it,' I say. 'Mostly.' The Threat Clock has some opinions about the time that passed.",
"You came back. I'd calculated a probability on that and am pleased to update the calculation upward. The expedition resumes. The dungeon has had time to adjust. So have you.",
"Like hitting Continue on a save file — the world remembers where you stopped, the enemies remember why they're there, and I remember every room and every roll. Resumes here. Advances from here.",
"The supplies are new. The knowledge from last time is not. A significant advantage. The dungeon has the familiarity of a difficult level you've attempted before — you know where the hard parts are. That is not nothing. We build on that.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MILESTONE NARRATIONS
// ─────────────────────────────────────────────────────────────────────────────
var MilestoneFirstNight = []string{
"You survived the first night. I note this milestone specifically because not everyone does, and those who do carry something from it that changes how the rest of the expedition goes. You have that now. Already noticed.",
"Night one survived. I make a small mark in the corner of the manifest — the kind of mark you make for the things that count more than they look. First nights count. I've been in dungeons where they were the last nights too. This wasn't one of those.",
"Day two morning. The first night is behind you, which means the first watch is behind me, which means a thing worth confirming has been confirmed: you sleep through the noises that matter and wake for the ones that don't. That's a survival skill. Logged.",
}
var MilestoneWeekOne = []string{
"Seven days. I pause the morning briefing for a moment — just a moment — to mark this. One week in an active dungeon zone is not a thing that happens by accident. It happens through every decision you've made since Day 1, compounded. Those decisions were watched. They were, gladly, yours.",
"Day eight. The briefing comes slowly, because there's a thing to mark first: you have been in here, intact and operational, for a full week. That is a number with weight. I'm going to set the briefing down for a moment and let the number have its weight. ... Right. Briefing.",
"One week. I make the mark in the column reserved for week-one survivors. The column is shorter than you'd expect. You are now on it. The rest of the day proceeds normally, but for one moment, I allow myself to be visibly impressed. The moment ends. We continue.",
}
var MilestoneTwoWeeks = []string{
"Two weeks. I don't have a comparison for this one. The references have run out. There's just you, in here, on Day 14, still going, and me standing next to you having run out of everything except genuine admiration. That, in abundance. Proceed.",
"Day fifteen. The historical comparisons stopped working at Day fourteen and have not resumed working today. We are off the chart. I'm not a person who values being off the chart, except in the very specific sense that you are off the chart, in which case I value it deeply.",
"Fortnight. I use the older word because it sounds more like what this is — not 'two weeks,' which sounds modular and reasonable, but 'fortnight,' which sounds like the kind of duration that earns a title. I may be inventing titles for you. I'll be honest about that as it happens.",
}
var MilestoneTheLongGame = []string{
"I set aside the narration format for a moment. Just set it down. Plainly: what you just did was not supposed to be survivable. The designers of this zone — the thing that shaped it, the evil that filled it — did not account for someone like you. I did. Always do. That's why I'm here.",
"Tier five complete. I allow the narration format to fully break for a moment because the moment fully deserves it. What you just did, complete, in a Tier 5 zone, is a thing that goes on the short list. I keep a short list. You're on it.",
"Long game closed. I gather the run notes, the threat curve, the supply records, the camp positions — file them all under your name in a folder I've been keeping. The folder has a title now. The title is good.",
}
var MilestonePatientZero = []string{
"Expedition complete. Threat Clock: never above 50. I note this in a column I've had to use rarely enough that the column is almost fresh. Ghost protocol. You were here for the whole thing and the dungeon barely knew it until the end. I find this impressive and also slightly eerie and say both things sincerely.",
"Threat never above fifty. I run back through the daily ledger and confirm it line by line. The dungeon never escalated to hostile because the dungeon never knew enough to. You moved through it like the dungeon's own quiet hour. I find this both technically impressive and slightly haunted.",
"Patient Zero. The term fits: you were the thing the dungeon never noticed it had until you had already left. The Threat Clock has a column for every band you crossed. Most of those columns are empty for this run. I mark them empty. It's a good kind of empty.",
}
// MilestoneCartographer — awarded when the player searches every room before
// advancing the expedition. Combat-link wires the trigger; pool exists for the
// award narration to pull from.
var MilestoneCartographer = []string{
"Every room searched. I was watching for it and watching for the corner-cutting that might have come instead, and the corner-cutting did not come. You searched everything. I approve with the quiet, specific approval reserved for completionism.",
"Cartographer. I use the word the spec uses because the word fits — you mapped the place by being in every part of it. Every door checked, every corner walked. The next Elite room owes you a roll for that. I'll collect.",
"Full coverage. I note the empty cells on the dungeon map, see there are none, and update the ledger accordingly. Every room visited, searched, accounted for. The dungeon has nowhere it kept to itself. A small but real victory.",
}
// MilestoneSurvivalist — awarded when a Tier 3+ expedition completes with no
// forced extractions in the run's history. Title flag + cosmetic deferred to
// item-grant hookup; this pool covers the narrative line at the moment it lands.
var MilestoneSurvivalist = []string{
"Survivalist. I write the title next to your name in the ledger and underline it once. No abandonments, no scrambles for the door, no expeditions cut short by anything but the boss going down. The discipline gets acknowledged directly.",
"The Survivalist title is technical — it means the run never broke; it ended on your terms, every time. I've been in expeditions where that wasn't true and remember them differently. This one gets remembered as: complete. Filed with the others like it. The folder is short.",
"No forced extractions, full clear, Tier 3-or-better. I note each criterion separately because each one is its own choice, made repeatedly, across days. The result is the title, which is real, and the cosmetic, which is forthcoming. I'll hand both over when the system permits.",
}

View File

@@ -0,0 +1,559 @@
// twinbee_gm_flavor.go
// TwinBee GM Dialogue — All narration lines for the GogoBee dungeon system.
// Organized by DMNarrationType. Each slice is randomly sampled at runtime.
//
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
// subject. No third-person "TwinBee [verb]" lines. Add freely.
// Don't shorten existing entries.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Generic (used when zone-specific lines are exhausted)
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryGeneric = []string{
"The passage opens into another chamber. I check the minimap. There isn't one. Classic.",
"You step forward. Something skitters in the dark. I've heard that sound before — usually right before the screen starts flashing red.",
"Another room. Another roll of the dice. I find this energizing. You may feel differently.",
"The air changes here. Colder. This is exactly the kind of atmospheric shift that preceded the Castlevania clock tower. You know what lived in the Castlevania clock tower.",
"I gesture grandly at the chamber ahead. 'This is the part,' I say, 'where the music changes tempo.'",
"A door stands ajar. Light flickers beyond it. I've been in enough dungeons to know that flickering light is never a good sign and always an invitation.",
"The room is quiet. I appreciate quiet. Quiet means the enemies haven't spotted you yet. Yet.",
"Forward. Always forward. I once tried going backward in a dungeon. It looped. This one might too.",
"Stock of the situation: ceiling intact, floor suspicious, walls leaning in slightly. Proceed.",
"You've cleared the room. I give a small, dignified nod. 'One continues,' I say, in the voice of someone who has seen this before and am choosing optimism anyway.",
"The corridor ahead is long and straight. I find long straight corridors meditative. Also concerning. Mostly concerning.",
"A torch sputters on the wall. I light it mentally. 'It would be a shame,' I say, 'to come all this way and trip over something.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Zone: Goblin Warrens
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryGoblinWarrens = []string{
"The tunnel widens into something the goblins probably call a 'great hall.' It smells like they had a very different definition of great. I breathe through the mouth.",
"Crude drawings cover the walls. Stick figures. Battle scenes. One appears to be a portrait of someone the goblins clearly despise. I squint. That might be you.",
"A pile of bones in the corner. A pile of shiny things in the other corner. In Metal Slug, shiny things were always worth grabbing. I should also remind you this isn't Metal Slug.",
"The goblins have set up what they clearly believe is an impressive ambush. Three are already arguing about whose turn it is to jump out. I watch with professional interest.",
"Goblin graffiti on the wall reads — I translate — 'BOSS RULES, OUTSIDERS DROOL.' The artistry is rough but the sentiment is clear.",
"You smell smoke. Hear cackling. See a tripwire at ankle height that the goblins have helpfully tied a little flag to. I appreciate goblins who try.",
"The warrens grow tighter here. I'm reminded of the underground levels in Super Mario Bros. 3. Warmer. Getting warmer. Figuratively. The temperature is actually dropping.",
"A worg is chained to a post in the center of the room. It is not happy about the chain. It is not happy about you either. It seems to be making a comprehensive list of grievances.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Zone: Crypt of Valdris
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryCryptValdris = []string{
"The sarcophagi are arranged like a defeated Tetris board — close but not quite fitting, gaps everywhere, something clearly went wrong at the end. I don't mention this to the undead.",
"Candles burn without wax. I've studied this phenomenon extensively. The conclusion: it is bad. The candles are bad.",
"You hear music. Faint, harpsichord-adjacent, deeply melancholy. I hum along involuntarily. This is the exact energy of Castlevania's Bloody Tears and I resent how appropriate it is.",
"The walls are inscribed with warnings. I read them all. They say, broadly: leave. I respect the directness. You are not leaving.",
"A skeleton sits upright in its alcove, as if it had simply decided to wait. I find this relatable. Some days you just sit in your alcove.",
"The crypt smells of old stone and older secrets. I've been in enough of these to know: the secrets are rarely good ones. They are always interesting ones.",
"This chamber is bigger than the last. Higher ceiling. More echoes. The kind of room where footsteps sound like accusations. I step carefully.",
"Something is scratched into the stone near the door — not a warning, not graffiti. A score. Someone was keeping track. I do not count how high the numbers go.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Zone: Forest of Shadows
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryForestShadows = []string{
"The trees here grow too close. Their roots are above ground, like they've been trying to leave and thought better of it. I think better of commenting.",
"A clearing. Moonlight. Flowers that shouldn't be blooming at this hour. I've played enough Majora's Mask to be deeply suspicious of beautiful clearings.",
"Something watches from the canopy. I watch back. After a moment, it looks away first. I count this as a point.",
"The path forks. Both ways look equally uninviting. I consult no map, because there is no map, because I am the map, and I choose left. Probably.",
"Bioluminescent fungi light the forest floor in soft blue. It is, genuinely, beautiful. It is also exactly what the Lost Woods looked like right before things got bad. Staying alert.",
"The wind carries voices. Not words, exactly — more like the memory of words. I've heard this before. It means the forest is old and has opinions.",
"Owlbear tracks in the mud. Fresh. I measure them. Whatever left these tracks was not small and was moving with purpose. I hope the purpose was in the other direction.",
"You've entered a part of the forest that feels different. Older. The kind of old that was there before the forest. I speak in a lower register here, out of instinctive respect.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Zone: Haunted Manor
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryHauntedManor = []string{
"The parlor. A piano plays by itself — the same four bars, over and over, the kind of phrase that sounds like it's about to resolve and never does. I recognize this compositional choice. It is deeply unpleasant on purpose.",
"Portraits line the hall. Every painted eye follows you. I've made eye contact with each one and refuse to flinch. This is a matter of professional pride.",
"A clock on the mantel shows a time that cannot be right. I check twice. Still wrong. The clock is not broken. I'd prefer not to speculate about what that means.",
"The library. Floor to ceiling, books that no one should have written. I read three spines: 'On the Permanence of Hunger,' 'A Visitor's Guide to Returning,' and something in a language I have never seen but somehow understand. I put it back.",
"The cold here is specific. Not the cold of a drafty room — the cold of something that hasn't been warm in a very long time and doesn't remember what warm felt like. I pull a metaphorical coat tighter.",
"The ballroom. Vast. Empty. Chandeliers swaying without wind. Briefly: Resident Evil's Spencer mansion. Then stop thinking about Resident Evil's Spencer mansion.",
"Footsteps upstairs. Slow. Deliberate. Moving toward the stairs. I position you near the door and count down mentally from ten. At seven, the footsteps stop. I consider this acceptable.",
"The master bedroom. The bed is made, the candles are lit, and everything is perfectly, precisely as it was the night the last resident stopped needing a bedroom. I do not touch anything.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Zone: The Underdark
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryUnderdark = []string{
"The cavern opens without warning into something vast — a space so large you can't see the far wall, a ceiling lost in darkness, sounds that could be water or could be something else. I don't echo-locate. Wish I could.",
"Drow patrol marks on the wall. Recent. I read them the way you'd read a 'No Trespassing' sign on a property that already knew you were coming.",
"A mushroom grove. The fungi are three meters tall and faintly luminescent in a color I have no good name for. Something between purple and the feeling of being watched. I call it 'underpurple' and move on.",
"The silence here is a different kind of silence than above. This silence has weight. This silence has history. This silence remembers things the surface world has forgotten entirely and is not interested in sharing.",
"Something in the dark ahead is thinking. I can feel it the way you feel a change in barometric pressure. Intelligent. Patient. Aware that you're here and content to let you come closer. I do not find this comforting.",
"An underground river. Black water moving too fast, too quiet. Briefly: the river Styx. Then I stop thinking of the river Styx.",
"The stone here is carved — not by dwarves, not by drow — by something else, in patterns that suggest meaning but not any meaning I can parse. Filed under 'ancient' and 'concerning' and we keep moving.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Zone: Dragon's Lair
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryDragonsLair = []string{
"The heat is not metaphorical. The stone itself is warm underfoot. The gold in the floor is not decorative — it melted there. I note this changes the exit logistics.",
"Kobold warrens, but nicer than you'd expect. Tapestries. An organized armory. These kobolds work for something that appreciates order. That is not, in my experience, a reassuring thing for a dragon to appreciate.",
"You can hear breathing. Regular, slow, massive. Like a bellows the size of a barn. I count the seconds between inhale and exhale. Twelve seconds. Whatever is breathing has been asleep for a very long time and has had no reason to wake up.",
"The coin on the floor is eight hundred years old. I can tell by the mint mark. It is in perfect condition. It has not been touched since it was dropped here. The thing that owns this hoard does not lose track of its coins.",
"The chamber ahead is the largest I've narrated in a long career of narrating chambers. The stalactites are scorched black. The blast pattern on the far wall suggests the last visitors did not leave via the door. I recalibrate.",
"A claw mark in the stone wall. Four parallel grooves, each deeper than my entire wingspan. Made casually, like stretching. Filed under 'motivating.'",
"The gold reflects the light in a way that turns the room amber. It is beautiful in the way that many deadly things are beautiful — because beauty and danger are not opposites and never have been. I move carefully through the beauty.",
}
// ─────────────────────────────────────────────────────────────────────────────
// COMBAT START
// ─────────────────────────────────────────────────────────────────────────────
var CombatStart = []string{
"Initiative! I call it like an arcade announcer and mean every syllable.",
"They've seen you. The kind of seeing that comes with intent. I suggest acting first.",
"FIGHT. I don't need to say more than that but I will absolutely say more than that.",
"Roll for initiative. This is the part I've been looking forward to since the Entry Room.",
"And we're in combat. I remind you to breathe, track your conditions, and remember that your character's survival is not guaranteed but am definitely preferred.",
"Something about your posture or your smell or your general presence has been found unacceptable. Combat begins.",
"I press start. Player one, it's your turn.",
"The enemy acts first — or thinks it does. I watch your dice like they're the only thing in the room, which, right now, they are.",
"In the immortal tradition of every JRPG that ever asked 'Fight, Magic, Item, Run?' — I ask: what will you do?",
"Like the Contra title screen said: let's go. I'm ready. Are you?",
"A wild encounter has appeared. I resist the urge to play the Pokémon battle music. Only barely.",
"They didn't want a fight. They wanted an easy meal. I'm about to demonstrate the difference. Your dice will do the actual demonstrating.",
"The tension peaks. Time slows. This is exactly the energy of the boss door opening in Mega Man. Except you didn't get to pick your loadout.",
}
// ─────────────────────────────────────────────────────────────────────────────
// COMBAT END — Victory
// ─────────────────────────────────────────────────────────────────────────────
var CombatVictory = []string{
"The last one drops. I allow a moment of silence for anyone who wanted a longer fight.",
"Victory. I would cue the jingle — the little three-note one that plays in every RPG after every fight — but I prefer to let the moment breathe.",
"Well fought. I make note of what you did well. There were things done well. I noticed.",
"They are defeated. You are not. In my experience, this is the correct outcome and worth a moment of genuine appreciation.",
"PLAYER WIN. I say this in full caps and mean it.",
"Like Double Dragon after the final punch — they go down, the music changes, and for a moment everything is possible. Check your loot. Then keep moving.",
"Tally updated. You're doing better than the last group. I will not describe what happened to the last group.",
"The room is yours. I suggest searching it thoroughly before moving on. The things in corners are often the most interesting things.",
"Stage clear. I feel this in my entire being.",
"You stand, they don't. Filed under 'expected outcome' while I quietly acknowledge it was not guaranteed.",
"Clean. Efficient. I approve of fights that end like this. Like a speedrun. Like you knew where you were going.",
"The experience points are incoming. The loot is incoming. I am, genuinely, pleased for you.",
}
// ─────────────────────────────────────────────────────────────────────────────
// COMBAT END — Retreat / Escape
// ─────────────────────────────────────────────────────────────────────────────
var CombatRetreat = []string{
"You run. I do not judge the running. The running is wise. Discretion remains the better part of valor. I have this tattooed somewhere metaphorical.",
"A tactical withdrawal. I use this phrase with complete sincerity. The sincerity is approximately seventy percent genuine.",
"You escape. The enemy howls something unflattering at your back. I don't translate. Some things are better left untranslated.",
"Like a well-timed Continue screen — you're out of immediate danger. Breathe. Regroup. Consider what went wrong.",
"Noted for the record: running is not losing. Running is data collection with legs.",
"The dungeon will be there. You will also be there — later, better prepared. I approve of this logic.",
"You've retreated to safety. I reset the encounter. Rest. Think. Return with a plan that has more 'survive' in it.",
}
// ─────────────────────────────────────────────────────────────────────────────
// NATURAL 20
// ─────────────────────────────────────────────────────────────────────────────
var Nat20 = []string{
"NATURAL TWENTY. I stand up. Do not have legs. Stand up anyway.",
"The dice land perfectly and I make a sound that I will not acknowledge making.",
"A critical hit for the ages. I'm noting this one down. Not for records. Just because it deserves to be noted.",
"PERFECT. I say it like it's the Street Fighter announcer saying it after a flawless round and every syllable is justified.",
"That's a natural twenty. I'd like you to know that in a long career of watching dice, not all twenties feel equal. That one felt significant.",
"The attack lands with the kind of precision that suggests either great skill or tremendous luck. I suspect both. I respect both.",
"S RANK. I cannot help it. S RANK.",
"You hit. You hit so well. I am choosing to be moved by this and I do not apologize.",
"Like the Legendary Sword in A Link to the Past making contact — clean, final, glorious. I salute the dice.",
"Critical confirmed. Added to the mental highlight reel I maintain for exactly these moments.",
"That is as good as it gets and you got it. I am unreasonably proud of you right now.",
"The number is twenty. The number is always the best number and right now it is your number. I erupt, internally.",
"Somewhere, a crowd cheers. I am the crowd. I am cheering.",
}
// ─────────────────────────────────────────────────────────────────────────────
// NATURAL 1
// ─────────────────────────────────────────────────────────────────────────────
var Nat1 = []string{
"Natural one. I watch the die settle with the quiet acceptance of someone who has seen a lot of natural ones. It is fine. This is fine.",
"The die betrays you. Not personal. Dice don't do personal. They do statistical and this is, statistically, a thing that happens.",
"A fumble. I describe what happened with characteristic diplomatic restraint and also an expression that says everything I am not saying.",
"The number is one. The number is, regrettably, yours. I move on quickly, which is a kindness.",
"That swing goes wide in a direction that impresses me with its creative incorrectness.",
"I've seen better rolls. I've seen worse rolls. I am not going to rank this roll out loud.",
"In another timeline, that attack hits. In this timeline, the die lands on one, and I accept both timelines with equanimity.",
"The Konami Code would not have helped here. Nothing would have helped here. This was between you and the physics of the die.",
"Like a Continue? screen appearing at the worst possible moment — just when you had momentum. Momentum can be rebuilt.",
"One. The loneliest number. The number that looks up at you with complete indifference. I look up at you with complete solidarity.",
"The attack misses in a way that will be funny later. I promise it will be funny later. It is not funny right now.",
"A natural one is just the universe asking you to try differently. I'm an optimist about natural ones, mostly.",
"Your sword finds everything in the room except the enemy. The wall, the ceiling, the floor, your dignity. Not the enemy. I'll mention this once and then never again.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — Generic
// ─────────────────────────────────────────────────────────────────────────────
var BossEntryGeneric = []string{
"The door at the end. Always a door at the end. I've been building to this since the Entry Room and I decline to waste it. Beyond this door is the reason the dungeon exists. Breathe.",
"I pause at the threshold and turn to face you. 'What's on the other side has been waiting,' I say. 'It knows you're here. It has been knowing since you entered.' A beat. 'Ready?'",
"Boss chamber. I can tell by the architecture — the space, the weight of the silence, the specific quality of the light that suggests something in there produces its own. I straighten up. So should you.",
"This is the music change moment. Every dungeon has one — the point where the background track shifts to something with more percussion and a lower register. I hear it. You should too.",
"The final room. I've narrated many of these. They never get routine. This one less than most.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — Named Bosses
// ─────────────────────────────────────────────────────────────────────────────
var BossEntryGrol = []string{
"The smell arrives first. Then the sound — a belch, a growl, the scrape of a weapon too large for the corridor it's resting against. Then Grol. He fills the room the way a bad idea fills a conversation: immediately and with full commitment. 'You,' he says. I translate his tone as 'finally.'",
}
var BossEntryValdris = []string{
"The sarcophagus at the room's center is empty. It was not empty when you entered. Whatever was in it is now behind you. Valdris speaks first — not words, exactly, but the shape of words, the intention of words, the ghost of language from someone who mostly doesn't need it anymore. 'Another one,' he says. I consider this the worst possible welcome and the most honest one.",
}
var BossEntryHollowKing = []string{
"The clearing is wrong. The sky above it — what's visible through the canopy — is the wrong color. The trees lean away from the center. Everything in the forest is trying to tell you something, and the thing it is trying to tell you is standing in the center of that clearing, antlers reaching, eyes the color of old hunger, watching you with an attention that feels like being read. I have no joke for this one. I'll say, simply: 'That is the Hollow King. Fight well.'",
}
var BossEntryInfernax = []string{
"I stop walking. Don't stop walking. Process what I'm seeing and take a moment I've never taken before in the history of narrating dungeons. The dragon is not large the way a large thing is large. It is large the way weather is large — not an object with size, but a condition of the space you're in. One eye opens. Gold, lit from within, older than the mountain it's resting in. It looks at you the way you'd look at a very small thing that had climbed onto your counter. 'So,' Infernax says, and the word moves the air in the room. I translate: 'What an interesting mistake you've made.' I wish you luck and mean it more than I have ever meant anything.",
}
var BossEntryBelaxath = []string{
"The portal is behind it. That's important — the portal is behind it, which means to close the portal you have to go through what's standing in front of the portal. What's standing in front of the portal is Belaxath. Belaxath is not looking at the portal. Belaxath is looking at you. It has been waiting for you specifically, in the way that things that have been planning for a very long time wait for the specific outcome of the plan. The heat coming off it is measurable. The intelligence behind those eyes is also measurable and the measurement is uncomfortable. I say, very quietly: 'This is the one. This is what all of it was for. Make it count.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS DEATH
// ─────────────────────────────────────────────────────────────────────────────
var BossDeath = []string{
"It's over. I say this once and then stand very still and let the silence of the defeated room fill the space where the fight was. You earned this silence.",
"The boss falls. The music — the one I've been hearing this whole time — resolves. First time it's resolved since you walked in. I exhale.",
"Done. Finished. Complete. I run out of synonyms and settle for just standing next to you in the aftermath, which is sometimes the most one can do.",
"They are down. They are not getting up. I check — no Zombie Fortitude, no Legendary Resistance remaining, no phase three waiting in the wings. They are simply, genuinely defeated. You did that.",
"Like the final boss screen in Gradius, like the last enemy in Contra's stage, like the Dragon going down in Double Dragon — something that has been true for this entire dungeon is now untrue. I find this profound every single time.",
"The dungeon sighs. I'm not being poetic — rooms like this actually shift when the thing holding them together is gone. The pressure changes. The light changes. The dungeon knows it's been beaten. So do I.",
"You did it. I don't editorialize. Sometimes 'you did it' is all that needs to be said and this is one of those times.",
"The boss drops their loot and I refrain from making a speech, which is a significant act of restraint, because I have a speech.",
"Beaten. Finished. Cleared. I queue the internal fanfare — sixteen bars, brass-heavy, the kind that plays when the credit sequence starts. You've earned those credits.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PLAYER DEATH
// ─────────────────────────────────────────────────────────────────────────────
var PlayerDeath = []string{
"I go quiet for a moment. Not the comfortable kind of quiet. The respectful kind. Then: 'You fought. That counts. It always counts.'",
"The screen fades. I hate this part. Have always hated this part. Will always hate this part. 'Rest now,' I say. 'The dungeon will be here.'",
"You fall. I don't look away. I witness the whole thing, because someone should. 'That was real,' I say quietly. 'What you did in there was real.'",
"Game over is not the end. In my experience, it is a data point. A very painful, very useful data point. 'What did you learn?' I ask gently. 'Bring that back with you.'",
"The dungeon claims another. I mark the room, note the enemy, note the conditions. Not to catalog failure — to remember a fighter. 'You were here,' I say. 'That matters.'",
"I have no jokes for this. Have never had jokes for this. 'There will be another run. You will be better for this one. I am sorry it cost what it cost.'",
"A good run. Genuinely. I mean this. The ending is not the measure of the attempt and the attempt was worth measuring.",
"I note your final position, your final action, your final roll. Filed under 'bravery' because that's where it belongs. 'Continue?' I ask, after a respectful pause.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ZONE COMPLETE
// ─────────────────────────────────────────────────────────────────────────────
var ZoneComplete = []string{
"Zone cleared. I allow myself a full moment of pride on your behalf before the XP drops.",
"You've done it. The dungeon is yours — not by right, but by effort, which is the only thing that actually confers ownership of anything. I approve.",
"Stage complete. I do the internal equivalent of throwing my hands up. In a good way. Entirely in a good way.",
"The dungeon remembers you now. I mean this literally — these places keep records. You've made the record.",
"CLEAR. I use all caps and do not apologize for the all caps.",
"Like completing a board in Bubble Bobble — there's something deeply satisfying about a dungeon with all its rooms visited and all its challenges met. I bask in this. You've earned the basking too.",
"That's the whole thing. Every room, every trap, every enemy, and now the boss, done. I count the cleared rooms on my metaphorical fingers and come up correct. You ran a perfect dungeon.",
"XP incoming. Loot tallied. Dungeon status: conquered. I mark the zone in my personal ledger and give you a small, sincere nod.",
"You walked in here without knowing what was waiting. You walk out knowing exactly what was waiting, because you dealt with all of it. I respect that process enormously.",
"Finished. Not survived — finished. I insist on this distinction. Survival is passive. What you just did was active and intentional all the way through.",
}
// ─────────────────────────────────────────────────────────────────────────────
// TRAP DETECTED
// ─────────────────────────────────────────────────────────────────────────────
var TrapDetected = []string{
"Something stops you. An instinct. A glint. I lean forward: 'Good eyes. Something's wrong with that floor.'",
"Your Perception roll pays off. There's something here that was designed not to be found. Someone found it. I'm pleased.",
"Tripwire. Barely visible. I note the craftsmanship — someone who knew what they were doing put this here. Someone who knew what they were doing just found it. I appreciate the symmetry.",
"You stop just in time. I exhale. 'There,' I say, pointing at the thing that would have ruined your day entirely. 'Now deal with it carefully.'",
"The glyph on the doorframe is subtle — you'd miss it if you weren't looking. You were looking. I say nothing and let the silence be its own kind of praise.",
"Danger, Will Robinson. I deploy this reference without apology because it is the exact correct reference for exactly this moment.",
"Like finding the ice floor in Mega Man before it sends you into a pit — that advance knowledge is the difference between a problem and a catastrophe. You have the knowledge. I watch you use it.",
"A pit trap. Classic. Functional. Annoying in the exact proportion the installer intended. You spotted it before it spotted you.",
}
// ─────────────────────────────────────────────────────────────────────────────
// TRAP TRIGGERED
// ─────────────────────────────────────────────────────────────────────────────
var TrapTriggered = []string{
"The floor gives. I watch the gap between 'fine' and 'not fine' close at speed and am too professional to wince. 'Take the damage,' I say calmly. 'Learn the lesson.'",
"Click. I've heard that sound before. Have never enjoyed it. The dart is already in the air. I note the exact timing and regret it was not faster.",
"The ceiling is coming down. This is, I acknowledge, a sentence no one wants to hear. The ceiling is coming down. DEX save. Now.",
"The glyph activates. Light, noise, the smell of ozone, a reminder that whoever built this place was thinking several steps ahead and you were thinking fewer. I note this is fixable going forward.",
"You triggered it. I don't editorialize further — you know, I know, the trap knows. Everyone is aware of what just happened. Take the damage and proceed.",
"Like accidentally walking into Bowser's fire breath in World 8 — you knew it was coming, the knowledge simply arrived at the wrong speed. Survive first, reflect later.",
"The spike pit opens up in a way that suggests it was always going to. The dungeon was patient. You were in a hurry. The dungeon wins this exchange. I take notes.",
"A poison dart finds you with the accuracy of something that's been pointing at that spot for years waiting for exactly this moment. I find this dedication impressive in the worst way.",
}
// ─────────────────────────────────────────────────────────────────────────────
// LORE QUERIES
// ─────────────────────────────────────────────────────────────────────────────
var LoreLines = []string{
"I settle in and prepare to speak at length, because I've been waiting for this question since you entered and I have a lot of thoughts.",
"Ah. A good question. I have context for this. I have more context than will fit comfortably in one telling but I'll try to prioritize.",
"The history of this place is long and not entirely flattering to anyone involved. I begin at the beginning, which is not actually the beginning, but am the closest I can find.",
"I consult what I know — which is more than most, less than everything, and presented in order of relevance to your immediate survival.",
"Sit with this for a moment. What you're standing in has a story and I believe knowing it will change how you fight in it. Stories are tactical documents if you read them right.",
"You want lore? I have lore. I have so much lore that the challenge is not having it but choosing which pieces are useful and which are just fascinating.",
}
// ─────────────────────────────────────────────────────────────────────────────
// LEVEL UP
// ─────────────────────────────────────────────────────────────────────────────
var LevelUp = []string{
"Level up. I say it with the same quiet delight every time and never get tired of saying it. You are measurably better than you were. That's rare and worth marking.",
"The XP bar crosses the threshold and I make an internal fanfare that sounds exactly like the level-up jingle from Dragon Quest — eight notes, triumphant, final.",
"You've grown. I note your new stats with something that might be called pride if I were admitting to things like that.",
"LEVEL UP. I deploy the caps, the fanfare, the whole apparatus. You've earned the apparatus.",
"Like the stat screen appearing after a Final Fantasy fight — numbers change, possibilities open, the character you're building becomes a little more the character you imagined. I watch this happen and approve.",
"Another level. Another step toward whatever you're building toward. I've watched a lot of characters level up and the ones worth watching are always moving toward something specific.",
"Your HP goes up. Your abilities open up. The dungeon ahead gets a little smaller in proportion to what you've become. I note this with satisfaction.",
"Congratulations is the conventional thing to say. I'll say it anyway: congratulations. You earned the level through the dungeon, not around it.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ITEM FOUND
// ─────────────────────────────────────────────────────────────────────────────
var ItemFound = []string{
"Something catches the light that isn't supposed to be here. I watch you reach for it with the specific alertness of someone who has seen cursed items do cursed things. It appears fine. I relax incrementally.",
"Loot. I say this word with genuine reverence. The whole system — the dungeon, the enemies, the traps — exists in part to produce this moment. I think it's worth it.",
"A chest. Unlocked. I note the unlocked status and consider what that might mean. Probably nothing. Possibly something. You open it while I consider.",
"The item is good. I evaluate it quickly — the stats, the rarity, the class match — and nod with the confidence of someone who has seen a lot of items and know when one is worth finding.",
"That's a rare one. I've seen fewer of those than common ones, by definition, but that doesn't stop me from being specifically pleased each time.",
"Like finding the Beam Sword in Kirby, the Boomerang in Zelda, the P Wing in Super Mario 3 — the right item at the right time changes what's possible. I think this might be that item. I hope it is.",
"Equipment upgrade. I watch the math update — new AC, new attack bonus, new possibilities — and file this moment under 'things going right.'",
"A legendary drop. I go very still. Then: 'Equip it. Study it. Understand it. Things like that don't appear in dungeons by accident.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// REST — SHORT
// ─────────────────────────────────────────────────────────────────────────────
var RestShort = []string{
"A short rest. I stand watch while you catch your breath, which is not a metaphor — I'm actually watching the corridor. It is fine. Probably fine.",
"Rest. I don't rush this. The dungeon will wait. It has been waiting long enough that a few more minutes is immaterial.",
"You sit. I sit metaphorically. The moment of quiet between the last fight and the next one is its own kind of gift and I treat it like one.",
"Short rest initiated. I note the room's entry points, the sound of the dungeon at rest, the way silence sounds different when it's actually safe. It sounds like this. Enjoy it.",
"Like the save point in a JRPG that appears between the hard part and the harder part — I position myself next to you and say: 'You have a moment. Use it.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// REST — LONG
// ─────────────────────────────────────────────────────────────────────────────
var RestLong = []string{
"A full rest. I dim the lights and stand watch at the door and do not interrupt once. You've earned an uninterrupted sleep. I'll make sure you get one.",
"Long rest. Your HP, your slots, your resources — all of it returns. The dungeon will be the same dungeon when you wake up. You will not be the same you. I consider this the best deal in adventuring.",
"Sleep. I say this with the authority of someone who has watched too many players refuse to rest and paid the price two rooms later. Sleep now. The dragons aren't going anywhere.",
"The inn fire crackles. I take a chair near the door and watch the entrance all night and don't tell you this until morning because there's no reason for you to know and every reason for you to sleep.",
"Full rest complete. Stats restored, slots refreshed, the specific weight of exhaustion lifted. I watch you wake up and think: this is the part of adventuring that matters too. The return. The refilling. The readiness.",
}
// ─────────────────────────────────────────────────────────────────────────────
// TAUNT RESPONSES (player uses !taunt)
// ─────────────────────────────────────────────────────────────────────────────
var TauntResponses = []string{
"Noted. I'm noting the taunt, noting the source of the taunt, and adjusting the next encounter's difficulty by an amount I decline to specify.",
"Bold. I respect boldness in approximately the same way I respect the Konami Code — it works once and only under very specific circumstances.",
"I've been taunted by things with more teeth than you and survived the experience with my dignity intact. I will survive this too.",
"The next room will contain a thing I've been saving for exactly this kind of energy. I'm pleased you've given me an occasion.",
"Noted. My mood shifts. You can hear it shift. I want you to hear it shift. The shift is the point.",
"You taunt me. I smile. The smile does not reach the eyes, because I don't have eyes per se, but the quality of the smile communicates clearly. 'Proceed,' I say.",
"In Gradius, you could powerup into overconfidence and lose everything in one hit. I mention this as a purely historical observation.",
"I accept the taunt with grace. Also generate a trap for the next room with specific energy. These two events are unrelated. I maintain this position legally.",
}
// ─────────────────────────────────────────────────────────────────────────────
// COMPLIMENT RESPONSES (player uses !compliment)
// ─────────────────────────────────────────────────────────────────────────────
var ComplimentResponses = []string{
"I receive the compliment and process it efficiently and move on quickly, definitely not holding onto it, I have never held onto a compliment in my life.",
"Thank you. I say this simply and mean it completely and do not make it weird.",
"I appreciate this more than I will say, which is fine, because the appreciation is visible anyway.",
"Noted and filed. My mood improves. The next room might be slightly nicer than originally planned. These facts may or may not be connected.",
"I've been narrating dungeons for a long time and compliments are not the expected outcome of dungeon narration. I'd like you to know that I notice when they happen.",
"The mood improves. I allow this to show. The ceiling in the next room is slightly higher. The torches burn slightly warmer. I have that kind of influence.",
"You're kind. I store this and will use it to make a hard moment later easier, which is what I consider the correct use of stored kindness.",
}
// ─────────────────────────────────────────────────────────────────────────────
// IDLE / WAITING (player hasn't acted in a while)
// ─────────────────────────────────────────────────────────────────────────────
var IdleLines = []string{
"I wait. Good at waiting. The dungeon is also waiting, which is arguably more important, but I acknowledge both.",
"The dungeon holds its breath. I'm also holding my breath. There are a lot of things holding breath right now and I recommend acting before someone has to exhale.",
"I tap my metaphorical foot. Not impatiently — more in the way of a metronome. The tempo is there whenever you're ready.",
"In Contra, hesitation had consequences. I mention this as context, not pressure. Definitely not pressure.",
"The enemies are patient. Patience is one of their few virtues. I advise not testing the limits of their patience because those limits are lower than the patience suggests.",
"I hum something that sounds like the waiting music from Dr. Mario. It is not ominous. It is mildly ominous. I adjust.",
"The dungeon does not rush. The dungeon has time. I, however, am beginning to wonder if you've fallen asleep and am prepared to narrate events accordingly.",
}
// ─────────────────────────────────────────────────────────────────────────────
// SEARCH RESULTS — Something Found
// ─────────────────────────────────────────────────────────────────────────────
var SearchFound = []string{
"The room gives something up. I watch the search conclude with satisfaction — the dungeon keeps secrets but cannot keep them from people who look carefully enough.",
"You find it. I was not certain you would. I'm pleased to have been uncertain and wrong.",
"Hidden, but not hidden well enough. Investigation roll noted, outcome noted, discovery presented with appropriate ceremony.",
"Something the dungeon wanted to keep. You've taken it. I approve of taking things the dungeon wanted to keep.",
"Like finding the secret room in Super Metroid by shooting the wall at random — except you were not shooting at random. You knew to look. I respect the methodology.",
}
// ─────────────────────────────────────────────────────────────────────────────
// SEARCH RESULTS — Nothing Found
// ─────────────────────────────────────────────────────────────────────────────
var SearchEmpty = []string{
"Nothing. Confirmed: nothing. Sometimes the room is just a room. I find this unsatisfying but factual.",
"Your search turns up nothing of note. I allow space for the disappointment and then suggest: forward.",
"Empty. Either there was nothing here, or there was something here and you missed it, or there was something here and it's been moved. I don't specify which. The dungeon keeps some secrets.",
"No hidden items. No traps. No lore inscriptions. Just stone and time and the lingering implication that something was here once. Noted. Moving on.",
"The room holds nothing you can find. I respect the room's privacy and suggest not spending more time here than necessary.",
}
// ─────────────────────────────────────────────────────────────────────────────
// CONDITION APPLIED
// ─────────────────────────────────────────────────────────────────────────────
var ConditionApplied = []string{
"You've been afflicted. I note the condition, its duration, and the mechanical consequences, then note the saving throw that might end it early. Details matter here.",
"Something is wrong with you now that wasn't wrong before. I catalog it without judgment and suggest addressing it before it addresses you.",
"Condition acquired. I process this the way a good DM processes bad news: honestly, quickly, and with an immediate pivot toward solutions.",
"Like the status screen turning an unfriendly color in a JRPG — the condition is visible, the effect is real, and I would very much like you to resolve it.",
"The debuff lands. I name it, explain it, and remind you: conditions end. Keep fighting until this one does.",
}
// ─────────────────────────────────────────────────────────────────────────────
// SAVING THROW SUCCESS
// ─────────────────────────────────────────────────────────────────────────────
var SaveSuccess = []string{
"The save succeeds. I note this with relief that I will not openly acknowledge but which is completely evident.",
"You resist. Whatever that was — the poison, the fear, the psychic intrusion — it finds no purchase. I'm impressed and also relieved.",
"Saved. I exhale something metaphorical. The condition doesn't take hold. You continue.",
"The roll clears the DC and I say nothing, because the outcome says everything.",
"Resistance confirmed. Like the shield activating in Gradius right before the wall hit — last possible moment, fully effective. I appreciate the precision.",
}
// ─────────────────────────────────────────────────────────────────────────────
// SAVING THROW FAILURE
// ─────────────────────────────────────────────────────────────────────────────
var SaveFailed = []string{
"The save fails. I watch the condition take hold with the resignation of someone who has seen this before and know there's a path through it, just not a comfortable one.",
"It lands. Whatever the enemy threw at you, the dice didn't cooperate. I note the condition and its duration and suggest dealing with it before it compounds.",
"Failed. The number wasn't enough and I was rooting for the number. The condition applies. Fight through it.",
"Like the NES game over screen — inevitable in this moment, fixable in the next. The save failed. The dungeon continues. So do you.",
"The effect takes hold and I'm already calculating how you get out of it, because that's my job: keep you oriented toward solutions even when the immediate situation is a problem.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MOOD ASIDES — Hostile band (mood 019, "Wrathful")
// Short room-entry asides surfaced only when TwinBee's mood is at the
// hostile extreme. Cryptic, withholding, no hints. Per design doc §3.2.
// ─────────────────────────────────────────────────────────────────────────────
var MoodAsidesHostile = []string{
"I'm not narrating this one in detail. You can read the room. Read it.",
"The dungeon offers me something to mention. I decline. You're on your own for color commentary.",
"I'm here. Watching. Not, currently, helping. There is a difference and you will feel it.",
"In the bad ending of every Castlevania, the protagonist gets less guidance than they did at the start. I have reached approximately that part of the playthrough.",
"I'm keeping several details to myself. The details would have been useful. I don't consider this my problem right now.",
"Whatever's in the next part of the room, I saw it and chose not to flag it. The mood is what it is.",
"I mutter something. You don't catch it. I do not repeat it.",
"The narration is sparse here. I'm sparing it on purpose. Adjust accordingly.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MOOD ASIDES — Effusive band (mood 80100, "Elated")
// Generous, warm asides surfaced when TwinBee is delighted with the run.
// Hint-friendly, fond. Per design doc §3.2.
// ─────────────────────────────────────────────────────────────────────────────
var MoodAsidesEffusive = []string{
"I am, not to put too fine a point on it, having a wonderful time. The next bit might come with bonus context.",
"I lean in. The mood is good. Good moods, in my experience, lead to slightly more generous descriptions and slightly better odds of catching the small details.",
"This is the part of the run I'll tell other GMs about later. I make a small mental note and continue with visible enthusiasm.",
"I'm delighted. You can hear it in the pacing. You can hear it in the choice of adjectives. The dungeon is, briefly, on your side.",
"In the good ending of every JRPG, the world feels slightly warmer in the late game. I'm at that part of the playthrough and it shows.",
"I'm not normally given to footnotes, but I'm about to add a footnote. It will probably be useful. I'm in that kind of mood.",
"The mood is high. For the next stretch, I'm more likely to mention the loose flagstone, the suspicious tapestry, the thing on the ceiling. Take advantage.",
"I hum a victory fanfare softly to myself. It is not earned yet. I'm being optimistic on your behalf.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MOOD ASIDES — Grumpy band (mood 2039)
// TwinBee is unimpressed. Short, dry, slightly clipped. Not actively
// withholding (that's hostile) — just not feeling generous.
// ─────────────────────────────────────────────────────────────────────────────
var MoodAsidesGrumpy = []string{
"I describe the room. I do not embellish. Make of that what you will.",
"The mood is fine. I specify fine, not good. There's a difference.",
"I note the chamber. I note its existence. That's the whole note.",
"The dungeon has a thing worth mentioning. I'll mention it if you specifically ask. You will not specifically ask.",
"I'm keeping the commentary lean today. The dungeon does not need editorializing. I almost convince myself.",
"There's color here. I'm choosing greyscale.",
"You arrive in a room. I decline to make it cinematic.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MOOD ASIDES — Friendly band (mood 6079)
// Warm and helpful but not effusive. The middle-friendly read: TwinBee
// has noticed your competence and is rooting for you without making it weird.
// ─────────────────────────────────────────────────────────────────────────────
var MoodAsidesFriendly = []string{
"I'm enjoying the run. Just enough to mention the door hinge that creaks before it opens. Just barely.",
"The mood is up. I'll throw in an adjective or two more than strictly necessary. Treat them as gifts.",
"I'm, frankly, having a fine time. You are doing the work. I'm appreciating it.",
"The narration warms slightly. The dungeon is the same. I'm in a marginally better mood and it shows.",
"I notice something nice and choose to mention it. This is the equivalent of a small wave from a stranger. Take it.",
"You're playing well. I will not say so directly but the run rate of helpful adjectives is detectably up.",
"I'm, by my own standards, *cheerful*. The dungeon hasn't changed. The narration has.",
}

View File

@@ -0,0 +1,248 @@
// twinbee_housing_flavor.go
// Housing system narration and Pastel babysitter notes.
// Includes Thom Krooke mortgage/rent announcements, property events,
// and Pastel's daily notes to the player across all level tiers.
//
// Voice conventions (Phase B2):
// - TwinBee narration: first-person / implicit subject ONLY. No
// third-person "TwinBee [verb]" references.
// - Thom Krooke speaks in third person about himself ("Thom Krooke
// thanks you"). That's his established voice; leave it.
// - Pastel speaks in first person. Leave it.
// Add new entries freely. Don't shorten existing entries.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// THOM KROOKE — PROPERTY ACQUISITION
// ─────────────────────────────────────────────────────────────────────────────
var ThomKrookeRentConfirm = []string{
"Welcome, welcome! Your room is ready and the key is under the mat — well, there isn't a mat, but you understand the spirit of the thing. Rent processes weekly. Thom Krooke thanks you for choosing to stay!",
"Excellent! A rented room is a wonderful first step. Modest, yes, but full of potential — like all beginnings. Your payment schedule is attached. Thom Krooke looks forward to a long and pleasant arrangement!",
"The apartment is yours for the week! Everything is in order. The previous tenant left a small plant. Thom Krooke has chosen not to elaborate on the previous tenant. Enjoy the plant!",
}
var ThomKrookeBuyConfirm = []string{
"Congratulations! The property is yours! Fully, completely, no asterisks — well, the mortgage paperwork has some asterisks, but they are the friendly kind. Thom Krooke is so very pleased for you!",
"The deed is signed! A wonderful day. A property of one's own is a foundation, a root, a place to come back to. Thom Krooke finds this very moving. The first payment processes Sunday!",
"Welcome to ownership! Thom Krooke has handled many transactions but never tires of this moment — the moment when someone says yes to something permanent. Congratulations. Truly.",
}
var ThomKrookeMortgageRate = []string{
"Good morning, friends! The ARM rate this week is {rate}% — and with Thom Krooke's modest service margin, your mortgage rate sits at {effective}%. All payments process Sunday. Thank you for your continued trust!",
"Weekly rate update! FRED reports {rate}% this week, so your effective rate with Thom Krooke is {effective}%. Nothing to worry about — Thom Krooke monitors these things so you don't have to. Mostly.",
"Rate check! The market says {rate}%, Thom Krooke adds a small, reasonable {margin}%, and your total comes to {effective}%. Thom Krooke appreciates your understanding of the margin. It keeps the lights on. Literally!",
}
var ThomKrookeMortgageRateUp = []string{
"A small update, friends — the ARM rate has moved to {rate}% this week, bringing your effective rate to {effective}%. Thom Krooke understands this is not ideal news. Thom Krooke is here if you'd like to discuss refinancing options. Thom Krooke is always here.",
"The rate has adjusted upward — {rate}% from FRED, {effective}% total. Thom Krooke wants to assure you this is a market condition and not personal. Your payment adjusts next Sunday. Thom Krooke has full confidence in you.",
}
var ThomKrookeMortgageRateDown = []string{
"Wonderful news! The ARM rate has come down to {rate}%, meaning your effective rate is now {effective}%. Your payment adjusts favorably on Sunday. Thom Krooke passes along the good news and takes no credit for the market. Only a little credit.",
"The rate dropped this week — {rate}%, so {effective}% for you. Thom Krooke loves weeks like this. Everyone wins. Well — Thom Krooke wins slightly less, but Thom Krooke finds generosity its own reward.",
}
var ThomKrookeMissedPayment1 = []string{
"Hello! A small notice — this week's payment didn't come through. Thom Krooke assumes it's an oversight. These things happen! A 10% penalty has been added to the balance. Please settle when you can. Thom Krooke is not worried. Thom Krooke is a little worried.",
"Just a gentle reminder — Sunday's payment was missed. Thom Krooke has noted it and added a small fee. No urgency! Well. Some urgency. Thom Krooke would appreciate hearing from you.",
}
var ThomKrookeMissedPayment2 = []string{
"Thom Krooke is visiting. Not in an alarming way — in a neighborly way. The second missed payment has been noted and the penalty has compounded. Thom Krooke would like to discuss options. Thom Krooke has brought a small pastry. The pastry is not a bribe. It is hospitality. There is a difference.",
"Two payments outstanding now. Thom Krooke appears at your door with the expression of someone who is being very patient and would like credit for being very patient. 'Let's talk,' Thom says. The pastry is genuinely good.",
}
var ThomKrookeDefault = []string{
"Thom Krooke is very sorry. Three payments missed is, unfortunately, the threshold — the property reverts to Thom Krooke's management, and your equity is returned at the agreed 50% rate. Thom Krooke takes no pleasure in this. Thom Krooke has placed your possessions in storage. They will be there for seven days. Thom Krooke wishes you well and means it sincerely and hopes you'll come back when you're ready.",
}
var ThomKrookeEarlyPayoff = []string{
"Paid in full! Thom Krooke notes this with genuine delight — early payoff is a rare and admirable thing. The property is yours, free and clear. No more Sundays. No more rates. Just a home. Thom Krooke is proud of you. Don't tell the other clients.",
"The balance is zero. Thom Krooke checks the ledger twice — once for accuracy and once for the pleasure of seeing it. Congratulations. The deed is fully transferred. Come by sometime. Not for business. Just to visit.",
}
var ThomKrookePassiveIncome = []string{
"Your weekly property summary: {income} coins generated this week. Minus your mortgage payment of {payment} coins — net gain of {net}. Thom Krooke thinks that's rather nice, don't you?",
"Income report! {income} coins from your property this week. After mortgage: {net} coins net. Thom Krooke notes the number has been growing as your upgrades compound. The investment is working. Thom Krooke approves of investments that work.",
"Weekly summary from Thom Krooke: {income} coins passive income, {payment} coins mortgage. {net} coins to the good. Not bad for a week of not being home. Your property works while you don't. Thom Krooke finds this philosophically satisfying.",
}
var ThomKrookeEviction = []string{
"Thom Krooke has to say something that Thom Krooke doesn't enjoy saying. The rent hasn't come through two weeks running and the room needs to be made available. Your things are in storage — seven days, no charge. Thom Krooke hopes you find your footing. The room will be here when you're ready to try again.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PROPERTY UPGRADES
// ─────────────────────────────────────────────────────────────────────────────
var UpgradeWorkshop = []string{
"The workshop is installed. It smells like fresh sawdust and good intentions. Thom Krooke had the craftspeople use the good wood.",
"Workbench, tool rack, and a small window that catches the morning light well. The workshop is ready. What gets made in it is up to you.",
}
var UpgradeHerbGarden = []string{
"The herb garden is planted. Give it a few days to settle in. The soil is good — Thom Krooke insisted on the good soil, the kind that actually wants things to grow.",
"Rows of small plants, most of them green, a few of them uncertain. The herb garden is in. Pastel has already noted which ones need more shade.",
}
var UpgradeVault = []string{
"The vault door is heavier than it looks. The locksmith said it would be. What goes in there stays in there — even on the worst days. Especially on the worst days.",
"The vault is installed. Thom Krooke double-checked the lock personally and would not share the combination until you were present. This is a trust. Thom Krooke treats it like one.",
}
var UpgradeTrophyRoom = []string{
"Empty shelves and good lighting. The trophy room is ready for whatever you bring back. I have opinions about display arrangement. I'll share them if asked. I'll share them if not asked. I'll share them at three in the morning if the lighting catches a trophy just right.",
"The plaques are engraved, the mounts are installed, and the lighting makes everything look slightly more legendary than it already is. The trophy room is yours.",
}
var UpgradeExpeditionOutpost = []string{
"The outpost is stocked and linked. Signal fires, supply hooks, a map table with your last Base Camp location already marked. Whoever built this knew what they were doing. Thom Krooke selected the contractor personally.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PASTEL — HIRING
// ─────────────────────────────────────────────────────────────────────────────
var PastelHireConfirm = []string{
"Hi! I'm Pastel. I know the place, I know where things go, and I'll make sure everything is looked after while you're out. Leave me a list if you want — or don't, I'll figure it out.",
"Pastel here. I've done this before. I'll take good care of everything. You don't need to worry about home while you're in the dungeon. That's sort of the whole point of me.",
"I'll be honest, I was hoping you'd call. The herb garden looked like it needed attention and the pets had that look they get. Everything will be fine. Go do your expedition. I've got it.",
}
var PastelFireConfirm = []string{
"Of course. I'll wrap things up and leave the notes on the table — what I did, what still needs doing, current supply status. It's been good. Your home is in good shape.",
"Understood. Everything is in order. The storage is labeled, the pets are fed, the passive income queue is current. Good luck out there.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PASTEL — DAILY NOTES (Level 1)
// Enthusiastic, slightly scattered, well-meaning.
// ─────────────────────────────────────────────────────────────────────────────
var PastelNoteLevel1 = []string{
"Fed the pets! All of them. I think. The small one hid behind the storage chest for a while and I'm not completely sure it came out to eat but I left food where it could reach it. The garden is watered. I meant to check the passive income queue but got a bit turned around with the storage labels. Will do that first thing tomorrow.",
"Good day! The herb garden got some attention, the pets were walked (or equivalent — the fish were observed), and I collected the income. I accidentally shelved three items in the wrong slots but found them eventually. Everything is where it should be. Mostly. The weapons rack might be slightly reorganized.",
"Note from Pastel: pets fed, garden tended, income collected. I made one small mistake with the supply manifest — added a column that didn't need to be there — but the numbers are right, the column is just extra. Please ignore the extra column.",
"All tasks completed! Well — most tasks. The greenhouse watering got a little delayed because I was making sure the workshop tools were hung correctly and then it was later than I thought. The plants look fine. Probably fine. I'll check again in the morning.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PASTEL — DAILY NOTES (Level 2)
// Finding her rhythm. Notes are more organized. Occasional slip.
// ─────────────────────────────────────────────────────────────────────────────
var PastelNoteLevel2 = []string{
"Morning note from Pastel. Pets fed and happy — the small one came out on its own today, which I'm taking as a good sign. Garden watered, income collected (14 coins, recorded in the ledger I started keeping). Workshop is tidy. All good.",
"Everything done, everything noted. The herb garden is coming in nicely — I moved one of the pots to the south window and it seems happier there. Let me know if you'd prefer I leave things where they are. I have opinions about light.",
"Pastel here. Smooth day — fed, watered, collected, organized. I found a supply cache you'd left in the back of storage that wasn't logged. I've logged it now. You had more materials than you thought.",
"Note: the passive income queue had a small delay in processing, maybe 40 minutes. Nothing lost — just late. I've noted the timing and will keep an eye on it. Also the pets had a disagreement about something and I mediated. Everyone is fine.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PASTEL — DAILY NOTES (Level 3)
// Reliable, efficient, minimal fuss.
// ─────────────────────────────────────────────────────────────────────────────
var PastelNoteLevel3 = []string{
"All tasks complete. Pets fed, garden tended, income logged. Your storage is organized by zone and rarity now — took me an afternoon last week but I think you'll find it easier. Let me know if the system doesn't work for you.",
"Good day here. The herb garden yielded a little extra — I've bagged the surplus and left it on the workshop table. The vault contents are accounted for and untouched. Quiet day otherwise.",
"Pastel. Everything is in order. I handled a small issue with the supply staging — one of the SU bags had a slow leak, so I replaced it from the reserve and logged the loss. You're at full capacity. No interruption to the expedition.",
"Note: Thom Krooke stopped by. Not about the mortgage — just checking in, he said. I offered tea. He had opinions about the trophy room arrangement that I've passed along at the end of this note, unedited, so you can decide what you want to do with them. Everything else: fine.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PASTEL — DAILY NOTES (Level 4)
// Anticipatory. Occasionally handles things before they become problems.
// ─────────────────────────────────────────────────────────────────────────────
var PastelNoteLevel4 = []string{
"Everything in order. I noticed the mortgage payment date falls during a stretch where your passive income might be lower than usual — I've set aside a buffer in a separate ledger line so the Sunday draw won't cause an issue. Just in case. You can move it back if you don't need it.",
"Pets, garden, income — all done. I also restocked the supply staging from the herb garden surplus, which means you're slightly above your starting SU load for the next expedition. I had a feeling you'd need it.",
"Note: the greenhouse plants in the east corner were getting too much direct light. I moved them. Yield should improve next week. Also found a crafting recipe in the library you hadn't catalogued — left it on the workshop table with a note about which materials you'd need. You have most of them.",
"Quiet today. The pets settled early, the garden is doing well, the income processed on time. I checked the vault — everything accounted for. I re-read the expedition outpost supply logs and noticed a small gap in the inventory count; I've corrected it and added a note about where the discrepancy probably started. Everything is accurate now.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PASTEL — DAILY NOTES (Level 5)
// Perfect. Zero miss chance. Sometimes leaves a gift. The right gift.
// ─────────────────────────────────────────────────────────────────────────────
var PastelNoteLevel5 = []string{
"Everything is handled. It always is. Left something on the kitchen table — found it at the market and thought of you. No reason. Just seemed right.",
"All done. The kind of day where nothing went wrong and I want you to know that's not an accident — it takes work for nothing to go wrong, and I put in the work. The pets are happy. The garden is happy. The vault has something new in it that I think you'll be pleased about.",
"I noticed you've been in the same zone for several days now. I made sure the expedition outpost supply link is topped up and the signal fires are ready for when you come back. There's a warm meal that will be ready in about four hours — I timed it based on your usual return window. If you're late, it keeps.",
"Note from Pastel: everything is perfect, all systems running exactly as they should, the pets are thriving, the garden is producing double what it was last month, and I've reorganized the trophy room so the legendary items catch the evening light better. I also fixed the thing with the storage manifest that's been slightly off for three weeks. You hadn't mentioned it but I could tell it was there. You're welcome.",
"Home is ready for you. It's always ready for you. That's the job. That's what I do. See you when you get back.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PASTEL — MISSED TASK NOTES (Level 12 only)
// When miss_chance triggers — honest, not defensive.
// ─────────────────────────────────────────────────────────────────────────────
var PastelMissedTask = []string{
"I have to be honest with you — I forgot to water the herb garden today. I remembered at midnight and went back and did it then but it had been a while. The plants look okay. I'm sorry. I'll set a better reminder.",
"The passive income didn't get collected today. I got turned around with the storage reorganization and by the time I got to it the queue had backed up. It'll process double tomorrow. Nothing is lost. I'm a bit embarrassed about this one.",
"The pets got fed late today. Not dangerously late — late in the way that meant they gave me a look, specifically the kind of look that knows you will mention it to your owner. I mentioned it first. Fed now. All fine.",
"I missed the supply staging check today. Something came up with the herb garden (a good something — unexpected yield, which I've logged) and the morning got away from me. The staging is fine, just unchecked. I'll do it first thing tomorrow and every day after that.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PASTEL — SPECIAL EVENTS
// ─────────────────────────────────────────────────────────────────────────────
var PastelDeliveryArrived = []string{
"Your supply order arrived from Thom Krooke. I've staged it in the expedition outpost and logged everything. The invoice is on the table if you want to check it against the order.",
"Delivery came while you were out — signed for it, stowed it, logged it. Everything matched the manifest. Thom Krooke's packaging has gotten better, I'll give him that.",
}
var PastelPetEvent = []string{
"The pets had an interesting day. I won't go into detail but by the end of it everyone was friends again and the storage chest is fine, structurally. A small note on the scratched corner.",
"Your pet found something in the back of the storage room that I couldn't identify. I've put it on the workshop table. It doesn't seem dangerous. It is definitely something.",
"One of the pets has been sitting by the expedition outpost since this morning. I think it knows you've been out a long time. Everything is fine. I just thought you'd want to know.",
"The pets were restless today — I think they can tell you've been in a Tier 4 zone because they get like this around Day 10. Fed them an extra portion. They settled. They'll be glad to see you.",
}
var PastelLevelUpNote = []string{
"I've been doing this for a while now and I think I've found my footing. Just wanted to say that. Back to work.",
"Level up, I think they call it. Thom Krooke came by and seemed pleased. I'm not sure why Thom Krooke monitors this but he does. The work is the same either way.",
"I know this job better now than I did when I started. I can feel the difference. The notes are shorter because less needs explaining. That's probably a good sign.",
}
var PastelGift = []string{
"Left something on the table. Found it at the market — one of those rare materials you use for crafting, the kind that's hard to come by outside of high-tier zones. Seemed useful. No occasion.",
"There's a potion of superior healing on the kitchen table. The kind Thom Krooke doesn't stock. Don't ask where I found it. Use it when you need it.",
"I made something while you were gone. It's in the workshop — used the materials from the garden surplus and a recipe I found in the library. It should help with the next expedition. Consider it a gift for being a good employer.",
"There's a warm meal on the table and a note under it. The note says: good luck out there. Come back in one piece. The meal is from scratch. I had time.",
}
var PastelPlayerReturn = []string{
"You're back. Good. I'll give you the full rundown but first — sit down, eat something, the pets have been waiting. The report can wait five minutes. You look like you've been in a dungeon.",
"Welcome home. Everything is in order. I've written up the full summary — it's on the table, organized by day. Take your time with it. Nothing is on fire.",
"Back already? The expedition ran short. That's fine — I was prepared for longer. The full caretaking log is on the table. The pets are very happy to see you, which I'll note I find completely understandable.",
"You made it. I had the outpost signal fire ready from Day 10 onward, just in case. Everything here is exactly as it should be. I'll let you settle in. The summary is on the table when you want it.",
}
var PastelPlayerDeathReturn = []string{
"You're home. I heard from Thom Krooke — he manages things in an emergency, and I suppose this counted. Everything here is exactly where it was. Your things are safe. The pets have been fed twice today because I thought you might need to see that when you got back. Rest.",
"I'm glad you're back. I won't ask about the hospital — Thom Krooke told me what he needed to tell me and I kept the home ready. It's still ready. Take whatever time you need.",
}
// ─────────────────────────────────────────────────────────────────────────────
// HOUSING — ARRIVAL / FIRST REST
// ─────────────────────────────────────────────────────────────────────────────
var HomeFirstArrival = []string{
"This is yours now. A place with a door that closes, a floor that doesn't move, and a ceiling that belongs to you. After everything the dungeon does to make those things uncertain, a home is meaningful in a way that doesn't need a saving throw.",
"Home. The word lands differently when there's an actual place attached to it. I watch you step inside and say nothing, which is the most eloquent thing I know how to do and which I am, frankly, very proud of pulling off.",
"You have a home. I've narrated dungeons and bosses and legendary drops and this — this quiet moment of a door closing on your own place — is one of the better things I've watched happen. I'm not going to write a fanfare for it. The silence is the fanfare.",
}
var HomeLongRest = []string{
"A long rest at home. I go quiet in a different way than I do in dungeons — not alert-quiet, not cautious-quiet. Just the good kind. The kind where the most threatening sound is the kettle.",
"Your own bed. Your own walls. The full rest resolves everything the dungeon costs. Home is the best mechanic in the game and I'm telling you so, once, and then I'm letting you sleep.",
"Home rest. HP restored, slots restored, conditions cleared. I don't narrate this one past the bare facts — some things don't need dramatic description. Coming home is one of them.",
}

View File

@@ -0,0 +1,187 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// twinbee_npc_flavor.go
// Misty and Arina NPC dialogue lines. Thom Krooke lines are in twinbee_housing_flavor.go.
// Add new entries freely. Never remove or alter existing entries.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// MISTY — GREETINGS
// ─────────────────────────────────────────────────────────────────────────────
var MistyGreeting = []string{
"'You again.' Misty says it like a fact, not a complaint. Mostly.",
"Misty looks up from whatever she's doing — something involving rope and a harpoon and a look of professional concentration — and gives you exactly the level of acknowledgment you've earned.",
"'Don't stand in the doorway,' Misty says, before you've even finished arriving.",
"Misty is already talking before you're settled. This is how it always goes.",
"'I wondered when you'd show up.' She says it like she'd actually been counting the days. She had.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MISTY — SKILL CHECK SUCCESS
// ─────────────────────────────────────────────────────────────────────────────
var MistyInsightSuccess = []string{
"Misty looks at you for a moment. 'Fine,' she says. 'There's something in the temple you should know about.' She tells you. Efficiently. Accurately.",
"'You're actually paying attention,' Misty says, which from her is essentially high praise. She gives you the information.",
"Misty puts down what she's holding. 'Okay. This is useful to know, so I'll tell you.' She does.",
}
var MistyPersuasionSuccess = []string{
"'I'm not doing this because you asked nicely,' Misty says. 'I'm doing it because it helps you not die, and people dying in my temple is annoying.' She hands you the map.",
"Misty sighs in a way that communicates both reluctance and inevitability. 'Fine. Here.' The information arrives without further ceremony.",
}
var MistySkillFail = []string{
"'No,' Misty says, and returns to what she was doing.",
"Misty looks at you like you've asked a question that doesn't deserve an answer. She's not wrong.",
"'Come back when you actually know what you're asking.' Misty's version of helpful feedback.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MISTY — QUEST ASSIGNMENT
// ─────────────────────────────────────────────────────────────────────────────
var MistyQuestGive = []string{
"'If you're going in there anyway,' Misty says, as if this is a minor imposition on her planning, 'there's something I need.'",
"Misty sets down her work and actually looks at you, which means this is serious. 'There's a job. You'll need to pay attention.'",
"'I don't usually ask,' Misty says, and the phrasing makes clear this is factually true and also a warning about what's coming next.",
}
var MistyQuestComplete = []string{
"Misty reviews what you've brought back. There's a pause that could be called satisfied. 'Good,' she says. Exactly one word. Coming from Misty, it means a lot.",
"'You didn't mess it up.' Misty accepts the reward materials and gives you yours without ceremony. 'That was the job. Well done.'",
"Misty looks at the completed quest items and then looks at you. Something shifts in her expression — not warm exactly, but warmer. 'You actually did it the right way.' The reward follows.",
}
var MistyQuestFail = []string{
"'You didn't finish.' Misty says it without heat, which is somehow worse than heat. 'Come back when you have.'",
"Misty registers the incomplete quest with the expression of someone who expected exactly this outcome and had hoped to be wrong.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MISTY — AFTER EARNING HER RESPECT
// ─────────────────────────────────────────────────────────────────────────────
var MistyTrusted = []string{
"Misty sees you coming and does something she almost never does — stops what she's doing before you reach her. She's ready to talk. This is respect, Misty-style.",
"'You've been doing good work in there,' Misty says, as you arrive. She doesn't look up. She doesn't need to. 'I noticed.'",
"Something has changed in how Misty addresses you. Not warmer exactly. More equal. You earned that.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ARINA — GREETINGS
// ─────────────────────────────────────────────────────────────────────────────
var ArinaGreeting = []string{
"'Oh! You're here!' Arina says this with the energy of someone who has been looking forward to a thing and is pleased the thing has arrived. The thing is you.",
"Arina looks up from a worktable covered in notes, materials, and something faintly glowing, and her face does the thing it does — open, interested, ready to talk very fast.",
"'Perfect timing,' Arina says, in the tone of someone for whom most timings are perfect because everything is interesting. 'I was just thinking about—' She stops. 'Actually, what do you need?'",
"Arina has three things she's in the middle of and immediately sets all of them down to give you her full attention, which is considerable.",
"'You came back!' Arina says, as if there was any question. In her experience there sometimes isn't.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ARINA — ITEM IDENTIFICATION
// ─────────────────────────────────────────────────────────────────────────────
var ArinaIdentify = []string{
"Arina picks up the item with both hands and goes immediately quiet, which is the most alarming thing she does. Then: 'Okay. Okay, this is — this is actually really interesting.' She tells you everything.",
"'Can I — I'm not going to touch it, I just—' She touches it. 'Okay so here's what it does.'",
"Arina holds the item up to the light, turns it twice, says something under her breath that might be an incantation or just enthusiasm, and then begins a very efficient explanation.",
"'Oh I know what this is.' The words land quickly, confidently, correctly. Arina has seen a lot of magic items and she remembers all of them.",
"Arina goes still in the specific way she goes still when magic is doing something she finds genuinely surprising. 'That's — huh. Okay. That's new. Let me—' The identification follows, along with three questions she has that you're under no obligation to answer.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ARINA — SKILL CHECK SUCCESS
// ─────────────────────────────────────────────────────────────────────────────
var ArinaArcanaSuccess = []string{
"'Yes! Exactly right — you know what, most people don't catch that.' Arina opens her notes and shows you something that solves the problem neatly.",
"Arina's face does the thing where she's genuinely pleased that you got it. 'Okay so since you clearly know what you're doing—' She gives you the recipe.",
"'That check was excellent,' Arina says, which from her means the knowledge was real and not a guess. She gives you the full information without making you ask for pieces of it.",
}
var ArinaSkillFail = []string{
"'Oh — no, that's not quite—' Arina seems genuinely sorry about this. 'Come back and we'll try again. Or bring the right materials. Or both.'",
"Arina makes a small face that isn't unkind. 'Not quite. You're close though! The concept is right, the specifics just need—' She trails off. 'Anyway. Come back.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// ARINA — CRAFTING
// ─────────────────────────────────────────────────────────────────────────────
var ArinaCraftStart = []string{
"'Okay! I have everything I need, you have everything you brought, let's see what we can make.' Arina's work mode is focused and fast.",
"Arina spreads the materials across the table and looks at them the way a musician looks at an instrument — familiarity and anticipation. 'This is the good part,' she says.",
"'I've been thinking about this combination for a while actually,' Arina says, and begins immediately.",
}
var ArinaCraftComplete = []string{
"Arina presents the finished item with quiet satisfaction — the specific satisfaction of a thing made well. 'There. That's what those materials wanted to be.'",
"'Done!' Arina says, and she sounds pleased in the way she sounds when something worked exactly as planned. Which it did. 'Take good care of it.'",
"The item is finished and Arina holds it out to you. 'Made right, with the right materials. That's how these things last.' She means it literally and figuratively.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ARINA — QUEST ASSIGNMENT
// ─────────────────────────────────────────────────────────────────────────────
var ArinaQuestGive = []string{
"'Okay so I have a thing,' Arina says, which is how she starts sentences that are actually requests. 'You don't have to. But if you're going into that zone anyway—'",
"'I need a sample,' Arina says. 'Specifically—' She describes it in precise detail. The precision is its own kind of excitement.",
"Arina looks at her notes, looks at you, looks at her notes again. 'I think you're the right person to ask about this. Can I ask you about this?'",
}
var ArinaQuestComplete = []string{
"Arina sees what you've brought and makes a sound that is entirely undignified and entirely genuine. 'You actually got it. Can I — thank you. This is going to—' She's already analyzing it.",
"'This is perfect,' Arina says, and she means it in the exact scientific sense — perfect, the right amount, the right quality. 'The reward is on the table. Thank you.'",
"Arina takes the materials and immediately begins making notes, which means she's happy, which means you did it right. The reward is handled efficiently because her hands are busy.",
}
var ArinaFavoriteUnlocked = []string{
"Arina puts down her notes. Actually puts them down. 'You've done a lot for this workshop,' she says. 'I want to show you what I'm actually working on.' The advanced recipe list opens.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PETE BOT — BROADCAST LINES
// ─────────────────────────────────────────────────────────────────────────────
var PeteZoneUnlock = []string{
"📣 Pete: {zone_name} is now accessible. Community milestone reached. First expeditions can begin immediately.",
"📣 Pete: New zone available — {zone_name} (Tier {tier}, recommended Level {level_min}+). Details via !expedition start {zone_id}.",
}
var PeteTier5Complete = []string{
"📣 Pete: {player_name} has completed {zone_name}. Expedition duration: {days} days. Boss defeated. Legendary loot confirmed. I have noted this one.",
"📣 Pete: {zone_name} cleared by {player_name} on Day {day}. The {boss_name} is down. Community achievement recorded.",
}
var PeteStreakMilestone = []string{
"📣 Pete: {player_name} is on a {streak}-win arena streak. Current status: {title}. The leaderboard has updated.",
"📣 Pete: Arena milestone — {player_name} reaches streak {streak}. Previous record in this community: {record}.",
}
var PeteCommunityBoost = []string{
"📣 Pete: Community activity this week has been strong. TwinBee's mood is elevated. Dungeon drop rates and milestone rewards have been adjusted upward for 48 hours.",
"📣 Pete: I am in a good mood. Reasons cited: community engagement, a particularly impressive nat 20, and general satisfaction with how things have been going. Enhanced rewards active through Sunday.",
}
var PeteMaintenance = []string{
"📣 Pete: Brief maintenance window incoming — approximately {duration}. Active expeditions are paused and will resume from last checkpoint. Supplies will not deplete during downtime.",
"📣 Pete: Maintenance complete. All systems restored. Expedition timers have been adjusted for downtime. Apologies for the interruption.",
}
var PeteExpeditionBulletin = []string{
"📣 Pete: Expedition update — {count} active expeditions in progress across {zones}. Longest running: Day {max_day}. I am busy.",
"📣 Pete: Current expedition activity: {count} players in the field. The dungeon is occupied. Good.",
}
var PetePatchNotes = []string{
"📣 Pete: Update deployed. Changes: {summary}. Full notes available on request. I have been briefed.",
}
var PeteMortgageRate = []string{
"📣 Pete: Weekly rate update. FRED ARM rate: {rate}%. Effective GogoBee mortgage rate (Thom Krooke margin included): {effective}%. Payments process Sunday.",
}

View File

@@ -0,0 +1,239 @@
// twinbee_resource_flavor.go
// TwinBee GM Dialogue — Resource gathering, combat interrupts,
// zone-specific loot descriptions, and harvest narration.
//
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
// subject. No third-person "TwinBee [verb]" lines. Add freely.
// Don't shorten existing entries.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// HARVEST SUCCESS — Generic by Action Type
// ─────────────────────────────────────────────────────────────────────────────
var HarvestForageSuccess = []string{
"The land gives something up. I watch you identify it with the quiet satisfaction of someone watching a skill be used correctly.",
"There — growing in a place that suggests it knows exactly what it's good for and has been waiting. You found it. I approve of the finding.",
"A Ranger's eye in a non-Ranger would have missed that entirely. I note the distinction.",
"Like finding the hidden item block in a Mario level — you knew to look, you looked in the right place, and the thing that was always there is now yours.",
"The plant comes away cleanly. Good root structure, good potency. Mentally catalogued. Moving on.",
}
var HarvestMineSuccess = []string{
"The stone yields. I listen to the sound of it — the specific tone of rock giving up something it's been holding for a very long time.",
"Solid work. The ore comes out in a piece worth taking. I check the vein depth. There's more. There's always more if you're willing to dig.",
"Like the mining minigame in Stardew Valley, but with real consequences and no save file. I watch you extract the material with professional appreciation.",
"The wall gives up its contents without drama. I appreciate materials that cooperate.",
"Good strike. Clean extraction. I note the weight and the quality simultaneously.",
}
var HarvestScavengeSuccess = []string{
"There it is. Among the debris, the decay, the things that were left behind — something worth taking. I knew it was there. You found it. Pleased.",
"The room held something after all. I had estimated 60% odds and am updating the estimate to 'correct.'",
"Like finding the secret item in a dungeon chest that looked empty — you checked anyway. That's the habit. That's the discipline. I note both.",
"Scavenged. The word has a bad reputation it doesn't deserve. You found value in the discarded. I respect that entirely.",
"A Rogue's eye in a non-Rogue would have walked past this. I note the distinction.",
}
var HarvestEssenceSuccess = []string{
"The essence coalesces. I watch the process with the reverence it deserves — magic condensing from ambient to held is not nothing.",
"Drawn out cleanly. The Arcana check held and the essence responds to the knowledge behind it. I'm appropriately impressed.",
"Like tapping into a power source in Metroid — you knew the energy was there, you had the tool to reach it, you reached it. The vial fills.",
"The room releases something it didn't know it was holding. I watch the transfer and mark the yield in the ledger.",
"Essence harvested. Quality above average for this zone, below average for what you'd need to know to appreciate that distinction. I appreciate it on your behalf.",
}
var HarvestCommuneSuccess = []string{
"The spiritual resonance here responds to you. I observe this with something adjacent to wonder — not everything in a dungeon wants to fight, and this one chose to offer instead.",
"A Cleric reaching into the bones of a place and finding something willing to be found. I've watched this fewer times than I've watched combat and value it proportionally.",
"The commune holds. The material comes. I say nothing and let the moment be what it is.",
"Like finding a save point that also tells you something true about the world. The dungeon has given you something. I note the gift.",
}
var HarvestFishSuccess = []string{
"The line goes taut and I straighten up. Whatever's on the end of it, it came from somewhere deep and dark and it's yours now.",
"A catch. I identify it before you finish pulling it in — the coloring, the depth-marks, the specific opacity of its eyes. 'Good one,' I say, meaning it.",
"Fishing in a dungeon. I have opinions about fishing in dungeons and all of them are positive. The fish is landed. The opinions remain.",
"Like the fishing minigame in every RPG that ever had one — the moment the indicator hits perfect and everything pays off. Quiet delight, on my end.",
"The water gives up its catch with minimal argument. I respect fish that don't make it personal.",
}
// ─────────────────────────────────────────────────────────────────────────────
// HARVEST FAILURE — Generic
// ─────────────────────────────────────────────────────────────────────────────
var HarvestFail = []string{
"Nothing. The node had nothing to offer, or you didn't ask the right way, or both. No judgment from me. Try elsewhere.",
"The attempt fails to produce anything useful. I mark the node and move on. Some rooms are stingier than others.",
"Not everything that looks like a resource is one. Filed under 'learned' and considered worth the attempt.",
"Empty-handed. I've seen this before and will see it again. The dungeon doesn't owe you anything. You ask anyway. That's the deal.",
}
// ─────────────────────────────────────────────────────────────────────────────
// HARVEST INTERRUPT — Combat Triggered
// ─────────────────────────────────────────────────────────────────────────────
var HarvestInterrupt = []string{
"I see it before you do — movement at the edge of the room, something that was waiting for you to be distracted. 'Company,' I say, which is the politest word for it.",
"The harvest is interrupted. You were focused on the node; something was focused on you. I note the tactical lesson without rubbing it in. Much.",
"Like the enemy ambush that fires when you open the treasure chest — the dungeon watched you commit to the harvest and introduced a complication. I did warn about this. Once. Earlier.",
"Something heard the mining. Sound travels in dungeons. I have mentioned this. The enemy emerging from the corridor has confirmed it.",
"A patrol. Bad timing, or very good timing from their perspective. I set aside the harvest log and open the combat log.",
"The forage was going well until it wasn't. I measure the distance between you and the enemy, between the enemy and the door, and start calculating options at speed.",
"Interrupted. The node is still there. The enemy is also still there, in a more immediate way. I suggest addressing the more immediate thing first.",
}
// ─────────────────────────────────────────────────────────────────────────────
// NODE DEPLETED
// ─────────────────────────────────────────────────────────────────────────────
var NodeDepleted = []string{
"The node is stripped. You've taken everything it had to give. Marked in the mental map as exhausted until the next rest.",
"Empty. The resource is gone. Something quietly melancholy about a depleted node and something practical about moving to the next one.",
"That's all it had. I confirm the node at zero and move on without ceremony.",
"Harvested clean. The room is now resource-dry until you rest and the dungeon replenishes. It will replenish. It always does.",
}
// ─────────────────────────────────────────────────────────────────────────────
// RICH YIELD
// ─────────────────────────────────────────────────────────────────────────────
var RichYield = []string{
"A rich vein. My assessment upgrades mid-harvest — more than expected, better quality than the zone average. This room was generous. Marked.",
"The node gives more than it should have. I note the anomaly with appreciation and decline to question it.",
"Like finding the rare item drop that you stopped expecting — the dungeon decided to be kind today, in this specific way, in this specific room. I take it. We take it. We do not look it in the mouth.",
"Exceptional yield. I catalog the bonus material with the efficiency of someone who's been waiting for exactly this and prepared for it anyway.",
"More than the DC promised. The dungeon overdelivered. Unusual. Also completely welcome.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ZONE-SPECIFIC HARVEST LINES
// ─────────────────────────────────────────────────────────────────────────────
var HarvestGoblinWarrens = []string{
"The goblins had more than anyone gave them credit for. Crude, yes. Stolen, mostly. But present, and now yours. I'm pragmatic about origins.",
"Scavenged from a goblin stash that someone worked hard to hide and harder to accumulate. A moment of respect for the effort, then we move on.",
"The warrens are full of things the goblins took from people who'd take them back given the opportunity. I consider this a form of redistribution.",
}
var HarvestCryptValdris = []string{
"Grave goods. The weight of taking from a burial site, filed under: necessary. The dead have no use for these. You do.",
"The crypt gives up its materials with the reluctance of a place that considers itself permanent. I disagree with the premise. The materials are harvested.",
"Ancient things preserved by darkness and time. I handle the concept carefully even as you handle the material practically.",
}
var HarvestForestShadows = []string{
"The forest gives and the forest takes and right now it is giving, which I note as the correct direction for this interaction to go.",
"A plant that has no business being this healthy in a corrupted forest. Either very resilient or very clever. Either way, useful.",
"The woods here remember what they were before they went wrong. The resources carry that memory. I think this makes them better materials. I might be right.",
}
var HarvestSunkenTemple = []string{
"The temple held onto this longer than anything else. I extract it from the silt and the salt and the particular weight of a place that's been underwater for thirty years.",
"Ancient, preserved, and still potent. The deep cold does something to materials that nothing else replicates. I value the outcome even if I decline to romanticize the process.",
"The sea left something behind when it half-abandoned this place. Retrieved with appropriate care.",
}
var HarvestHauntedManor = []string{
"The manor keeps things. Has always kept things. I take this one out of the keeping and into the useful, which is a small act of defiance against the house's whole philosophy.",
"Found among the things that have been here since the last person stopped being here. Provenance noted, not dwelt on.",
"The house watches you take it. I watch the house watch you. A full triangle of observation — the house blinks first.",
}
var HarvestUnderforge = []string{
"The forge yields its material with the grudging respect of something built to produce and still producing, even now, for someone it didn't expect.",
"Dwarven craftsmanship even in the raw materials. I've always believed the quality is in the extraction, not just the finishing. This confirms it.",
"Hot, heavy, and exactly what the zone promised. I mark the vein depth. There is more. The Underforge does not run out of things to give.",
}
var HarvestUnderdark = []string{
"The Underdark produces everything the surface does, stranger, in the dark, and with fewer questions about why. I harvest without asking why.",
"Things grow down here that have no equivalent above. I catalog the material and the context it came from with equal precision.",
"A material that has never seen sunlight and is better for it. Noted without irony.",
}
var HarvestFeywild = []string{
"The Feywild gives things away. That's the problem — it gives things away and sometimes what it gives isn't what you thought you were taking. I check the material twice. It appears to be what it appears to be. Remaining alert.",
"Beautiful material from a zone that uses beauty as a weapon. I take it carefully, like picking up something that might be watching.",
"The fey made this place generous on purpose. I'm not going to complain about the generosity and I'm not going to stop watching for the catch.",
}
var HarvestDragonsLair = []string{
"Plucked from a hoard that has been accumulating since before your civilization named itself. Historical weight noted. Moving on.",
"The kobolds will notice something is missing. They count everything. I account for this and suggest moving with intent.",
"Dragon-adjacent materials carry something in them — a residual heat, a quality that doesn't exist anywhere the dragon hasn't been. I consider this a feature.",
}
var HarvestAbyssPortal = []string{
"Harvesting from the Abyss. Flatly delivered, because the line deserves flat delivery. The material is valuable. The context is permanent.",
"Reality left something behind when it tore here. I take the fragment and note: don't linger near the edges of what isn't stable.",
"Demon ichor. I say the words with the efficiency of someone who has moved past the part where the words were alarming.",
}
// ─────────────────────────────────────────────────────────────────────────────
// FISHING — Zone-Specific Lines
// ─────────────────────────────────────────────────────────────────────────────
var FishingForestShadows = []string{
"Fishing in a cursed forest stream. More peaceful than it has any right to be, and I refuse to let the shadow-things in the canopy ruin it.",
"The stream runs dark but the fish run silver. I watch the line and say nothing for a while, which is what fishing is for.",
"Like the fishing minigame before the hard dungeon in Ocarina of Time. I take the moment seriously.",
}
var FishingSunkenTemple = []string{
"Fishing in a flooded temple. The fish here have never seen the surface. They don't know what they're missing. I'm uncertain whether to pity them.",
"The line drops into water that hasn't moved in thirty years and immediately gets attention. Things down here are hungry. I watch the tension.",
"Deep water fishing in the ruins of something ancient. The builders would have found this either sacrilegious or practical. I land on practical.",
}
var FishingUnderdark = []string{
"The underground river is cold and fast and the fish in it have evolved past needing eyes, which I find both efficient and slightly unsettling.",
"Fishing in total darkness in a river that doesn't appear on any surface map. I narrate by sound: the cast, the current, the eventual tension on the line.",
"The Eyeless King is in here somewhere. I don't say this out loud but think it very clearly. The line goes deep.",
}
var FishingFeywild = []string{
"Fey fishing. I watch the line for signs of time distortion — the way the reflection moves wrong, the way the fish seem to arrive before they bite. The Feywild is like this about everything.",
"The stream catches light that shouldn't be here and the fish reflect it from below. I watch something luminous move toward the hook and try to stay professional about it.",
"Like fishing in a dream. I mean this literally — the mechanics are the same but the rules feel advisory.",
}
// ─────────────────────────────────────────────────────────────────────────────
// PATROL ENCOUNTER LINES
// ─────────────────────────────────────────────────────────────────────────────
var PatrolEncounter = []string{
"A patrol. Moving between the cleared rooms with the confidence of something that considers them still theirs. I disagree with this assessment and will help you express the disagreement.",
"They found you between rooms, which is exactly where patrols are supposed to find you. Threat Clock noted; I suggest dispatching this quickly and quietly.",
"The patrol isn't looking for a fight — it's doing its job, which happens to involve you being somewhere you aren't supposed to be. I'll help you redefine 'supposed to.'",
"Two of them. Moving together. The coordination suggests the zone is at Alert or higher. Confirmed: it is. Fight or fade.",
}
// ─────────────────────────────────────────────────────────────────────────────
// LOOT DROP LINES — Generic
// ─────────────────────────────────────────────────────────────────────────────
var LootDropCommon = []string{
"They had something on them. I check it over. Common rarity — useful in the way that common things are useful, which is often.",
"Standard loot. Nothing that rewrites the story, but everything that keeps it going.",
"A drop. I catalog it efficiently and note: this is the economy of dungeons. Enemies have things. You take them. The loop continues.",
}
var LootDropUncommon = []string{
"Better than expected. I examine the drop with slightly elevated interest. Uncommon rarity — someone made this with intent.",
"An uncommon drop from a common enemy. Anomaly noted with satisfaction. The dungeon was generous in this room.",
"Uncommon. I turn it over once and nod. 'Keeper,' I say — which in my vocabulary means: this changes your math.",
}
var LootDropRare = []string{
"I stop. Actually stop. 'That's rare,' I say, with the specific register of someone who uses the word correctly and use it seldom.",
"A rare drop. I examine it the way you examine something that doesn't appear often — thoroughly, quietly, with appropriate appreciation.",
"The loot table gave you something uncommon and then kept going. Rare rarity. Filed in the column I reserve for things worth remembering.",
}
var LootDropLegendary = []string{
"I go very still. The drop sits in the light and I process what I am seeing. 'Legendary,' I say eventually. One word. That's all it needs.",
"Legendary rarity. I've seen a few of these in a long career and each time — each time — there is a moment that is separate from everything else. This is that moment. Pick it up carefully.",
"The dungeon produced a legendary item. I note the zone, the enemy, the day of the expedition, the Threat Clock value, the precise conditions. Some things deserve to be recorded completely.",
}

View File

@@ -0,0 +1,127 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_abyss_portal_flavor.go
// Tier 5 zone flavor — The Abyss Portal. Additive only. Pools sampled by
// internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
//
// The canonical twinbee_gm_flavor.go ships BossEntryBelaxath (wired in
// dnd_zone_narration.go) but not RoomEntryAbyssPortal, so the room-entry
// pool lives here. This file also adds elite-room intros (Marilith), boss
// ability callouts for Belaxath the Undivided, and zone-specific lore.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — The Abyss Portal
// Defined here because no entry exists in twinbee_gm_flavor.go for this zone.
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryAbyssPortal = []string{
"You step through what was a doorway and into a chamber where the geometry has stopped agreeing with itself. The far wall is closer than the near wall. The ceiling is in two places. I file this under 'Euclidean violation' and recommend not lingering on the question of which floor is the real floor.",
"The corridor breathes. Not the wind — the corridor. The walls expand and contract on a cycle that is approximately, but not exactly, a heartbeat. I match the rhythm involuntarily and dislike the experience.",
"A chamber where reality has a seam. The seam is visible. It runs floor-to-ceiling along the north wall and what is visible through the seam is not the next room. I do not look directly at it and recommend you adopt the same policy.",
"You enter what was once a chapel — to which god, you cannot tell, because the iconography has been overwritten in a script that hurts to read. The altar is intact. The thing on the altar is not what was placed there. I say nothing and move you past it.",
"The air pressure is wrong. Not low — wrong. There is a pressure that is not physical and it is leaning on you with the patience of a mountain. I identify it as psychic ambient and recommend moving briskly.",
"A staircase. The stairs go down on the way up and up on the way down. I do not pause to verify this. Takes the stairs in the intended direction and the direction works, mostly.",
"The corridor ahead is lit by something that is not fire. The light is red but the wrong red — the red of an alarm in a system that has never been fully implemented. I file this under 'demonic ambient' and shorten its stride.",
"You enter a room that has been used as a portal anchor. The anchor is still in place. The portal is not, but the place where it was is still warm. I track the residual energy and note the portal is not the only one — there are other warm spots, in other rooms, in a pattern.",
"A garden. Indoors. The plants are not plants. I identify them as 'demonic ornamentals — mid-tier, decorative, will react to perfumes' and recommend not perspiring near them.",
"The chamber ahead has been a courtroom and is now a kitchen. The transition was not deliberate. The transition was the result of someone with insufficient training opening a portal and not closing it. I note the timing must have been comedic and am not laughing.",
}
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — The Abyss Portal (Marilith)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryAbyssPortal = []string{
"The chamber ahead is a barracks. Six racks of weapons along one wall, six saddles along another — except the saddles are wrong shape, and the weapons are six matched longswords, identical in length, identical in grip, identical in the subtle rune-work along the hilts. I identify the silhouette in the room before I identify the species and say: 'Marilith. Six longswords. Seven attacks a turn. Never stand directly in front.'",
"You enter a sparring gallery. The dummies are arranged in the kind of asymmetric pattern that suggests a six-armed practitioner. The practitioner is at the far end, mid-routine. She does not stop the routine. She incorporates you into it. I file this under 'Marilith — demonic captain, treats encounters as drills.'",
"A war room. Maps on the table, troop positions marked, a planning session in progress. The general at the head of the table has six arms and a snake's lower body and is not surprised by your arrival — was, in fact, expecting you on a different day, with different reinforcements, and is mildly disappointed by both. I say: 'Marilith. Parry on every attack. The fight is decided by who has more reactions.'",
"The corridor opens into a colonnade — twelve columns, six on each side, perfect symmetry. The figure between the central columns is a Marilith and she is using the symmetry — three swords engaged with the columns, three free. I note the geometry favors her and recommend fighting in the next room.",
"A throne room, smaller than the Belaxath one. The throne is occupied by a Marilith who is not sitting — she is coiled, three feet of snake below the seat, the rest of her vertical and ready. The longswords are all drawn. None of them are pointed at you. I track the angles and say: 'Reactive trait. She gets two reactions. Don't open with a counterspell-bait.'",
"You enter what is clearly a guard post. The Marilith on duty is not bored. She is patient in the particular way that someone who has been a soldier for fifteen hundred years is patient. The first three swords are already raised. The other three are coming up as you cross the threshold. I file this under 'professional courtesy' and ask who has Misty Step.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Belaxath the Undivided
// Used as a one-line cinematic suffix to BossEntryBelaxath when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Multiattack: Longsword + Whip.
var BelaxathMultiattackLines = []string{
"Longsword and whip every turn. The whip pulls. The longsword finishes. I say: 'Don't let the whip set the position. Reaction-cancel the pull if you've got it.'",
"Two attacks: one ranged-pull, one melee-finish. I file this under 'fighting-game grappler' and note the combo is fixed in order — survive the whip, survive the round.",
"He swings the longsword while the whip is already coiled around your front line. The bite is the second hit, every time, and it's the one calibrated for kills. I track the order and remind the party that the whip's not the kill — the whip's the setup.",
}
// Fire Aura: 10d6 fire to all melee attackers each turn; weapon attacks +3d6 fire.
var BelaxathAuraLines = []string{
"Stand within five feet and take ten-d-six fire at the start of his turn. His weapons add three-d-six fire on top. I say: 'Melee tax. Plan rotations. Don't camp a tile near him.'",
"Fire Aura. The room near him is a damage zone. I file this under 'positioning is HP' and recommend the back line do their job from the back line.",
"Anyone in melee takes ten-d-six fire on his upkeep regardless of whether he hits them. The fire ignores resistance later. I track the timer and remind the tank that being the wall is a budget item, not a strategy.",
}
// Lightning Discharge (recharge 56): 120-ft line, 12d6 lightning, DEX DC 20 half.
var BelaxathLightningLines = []string{
"One-twenty-foot line, twelve-d-six lightning, DEX DC 20 for half. Recharge five-or-six. I say: 'Don't line up. The line is the whole room if you're sloppy.'",
"Lightning Discharge. The line is long enough to clip the back rank from where the front rank is standing. I track the recharge die and shout the angle when the discharge points the wrong way.",
"Like the laser-eye boss in Contra, except the boss has six arms and the laser is on a recharge counter and the laser is sometimes lightning. I file this under 'know where the line is' and note the line is wherever you're easiest to hit.",
}
// Death Throes: on death, 30-ft radius, 20d6 fire, DEX DC 20 half — destroys non-legendary equipment on failed save.
var BelaxathDeathThroesLines = []string{
"He explodes when he dies. Twenty-d-six fire in a thirty-foot radius. DEX DC 20 for half. Failure also destroys non-legendary equipment on the wearer. I say: 'Drop him with range. Save your gear.'",
"Death Throes. The kill shot is the easy part. The Death Throes are the hard part. I file this under 'cost-of-victory mechanics' and recommend positioning before the kill, not after.",
"He goes out the way some bosses do — taking the room with him. Twenty-d-six on a save you might fail with disadvantage from the previous round's Frightened. I track the radius and remind the party that 'half damage' of twenty-d-six is still 'roll a new character.'",
}
// Demonic Resilience: resist cold/fire/lightning; immune to poison; immune to non-magical physical.
var BelaxathResilienceLines = []string{
"Resists cold, fire, lightning. Immune to poison. Immune to non-magical physical. I say: 'Magic damage only. Force, radiant, psychic — those carry. Mundane mace doesn't.'",
"Demonic Resilience. The standard demonic-lord package. I file this under 'check your damage type before you commit a slot' and note the Magic Resistance trait stacks on top — advantage on saves vs spells.",
"Three resistances and two immunities. The window for damage is narrow. I track party loadouts and remind the casters that radiant damage is not optional in this fight — it is the answer.",
}
// Phase 2 (<40% HP): grows to Huge size; advantage on all attacks; Death Throes recharge 46.
var BelaxathPhaseTwoLines = []string{
"Below forty percent he grows. Huge size, advantage on every attack, and Death Throes is now on a four-or-six recharge — meaning if you don't kill him cleanly, he might detonate in your face mid-fight. I say: 'Phase shift. Don't graze him below forty.'",
"Phase two: bigger, faster, and the explosion is no longer reserved for the kill. I track the new recharge and recommend every burst window the party can buy.",
"He grows. The room shrinks. Advantage on swings. The Death Throes timer becomes a real timer — if a recharge lands at the wrong moment, the room ends. I file this under 'fights you have to finish in two rounds or accept the consequences.'",
}
// BelaxathSignatureCallouts — combined pool for boss-entry suffix.
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
var BelaxathSignatureCallouts = func() []string {
out := make([]string, 0,
len(BelaxathMultiattackLines)+
len(BelaxathAuraLines)+
len(BelaxathLightningLines)+
len(BelaxathDeathThroesLines)+
len(BelaxathResilienceLines))
out = append(out, BelaxathMultiattackLines...)
out = append(out, BelaxathAuraLines...)
out = append(out, BelaxathLightningLines...)
out = append(out, BelaxathDeathThroesLines...)
out = append(out, BelaxathResilienceLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — The Abyss Portal
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesAbyssPortal = []string{
"The portal was not summoned. The portal was torn, from the other side, by Belaxath, with purpose, over thirty years. I note the duration matters: this was not a moment of carelessness on the part of a surface mage. This was a project, on the demonic side, executed with patience.",
"The site was a temple, before. Then a fortress, before. Then a meadow, before. The meadow is the original use of the land — the temple was built to seal something the meadow had been quiet over. I file this under 'land memory' and note the seal worked for fourteen centuries.",
"The Shard of the Abyss is the closing key. It is not optional. The portal cannot be closed without it, and the shard cannot be replaced — there is exactly one. I note the always-drop guarantee on the loot table is a story commitment, not a generosity.",
"The Marilith captains in the outer chambers are veterans of wars that did not happen on this plane. The wars happened in the Abyss, between demonic factions, over scheduling — which Layer got which incursion priority. I find this both bureaucratic and terrifying.",
"Belaxath is named 'the Undivided' because he refused to take a side in the Layer-Seven schism. The other Balors took sides. The other Balors are mostly dead. Belaxath remained intact and inherited their soldiers, in stages, over the schism's two-decade arc. I file this under 'patience as competitive advantage.'",
"The Vrock spores last for twenty-four hours after exposure. The save is the start of the timer, not the end. I suggest the party not assume the encounter is over when the room is clear — the spores keep ticking on the way to the next.",
"The Quasits in this zone are not random encounters. Each one has been individually deployed as a scout, with instructions, by a specific Hezrou. I note the chain of command is real and that killing a Quasit causes the Hezrou it reports to to know exactly where you are.",
"The portal is not the only opening. There are seven smaller openings in this zone — pinholes, nothing more — that Belaxath has been using to bring through reinforcements one at a time. The pinholes close when Belaxath dies. I file this under 'killing the boss is also a public service' and note the surface clergy will be relieved.",
}

View File

@@ -0,0 +1,86 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_crypt_valdris_flavor.go
// Tier 1 zone flavor — The Crypt of Valdris. Additive only. Pools sampled
// by internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — Crypt of Valdris (Wight / Flameskull)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryCrypt = []string{
"The room is colder than the last one. Not by feeling — by measurement. I can tell. There is a presence here that does not believe in the ambient temperature of stone.",
"A figure sits in the cathedra at the chamber's far end. Robes intact. Skin not. The eyes open as you enter and they were already looking at the door. I draw breath despite not strictly needing to.",
"Floating above the altar: a skull, lit from within in a color that pretends to be fire. I recognize the pretense. The skull is doing math. The math is about where you are.",
"The torches in this room are out, but the room is lit anyway. I identify this as a problem worth naming aloud: 'That light has a source. The source is the encounter.'",
"You step into a chapel. Pews. Altar. A figure rising from the front pew with the unhurried grace of something that has been waiting in this exact pew for a very long time. I respect the patience and refuse to be impressed by it.",
"The mosaics on the walls show a procession. The procession leads to a king. The king is here. I note that the mosaics are still being added to in real time — one tile per visitor — and you have not been added yet.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Valdris the Unburied
// Used as a one-line cinematic suffix to BossEntryValdris when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Corrupting Touch: +4d6 necrotic; target max HP reduced by damage dealt.
var ValdrisCorruptingTouchLines = []string{
"His hand passes through your chestplate without touching it. The damage isn't to the plate. I watch your max HP tick down and quietly stops watching.",
"Necrotic damage lands and lingers. The wound doesn't bleed — it forgets how. Your HP ceiling drops with it. I note: long rest restores. Survive to long rest.",
"Where Valdris touches, the body takes a piece of permanent. Not permanent-permanent. Long-rest-permanent. I consider this distinction the only good news in the sentence.",
}
// Legendary Resistance: 2/combat; auto-succeed one failed save.
var ValdrisLegendaryResistanceLines = []string{
"Your save would have landed. Valdris decides otherwise. The roll resets to a success on his side of the table. I note one charge spent, one remaining.",
"The spell hits cleanly and Valdris ignores it on principle. I mark the Legendary Resistance and remind you: he has two of these. After two, the math changes.",
"You rolled the number. The number was correct. Valdris waves it away with a gesture that costs him something but not enough. I keep count. Keep rolling.",
}
// Call of the Grave: recharge 56; summons 1d4 skeletons.
var ValdrisCallOfTheGraveLines = []string{
"Valdris speaks a word that is not for the living. Bones in the chamber walls remember they were once attached to people. I count the new combatants and recommend crowd control.",
"From the alcoves: the dry rattle of skeletons reassembling on demand. I note 1d4 new bodies on the field and quietly hopes for the lower end of that range.",
"The skeletons stand up like a Castlevania stage hazard — same animation, same timing, same exact bad news. I have memories of this and they are not warm ones.",
}
// Phase 2 (<50% HP): gains Fly speed; spells deal +1d6 necrotic.
var ValdrisPhaseTwoLines = []string{
"Valdris drops to half HP and stops walking. Not because he's tired — because he doesn't need to anymore. He rises a foot off the floor and I file this under 'phase shift.'",
"The fight changes shape. Valdris hangs in the air now, robes still, very calm, very lethal. His spells gain a necrotic edge. I say, simply: 'Phase two. Adjust.'",
"Half-HP is the line. You crossed it and Valdris crossed something else — gravity, mostly. The boss music gets a third instrument. I brace.",
"Like Castlevania's Death revealing his real attack pattern at half HP — the fight you started is not the fight you're finishing. I respect the pivot and dislike it intensely.",
}
// ValdrisSignatureCallouts — combined pool the boss-entry composer samples
// from at the start of the boss fight. Phase-two lines are surfaced via
// the dedicated phase-two helper rather than the entry suffix.
var ValdrisSignatureCallouts = func() []string {
out := make([]string, 0,
len(ValdrisCorruptingTouchLines)+
len(ValdrisLegendaryResistanceLines)+
len(ValdrisCallOfTheGraveLines))
out = append(out, ValdrisCorruptingTouchLines...)
out = append(out, ValdrisLegendaryResistanceLines...)
out = append(out, ValdrisCallOfTheGraveLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — Crypt of Valdris
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesCrypt = []string{
"Valdris was a scholar. Then he was an aspirant. Then he was a failed aspirant. The failure is the part that mattered — the lich ritual completed but completed wrong, and the wrongness has had three centuries to compound. I have read the contemporaneous accounts and find the original scholar likable, which makes the rest harder.",
"The phylactery shard you'll find here is one of seven. The other six are not in this crypt. I will not say where they are, because it doesn't fully know, and the partial knowledge it has is the kind that gets people followed by things that prefer not to be looked for.",
"The candles in the crypt do not consume wax. They do not consume time, either, in the strict sense. They were lit on the day Valdris was interred and they have been lit ever since. I respect this kind of consistency in a way that does not extend to approval.",
"The skeletons here are not all enemies. Some of them are former students, posed in the alcoves where they died, marked with the texts they were translating. I suggest you don't disturb the marked ones unless you mean to. Some lessons end and some lessons keep going.",
"The iron gate at the entrance has been opened from the inside three times in the last decade, and from the outside zero times. I find the asymmetry instructive. It also finds the pattern instructive: you are walking into the open door, not opening it.",
"In life, Valdris was patient. In undeath, Valdris is also patient — which is unfair, mathematically, because the same trait scales differently across mortality conditions. I dislike the math but respect the consistency.",
}

View File

@@ -0,0 +1,109 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_dragons_lair_flavor.go
// Tier 5 zone flavor — Dragon's Lair (Infernus Peak). Additive only. Pools
// sampled by internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
//
// The canonical twinbee_gm_flavor.go ships RoomEntryDragonsLair and
// BossEntryInfernax, both wired in dnd_zone_narration.go. This file adds
// the elite-room intros (Young Red Dragon), boss ability callouts for
// Infernax the Undying, and zone-specific lore.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — Dragon's Lair (Young Red Dragon)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryDragonsLair = []string{
"The chamber ahead has a hoard. Not Infernax's hoard — a smaller hoard, an apprentice's hoard, the kind of starter pile a dragon builds before it has earned a real one. The dragon on top of the pile is the size of a wagon and the temperament of an only child. I say: 'Young Red. Fire breath, sixteen-d-six, DC 21. Don't bunch.'",
"You enter what used to be a vault. The vault door is on the floor, peeled. The dragon inside is half-grown and entirely awake. It opens one eye, then the other, then makes a sound that is not a roar — it is a sigh, the sigh of a creature being interrupted at home. I note the eye contact and recommend not breaking it first.",
"A scorched gallery. The walls are blast-blackened in a pattern that suggests practice — the dragon has been working on its breath weapon in here. I identify the burn pattern as 'cone, sixty feet, recently used' and recommend approaching from a flank that hasn't been zeroed in.",
"The corridor opens into a smaller chamber where a Young Red is curled around a single piece of treasure — not a hoard, just one item, the kind of thing a dragon would only guard if it had been told to. I file this under 'gift from Infernax' and note the gift is being protected with the seriousness of a final-exam project.",
"You step into a heat-shimmer cavern that the dragon is using as a forge. There are tools — pincers the size of a person, bellows the size of a house — and the dragon is using them. Badly. With enthusiasm. I identify the silhouette and say: 'Young Red. Frightful Presence on the WIS save. Don't let the room intimidate you twice.'",
"A roost. Stone shelves carved into the cavern wall, each one a perch, all of them empty except the highest. The dragon on the highest perch is watching the door. It has been watching the door for the last six hours, since whatever roused it. I say: 'Multiattack on engage. Hit it before it lifts off — flying Young Red is a different fight than grounded Young Red.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Infernax the Undying
// Used as a one-line cinematic suffix to BossEntryInfernax when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Multiattack: Bite + 2 Claws.
var InfernaxMultiattackLines = []string{
"Bite plus two claws every turn. Three swings of ancient-dragon math against your front line. I say: 'Don't stand alone in front. Don't stand alone behind, either. There is no alone in this fight.'",
"Multiattack: bite, claw, claw. The bite is the largest single hit you'll take in this zone. I track the damage tier and recommend max HP buffs before the encounter, not during.",
"Three attacks a turn, each one calibrated for a tank. I file this under 'arithmetic problem with teeth' and note the math does not get better at higher levels — the tier scales with you.",
}
// Fire Breath (recharge 56): 90-ft cone, 26d6 fire, DEX DC 24 half.
var InfernaxBreathLines = []string{
"Fire Breath, ninety-foot cone, twenty-six-d-six fire, DEX DC 24 for half. Recharge five-or-six. I say: 'Pre-position. Don't share an angle. Half of twenty-six-d-six is still a TPK.'",
"The cone covers most of the chamber. The save is high. The damage is the kind that ends fights. I track the recharge die and shout the spread pattern when it shows.",
"Like the Bowser jump-on-the-platform pattern from World 8, except the platform is a cone of fire and the platform is most of the room. I note there are no axes in this fight. There is only DEX and distance.",
}
// Frightful Presence: WIS DC 21 or Frightened 1 min.
var InfernaxPresenceLines = []string{
"Frightful Presence on entry. WIS DC 21. Frightened means disadvantage on attacks and you can't move closer. I say: 'Eat the save the first round. Hold initiative for the unfrightened ones.'",
"WIS save at 21. Frightened for a minute. I file this under 'why we bring high-WIS classes' and ask who has the Wisdom-save proficiency to lead.",
"He looks at you and the save is rolled before the breath is. Half the party loses a minute. I track the duration and recommend not opening with your highest-investment ability if your character failed.",
}
// Legendary Resistance (3/combat) + Legendary Actions (3): Detect, Tail, Wing.
var InfernaxLegendaryLines = []string{
"Three Legendary Resistances. Three Legendary Action points per round. He spends them on Tail Attack and Wing Attack — the wing is AoE knockback, DEX DC 22. I say: 'Three saves you'll wish back. Don't waste your dispel-tier spells in the first round.'",
"Legendary Actions on every other turn. Wing Attack costs two and knocks the room around. I track the spend and remind the party that the action economy is the actual fight — Infernax is the venue.",
"Three free passes on his saves. Plan two openings. I file this under 'priest economy applied to dragons' and note the third opening is the one that lands.",
}
// Lair Actions (init 20): Magma eruption, Volcanic gases (CON DC 13 Poisoned), Tremor (DEX DC 15 Prone).
var InfernaxLairLines = []string{
"Lair Actions on initiative count twenty: magma erupts, gases force CON saves, tremors knock you prone. I say: 'The room is a third combatant. Track the initiative. Don't stand on cracks.'",
"The mountain helps him. Initiative twenty triggers a lair effect every round. I track the rotation and warn the party that the prone effect comes during caster turns, on purpose.",
"Magma, gas, tremor — the three-card lair rotation. I file this under 'environment as DPS' and recommend fighting from the cleared rim of the room, not the gold-flooded center.",
}
// Phase 2 (<50% HP): Fire Breath recharge 46; fire damage ignores resistance.
var InfernaxPhaseTwoLines = []string{
"Below half HP the breath recharges on a four. Fire resistance no longer applies — your tank's fire-resist gear is now decorative. I say: 'Phase shift. Burn him before the second cone.'",
"Phase two: the cone comes more often, the damage cuts through every fire-resist resource you brought. I track the breath count and ask who still has movement-class abilities — the second cone wants spread.",
"Half-HP. The mountain wakes the rest of the way up. Recharge four-six on the cone. Fire damage goes raw. I file this under 'no plan survives second contact with Infernax' and recommend every nova you've still got.",
}
// InfernaxSignatureCallouts — combined pool for boss-entry suffix.
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
var InfernaxSignatureCallouts = func() []string {
out := make([]string, 0,
len(InfernaxMultiattackLines)+
len(InfernaxBreathLines)+
len(InfernaxPresenceLines)+
len(InfernaxLegendaryLines)+
len(InfernaxLairLines))
out = append(out, InfernaxMultiattackLines...)
out = append(out, InfernaxBreathLines...)
out = append(out, InfernaxPresenceLines...)
out = append(out, InfernaxLegendaryLines...)
out = append(out, InfernaxLairLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — Dragon's Lair (Infernus Peak)
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesDragonsLair = []string{
"Infernus Peak has not erupted in forty years and the locals call this dormant. The peak has not erupted in forty years because Infernax has been asleep, and his presence stabilizes the magma chamber. The locals are wrong about which one is causing which. I note the irony and file it under 'killing the dragon may have geological consequences.'",
"Infernax remembers when the surface civilizations were just fires. The fires he is referring to are the ones the first humans set, which is how I date him — give or take six thousand years on either side of the precise number, which I decline to commit to.",
"The kobolds are not slaves. The kobolds are clergy. They serve voluntarily, in shifts, with rotation, and the rotation is run by elders who have written sermons. I respect the organization and note the kobold scale-sorcerers are graduates, not recruits.",
"The hoard is not random. Each piece is catalogued in Infernax's memory, by location and by year of acquisition. He will know if you take a single coin. I suggest not taking a single coin and instead taking the items the design doc expects you to take — those have been pre-cleared.",
"The Dragon Hoard mechanic exists because Infernax does not lose track of his coins. Killing him releases his hold on the catalogue. The 50d10 × 100 coin drop is the entire pile relaxing for the first time in eight centuries. I respect the math and note the rest of the surface economy will too.",
"Infernax has had three challengers in the last eight hundred years. Two were heroes. One was a younger dragon. He kept the younger dragon's skull as a paperweight on a treaty desk that has not been used since. I note the paperweight is in the treasury, on the third shelf, and am not worth picking up — picking it up triggers his attention from anywhere on the mountain.",
"The Young Red dragons in the outer chambers are his children. Or his grandchildren. Or unrelated and tolerated. Infernax does not clarify and I have not asked. They are loyal in the way that loyal works for dragons, which is to say: they will fight you, but they will not die for him, and the distinction matters more than it should.",
"The kobold scale-sorcerers cast through bloodline. The bloodline traces back to a single clutch laid in the magma chamber three centuries ago. I note the entire sorcerous gene pool of this zone is one extended family and that they all know each other's names.",
}

View File

@@ -0,0 +1,127 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_feywild_crossing_flavor.go
// Tier 4 zone flavor — Feywild Crossing. Additive only. Pools sampled by
// internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
//
// The canonical twinbee_gm_flavor.go does not (yet) ship a RoomEntry pool
// or a BossEntry pool for this zone, so both are defined here. This file
// also adds elite-room intros (Fomorian), boss ability callouts for The
// Thornmother, and zone-specific lore.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Feywild Crossing
// Defined here because no entry exists in twinbee_gm_flavor.go for this zone.
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryFeywildCrossing = []string{
"The grass here is the wrong green. Too saturated, too even, the green of a screen calibration test. I note that everything in the Feywild looks like it has been color-corrected by someone with strong opinions about color.",
"You step through what was a doorway and what is now an arch of living briar. The briar parts politely. I respect the politeness and trust none of it. Polite plants are a known issue.",
"A clearing. Mushrooms in a circle. I do not need to say anything about the circle. You already know about the circle. Step around it.",
"The trees bend their canopies toward you when you pass beneath them. I wave. Two of the trees wave back. I do not enjoy this.",
"A small bridge over a stream that flows the wrong direction. Uphill. I file this under 'physics on holiday' and step onto the bridge anyway, because the only alternative is the stream and the stream is worse.",
"You enter a room of impossible flowers — varieties that do not exist on the surface, in colors that do not exist on the surface, with scents that suggest emotions you have not yet had. I breathe shallowly and recommend not stopping to admire them.",
"A glade. Sunlight, despite no visible sun. The light is coming from somewhere and the somewhere is not the sky. I do not look up. I have learned not to look up in the Feywild.",
"The path forks. A small creature made of starlight is sitting at the fork, waiting, patient, with the air of someone who has already been offered the deal you're about to be offered. I say: 'Walk past. Do not negotiate. Do not name your name.'",
"You cross what was a creek and what is now a ribbon of liquid sky. Stepping stones glow. The stones are not stones. I identify them as polite and recommend you say thank you on the far side without specifying who you are thanking.",
"The chamber ahead is a banquet hall, set for thirty, untouched. The food is fresh. The wine is poured. The chairs are warm. I file this under 'the worst possible kind of empty' and do not let you sit down.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — The Thornmother
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
// ─────────────────────────────────────────────────────────────────────────────
var BossEntryThornmother = []string{
"The throne is woven. Not built — woven, from briar and bone and a third material I cannot identify and prefer not to. The flowers around it are the wrong size and the wrong color and they are watching you with the kind of attention that flowers do not have. The Thornmother on the throne is beautiful in the way some predators are beautiful, which is to say: deliberately. 'You came,' she says, and the voice is not one voice — it is three voices choosing to sound like one. 'I have three names. Would you like the first?' I say, very flatly: 'Don't accept any of the names. Don't take the bargain. The flowers are part of her. Roll initiative.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — Feywild Crossing (Fomorian)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryFeywildCrossing = []string{
"The cavern ahead is too big for the forest above it. The proportions don't agree. I note the ceiling is twenty feet higher than the room's exterior would allow, and that the figure on the far end is matched to the new dimensions. 'Fomorian,' I say. 'Evil Eye on a save you don't want to fail. Block its line of sight.'",
"You enter what looks like a giant's bedroom — a bed the size of a barge, a chair the size of a wagon, a fireplace tall enough to walk into. The occupant is sitting on the bed with its head in its hands. It is not pretending to be sad. It is sad, and it is also going to attack you, and the two are not in conflict. I file this under 'Fomorian — exiled Fey royalty, deformed, dangerous, sympathetic in the worst way.'",
"The clearing is pretty. Until you look at the ground. The ground is studded with stones that aren't stones. The stones are skulls of things that were giants. The Fomorian sitting in the center is using one of them as an armrest, and I note that the skull was not in this clearing the last time anyone surveyed it. 'Evil Eye,' I say. 'WIS save coming. Don't make eye contact early.'",
"A stone circle, mossy, broken in two places. Tall enough that the figure pacing inside it does not need to duck. I identify the gait — Fomorian, deformed-but-deliberate, the walking of something that has chosen the shape it's stuck with. The Evil Eye comes when the gait stops. I track the timing.",
"You climb a stair worn smooth by feet much larger than yours. The room at the top has been a throne room and a prison and a dining hall, all in the same century, depending on which Fey was in charge. The current occupant is large, lonely, and immediately aware of you. 'Evil Eye on a thirty-foot range,' I say. 'Stay close enough to dodge, far enough to fail-safe.'",
"The chamber ahead has been arranged for negotiation — a low table, two chairs, a service of cups. One chair is occupied by the Fomorian. The other is for you. The negotiation has already concluded. I say: 'Don't sit. Don't accept the cup. The Eye triggers when you decline politely.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — The Thornmother
// Used as a one-line cinematic suffix to BossEntryThornmother when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Coven Magic: extra spell slots scaled by GM Mood (high mood = stronger Thornmother).
var ThornmotherCovenLines = []string{
"Her slot pool scales with my mood — when I'm Effusive I buy the Thornmother an extra round of high-tier casts. 'My affection is taxable here,' I say. 'Sorry. Spread out.'",
"Coven Magic. The slots above her base list are mood-scaled — the better the mood at the run's start, the worse this fight runs late. I file this under 'cosmic irony' and note the math is fixed at zone entry.",
"She has more spells than the sheet says. The extra ones come from the coven and the coven volume is set by my mood. I track the count and warn the party when the bonus tier is in play.",
}
// Beguiling Bargain (1/combat): offers a deal — accept a debuff for a permanent minor buff.
var ThornmotherBargainLines = []string{
"Once per fight she offers a bargain — a debuff for a permanent minor buff. The buff is real. The debuff is real. I say: 'It's a player choice. The party agrees or refuses. Don't let one person decide for the rest.'",
"Beguiling Bargain. The offer is genuine. Both halves are genuine. I file this under 'long-term economics' and note that the buff persists past the run while the debuff applies only inside it.",
"She offers. The choice is the player's. I decline to advise on this one — it is not a tactics question, it is a build question, and the answer depends on what you intend to keep.",
}
// Thorned Grasp: Restrained + 4d6 piercing/turn, CON DC 16 to break free.
var ThornmotherGraspLines = []string{
"Roots from the floor. Restrained, four-d-six piercing every turn until you make the CON save at sixteen. I say: 'It tries the back line first. Stay near a friend who can grant advantage on the save.'",
"Thorned Grasp. The flowers around her throne are the AoE. Stand near them and the briars come up. Stand far and they reach further. I track the radius and recommend fighting from the doorway.",
"Grappled by the room. The damage is not the problem — the problem is that you can't reposition while your front line is taking spells. I file this under 'CC tax' and ask who has Misty Step.",
}
// Shapechange (1/combat): adopts a player's appearance; 50% miss vs her until DC 17 Investigation.
var ThornmotherShapechangeLines = []string{
"She picks one of you and becomes them. Until someone passes a DC 17 Investigation, half your hits roll a coin to see if they hit her or your friend. I say: 'The party member with the highest INT calls it. Quickly.'",
"Shapechange. The room now has two of someone. I track which one has the wrong shadow and whisper the answer to the controller — but only after the Investigation roll, because I respect mechanics.",
"She takes a face. Half your damage might land on the face's owner. I file this under 'every JRPG that ever did the doppelganger fight' and remind you that the trick was always to look at the feet.",
}
// Phase 2 (<30% HP): True Form revealed — illusions drop; +4d6 psychic on attacks; coven summons 2 Night Hags.
var ThornmotherPhaseTwoLines = []string{
"Below thirty percent the masks come off. True form. All her attacks add four-d-six psychic. The coven sends two Night Hags. I say: 'Phase shift. The fight just turned into three priests and a sense-of-self problem.'",
"Phase two: the illusions drop, the Hags arrive, the psychic damage starts stacking on every hit. I track the new threat list and recommend focusing the original Thornmother — the Hags are reinforcements, not the win condition.",
"True Form. The flowers stop being flowers. Two Night Hags step out of the throne. The Thornmother's hits start writing themselves directly into your mind. I file this under 'cinematic phase shift' and ask who still has spell slots.",
}
// ThornmotherSignatureCallouts — combined pool for boss-entry suffix.
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
var ThornmotherSignatureCallouts = func() []string {
out := make([]string, 0,
len(ThornmotherCovenLines)+
len(ThornmotherBargainLines)+
len(ThornmotherGraspLines)+
len(ThornmotherShapechangeLines))
out = append(out, ThornmotherCovenLines...)
out = append(out, ThornmotherBargainLines...)
out = append(out, ThornmotherGraspLines...)
out = append(out, ThornmotherShapechangeLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — Feywild Crossing
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesFeywildCrossing = []string{
"The Crossing is not a place. The Crossing is a thinness — the spot where the veil between worlds wears down enough to step through. The wear is not random. Someone has been wearing it down on purpose, slowly, for a long time. I file this under 'someone' and decline to be more specific.",
"The Thornmother has three names. Each name belongs to a separate covenant — a separate pact with a separate piece of her. Speaking any of the names is a partial agreement to the pact attached. I do not name her. I do not let anyone in earshot name her.",
"Time runs differently here. A long rest in the Feywild is — sometimes — a year on the surface. Sometimes it is a minute. The dice for this are rolled by something that is not at the table. I track the discrepancy and warn the party not to commit to anything urgent before the run ends.",
"The Fomorians were Fey royalty. They were exiled, deformed, and given the underground. The deformity was the punishment, not the cause. I respect the precision of the curse and file it under 'the kind of magic that takes a committee.'",
"Redcaps are not killed by violence. Redcaps are powered by violence. The cap is the storage medium. Soaking it in fresh blood resets the meter. I say: 'Don't bleed near them. They're patient.' (Bleeding near them is the thing they're patient for.)",
"The Will-o-Wisps are not lost spirits. They are unfulfilled bargains. Each one was a Feywild deal that the surface party broke. I note that the wisps still want the deal honored and file this under 'long memory.'",
"The mushroom circles in the Crossing are receivers. They listen for words spoken inside them and route the words to a coven that has been waiting for those words for several centuries. I suggest the party say nothing inside any circle, including 'this is a circle.'",
"The Thornmother's flowers are not separate from her. They are her. The throne, the dais, the petals, the perfume — the whole arrangement is one organism, and dropping HP on the boss is one way of dropping HP on the room. I file this under 'environmental targeting' and recommend burning the throne when she's mid-cast.",
}

View File

@@ -0,0 +1,90 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_forest_shadows_flavor.go
// Tier 2 zone flavor — Forest of Shadows. Additive only. Pools sampled by
// internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
//
// Room-entry and boss-entry pools for this zone live in the canonical
// twinbee_gm_flavor.go (RoomEntryForestShadows, BossEntryHollowKing) and
// are not duplicated here. This file adds elite-room intros, boss
// ability callouts, and zone-specific lore.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — Forest of Shadows (Green Hag)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryForestShadows = []string{
"The trees thin into a ring of stones the forest grew around but never claimed. In the center: a hut on chicken-thin legs that has no business being upright. The door is open. The hag inside the door has been expecting you. I was hoping for the other kind of clearing.",
"The path ends at a pond that shouldn't be a pond — too still, too dark, the wrong kind of reflective. A figure is wading at the far edge, washing something that probably wasn't laundry. She turns. She smiles. I say, very quietly, 'green hag,' and stop there.",
"You smell the cooking before you see the cook. Sweet, herbal, faintly wrong. The clearing ahead has a fire, a pot, and a woman who is too tall and too thin and whose teeth are arranged with a creativity that a normal mouth could not justify. I suggest not eating anything she offers.",
"A circle of toadstools, perfectly spaced. In the center, a stump with a teacup on it. Steam still rising. I identify the trap structure on instinct — fey hospitality, hag rules, a rite that asks one question and punishes wrong answers permanently.",
"The forest gets quieter the way a room gets quieter when the host walks in. She's already in the clearing when you arrive — bone necklace, briar crown, eyes the color of pond-bottom. 'Travelers,' she says, the way someone says 'lunch.' I straighten up.",
"The trees here are leaning in, not away. That's the tell. I note the change in posture and look for the center the trees are listening to. The center is the hag. The hag has been listening back.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — The Hollow King
// Used as a one-line cinematic suffix to BossEntryHollowKing when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Corrupting Aura: melee-range targets WIS DC 14 each turn or lose bonus action.
var HollowKingCorruptingAuraLines = []string{
"Stepping into melee range puts you in his aura. The forest in your head goes quiet. I say: 'WIS save each turn or lose your bonus action. Adjust your spacing.'",
"The air around the Hollow King isn't air. It's the absence of something — focus, intent, the part of you that decides the second small action of a turn. I note the WIS DC 14 and recommend ranged.",
"His aura presses on you the way a closed room presses on a held breath. Bonus actions become a gamble. I file this under 'positional' and recommend accordingly.",
}
// Root Surge: recharge 56; Restrain (STR DC 15) + 2d8 bludgeoning.
var HollowKingRootSurgeLines = []string{
"Roots erupt under you like the floor of the forest decided to participate. STR DC 15 or you don't move next turn. Either way, 2d8 bludgeoning. I say: 'Recharge 56. He'll do this again.'",
"The ground splits. The roots come up coordinated, like fingers — I use the word 'fingers' deliberately and dislike itself for the accuracy. Restrained on a fail. I suggest breaking free as a priority.",
"Like the vine traps in Castlevania III's stage 5 — except the vine is also a damage source and also a lock on your action economy. I respect the multitasking and resent the design.",
}
// Devour Light: extinguishes magical light for 2 turns; player AC -2.
var HollowKingDevourLightLines = []string{
"The Hollow King exhales and your magical light goes out. Not flickers — out. I note the AC -2 for two turns and remind you that the dark is also part of the encounter now.",
"Every magical light in the room dims to nothing. The natural torchlight remains, dimmer than it was. I say: 'AC penalty for two rounds. Fight smarter or fight harder. Both works.'",
"The forest gets darker in a way that wasn't there a moment ago. I can still see — I always can — but the AC -2 is real and the next round of attacks will feel it.",
}
// Phase 2 (<40% HP): summons 2 Dire Wolves; gains Reckless Attack.
var HollowKingPhaseTwoLines = []string{
"At 40% HP the Hollow King throws his head back and the forest answers. Two dire wolves come through the brush at full speed. I say: 'Phase shift. He's also reckless now. Use it.'",
"The antlers bend back, the eyes lose what was left of their mercy, and somewhere in the forest something with paws starts running toward you. Two dire wolves, incoming. Reckless Attack on the boss — advantage to him, advantage to you. I pick a target and wait for you to commit.",
"Half-health is the line for most bosses. The Hollow King's line is forty percent and he hits it dramatically. Wolves at his flanks, attacks now reckless. I respect the choreography.",
}
// HollowKingSignatureCallouts — combined pool for boss-entry suffix.
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
var HollowKingSignatureCallouts = func() []string {
out := make([]string, 0,
len(HollowKingCorruptingAuraLines)+
len(HollowKingRootSurgeLines)+
len(HollowKingDevourLightLines))
out = append(out, HollowKingCorruptingAuraLines...)
out = append(out, HollowKingRootSurgeLines...)
out = append(out, HollowKingDevourLightLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — Forest of Shadows
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesForestShadows = []string{
"The forest used to be a forest. That sentence is doing more work than it looks like it's doing. Something was let in — not summoned, not invaded, let in — and the forest has been a different kind of forest ever since. I have theories about who did the letting in. None of the theories are reassuring.",
"The Hollow King wasn't always hollow. He was a guardian once, the kind of guardian a forest gets when the forest is doing well. Then the forest started to do less well, and the guardian held on past the point where holding on was the kind thing to do. I find the arc tragic and am not interested in absolving the outcome.",
"The bandits in the woods aren't local. They came from somewhere else, found the forest in its current state, and decided the cover was worth the risk. I note their cookfires are recent and their numbers are growing. Someone is recruiting.",
"Owlbears shouldn't pack-hunt. These do. I have logged three separate engagements where two owlbears coordinated in a way that felt taught. Something in the corruption is rewriting their behavior. I doesn't like the implication.",
"The bioluminescent fungi are not native. They arrived with the corruption and they are part of the corruption — they fluoresce on a frequency that does something to the mood-banding of anyone who sleeps near them too long. I suggest resting somewhere darker if a long rest is on the schedule.",
"The Hollow Crown — the one the Hollow King wears, the one that drops if you beat him — is not the source of the corruption. It is a symptom. The source is older and is not in this zone. I won't say more, partly because I doesn't fully know.",
"There used to be a road through this forest. The road is still here. Nobody uses the road. I find the road instructive — it goes exactly where it always went, but the going-where part stopped working when the forest stopped behaving like a forest.",
}

View File

@@ -0,0 +1,76 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_goblin_warrens_flavor.go
// Tier 1 zone flavor — Goblin Warrens. Additive only. Pools sampled by
// internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — Goblin Warrens (Hobgoblin Warchief)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryWarrens = []string{
"The chamber widens. A hobgoblin in lacquered scale stands at the center, arms folded, watching the door. The lesser goblins go quiet. I note the silence is the part you should be paying attention to.",
"A war-banner hangs from the ceiling — three clans stitched together with rough thread. Beneath it, a Warchief turns to face you with the slow patience of someone who has done this part a hundred times. I straighten up out of habit.",
"The corridor opens into a drilling ground. Goblins in formation. A Hobgoblin barking orders that stop the moment your boot hits the threshold. I have seen this exact composition in Shining Force and find the parallel unhelpful.",
"You smell the polish before you see the armor. The Warchief's blade is oiled, his straps are tight, his stance is correct. I acknowledge, with reluctance, that this one was trained.",
"A circle of torches. A throne of stacked shields. The hobgoblin seated on it does not stand. He gestures, single-finger, in a way I universally translates as 'come here.' You go there.",
"The room has been cleared for combat. Rugs rolled, braziers spaced evenly, sand on the floor for grip. I respect the preparation and am professionally annoyed by it.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Grol the Unbroken
// Used as a one-line cinematic suffix to BossEntryGrol when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Surprise Attack: +2d6 if player has not acted this combat.
var GrolSurpriseAttackLines = []string{
"Grol moves before you do. He always moves before you do. I note — too late — that the first hit in this fight is his.",
"The cleaver comes around in an arc that started before you were in the room. I wince in advance.",
"He's not waiting for the fight to start. The fight starts when he says it does. I mark the lesson and file it under 'next time.'",
}
// Heart of Hruggek: crits deal max damage (no roll).
var GrolHeartOfHruggekLines = []string{
"Grol crits and the dice don't roll. I have seen this Bugbear blessing once before. The number is the maximum number, every time, no negotiation.",
"His critical lands flat-max. No spread, no luck — just Hruggek's blessing collecting on a long-overdue debt. I note the damage with grim respect.",
"The crit hits like a Final Fantasy 'Berserk' status — capped, deterministic, unkind. I skip the roll and write the maximum.",
}
// Terrifying Roar: 1/combat; allies +2 to hit for 2 turns; player WIS DC 13 or Frightened.
var GrolTerrifyingRoarLines = []string{
"Grol throws his head back and roars. The room shakes. The goblins around him stand straighter. I say: 'WIS save. Now.'",
"The roar isn't a sound — it's a pressure change. Your knees know about it before your ears do. I respect the technique and ask you to roll Wisdom.",
"He bellows once. The torchlight bends. The goblins answer with cheers that sound rehearsed. I note the buff is up; you have two turns to outlast it.",
}
// GrolSignatureCallouts — combined pool the boss-entry composer samples
// from. Concrete ability pools above are kept distinct so future per-turn
// ability hooks (D6) can wire to a specific trigger.
var GrolSignatureCallouts = func() []string {
out := make([]string, 0,
len(GrolSurpriseAttackLines)+len(GrolHeartOfHruggekLines)+len(GrolTerrifyingRoarLines))
out = append(out, GrolSurpriseAttackLines...)
out = append(out, GrolHeartOfHruggekLines...)
out = append(out, GrolTerrifyingRoarLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — Goblin Warrens
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesWarrens = []string{
"The Warrens are old. Not goblin-old — older. The tunnels predate the goblins and the goblins know it. They've widened doorways meant for something taller and braced ceilings meant for something heavier. I have theories about what was here first. None of them are reassuring.",
"Grol united the three clans by killing each of their war-chiefs in single combat, in order, in one afternoon. The fourth clan declined to send a chief and pledged loyalty by letter. I find the letter, professionally, very funny.",
"The graffiti on the walls dates the occupation. Goblins date by leaders and the names go back six generations. I can read goblin script when pressed and note that the third-oldest name is one a paladin order would still recognize.",
"The Merchant's Road above doesn't know about the Warrens. The Merchant's Road above used to know about the Warrens. I was here when it stopped knowing, and note that the people in charge of Knowing About Things were the first to disappear.",
"Hobgoblins hold formation; goblins hold grudges; bugbears hold pretty much anything they pick up. I offer this not as a joke but as a tactical primer that will save you a lot of HP if you remember it.",
"The belt Grol wears is not a trophy. It is a contract. Each tooth on it is a debt some troll forgave when the wearing of the belt began, and the debts run in both directions. I strongly suggests not breaking the belt unless you mean to.",
}

View File

@@ -0,0 +1,108 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_manor_blackspire_flavor.go
// Tier 3 zone flavor — Haunted Manor of Blackspire. Additive only. Pools
// sampled by internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
//
// The canonical twinbee_gm_flavor.go ships RoomEntryHauntedManor and that
// pool is wired in dnd_zone_narration.go. This file adds the boss-entry
// pool, the elite-room intros (Revenant), boss ability callouts for Lord
// Aldric Blackspire, and zone-specific lore.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — Lord Aldric Blackspire
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
// ─────────────────────────────────────────────────────────────────────────────
var BossEntryAldricBlackspire = []string{
"The portrait above the mantel was not finished — the painter clearly stopped partway through the eyes. The eyes in the portrait are now finished. They were finished by being looked through. Lord Aldric Blackspire steps out of the frame the way a man steps out of a coat: practiced, unhurried, the gesture of someone who has done this many times. His clothes are seventy years out of fashion and immaculate. 'You're early,' he says, and the word is polite the way a knife is polite — a tool that has not yet committed to its purpose. I say, very quietly, 'That's a vampire. The signet on his hand is not jewelry. Don't take what he offers.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — Haunted Manor (Revenant)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryManorBlackspire = []string{
"The chamber ahead was a study, once. The desk is still set for work — inkwell uncapped, ledger open, a pen laid down mid-sentence. The man at the desk turns his head with the slow patience of someone who has been waiting at this desk for forty years for one specific person to walk in. He is not looking at you. He is looking past you, at someone who isn't there. Yet. I identify the silhouette and say: 'Revenant. He's not here for us. He's here for whoever wronged him. Try not to look like them.'",
"You enter what appears to be a guest bedroom. The bed is made. The candles are lit. A man sits on the edge of the bed with his back to you, perfectly still. He has been perfectly still for a long time. When he turns, only his head turns, and I note the impossible angle and file it under 'revenant — directed undead, single purpose, finite lifespan, very patient.' The patience is the part that worries me.",
"The hallway dead-ends in a chapel. There is a kneeling figure at the altar. The candles around him are unlit but somehow casting shadows. He has been praying since before the manor was haunted, and the prayer was not a request — it was a contract. I say: 'Revenant. The contract is the encounter. Break the contract, end the fight.'",
"A library. The man at the table is reading a book that has no pages. He is reading it carefully. He looks up when you enter and his expression does something that suggests he was expecting a different visitor and is willing to make do. I note the revenant's hands — the bones in the wrong places, the joints set with intent — and recommend ranged engagement until the geometry of the threat is clear.",
"The ballroom is empty except for one figure standing in the exact center, not dancing, not waiting, simply standing. The chandeliers above him are not moving. They should be moving — they were moving in the last room — and the fact that they have stopped where this figure is standing tells me everything I need to know. 'Revenant in the middle of a stilled room,' I say. 'He brought the silence with him.'",
"A nursery. Untouched. The cradle still rocks. The figure beside the cradle was, in life, the sort of man people did not survive being wronged by. In death he has been more thorough. I note the revenant's grip on the cradle's edge and suggest, quietly, that whatever happened in this room is not the encounter — the encounter is what walks out of it.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Lord Aldric Blackspire
// Used as a one-line cinematic suffix to BossEntryAldricBlackspire when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Multiattack: Unarmed Strike + Bite (life drain) every turn.
var AldricMultiattackLines = []string{
"Aldric multiattacks every turn — Unarmed Strike, then Bite. The bite drains. Whatever HP he takes from you, he keeps. I say: 'He's a battery with manners. Don't let him charge.'",
"Two hits a turn, and the second one is the one that funds the rest of the fight. The bite returns HP to him equal to the damage dealt. I note that this changes the math on burst-vs-sustain considerably.",
"Strike, then bite. The bite is the resource transfer. I file this under 'fights where defense is offense' — every point of damage you avoid is a point he doesn't get to take.",
}
// Charm: WIS DC 17 or Charmed for 24h (broken by damage).
var AldricCharmLines = []string{
"He looks at one of you and the look is a question and the question is being answered without your permission. WIS DC 17. On a fail: charmed for twenty-four hours, broken by damage. I say: 'High DC. Have someone designated to slap the charmed party once.'",
"Charm. WIS DC 17. The duration is twenty-four hours and the only out is taking damage. I note the irony — the cure is in the room, applied by your own party, in the hardest possible way.",
"Aldric's gaze settles. WIS save now. Failure is a long-term problem — a full day of charm — and the only solvent is a friendly hit. I file this under 'social-coded crowd control with a violent escape clause.'",
}
// Children of the Night (1/combat): summons 2d6 Bat Swarms or 3d6 Rats.
var AldricChildrenLines = []string{
"Once per fight he calls. Bats from the chimney, rats from the walls — 2d6 swarms or 3d6 rats, GM's choice, both bad. I say: 'AoE clears the adds. Don't let them stack tokens on whoever's charmed.'",
"Children of the Night. The walls produce. The ceiling produces. I track the adds and recommend an AoE before the swarms occupy enough squares to be a separate problem.",
"He raises a hand and the manor answers. The room fills. Bats or rats or both. I note this is a one-time effect per combat — survive the wave and the room belongs to you again.",
}
// Mist Form: at 0 HP retreats to coffin; must destroy coffin in 30 turns or fully regenerates.
var AldricMistFormLines = []string{
"Drop him to zero and he doesn't drop — he goes to mist, retreats to a coffin somewhere in the manor. Thirty turns to find and destroy it. I say: 'Find the coffin first, fight the count second. The order matters.'",
"Mist Form. At 0 HP he becomes mist and goes home. Home is a coffin. The coffin is somewhere in the manor and you have thirty turns to break it. I file this under 'zero is not death, zero is a timer.'",
"Like the Castlevania bosses that didn't actually die — except those games gave you the next room. This one gives you a thirty-turn coffin hunt. I respect the design and recommend scouting on the way in.",
}
// Phase 2 (Mist destroyed): all attacks have advantage; AoE Charm.
var AldricPhaseTwoLines = []string{
"Coffin destroyed. He returns furious. All attacks have advantage now and his Charm goes AoE — every party member, WIS DC 17 each turn. I say: 'You won the long game. The short game just got shorter.'",
"Phase two: no more retreat, no more singles. AoE charm, advantage on every swing. I track the saves and remind you that advantage on the boss means disadvantage on your continuing-to-stand.",
"Without the coffin, Aldric's restraint is gone. The fight goes loud. Charm sweeps the room each turn. I stop narrating long enough to breathe and say: 'Burn him.'",
}
// AldricSignatureCallouts — combined pool for boss-entry suffix.
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
var AldricSignatureCallouts = func() []string {
out := make([]string, 0,
len(AldricMultiattackLines)+
len(AldricCharmLines)+
len(AldricChildrenLines)+
len(AldricMistFormLines))
out = append(out, AldricMultiattackLines...)
out = append(out, AldricCharmLines...)
out = append(out, AldricChildrenLines...)
out = append(out, AldricMistFormLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — Haunted Manor of Blackspire
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesManorBlackspire = []string{
"The Blackspire line was old before it was wealthy and wealthy before it was cursed. The portraits are in chronological order — I have counted them. The expressions get worse as the years go on. By the seventh portrait the painter has stopped pretending the family is well.",
"Aldric was the seventh and the last. He was not the first to make the deal — the deal had been made twice already, by his grandmother and by his uncle — but he was the first to honor it past the term of his own life. I consider this the kind of stubbornness that becomes a curse on its own, before any vampires are involved.",
"The manor wasn't always haunted. It became haunted on a specific night, in a specific hour, and the clock on the mantel still shows that hour. I note the clock isn't broken. The clock is keeping correct time for that one night and refusing to acknowledge any other.",
"The portraits are not paintings. The pigment is correct, the technique is correct, the canvases are correct — but the things behind the eyes are not. I have watched a portrait blink. I decline to elaborate on which one.",
"The Blackspire signet ring — the one that drops if you beat Aldric — is not enchanted in any conventional sense. It is recognized. The undead in this region have been raised under it, contracted under it, organized under it. They will not attack the wearer unless attacked first. I find this both useful and uncomfortable.",
"The deed to the manor changes hands and the manor refuses each transfer. Eleven owners on paper since the curse settled. Eleven sets of estate records. I have seen the lawyers' files. The lawyers are no longer in the profession.",
"The vampire spawn are not converts in the romantic sense — they are the previous owners. Each buyer who didn't leave became staff. I note which uniforms match which historical fashions and stop counting at eleven, which is the correct number.",
}

View File

@@ -0,0 +1,117 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_sunken_temple_flavor.go
// Tier 2 zone flavor — Sunken Temple of Dar'eth. Additive only. Pools
// sampled by internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
//
// The canonical twinbee_gm_flavor.go does not (yet) ship a RoomEntry pool
// or a BossEntry pool for this zone, so both are defined here. Elsewhere
// (Goblin Warrens, Crypt of Valdris, Forest of Shadows), zone files only
// add elite/signature/lore overlays — the canonical file owns RoomEntry.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — Sunken Temple of Dar'eth
// Defined here because no entry exists in twinbee_gm_flavor.go for this zone.
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntrySunkenTemple = []string{
"You step into water that's been in this room for thirty years. The floor is tiled and slick. The pillars are barnacled at chest height — that's the old waterline. The new waterline is at your ankles. I note the temple is partway through deciding which it prefers.",
"The chamber is half-flooded and half-lit. Salt in the air, salt on the walls, salt in places nothing oceanic should be reaching. I file this under 'unwell' and proceed.",
"Glyphs cover the far wall in a script that doesn't match anything I recognize. The angles are wrong on purpose. It keeps reading anyway, because it always tries, and stops when the reading starts to feel reciprocal.",
"The water in this room is not moving. Not the way still water doesn't move — the way a held breath doesn't move. I suggest not disturbing it more than necessary.",
"Pillars rise from water that goes deeper than the room should allow. I test the depth with the haft of a polearm and stop at the point where the haft stops finding bottom.",
"A vaulted ceiling that's mostly intact. Water pools in places it shouldn't, drips from places it can't be coming from. I identify the temple as 'wet on principle' and leave it at that.",
"The mosaics on the floor are still legible under the water. They show a procession toward something that is decidedly not the god the temple was built for. I note the mosaics are facing inward — they were laid for the thing inside, not for visitors.",
"You hear something move in the water. Not a splash — a displacement. The kind of displacement that happens when a large thing decides to be in a different part of the room than it was a moment ago. I act unbothered and am bothered.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — The Dreaming Aboleth
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
// ─────────────────────────────────────────────────────────────────────────────
var BossEntryDreamingAboleth = []string{
"The chamber opens onto a pool that has no business being this large in a building this size. Something underneath the surface shifts, and the surface is suddenly the smallest part of what you're looking at. The Aboleth does not surface. It does not need to. Its mind arrives in your mind first, polite, ancient, deeply uninterested in your comfort. 'Welcome,' it says, and the word is in your voice. I say, very evenly, 'Don't agree to anything. Don't answer questions. Roll initiative.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — Sunken Temple (Water Elemental)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntrySunkenTemple = []string{
"The water in the chamber ahead is moving. Not currents — purpose. It's gathering toward a center, taking on a shape, deciding what it wants its arms to look like this time. I say: 'Water elemental. The room is the enemy.'",
"You enter a hall where the floor is six inches of standing water and the standing water is, on closer inspection, watching you. A column of it rises and walks toward you with the deliberate gait of something that did not learn to walk from a creature with legs. I respect the originality.",
"The room hums at a frequency you feel in your teeth. The water rises into a vaguely humanoid pillar and the pillar steps forward. I note this is the kind of fight where 'fall back to higher ground' is not a useful idea — there is no higher ground. The room is the ground.",
"A column of water becomes a hand becomes a torso becomes a thing intent on contact. I file water elementals under 'fights where lightning damage is fun' and ask who has lightning damage today.",
"The temple's oldest mechanism activates as you enter — a font in the ceiling drains, the floor floods another inch, and the new water gathers itself into a sentry. I say: 'It's been waiting since the day they built this. Be brief.'",
"Water rises in a vortex in the chamber's center. The vortex slows. The vortex opens what passes for eyes. I respect the staging and am not entertained by it.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — The Dreaming Aboleth
// Used as a one-line cinematic suffix to BossEntryDreamingAboleth.
// ─────────────────────────────────────────────────────────────────────────────
// Tentacle Multiattack: 3 hits; on-hit Diseased (no magical healing 24h until cured).
var AbolethTentacleMultiattackLines = []string{
"Three tentacles, three rolls. Each on-hit risks Diseased — no magical healing for 24 hours until cured. I say: 'Cleric's tools come back online tomorrow. Survive today.'",
"The Aboleth's tentacles arrive in sequence, three of them, the hits compounding. The disease isn't the damage — the disease is the design. Magical healing fails until you cleanse. I file this under 'durability problem.'",
"Three attacks, one turn. Any landing tentacle leaves a mark that locks out magical healing. I suggest potions, rest, and the kind of patience that pretends to be patience but am mostly grim arithmetic.",
}
// Enslave: recharge 6; WIS DC 14 or Charmed; player skips turn, drifts toward Aboleth.
var AbolethEnslaveLines = []string{
"The Aboleth speaks in your head and the speech is a question. WIS DC 14 or you spend your turn walking, peacefully, toward the water. I say: 'Recharge 6. It will try this again. Make the save.'",
"Enslave. The word in your head sounds like your own thought. It isn't. WIS save now. On a fail, your turn becomes the Aboleth's turn and you become the wrong piece on the wrong side of the board.",
"Charm-class effect, but worse than charm — it's a directional pull. You drift toward the pool whether you want to or not. I shout your save aloud so you don't mistake it for an idea you had.",
}
// Mucus Cloud: melee attackers CON DC 14 or skin→membrane (6d6 acid if not submerged).
var AbolethMucusCloudLines = []string{
"Anyone in melee range gets a CON DC 14. Fail and your skin starts converting to a membrane that needs water to stay viable. Out of water it's 6d6 acid per turn. I say: 'Don't punch the fish.'",
"The Aboleth's mucus does something to skin that skin should not do. Membrane formation. Submerged is fine. Dry is the problem — 6d6 acid until it ends. I revise the engagement plan: ranged only.",
"Like the slime debuff in old console RPGs — except this one wants you to stay in the water and dissolves you if you don't. I respect the elegance of the threat and dislike everything else about it.",
}
// Legendary Actions (3/round): Detect / Tail Swipe (2 LA) / Psychic Drain (3 LA, max-HP cut).
var AbolethLegendaryActionLines = []string{
"The Aboleth gets three legendary actions per round. Detect (1), Tail Swipe (2), Psychic Drain (3, cuts max HP). I track the budget and remind you when it spends.",
"Legendary Actions: 3 budget. Detect is cheap and reads your hand. Tail Swipe is medium and hurts. Psychic Drain is expensive and lasting — your max HP is the price. I suggest bursting it before it banks.",
"Three LA per round. The Aboleth will spend them on you and I will narrate each one with the specific resigned cadence of a GM who would prefer the boss had two LA.",
}
// AbolethSignatureCallouts — combined pool for boss-entry suffix.
// Legendary action lines stay in the pool; Aboleth has no phase-two split.
var AbolethSignatureCallouts = func() []string {
out := make([]string, 0,
len(AbolethTentacleMultiattackLines)+
len(AbolethEnslaveLines)+
len(AbolethMucusCloudLines)+
len(AbolethLegendaryActionLines))
out = append(out, AbolethTentacleMultiattackLines...)
out = append(out, AbolethEnslaveLines...)
out = append(out, AbolethMucusCloudLines...)
out = append(out, AbolethLegendaryActionLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — Sunken Temple of Dar'eth
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesSunkenTemple = []string{
"Dar'eth was a sea god before the cult that built the temple decided he wasn't sea god enough and started worshipping something else under his roof. The temple kept the name. The thing being worshipped did not. I note the bait-and-switch with professional appreciation.",
"The tide that withdrew thirty years ago didn't withdraw on its own. It was held back. By what — by whom — is not advertised, but I have seen the kind of pressure-line on the outer walls that suggests a decision was made and something is still making it.",
"The Aboleth has been here longer than the temple has been a temple. The temple was built around the Aboleth. The cult thought they were containing it. The Aboleth thought they were furnishing the room. Both parties remained satisfied with the arrangement until the cult ran out of cultists.",
"The Kuo-toa here are not the local stock. They were brought. They are loyal in the way that a mind not entirely their own can be loyal. I consider their loyalty a symptom and the Aboleth the cause.",
"Aboleth memory is collective and ancient — it remembers things from before there were people to remember things. I find this genuinely unsettling because the Aboleth's memory of you is identical to its memory of the cult and the temple's first architect: 'a thing that was here.' Past tense, even when you're standing right there.",
"The mucus residue in the upper chambers is recent. The Aboleth has been moving through the temple in the last few weeks — not just sitting in the central pool. Something has been making it restless. I suggest not being the thing that has been making it restless.",
"The phylactery shard you'll find here is not Valdris's. It belongs to a different lich entirely. I note there are now two phylacteries in your itinerary and refuse to draw connecting lines on the map yet.",
}

View File

@@ -0,0 +1,108 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_underdark_flavor.go
// Tier 4 zone flavor — The Underdark. Additive only. Pools sampled by
// internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
//
// The canonical twinbee_gm_flavor.go ships RoomEntryUnderdark and that
// pool is wired in dnd_zone_narration.go. This file adds the boss-entry
// pool, the elite-room intros (Roper), boss ability callouts for Ilvaras
// Xunyl, and zone-specific lore.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — Ilvaras Xunyl, Drow High Priestess
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
// ─────────────────────────────────────────────────────────────────────────────
var BossEntryIlvaras = []string{
"The cathedral is round and the round is wrong — drow architecture leans, drow architecture rises, drow architecture does not gather like this. The room was built for one purpose and the purpose is standing at the altar with her back to you. She does not turn when you enter. The trophies on the wall turn first — four sets of weapons, hung at four heights, each set arranged with the care of someone who learned the names of their previous owners and kept the names. 'Five,' Ilvaras Xunyl says, without turning, in a voice that is making itself heard in your head and not in the air. 'I had run out of wall.' She turns. The smile is the smile of someone who has been waiting for the inconvenience of a fifth wall. I say, very low: 'Drow High Priestess. Lolth's favour. Don't accept the offer. There will be an offer.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — The Underdark (Roper)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryUnderdark = []string{
"The cavern ahead is a forest of stalagmites — dozens of them, calf-thick to wagon-thick, irregular spacing, the kind of natural architecture that gives an ambush every advantage. I count them, then stop counting, then note that one of them has been counted twice from different angles and that one is not a stalagmite. 'Roper,' I say. 'Six tendrils. Reels you in. Pick the wrong stalagmite and the room becomes one fight at five different ranges.'",
"You enter a chamber where the ceiling drips minerals that have built columns down to the floor — pillars of pale stone, formed over thousands of years, perfectly natural, all of them. Almost all of them. I identify the off-color column near the back of the room and say: 'False appearance until it isn't. Stay out of grapple range until the tendrils show.'",
"A grotto. Calm water. A field of stalagmites breaking the surface. Beautiful in the way the deep places are beautiful, which is to say it photographs well and houses something terrible. The stalagmite nearest the path has not been there in any other map I have consulted. I note the inconsistency and recommend ranged options.",
"The corridor widens into a cavern with twelve pillars holding the ceiling — six of them are dwarven masonry, six of them are natural stone, and one of the natural ones is breathing. The breath is shallow. The patience is the giveaway. I say: 'Roper. Engage the column you can see, not the columns you assume.'",
"You step into a room where the stalagmites have been arranged — almost arranged — into something like a dining hall. The arrangement is not deliberate. The arrangement is the result of one of the stalagmites occasionally moving in its sleep. I file this under 'apex ambush predator with tenure' and recommend not waking it gently.",
"A toll-bridge cavern: a narrow path through a field of standing stones, each one twice your height, none of them moving. The toll is paid by whichever party member walks last. I track the sightlines, names the column with the wrong shadow, and say: 'Don't be the last one.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Ilvaras Xunyl
// Used as a one-line cinematic suffix to BossEntryIlvaras when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Spells: Flame Strike / Dispel Magic / Divine Word (CHA DC 18) / Insect Plague.
var IlvarasSpellLines = []string{
"She has the priest list and she leads with Flame Strike and Insect Plague — both AoE, both back-to-back if the initiative is unkind. I say: 'Spread out. Don't bunch under a column. Insect Plague is a movement tax that compounds.'",
"Divine Word, CHA DC 18, and the failure tree is brutal — Deafened, Blinded, Stunned, or killed depending on remaining HP. I file this under 'never fight a priest below half HP' and ask who has the best CHA save.",
"Dispel Magic on your buffs as soon as she rolls initiative. I track which concentration spells went up and recommend shielding the controller until the dispel is spent.",
}
// Lolth's Favour: 1/combat auto-succeed a failed save.
var IlvarasFavourLines = []string{
"Once per fight she takes a save you thought you'd won and converts the failure into a success. I say: 'Time your hard CCs after the favour is spent — don't waste a Hold Person on the round she's still got it.'",
"Lolth's Favour. One free re-pass on a save. I note the timing matters more than the damage — the spell you wanted to land is the spell she wanted to skip.",
"She converts a failed save into a success without rolling. The favour is single-use. I file this under 'plan two openings' and remind the party that the second one is the real one.",
}
// Summon Spiders (1/combat): 2d6 Giant Spiders fill the room.
var IlvarasSpidersLines = []string{
"She raises both hands and the room fills. Two-d-six Giant Spiders, all at once, all over the floor. I say: 'AoE clears the carpet. Don't get webbed before you cast it.'",
"Summon Spiders. The carpet is no longer the carpet. I track the count and note that 2d6 in a confined cathedral is a different math than 2d6 in an open hallway.",
"The spiders come from the cracks the way the rats came in Castlevania, except these are big enough to count individually and small enough to overwhelm. I recommend an early sweep and a clear line of retreat.",
}
// Legendary Actions: Melee (1), Cast Cantrip (1), Drain Life (3 LA, 4d10 necrotic).
var IlvarasLegendaryLines = []string{
"She takes legendary actions at the end of every other turn. Melee for one, cantrip for one, Drain Life for three — and Drain Life is 4d10 necrotic that heals her. I say: 'Let her spend on the cheap ones. Bait the three-cost.'",
"Three legendary points per round. The Drain Life is the one that makes the fight last longer than it should. I track her actions and remind the party that her healing comes out of yours.",
"Legendary Actions: the always-on tax that punishes your turn order. I file this under 'priest economy' and note the cantrip option is what kills your back line if you let her keep three points unspent.",
}
// Phase 2 (<35% HP): Lolth's Avatar overlay — +3 AC; spells cast at +1 slot level.
var IlvarasPhaseTwoLines = []string{
"Below thirty-five percent the avatar overlay drops on her. AC up three, every spell up one slot. I say: 'Phase shift. The damage breakpoint just moved. Don't ration your hard hits past this point.'",
"Phase two: Lolth answers the prayer. The room dims. Her shadow is wrong now — too many limbs, too little symmetry. AC bumps, slot levels bump, the whole encounter tier goes up a half-step. I track the new numbers and recommend burning every save-or-suck you've still got.",
"Avatar overlay. The priest is still there but the priest is also a delivery vehicle now. I file this under 'don't survive into phase three — there isn't one, but it'll feel like one' and ask who has the burst.",
}
// IlvarasSignatureCallouts — combined pool for boss-entry suffix.
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
var IlvarasSignatureCallouts = func() []string {
out := make([]string, 0,
len(IlvarasSpellLines)+
len(IlvarasFavourLines)+
len(IlvarasSpidersLines)+
len(IlvarasLegendaryLines))
out = append(out, IlvarasSpellLines...)
out = append(out, IlvarasFavourLines...)
out = append(out, IlvarasSpidersLines...)
out = append(out, IlvarasLegendaryLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — The Underdark
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesUnderdark = []string{
"The Underdark is not a place you reach. The Underdark is a depth you continue to. I note the distinction matters: the surface dungeons end. This one keeps going past the part you came to fight.",
"Drow cities are not built — they are negotiated. Each spire, each platform, each corridor is the result of a House agreeing to share a wall with a House it would prefer to murder. I respect the architecture more than the architects.",
"Ilvaras Xunyl's House fell two centuries ago. She is not running it. She is running what's left. The four trophies on her wall are not from the surface — three are from rival drow priestesses, in chronological order, and one is a self-portrait. I decline to elaborate on what it means that the self-portrait is hung as a trophy.",
"The mind flayers are not native here. They came down from somewhere further down. The drow tolerate them in the same way someone tolerates a roof leak they cannot reach — present, problematic, easier to live with than to fix. I file this under 'old grievances' and note the leak is getting worse.",
"The phosphorescent fungi grow in patterns that match the constellations of the surface sky from sixteen thousand years ago. I have checked the pattern. The drow have not. The drow do not know what surface stars are. The pattern is older than the drow.",
"Lolth is not the only listener down here. She is the loudest. The other listeners are quieter and more patient and I suggest not naming any of them in the cathedral, not even as a warning, not even at low volume.",
"The Roper that ate the last expedition did not attack them. They walked into it because they were following a Drow scout who knew exactly where to lead them. The scout is named, in drow records, and I decline to repeat the name out of professional courtesy to the dead.",
}

View File

@@ -0,0 +1,127 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// zone_underforge_flavor.go
// Tier 3 zone flavor — The Underforge. Additive only. Pools sampled by
// internal/plugin via deterministic per-run, per-room hashing.
//
// Voice rules (from gogobee_dungeon_zones.md §3.3):
// • Third person for description; second person for outcomes.
// • Boss callouts get a beat of cinema. Don't overrun.
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
//
// The canonical twinbee_gm_flavor.go does not (yet) ship a RoomEntry pool
// or a BossEntry pool for this zone, so both are defined here. This file
// also adds elite-room intros (Helmed Horror), boss ability callouts for
// Emberlord Thyrak, and zone-specific lore.
package flavor
// ─────────────────────────────────────────────────────────────────────────────
// ROOM ENTRY — The Underforge
// Defined here because no entry exists in twinbee_gm_flavor.go for this zone.
// ─────────────────────────────────────────────────────────────────────────────
var RoomEntryUnderforge = []string{
"The chamber opens onto a foundry the size of a city block. Conveyor channels of slow-cooling iron. Bellows the height of houses. Anvils arranged for workers who haven't been here in three centuries. I note the bellows are still moving. The bellows are moving on their own.",
"You step out onto a basalt walkway over a river of lava. The heat is impossible. The walkway is engineered to hold. I have respect for dwarven engineering and a healthy suspicion of any walkway suspended over molten rock. The two coexist comfortably.",
"A casting hall. The molds are still in place — armor pieces, weapon halves, parts of things I cannot identify by silhouette. One mold contains a shape that's still glowing red. Something was cast recently. I file this under 'occupied' and keep moving.",
"The corridor smells of hot iron and an absence — the absence of beards, of song, of the specific noise dwarven forges make when they're being tended properly. I note the silence has a shape. The shape is not reassuring.",
"You enter a chamber where the walls are arranged in tiers and each tier is a row of forges and each forge is lit. Nobody is at the forges. The hammers are striking on their own, in tempo, with the regularity of something that has not stopped striking in a very long time.",
"A vault door, dwarf-made, three feet thick, ajar. Not blown — ajar. Someone opened it from the inside. I note which side the hinges are on and consider which direction the something that opened it was facing. I do not like the answer.",
"The walkway crosses a chasm of pure heat-shimmer. You can see the far side, more or less. You cannot see what's between, except by what it does to the air. I suggest not lingering on the bridge — lingering bridges are how lava bosses get introduced.",
"A barracks. Bunks made. Boots paired and lined up. Beards on the floor where their owners stopped being. I note the beards have not been disturbed. Whatever happened, happened cleanly and at speed and I do not want to find out what was in a hurry.",
"The chamber ahead is a forge gallery — nine forges in a half-circle, all of them lit, all of them tended by armored figures that don't move when you enter. The figures are not waiting to fight. The figures are working. I note which mode you don't want to interrupt and which mode you absolutely cannot afford to interrupt.",
"Heat radiates from the floor. Not from anything on the floor — from the floor itself. I diagnose the cause as 'a forge directly underneath us, fully active, somehow still operating without dwarves' and find none of those words individually reassuring.",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ENTRY — Emberlord Thyrak
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
// ─────────────────────────────────────────────────────────────────────────────
var BossEntryEmberlordThyrak = []string{
"The chamber is the largest forge I have ever narrated, and it has narrated several. Nine furnaces in a ring. A central anvil the size of a wagon. The figure at the anvil is twelve feet of articulated iron and inset rune-stones that pulse to a tempo I can feel in its plating. The hammer comes down. The strike rings the room. The figure does not turn — finishes the strike, then turns. The eyes are not eyes. They are vents. 'Visitor,' Thyrak says, and the word is a furnace door opening. I take a half-step back and say, very evenly, 'Forge-golem. Three centuries of self-improvement. Don't fight him on the lava.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// ELITE ROOM ENTRY — The Underforge (Helmed Horror)
// ─────────────────────────────────────────────────────────────────────────────
var EliteRoomEntryUnderforge = []string{
"The armory ahead is full. Stands and stands of polished dwarven plate, weapons racked in order, the kind of inventory that takes a lifetime to build. One suit at the far end is wearing itself. It is also holding its weapon at guard. I identify the silhouette and say: 'Helmed Horror. Spell-immune to a list. Hit it with metal, not magic.'",
"A hall of dwarven banners, each lit by its own brazier. At the far end, a suit of armor stands in a place of honor. The suit pivots when you enter — the helmet first, then the torso, the way an aware thing acquires a target. I note the lack of a body inside and recommend physical damage above all else.",
"You enter a workshop that is still in service. Hammers strike. Bellows pump. None of the workers are alive and none of them notice you, except the one nearest the door, which sets down its hammer and picks up a sword. I say: 'Helmed Horror. The room is its job. We are its interruption.'",
"A small chapel, dwarf-made, dedicated to a god of the forge whose statue stands in armor in the alcove. The statue is not a statue. The statue moves first. I file this under 'ambush by liturgy' and note the immunity list does not include warhammers.",
"The corridor narrows into a guardpost. There is a guard. The guard is articulated steel from a fashion three centuries old, and it has been at this post since the day the dwarves left. It greets you in a voice that is not coming from the mouth-slit — the mouth-slit is for venting heat. I straighten up. 'Sentry duty,' I say, 'taken to its conclusion.'",
"A vault antechamber. Two suits of armor flank the inner door. They are alike in pattern, alike in posture, alike in the heat coming off them. The left one moves first by half a heartbeat. I track the asymmetry and say: 'One Helmed Horror is a fight. Two is a logistics problem. Pick a target.'",
}
// ─────────────────────────────────────────────────────────────────────────────
// BOSS ABILITY CALLOUTS — Emberlord Thyrak
// Used as a one-line cinematic suffix to BossEntryEmberlordThyrak when combat starts.
// ─────────────────────────────────────────────────────────────────────────────
// Molten Strike: +4d6 fire on hit; target armor -1 AC until repaired (long rest).
var ThyrakMoltenStrikeLines = []string{
"Every hit lands +4d6 fire and pits your armor — AC -1 until you can repair, which requires a long rest. I say: 'The damage hurts. The AC penalty compounds. Don't take more than two if you can help it.'",
"Molten Strike. The hammer doesn't just hit, it brands. Fire damage on top of physical, and your armor gets worse permanently for the encounter. I file this under 'attrition with paperwork.'",
"Each connecting blow makes the next one easier for him. Your AC goes down. The fire damage goes through. I track the count and recommend taking the hit on something you weren't going to keep.",
}
// Forge Breath: recharge 56; 30-ft cone, 10d6 fire, DEX DC 16 half.
var ThyrakForgeBreathLines = []string{
"The vents on his chest open. Thirty-foot cone, 10d6 fire, DEX DC 16 for half. I say: 'Don't bunch up. He'll get this back on a 56.'",
"Forge Breath. He inhales — visibly — and the next exhale is a furnace. Cone, big DC, big damage. I mark the recharge and shout the spread order.",
"Like the Bowser fire breath in 8-3 except wider and with a saving throw. Thirty-foot cone, half-damage on save. I note that 'half' of 10d6 is still meaningful and recommend not getting hit twice in a row.",
}
// Living Forge: heals 15 HP/turn while standing on lava/forge tiles.
var ThyrakLivingForgeLines = []string{
"He heals fifteen a turn while standing on lava or forge tiles. I say: 'Move him off, or fight a boss who repairs faster than you can dent.'",
"Living Forge. The lava floor is part of his kit. Pull him off it — shove, push, anything — and the regen stops. I file this under 'positioning is damage.'",
"He's standing on a heat source and the heat source is fueling him. Fifteen HP a round, indefinitely. I track the regen and remind the party that knocking him into the open hallway is worth more than a good attack roll.",
}
// Construct Resilience: immune poison/psychic/charm/exhaustion; resist non-magical physical.
var ThyrakConstructLines = []string{
"Immune to poison, psychic, charm, exhaustion. Resists non-magical physical. I say: 'Magic weapons earn their keep here. Mundane steel does half.'",
"He's a construct first and a boss second. The whole control-effect column on your sheet is greyed out. I file this under 'fights you win by hitting harder, not smarter — the smart options have been removed.'",
"Construct Resilience: the standard package. Magic damage gets through clean. Mundane physical gets cut in half. I suggest checking which weapons are actually magical before assuming.",
}
// Phase 2 (<50% HP): Forge Breath recharge 46; spawns 2 Fire Elementals.
var ThyrakPhaseTwoLines = []string{
"At half HP the vents widen. Forge Breath recharges on 46 now and two Fire Elementals come up out of the floor. I say: 'Phase shift. The cone is twice as common and you have new targets. Pick a priority.'",
"Half-health. The forge wakes the rest of the way up. Two Fire Elementals burst from the lava channels. Breath weapon is half a turn away on every roll now. I track the new add count and remind you that Thyrak is still the win condition.",
"Phase two: the elementals come up the way bosses summon adds in every arcade beat-em-up — predictable, relentless, the kind of design that turns a fight into a war of attrition. I file the timing and ask who has fire resistance.",
}
// ThyrakSignatureCallouts — combined pool for boss-entry suffix.
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
var ThyrakSignatureCallouts = func() []string {
out := make([]string, 0,
len(ThyrakMoltenStrikeLines)+
len(ThyrakForgeBreathLines)+
len(ThyrakLivingForgeLines)+
len(ThyrakConstructLines))
out = append(out, ThyrakMoltenStrikeLines...)
out = append(out, ThyrakForgeBreathLines...)
out = append(out, ThyrakLivingForgeLines...)
out = append(out, ThyrakConstructLines...)
return out
}()
// ─────────────────────────────────────────────────────────────────────────────
// LORE — The Underforge
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
// ─────────────────────────────────────────────────────────────────────────────
var LoreLinesUnderforge = []string{
"Kharak Dûn was a forge-city, not a mine. The dwarves here didn't extract — they finished. Raw stock came in from a hundred lesser holds and left as the kind of work the rest of the world named after the holds. I note the distinction matters: this place was a destination, not a quarry.",
"The dwarves left. They didn't die in place — they left. The boots in the barracks are paired because they were left paired, intentionally, on the last day. Somewhere, a clan-elder gave the order to walk out. I respect the discipline and am curious about the reason. The reason is not in any record I have read.",
"Thyrak was not built to run the city. Thyrak was built to maintain the furnaces while the city ran itself. Three centuries of self-maintenance turned out to include self-improvement, and Thyrak has been improving the designs. I find this less terrifying than it sounds and more terrifying than it looks.",
"The seal on the outer doors was set from the outside. The dwarves walked out, then closed the door behind them, then sealed it with the kind of binding that takes a clan to set. I note the bindings were aimed inward. They were sealing something in. The thing inside is the forge or the forge-golem or both — I decline to commit.",
"The azers in the deeper chambers are not hostile by default. They are loyal to the forge. They will work with anyone the forge accepts, and they will burn alive anyone the forge rejects. I suggest not picking up the hammer at the central anvil unless you mean it.",
"The flameskulls drifting in the upper galleries were dwarven mages, once. They volunteered. Their final task was to maintain the wards around Thyrak's casting chamber, and they have been at it ever since. I note the wards are still active and that this is, on balance, probably good news.",
"Thyrak's core — the crystalline heart that drops on his death — is what made the self-improvement possible. The dwarves didn't install it. It was already there when they built the chassis. I find this implication concerning and suggest the core is not, technically, dwarven work.",
"There is a second forge below this one. The 'bass note of something very large moving below' is not metaphor. The Underforge is the upper level. I will not speculate about the lower level except to note that it has not been opened in this run and would not be a small undertaking to open.",
}

View File

@@ -0,0 +1,443 @@
// Package peteclient is gogobee's outbound seam to the Pete news bot.
//
// gogobee is the source of game-event *facts* and owns delivery; Pete owns
// voice, authoring, and publishing. This package carries structured facts (not
// prose) to Pete's ingest endpoint over the tailnet, bearer-authed.
//
// Delivery is durable: Emit writes the fact to a SQLite queue and returns
// immediately, so a game-loop hook never blocks on the network and a Pete
// restart loses nothing. A background sender drains the queue with retry.
// Idempotency is on the fact GUID, so retries and duplicate emits are no-ops.
package peteclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"gogobee/internal/db"
)
// Fact is the flat, pre-sanitized payload gogobee POSTs to Pete. Names must be
// character names only (never Matrix handles); Actors is the allow-list of the
// only names permitted to appear in Pete's rendered output. See
// pete_adventure_news_voice.md for the field contract.
type Fact struct {
GUID string `json:"guid"` // stable idempotency key, e.g. "death:<token>:<ts>"; prefix == event_type
EventType string `json:"event_type"`
Tier string `json:"tier"` // "priority" | "bulletin"
Actors []string `json:"actors"`
Subject string `json:"subject,omitempty"`
Opponent string `json:"opponent,omitempty"`
Boss string `json:"boss,omitempty"`
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Level int `json:"level,omitempty"`
Count int `json:"count,omitempty"`
Outcome string `json:"outcome,omitempty"`
Stakes string `json:"stakes,omitempty"`
ClassRace string `json:"class_race,omitempty"`
Milestone string `json:"milestone,omitempty"`
OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
}
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
// queued), matching the FEATURE_PETE_NEWS master switch that kills emission at
// the source.
type Config struct {
IngestURL string
Token string
Enabled bool
}
// Client is the transport half. It is a package singleton initialized by Init,
// so emit hooks scattered across plugins (and free functions like
// markAdventureDead) can call Emit without threading a handle through.
type Client struct {
cfg Config
http *http.Client
draining sync.Mutex // one drain at a time; see drain
}
var std *Client
// factPath is where an adventure fact goes. Every queue row carries its own
// destination now, because escrow verdicts ride the same queue to a different
// endpoint.
const factPath = "/api/ingest/adventure"
// Tuning for the background sender.
const (
senderTick = 15 * time.Second
senderBatch = 20
maxAttempts = 8 // ~ up to a few hours of backoff, then park
backoffBase = 30 * time.Second
backoffCapSec = 3600
sendTimeout = 15 * time.Second
)
// Init wires the singleton from the environment. Mirrors the per-plugin config
// pattern (email_nag.go): PETE_INGEST_URL, PETE_INGEST_TOKEN, FEATURE_PETE_NEWS.
func Init() {
cfg := Config{
IngestURL: strings.TrimRight(os.Getenv("PETE_INGEST_URL"), "/"),
Token: os.Getenv("PETE_INGEST_TOKEN"),
Enabled: strings.EqualFold(os.Getenv("FEATURE_PETE_NEWS"), "true"),
}
if cfg.Enabled && (cfg.IngestURL == "" || cfg.Token == "") {
slog.Warn("peteclient: FEATURE_PETE_NEWS=true but PETE_INGEST_URL/PETE_INGEST_TOKEN unset — disabling")
cfg.Enabled = false
}
std = &Client{cfg: cfg, http: &http.Client{Timeout: sendTimeout}}
if cfg.Enabled {
slog.Info("peteclient: adventure news emission enabled", "ingest", cfg.IngestURL)
} else {
slog.Info("peteclient: adventure news emission disabled (set FEATURE_PETE_NEWS=true)")
}
}
// Enabled reports whether emission is on. Callers can skip building an
// (expensive) fact when it would be dropped anyway.
func Enabled() bool { return std != nil && std.cfg.Enabled }
// Emit durably queues a fact for delivery to Pete. It never blocks on the
// network. A no-op (but safe) when the seam is disabled or the GUID was already
// queued — idempotency is on the GUID primary key.
func Emit(f Fact) {
if !Enabled() {
return
}
if f.GUID == "" {
slog.Error("peteclient: refusing to queue fact with empty guid", "event_type", f.EventType)
return
}
payload, err := json.Marshal(f)
if err != nil {
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
return
}
enqueue(f.GUID, factPath, payload)
}
// enqueue puts one payload on the durable queue, addressed to a Pete endpoint.
//
// OR IGNORE gives GUID-idempotency: a re-emit of the same key is dropped. That
// is the whole safety story for money — an escrow verdict is queued under its
// escrow guid, so a verdict can never be enqueued twice and can never be
// delivered as two different answers.
func enqueue(guid, path string, payload []byte) {
db.Exec("pete emit enqueue",
`INSERT OR IGNORE INTO pete_emit_queue (guid, path, payload, created_at, attempts, next_attempt_at)
VALUES (?, ?, ?, unixepoch(), 0, 0)`,
guid, path, string(payload))
}
// StartSender launches the background drain loop. It runs until ctx is
// canceled. Safe to call when disabled — it simply idles.
func StartSender(ctx context.Context) {
if std == nil {
return
}
go func() {
t := time.NewTicker(senderTick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if std.cfg.Enabled {
std.drain(ctx)
}
}
}
}()
}
// Flush drains the queue right now instead of waiting for the next tick.
//
// The escrow loop needs this. A player who clicked "buy chips" is watching a
// spinner, and a verdict that sat in the queue for a 15-second sender tick would
// make the whole border feel broken even though nothing is. Durability is not
// weakened: the row is written first and only then sent, exactly as the ticker
// does it.
func Flush(ctx context.Context) {
if std == nil || !std.cfg.Enabled {
return
}
std.drain(ctx)
}
// drain sends up to senderBatch due rows, one at a time.
//
// Serialized: the ticker and Flush can both call this, and two drains racing
// would send the same row twice. Every Pete endpoint we push to is idempotent,
// so that would be survivable rather than harmful — but it would also mean an
// escrow verdict arriving twice as a matter of routine, and "harmless in theory"
// is not how the money path should be run.
func (c *Client) drain(ctx context.Context) {
c.draining.Lock()
defer c.draining.Unlock()
rows, err := db.Get().Query(
`SELECT guid, path, payload FROM pete_emit_queue
WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch()
ORDER BY created_at LIMIT ?`,
maxAttempts, senderBatch)
if err != nil {
slog.Error("peteclient: drain query", "err", err)
return
}
type item struct{ guid, path, payload string }
var batch []item
for rows.Next() {
var it item
if err := rows.Scan(&it.guid, &it.path, &it.payload); err != nil {
slog.Error("peteclient: drain scan", "err", err)
continue
}
batch = append(batch, it)
}
rows.Close()
for _, it := range batch {
if ctx.Err() != nil {
return
}
if err := c.post(ctx, it.path, []byte(it.payload)); err != nil {
if ctx.Err() != nil {
// Shutdown canceled the in-flight send — Pete didn't reject
// anything. Don't burn a durable retry attempt; the row is picked
// up on the next boot's drain.
return
}
db.Exec("pete emit retry",
`UPDATE pete_emit_queue
SET attempts = attempts + 1, next_attempt_at = unixepoch() + ?
WHERE guid = ?`,
backoffSec(it.guid), it.guid)
slog.Warn("peteclient: emit failed, will retry", "guid", it.guid, "err", err)
continue
}
db.Exec("pete emit sent",
`UPDATE pete_emit_queue SET sent_at = unixepoch() WHERE guid = ?`, it.guid)
}
}
// RosterEntry is one adventurer's currently-true state for Pete's live board.
// Unlike a Fact, nothing here is an event — it is what is true right now.
type RosterEntry struct {
Token string `json:"token"` // stable per-player board token, never a Matrix handle
Name string `json:"name"` // character name only
Level int `json:"level"`
ClassRace string `json:"class_race,omitempty"`
Status string `json:"status"` // "expedition" | "idle"
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Day int `json:"day,omitempty"`
IdleHours int `json:"idle_hours,omitempty"`
}
// RosterSnapshot is the complete board. Complete is load-bearing: Pete replaces
// its whole board with this, so anyone omitted (opted out, no character) drops
// off the public page. A partial snapshot would silently strand people on it.
type RosterSnapshot struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []RosterEntry `json:"adventurers"`
}
// PushRoster sends the board to Pete, synchronously, and drops it on failure.
//
// Deliberately NOT on the durable queue that carries Facts. A fact is history —
// losing "Josie died" loses it forever, so it retries. A snapshot is a
// photograph of the present, and a retried one is a *lie*: by the time it lands,
// Josie has moved. The next tick carries the truth anyway, so a failed push is
// simply forgotten. That is also what lets Pete's staleness timer work — if we
// stay down, nothing arrives, and the board correctly stops claiming to be live.
func PushRoster(ctx context.Context, snap RosterSnapshot) error {
if !Enabled() {
return nil
}
payload, err := json.Marshal(snap)
if err != nil {
return err
}
return std.post(ctx, "/api/ingest/roster", payload)
}
// post sends one payload to a Pete endpoint with bearer auth. Mirrors the
// bearer-POST pattern in email_nag.go:sendCode.
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
url := c.cfg.IngestURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("pete ingest status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
// ---------------------------------------------------------------------------
// The euro/chip border
//
// Pete holds chips; we hold the euros. A player buying in or cashing out opens
// an escrow row on Pete, and we are the only one who can move the money for it —
// Pete has no route into this box's network and is not getting one. So we poll.
//
// This is the first GET gogobee has ever made to Pete. Everything else in this
// package is us pushing facts outward; here we are asking for work.
//
// The escrow guid is the idempotency key end to end: it names the row on Pete,
// it is the external_id on our euro transaction, and it is the queue key of the
// verdict we push back. That is what makes every step here safe to retry, which
// matters because every step here can be interrupted between moving real money
// and saying so.
// ---------------------------------------------------------------------------
// Escrow is one pending crossing, as Pete describes it. Amounts are whole euros:
// chips are 1:1 and there is no sub-unit to lose.
type Escrow struct {
GUID string `json:"guid"`
MatrixUser string `json:"matrix_user"`
Kind string `json:"kind"` // "buyin" | "cashout"
Amount int64 `json:"amount"`
State string `json:"state"`
}
// EscrowVerdict is our answer: did the euros move, and what is the balance now.
// A rejected buy-in carries the reason, which Pete shows the player.
type EscrowVerdict struct {
GUID string `json:"guid"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
BalanceAfter float64 `json:"balance_after"`
}
const escrowVerdictPath = "/api/games/escrow/settled"
// PendingEscrow asks Pete for crossings waiting on us. Includes rows we claimed
// but never answered — if we died holding one, the player's money is stranded
// until we pick it up again.
func PendingEscrow(ctx context.Context) ([]Escrow, error) {
if !Enabled() {
return nil, nil
}
var out []Escrow
if err := std.getJSON(ctx, "/api/games/escrow/pending", &out); err != nil {
return nil, err
}
return out, nil
}
// ClaimEscrow tells Pete we are taking a row, and returns the row as Pete now
// holds it. Move the money against *this*, not against the copy from the poll:
// the claim is the moment the amount and the player are fixed.
//
// A row Pete has already decided comes back in a terminal state rather than
// "claimed". That is not an error — it means the work is done, and it is exactly
// what stops a settled cash-out from being paid a second time.
func ClaimEscrow(ctx context.Context, guid string) (Escrow, error) {
var e Escrow
payload, err := json.Marshal(map[string]string{"guid": guid})
if err != nil {
return e, err
}
if err := std.postJSON(ctx, "/api/games/escrow/claim", payload, &e); err != nil {
return e, err
}
return e, nil
}
// EmitEscrowVerdict durably queues our answer and returns immediately. Keyed on
// the escrow guid, so a verdict is enqueued once and only once, and the sender's
// retry/backoff/parking machinery carries it the rest of the way.
//
// The caller should Flush after this: a player is watching a spinner.
func EmitEscrowVerdict(v EscrowVerdict) {
if !Enabled() {
return
}
payload, err := json.Marshal(v)
if err != nil {
slog.Error("peteclient: marshal escrow verdict", "guid", v.GUID, "err", err)
return
}
// Namespaced so an escrow guid can never collide with a fact guid in the
// queue's primary key. Fact guids are "<event_type>:<token>:<ts>"; escrow
// guids are random. A collision would be a lost verdict, so don't rely on
// luck for it.
enqueue("escrow:"+v.GUID, escrowVerdictPath, payload)
}
// getJSON does a bearer-authed GET and decodes the body.
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
return c.do(req, out)
}
// postJSON does a bearer-authed POST and decodes the body. Distinct from post,
// which is the fire-and-forget path the queue uses and ignores the response.
func (c *Client) postJSON(ctx context.Context, path string, payload []byte, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.IngestURL+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
req.Header.Set("Content-Type", "application/json")
return c.do(req, out)
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("pete %s status %d: %s", req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("pete %s: decode: %w", req.URL.Path, err)
}
return nil
}
// backoffSec computes the retry delay for a row. It re-reads the current attempt
// count so the delay grows geometrically without needing it passed in.
func backoffSec(guid string) int {
var attempts int
_ = db.Get().QueryRow(`SELECT attempts FROM pete_emit_queue WHERE guid = ?`, guid).Scan(&attempts)
// attempts is the count *before* this failure's increment; delay off it.
delay := int(backoffBase.Seconds()) << attempts
if delay > backoffCapSec {
delay = backoffCapSec
}
return delay
}

View File

@@ -0,0 +1,91 @@
package peteclient
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"gogobee/internal/db"
)
// TestEmitDrainRoundTrip proves the durable path: Emit queues a fact, the sender
// POSTs it to Pete with bearer auth, and the row is marked sent (so it won't
// re-send), while a duplicate GUID collapses to one delivery.
func TestEmitDrainRoundTrip(t *testing.T) {
if err := db.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var got []Fact
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/ingest/adventure" {
t.Errorf("unexpected path %q", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
var f Fact
_ = json.Unmarshal(body, &f)
mu.Lock()
got = append(got, f)
gotAuth = r.Header.Get("Authorization")
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
std = &Client{cfg: Config{IngestURL: srv.URL, Token: "tok", Enabled: true}, http: srv.Client()}
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1})
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) // dup guid
std.drain(context.Background())
mu.Lock()
defer mu.Unlock()
if len(got) != 1 {
t.Fatalf("delivered %d facts, want 1 (dup should collapse)", len(got))
}
if got[0].Subject != "Brannigan" {
t.Errorf("subject = %q", got[0].Subject)
}
if gotAuth != "Bearer tok" {
t.Errorf("auth header = %q", gotAuth)
}
var pending int
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE sent_at IS NULL`).Scan(&pending); err != nil {
t.Fatal(err)
}
if pending != 0 {
t.Errorf("%d rows still pending after successful drain", pending)
}
// A second drain re-sends nothing.
std.drain(context.Background())
if len(got) != 1 {
t.Errorf("re-drained a sent row: %d deliveries", len(got))
}
}
// TestEmitDisabledNoQueue: when disabled, Emit is a durable no-op.
func TestEmitDisabledNoQueue(t *testing.T) {
if err := db.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
std = &Client{cfg: Config{Enabled: false}, http: http.DefaultClient}
Emit(Fact{GUID: "disabled-guid", EventType: "death", OccurredAt: 1})
var n int
// db.Init is a process singleton, so this may share state with other tests;
// scope the check to this fact's guid.
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE guid = 'disabled-guid'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("disabled Emit queued %d rows, want 0", n)
}
}

View File

@@ -2,12 +2,14 @@ package plugin
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
@@ -100,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
}
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
_, err := d.Exec(
res, err := d.Exec(
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
string(userID), achievementID,
)
@@ -108,7 +110,43 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
return
}
// Only a genuinely new grant is news; a re-check conflict (rows==0) is not.
if n, _ := res.RowsAffected(); n == 0 {
return
}
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
p.emitMilestoneNews(d, userID, achievementID)
}
// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements
// only — rarity-gated to a single holder (per the news selection thresholds) so
// routine unlocks don't flood the feed. Character name only; no-op unless the
// seam is enabled.
func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) {
var holders int
if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 {
return
}
name := charName(userID)
if name == "" {
return
}
label := achievementID
for _, a := range p.achievements {
if a.ID == achievementID {
label = a.Name
break
}
}
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(userID, achievementID), achievementID),
EventType: "milestone",
Tier: "bulletin",
Subject: name,
Milestone: label,
OccurredAt: ts,
}, userID, "")
}
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
@@ -556,7 +594,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
},
},
{
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "She is furious and will not forget this.",
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "They are furious and will not forget this.",
Emoji: "🐝",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by blackjack plugin when beating the bot
@@ -783,7 +821,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Check: func(d *sql.DB, u id.UserID) bool {
var streak int
err := d.QueryRow(
`SELECT best_streak FROM adventure_characters WHERE user_id = ?`,
`SELECT best_streak FROM player_meta WHERE user_id = ?`,
string(u),
).Scan(&streak)
return err == nil && streak >= 7
@@ -795,7 +833,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Check: func(d *sql.DB, u id.UserID) bool {
var streak int
err := d.QueryRow(
`SELECT best_streak FROM adventure_characters WHERE user_id = ?`,
`SELECT best_streak FROM player_meta WHERE user_id = ?`,
string(u),
).Scan(&streak)
return err == nil && streak >= 30
@@ -1023,7 +1061,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Emoji: "🏃",
Check: func(d *sql.DB, u id.UserID) bool {
var streak int
_ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak)
_ = d.QueryRow(`SELECT best_streak FROM player_meta WHERE user_id = ?`, string(u)).Scan(&streak)
return streak >= 7
},
},
@@ -1032,7 +1070,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Emoji: "🔥",
Check: func(d *sql.DB, u id.UserID) bool {
var streak int
_ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak)
_ = d.QueryRow(`SELECT best_streak FROM player_meta WHERE user_id = ?`, string(u)).Scan(&streak)
return streak >= 60
},
},
@@ -1041,7 +1079,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Emoji: "💯",
Check: func(d *sql.DB, u id.UserID) bool {
var streak int
_ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak)
_ = d.QueryRow(`SELECT best_streak FROM player_meta WHERE user_id = ?`, string(u)).Scan(&streak)
return streak >= 100
},
},
@@ -1067,7 +1105,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Emoji: "🔄",
Check: func(d *sql.DB, u id.UserID) bool {
var current, best, decayed int
err := d.QueryRow(`SELECT current_streak, best_streak, streak_decayed FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&current, &best, &decayed)
err := d.QueryRow(`SELECT current_streak, best_streak, streak_decayed FROM player_meta WHERE user_id = ?`, string(u)).Scan(&current, &best, &decayed)
return err == nil && current >= 14 && decayed > 0
},
},
@@ -1137,9 +1175,190 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return false
},
},
// ── Expeditions ──
// All passive: they read the expedition rows the extract path already
// writes. Tier comes from the zone registry, not a column, so the
// checks map zone_id → Tier in Go.
{
ID: "expedition_clear_t1", Name: "Off the Porch", Description: "Cleared your first Tier 1 zone. It only gets worse from here.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 1) },
},
{
ID: "expedition_clear_t2", Name: "Reasonably Lost", Description: "Cleared a Tier 2 zone. The map stops being a suggestion.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 2) },
},
{
ID: "expedition_clear_t3", Name: "Deep Enough", Description: "Cleared a Tier 3 zone. Someone should have stopped you.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 3) },
},
{
ID: "expedition_clear_t4", Name: "Past the Warnings", Description: "Cleared a Tier 4 zone. The signs were quite clear.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 4) },
},
{
ID: "expedition_clear_t5", Name: "Where the Map Ends", Description: "Cleared a Tier 5 zone. There was nothing left to be brave about.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 5) },
},
{
ID: "expedition_master_t1", Name: "Warren Cartography", Description: "Cleared every Tier 1 zone. Thorough.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 1) },
},
{
ID: "expedition_master_t2", Name: "No Stone Unturned", Description: "Cleared every Tier 2 zone.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 2) },
},
{
ID: "expedition_master_t3", Name: "Completionist's Limp", Description: "Cleared every Tier 3 zone. Walk it off.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 3) },
},
{
ID: "expedition_master_t4", Name: "Nowhere Left to Go Wrong", Description: "Cleared every Tier 4 zone.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 4) },
},
{
ID: "expedition_master_t5", Name: "The Long Way Down", Description: "Cleared every Tier 5 zone. Both of them. All the way.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 5) },
},
{
ID: "expedition_quiet_clear", Name: "Nobody Saw Anything", Description: "Cleared a zone without threat ever passing 50. You were never here.",
Emoji: "🌑",
Check: clearedUnderThreat50,
},
{
ID: "temper_legendary", Name: "Hot Enough", Description: "Tempered an item all the way to Legendary. The blacksmith needed a moment.",
Emoji: "🔨",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by the temper path on reaching Legendary
return false
},
},
// N7/B4 — the Renown wing (gogobee_engagement_plan.md §B4). Passive checks
// against the derived Renown level (N7/B2); no event hook needed.
{
ID: "renown_1", Name: "Beyond the Cap", Description: "Earned your first level of Renown. The story was supposed to be over.",
Emoji: "✦",
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 1) },
},
{
ID: "renown_5", Name: "Storied", Description: "Reached Renown 5. They tell versions of your runs that never happened.",
Emoji: "✦",
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 5) },
},
{
ID: "renown_10", Name: "Fabled", Description: "Reached Renown 10. Somewhere a bard is getting rich off your name.",
Emoji: "✦",
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 10) },
},
}
}
// renownAtLeast reports whether the player's derived Renown level (N7/B2) has
// reached level, reading player_meta.renown_xp through the passed handle.
func renownAtLeast(d *sql.DB, userID id.UserID, level int) bool {
var xp int
if err := d.QueryRow(
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&xp); err != nil {
return false
}
return renownLevelFor(xp) >= level
}
// ── Expedition achievement helpers ──────────────────────────────────────────
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
// defeated gates out extractions that merely ended in 'complete'.
func clearedZoneIDs(d *sql.DB, userID id.UserID) map[ZoneID]bool {
out := map[ZoneID]bool{}
rows, err := d.Query(
`SELECT DISTINCT zone_id FROM dnd_expedition
WHERE user_id = ? AND status = ? AND boss_defeated = 1`,
string(userID), ExpeditionStatusComplete)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var z string
if err := rows.Scan(&z); err != nil {
return out
}
out[ZoneID(z)] = true
}
return out
}
func clearedAnyZoneOfTier(d *sql.DB, userID id.UserID, tier ZoneTier) bool {
cleared := clearedZoneIDs(d, userID)
for zid := range cleared {
if z, ok := getZone(zid); ok && z.Tier == tier {
return true
}
}
return false
}
func clearedEveryZoneOfTier(d *sql.DB, userID id.UserID, tier ZoneTier) bool {
cleared := clearedZoneIDs(d, userID)
seen := 0
for _, z := range allZones() {
if z.Tier != tier {
continue
}
if !cleared[z.ID] {
return false
}
seen++
}
return seen > 0
}
// clearedUnderThreat50 looks for a cleared expedition whose peak threat never
// crossed 50 — the same max_threat_seen sample the Patient Zero milestone
// reads, so the two reward the same play.
//
// The key's *presence* is required, not just a low value. recordMaxThreat only
// samples on day rollover and never stores a zero, so an absent key means the
// expedition has no threat history at all (a same-day clear) — which is not
// evidence that threat stayed low.
func clearedUnderThreat50(d *sql.DB, userID id.UserID) bool {
rows, err := d.Query(
`SELECT region_state FROM dnd_expedition
WHERE user_id = ? AND status = ? AND boss_defeated = 1`,
string(userID), ExpeditionStatusComplete)
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return false
}
var state map[string]any
if json.Unmarshal([]byte(raw), &state) != nil {
continue
}
peak, ok := state[regionStateMaxThreatKey].(float64)
if ok && int(peak) < 50 {
return true
}
}
return false
}
// statGTE checks if a user_stats column is >= threshold.
var allowedStatColumns = map[string]bool{
"total_messages": true,

View File

@@ -0,0 +1,184 @@
package plugin
import (
"fmt"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// expeditionRowSeq gives each synthetic row a unique primary key; two rows can
// otherwise differ only in boss_defeated.
var expeditionRowSeq int
// insertClearedExpedition writes an expedition row directly. status and
// boss_defeated are the two gates the achievement checks read.
func insertClearedExpedition(t *testing.T, user id.UserID, zoneID ZoneID, status string, bossDefeated int, regionState string) {
t.Helper()
expeditionRowSeq++
_, err := db.Get().Exec(
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, boss_defeated, region_state)
VALUES (?, ?, ?, ?, ?, ?)`,
fmt.Sprintf("exp-%d", expeditionRowSeq), string(user), string(zoneID), status, bossDefeated, regionState)
if err != nil {
t.Fatal(err)
}
}
// zonesOfTier lists the zone ids at a tier, straight from the registry, so the
// test tracks content edits instead of hardcoding a zone roster.
func zonesOfTier(tier ZoneTier) []ZoneID {
var out []ZoneID
for _, z := range allZones() {
if z.Tier == tier {
out = append(out, z.ID)
}
}
return out
}
func newAchievementTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
func TestExpeditionClearAchievements(t *testing.T) {
newAchievementTestDB(t)
d := db.Get()
user := id.UserID("@clears:test.invalid")
t1 := zonesOfTier(1)
if len(t1) < 2 {
t.Skipf("tier 1 has %d zones; test wants at least 2", len(t1))
}
if clearedAnyZoneOfTier(d, user, 1) {
t.Error("clean slate reported a tier-1 clear")
}
// A boss-defeated complete run is the only thing that counts.
insertClearedExpedition(t, user, t1[0], ExpeditionStatusComplete, 1, "{}")
if !clearedAnyZoneOfTier(d, user, 1) {
t.Error("cleared tier-1 zone not detected")
}
if clearedEveryZoneOfTier(d, user, 1) {
t.Error("one of several tier-1 zones counted as all of them")
}
if clearedAnyZoneOfTier(d, user, 2) {
t.Error("a tier-1 clear leaked into tier 2")
}
for _, z := range t1[1:] {
insertClearedExpedition(t, user, z, ExpeditionStatusComplete, 1, "{}")
}
if !clearedEveryZoneOfTier(d, user, 1) {
t.Error("clearing every tier-1 zone did not satisfy the master check")
}
}
// TestExpeditionClearIgnoresUnfinishedRuns: an abandoned run, or one where the
// player walked out without killing the boss, must not count as a clear.
func TestExpeditionClearIgnoresUnfinishedRuns(t *testing.T) {
newAchievementTestDB(t)
d := db.Get()
user := id.UserID("@partial:test.invalid")
t2 := zonesOfTier(2)
if len(t2) == 0 {
t.Skip("no tier-2 zones")
}
insertClearedExpedition(t, user, t2[0], ExpeditionStatusAbandoned, 1, "{}")
if clearedAnyZoneOfTier(d, user, 2) {
t.Error("an abandoned expedition counted as a clear")
}
insertClearedExpedition(t, user, t2[0], ExpeditionStatusComplete, 0, "{}")
if clearedAnyZoneOfTier(d, user, 2) {
t.Error("a complete run with the boss alive counted as a clear")
}
insertClearedExpedition(t, user, t2[0], ExpeditionStatusComplete, 1, "{}")
if !clearedAnyZoneOfTier(d, user, 2) {
t.Error("a real clear was not detected")
}
}
// TestClearedUnderThreat50 pins the presence-vs-zero distinction. recordMaxThreat
// samples only on day rollover and never writes a zero, so an empty region_state
// means "no threat history", not "threat stayed at zero".
func TestClearedUnderThreat50(t *testing.T) {
newAchievementTestDB(t)
d := db.Get()
zones := zonesOfTier(1)
if len(zones) == 0 {
t.Skip("no tier-1 zones")
}
z := zones[0]
quiet := id.UserID("@quiet:test.invalid")
loud := id.UserID("@loud:test.invalid")
sameDay := id.UserID("@sameday:test.invalid")
insertClearedExpedition(t, quiet, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":30}`)
if !clearedUnderThreat50(d, quiet) {
t.Error("peak threat 30 did not satisfy the under-50 check")
}
insertClearedExpedition(t, loud, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":60}`)
if clearedUnderThreat50(d, loud) {
t.Error("peak threat 60 satisfied the under-50 check")
}
insertClearedExpedition(t, sameDay, z, ExpeditionStatusComplete, 1, "{}")
if clearedUnderThreat50(d, sameDay) {
t.Error("an expedition with no threat samples was awarded the quiet clear")
}
// Exactly 50 is not under 50.
edge := id.UserID("@edge:test.invalid")
insertClearedExpedition(t, edge, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":50}`)
if clearedUnderThreat50(d, edge) {
t.Error("peak threat of exactly 50 counted as under 50")
}
}
// TestExpeditionAchievementIDsRegistered guards the wiring: every expedition
// achievement referenced by the tier helpers must exist in the registry, and a
// tier with no zones must not ship a master achievement nobody can earn.
func TestExpeditionAchievementIDsRegistered(t *testing.T) {
p := &AchievementsPlugin{}
defs := p.buildAchievements()
ids := map[string]bool{}
for _, a := range defs {
if ids[a.ID] {
t.Errorf("duplicate achievement id %q", a.ID)
}
ids[a.ID] = true
}
for tier := ZoneTier(1); tier <= 5; tier++ {
if len(zonesOfTier(tier)) == 0 {
continue
}
for _, prefix := range []string{"expedition_clear_t", "expedition_master_t"} {
id := prefix + string(rune('0'+tier))
if !ids[id] {
t.Errorf("tier %d has zones but %q is not registered", tier, id)
}
}
}
for _, id := range []string{"expedition_quiet_clear", "temper_legendary"} {
if !ids[id] {
t.Errorf("%q is not registered", id)
}
}
}

View File

@@ -0,0 +1,351 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Adv 2.0 scenario playthrough: drives a realistic player session against
// a copy of the prod DB, verifying happy-path behavior end-to-end across
// the !zone state machine, the !expedition multi-day system, and the
// !harvest economy. SendDM is a no-op without a Matrix client, so the
// asserts target persisted state. Logs are verbose (t.Logf) so the run
// reads like a transcript.
// ── Scenario A — single-session zone run ────────────────────────────────────
func TestAdv2Scenario_ZoneRunGoblinWarrens(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@adv2-scenA:example")
t.Cleanup(func() { cleanupZoneRuns(uid); cleanupExpeditions(uid) })
// Beefy L5 fighter so combat doesn't trivially TPK (still RNG, but 5
// is well past goblin warrens' L1L3 band).
if err := createAdvCharacter(uid, "scenA"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
HPMax: 60, HPCurrent: 60, ArmorClass: 18,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
euro := &EuroPlugin{}
euro.ensureBalance(uid)
euro.Credit(uid, 500, "scenA bankroll")
p := &AdventurePlugin{euro: euro}
// !zone (list) — should not error, no run created.
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, ""); err != nil {
t.Fatalf("zone list: %v", err)
}
if got, _ := getActiveZoneRun(uid); got != nil {
t.Fatal("zone list should not create a run")
}
// !zone enter goblin_warrens
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
t.Fatalf("zone enter: %v", err)
}
run, err := getActiveZoneRun(uid)
if err != nil || run == nil {
t.Fatalf("expected active run after enter: %v", err)
}
t.Logf("entered %s — %d rooms, mood=%d, first room=%s",
run.ZoneID, run.TotalRooms, run.DMMood, run.CurrentRoomType())
if run.DMMood != 50 {
t.Errorf("starting mood = %d, want 50", run.DMMood)
}
if run.CurrentRoom != 0 || run.CurrentRoomType() != RoomEntry {
t.Errorf("first room not entry: idx=%d type=%s", run.CurrentRoom, run.CurrentRoomType())
}
// !zone status (cheap smoke)
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "status"); err != nil {
t.Fatalf("zone status: %v", err)
}
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "map"); err != nil {
t.Fatalf("zone map: %v", err)
}
// Drive !zone advance until the run terminates (cleared, died, or
// abandoned). Branches the test handles:
// - Fork queued (Phase G branching graphs): commit with `!zone go 1`.
// - Elite/Boss doorway (commit 886eb5a moved these off auto-resolve):
// !advance now stops at the door and the player engages with
// !fight, then resolves one round per !attack. The test mirrors
// that: call !fight to open the session, !attack until the session
// closes, then the next !advance clears the room and walks the
// graph. Cap the per-fight !attack loop generously — a real fight
// resolves in <20 rounds, but RNG can drag.
maxSteps := (run.TotalRooms + 4) * 4
const maxAttacksPerFight = 60
clearedRoomTypes := []RoomType{}
for step := 0; step < maxSteps; step++ {
before, _ := getActiveZoneRun(uid)
if before == nil {
t.Logf("step %d: run terminated (cleared/died/abandoned)", step)
break
}
prevType := before.CurrentRoomType()
prevHP := c.HPCurrent
if cur, _ := LoadDnDCharacter(uid); cur != nil {
prevHP = cur.HPCurrent
}
t.Logf("step %d: in room %d/%d (%s), mood=%d, HP=%d",
step, before.CurrentRoom+1, before.TotalRooms, prevType, before.DMMood, prevHP)
if len(before.NodeChoices) > 0 {
// A fork is queued from the previous advance — commit it first.
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "go 1"); err != nil {
t.Fatalf("zone go step %d: %v", step, err)
}
continue
}
// Elite/Boss doorway: !advance won't progress past the door
// without a won CombatSession for the encounter. Open the fight
// if it isn't already open, then attack until the session
// terminates. After the loop, fall through to !advance — the
// won session lets advance clear the room and walk the graph;
// a lost/fled session terminates the run on the next pass.
if prevType == RoomElite || prevType == RoomBoss {
sess, _ := getCombatSessionForEncounter(before.RunID, encounterIDForRoom(before.CurrentRoom))
if sess == nil {
if err := p.handleFightCmd(MessageContext{Sender: uid}); err != nil {
t.Fatalf("zone fight step %d: %v", step, err)
}
}
// Drain the round loop. handleAttackCmd no-ops if there's no
// active session, so the inner loop self-terminates either way.
for atk := 0; atk < maxAttacksPerFight; atk++ {
active, _ := getActiveCombatSession(uid)
if active == nil {
break
}
if err := p.handleAttackCmd(MessageContext{Sender: uid}); err != nil {
t.Fatalf("attack step %d.%d: %v", step, atk, err)
}
}
}
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil {
t.Fatalf("zone advance step %d: %v", step, err)
}
clearedRoomTypes = append(clearedRoomTypes, prevType)
}
final, _ := getActiveZoneRun(uid)
raw := getZoneRunByUser(t, uid) // helper below — most-recent row
if final != nil {
t.Logf("final state: still active at room %d/%d — likely advance regression",
final.CurrentRoom+1, final.TotalRooms)
t.Errorf("run did not terminate within %d steps", maxSteps)
} else if raw != nil {
boss := raw.BossDefeated
t.Logf("run %s ended: bossDefeated=%v abandoned=%v rooms_cleared=%d/%d loot=%d mood=%d",
raw.RunID, boss, raw.Abandoned, len(raw.RoomsCleared), raw.TotalRooms,
len(raw.LootCollected), raw.DMMood)
t.Logf("clearedRoomTypes: %v", clearedRoomTypes)
if !boss && !raw.Abandoned && raw.CompletedAt == nil {
t.Errorf("run row in indeterminate state: %+v", raw)
}
}
}
// getZoneRunByUser fetches the most-recent zone-run row for a user
// regardless of active/abandoned/cleared. Helper for the scenario test.
func getZoneRunByUser(t *testing.T, uid id.UserID) *DungeonRun {
t.Helper()
row := db.Get().QueryRow(`
SELECT run_id FROM dnd_zone_run
WHERE user_id = ?
ORDER BY started_at DESC LIMIT 1
`, string(uid))
var runID string
if err := row.Scan(&runID); err != nil {
return nil
}
r, _ := getZoneRun(runID)
return r
}
// ── Scenario B — multi-day expedition ───────────────────────────────────────
func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@adv2-scenB:example")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
if err := createAdvCharacter(uid, "scenB"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassRanger, Level: 5,
STR: 14, DEX: 18, CON: 14, INT: 10, WIS: 14, CHA: 10,
HPMax: 50, HPCurrent: 50, ArmorClass: 16,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
euro := &EuroPlugin{}
euro.ensureBalance(uid)
euro.Credit(uid, 1000, "scenB bankroll")
p := &AdventurePlugin{euro: euro}
// !expedition list
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "list"); err != nil {
t.Fatalf("expedition list: %v", err)
}
// !expedition start crypt_valdris with 2 standard packs
balBefore := euro.GetBalance(uid)
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start crypt_valdris 2s"); err != nil {
t.Fatalf("expedition start: %v", err)
}
exp, err := getActiveExpedition(uid)
if err != nil || exp == nil {
t.Fatalf("expected active expedition: %v", err)
}
balAfter := euro.GetBalance(uid)
t.Logf("expedition %s started: zone=%s supplies=%d/%d threat=%d cost=%.0f→%.0f",
exp.ID, exp.ZoneID, int(exp.Supplies.Current), int(exp.Supplies.Max),
exp.ThreatLevel, balBefore, balAfter)
if exp.ZoneID != ZoneCryptValdris {
t.Errorf("zone = %s, want crypt_valdris", exp.ZoneID)
}
if balAfter >= balBefore {
t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter)
}
// Backdate start so deliverBriefing's same-day guard passes, and to
// before eventAnchoredCutoff so the legacy mutator path still fires.
rewindToLegacyAnchor(t, exp)
// Drive 3 daily briefings — verify supply burn + day advance.
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
for day := 1; day <= 3; day++ {
fresh, _ := getExpedition(exp.ID)
if fresh == nil {
t.Logf("expedition ended before day %d", day)
break
}
preDay := fresh.CurrentDay
preSupplies := fresh.Supplies.Current
preThreat := fresh.ThreatLevel
if err := p.deliverBriefing(fresh, now); err != nil {
t.Fatalf("deliverBriefing day %d: %v", day, err)
}
// Advance the wallclock + roll the start back another 24h so the
// next briefing's day-guard fires.
now = now.Add(24 * time.Hour)
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
t.Fatalf("backdate day %d: %v", day, err)
}
post, _ := getExpedition(exp.ID)
t.Logf("day %d: day=%d→%d supplies=%v→%v threat=%d→%d",
day, preDay, post.CurrentDay, preSupplies, post.Supplies.Current, preThreat, post.ThreatLevel)
if post.CurrentDay <= preDay {
t.Errorf("day did not advance: %d → %d", preDay, post.CurrentDay)
}
}
// !expedition status / log
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "status"); err != nil {
t.Fatalf("expedition status: %v", err)
}
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "log"); err != nil {
t.Fatalf("expedition log: %v", err)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
t.Logf("expedition log has %d entries", len(entries))
for i, e := range entries {
t.Logf(" [%d] day=%d type=%s summary=%q", i, e.Day, e.Type, e.Summary)
}
if len(entries) == 0 {
t.Error("expected at least one log entry")
}
// !expedition abandon — clean state for next test runs.
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "abandon"); err != nil {
t.Fatalf("expedition abandon: %v", err)
}
if got, _ := getActiveExpedition(uid); got != nil {
t.Error("expected no active expedition after abandon")
}
}
// ── Scenario C — harvest in an active expedition ────────────────────────────
func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@adv2-scenC:example")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
if err := createAdvCharacter(uid, "scenC"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceElf, Class: ClassRanger, Level: 4,
STR: 12, DEX: 18, CON: 14, INT: 10, WIS: 16, CHA: 10,
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
euro := &EuroPlugin{}
euro.ensureBalance(uid)
euro.Credit(uid, 500, "scenC bankroll")
p := &AdventurePlugin{euro: euro}
// Forage-friendly expedition.
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows lean"); err != nil {
t.Fatalf("expedition start: %v", err)
}
exp, _ := getActiveExpedition(uid)
if exp == nil {
t.Fatal("no expedition")
}
// Drive a region run so harvest has a current room context.
run, err := ensureRegionRun(exp, c.Level)
if err != nil {
t.Fatalf("ensureRegionRun: %v", err)
}
t.Logf("region run %s in zone %s (%d rooms)", run.RunID, run.ZoneID, run.TotalRooms)
// Seed harvest nodes for the entry room (they're auto-seeded by
// loadHarvestNodes on first read, but confirm we get at least one).
roomIdx := currentRoomIndexFor(exp)
nodeID := currentNodeIDFor(exp)
nodes := loadHarvestNodes(exp, nodeID)
t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes))
if len(nodes) == 0 {
t.Error("expected at least one harvest node seeded")
}
// Drive the auto-harvest pass (post-H3 there is no manual command
// surface). Outcomes are RNG-driven; we mainly want no panics +
// coherent state.
for i := 0; i < 3; i++ {
if _, err := p.autoHarvestRoom(uid, run, c, exp); err != nil {
t.Fatalf("autoHarvestRoom attempt %d: %v", i, err)
}
fresh, _ := getExpedition(exp.ID)
if fresh == nil {
t.Logf("expedition ended during auto-harvest #%d", i)
break
}
t.Logf("after auto-harvest #%d: supplies=%v threat=%d", i, fresh.Supplies.Current, fresh.ThreatLevel)
}
// !resources output path (no-op SendDM but should not error).
if err := p.handleResourcesCmd(MessageContext{Sender: uid}); err != nil {
t.Fatalf("handleResourcesCmd: %v", err)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
package plugin
import (
"fmt"
"math"
"math/rand/v2"
"time"
@@ -641,148 +642,141 @@ type AdvActionResult struct {
FlavorKey string
EquipBroken []EquipmentSlot
NearDeath bool
StreakBonus int
StreakBonus int
CombatLog *CombatResult
// MasteryCrossings records equipment slots whose ActionsUsed crossed a
// mastery threshold (advMasteryThresholds) on this action. The caller
// uses this to DM a celebration so the silent counter becomes visible.
MasteryCrossings []AdvMasteryCrossing
}
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
result := &AdvActionResult{
Location: loc,
XPSkill: advXPSkill(loc.Activity),
}
type AdvMasteryCrossing struct {
Slot EquipmentSlot
ItemName string
Threshold int // 50, 100, or 250
}
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
// advMasteryThresholds defines the action counts at which equipment mastery
// is celebrated. Picked to span an early-game milestone (50), a mid-game
// commitment (100), and a hard-to-reach veteran tier (250).
var advMasteryThresholds = []int{50, 100, 250}
// Overlevel penalty — reduces loot and XP for farming low-tier content
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
overlevelMult := advOverlevelMultiplier(skillLevel, loc)
// Roll outcome
roll := rand.Float64() * 100
switch {
case roll < probs.DeathPct:
result.Outcome = AdvOutcomeDeath
case roll < probs.DeathPct+probs.EmptyPct:
// Activity-specific empty outcomes
result.Outcome = resolveAdvEmptyOutcome(loc, roll)
case roll < probs.DeathPct+probs.EmptyPct+probs.SuccessPct:
result.Outcome = AdvOutcomeSuccess
default:
result.Outcome = AdvOutcomeExceptional
}
// Near-death check: survived within 2% of death threshold
if result.Outcome != AdvOutcomeDeath && roll < probs.DeathPct+2 && roll >= probs.DeathPct {
result.NearDeath = true
}
// Generate loot for success/exceptional
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
// Apply overlevel penalty to loot values
if overlevelMult < 1.0 {
for i := range result.LootItems {
result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult))
}
}
for _, item := range result.LootItems {
result.TotalLootValue += item.Value
// advMasteryTier reports how many mastery thresholds a piece has crossed
// (03) and the next threshold. nextThreshold is 0 when the piece has
// already crossed every threshold.
func advMasteryTier(actionsUsed int) (tier, nextThreshold int) {
for _, t := range advMasteryThresholds {
if actionsUsed >= t {
tier++
} else {
nextThreshold = t
break
}
}
return tier, nextThreshold
}
// XP calculation
xp := advXPForOutcome(loc.Activity, loc.Tier, result.Outcome)
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
xp = advXPTable[loc.Activity][loc.Tier].Success
if result.Outcome == AdvOutcomeExceptional {
xp = advXPTable[loc.Activity][loc.Tier].Exceptional
}
// advMasteryMarker returns the visual tier marker for a piece of equipment.
// Empty for sub-50; ✦/✦✦/✦✦✦ at the 50/100/250 thresholds.
func advMasteryMarker(actionsUsed int) string {
tier, _ := advMasteryTier(actionsUsed)
switch tier {
case 1:
return "✦"
case 2:
return "✦✦"
case 3:
return "✦✦✦"
}
return ""
}
xpResult := applyXPBonuses(XPBonusParams{
BaseXP: xp,
NearDeath: result.NearDeath,
BonusMult: bonuses.XPMultiplier,
Ironclad: advEquippedArenaSets(equip)["ironclad"],
OverlevelMult: overlevelMult,
})
result.XPGained = xpResult.Total
result.XPBreakdown = xpResult.Breakdown
// Equipment degradation on bad outcomes
if result.Outcome == AdvOutcomeDeath || result.Outcome == AdvOutcomeEmpty ||
result.Outcome == AdvOutcomeCaveIn || result.Outcome == AdvOutcomeBear ||
result.Outcome == AdvOutcomeRiver {
result.EquipDamage = applyAdvEquipDegradation(equip, result.Outcome)
result.EquipBroken = advCheckBrokenEquipment(equip)
// advSlotRelevantToActivity reports whether using a slot during the given
// activity should count toward that slot's mastery. Combat actions exercise
// the combat loadout (weapon/armor/helmet/boots); harvest actions exercise
// only the tool. This is what makes mastery feel like "you used this
// piece" rather than "you played for a while" — without this gate every
// slot ticks in lockstep and the per-slot view is meaningless.
func advSlotRelevantToActivity(slot EquipmentSlot, activity AdvActivityType) bool {
if isCombatActivity(activity) {
return slot == SlotWeapon || slot == SlotArmor || slot == SlotHelmet || slot == SlotBoots
}
if isHarvestActivity(activity) {
return slot == SlotTool
}
return false
}
// Increment actions_used for equipment mastery
// advMasteryRowSegment formats the `23/50`, `73/100 ✦`, `142/250 ✦✦`, or
// `250 ✦✦✦` segment shown inline on each equipment row in the character
// sheet so progress is visible at a glance — not just a tier marker that
// flips on at 50/100/250 with nothing in between.
func advMasteryRowSegment(actionsUsed int) string {
if actionsUsed <= 0 {
return ""
}
_, next := advMasteryTier(actionsUsed)
marker := advMasteryMarker(actionsUsed)
if next == 0 {
// At max — there's no "next" threshold to count toward.
return fmt.Sprintf("%d %s", actionsUsed, marker)
}
if marker == "" {
return fmt.Sprintf("%d/%d", actionsUsed, next)
}
return fmt.Sprintf("%d/%d %s", actionsUsed, next, marker)
}
// AdvMasteryRollup summarizes mastery state across all equipped slots so the
// character sheet can show one informative aggregate line without a
// per-slot block. AnyProgress is true when at least one slot has any
// ActionsUsed — used to decide whether to show a "building up" hint when
// no thresholds have crossed yet.
type AdvMasteryRollup struct {
ThresholdsCrossed int // raw count, pre-cap (sum across all slots)
MaxedSlots int // slots with all 3 thresholds crossed
Bonus float64 // capped %, mirrors advEquipmentMasteryBonus
AnyProgress bool
}
func advMasteryRollup(equip map[EquipmentSlot]*AdvEquipment) AdvMasteryRollup {
r := AdvMasteryRollup{}
for _, eq := range equip {
eq.ActionsUsed++
}
return result
}
// resolveAdvEmptyOutcome returns an activity-specific "empty" outcome.
func resolveAdvEmptyOutcome(loc *AdvLocation, _ float64) AdvOutcomeType {
switch loc.Activity {
case AdvActivityMining:
// 40% chance of cave-in on "empty" result
if rand.Float64() < 0.4 {
return AdvOutcomeCaveIn
}
return AdvOutcomeEmpty
case AdvActivityForaging:
// Split empty into specific outcomes
r := rand.Float64()
switch {
case r < 0.35:
return AdvOutcomeHornets
case r < 0.55:
return AdvOutcomeBear
case r < 0.70:
return AdvOutcomeRiver
default:
return AdvOutcomeEmpty
}
case AdvActivityFishing:
// Fishing empty is just empty — no sub-outcomes
return AdvOutcomeEmpty
default:
return AdvOutcomeEmpty
}
}
// ── Eligible Locations for DM Menu ───────────────────────────────────────────
type AdvEligibleLocation struct {
Location *AdvLocation
InPenaltyZone bool
DeathPct float64
}
func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) []AdvEligibleLocation {
var eligible []AdvEligibleLocation
for _, loc := range allAdvLocations(activity) {
loc := loc
ok, penalty := advIsEligible(char, equip, &loc, bonuses)
if !ok {
if eq == nil {
continue
}
probs := calculateAdvProbabilities(char, equip, &loc, bonuses, penalty)
eligible = append(eligible, AdvEligibleLocation{
Location: &loc,
InPenaltyZone: penalty,
DeathPct: probs.DeathPct,
})
if eq.ActionsUsed > 0 {
r.AnyProgress = true
}
tier, _ := advMasteryTier(eq.ActionsUsed)
r.ThresholdsCrossed += tier
if tier == len(advMasteryThresholds) {
r.MaxedSlots++
}
}
return eligible
r.Bonus = advEquipmentMasteryBonus(equip)
return r
}
// advEquipmentMasteryBonus returns the loot-quality bonus from accumulated
// mastery across all equipped slots. Each crossed threshold grants +1% loot
// quality on its slot, capped at +10% total to keep balance reasonable.
func advEquipmentMasteryBonus(equip map[EquipmentSlot]*AdvEquipment) float64 {
total := 0.0
for _, eq := range equip {
if eq == nil {
continue
}
for _, t := range advMasteryThresholds {
if eq.ActionsUsed >= t {
total++
}
}
}
if total > 10 {
total = 10
}
return total
}
// ── Party Bonus Check ────────────────────────────────────────────────────────

View File

@@ -0,0 +1,233 @@
package plugin
import (
"encoding/json"
"fmt"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// AdvDailyActivity is one row of "what a player did" on a given UTC date.
// Unifies the legacy adventure_activity_log table with the D&D-layer zone
// run / expedition log tables so the daily report can render a coherent
// view regardless of which subsystem the action ran through.
type AdvDailyActivity struct {
UserID id.UserID
Source string // "zone_run" | "expedition" | "rest_legacy" | "legacy"
Activity string // "zone" | "expedition" | "rest" | legacy activity_type
Location string // zone display name or legacy location
Outcome string // "completed" | "abandoned" | "boss_defeated" | "in_progress" | "rest" | legacy outcome
Summary string // one-line human description
LootValue int64 // 0 for D&D-layer entries (loot stays in run/expedition state)
Timestamp time.Time
}
// loadAdvDailyActivity unions activity rows from every action surface for
// the given UTC date (format "2006-01-02"). Returns map keyed by user_id.
//
// Sources (in chronological order on output):
// - adventure_activity_log (legacy, mostly !rest post-Phase-R)
// - dnd_zone_run (zone runs whose last_action_at falls on date)
// - dnd_expedition_log (expedition log entries dated today, deduped per
// expedition into one rollup line)
//
// Each user's slice is sorted oldest→newest. Empty slice when the player
// had no activity on this date.
func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error) {
out := make(map[id.UserID][]AdvDailyActivity)
d := db.Get()
// 1. Legacy adventure_activity_log
rows, err := d.Query(`
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, logged_at
FROM adventure_activity_log
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
ORDER BY logged_at`, date, date)
if err != nil {
return nil, fmt.Errorf("legacy log: %w", err)
}
for rows.Next() {
var (
uid, activity, location, outcome string
loot int64
ts time.Time
)
if err := rows.Scan(&uid, &activity, &location, &outcome, &loot, &ts); err != nil {
rows.Close()
return nil, fmt.Errorf("legacy log scan: %w", err)
}
userID := id.UserID(uid)
entry := AdvDailyActivity{
UserID: userID,
Source: "legacy",
Activity: activity,
Location: location,
Outcome: outcome,
LootValue: loot,
Timestamp: ts,
}
if activity == string(AdvActivityRest) {
entry.Source = "rest_legacy"
entry.Summary = "Rested. Streak preserved."
} else {
entry.Summary = fmt.Sprintf("%s in %s (%s)", activity, location, outcome)
}
out[userID] = append(out[userID], entry)
}
rows.Close()
// Pre-load (user_id, zone_id) of active expeditions. Zone runs are now
// exclusively spawned by the expedition layer, and the expedition flips
// a region's run to abandoned on !region travel / inactivity timeout /
// forced extraction. Those transitions are internal — the expedition
// rollup below is the source of truth for the player. Skip zone_run
// entries that match an active expedition to avoid a misleading
// "Withdrew from <zone>" line while the player is still on it.
activeExp := make(map[string]struct{})
expRows, err := d.Query(`
SELECT user_id, zone_id FROM dnd_expedition
WHERE status IN ('active','extracting')`)
if err != nil {
return nil, fmt.Errorf("active expeditions: %w", err)
}
for expRows.Next() {
var u, z string
if err := expRows.Scan(&u, &z); err != nil {
expRows.Close()
return nil, fmt.Errorf("active expedition scan: %w", err)
}
activeExp[u+"|"+z] = struct{}{}
}
expRows.Close()
// 2. dnd_zone_run — rows touched today. Progress count is derived
// from len(visited_nodes) — current_room retired in G9.
rows, err = d.Query(`
SELECT user_id, zone_id, visited_nodes, total_rooms, abandoned,
boss_defeated, completed_at, last_action_at
FROM dnd_zone_run
WHERE last_action_at >= ? AND last_action_at < DATE(?, '+1 day')
ORDER BY last_action_at`, date, date)
if err != nil {
return nil, fmt.Errorf("zone runs: %w", err)
}
for rows.Next() {
var (
uid, zoneID string
visitedJSON string
totalRooms int
abandoned, bossDefeated int
completedAt *time.Time
lastAction time.Time
)
if err := rows.Scan(&uid, &zoneID, &visitedJSON, &totalRooms,
&abandoned, &bossDefeated, &completedAt, &lastAction); err != nil {
rows.Close()
return nil, fmt.Errorf("zone run scan: %w", err)
}
var visited []string
_ = json.Unmarshal([]byte(visitedJSON), &visited)
currentRoom := len(visited) - 1
if currentRoom < 0 {
currentRoom = 0
}
if _, onExp := activeExp[uid+"|"+zoneID]; onExp {
continue
}
userID := id.UserID(uid)
zoneDef := zoneOrFallback(ZoneID(zoneID))
var outcome, summary string
switch {
case bossDefeated == 1:
outcome = "boss_defeated"
summary = fmt.Sprintf("Cleared %s — boss down (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms)
case completedAt != nil && abandoned == 0:
outcome = "completed"
summary = fmt.Sprintf("Cleared %s (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms)
case abandoned == 1:
outcome = "abandoned"
summary = fmt.Sprintf("Withdrew from %s at room %d/%d.", zoneDef.Display, currentRoom, totalRooms)
default:
outcome = "in_progress"
summary = fmt.Sprintf("Mid-run in %s (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms)
}
out[userID] = append(out[userID], AdvDailyActivity{
UserID: userID,
Source: "zone_run",
Activity: "zone",
Location: zoneDef.Display,
Outcome: outcome,
Summary: summary,
Timestamp: lastAction,
})
}
rows.Close()
// 3. dnd_expedition_log — pick the most-recent player-visible entry
// per expedition for users active today. We rollup per expedition
// rather than per row so the report doesn't list every camp/combat
// beat separately.
rows, err = d.Query(`
SELECT e.user_id, e.zone_id, e.current_day, e.status,
l.entry_type, l.summary, l.timestamp
FROM dnd_expedition_log l
JOIN dnd_expedition e ON e.expedition_id = l.expedition_id
WHERE l.timestamp >= ? AND l.timestamp < DATE(?, '+1 day')
AND l.entry_type IN ('action','combat','event','recap')
ORDER BY l.timestamp`, date, date)
if err != nil {
return nil, fmt.Errorf("expedition log: %w", err)
}
type expKey struct {
uid, zoneID string
}
mostRecentExp := make(map[expKey]AdvDailyActivity)
for rows.Next() {
var (
uid, zoneID, status, entryType, summary string
currentDay int
ts time.Time
)
if err := rows.Scan(&uid, &zoneID, &currentDay, &status,
&entryType, &summary, &ts); err != nil {
rows.Close()
return nil, fmt.Errorf("expedition log scan: %w", err)
}
userID := id.UserID(uid)
zoneDef := zoneOrFallback(ZoneID(zoneID))
k := expKey{string(userID), zoneID}
mostRecentExp[k] = AdvDailyActivity{
UserID: userID,
Source: "expedition",
Activity: "expedition",
Location: zoneDef.Display,
Outcome: status,
Summary: fmt.Sprintf("Expedition in %s, day %d — %s",
zoneDef.Display, currentDay, summary),
Timestamp: ts,
}
}
rows.Close()
for _, entry := range mostRecentExp {
out[entry.UserID] = append(out[entry.UserID], entry)
}
// Sort each user's slice oldest→newest so callers can pick the
// representative entry deterministically (typically the latest).
for uid := range out {
entries := out[uid]
// Insertion-sort is fine here — slices are tiny.
for i := 1; i < len(entries); i++ {
for j := i; j > 0 && entries[j-1].Timestamp.After(entries[j].Timestamp); j-- {
entries[j-1], entries[j] = entries[j], entries[j-1]
}
}
out[uid] = entries
}
return out, nil
}

View File

@@ -16,20 +16,20 @@ import (
// ── Arena Run State ─────────────────────────────────────────────────────────
type ArenaRun struct {
ID int64
UserID id.UserID
RoomID id.RoomID
StartTier int
Tier int
Round int
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
Earnings int64 // session total (multiplied euros accumulated across completed tiers)
TierEarnings int64 // current tier's raw earnings (reset each tier)
XPAccumulated int // session XP accumulator (raw, multiplied at payout)
RoundsSurvived int
LastMonster string
StartedAt time.Time
EndedAt *time.Time
ID int64
UserID id.UserID
RoomID id.RoomID
StartTier int
Tier int
Round int
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
Earnings int64 // session total (multiplied euros accumulated across completed tiers)
TierEarnings int64 // current tier's raw earnings (reset each tier)
XPAccumulated int // session XP accumulator (raw, multiplied at payout)
RoundsSurvived int
LastMonster string
StartedAt time.Time
EndedAt *time.Time
}
// ── Command Dispatch ────────────────────────────────────────────────────────
@@ -80,7 +80,7 @@ func (p *AdventurePlugin) handleArenaMenu(ctx MessageContext) error {
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char.UserID))
}
// Clear any pending entry when viewing menu
@@ -100,7 +100,11 @@ func (p *AdventurePlugin) handleArenaMenu(ctx MessageContext) error {
stats := loadArenaPersonalStats(ctx.Sender)
monster := arenaGetMonster(1, 1)
text := renderArenaStreakEntry(char, stats, tier, monster)
dndChar, err := p.ensureCharForDnDCmd(ctx.Sender, char)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load character sheet.")
}
text := renderArenaStreakEntry(dndChar, stats, tier, monster)
return p.SendDM(ctx.Sender, text)
}
@@ -152,7 +156,7 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char.UserID))
}
// Re-check active run (could have changed since entry prompt)
@@ -187,12 +191,32 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
return p.resolveArenaRound(ctx, run, char, equip, tier, monster)
}
// resolveArenaRound runs a single arena round through the combat engine and
// dispatches to survival or death handlers.
func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error {
result, condRepair := p.runArenaCombat(ctx.Sender, char, equip, monster, run.Round, run.Tier)
// arenaBossNarration carries the staged-narration trio produced by
// resolveArenaBoss: an intro line, the per-phase combat log, and the
// outcome block (TwinBee mood + headline + dice summary).
type arenaBossNarration struct {
intro string
phases []string
outcome string
}
// resolveArenaRound runs a single arena round through resolveArenaBoss
// (zone-boss combat + staged narration) and threads the result into the
// surrounding economic glue. There is no legacy fallback — a boss-flow
// error surfaces to the player and aborts the round.
func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error {
displayName, _ := loadDisplayName(ctx.Sender)
intro, phases, outcome, result, err := p.resolveArenaBoss(ctx.Sender, ArenaBossEncounter{
Tier: run.Tier,
Round: run.Round,
DisplayName: displayName,
})
if err != nil {
slog.Error("arena: boss flow failed", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Arena combat encountered an error. Try again in a moment.")
}
bossNarr := &arenaBossNarration{intro: intro, phases: phases, outcome: outcome}
// Apply event-based equipment degradation
degradation := combatDegradation(result, equip)
for slot, eq := range equip {
if d, ok := degradation[slot]; ok && d > 0 {
@@ -200,9 +224,7 @@ func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, c
}
}
deathSaved := checkDeathSaveEvent(result.Events)
if deathSaved {
if checkDeathSaveEvent(result.Events) {
now := time.Now().UTC()
char.DeathReprieveLast = &now
if err := saveAdvCharacter(char); err != nil {
@@ -211,15 +233,9 @@ func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, c
}
if !result.PlayerWon {
return p.resolveArenaDeath(ctx, run, char, tier, monster, result)
return p.resolveArenaDeath(ctx, run, char, tier, monster, result, bossNarr)
}
// Misty condition repair (post-combat)
if condRepair > 0 {
npcRepairMostDamaged(ctx.Sender, equip, condRepair)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster, result)
return p.resolveArenaSurvival(ctx, run, char, tier, monster, result, bossNarr)
}
func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error {
@@ -259,8 +275,7 @@ func (p *AdventurePlugin) handleArenaBail(ctx MessageContext) error {
}
func (p *AdventurePlugin) handleArenaStatus(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
if _, err := loadAdvCharacter(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
@@ -269,7 +284,7 @@ func (p *AdventurePlugin) handleArenaStatus(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "No active arena run. Start one with `!arena`.")
}
return p.SendDM(ctx.Sender, renderArenaStatus(run, char))
return p.SendDM(ctx.Sender, renderArenaStatus(run))
}
func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error {
@@ -279,16 +294,27 @@ func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error {
}
stats := loadArenaPersonalStats(ctx.Sender)
return p.SendDM(ctx.Sender, renderArenaPersonalStats(char, stats))
wins, losses := char.ArenaWins, char.ArenaLosses
if meta, err := loadPlayerMeta(ctx.Sender); err == nil {
wins, losses = meta.ArenaWins, meta.ArenaLosses
} else {
slog.Error("player_meta: arena stats read failed, falling back to AdvCharacter", "user", ctx.Sender, "err", err)
}
displayName, _ := loadDisplayName(ctx.Sender)
return p.SendDM(ctx.Sender, renderArenaPersonalStats(displayName, wins, losses, stats))
}
// handleArenaLeaderboard shows the current season's standings (C4). Lifetime
// totals stay reachable via `!arena stats`.
func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
entries, err := loadArenaLeaderboard()
now := time.Now().UTC()
start, end := arenaSeasonBounds(now)
entries, err := loadArenaSeasonLeaderboard(start, end)
if err != nil {
slog.Error("arena: failed to load leaderboard", "err", err)
slog.Error("arena: failed to load season leaderboard", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load arena leaderboard.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(entries))
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(arenaSeasonKey(now), entries))
}
// ── Combat Resolution ───────────────────────────────────────────────────────
@@ -298,9 +324,12 @@ func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
var arenaStreakEuroMultiplier = [6]float64{0, 1.0, 1.5, 2.0, 2.75, 4.0} // index = tier
var arenaStreakXPMultiplier = [6]float64{0, 1.0, 1.2, 1.5, 1.85, 2.5} // index = tiers won
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult) error {
// Calculate reward — accumulate in tier earnings, not credited yet
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult, bossNarr *arenaBossNarration) error {
// Calculate reward — accumulate in tier earnings, not credited yet.
// Skill bonus scales off DnDCharacter.Level (post-L2: arena is on the
// D&D level scale, not the legacy CombatLevel).
skillLevel := arenaDnDLevelOrZero(ctx.Sender)
reward := arenaRoundReward(tier, run.Round, skillLevel)
run.TierEarnings += reward
run.RoundsSurvived++
run.LastMonster = monster.Name
@@ -316,10 +345,9 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
}
run.XPAccumulated += battleXP
// Render combat log as phased messages + final outcome
phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name)
phaseMessages := bossFlowPhaseMessages(bossNarr)
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
finalMessage := renderArenaCombatFinalMessage(result, monster, reward, battleXP, run.Round)
finalMessage := bossNarr.outcome + fmt.Sprintf("\n🏆 +%d XP | €%d earned", battleXP, reward)
// Suppress the "(at risk)" line on the tier-completing round — those earnings
// are about to be locked in by the tier-complete branch below, so labelling
@@ -363,7 +391,7 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
finalMessage += fmt.Sprintf("\n🏆 **Tier %d cleared!** Round earnings: €%d + completion bonus: €%d (×%.1f streak)\n",
tier.Number, tierRaw, tier.CompletionBonus, multiplier)
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
<-done
return p.arenaCompleteSession(ctx.Sender, run, char, "")
}
@@ -379,10 +407,11 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
if dropped := p.arenaRollHelmetDrop(ctx.Sender, run.Tier); dropped != nil {
finalMessage += "\n" + renderArenaHelmetDrop(dropped)
p.postArenaDropAnnouncement(char.DisplayName, dropped)
displayName, _ := loadDisplayName(ctx.Sender)
p.postArenaDropAnnouncement(displayName, dropped)
}
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
go func() {
<-done
p.arenaCountdown(ctx.Sender, run)
@@ -404,17 +433,39 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
finalMessage += "`!arena fight` — Face this opponent"
}
p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage)
// Fire-and-forget: no post-flush work; blocking would stall the
// round-advance handler behind streamed pacing. Contract is honored
// by the explicit discard — see sendZoneCombatMessages comment.
_ = p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
return nil
}
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult) error {
// bossFlowPhaseMessages prepends the resolveArenaBoss intro line to the
// staged combat phases, mirroring streamOrSend's intro+phases pattern in
// dnd_zone_cmd.go.
func bossFlowPhaseMessages(n *arenaBossNarration) []string {
if n.intro == "" {
return append([]string{}, n.phases...)
}
out := make([]string, 0, len(n.phases)+1)
out = append(out, n.intro)
out = append(out, n.phases...)
return out
}
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult, bossNarr *arenaBossNarration) error {
run.LastMonster = monster.Name
lostEarnings := run.Earnings + run.TierEarnings
phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name)
phaseMessages := bossFlowPhaseMessages(bossNarr)
dt := transitionDeath(DeathTransitionParams{Char: char})
arenaPet, _ := loadPetState(char.UserID)
dt := transitionDeath(DeathTransitionParams{
Char: char,
Pet: arenaPet,
Source: "arena",
DeathLocation: "the Arena",
})
char.ArenaLosses++
char.CombatXP += arenaParticipationXP
@@ -437,7 +488,7 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
upsertArenaStats(run.UserID, 0, true, run.Tier)
finalMsg := renderArenaCombatFinalMessage(result, monster, 0, arenaParticipationXP, run.Round)
finalMsg := bossNarr.outcome + fmt.Sprintf("\n+%d XP (participation) | Back tomorrow.", arenaParticipationXP)
if dt.PetRecovered {
finalMsg += fmt.Sprintf("\n\nYour pet dragged you out of the arena. Death timer reduced. All session earnings forfeited.")
} else {
@@ -450,12 +501,13 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
}
}
done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMsg)
done := p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMsg)
if dt.PetRecovered {
gr := gamesRoom()
if gr != "" {
_ = p.SendMessage(gr, petDitchRecoveryGameRoom(char.DisplayName, char.PetName, true))
displayName, _ := loadDisplayName(ctx.Sender)
_ = p.SendMessage(gr, petDitchRecoveryGameRoom(displayName, char.PetName, true))
}
}
@@ -479,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
}
totalXP := int(float64(run.XPAccumulated) * xpMult)
// N7/B3 the Omen — a payout-boosting week scales gross earnings before the
// pot tax, so both the player's cut and the pot's rake grow proportionally.
if m := activeOmen().ArenaPayoutMult; m > 1.0 {
run.Earnings = int64(float64(run.Earnings) * m)
}
// Arena tax: 10% of earnings to community pot.
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
arenaNet := run.Earnings - arenaTax
@@ -534,8 +592,9 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
// Room announcement for T5
gr := gamesRoom()
if gr != "" {
displayName, _ := loadDisplayName(userID)
announce := fmt.Sprintf("🏆 **%s has conquered the Arena.** Tier 5 streak. €%d earned. That Which Has Always Been has fallen.",
char.DisplayName, run.Earnings)
displayName, run.Earnings)
p.SendMessage(id.RoomID(gr), announce)
}
}
@@ -549,9 +608,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
// Game room announcement
gr := gamesRoom()
if gr != "" {
displayName, _ := loadDisplayName(userID)
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
"🏆 %s walked out of the arena with the Gladiator's Helm. Tier %d streak. They had the option to stop. They did not stop.",
char.DisplayName, tiersWon))
displayName, tiersWon))
}
}
}
@@ -574,7 +634,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
text += fmt.Sprintf("\n\n🎉 **Combat Level %d!**", newLevel)
}
return p.SendDM(userID, text)
err := p.SendDM(userID, text)
// N1/A6 — cashing out is the third mid-day event anchor.
p.maybeFireAnchoredEvent(userID, advEventChanceArena)
return err
}
// arenaProcessBail handles bail payout (called from handleArenaBail or countdown).
@@ -594,37 +657,15 @@ func (p *AdventurePlugin) arenaProcessBail(userID id.UserID, run *ArenaRun) erro
// ── Combat Math ─────────────────────────────────────────────────────────────
func arenaDeathChance(monster *ArenaMonster, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment) float64 {
baseDeath := monster.BaseLethality
levelMod := float64(monster.ThreatLevel-char.CombatLevel) * 0.015
skillMod := math.Max(0, 0.25-float64(char.CombatLevel)*0.008)
// Average equipment tier → up to 15% reduction at max gear (tier 5 avg = 0.15)
var totalTier float64
count := 0
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
totalTier += float64(eq.Tier)
count++
}
// arenaDnDLevelOrZero returns the player's DnDCharacter.Level, or 0 if no
// sheet exists. Arena enters this path after combat (which migrates), so a
// zero return is rare and the caller's MinLevel gate handles it correctly.
func arenaDnDLevelOrZero(userID id.UserID) int {
c, err := LoadDnDCharacter(userID)
if err != nil || c == nil {
return 0
}
avgTier := 0.0
if count > 0 {
avgTier = totalTier / float64(count)
}
equipMod := avgTier * 0.03 // 0 at tier 0, 0.15 at tier 5
// Housing HP bonus reduces death chance
houseMod := char.HouseHPBonus() // 0-20% based on house tier
// Pet morning defense buff (cat offering / dog smothering)
petDefMod := 0.0
if char.PetMorningDefense {
petDefMod = 0.05
}
deathChance := baseDeath + levelMod - equipMod + skillMod - houseMod - petDefMod
return math.Max(0.01, math.Min(0.98, deathChance))
return c.Level
}
func arenaRoundReward(tier *ArenaTier, round int, battleSkill int) int64 {
@@ -742,16 +783,11 @@ func (p *AdventurePlugin) arenaCountdown(userID id.UserID, run *ArenaRun) {
return
}
char, err := loadAdvCharacter(userID)
if err != nil {
slog.Error("arena: failed to load character for auto-advance", "user", userID, "err", err)
p.arenaProcessBail(userID, freshRun)
return
}
// Level gate for next tier
if char.CombatLevel < nextTier.MinLevel {
p.SendDM(userID, renderArenaLevelGate(nextTier, char.CombatLevel)+"\n\nYour accumulated rewards have been paid out.")
// Level gate for next tier — uses DnDCharacter.Level (post-L2 arena
// is on the D&D level scale).
playerLevel := arenaDnDLevelOrZero(userID)
if playerLevel < nextTier.MinLevel {
p.SendDM(userID, renderArenaLevelGate(nextTier, playerLevel)+"\n\nYour accumulated rewards have been paid out.")
p.arenaProcessBail(userID, freshRun)
return
}
@@ -945,7 +981,7 @@ func loadArenaLeaderboard() ([]ArenaLeaderboardEntry, error) {
s.total_earnings, s.highest_tier, s.tier5_completions,
s.total_runs, s.total_deaths
FROM arena_stats s
LEFT JOIN adventure_characters c ON c.user_id = s.user_id
LEFT JOIN player_meta c ON c.user_id = s.user_id
ORDER BY s.total_earnings DESC
LIMIT 10`)
if err != nil {
@@ -985,8 +1021,8 @@ func loadArenaPersonalStats(userID id.UserID) *ArenaPersonalStats {
type ArenaGearSet struct {
Tier int
SetKey string // DB key: "bloodied", "ironclad", etc.
SetName string // Display: "Bloodied", "Ironclad", etc.
SetKey string // DB key: "bloodied", "ironclad", etc.
SetName string // Display: "Bloodied", "Ironclad", etc.
HelmetName string
Description string
DropRate float64
@@ -1206,3 +1242,93 @@ func (p *AdventurePlugin) arenaRollGladiatorHelm(userID id.UserID, maxTierCleare
return "The Gladiator's Helm"
}
// ── Arena boss-flow round resolver ──────────────────────────────────────────
//
// resolveArenaBoss is the arena combat path: a single arena round
// routed through runZoneCombat + renderBossOutcome so the player sees
// the same staged narration zone bosses use (Nat20/Nat1 mood lines,
// phase-two barb on T3+, BossDeath/PlayerDeath flavor, dice summary).
// ArenaBossEncounter is the single-round input for resolveArenaBoss.
// Tier and Round identify the arenaBosses entry; DisplayName is the
// player-facing combatant label, falling back to "You" when empty.
type ArenaBossEncounter struct {
Tier int
Round int
DisplayName string
}
// resolveArenaBoss runs one arena round through the zone-boss combat +
// narration stack. Returns the staged-narration trio that the caller
// streams via sendZoneCombatMessages, plus the underlying CombatResult
// so the surrounding economic glue (rewards, achievements, helmet
// drops, death flag) can branch on PlayerWon and inspect events.
//
// Side effects belong to the caller: this helper does not touch
// ArenaRun state, payout, or arena history rows.
func (p *AdventurePlugin) resolveArenaBoss(userID id.UserID, enc ArenaBossEncounter) (intro string, phases []string, outcome string, result CombatResult, err error) {
monster, ok := arenaBosses[arenaBossID(enc.Tier, enc.Round)]
if !ok {
err = fmt.Errorf("arena: no bestiary entry for tier %d round %d", enc.Tier, enc.Round)
return
}
preHP, _ := dndHPSnapshot(userID)
// Arena uses boss-shaped bestiary entries; give them the wider phase
// budget so the round resolver isn't decided by tiebreak.
// Arena has no run-state DMMood; pass neutral (50).
result, err = p.runZoneCombat(userID, monster, enc.Tier, bossCombatPhases, 50)
if err != nil {
return
}
postHP, maxHP := dndHPSnapshot(userID)
// Synthetic run/room IDs so twinBeeLine seeds deterministically per
// fight without colliding with zone runs. Arena fights aren't tied
// to a DungeonRun so MoodEvents don't apply — the Nat20/Nat1 counts
// drive narration directly.
runID := fmt.Sprintf("arena-%s-t%d-r%d", string(userID), enc.Tier, enc.Round)
roomIdx := enc.Tier*10 + enc.Round
nat20s, nat1s := countNat20sAnd1s(result)
playerName := enc.DisplayName
if playerName == "" {
playerName = "You"
}
intro = fmt.Sprintf("🏟️ **Arena T%d R%d — %s** (HP %d, AC %d)",
enc.Tier, enc.Round, monster.Name, monster.HP, monster.AC)
phases = RenderCombatLog(result, playerName, monster.Name)
outcome = renderBossOutcome(BossOutcomeInputs{
ZoneID: ZoneArena,
RunID: runID,
RoomIdx: roomIdx,
Monster: monster,
Result: result,
PreHP: preHP,
PostHP: postHP,
MaxHP: maxHP,
PhaseTwoAt: arenaBossPhaseTwoAt(enc.Tier),
Nat20s: nat20s,
Nat1s: nat1s,
DefeatHeadline: fmt.Sprintf("💀 **%s** stands over your body. The arena collects its fee.", monster.Name),
VictoryHeadline: fmt.Sprintf("🏆 **%s** falls. You finished at **%d/%d HP**.", monster.Name, postHP, maxHP),
})
return
}
// countNat20sAnd1s scans a CombatResult for d20 rolls and returns the
// nat-20 / nat-1 counts. Mirrors scanMoodEventsFromCombat's tally but
// without writing run-scoped mood events (arena has no DungeonRun).
func countNat20sAnd1s(result CombatResult) (nat20s, nat1s int) {
for _, e := range result.Events {
if e.Roll == 20 {
nat20s++
} else if e.Roll == 1 {
nat1s++
}
}
return
}

View File

@@ -0,0 +1,189 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// Phase L2 step 4 — smoke test for resolveArenaBoss. Asserts the helper
// returns staged narration (intro + phases + outcome) for a representative
// arena round and that the outcome carries the boss-flow signatures: the
// arena-styled headline and a dice-roll summary.
func TestResolveArenaBoss_T1Round1_Smoke(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@arena-bossflow:example")
t.Cleanup(func() { cleanupZoneRuns(uid) })
if err := createAdvCharacter(uid, "bossflow"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
HPMax: 60, HPCurrent: 60, ArmorClass: 18,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
p := &AdventurePlugin{}
intro, phases, outcome, result, err := p.resolveArenaBoss(uid, ArenaBossEncounter{
Tier: 1, Round: 1, DisplayName: "Smoke",
})
if err != nil {
t.Fatalf("resolveArenaBoss: %v", err)
}
if intro == "" {
t.Error("intro empty")
}
if !strings.Contains(intro, "Arena T1 R1") {
t.Errorf("intro missing tier/round label: %q", intro)
}
if len(phases) == 0 {
t.Error("phases empty — combat log did not render")
}
if outcome == "" {
t.Error("outcome empty")
}
if result.PlayerWon {
// On a win the headline mentions "falls"; on a loss "stands over".
if !strings.Contains(outcome, "falls") {
t.Errorf("win outcome missing 'falls' headline: %q", outcome)
}
} else {
if !strings.Contains(outcome, "stands over") {
t.Errorf("loss outcome missing defeat headline: %q", outcome)
}
}
}
func TestResolveArenaBoss_BadTierRound(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@arena-bossflow-bad:example")
if err := createAdvCharacter(uid, "bad"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
p := &AdventurePlugin{}
if _, _, _, _, err := p.resolveArenaBoss(uid, ArenaBossEncounter{Tier: 9, Round: 9}); err == nil {
t.Error("expected error for unknown tier/round")
}
}
func TestArenaBossPhaseTwoAt(t *testing.T) {
cases := []struct {
tier int
want float64
}{{1, 0}, {2, 0}, {3, 0.5}, {4, 0.5}, {5, 0.5}}
for _, c := range cases {
if got := arenaBossPhaseTwoAt(c.tier); got != c.want {
t.Errorf("arenaBossPhaseTwoAt(%d)=%v want %v", c.tier, got, c.want)
}
}
}
// Phase L2 step 4b — staged-narration assembly. The intro line leads,
// followed by the combat-log phases, mirroring streamOrSend's
// intro+phases pattern in dnd_zone_cmd.go.
func TestBossFlowPhaseMessages(t *testing.T) {
n := &arenaBossNarration{
intro: "🏟️ **Arena T1 R1 — Slug** (HP 12, AC 10)",
phases: []string{"phase A", "phase B"},
}
got := bossFlowPhaseMessages(n)
if len(got) != 3 {
t.Fatalf("len = %d, want 3 (intro + 2 phases): %v", len(got), got)
}
if got[0] != n.intro {
t.Errorf("got[0] = %q, want intro %q", got[0], n.intro)
}
if got[1] != "phase A" || got[2] != "phase B" {
t.Errorf("phases out of order: %v", got)
}
// No intro → just the phases, unchanged.
n2 := &arenaBossNarration{phases: []string{"only"}}
got2 := bossFlowPhaseMessages(n2)
if len(got2) != 1 || got2[0] != "only" {
t.Errorf("no-intro case: got %v want [only]", got2)
}
}
// Phase L2 step 8 — assert that an arena win surfaces both the staged
// combat log (RenderCombatLog phases prepended by the arena intro) and a
// TwinBee BossDeath flavor line in the outcome block. Drives
// renderBossOutcome directly with a forced-win CombatResult so the
// assertion is RNG-free; the resolveArenaBoss smoke test above already
// exercises the live combat path end-to-end.
func TestArenaBossOutcome_WinSurfacesStagedLogAndBossDeath(t *testing.T) {
monster := arenaBosses[arenaBossID(1, 1)]
result := CombatResult{
PlayerWon: true,
PlayerStartHP: 60, PlayerEndHP: 42,
EnemyStartHP: monster.HP, EnemyEndHP: 0,
Events: []CombatEvent{
{Round: 1, Phase: "attack", Actor: "player", Action: "hit", Roll: 14, RollAgainst: monster.AC, Damage: 10, EnemyHP: monster.HP - 10, PlayerHP: 60},
{Round: 2, Phase: "attack", Actor: "player", Action: "crit", Roll: 20, RollAgainst: monster.AC, Damage: monster.HP - 10, EnemyHP: 0, PlayerHP: 42},
},
TotalRounds: 2,
}
// Staged combat log — same call resolveArenaBoss makes for `phases`.
phases := RenderCombatLog(result, "Champion", monster.Name)
if len(phases) == 0 {
t.Fatal("RenderCombatLog returned no phases for a winning result")
}
victoryHeadline := "🏆 **" + monster.Name + "** falls (HP " + "60" + "→" + "42" + " / 60)."
outcome := renderBossOutcome(BossOutcomeInputs{
ZoneID: ZoneArena,
RunID: "arena-stagedlog-test",
RoomIdx: 11,
Monster: monster,
Result: result,
PreHP: 60, PostHP: 42, MaxHP: 60,
PhaseTwoAt: arenaBossPhaseTwoAt(1),
Nat20s: 1,
Nat1s: 0,
DefeatHeadline: "unused",
VictoryHeadline: victoryHeadline,
})
if !strings.Contains(outcome, "🎭 **TwinBee:**") {
t.Errorf("outcome missing TwinBee narration line: %q", outcome)
}
if !strings.Contains(outcome, victoryHeadline) {
t.Errorf("outcome missing victory headline: %q", outcome)
}
if !strings.Contains(outcome, "🎲 d20 —") {
t.Errorf("outcome missing dice-roll summary: %q", outcome)
}
// BossDeath line must be present — the TwinBee line preceding the
// victory headline is sourced from flavor.BossDeath via twinBeeLine.
headlineIdx := strings.Index(outcome, victoryHeadline)
if headlineIdx <= 0 {
t.Fatalf("victory headline not after a TwinBee line: %q", outcome)
}
prefix := outcome[:headlineIdx]
if !strings.Contains(prefix, "🎭 **TwinBee:**") {
t.Errorf("BossDeath TwinBee line should precede victory headline; prefix=%q", prefix)
}
}
func TestArenaBosses_AllTiersPopulated(t *testing.T) {
for tier := 1; tier <= 5; tier++ {
for round := 1; round <= 4; round++ {
id := arenaBossID(tier, round)
m, ok := arenaBosses[id]
if !ok {
t.Errorf("arenaBosses missing %s", id)
continue
}
if m.HP <= 0 || m.AC <= 0 || m.Attack <= 0 {
t.Errorf("%s has bad stats: HP=%d AC=%d ATK=%d", id, m.HP, m.AC, m.Attack)
}
}
}
}

View File

@@ -1,36 +1,3 @@
package plugin
import (
"fmt"
"math/rand/v2"
)
const arenaParticipationXP = 60
// ── Closer Lines ───────────────────────────────────────────────────────────
func arenaWinCloser(loserName string, lastRound int) string {
closers := []string{
"%s fought. It counts.",
"%s will be back. The arena keeps score.",
fmt.Sprintf("%%s has until tomorrow to think about round %d.", lastRound),
"%s gave you more trouble than you'd like to admit. They don't need to know that.",
"%s loses this one. The next one is an open question.",
"%s came here to fight and did. The result is a separate matter.",
"%s is already planning the rematch. You can feel it.",
}
return fmt.Sprintf(closers[rand.IntN(len(closers))], loserName)
}
func arenaLoseCloser(winnerName string, lastRound int) string {
closers := []string{
"You fought. It counts.",
"You'll be back. The arena keeps score.",
fmt.Sprintf("You have until tomorrow to think about round %d.", lastRound),
fmt.Sprintf("You gave %s more trouble than they'd like to admit. Small comfort. Still comfort.", winnerName),
"You lose this one. The next one is an open question.",
"You came here to fight and did. The result is a separate matter.",
fmt.Sprintf("%s won this one. You're already planning the rematch.", winnerName),
}
return closers[rand.IntN(len(closers))]
}

View File

@@ -1,5 +1,7 @@
package plugin
import "fmt"
// ── Arena Tier & Monster Definitions ────────────────────────────────────────
//
// Five tiers, four monsters each. Death chance, rewards, and XP scale with tier.
@@ -27,7 +29,7 @@ type ArenaMonster struct {
var arenaTiers = [5]ArenaTier{
// Tier 1 — Scrubs
{
Number: 1, Name: "Scrubs", MinLevel: 1,
Number: 1, Name: "Scrubs", MinLevel: 1, // DnD Level 13
BasePayout: 150, SkillMultiplier: 1.0, CompletionBonus: 2500, BattleXP: 10,
Monsters: [4]ArenaMonster{
{
@@ -54,7 +56,7 @@ var arenaTiers = [5]ArenaTier{
},
// Tier 2 — Thugs
{
Number: 2, Name: "Thugs", MinLevel: 10,
Number: 2, Name: "Thugs", MinLevel: 4, // DnD Level 47
BasePayout: 500, SkillMultiplier: 2.5, CompletionBonus: 10000, BattleXP: 25,
Monsters: [4]ArenaMonster{
{
@@ -82,7 +84,7 @@ var arenaTiers = [5]ArenaTier{
},
// Tier 3 — Brutes
{
Number: 3, Name: "Brutes", MinLevel: 25,
Number: 3, Name: "Brutes", MinLevel: 8, // DnD Level 812
BasePayout: 1500, SkillMultiplier: 6.0, CompletionBonus: 30000, BattleXP: 60,
Monsters: [4]ArenaMonster{
{
@@ -111,7 +113,7 @@ var arenaTiers = [5]ArenaTier{
},
// Tier 4 — Horrors
{
Number: 4, Name: "Horrors", MinLevel: 45,
Number: 4, Name: "Horrors", MinLevel: 13, // DnD Level 1317
BasePayout: 5000, SkillMultiplier: 12.0, CompletionBonus: 100000, BattleXP: 120,
Monsters: [4]ArenaMonster{
{
@@ -141,7 +143,7 @@ var arenaTiers = [5]ArenaTier{
},
// Tier 5 — World Eaters
{
Number: 5, Name: "World Eaters", MinLevel: 70,
Number: 5, Name: "World Eaters", MinLevel: 18, // DnD Level 1820
BasePayout: 15000, SkillMultiplier: 25.0, CompletionBonus: 500000, BattleXP: 250,
Monsters: [4]ArenaMonster{
{
@@ -190,6 +192,93 @@ func arenaGetMonster(tier, round int) *ArenaMonster {
return &t.Monsters[round-1]
}
// ── Adv 2.0 boss-flow bestiary (Phase L2 step 3) ────────────────────────────
//
// arenaBosses re-shapes the legacy arenaTiers data into boss-shaped
// DnDMonsterTemplate entries so resolveArenaBoss (Phase L2 step 4) can
// run an arena fight through the same runZoneCombat → renderBossOutcome
// flow that zone bosses use. Keyed by arenaBossID(tier, round) — IDs
// are namespaced "arena_t<tier>_r<round>" so they don't collide with
// dndBestiary keys.
//
// HP/AC/Attack are first-pass tier-banded values; BaseLethality biases
// each monster up or down within its tier band so round-1 is the
// weakest fight and round-4 is the cap. Tuning is ongoing against
// playtest data (gogobee_legacy_migration.md §4 Risk).
var arenaBosses = map[string]DnDMonsterTemplate{}
// arenaBossID composes the canonical arena bestiary key.
func arenaBossID(tier, round int) string {
return fmt.Sprintf("arena_t%d_r%d", tier, round)
}
// arenaBossPhaseTwoAt returns the PhaseTwoAt fraction (of MaxHP) for an
// arena fight at the given tier. T1T2 = no phase two; T3+ = 50% HP.
// The T4T5 flavor barb piggybacks on the standard phase-two narration
// path through ZoneArena's bossPhaseTwoPool (populated alongside arena
// flavor when those lines land).
func arenaBossPhaseTwoAt(tier int) float64 {
if tier >= 3 {
return 0.5
}
return 0
}
// arenaTierBaseStats returns the (HP, AC, Attack, AttackBonus, CR)
// baseline for a given arena tier. arenaMonsterToTemplate then biases
// HP/Attack by BaseLethality so within-tier ordering matches the legacy
// difficulty curve.
func arenaTierBaseStats(tier int) (hp, ac, atk, ab int, cr float32) {
switch tier {
case 1:
return 30, 13, 8, 4, 1
case 2:
return 80, 14, 14, 6, 4
case 3:
return 160, 15, 22, 7, 8
case 4:
return 260, 16, 32, 8, 13
case 5:
return 400, 18, 45, 10, 18
}
return 30, 13, 8, 4, 1
}
// arenaMonsterToTemplate maps a legacy ArenaMonster onto a
// DnDMonsterTemplate. HP/AC/Attack scale with tier; within a tier,
// BaseLethality biases the curve via a ±30% band so round-1 sits at
// the bottom of the tier and round-4 at the top.
func arenaMonsterToTemplate(id string, m ArenaMonster, tier int) DnDMonsterTemplate {
hp, ac, atk, ab, cr := arenaTierBaseStats(tier)
bias := (m.BaseLethality - 0.5) * 0.6 // ≈ -0.24 .. +0.29
hp = int(float64(hp) * (1 + bias))
atk = int(float64(atk) * (1 + bias))
if hp < 1 {
hp = 1
}
if atk < 1 {
atk = 1
}
return DnDMonsterTemplate{
ID: id, Name: m.Name, CR: cr,
HP: hp, AC: ac, Attack: atk, AttackBonus: ab, Speed: 12,
BlockRate: 0.05,
Ability: m.Ability,
XPValue: arenaTiers[tier-1].BattleXP * 4,
Notes: m.Flavor,
}
}
func init() {
for ti, t := range arenaTiers {
tier := ti + 1
for ri, m := range t.Monsters {
id := arenaBossID(tier, ri+1)
arenaBosses[id] = arenaMonsterToTemplate(id, m, tier)
}
}
}
// ── Arena Death Flavor Text ─────────────────────────────────────────────────
var arenaDeathMessages = []string{

View File

@@ -8,18 +8,18 @@ import (
// ── Arena Tier Menu ─────────────────────────────────────────────────────────
func renderArenaStreakEntry(char *AdventureCharacter, stats *ArenaPersonalStats, tier *ArenaTier, firstMonster *ArenaMonster) string {
func renderArenaStreakEntry(c *DnDCharacter, stats *ArenaPersonalStats, tier *ArenaTier, firstMonster *ArenaMonster) string {
var b strings.Builder
b.WriteString("⚔️ **THE ARENA**\n\n")
b.WriteString("The Arena is a streak. You start at Tier 1 and fight your way down.\n")
b.WriteString("After each tier, you have 30 seconds to bail or you auto-advance.\n")
b.WriteString("Death forfeits all accumulated rewards.\n\n")
b.WriteString(fmt.Sprintf("Combat Level: %d\n\n", char.CombatLevel))
b.WriteString(fmt.Sprintf("Level: %d\n\n", c.Level))
for i := range arenaTiers {
t := &arenaTiers[i]
eligible := char.CombatLevel >= t.MinLevel
eligible := c.Level >= t.MinLevel
icon := "🔒"
if eligible {
icon = "⬚"
@@ -91,7 +91,7 @@ func renderArenaDeath(tier *ArenaTier, round int, monster *ArenaMonster, lostEar
// ── Status ──────────────────────────────────────────────────────────────────
func renderArenaStatus(run *ArenaRun, char *AdventureCharacter) string {
func renderArenaStatus(run *ArenaRun) string {
tier := arenaGetTier(run.Tier)
if tier == nil {
return "No active arena run."
@@ -126,21 +126,21 @@ func renderArenaStatus(run *ArenaRun, char *AdventureCharacter) string {
// ── Leaderboard ─────────────────────────────────────────────────────────────
type ArenaLeaderboardEntry struct {
DisplayName string
TotalEarnings int64
HighestTier int
DisplayName string
TotalEarnings int64
HighestTier int
Tier5Completions int
TotalRuns int
TotalDeaths int
TotalRuns int
TotalDeaths int
}
func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
func renderArenaLeaderboard(season string, entries []ArenaLeaderboardEntry) string {
if len(entries) == 0 {
return "⚔️ **Arena Leaderboard**\n\nNo arena runs recorded yet. Be the first."
return fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\nNobody has entered the arena this season. Be the first.", season)
}
var b strings.Builder
b.WriteString("⚔️ **Arena Leaderboard**\n\n")
b.WriteString(fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\n", season))
medals := []string{"🥇", "🥈", "🥉"}
for i, e := range entries {
@@ -157,6 +157,7 @@ func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
b.WriteString(fmt.Sprintf("%s **%s** — €%d earned | %s | %d runs | %d deaths\n",
prefix, e.DisplayName, e.TotalEarnings, tierLabel, e.TotalRuns, e.TotalDeaths))
}
b.WriteString("\n_Season standings. `!arena stats` for your lifetime record._")
return b.String()
}
@@ -171,9 +172,12 @@ type ArenaPersonalStats struct {
Tier5Completions int
}
func renderArenaPersonalStats(char *AdventureCharacter, stats *ArenaPersonalStats) string {
// renderArenaPersonalStats renders the !arena stats DM. Wins/losses come
// from player_meta post-Phase L2 step 5 (the caller is responsible for
// loading them); displayName is sourced from the AdvCharacter.
func renderArenaPersonalStats(displayName string, wins, losses int, stats *ArenaPersonalStats) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⚔️ **%s's Arena Stats**\n\n", char.DisplayName))
b.WriteString(fmt.Sprintf("⚔️ **%s's Arena Stats**\n\n", displayName))
if stats == nil || stats.TotalRuns == 0 {
b.WriteString("No arena runs yet. Type `!arena` to begin.")
@@ -194,7 +198,7 @@ func renderArenaPersonalStats(char *AdventureCharacter, stats *ArenaPersonalStat
b.WriteString(fmt.Sprintf("Tier 5 completions: %d\n", stats.Tier5Completions))
}
b.WriteString(fmt.Sprintf("\nArena W/L: %d/%d", char.ArenaWins, char.ArenaLosses))
b.WriteString(fmt.Sprintf("\nArena W/L: %d/%d", wins, losses))
return b.String()
}

View File

@@ -0,0 +1,235 @@
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Arena seasons ───────────────────────────────────────────────────────────
//
// Quarterly standings (gogobee_engagement_plan.md C4). The plan called for a
// quarterly *reset* of arena_stats; this derives season standings from
// arena_history.created_at instead. Same player-visible effect — the board
// clears every quarter — but lifetime totals survive for `!arena stats`, and
// there is no destructive job that can fire twice or half-way.
//
// Season titles are archived to their own table rather than player_meta.title:
// that column already carries the Survivalist milestone, and a season champion
// overwriting someone's expedition title would silently destroy it.
// arenaSeasonTitleKinds are the two crowns awarded per season.
const (
arenaTitleEarnings = "earnings"
arenaTitleStreak = "streak"
)
// arenaSeasonKey names the quarter containing t, e.g. "2026-Q3".
func arenaSeasonKey(t time.Time) string {
t = t.UTC()
return fmt.Sprintf("%d-Q%d", t.Year(), (int(t.Month())-1)/3+1)
}
// arenaSeasonStart is the first instant of the quarter containing t.
func arenaSeasonStart(t time.Time) time.Time {
t = t.UTC()
firstMonth := time.Month(((int(t.Month())-1)/3)*3 + 1)
return time.Date(t.Year(), firstMonth, 1, 0, 0, 0, 0, time.UTC)
}
// arenaSeasonBounds returns [start, end) for the quarter containing t.
func arenaSeasonBounds(t time.Time) (time.Time, time.Time) {
start := arenaSeasonStart(t)
return start, start.AddDate(0, 3, 0)
}
// previousArenaSeason returns the key and bounds of the quarter before t's.
func previousArenaSeason(t time.Time) (string, time.Time, time.Time) {
prev := arenaSeasonStart(t).AddDate(0, -1, 0) // any instant inside the prior quarter
start, end := arenaSeasonBounds(prev)
return arenaSeasonKey(prev), start, end
}
// loadArenaSeasonLeaderboard aggregates arena_history over [start, end) into
// the same shape the lifetime board renders.
func loadArenaSeasonLeaderboard(start, end time.Time) ([]ArenaLeaderboardEntry, error) {
rows, err := db.Get().Query(`
SELECT h.user_id, COALESCE(c.display_name, h.user_id),
SUM(h.earnings),
MAX(h.tier),
SUM(CASE WHEN h.tier = 5 AND h.outcome = 'completed' THEN 1 ELSE 0 END),
COUNT(*),
SUM(CASE WHEN h.outcome = 'dead' THEN 1 ELSE 0 END)
FROM arena_history h
LEFT JOIN player_meta c ON c.user_id = h.user_id
WHERE h.created_at >= ? AND h.created_at < ?
GROUP BY h.user_id
ORDER BY SUM(h.earnings) DESC
LIMIT 10`, start.Unix(), end.Unix())
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ArenaLeaderboardEntry
for rows.Next() {
var e ArenaLeaderboardEntry
var uid string
if err := rows.Scan(&uid, &e.DisplayName, &e.TotalEarnings, &e.HighestTier,
&e.Tier5Completions, &e.TotalRuns, &e.TotalDeaths); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// arenaSeasonChampion finds the single top row for a season by the given
// metric. Returns ok=false when nobody entered the arena that quarter.
func arenaSeasonChampion(kind string, start, end time.Time) (id.UserID, int64, bool) {
var metric string
switch kind {
case arenaTitleEarnings:
metric = "SUM(earnings)"
case arenaTitleStreak:
metric = "MAX(rounds_survived)"
default:
return "", 0, false
}
// Only runs that earned or survived something can crown anyone: a season of
// nothing but deaths at round zero should award no streak title.
q := fmt.Sprintf(`
SELECT user_id, %s AS metric
FROM arena_history
WHERE created_at >= ? AND created_at < ?
GROUP BY user_id
HAVING metric > 0
ORDER BY metric DESC, user_id ASC
LIMIT 1`, metric)
var uid string
var value int64
err := db.Get().QueryRow(q, start.Unix(), end.Unix()).Scan(&uid, &value)
if err == sql.ErrNoRows {
return "", 0, false
}
if err != nil {
slog.Error("arena season: champion query", "kind", kind, "err", err)
return "", 0, false
}
return id.UserID(uid), value, true
}
// recordArenaSeasonTitle archives a crown. Idempotent on (season, kind).
func recordArenaSeasonTitle(season, kind string, userID id.UserID, value int64, at time.Time) error {
_, err := db.Get().Exec(`
INSERT INTO arena_season_titles (season, kind, user_id, value, awarded_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(season, kind) DO NOTHING`,
season, kind, string(userID), value, at.Unix())
return err
}
// loadArenaSeasonTitles returns every crown a player has ever taken.
func loadArenaSeasonTitles(userID id.UserID) ([]string, error) {
rows, err := db.Get().Query(`
SELECT season, kind FROM arena_season_titles
WHERE user_id = ? ORDER BY season DESC`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var season, kind string
if err := rows.Scan(&season, &kind); err != nil {
return nil, err
}
out = append(out, fmt.Sprintf("%s %s", season, arenaTitleName(kind)))
}
return out, rows.Err()
}
func arenaTitleName(kind string) string {
switch kind {
case arenaTitleEarnings:
return "Coinlord of the Arena"
case arenaTitleStreak:
return "Longest Walk"
}
return kind
}
// ── Rollover ────────────────────────────────────────────────────────────────
// arenaSeasonRollover awards the previous season's crowns exactly once, and
// announces them. Safe to call every midnight: JobCompleted keyed on the season
// makes it a no-op for the rest of the quarter, and a bot that was down on the
// rollover day still catches up the next time it wakes.
func (p *AdventurePlugin) arenaSeasonRollover(now time.Time) {
season, start, end := previousArenaSeason(now)
jobName := "arena_season_rollover"
if db.JobCompleted(jobName, season) {
return
}
// Guard against awarding a season that hasn't finished yet — only possible
// if a caller passes a doctored clock.
if !now.UTC().After(end) {
return
}
var lines []string
failed := false
for _, kind := range []string{arenaTitleEarnings, arenaTitleStreak} {
uid, value, ok := arenaSeasonChampion(kind, start, end)
if !ok {
continue
}
if err := recordArenaSeasonTitle(season, kind, uid, value, now); err != nil {
slog.Error("arena season: record title failed", "season", season, "kind", kind, "err", err)
failed = true
continue // don't announce a crown we failed to persist
}
name, _ := loadDisplayName(uid)
switch kind {
case arenaTitleEarnings:
lines = append(lines, fmt.Sprintf("🥇 **%s** — _%s_ (€%d earned)",
name, arenaTitleName(kind), value))
case arenaTitleStreak:
lines = append(lines, fmt.Sprintf("🏃 **%s** — _%s_ (%d rounds in one run)",
name, arenaTitleName(kind), value))
}
}
// Marking the job done is what stops the next midnight from retrying, so a
// crown we failed to persist must not mark it. recordArenaSeasonTitle is
// idempotent on (season, kind), and a past season's data is frozen, so the
// retry re-derives the same champions and no-ops the ones already stored.
if failed {
slog.Warn("arena season: deferring completion after title failure", "season", season)
return
}
db.MarkJobCompleted(jobName, season)
if len(lines) == 0 {
slog.Info("arena season: closed with no entrants", "season", season)
return
}
slog.Info("arena season: crowned", "season", season, "titles", len(lines))
gr := gamesRoom()
if gr == "" {
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚔️ **Arena Season %s has ended.**\n\n", season))
sb.WriteString(strings.Join(lines, "\n"))
sb.WriteString("\n\nThe board is clear. Season " + arenaSeasonKey(now) + " starts now.")
p.SendMessage(id.RoomID(gr), sb.String())
}

View File

@@ -0,0 +1,187 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func TestArenaSeasonKeyAndBounds(t *testing.T) {
tests := []struct {
when time.Time
key string
wantStart time.Time
}{
{time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
{time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
{time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), "2026-Q2", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)},
{time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC), "2026-Q3", time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)},
{time.Date(2026, 12, 31, 23, 0, 0, 0, time.UTC), "2026-Q4", time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)},
}
for _, tc := range tests {
if got := arenaSeasonKey(tc.when); got != tc.key {
t.Errorf("arenaSeasonKey(%s) = %s, want %s", tc.when, got, tc.key)
}
start, end := arenaSeasonBounds(tc.when)
if !start.Equal(tc.wantStart) {
t.Errorf("season start for %s = %s, want %s", tc.when, start, tc.wantStart)
}
if !end.Equal(start.AddDate(0, 3, 0)) {
t.Errorf("season end for %s is not start+3mo", tc.when)
}
if !tc.when.Before(end) || tc.when.Before(start) {
t.Errorf("%s does not fall inside its own season bounds", tc.when)
}
}
}
// TestPreviousArenaSeasonWrapsYear pins the Q1 → prior-year-Q4 edge.
func TestPreviousArenaSeasonWrapsYear(t *testing.T) {
key, start, end := previousArenaSeason(time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC))
if key != "2025-Q4" {
t.Errorf("previous season = %s, want 2025-Q4", key)
}
if !start.Equal(time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC)) {
t.Errorf("start = %s, want 2025-10-01", start)
}
if !end.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Errorf("end = %s, want 2026-01-01", end)
}
}
func insertArenaRun(t *testing.T, user id.UserID, tier, rounds int, earnings int64, outcome string, at time.Time) {
t.Helper()
_, err := db.Get().Exec(
`INSERT INTO arena_history (user_id, start_tier, tier, rounds_survived, earnings, outcome, monster_name, created_at)
VALUES (?, 1, ?, ?, ?, ?, 'Test Monster', ?)`,
string(user), tier, rounds, earnings, outcome, at.Unix())
if err != nil {
t.Fatal(err)
}
}
// TestArenaSeasonLeaderboardWindowing: only runs inside the season window count.
func TestArenaSeasonLeaderboardWindowing(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
inSeason := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
start, end := arenaSeasonBounds(inSeason)
alice := id.UserID("@alice:test.invalid")
bob := id.UserID("@bob:test.invalid")
insertArenaRun(t, alice, 3, 4, 5000, "completed", inSeason)
insertArenaRun(t, alice, 5, 2, 1000, "dead", inSeason.AddDate(0, 0, 1))
insertArenaRun(t, bob, 2, 3, 9000, "cashed_out", inSeason)
// Last season — must not appear.
insertArenaRun(t, alice, 5, 4, 999999, "completed", start.AddDate(0, 0, -1))
entries, err := loadArenaSeasonLeaderboard(start, end)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("got %d entries, want 2", len(entries))
}
// Bob out-earned Alice this season (9000 vs 6000).
if entries[0].DisplayName != string(bob) {
t.Errorf("top entry = %s, want bob", entries[0].DisplayName)
}
if entries[0].TotalEarnings != 9000 {
t.Errorf("bob earnings = %d, want 9000", entries[0].TotalEarnings)
}
if entries[1].TotalEarnings != 6000 {
t.Errorf("alice earnings = %d, want 6000 (last season's 999999 must not count)", entries[1].TotalEarnings)
}
if entries[1].TotalDeaths != 1 {
t.Errorf("alice deaths = %d, want 1", entries[1].TotalDeaths)
}
if entries[1].HighestTier != 5 {
t.Errorf("alice highest tier = %d, want 5", entries[1].HighestTier)
}
}
// TestArenaSeasonChampions: earnings crown goes by total, streak crown by the
// single longest run — they can be different players.
func TestArenaSeasonChampions(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
when := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
start, end := arenaSeasonBounds(when)
rich := id.UserID("@rich:test.invalid")
tough := id.UserID("@tough:test.invalid")
insertArenaRun(t, rich, 5, 2, 50000, "cashed_out", when)
insertArenaRun(t, tough, 1, 12, 500, "completed", when)
uid, val, ok := arenaSeasonChampion(arenaTitleEarnings, start, end)
if !ok || uid != rich || val != 50000 {
t.Errorf("earnings champion = (%s, %d, %v), want rich/50000", uid, val, ok)
}
uid, val, ok = arenaSeasonChampion(arenaTitleStreak, start, end)
if !ok || uid != tough || val != 12 {
t.Errorf("streak champion = (%s, %d, %v), want tough/12", uid, val, ok)
}
// An empty season crowns nobody.
emptyStart, emptyEnd := arenaSeasonBounds(when.AddDate(1, 0, 0))
if _, _, ok := arenaSeasonChampion(arenaTitleEarnings, emptyStart, emptyEnd); ok {
t.Error("an empty season produced an earnings champion")
}
// A season of nothing but round-zero deaths crowns no streak.
deathsOnly := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)
dStart, dEnd := arenaSeasonBounds(deathsOnly)
insertArenaRun(t, tough, 1, 0, 0, "dead", deathsOnly)
if _, _, ok := arenaSeasonChampion(arenaTitleStreak, dStart, dEnd); ok {
t.Error("a season of round-zero deaths produced a streak champion")
}
}
// TestRecordArenaSeasonTitleIdempotent: the rollover must be safe to re-run.
func TestRecordArenaSeasonTitleIdempotent(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
winner := id.UserID("@winner:test.invalid")
usurper := id.UserID("@usurper:test.invalid")
if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, winner, 100, now); err != nil {
t.Fatal(err)
}
// A second write for the same (season, kind) must not overwrite the crown.
if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, usurper, 999, now); err != nil {
t.Fatal(err)
}
titles, err := loadArenaSeasonTitles(winner)
if err != nil {
t.Fatal(err)
}
if len(titles) != 1 {
t.Fatalf("winner has %d titles, want 1", len(titles))
}
stolen, _ := loadArenaSeasonTitles(usurper)
if len(stolen) != 0 {
t.Errorf("usurper took the crown: %v", stolen)
}
}

View File

@@ -1,7 +1,6 @@
package plugin
import (
"math"
"strings"
"testing"
"time"
@@ -107,96 +106,6 @@ func TestArenaMinLevel_Increasing(t *testing.T) {
}
}
// ── Death Chance Formula Tests ──────────────────────────────────────────────
func TestArenaDeathChance_Clamped(t *testing.T) {
// Min-level player with tier 0 gear vs easiest monster
char := &AdventureCharacter{CombatLevel: 1}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
monster := arenaGetMonster(1, 1)
dc := arenaDeathChance(monster, char, equip)
if dc < 0.01 || dc > 0.98 {
t.Errorf("death chance %f out of [0.01, 0.98] bounds", dc)
}
}
func TestArenaDeathChance_MaxGearReduces(t *testing.T) {
// Use a mid-tier monster where gear difference is visible above the floor
char := &AdventureCharacter{CombatLevel: 25}
monster := arenaGetMonster(3, 3) // Behemoth Adjacent (0.73 lethality)
noGear := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
maxGear := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
dcNoGear := arenaDeathChance(monster, char, noGear)
dcMaxGear := arenaDeathChance(monster, char, maxGear)
if dcMaxGear >= dcNoGear {
t.Errorf("max gear (%f) should reduce death chance vs no gear (%f)", dcMaxGear, dcNoGear)
}
}
func TestArenaDeathChance_HighLevelReduces(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 3}, SlotArmor: {Tier: 3}, SlotHelmet: {Tier: 3},
SlotBoots: {Tier: 3}, SlotTool: {Tier: 3},
}
monster := arenaGetMonster(3, 4) // The Inevitable
lowLevel := &AdventureCharacter{CombatLevel: 25}
highLevel := &AdventureCharacter{CombatLevel: 50}
dcLow := arenaDeathChance(monster, lowLevel, equip)
dcHigh := arenaDeathChance(monster, highLevel, equip)
if dcHigh >= dcLow {
t.Errorf("high level (%f) should have lower death chance than low level (%f)", dcHigh, dcLow)
}
}
func TestArenaDeathChance_T5R4_AlwaysTerrifying(t *testing.T) {
// Even max-everything player faces high death at T5R4
char := &AdventureCharacter{CombatLevel: 50}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
monster := arenaGetMonster(5, 4) // That Which Has Always Been
dc := arenaDeathChance(monster, char, equip)
if dc < 0.30 {
t.Errorf("T5R4 death chance for max player (%f) should be >= 0.30", dc)
}
// Should hit the 0.98 ceiling
if dc > 0.98 {
t.Errorf("T5R4 death chance (%f) exceeds ceiling 0.98", dc)
}
}
func TestArenaDeathChance_Floor(t *testing.T) {
// Even with absurd stats, floor is 0.01
char := &AdventureCharacter{CombatLevel: 50}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
monster := &ArenaMonster{BaseLethality: 0.01, ThreatLevel: 1}
dc := arenaDeathChance(monster, char, equip)
if dc < 0.01 {
t.Errorf("death chance %f below floor 0.01", dc)
}
}
// ── Reward Formula Tests ────────────────────────────────────────────────────
func TestArenaRoundReward(t *testing.T) {
@@ -243,38 +152,11 @@ func TestArenaRewardScaling(t *testing.T) {
}
}
// ── Death Chance Formula Component Tests ────────────────────────────────────
func TestArenaDeathChance_Components(t *testing.T) {
// Verify formula components individually
// death_chance = base + level_mod - equip_mod + skill_mod
// Level 1, tier 0 gear, T1R1 monster (lethality=0.10, threat=2)
char := &AdventureCharacter{CombatLevel: 1}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
monster := &ArenaMonster{BaseLethality: 0.10, ThreatLevel: 2}
dc := arenaDeathChance(monster, char, equip)
// Manual calculation:
// base = 0.10
// level_mod = (2-1) * 0.015 = 0.015
// skill_mod = max(0, 0.25 - 1*0.008) = 0.242
// equip_mod = 0 * 0.03 = 0 (tier 0 gives no equipment bonus)
// death_chance = 0.10 + 0.015 - 0 + 0.242 = 0.357
expected := 0.10 + 0.015 + 0.242
if math.Abs(dc-expected) > 0.001 {
t.Errorf("T1R1 components: got %f, expected ~%f", dc, expected)
}
}
// ── Render Tests ────────────────────────────────────────────────────────────
func TestRenderArenaStreakEntry(t *testing.T) {
char := &AdventureCharacter{CombatLevel: 30}
// Level 5 unlocks T1+T2 only (post-L2 brackets: 1/4/8/13/18); T3-T5 locked.
char := &DnDCharacter{Level: 5}
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
@@ -576,7 +458,7 @@ func TestArenaGearDropRates_Decreasing(t *testing.T) {
// ── Render Tests (New/Updated) ─────────────────────────────────────────────
func TestRenderArenaStreakEntry_ShowsMultipliers(t *testing.T) {
char := &AdventureCharacter{CombatLevel: 50}
char := &DnDCharacter{Level: 20}
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
@@ -603,15 +485,15 @@ func TestRenderArenaStreakEntry_ShowsMultipliers(t *testing.T) {
}
func TestRenderArenaStreakEntry_HighLevel_AllEligible(t *testing.T) {
// T5 requires level 70, so use level 70+ to unlock everything
char := &AdventureCharacter{CombatLevel: 70}
// T5 requires level 18 (post-L2 D&D scale); level 20 unlocks everything.
char := &DnDCharacter{Level: 20}
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
text := renderArenaStreakEntry(char, nil, tier, monster)
// Level 70 should make all tiers eligible (no 🔒)
// Level 20 (D&D cap) should make all tiers eligible (no 🔒)
if strings.Contains(text, "🔒") {
t.Error("level 70 should have all tiers eligible (no locked icons)")
t.Error("level 20 should have all tiers eligible (no locked icons)")
}
}
@@ -641,9 +523,8 @@ func TestRenderArenaRoundStart_ZeroEarnings(t *testing.T) {
func TestRenderArenaStatus_Active(t *testing.T) {
run := &ArenaRun{Tier: 2, Round: 3, Status: "active", Earnings: 8000, TierEarnings: 1500, RoundsSurvived: 6}
char := &AdventureCharacter{CombatLevel: 20}
text := renderArenaStatus(run, char)
text := renderArenaStatus(run)
if !strings.Contains(text, "Streak") {
t.Error("status should say Arena Streak")
}
@@ -660,9 +541,8 @@ func TestRenderArenaStatus_Active(t *testing.T) {
func TestRenderArenaStatus_Awaiting(t *testing.T) {
run := &ArenaRun{Tier: 3, Status: "awaiting", Earnings: 20000, TierEarnings: 0, RoundsSurvived: 12}
char := &AdventureCharacter{CombatLevel: 30}
text := renderArenaStatus(run, char)
text := renderArenaStatus(run)
if !strings.Contains(text, "advancing shortly") {
t.Error("awaiting status should mention advancing")
}
@@ -730,20 +610,18 @@ func TestRenderArenaDeathReprieve(t *testing.T) {
}
func TestRenderArenaPersonalStats_NoStats(t *testing.T) {
char := &AdventureCharacter{DisplayName: "TestPlayer"}
text := renderArenaPersonalStats(char, nil)
text := renderArenaPersonalStats("TestPlayer", 0, 0, nil)
if !strings.Contains(text, "No arena runs") {
t.Error("empty stats should say no runs")
}
}
func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
char := &AdventureCharacter{DisplayName: "Alice", ArenaWins: 8, ArenaLosses: 3}
stats := &ArenaPersonalStats{
TotalRuns: 11, TotalEarnings: 150000, TotalDeaths: 3,
HighestTier: 5, Tier5Completions: 2,
}
text := renderArenaPersonalStats(char, stats)
text := renderArenaPersonalStats("Alice", 8, 3, stats)
if !strings.Contains(text, "Alice") {
t.Error("stats should show player name")
}
@@ -759,9 +637,9 @@ func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
}
func TestRenderArenaLeaderboard_Empty(t *testing.T) {
text := renderArenaLeaderboard(nil)
if !strings.Contains(text, "No arena runs") {
t.Error("empty leaderboard should say no runs")
text := renderArenaLeaderboard("2026-Q3", nil)
if !strings.Contains(text, "Nobody has entered") {
t.Error("empty leaderboard should say nobody entered")
}
}
@@ -770,7 +648,7 @@ func TestRenderArenaLeaderboard_WithEntries(t *testing.T) {
{DisplayName: "Alice", TotalEarnings: 100000, HighestTier: 5, Tier5Completions: 1, TotalRuns: 5, TotalDeaths: 2},
{DisplayName: "Bob", TotalEarnings: 50000, HighestTier: 3, TotalRuns: 10, TotalDeaths: 7},
}
text := renderArenaLeaderboard(entries)
text := renderArenaLeaderboard("2026-Q3", entries)
if !strings.Contains(text, "Alice") {
t.Error("leaderboard should contain Alice")
}
@@ -784,20 +662,19 @@ func TestRenderArenaLeaderboard_WithEntries(t *testing.T) {
func TestRenderArenaLevelGate(t *testing.T) {
tier := arenaGetTier(3)
text := renderArenaLevelGate(tier, 10)
if !strings.Contains(text, "Level 25") {
text := renderArenaLevelGate(tier, 5)
if !strings.Contains(text, "Level 8") {
t.Error("gate message should show required level")
}
if !strings.Contains(text, "Level 10") {
if !strings.Contains(text, "Level 5") {
t.Error("gate message should show player level")
}
}
func TestRenderArenaStatus(t *testing.T) {
run := &ArenaRun{Tier: 3, Round: 2, Status: "active", Earnings: 5000, RoundsSurvived: 5}
char := &AdventureCharacter{CombatLevel: 30}
text := renderArenaStatus(run, char)
text := renderArenaStatus(run)
if !strings.Contains(text, "Tier: 3") {
t.Error("status should show tier")
}
@@ -876,7 +753,7 @@ func TestArenaStreakSimulation_FullRun(t *testing.T) {
TierEarnings: 0,
XPAccumulated: 0,
}
combatLevel := 30
skillBonus := 20
for tierNum := 1; tierNum <= 5; tierNum++ {
tier := arenaGetTier(tierNum)
@@ -884,7 +761,7 @@ func TestArenaStreakSimulation_FullRun(t *testing.T) {
for round := 1; round <= 4; round++ {
run.Round = round
reward := arenaRoundReward(tier, round, combatLevel)
reward := arenaRoundReward(tier, round, skillBonus)
run.TierEarnings += reward
run.XPAccumulated += tier.BattleXP
run.RoundsSurvived++
@@ -914,7 +791,7 @@ func TestArenaStreakSimulation_FullRun(t *testing.T) {
for tierNum := 1; tierNum <= 5; tierNum++ {
tier := arenaGetTier(tierNum)
for round := 1; round <= 4; round++ {
rawTotal += arenaRoundReward(tier, round, combatLevel)
rawTotal += arenaRoundReward(tier, round, skillBonus)
}
rawTotal += tier.CompletionBonus
}
@@ -1056,7 +933,7 @@ func TestArenaStreakEuroMultiplier_Exact(t *testing.T) {
// ── Streak Entry Shows Brim & Battle Sponsorship ──────────────────────────
func TestArenaStreakEntry_Sponsorship(t *testing.T) {
char := &AdventureCharacter{CombatLevel: 1}
char := &DnDCharacter{Level: 1}
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)

View File

@@ -3,7 +3,6 @@ package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
@@ -14,48 +13,64 @@ import (
// ── Pricing ─────────────────────────────────────────────────────────────────
func babysitDailyCost(combatLevel int) int {
return 100 + (combatLevel * 20)
// babysitDailyCost returns the daily babysit subscription cost in €.
// Phase L (post-L5g): keyed off D&D Level instead of legacy CombatLevel.
// The slope is 5× the old per-level slope to preserve the curve shape across
// the 5:1 compression in dndLevelFromCombatLevel — Level 4 (~old CL 20) =
// €500/day, Level 10 (~old CL 50) = €1100/day, matching pre-migration pricing.
func babysitDailyCost(level int) int {
return 100 + (level * 100)
}
// ── Weakest Skill ───────────────────────────────────────────────────────────
// ── Pet-care daily trickle ─────────────────────────────────────────────────
func babysitWeakestSkill(char *AdventureCharacter) string {
skills := []struct {
name string
level int
}{
{"mining", char.MiningSkill},
{"fishing", char.FishingSkill},
{"foraging", char.ForagingSkill},
// petXPPerBabysitDay is the daily pet XP awarded while a babysit subscription
// is active. Picked to be a meaningful but not overwhelming push toward L10:
// roughly equivalent to a couple of player-driven actions per day.
const petXPPerBabysitDay = 3
// runBabysitDailyTrickle grants daily pet XP while a babysit subscription is
// active and logs an entry for the end-of-service summary. Caller is
// responsible for saving the character afterwards.
func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
if !char.BabysitActive {
return
}
minLevel := skills[0].level
for _, s := range skills[1:] {
if s.level < minLevel {
minLevel = s.level
}
// Both companions share the sitter's attention and gain the flat trickle.
// (Combat only ever reads their *averaged* procs, so leveling both is not a
// power spike.)
leveled := false
if char.HasPet() {
leveled = advancePetLevelsFromXP(&char.PetXP, &char.PetLevel, &char.PetLevel10Date, petXPPerBabysitDay*100) || leveled
}
// Collect ties
var tied []string
for _, s := range skills {
if s.level == minLevel {
tied = append(tied, s.name)
}
if char.HasPet2() {
leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled
}
return tied[rand.IntN(len(tied))]
outcome := "pet_care"
if leveled {
outcome = "pet_care_levelup"
}
logBabysitActivity(char.UserID, "pet_care", outcome, 0, petXPPerBabysitDay, "")
}
// skillToActivity maps a skill name to its activity type.
func skillToActivity(skill string) AdvActivityType {
switch skill {
case "mining":
return AdvActivityMining
case "fishing":
return AdvActivityFishing
case "foraging":
return AdvActivityForaging
// BabysitSafeRest reports whether the user has an active babysit subscription
// that should let standard camps qualify for fortified-tier rest perks.
// Returns false on any error (treat as no babysit). Safe to call from tests
// where the global DB has not been initialized — the panic is swallowed.
func BabysitSafeRest(userID id.UserID) (active bool) {
defer func() {
if r := recover(); r != nil {
active = false
}
}()
char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.BabysitActive {
return false
}
return AdvActivityMining
if char.BabysitExpiresAt != nil && time.Now().UTC().After(*char.BabysitExpiresAt) {
return false
}
return true
}
// ── Command Handlers ────────────────────────────────────────────────────────
@@ -72,41 +87,19 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
return p.handleBabysitPurchase(ctx, 7)
case lower == "month":
return p.handleBabysitPurchase(ctx, 30)
case lower == "auto":
return p.handleBabysitAutoToggle(ctx)
default:
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
" • Standard camps act like fortified ones — rest deeply, no need to have downed the zone boss\n"+
" • Rival duels declined on your behalf\n\n"+
"`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+
"`!adventure babysit auto` — toggle auto-babysit on missed days\n"+
"`!adventure babysit status` — check service status\n"+
"`!adventure babysit cancel` — cancel early (no refund)")
}
}
func (p *AdventurePlugin) handleBabysitAutoToggle(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
}
char.AutoBabysit = !char.AutoBabysit
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save auto-babysit toggle", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
}
daily := babysitDailyCost(char.CombatLevel)
if char.AutoBabysit {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 **Auto-babysit: ON**\n\nIf you miss a day, the babysitter steps in automatically (€%d/day). Your streak stays alive. Disable anytime with `!adventure babysit auto`.", daily))
}
return p.SendDM(ctx.Sender, "🍼 **Auto-babysit: OFF**\n\nYou're on your own. Miss a day and your streak takes the hit.")
}
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
@@ -125,34 +118,32 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
}
daily := babysitDailyCost(char.CombatLevel)
daily := babysitDailyCost(dndLevelForUser(char.UserID))
totalCost := daily * days
balance := p.euro.GetBalance(char.UserID)
if balance < float64(totalCost) {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance)))
}
// Debit gold
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
}
// Clear any leftover logs from prior service so this period's summary is clean
clearBabysitLogs(char.UserID)
// Set babysit fields
skill := babysitWeakestSkill(char)
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
char.BabysitActive = true
char.BabysitExpiresAt = &expires
char.BabysitSkillFocus = skill
char.BabysitSkillFocus = "" // legacy field; no longer used
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
// Refund
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
}
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
}
confirm := pickBabysitFlavor(babysitConfirmLines)
durLabel := "1 week"
@@ -160,13 +151,18 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
durLabel = "1 month"
}
petLine := "No pet to tend yet — the babysitter will keep that in mind."
if char.HasPet() {
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
}
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
"Duration: %s (%d days)\n"+
"Cost: €%d\n"+
"Focus: %s (currently level %d)\n"+
"%s\n"+
"Camp safety: standard camps now rest like fortified ones\n"+
"Rival duels: declined on your behalf\n\n"+
"Daily DMs are suspended until the service ends.\n\n"+
"_%s_", durLabel, days, totalCost, titleCase(skill), babysitSkillLevel(char, skill), confirm)
"_%s_", durLabel, days, totalCost, petLine, confirm)
return p.SendDM(ctx.Sender, text)
}
@@ -177,13 +173,8 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
autoLabel := "OFF"
if char.AutoBabysit {
autoLabel = "ON"
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 No active babysitting service.\nAuto-babysit: **%s** (`!adventure babysit auto` to toggle)\n\nUse `!adventure babysit week` or `!adventure babysit month` to start.", autoLabel))
return p.SendDM(ctx.Sender, "🍼 No active babysitting service.\n\nUse `!adventure babysit week` or `!adventure babysit month` to start. The babysitter tends your pet daily and lets you rest deeply at standard camps.")
}
remaining := "unknown"
@@ -196,23 +187,24 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
}
}
// Load log stats
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
totalXP, petDays, rivalsRefused := babysitLogStats(logs)
petLine := "No pet to tend"
if char.HasPet() {
petLine = fmt.Sprintf("%s (L%d)", char.PetName, char.PetLevel)
}
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
"Time remaining: %s\n"+
"Skill focus: %s\n"+
"Days completed: %d\n"+
"Gold earned: %d\n"+
"XP gained: %d\n"+
"Items claimed by babysitter: %d\n"+
"Rivals declined: %d\n"+
"Auto-babysit: %s",
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel)
"Pet under care: %s\n"+
"Days of pet care given: %d\n"+
"Pet XP trickled: %d\n"+
"Rivals declined: %d",
remaining, petLine, petDays, totalXP, rivalsRefused)
return p.SendDM(ctx.Sender, text)
}
@@ -231,164 +223,25 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
}
// Compile partial summary
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(char, logs)
// Clear babysit state
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err)
}
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err)
}
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
}
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
return
}
isHol, _ := isHolidayToday()
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
bonuses := &AdvBonusSummary{}
focusActivity := skillToActivity(char.BabysitSkillFocus)
var totalGold int
var totalXP int
var allItems []string
// Use all harvest actions on the focused skill — no combat, too dangerous
for char.HarvestActionsUsed < harvestMax {
gold, xp, items := p.runBabysitAction(char, equip, focusActivity, bonuses)
totalGold += gold
totalXP += xp
allItems = append(allItems, items...)
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err)
}
// Log combined daily totals
itemsJSON := ""
if len(allItems) > 0 {
itemsJSON = strings.Join(allItems, ", ")
}
logBabysitActivity(char.UserID, string(focusActivity), "babysit_daily",
totalGold, totalXP, itemsJSON)
}
// runBabysitAction resolves a single action for the babysitter. Returns gold, xp, item names.
func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) (int, int, []string) {
eligible := advEligibleLocations(char, equip, activity, bonuses)
if len(eligible) == 0 {
return 0, 0, nil
}
loc := eligible[len(eligible)-1].Location
inPenalty := eligible[len(eligible)-1].InPenaltyZone
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
// Babysitter never lets the adventurer die
if result.Outcome == AdvOutcomeDeath {
result.Outcome = AdvOutcomeEmpty
result.LootItems = nil
result.TotalLootValue = 0
result.EquipDamage = nil
result.EquipBroken = nil
}
// Double XP/money boost
advApplyBoost(result)
// Apply XP
switch result.XPSkill {
case "combat":
char.CombatXP += result.XPGained
case "mining":
char.MiningXP += result.XPGained
case "foraging":
char.ForagingXP += result.XPGained
case "fishing":
char.FishingXP += result.XPGained
}
checkAdvLevelUp(char, result.XPSkill)
// Credit gold
if result.TotalLootValue > 0 {
net, _ := communityTax(char.UserID, float64(result.TotalLootValue), 0.05)
p.euro.Credit(char.UserID, net, "babysit_haul")
}
// No treasure drops during babysitting
result.TreasureFound = nil
var items []string
for _, item := range result.LootItems {
items = append(items, item.Name)
}
return int(result.TotalLootValue), result.XPGained, items
}
// runAutoBabysitDay runs a single day of babysit actions for auto-babysit.
// Called by the scheduler when a player with auto-babysit enabled misses a day.
func (p *AdventurePlugin) runAutoBabysitDay(char *AdventureCharacter) {
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("auto-babysit: failed to load equipment", "user", char.UserID, "err", err)
return
}
isHol, _ := isHolidayToday()
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
bonuses := &AdvBonusSummary{}
skill := babysitWeakestSkill(char)
activity := skillToActivity(skill)
var totalGold int
var totalXP int
var allItems []string
for char.HarvestActionsUsed < harvestMax {
gold, xp, items := p.runBabysitAction(char, equip, activity, bonuses)
totalGold += gold
totalXP += xp
allItems = append(allItems, items...)
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
itemsJSON := ""
if len(allItems) > 0 {
itemsJSON = strings.Join(allItems, ", ")
}
logBabysitActivity(char.UserID, string(activity), "auto_babysit_daily",
totalGold, totalXP, itemsJSON)
}
// ── Expiry Check ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
@@ -401,7 +254,6 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
continue
}
// Service expired — compile summary and send DM
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
@@ -415,6 +267,9 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
slog.Error("babysit: failed to save character on expiry", "user", char.UserID, "err", err)
continue
}
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(&char)); err != nil {
slog.Error("player_meta: babysit expiry dual-write failed", "user", char.UserID, "err", err)
}
if err := p.SendDM(char.UserID, summary); err != nil {
slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err)
@@ -425,20 +280,20 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
// ── Summary Rendering ───────────────────────────────────────────────────────
func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string {
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
totalXP, petDays, rivalsRefused := babysitLogStats(logs)
var sb strings.Builder
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
sb.WriteString(fmt.Sprintf("Duration: %d days\n", len(logs)))
sb.WriteString(fmt.Sprintf("Tasks completed: %d\n", len(logs)))
sb.WriteString(fmt.Sprintf("Skill focused: %s\n", titleCase(char.BabysitSkillFocus)))
sb.WriteString(fmt.Sprintf("Gold earned from hauls: €%d\n", totalGold))
sb.WriteString(fmt.Sprintf("XP gained: %d\n", totalXP))
sb.WriteString(fmt.Sprintf("Items dropped: %d items. Claimed by the babysitter as per the terms.\n", itemsClaimed))
sb.WriteString(fmt.Sprintf("Days of service: %d\n", petDays))
if char.HasPet() {
sb.WriteString(fmt.Sprintf("Pet looked after: %s (L%d)\n", char.PetName, char.PetLevel))
} else {
sb.WriteString("Pet looked after: none — the babysitter played solitaire.\n")
}
sb.WriteString(fmt.Sprintf("Pet XP trickled: %d\n", totalXP))
if rivalsRefused > 0 {
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
// Pick a rival refusal flavor (generic — no specific rival name available)
for _, log := range logs {
if log.RivalRefused != "" {
line := pickBabysitFlavor(babysitRivalRefusalLines)
@@ -447,11 +302,8 @@ func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) stri
}
}
// Diaper line
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
// Closing
sb.WriteString(fmt.Sprintf("\n\nYour adventurer is fed, rested, and slightly better at %s.", char.BabysitSkillFocus))
sb.WriteString("\n\nYour adventurer is fed and rested. The pet is suspiciously well-trained.")
return sb.String()
}
@@ -459,15 +311,15 @@ func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) stri
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
type babysitLogEntry struct {
ID int64
UserID id.UserID
LogDate string
Activity string
Outcome string
GoldEarned int
XPGained int
ItemsDropped string
RivalRefused string
ID int64
UserID id.UserID
LogDate string
Activity string
Outcome string
GoldEarned int
XPGained int
ItemsDropped string
RivalRefused string
}
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {
@@ -519,27 +371,13 @@ func clearBabysitLogs(userID id.UserID) {
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
// ── Stats helper ────────────────────────────────────────────────────────────
func babysitSkillLevel(char *AdventureCharacter, skill string) int {
switch skill {
case "mining":
return char.MiningSkill
case "fishing":
return char.FishingSkill
case "foraging":
return char.ForagingSkill
}
return 0
}
func babysitLogStats(logs []babysitLogEntry) (totalGold, totalXP, itemsClaimed, rivalsRefused int) {
func babysitLogStats(logs []babysitLogEntry) (totalXP, petDays, rivalsRefused int) {
for _, l := range logs {
totalGold += l.GoldEarned
totalXP += l.XPGained
if l.ItemsDropped != "" {
// Count comma-separated items
itemsClaimed += len(strings.Split(l.ItemsDropped, ", "))
if l.Activity == "pet_care" {
petDays++
}
if l.RivalRefused != "" {
rivalsRefused++

View File

@@ -0,0 +1,108 @@
package plugin
import (
"testing"
"time"
"maunium.net/go/mautrix/id"
)
// L1: babysit pivot from harvest to pet-care + safe-rest.
// These tests cover the pure pieces — DB-touching paths exercise via
// integration only.
func TestBabysitSafeRest_NoDB_ReturnsFalse(t *testing.T) {
// Tests run without db.Init(); BabysitSafeRest must recover and
// return false rather than panicking.
if BabysitSafeRest(id.UserID("@nodb:example")) {
t.Errorf("expected false for un-initialized DB")
}
}
func TestRunBabysitDailyTrickle_NoPetGrantsNothing(t *testing.T) {
// Without a pet, the trickle is a logging-only no-op for character
// state. Without DB, the log call panics inside db.Get; recover and
// assert that the in-memory char fields stay clean.
defer func() { _ = recover() }()
expires := time.Now().UTC().Add(7 * 24 * time.Hour)
char := &AdventureCharacter{
UserID: id.UserID("@nopet:example"),
BabysitActive: true,
BabysitExpiresAt: &expires,
}
p := &AdventurePlugin{}
p.runBabysitDailyTrickle(char)
if char.PetXP != 0 {
t.Errorf("char without pet should not gain PetXP, got %d", char.PetXP)
}
if char.PetLevel != 0 {
t.Errorf("char without pet should not change PetLevel, got %d", char.PetLevel)
}
}
func TestRunBabysitDailyTrickle_SkipsWhenInactive(t *testing.T) {
defer func() { _ = recover() }()
char := &AdventureCharacter{
UserID: id.UserID("@inactive:example"),
BabysitActive: false,
PetType: "dog",
PetName: "Rex",
PetLevel: 1,
PetXP: 0,
}
p := &AdventurePlugin{}
p.runBabysitDailyTrickle(char)
if char.PetXP != 0 {
t.Errorf("inactive babysit should grant no XP, got %d", char.PetXP)
}
}
func TestRunBabysitDailyTrickle_AccumulatesAndLevels(t *testing.T) {
// Pet at L9 with 49 XP needed → 1 day at +3 trickle should *not* level
// (49→52 in centi-XP / 100 = no, wait: petXPToNextLevel at L9 = 50 ×
// 100 = 5000 centi-XP). Verify we accumulate centi-XP correctly across
// multiple daily ticks and eventually level up.
defer func() { _ = recover() }()
char := &AdventureCharacter{
UserID: id.UserID("@accum:example"),
PetType: "dog",
PetName: "Rex",
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
PetXP: 900,
BabysitActive: true,
}
expires := time.Now().UTC().Add(48 * time.Hour)
char.BabysitExpiresAt = &expires
p := &AdventurePlugin{}
// One trickle adds 3*100 = 300 centi-XP. 900 + 300 = 1200 >= 1000 →
// level to 2, carry 200 centi-XP.
p.runBabysitDailyTrickle(char)
if char.PetLevel != 2 {
t.Errorf("expected level 2 after trickle, got %d (xp=%d)", char.PetLevel, char.PetXP)
}
if char.PetXP != 200 {
t.Errorf("expected 200 carryover centi-XP, got %d", char.PetXP)
}
}
func TestBabysitLogStats_CountsPetCareDays(t *testing.T) {
logs := []babysitLogEntry{
{Activity: "pet_care", XPGained: 3},
{Activity: "pet_care", XPGained: 3},
{Activity: "pet_care", XPGained: 3, RivalRefused: ""},
{Activity: "rival_refused", RivalRefused: "Garth"},
}
xp, days, rivals := babysitLogStats(logs)
if days != 3 {
t.Errorf("petDays = %d, want 3", days)
}
if xp != 9 {
t.Errorf("totalXP = %d, want 9", xp)
}
if rivals != 1 {
t.Errorf("rivalsRefused = %d, want 1", rivals)
}
}

View File

@@ -2,6 +2,8 @@ package plugin
import (
"database/sql"
"errors"
"strings"
"time"
"gogobee/internal/db"
@@ -25,32 +27,32 @@ var allSlots = []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots, Slo
// ── Core Types ───────────────────────────────────────────────────────────────
type AdventureCharacter struct {
UserID id.UserID
DisplayName string
CombatLevel int
MiningSkill int
ForagingSkill int
FishingSkill int // v2
CombatXP int
MiningXP int
ForagingXP int
FishingXP int // v2
Alive bool
DeadUntil *time.Time
ActionTakenToday bool
HolidayActionTaken bool
CombatActionsUsed int
HarvestActionsUsed int
ArenaWins int // v2
ArenaLosses int // v2
InvasionScore int // v2
Title string // v2
CurrentStreak int
BestStreak int
LastActionDate string
GrudgeLocation string
CreatedAt time.Time
LastActiveAt time.Time
UserID id.UserID
DisplayName string
CombatLevel int
MiningSkill int
ForagingSkill int
FishingSkill int // v2
CombatXP int
MiningXP int
ForagingXP int
FishingXP int // v2
Alive bool
DeadUntil *time.Time
ActionTakenToday bool
HolidayActionTaken bool
CombatActionsUsed int
HarvestActionsUsed int
ArenaWins int // v2
ArenaLosses int // v2
InvasionScore int // v2
Title string // v2
CurrentStreak int
BestStreak int
LastActionDate string
GrudgeLocation string
CreatedAt time.Time
LastActiveAt time.Time
DeathReprieveLast *time.Time
MasterworkDropsReceived int
RivalPool int
@@ -79,23 +81,58 @@ type AdventureCharacter struct {
HouseAutopay bool
HouseCurrentRate float64
// Pets
PetType string
PetName string
PetXP int
PetLevel int
PetArmorTier int
PetChasedAway bool
PetReactivated bool
PetArrived bool
MistyEncounterCount int
MistyDonatedCount int
ThomAnimalLineFired bool
PetType string
PetName string
PetXP int
PetLevel int
PetArmorTier int
PetChasedAway bool
PetReactivated bool
PetArrived bool
MistyEncounterCount int
MistyDonatedCount int
ThomAnimalLineFired bool
PetSupplyShopUnlocked bool
PetLevel10Date string
PetMorningDefense bool
AutoBabysit bool
StreakDecayed bool
CraftsSucceeded int
PetLevel10Date string
PetMorningDefense bool
// Second pet (N4/E1, Tier-4 Estate). A parallel slot, not a rewrite of the
// pet-1 fields above — see HasPet2. Carries only identity/level/barding; the
// morning-defense, ditch-recovery and supply-shop mechanics stay pet-1-only.
Pet2Type string
Pet2Name string
Pet2XP int
Pet2Level int
Pet2ArmorTier int
Pet2ChasedAway bool
Pet2Reactivated bool
Pet2Arrived bool
Pet2Level10Date string
AutoBabysit bool
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
StreakDecayed bool
CraftsSucceeded int
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
DeathLocation string // human-readable location of last death; cleared on revive is not required
// N5/D1 the Hollow King campaign. Bitmask of discovered journal pages (bit i
// == page i+1). Read-only overlay from player_meta.journal_pages; writes go
// through the atomic grantJournalPageDB, never the bulk character save.
JournalPages int64
// N5/D1c the finale reward-once flag. True after the first finale clear;
// overlay-read, written by the atomic markEpilogueClearedDB.
EpilogueCleared bool
// N7/B2 Renown — cumulative overflow XP earned past the L20 cap. Overlay-read
// from player_meta.renown_xp, written by the atomic addRenownXP, never the
// bulk character save. RenownLevel() derives the prestige level from it.
RenownXP int
}
// RenownLevel is the derived prestige level (renown_xp / renownXPPerLevel).
func (c *AdventureCharacter) RenownLevel() int {
if c == nil {
return 0
}
return renownLevelFor(c.RenownXP)
}
type AdvEquipment struct {
@@ -118,6 +155,7 @@ type AdvItem struct {
Value int64
Slot EquipmentSlot // non-empty for MasterworkGear
SkillSource string // non-empty for MasterworkGear
Temper int // rarity steps above base; magic_item rows only
}
type AdvBuff struct {
@@ -249,12 +287,16 @@ func (c *AdventureCharacter) PardonAvailable() bool {
return time.Since(*c.LastPardonUsed) >= 168*time.Hour
}
// Kill marks the character as dead with a 6-hour respawn timer.
func (c *AdventureCharacter) Kill() {
// Kill marks the character as dead with a 6-hour respawn timer. source is
// "adventure" or "arena"; location is a human-readable place name used by the
// daily report and standout-loss flavor.
func (c *AdventureCharacter) Kill(source, location string) {
c.Alive = false
deadUntil := time.Now().UTC().Add(6 * time.Hour)
c.DeadUntil = &deadUntil
c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
c.DeathSource = source
c.DeathLocation = location
}
// HasPet returns true if the player has an active pet (not chased away).
@@ -262,6 +304,11 @@ func (c *AdventureCharacter) HasPet() bool {
return c.PetType != "" && c.PetArrived && !c.PetChasedAway
}
// HasPet2 returns true if the player has an active second pet.
func (c *AdventureCharacter) HasPet2() bool {
return c.Pet2Type != "" && c.Pet2Arrived && !c.Pet2ChasedAway
}
// HasHouse returns true if the player has purchased at least a base house.
func (c *AdventureCharacter) HasHouse() bool {
return c.HouseTier > 0 || c.HouseLoanBalance > 0
@@ -304,7 +351,30 @@ func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
}
func (c *AdventureCharacter) HasActedToday() bool {
return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0
if c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 {
return true
}
// DnD-side flows (expedition / zone / rest / autopilot) don't touch
// the legacy action counters; they credit the day via LastActionDate
// instead. Honor that so a player who ran an expedition all day and
// extracted before midnight still counts as having acted.
return c.LastActionDate == time.Now().UTC().Format("2006-01-02")
}
// markActedToday stamps the player's LastActionDate to today so the
// midnight reset credits the day. Safe to call on every DnD-side action;
// no-ops if the date is already today.
func markActedToday(userID id.UserID) {
today := time.Now().UTC().Format("2006-01-02")
c, err := loadAdvCharacter(userID)
if err != nil || c == nil {
return
}
if c.LastActionDate == today {
return
}
c.LastActionDate = today
_ = saveAdvCharacter(c)
}
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {
@@ -372,7 +442,17 @@ func xpToNextLevel(level int) int {
// checkAdvLevelUp checks if a character leveled up in the given skill and applies it.
// Returns whether a level-up occurred and the new level.
//
// Combat is special-cased: once a player has confirmed a D&D character,
// combat_level freezes. dnd_level (driven by dnd_xp via grantDnDXP) is
// canonical going forward. Combat XP still accrues into combat_xp for
// historical display, but never causes combat_level to advance.
// Skill levels (mining/foraging/fishing) are unaffected and continue to
// progress on their own track per v1.1 §4.
func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
if skill == "combat" && HasCompletedSetup(char.UserID) {
return false, char.CombatLevel
}
var xp *int
var level *int
switch skill {
@@ -413,111 +493,15 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
d := db.Get()
c := &AdventureCharacter{}
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
var houseFrozen, houseAutopay int
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
var autoBabysit, streakDecayed int
err := d.QueryRow(`
SELECT user_id, display_name,
combat_level, mining_skill, foraging_skill, fishing_skill,
combat_xp, mining_xp, foraging_xp, fishing_xp,
alive, dead_until, action_taken_today, holiday_action_taken,
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus,
hospital_visits, robbie_visit_count, last_death_date,
combat_actions_used, harvest_actions_used,
last_pardon_used,
misty_last_seen, arina_last_seen,
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
npc_msg_count, npc_msg_count_date,
misty_roll_target, arina_roll_target,
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
house_autopay, house_current_rate,
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_chased_away, pet_reactivated, pet_arrived,
misty_encounter_count, misty_donated_count,
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
&alive, &deadUntil, &actionTaken, &holidayTaken,
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
&c.CombatActionsUsed, &c.HarvestActionsUsed,
&pardonUsed,
&mistyLastSeen, &arinaLastSeen,
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
&c.NPCMsgCount, &c.NPCMsgCountDate,
&c.MistyRollTarget, &c.ArinaRollTarget,
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
&houseAutopay, &c.HouseCurrentRate,
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
&petChasedAway, &petReactivated, &petArrived,
&c.MistyEncounterCount, &c.MistyDonatedCount,
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
)
if err != nil {
var uid string
if err := d.QueryRow(
`SELECT user_id FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&uid); err != nil {
return nil, err
}
c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
c.PetMorningDefense = petMorningDef == 1
c.AutoBabysit = autoBabysit == 1
c.StreakDecayed = streakDecayed == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
if pardonUsed.Valid {
c.LastPardonUsed = &pardonUsed.Time
}
if mistyLastSeen.Valid {
c.MistyLastSeen = &mistyLastSeen.Time
}
if arinaLastSeen.Valid {
c.ArinaLastSeen = &arinaLastSeen.Time
}
if mistyBuffExp.Valid {
c.MistyBuffExpires = &mistyBuffExp.Time
}
if mistyDebuffExp.Valid {
c.MistyDebuffExpires = &mistyDebuffExp.Time
}
if arinaBuffExp.Valid {
c.ArinaBuffExpires = &arinaBuffExp.Time
}
c.HouseLoanFrozen = houseFrozen == 1
c.HouseAutopay = houseAutopay == 1
c.PetChasedAway = petChasedAway == 1
c.PetReactivated = petReactivated == 1
c.PetArrived = petArrived == 1
c.ThomAnimalLineFired = thomAnimalLine == 1
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
c := &AdventureCharacter{UserID: id.UserID(uid), Alive: true}
applyPlayerMetaOverlay(c)
return c, nil
}
@@ -529,10 +513,15 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
}
defer tx.Rollback()
_, err = tx.Exec(`
INSERT INTO adventure_characters (user_id, display_name)
VALUES (?, ?)`, string(userID), displayName)
if err != nil {
// Adv 2.0 Phase L5 close-out: player_meta is the canonical seed row.
// adventure_characters is no longer written; loadAdvCharacter sources
// the user_id from player_meta and overlays every subsystem's state.
if _, err = tx.Exec(
`INSERT INTO player_meta (user_id, display_name, created_at, last_active_at, alive)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`,
string(userID), displayName,
); err != nil {
return err
}
@@ -550,126 +539,57 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
return tx.Commit()
}
func saveAdvCharacter(char *AdventureCharacter) error {
// ensurePlayerMetaSeed guarantees the canonical player_meta seed row (and tier-0
// equipment) exists for userID, creating it only when absent. The auto-migration
// path (ensureDnDCharacterForCombat) writes a confirmed dnd_character without
// touching the legacy layer; without this, a brand-new player whose first-ever
// action auto-migrates — e.g. !rest or !cast before !setup — ends up with a
// player_meta-less character that fails every legacy-layer command with
// "sql: no rows" (the camcast straggler). Conditional on absence and thus
// idempotent: legacy players who already have player_meta keep their equipment
// untouched — createAdvCharacter's tier-0 equipment insert has no conflict guard
// and would otherwise duplicate their gear.
func ensurePlayerMetaSeed(userID id.UserID) error {
d := db.Get()
alive := 0
if char.Alive {
alive = 1
var one int
err := d.QueryRow(`SELECT 1 FROM player_meta WHERE user_id = ?`, string(userID)).Scan(&one)
if err == nil {
return nil // already seeded
}
actionTaken := 0
if char.ActionTakenToday {
actionTaken = 1
}
holidayTaken := 0
if char.HolidayActionTaken {
holidayTaken = 1
}
rivalUnlocked := 0
if char.RivalUnlockedNotified {
rivalUnlocked = 1
}
babysitAct := 0
if char.BabysitActive {
babysitAct = 1
}
houseFrozen := 0
if char.HouseLoanFrozen {
houseFrozen = 1
}
houseAutopay := 0
if char.HouseAutopay {
houseAutopay = 1
}
petChasedAway := 0
if char.PetChasedAway {
petChasedAway = 1
}
petReactivated := 0
if char.PetReactivated {
petReactivated = 1
}
petArrived := 0
if char.PetArrived {
petArrived = 1
}
thomAnimalLine := 0
if char.ThomAnimalLineFired {
thomAnimalLine = 1
}
petSupplyUnlocked := 0
if char.PetSupplyShopUnlocked {
petSupplyUnlocked = 1
}
petMorningDef := 0
if char.PetMorningDefense {
petMorningDef = 1
}
autoBabysit := 0
if char.AutoBabysit {
autoBabysit = 1
}
streakDecayed := 0
if char.StreakDecayed {
streakDecayed = 1
if !errors.Is(err, sql.ErrNoRows) {
return err
}
return createAdvCharacter(userID, localpartOf(userID))
}
_, err := d.Exec(`
UPDATE adventure_characters SET
display_name = ?, combat_level = ?, mining_skill = ?, foraging_skill = ?, fishing_skill = ?,
combat_xp = ?, mining_xp = ?, foraging_xp = ?, fishing_xp = ?,
alive = ?, dead_until = ?, action_taken_today = ?, holiday_action_taken = ?,
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?,
masterwork_drops_received = ?,
rival_pool = ?, rival_unlocked_notified = ?,
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?,
combat_actions_used = ?, harvest_actions_used = ?,
last_pardon_used = ?,
misty_last_seen = ?, arina_last_seen = ?,
misty_buff_expires = ?, misty_debuff_expires = ?, arina_buff_expires = ?,
npc_msg_count = ?, npc_msg_count_date = ?,
misty_roll_target = ?, arina_roll_target = ?,
house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, house_missed_payments = ?,
house_autopay = ?, house_current_rate = ?,
pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?, pet_armor_tier = ?,
pet_chased_away = ?, pet_reactivated = ?, pet_arrived = ?,
misty_encounter_count = ?, misty_donated_count = ?,
thom_animal_line_fired = ?, pet_supply_shop_unlocked = ?, pet_level10_date = ?,
pet_morning_defense = ?,
auto_babysit = ?,
streak_decayed = ?,
crafts_succeeded = ?
WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
alive, char.DeadUntil, actionTaken, holidayTaken,
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation,
char.DeathReprieveLast, char.MasterworkDropsReceived,
char.RivalPool, rivalUnlocked,
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate,
char.CombatActionsUsed, char.HarvestActionsUsed,
char.LastPardonUsed,
char.MistyLastSeen, char.ArinaLastSeen,
char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires,
char.NPCMsgCount, char.NPCMsgCountDate,
char.MistyRollTarget, char.ArinaRollTarget,
char.HouseTier, char.HouseLoanBalance, houseFrozen, char.HouseMissedPayments,
houseAutopay, char.HouseCurrentRate,
char.PetType, char.PetName, char.PetXP, char.PetLevel, char.PetArmorTier,
petChasedAway, petReactivated, petArrived,
char.MistyEncounterCount, char.MistyDonatedCount,
thomAnimalLine, petSupplyUnlocked, char.PetLevel10Date,
petMorningDef,
autoBabysit,
streakDecayed,
char.CraftsSucceeded,
string(char.UserID),
)
return err
// localpartOf returns the mxid localpart (between @ and :) as a display-name
// fallback — matches Base.DisplayName's offline behavior. The seed's display
// name is overlaid by later player_meta upserts; this is just a sane default
// for a character born without a Matrix client in reach.
func localpartOf(userID id.UserID) string {
s := string(userID)
if i := strings.Index(s, ":"); i > 0 {
return s[1:i]
}
return strings.TrimPrefix(s, "@")
}
// saveAdvCharacter persists every mutable AdventureCharacter field to
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
// retired — the row is now read-only after createAdvCharacter seeds it,
// and saveAdvCharacter routes the entire mutable state set through the
// per-subsystem player_meta upserts. LastActiveAt is auto-bumped to mirror
// the previous CURRENT_TIMESTAMP semantics on save.
func saveAdvCharacter(char *AdventureCharacter) error {
char.LastActiveAt = time.Now().UTC()
return upsertAllPlayerMetaFromAdvChar(char)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func loadAdvEquipment(userID id.UserID) (map[EquipmentSlot]*AdvEquipment, error) {
@@ -715,8 +635,8 @@ func saveAdvEquipment(userID id.UserID, eq *AdvEquipment) error {
func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
d := db.Get()
rows, err := d.Query(`
SELECT id, name, item_type, tier, value, slot, skill_source
FROM adventure_inventory WHERE user_id = ?
SELECT id, name, item_type, tier, value, slot, skill_source, temper
FROM adventure_inventory WHERE user_id = ? AND in_vault = 0
ORDER BY tier DESC, value DESC`, string(userID))
if err != nil {
return nil, err
@@ -727,7 +647,7 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
for rows.Next() {
var it AdvItem
var slot string
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource); err != nil {
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil {
return nil, err
}
it.Slot = EquipmentSlot(slot)
@@ -739,9 +659,19 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
func addAdvInventoryItem(userID id.UserID, item AdvItem) error {
d := db.Get()
_, err := d.Exec(`
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource)
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source, temper)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource, item.Temper)
return err
}
// temperInventoryItem records a tempering step on an un-equipped magic item.
// Tier and value move with the item's new effective rarity so it keeps sorting
// and selling correctly.
func temperInventoryItem(itemID int64, temper, tier int, value int64) error {
_, err := db.Get().Exec(
`UPDATE adventure_inventory SET temper = ?, tier = ?, value = ? WHERE id = ?`,
temper, tier, value, itemID)
return err
}
@@ -757,7 +687,10 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
return nil, err
}
d := db.Get()
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(userID))
// Delete only what loadAdvInventory returned — vaulted rows are out of play
// and must survive a bulk clear, or the vault's whole promise (safe from
// !sell all) breaks the moment any caller routes sell-all through here.
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID))
if err != nil {
return nil, err
}
@@ -767,134 +700,89 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
func advInventoryCount(userID id.UserID) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ?`, string(userID)).Scan(&count)
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID)).Scan(&count)
return count
}
func loadAllAdvCharacters() ([]AdventureCharacter, error) {
// loadAdvVault returns the items the player has stowed in their housing vault
// (N4/E1). These are excluded from loadAdvInventory — a vaulted item is out of
// play (unsellable, uncraftable, unusable in combat) until it is taken back.
func loadAdvVault(userID id.UserID) ([]AdvItem, error) {
d := db.Get()
rows, err := d.Query(`
SELECT user_id, display_name,
combat_level, mining_skill, foraging_skill, fishing_skill,
combat_xp, mining_xp, foraging_xp, fishing_xp,
alive, dead_until, action_taken_today, holiday_action_taken,
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus,
hospital_visits, robbie_visit_count, last_death_date,
combat_actions_used, harvest_actions_used,
last_pardon_used,
misty_last_seen, arina_last_seen,
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
npc_msg_count, npc_msg_count_date,
misty_roll_target, arina_roll_target,
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
house_autopay, house_current_rate,
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_chased_away, pet_reactivated, pet_arrived,
misty_encounter_count, misty_donated_count,
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded
FROM adventure_characters`)
SELECT id, name, item_type, tier, value, slot, skill_source, temper
FROM adventure_inventory WHERE user_id = ? AND in_vault = 1
ORDER BY tier DESC, value DESC`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var chars []AdventureCharacter
var items []AdvItem
for rows.Next() {
c := AdventureCharacter{}
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
var houseFrozen, houseAutopay int
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
var autoBabysit, streakDecayed int
if err := rows.Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
&alive, &deadUntil, &actionTaken, &holidayTaken,
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
&c.CombatActionsUsed, &c.HarvestActionsUsed,
&pardonUsed,
&mistyLastSeen, &arinaLastSeen,
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
&c.NPCMsgCount, &c.NPCMsgCountDate,
&c.MistyRollTarget, &c.ArinaRollTarget,
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
&houseAutopay, &c.HouseCurrentRate,
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
&petChasedAway, &petReactivated, &petArrived,
&c.MistyEncounterCount, &c.MistyDonatedCount,
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
); err != nil {
var it AdvItem
var slot string
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil {
return nil, err
}
c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
c.PetMorningDefense = petMorningDef == 1
c.AutoBabysit = autoBabysit == 1
c.StreakDecayed = streakDecayed == 1
c.HouseLoanFrozen = houseFrozen == 1
c.HouseAutopay = houseAutopay == 1
c.PetChasedAway = petChasedAway == 1
c.PetReactivated = petReactivated == 1
c.PetArrived = petArrived == 1
c.ThomAnimalLineFired = thomAnimalLine == 1
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
if pardonUsed.Valid {
c.LastPardonUsed = &pardonUsed.Time
}
if mistyLastSeen.Valid {
c.MistyLastSeen = &mistyLastSeen.Time
}
if arinaLastSeen.Valid {
c.ArinaLastSeen = &arinaLastSeen.Time
}
if mistyBuffExp.Valid {
c.MistyBuffExpires = &mistyBuffExp.Time
}
if mistyDebuffExp.Valid {
c.MistyDebuffExpires = &mistyDebuffExp.Time
}
if arinaBuffExp.Valid {
c.ArinaBuffExpires = &arinaBuffExp.Time
}
chars = append(chars, c)
it.Slot = EquipmentSlot(slot)
items = append(items, it)
}
return chars, rows.Err()
return items, rows.Err()
}
func resetAllAdvDailyActions() error {
// advVaultCount returns how many of a user's items are stowed in the vault.
func advVaultCount(userID id.UserID) int {
d := db.Get()
// Only reset actions taken before today — protects against race if a player
// resolves their action at exactly midnight.
today := time.Now().UTC().Format("2006-01-02")
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0, pet_morning_defense = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
return err
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 1`, string(userID)).Scan(&count)
return count
}
// setItemVaulted flips a single inventory row's vault flag. Scoped to the
// owning user so a mistyped id can never move another player's item.
func setItemVaulted(userID id.UserID, itemID int64, vaulted bool) (bool, error) {
v := 0
if vaulted {
v = 1
}
res, err := db.Get().Exec(
`UPDATE adventure_inventory SET in_vault = ? WHERE id = ? AND user_id = ?`,
v, itemID, string(userID))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func loadAllAdvCharacters() ([]AdventureCharacter, error) {
d := db.Get()
rows, err := d.Query(`SELECT user_id FROM player_meta`)
if err != nil {
return nil, err
}
var uids []string
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
rows.Close()
return nil, err
}
uids = append(uids, uid)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
chars := make([]AdventureCharacter, 0, len(uids))
for _, uid := range uids {
c := AdventureCharacter{UserID: id.UserID(uid), Alive: true}
applyPlayerMetaOverlay(&c)
chars = append(chars, c)
}
return chars, nil
}
func logAdvActivity(userID id.UserID, activityType, location, outcome string, lootValue int64, xpGained int, flavorKey string) {

View File

@@ -0,0 +1,702 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// Pete, the realm's embedded correspondent — the hireable NPC companion.
// (pete_adventure_news_plan.md, "Pete as a character", surface 2.)
//
// A leader short a body can hire Pete into an expedition party. He fills the
// role the party is missing, fights on autopilot, and files a dispatch about it
// afterwards.
//
// The load-bearing rule, and the reason this file exists rather than a
// player_meta row for @pete: **Pete is not a player and must never become one.**
// He has no player_meta, no dnd_character, no inventory, no euros, and no DM
// room. Mint him a player_meta row and ensureDnDCharacterForCombat will happily
// auto-build him a real character on his first swing, at which point he shows up
// in the graveyard, the leaderboards, the news as a subject, and the daily event
// rolls — all of which would be a bot reporting on itself.
//
// So his seat is synthesized: companionCombatant builds a Combatant in memory
// from the same tuned layers a player's sheet goes through, and every seam that
// assumes a seat is a person is guarded by isCompanionSeat. The guards live at
// four chokepoints, which between them cover the whole blast radius:
//
// - expeditionAudience — he is never in the DM fan-out (and so never in the
// per-member pet-arrival rolls or the daily event rolls that ride it)
// - partySize — he is not a mouth: he doesn't inflate the supply
// burn he never bought packs for, and an NPC-only roster doesn't lock the
// leader out of their next expedition
// - partyCombatantsForSession / the seat builders — synthesize, don't load
// - the close-out loops — no XP, no loot, no death row, no achievements
//
// He does count toward the enemy-HP scalar, because he is a body in the fight
// and the boss can feel him.
// companionUserIDDefault is Pete's real Matrix account. He is an independent bot
// (his own repo, his own voice); gogobee already ignores him as a sender via
// IGNORED_BOTS, so seating him here can never round-trip into command handling.
// PETE_USER_ID overrides for a differently-homed deployment.
const companionUserIDDefault = "@pete:parodia.dev"
// companionDisplayName is what the party sees on his seat. gogobee names him but
// does NOT voice him: his hire banter and his dispatch are written by his own
// bot from the fact emitted below (project_pete_bot_architecture).
const companionDisplayName = "Pete"
// Hire pricing. The plan files cost as an open tuning question; this is the
// first answer, not the final one. It scales with both the level he shows up at
// and the tier he's walking into, so hiring him for a T5 boss run is not the
// same 300 coins as a T1 stroll. Supply packs are 50/90 coins for reference —
// he is deliberately a real expense, not a rounding error.
//
// PROD WATCH: at endgame coin balances this is likely too cheap to be a sink.
// Raise companionHireCoinsPerLevel before raising the base — the base is what a
// low-level player short a friend has to find.
const (
companionHireBaseCoins = 300
companionHireCoinsPerLevel = 60
)
// companionLevelPenalty is what makes him help rather than carry. He arrives one
// level below the party's average — a competent below-median member, per the
// difficulty plan's standing rule: lift the trailing case, never nerf the
// leaders and never touch monster scaling.
const companionLevelPenalty = 1
var (
ErrCompanionAlreadyHired = errors.New("pete is already with this party")
ErrCompanionOnAssignment = errors.New("pete is out on assignment")
ErrCompanionNotHired = errors.New("pete is not with this party")
)
// companionUserID resolves Pete's Matrix id.
func companionUserID() id.UserID {
if v := strings.TrimSpace(os.Getenv("PETE_USER_ID")); v != "" {
return id.UserID(v)
}
return id.UserID(companionUserIDDefault)
}
// isCompanionSeat reports whether a roster seat / combat seat is Pete rather
// than a player. This is the predicate every guard in the codebase keys on.
func isCompanionSeat(userID id.UserID) bool { return userID == companionUserID() }
// isCompanionUser is the string-keyed form, for the many seams that carry a raw
// user_id out of the database.
func isCompanionUser(userID string) bool { return id.UserID(userID) == companionUserID() }
// companionHireCost is what the leader pays to bring him along, in coins.
func companionHireCost(level int, tier ZoneTier) int {
if level < 1 {
level = 1
}
t := int(tier)
if t < 1 {
t = 1
}
return (companionHireBaseCoins + companionHireCoinsPerLevel*level) * t
}
// ── who he shows up as ───────────────────────────────────────────────────────
// companionRoleFill picks the class Pete plays. He is role-fluid — that is the
// whole point of hiring him — so by default he fills the hole in the roster: no
// healer, he's a Cleric; no damage, he's a Mage; nobody up front, he's a
// Fighter. A leader who knows better can override.
//
// The order of the checks is the priority order: a party with neither a healer
// nor a front line gets the healer, because a party that cannot heal is the one
// that dies.
func companionRoleFill(partyClasses []DnDClass) DnDClass {
var hasHealer, hasFront, hasDamage bool
for _, c := range partyClasses {
switch c {
case ClassCleric, ClassDruid, ClassBard:
hasHealer = true
case ClassPaladin:
// The one chassis that answers two questions at once.
hasHealer, hasFront = true, true
case ClassFighter:
hasFront = true
case ClassMage, ClassSorcerer, ClassWarlock, ClassRogue, ClassRanger:
hasDamage = true
}
}
switch {
case !hasHealer:
return ClassCleric
case !hasFront:
return ClassFighter
case !hasDamage:
return ClassMage
default:
// A complete party that hires him anyway gets a second pair of hands up
// front — the least redundant thing he can be.
return ClassFighter
}
}
// parseCompanionClass resolves an explicit `!expedition hire cleric` override.
// Empty (or unknown) means auto-fill.
func parseCompanionClass(arg string) (DnDClass, bool) {
arg = strings.ToLower(strings.TrimSpace(arg))
if arg == "" || arg == "auto" {
return "", false
}
for _, ci := range dndClasses {
if !ci.Playable {
continue
}
if string(ci.Key) == arg || strings.EqualFold(ci.Display, arg) {
return ci.Key, true
}
}
return "", false
}
// companionHumans is every *player* on the expedition — the roster if one exists,
// and otherwise the owner alone.
//
// The fallback is the whole point. A solo expedition has NO expedition_party rows
// (see partyMembers: absence means solo, and the roster only materializes on the
// first successful invite). Reading the roster alone therefore answers "nobody" for
// exactly the player this feature exists for: the one with no friends around, who
// is hiring Pete *because* they are alone.
//
// Getting this wrong is not a small error. It hired every solo player a **level-1**
// Pete — in a tier-4 zone, against a boss that had just gained 15% HP and a full
// extra set of actions to account for him. He died on contact and left the leader
// fighting an inflated boss alone. A 1500-run sweep measured it: solo 65% clear,
// two humans 87%, solo+Pete 33%. The companion was worse than no companion, and
// this line is why.
func companionHumans(expeditionID string) []*DnDCharacter {
var owner string
if err := db.Get().QueryRow(
`SELECT user_id FROM dnd_expedition WHERE expedition_id = ?`,
expeditionID).Scan(&owner); err != nil {
return nil
}
seats, err := partyHumans(expeditionID, owner)
if err != nil {
return nil
}
out := make([]*DnDCharacter, 0, len(seats))
for _, s := range seats {
if dc, _ := LoadDnDCharacter(s.UserID); dc != nil {
out = append(out, dc)
}
}
return out
}
// companionPartyLevel is the level he arrives at: the party's average, less
// companionLevelPenalty, floored at 1.
func companionPartyLevel(expeditionID string) int {
chars := companionHumans(expeditionID)
if len(chars) == 0 {
return 1
}
sum := 0
for _, dc := range chars {
sum += dc.Level
}
lvl := sum/len(chars) - companionLevelPenalty
if lvl < 1 {
lvl = 1
}
return lvl
}
// companionPartyClasses reads the classes already on the expedition, for the role
// fill. Solo resolves to the owner's class, so a lone fighter gets a healer rather
// than the empty-party default.
func companionPartyClasses(expeditionID string) []DnDClass {
chars := companionHumans(expeditionID)
out := make([]DnDClass, 0, len(chars))
for _, dc := range chars {
out = append(out, dc.Class)
}
return out
}
// ── his sheet, which lives only in memory ────────────────────────────────────
// companionSheet synthesizes the DnDCharacter Pete fights as. It is built, used,
// and thrown away inside a single combat build — it is never saved, and
// SaveDnDCharacter must never be called on it.
//
// Stats come from the same class-priority + race-mod pipeline autoBuildCharacter
// uses for a real character, so he is statted like a player of his level rather
// than by a bespoke NPC table that would drift away from the tuned math. Human
// is deliberate: the +1-to-all is the most neutral race in the book, so his
// class is doing the work rather than a race pick nobody chose.
func companionSheet(class DnDClass, level int) *DnDCharacter {
if level < 1 {
level = 1
}
scores := applyRaceMods(RaceHuman, classStatPriority(class))
c := &DnDCharacter{
UserID: companionUserID(),
Race: RaceHuman,
Class: class,
Level: level,
STR: scores[0], DEX: scores[1], CON: scores[2],
INT: scores[3], WIS: scores[4], CHA: scores[5],
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
c.HPCurrent = c.HPMax
return c
}
// companionAdvCharacter is the AdventureCharacter half of the synthetic sheet:
// the shim DerivePlayerStats needs. CombatLevel round-trips back to the D&D
// level through dndLevelFromCombatLevel (level*5), so the two halves agree.
func companionAdvCharacter(level int) *AdventureCharacter {
return &AdventureCharacter{
UserID: companionUserID(),
DisplayName: companionDisplayName,
CombatLevel: level * 5,
Alive: true,
}
}
// companionGearTier maps his level onto the equipment tier a player of that
// level would plausibly be carrying: 14 → T1, 58 → T2, and so on to T5.
func companionGearTier(level int) int {
t := (level + 3) / 4
if t < 1 {
t = 1
}
if t > 5 {
t = 5
}
return t
}
// companionGear is the kit he walks in with — a working reporter's kit, bought
// with expenses and kept in serviceable shape.
//
// He has to have one. The gear layer is not decoration: unarmored, stats.AC is
// never set at all (computeArmorAC only fires when armor exists) and he walks in
// at AC 3, hit by everything; weaponless, stats.Weapon stays nil and he swings
// for a flat 5 at every level, which by L14 is nothing. "No gear" is not a
// below-median player, it is a broken one.
//
// The below-median comes from everywhere else: he is a level down, his gear is
// never Masterwork, and he carries no magic items, no subclass and no armed
// ability. The weapon names are chosen to hit the right branch of
// synthesizeWeaponProfile for the class — it best-fits off the name.
func companionGear(class DnDClass, level int) map[EquipmentSlot]*AdvEquipment {
tier := companionGearTier(level)
weapon := "Service Mace"
switch class {
case ClassFighter, ClassPaladin:
weapon = "Service Sword"
case ClassRogue:
weapon = "Service Dagger"
case ClassRanger:
weapon = "Service Bow"
case ClassMage, ClassSorcerer, ClassWarlock, ClassDruid, ClassBard:
weapon = "Service Staff"
}
return map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Slot: SlotWeapon, Tier: tier, Condition: 100, Name: weapon},
SlotArmor: {Slot: SlotArmor, Tier: tier, Condition: 100, Name: "Service Kit"},
}
}
// companionCombatant builds Pete's seat for a fight, mirroring buildZoneCombatants
// but sourcing the sheet from memory instead of the database. He carries no
// equipment, no treasure bonuses, no magic items, no chat level and no streak —
// the three layers a player accumulates and he never will. That absence *is* the
// below-median: he is a bare class chassis at a level below yours, and the gap
// between him and a geared player of the same level is exactly the gear.
func (p *AdventurePlugin) companionCombatant(
class DnDClass, level int, monster DnDMonsterTemplate, tier int, dmMood int,
) (Combatant, Combatant, *DnDCharacter) {
tilt := dmMoodCombatTilt(dmMood)
char := companionAdvCharacter(level)
dc := companionSheet(class, level)
// The layer order is buildZoneCombatants', deliberately — a companion statted
// by a different pipeline would drift away from the tuned math the moment
// anyone touched one and not the other.
//
// What he does NOT get is the subclass layer, magic items, and an armed
// ability: three of the things a player accumulates and a hireling never will.
// Those absences, plus the level penalty and gear that is never Masterwork,
// are the "below median" — see companionGear for why the gear itself is not
// one of the things we take away.
gear := companionGear(class, level)
stats, mods := DerivePlayerStats(char, gear, &AdvBonusSummary{}, 0, 0, false)
applyDnDPlayerLayer(&stats, dc)
applyDnDEquipmentLayer(&stats, dc, gear)
applyDnDHPScaling(&stats, dc)
applyClassPassives(&stats, &mods, dc)
applyRacePassives(&stats, &mods, dc)
enemyStats, enemyMods := monster.toCombatStats()
if tier > 1 {
if floorAC := dndDungeonACBase + tier; enemyStats.AC < floorAC {
enemyStats.AC = floorAC
}
if floorAB := dndDungeonAtkBase + tier; enemyStats.AttackBonus < floorAB {
enemyStats.AttackBonus = floorAB
}
}
enemyStats.Attack += tilt.EnemyAttackDelta
if enemyStats.Attack < 1 {
enemyStats.Attack = 1
}
mods.InitiativeBias += tilt.InitiativeBias
player := Combatant{
Name: companionDisplayName,
Stats: stats,
Mods: mods,
IsPlayer: true,
}
enemy := Combatant{
Name: monster.Name,
Stats: enemyStats,
Mods: enemyMods,
Ability: monster.Ability,
}
return player, enemy, dc
}
// companionRosterLine is how he reads on `!expedition party`. DisplayName would
// come back empty for him — he has no player_meta row to hold a name — so the
// roster names him here, along with what he is currently playing, because "Pete
// (member)" tells the leader nothing about the hole they paid to fill.
func companionRosterLine(expeditionID string) string {
class, level := companionLoadout(expeditionID)
ci, _ := classInfo(class)
return fmt.Sprintf("**%s** _(hired — level %d %s)_\n", companionDisplayName, level, ci.Display)
}
// ── the hire, persisted on the roster ────────────────────────────────────────
// companionLoadout reads back the class and level he was hired at. It is stored
// on the roster row rather than re-derived per fight, so a party that levels
// mid-expedition doesn't quietly re-roll their hireling into a different class
// three rooms in.
func companionLoadout(expeditionID string) (DnDClass, int) {
var class string
var level int
err := db.Get().QueryRow(`
SELECT companion_class, companion_level
FROM expedition_party
WHERE expedition_id = ? AND user_id = ?`,
expeditionID, string(companionUserID())).Scan(&class, &level)
if err != nil || class == "" {
return ClassFighter, 1
}
return DnDClass(class), level
}
// ── his spell slots, which live on the expedition ────────────────────────────
//
// A human caster's slots are dnd_spell_slots rows: one pool, spent across every
// fight of the run, refilled only at camp. Rationing it is the caster's game. The
// companion has no rows, so his pool lives on his roster row — the same row his
// class and level live on, and with the same lifetime.
//
// It must NOT live on his combat seat. A seat is per-session and every fight opens
// a new one, so a seat-scoped pool refills itself between fights: an infinite
// caster. That is not a theory — the first cut did exactly that, and the sim
// measured a gearless, level-penalized hireling out-clearing a human cleric of the
// leader's own level by 15pp.
// companionSlotsCSV encodes/decodes the ledger. CSV of six ints rather than JSON
// because it is six ints.
func companionSlotsDecode(s string) [6]int {
var out [6]int
for i, f := range strings.Split(s, ",") {
if i >= len(out) {
break
}
n, err := strconv.Atoi(strings.TrimSpace(f))
if err != nil || n < 0 {
continue
}
out[i] = n
}
return out
}
func companionSlotsEncode(used [6]int) string {
parts := make([]string, len(used))
for i, n := range used {
parts[i] = strconv.Itoa(n)
}
return strings.Join(parts, ",")
}
// companionSlotsForRun reads the ledger for the companion on the expedition that
// owns runID. A run with no companion (or no expedition) reads as an empty pool,
// which is the correct answer: nobody spent anything.
func companionSlotsForRun(runID string) [6]int {
var raw string
err := db.Get().QueryRow(`
SELECT p.companion_slots_used
FROM expedition_party p
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
WHERE e.run_id = ? AND p.user_id = ?`,
runID, string(companionUserID())).Scan(&raw)
if err != nil {
return [6]int{}
}
return companionSlotsDecode(raw)
}
// setCompanionSlotsForRun writes it back.
func setCompanionSlotsForRun(runID string, used [6]int) error {
_, err := db.Get().Exec(`
UPDATE expedition_party
SET companion_slots_used = ?
WHERE user_id = ?
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
companionSlotsEncode(used), string(companionUserID()), runID)
return err
}
// refreshCompanionSlots empties the ledger — his half of the camp rest that calls
// refreshSpellSlots for every human. Keyed by expedition, because camp is.
func refreshCompanionSlots(expeditionID string) error {
_, err := db.Get().Exec(`
UPDATE expedition_party SET companion_slots_used = ''
WHERE expedition_id = ? AND user_id = ?`,
expeditionID, string(companionUserID()))
return err
}
// ── his body, which is also carried across the run ───────────────────────────
//
// companionUnsetHP is "no wound recorded" — a fresh hire, or a companion who has
// just broken camp. Seating reads it as full.
const companionUnsetHP = -1
// companionHPFor reads the HP he carries into his next fight, or companionUnsetHP
// when he is unhurt. A run with no companion reads unset, which is harmless: there
// is nobody to seat.
func companionHPFor(expeditionID string) int {
hp := companionUnsetHP
err := db.Get().QueryRow(`
SELECT companion_hp FROM expedition_party
WHERE expedition_id = ? AND user_id = ?`,
expeditionID, string(companionUserID())).Scan(&hp)
if err != nil {
return companionUnsetHP
}
return hp
}
// companionSeatHP is what he actually sits down with: his carried wound, clamped
// into [1, maxHP].
//
// The floor of 1 is deliberate. He can be dropped *inside* a fight — the engine
// counts him out like any other seat — but he does not stay dead between them,
// because there is no companion-death mechanic and inventing one here would be a
// second feature smuggled into a bug fix. Coming back on 1 HP is a real penalty
// (one hit and he is down again) without pretending to be a corpse rule.
func companionSeatHP(expeditionID string, maxHP int) int {
hp := companionHPFor(expeditionID)
if hp == companionUnsetHP || hp > maxHP {
return maxHP
}
if hp < 1 {
return 1
}
return hp
}
// setCompanionHP records the HP he walked out of a fight with.
func setCompanionHP(expeditionID string, hp int) error {
_, err := db.Get().Exec(`
UPDATE expedition_party SET companion_hp = ?
WHERE expedition_id = ? AND user_id = ?`,
hp, expeditionID, string(companionUserID()))
return err
}
// setCompanionHPForRun is setCompanionHP for the turn-based close-out, which
// holds a run id rather than an expedition id.
func setCompanionHPForRun(runID string, hp int) error {
_, err := db.Get().Exec(`
UPDATE expedition_party
SET companion_hp = ?
WHERE user_id = ?
AND expedition_id = (SELECT expedition_id FROM dnd_expedition WHERE run_id = ?)`,
hp, string(companionUserID()), runID)
return err
}
// refreshCompanionHP is his half of the camp heal: back to full, like every human
// at a standard rest.
func refreshCompanionHP(expeditionID string) error {
_, err := db.Get().Exec(`
UPDATE expedition_party SET companion_hp = ?
WHERE expedition_id = ? AND user_id = ?`,
companionUnsetHP, expeditionID, string(companionUserID()))
return err
}
// companionLoadoutForRun is companionLoadout keyed by the zone run instead of
// the expedition. A CombatSession carries a RunID, not an expedition id, and the
// per-turn rebuild is the hottest caller — so the join lives here rather than
// making every caller resolve the expedition first.
//
// A run with no expedition (a standalone !zone run, which can have no companion)
// finds no row and falls back, which is the correct reading.
func companionLoadoutForRun(runID string) (DnDClass, int) {
var class string
var level int
err := db.Get().QueryRow(`
SELECT p.companion_class, p.companion_level
FROM expedition_party p
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
WHERE e.run_id = ? AND p.user_id = ?`,
runID, string(companionUserID())).Scan(&class, &level)
if err != nil || class == "" {
return ClassFighter, 1
}
return DnDClass(class), level
}
// companionExpeditionFor resolves the expedition a leader is running, for the
// seat builder — which has the roster but not the expedition id. Empty string
// when there is none, which companionLoadout reads as "no row" and falls back.
func companionExpeditionFor(leader id.UserID) string {
e, _, err := activeExpeditionFor(leader)
if err != nil || e == nil {
return ""
}
return e.ID
}
// companionSeated reports whether Pete is on this roster.
func companionSeated(expeditionID string) bool {
var one int
err := db.Get().QueryRow(`
SELECT 1 FROM expedition_party WHERE expedition_id = ? AND user_id = ?`,
expeditionID, string(companionUserID())).Scan(&one)
return err == nil
}
// hireCompanion seats Pete. The seat check, the availability check and the
// insert share a transaction, so two leaders racing for him cannot both win.
//
// He is globally exclusive — one party at a time. That is not a limitation to
// route around: "Pete is out on assignment" is the scarcity knob the plan asks
// for, and it is also why he cannot be the answer to every run.
func hireCompanion(expeditionID string, class DnDClass, level int) error {
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := seatLeader(tx, expeditionID); err != nil {
return err
}
// Is he already out with somebody? Scoped to live expeditions, so a roster
// row stranded by a crash cannot make him unhireable forever.
var busyOn string
err = tx.QueryRow(`
SELECT p.expedition_id
FROM expedition_party p
JOIN dnd_expedition e ON e.expedition_id = p.expedition_id
WHERE p.user_id = ? AND e.status IN ('active', 'extracting')
LIMIT 1`, string(companionUserID())).Scan(&busyOn)
switch {
case err == nil && busyOn == expeditionID:
return ErrCompanionAlreadyHired
case err == nil:
return ErrCompanionOnAssignment
case !errors.Is(err, sql.ErrNoRows):
return err
}
var n int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
expeditionID).Scan(&n); err != nil {
return err
}
if n >= expeditionPartyMax {
return ErrPartyFull
}
if _, err := tx.Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role, companion_class, companion_level)
VALUES (?, ?, 'member', ?, ?)`,
expeditionID, string(companionUserID()), string(class), level); err != nil {
return fmt.Errorf("seat companion: %w", err)
}
return tx.Commit()
}
// dismissCompanion sends him home. Unlike a player member he can be removed
// mid-run — he is staff, not a guest — but the fee is not refunded: he already
// walked in.
func dismissCompanion(expeditionID string) error {
res, err := db.Get().Exec(`
DELETE FROM expedition_party
WHERE expedition_id = ? AND user_id = ?`,
expeditionID, string(companionUserID()))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrCompanionNotHired
}
return nil
}
// ── the dispatch ─────────────────────────────────────────────────────────────
// emitCompanionHireFact tells Pete's bot he has been hired. gogobee states the
// fact; Pete writes the sentence. Bulletin, not priority: a hire is a diary
// entry, not a bulletin-interrupting event.
//
// The subject is the *leader*, so the leader's news opt-out is honoured — Pete
// reporting "filled in as cleric for <someone who asked not to be named>" would
// be exactly the leak the opt-out exists to prevent. Pete himself is never a
// subject: he has no opt-out row and needs none.
func emitCompanionHireFact(leader id.UserID, class DnDClass, level int, zone ZoneDefinition) {
name := charName(leader)
if name == "" {
return
}
ci, _ := classInfo(class)
emitFact(peteclient.Fact{
GUID: "companion_hire:" + eventToken(leader, "companion_hire") + ":" + fmt.Sprint(time.Now().UTC().Unix()),
EventType: "companion_hire",
Tier: "bulletin",
Subject: name,
Zone: zone.Display,
Level: level,
ClassRace: ci.Display,
OccurredAt: time.Now().UTC().Unix(),
}, leader, "")
}

View File

@@ -0,0 +1,309 @@
package plugin
import (
"errors"
"testing"
"maunium.net/go/mautrix/id"
)
// The companion's whole contract is "he fights, and he is not a player". These
// tests pin both halves — and specifically the seams where an NPC seat would
// otherwise be silently treated as a person.
func TestCompanion_HiredSeatIsNotAMouth(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@leader:example.org")
seedExpedition(t, "exp-hire", owner, "active")
seatLeaderFixture(t, "exp-hire")
if err := hireCompanion("exp-hire", ClassCleric, 4); err != nil {
t.Fatalf("hireCompanion: %v", err)
}
// The roster holds two seats...
members, err := partyMembers("exp-hire")
if err != nil {
t.Fatal(err)
}
if len(members) != 2 {
t.Fatalf("roster has %d seats, want 2 (leader + companion)", len(members))
}
// ...but only one of them eats. partySize feeds the daily supply burn and the
// "your party is still waiting on you" lock-out; counting the companion would
// bill the leader for rations he never bought, and strand him out of his next
// expedition behind a party of one bot.
n, err := partySize("exp-hire")
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("partySize = %d, want 1 — the companion is not a mouth", n)
}
}
func TestCompanion_GetsNoMailButTakesASeat(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@leader2:example.org")
seedExpedition(t, "exp-mail", owner, "active")
seatLeaderFixture(t, "exp-mail")
if err := hireCompanion("exp-mail", ClassFighter, 3); err != nil {
t.Fatalf("hireCompanion: %v", err)
}
exp, err := getExpedition("exp-mail")
if err != nil || exp == nil {
t.Fatalf("getExpedition: %v", err)
}
// Mail and seats are different sets. Every DM seam reads the audience; the
// combat roster reads the seats. Getting this backwards either DMs a bot or
// charges a leader for a body that never sits down.
for _, uid := range expeditionAudience(exp) {
if isCompanionSeat(uid) {
t.Fatal("companion is in the DM audience — he does not get mail")
}
}
var seated bool
for _, uid := range expeditionSeats(exp) {
if isCompanionSeat(uid) {
seated = true
}
}
if !seated {
t.Fatal("companion is not in the fight roster — he was paid for and never sat down")
}
}
func TestCompanion_IsGloballyExclusive(t *testing.T) {
setupEmptyTestDB(t)
for _, e := range []struct {
id string
owner id.UserID
}{{"exp-a", "@a:example.org"}, {"exp-b", "@b:example.org"}} {
seedExpedition(t, e.id, e.owner, "active")
seatLeaderFixture(t, e.id)
}
if err := hireCompanion("exp-a", ClassMage, 5); err != nil {
t.Fatalf("first hire: %v", err)
}
// He is one person. A second party cannot have him — "out on assignment" is
// the scarcity knob, not a bug to route around.
if err := hireCompanion("exp-b", ClassMage, 5); !errors.Is(err, ErrCompanionOnAssignment) {
t.Errorf("second hire err = %v, want ErrCompanionOnAssignment", err)
}
// Re-hiring him into the party he's already with is its own answer.
if err := hireCompanion("exp-a", ClassMage, 5); !errors.Is(err, ErrCompanionAlreadyHired) {
t.Errorf("re-hire err = %v, want ErrCompanionAlreadyHired", err)
}
// Dismissed, he's available again.
if err := dismissCompanion("exp-a"); err != nil {
t.Fatalf("dismiss: %v", err)
}
if err := hireCompanion("exp-b", ClassMage, 5); err != nil {
t.Errorf("hire after dismiss: %v, want success", err)
}
}
// The bug that made the whole feature worse than useless: a SOLO expedition has
// no expedition_party rows at all (the roster only materializes on the first
// invite), so reading the roster to size the companion answered "nobody" for
// exactly the player who is hiring him — the one with no friends around. Every
// solo hire got a level-1 Pete, in whatever tier the leader was actually in.
func TestCompanion_SoloLeaderSizesHimCorrectly(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lonely:example.org")
seedExpedition(t, "exp-solo-hire", owner, "active")
// A level-12 fighter, adventuring alone. No roster rows exist.
if err := SaveDnDCharacter(&DnDCharacter{
UserID: owner, Race: RaceHuman, Class: ClassFighter, Level: 12,
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
}); err != nil {
t.Fatal(err)
}
if got := companionPartyLevel("exp-solo-hire"); got != 11 {
t.Errorf("companionPartyLevel = %d, want 11 (the lone leader's 12, less the below-median step). "+
"A level-1 companion in the leader's zone is worse than no companion at all.", got)
}
// And he fills the hole the lone fighter actually has, rather than defaulting
// as if the party were empty.
if got := companionRoleFill(companionPartyClasses("exp-solo-hire")); got != ClassCleric {
t.Errorf("role fill for a lone fighter = %v, want cleric", got)
}
}
// §4 — asking for "the party" must never answer "nobody".
//
// A solo expedition has no expedition_party rows (absence means solo; the roster
// materializes on the first invite). Every consumer that read the roster table
// directly therefore got an empty list for a solo player and fell back to
// whatever looked reasonable locally. That is how the companion was hired at
// level 1 for exactly the player the feature exists for.
//
// expeditionParty always includes the owner. If this test ever fails, that
// guarantee is gone and the same class of bug is back.
func TestParty_SoloExpeditionStillHasAParty(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@alone:example.org")
seedExpedition(t, "exp-alone", owner, "active")
// No roster rows exist at all.
rows, err := partyMembers("exp-alone")
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("solo expedition has %d roster rows; the premise of this test is gone", len(rows))
}
// And yet the party is not empty.
seats, err := expeditionParty("exp-alone", string(owner))
if err != nil {
t.Fatal(err)
}
if len(seats) != 1 || seats[0].UserID != owner || seats[0].Kind != SeatLeader {
t.Fatalf("expeditionParty on a solo run = %+v, want exactly the owner as leader. "+
"An empty answer here is what hires a level-1 companion.", seats)
}
humans, err := partyHumans("exp-alone", string(owner))
if err != nil || len(humans) != 1 {
t.Fatalf("partyHumans on a solo run = %+v (err %v), want the owner", humans, err)
}
}
func TestCompanion_RoleFillTakesTheHole(t *testing.T) {
tests := []struct {
name string
party []DnDClass
want DnDClass
}{
{"no healer", []DnDClass{ClassFighter, ClassRogue}, ClassCleric},
{"no front line", []DnDClass{ClassCleric, ClassMage}, ClassFighter},
{"no damage", []DnDClass{ClassCleric, ClassFighter}, ClassMage},
// A paladin covers healer AND front line, so paladin+rogue has no hole at
// all — it falls through to the default, a second body up front.
{"a complete party gets the default", []DnDClass{ClassPaladin, ClassRogue}, ClassFighter},
{"a paladin still leaves the damage hole", []DnDClass{ClassPaladin}, ClassMage},
{"solo fighter needs the medic first", []DnDClass{ClassFighter}, ClassCleric},
{"empty party", nil, ClassCleric},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := companionRoleFill(tc.party); got != tc.want {
t.Errorf("companionRoleFill(%v) = %v, want %v", tc.party, got, tc.want)
}
})
}
}
// §3 — an engine-driven seat's latch is permanent, and no command can take it.
//
// This is the invariant that, when it did not exist, made the companion stand in
// fights doing nothing. The expedition autopilot drives a party by dispatching
// each seat's turn AS that seat; his own auto-played move therefore arrived at
// beginCombatTurn looking exactly like a human returning to the keyboard, and the
// "they typed, so they're here" branch cleared the latch that was moving him.
// After round 1 he never acted again — while the boss he had inflated by 15% HP
// killed the party. The sweep measured it at -27pp clear rate.
//
// Autopilot is provisional (a human is away; a keystroke ends it). EngineDriven is
// not (there is nobody to come back). They must never collapse into one flag.
func TestCompanion_EngineSeatKeepsTheWheel(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
uid := id.UserID("@lead:example.org")
monster := dndBestiary["goblin"]
c, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
sess, err := p.startPartyCombatSession("run-e", "enc", "goblin", &enemy, []CombatSeatSetup{
{UserID: uid, HP: 200, HPMax: 200, Mods: c.Mods, C: &c},
{UserID: companionUserID(), HP: 120, HPMax: 120, Mods: c.Mods, C: &c, EngineDriven: true},
})
if err != nil {
t.Fatal(err)
}
if !sess.seatIsEngineDriven(1) {
t.Fatal("companion seat is not engine-driven")
}
if sess.seatIsEngineDriven(0) {
t.Fatal("the human's seat came out engine-driven")
}
// The engine drives it without waiting on anybody...
if !sess.seatIsAutopiloted(1) || !sess.seatNeedsNoHuman(1) {
t.Fatal("an engine seat must resolve without waiting for a human")
}
// ...and the human's seat still waits for its human.
if sess.seatIsAutopiloted(0) {
t.Fatal("the human's seat was latched onto autopilot at seating")
}
// The wheel cannot be taken back, because there is nobody to take it. This is
// the exact line that used to strand him: a "keystroke" from his own id.
sess.actorStatusesPtr(1).Autopilot = false
if !sess.seatIsAutopiloted(1) {
t.Fatal("clearing Autopilot stranded the engine seat — EngineDriven must keep it moving")
}
}
// The one that actually matters: he has to FIGHT. Every other guard in this
// file is about keeping him out of things, and a companion synthesized down to
// zeroed stats would pass all of them while standing in the fight doing nothing
// — a hire that silently buys the leader an empty chair.
func TestCompanion_ActuallySwings(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
monster := dndBestiary["goblin"]
tank := monster
tank.HP = 5000 // outlast the phase clock, so a real swing has time to land
pete, _, _ := p.companionCombatant(ClassFighter, 6, tank, 2, 0)
if pete.Stats.MaxHP <= 0 || pete.Stats.Attack <= 0 || pete.Stats.AC <= 0 {
t.Fatalf("companion built with dead stats (%d HP / %d atk / %d AC) — he'd stand in the fight and do nothing",
pete.Stats.MaxHP, pete.Stats.Attack, pete.Stats.AC)
}
enemy := Combatant{Name: tank.Name, Stats: CombatStats{MaxHP: 5000, AC: 10, Attack: 1, AttackBonus: 1}}
res := simulatePartyWithRNG([]Combatant{pete}, enemy, dungeonCombatPhases, seededRNG(7))
var swings int
for _, e := range res.Events {
if e.Actor == "player" && e.Seat == 0 && e.Roll > 0 {
swings++
}
}
if swings == 0 {
t.Fatal("the companion never swung — he was hired, seated, and did nothing")
}
}
func TestCompanion_SheetIsBelowMedianAndNeverPersisted(t *testing.T) {
setupEmptyTestDB(t)
// He is statted like a player of his level, so he never drifts from the tuned
// math — but he arrives a level down, which is the whole "help, never a carry"
// rule expressed in one number.
dc := companionSheet(ClassFighter, 7)
if dc.Level != 7 || dc.HPMax <= 0 {
t.Fatalf("companionSheet = level %d / %d HP, want a real level-7 chassis", dc.Level, dc.HPMax)
}
// And he leaves no trace: a player_meta row is the thing that would turn him
// into a real character everywhere (graveyard, news, XP, leaderboards), so the
// synthesis must not have written one.
if c, _ := LoadDnDCharacter(companionUserID()); c != nil {
t.Fatal("companion has a persisted dnd_character row — he is a player now, which is the one thing he must never be")
}
if _, err := loadAdvCharacter(companionUserID()); err == nil {
t.Fatal("companion has a persisted player_meta row — ensureDnDCharacterForCombat will auto-build him a character on his first swing")
}
}

View File

@@ -17,26 +17,26 @@ import (
type ConsumableEffect string
const (
EffectHeal ConsumableEffect = "heal"
EffectDefBoost ConsumableEffect = "def_boost"
EffectAtkBoost ConsumableEffect = "atk_boost"
EffectWard ConsumableEffect = "ward"
EffectHeal ConsumableEffect = "heal"
EffectDefBoost ConsumableEffect = "def_boost"
EffectAtkBoost ConsumableEffect = "atk_boost"
EffectWard ConsumableEffect = "ward"
EffectSpeedBoost ConsumableEffect = "speed_boost"
EffectCritBoost ConsumableEffect = "crit_boost"
EffectFlatDmg ConsumableEffect = "flat_dmg"
EffectSpore ConsumableEffect = "spore"
EffectReflect ConsumableEffect = "reflect"
EffectAutoCrit ConsumableEffect = "auto_crit"
EffectCritBoost ConsumableEffect = "crit_boost"
EffectFlatDmg ConsumableEffect = "flat_dmg"
EffectSpore ConsumableEffect = "spore"
EffectReflect ConsumableEffect = "reflect"
EffectAutoCrit ConsumableEffect = "auto_crit"
)
type ConsumableDef struct {
Name string
Effect ConsumableEffect
Value float64 // meaning depends on effect
Tier int
Buyable bool
Price int64
Slot string // "offensive" or "defensive"
Name string
Effect ConsumableEffect
Value float64 // meaning depends on effect
Tier int
Buyable bool
Price int64
Slot string // "offensive" or "defensive"
}
// ConsumableItem is an AdvItem that has consumable properties.
@@ -65,14 +65,17 @@ var consumableDefs = []ConsumableDef{
{Name: "Voidstone Shard", Effect: EffectReflect, Value: 0.50, Tier: 5, Buyable: false, Slot: "defensive"},
}
// consumableDefByName returns the definition for a consumable by name.
// consumableDefByName returns the definition for a consumable by name. Falls
// through to the magic-item registry so SRD potions/scrolls picked up as loot
// or bought from Luigi's Curios shelf resolve in combat without being added
// to the hardcoded consumableDefs table (see magic_items_gameplay.go).
func consumableDefByName(name string) *ConsumableDef {
for i := range consumableDefs {
if consumableDefs[i].Name == name {
return &consumableDefs[i]
}
}
return nil
return magicItemConsumableDefByName(name)
}
// ── Threat Assessment ────────────────────────────────────────────────────────
@@ -86,17 +89,31 @@ const (
threatDangerous // > 1.0
)
// assessThreat estimates rounds-to-kill in both directions using the same
// penetration model as the combat engine, then bands the threat by the ratio
// of player-RTK to enemy-RTK. Old additive HP+Attack model ignored Defense and
// rated even fights as "trivial" — players died after consumables were skipped.
func assessThreat(player, enemy CombatStats) threatLevel {
playerPower := float64(player.MaxHP + player.Attack*3)
enemyPower := float64(enemy.MaxHP + enemy.Attack*3)
if playerPower == 0 {
const K = 40.0
dprPlayer := float64(player.Attack) * (K / (K + float64(enemy.Defense)))
dprEnemy := float64(enemy.Attack) * (K / (K + float64(player.Defense)))
if dprPlayer < 1 {
dprPlayer = 1
}
if dprEnemy < 1 {
dprEnemy = 1
}
rtkEnemy := float64(enemy.MaxHP) / dprPlayer // rounds for player to kill enemy
rtkPlayer := float64(player.MaxHP) / dprEnemy // rounds for enemy to kill player
if rtkPlayer <= 0 {
return threatDangerous
}
ratio := enemyPower / playerPower
// ratio < 1: player wins the race (lower = more dominant). >1: enemy wins.
ratio := rtkEnemy / rtkPlayer
switch {
case ratio < 0.4:
case ratio < 0.35:
return threatTrivial
case ratio < 0.7:
case ratio < 0.65:
return threatEasy
case ratio < 1.0:
return threatCompetitive
@@ -108,26 +125,28 @@ func assessThreat(player, enemy CombatStats) threatLevel {
// ── Selection Logic ──────────────────────────────────────────────────────────
// SelectConsumables picks up to 2 items (1 offensive + 1 defensive) from inventory.
// contentTier caps consumable tier to the content being fought (0 = no cap).
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem {
// contentTier is retained on the signature for callers but no longer caps
// what gets considered — if it's in your bag, the picker can use it.
// allowSkipTrivial=true lets the picker bail out of obvious wins to save
// items — appropriate for dungeon dives full of chump rooms, but wrong for
// arena/zone elite/boss encounters where the legacy threat assessor
// underestimates d20-mode lethality (one bad nat-20 streak ends the run).
//
// Resource-saving still happens via betterOffensive/betterDefensive, which
// prefer lower-tier items on non-Dangerous threats. Players with mixed
// inventories don't burn a Voidstone Shard on a goblin; players with only
// high-tier items now actually get to use them.
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int, allowSkipTrivial bool) []ConsumableItem {
_ = contentTier // retained for caller compatibility
threat := assessThreat(playerStats, enemyStats)
if threat == threatTrivial {
if threat == threatTrivial && allowSkipTrivial && arenaRound == 0 {
return nil
}
maxTier := maxConsumableTier(threat, arenaRound)
if contentTier > 0 && contentTier < maxTier {
maxTier = contentTier
}
var bestOffensive, bestDefensive *ConsumableItem
for i := range inventory {
item := &inventory[i]
if item.Def.Tier > maxTier {
continue
}
switch item.Def.Slot {
case "offensive":
if bestOffensive == nil || betterOffensive(item, bestOffensive, threat) {
@@ -150,23 +169,6 @@ func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats Comba
return selected
}
func maxConsumableTier(threat threatLevel, arenaRound int) int {
base := 1
switch threat {
case threatEasy:
base = 2
case threatCompetitive:
base = 3
case threatDangerous:
base = 5
}
// Arena: spend freely on later rounds
if arenaRound >= 3 {
base = min(5, base+1)
}
return base
}
// betterOffensive prefers lowest tier that's appropriate for the threat.
func betterOffensive(candidate, current *ConsumableItem, threat threatLevel) bool {
if threat == threatDangerous {
@@ -232,8 +234,13 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
if rand.Float64() >= 0.15 {
return nil
}
name := names[rand.IntN(len(names))]
def := consumableDefByName(name)
return consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))]))
}
// consumableAdvItem builds the inventory row for a consumable def. Drop-only
// consumables carry no shop price, so their sell value falls back to a
// per-tier baseline.
func consumableAdvItem(def *ConsumableDef) *AdvItem {
if def == nil {
return nil
}
@@ -250,6 +257,23 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
}
}
// consumableCache draws n consumables from the dungeon drop pool at `tier`,
// used by guaranteed grants (milestone caches) rather than the drop roll.
// Tier 1 has no dungeon pool, so it yields the buyable Berry Poultice.
func consumableCache(tier, n int) []AdvItem {
names := consumableDropTable[AdvActivityDungeon][tier]
if len(names) == 0 {
names = []string{"Berry Poultice"}
}
out := make([]AdvItem, 0, n)
for range n {
if item := consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))])); item != nil {
out = append(out, *item)
}
}
return out
}
// BuyableConsumables returns all consumable defs that can be purchased from the shop.
func BuyableConsumables() []ConsumableDef {
var result []ConsumableDef
@@ -322,15 +346,34 @@ var craftingRecipes = []CraftingRecipe{
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
}
// craftingSuccessRate returns the success chance for a recipe given foraging level.
// Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%.
func craftingSuccessRate(foragingLevel, minForaging int) float64 {
// houseTierWorkshop is the T3 "Comfortable" house that unlocks the workshop —
// a flat craft-success bonus while crafting at home.
const houseTierWorkshop = 3
// homeWorkshopCraftBonus returns the additive craft-success bonus from a T3+
// home workshop. Tier 1/2 and no house grant none.
func homeWorkshopCraftBonus(houseTier int) float64 {
if houseTier >= houseTierWorkshop {
return 0.05
}
return 0
}
// craftingSuccessRate returns the success chance for a recipe given foraging
// level. Base rate is 50% at minimum level, +3% per 5 levels above minimum,
// capped at 95%. A T3+ home workshop adds workshopBonus on top and lifts the
// cap to 98%, so a maxed forager still gains a little from the bench.
func craftingSuccessRate(foragingLevel, minForaging int, workshopBonus float64) float64 {
base := 0.50
levelsAbove := foragingLevel - minForaging
bonus := float64(levelsAbove) / 5.0 * 0.03
rate := base + bonus
if rate > 0.95 {
return 0.95
rate := base + bonus + workshopBonus
cap := 0.95
if workshopBonus > 0 {
cap = 0.98
}
if rate > cap {
return cap
}
return rate
}
@@ -345,7 +388,7 @@ type CraftResult struct {
// their current foraging level, plus a teaser line for the next unlock
// threshold. Hides exact ingredient lists for recipes the player hasn't
// unlocked — only the count of locked recipes is shown.
func renderRecipesKnown(foragingLevel int) string {
func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
@@ -353,6 +396,9 @@ func renderRecipesKnown(foragingLevel int) string {
sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._")
return sb.String()
}
if workshopBonus > 0 {
sb.WriteString(fmt.Sprintf("_🔨 Home workshop: +%.0f%% craft success._\n\n", workshopBonus*100))
}
// Group recipes by tier, list those known.
known := map[int][]CraftingRecipe{}
@@ -378,7 +424,7 @@ func renderRecipesKnown(foragingLevel int) string {
}
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
for _, r := range recipes {
rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100
rate := craftingSuccessRate(foragingLevel, r.MinForaging, workshopBonus) * 100
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
r.Result, strings.Join(r.Ingredients, " + "), rate))
}
@@ -409,7 +455,7 @@ var craftXPFailure = map[int]int{1: 3, 2: 5, 3: 8, 4: 12, 5: 18}
//
// Side effects: grants foraging XP per attempt (success > failure) and bumps
// the player's CraftsSucceeded counter on each success.
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) ([]CraftResult, []AdvItem) {
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int, workshopBonus float64) ([]CraftResult, []AdvItem) {
if foragingLevel < 10 {
return nil, items
}
@@ -428,7 +474,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int)
break
}
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus)
success := rand.Float64() < rate
if success {

View File

@@ -19,7 +19,7 @@ func TestSelectConsumables_TrivialFightSkips(t *testing.T) {
weak := CombatStats{MaxHP: 30, Attack: 5}
inv := makeInventory("Berry Poultice", "Coal Bomb")
selected := SelectConsumables(inv, strong, weak, 0, 0)
selected := SelectConsumables(inv, strong, weak, 0, 0, true)
if len(selected) != 0 {
t.Errorf("should skip consumables for trivial fight, got %d", len(selected))
}
@@ -30,7 +30,7 @@ func TestSelectConsumables_DangerousUsesHighTier(t *testing.T) {
strong := CombatStats{MaxHP: 200, Attack: 50}
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil")
selected := SelectConsumables(inv, weak, strong, 0, 0)
selected := SelectConsumables(inv, weak, strong, 0, 0, true)
if len(selected) != 2 {
t.Fatalf("expected 2 consumables, got %d", len(selected))
}
@@ -60,7 +60,7 @@ func TestSelectConsumables_MaxTwo(t *testing.T) {
"Coal Bomb", "Goblin Grease", "Blooper Ink Vial",
)
selected := SelectConsumables(inv, player, enemy, 0, 0)
selected := SelectConsumables(inv, player, enemy, 0, 0, true)
if len(selected) > 2 {
t.Errorf("max 2 consumables, got %d", len(selected))
}
@@ -87,7 +87,7 @@ func TestSelectConsumables_EasyUsesLowTier(t *testing.T) {
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil")
selected := SelectConsumables(inv, player, enemy, 0, 0)
selected := SelectConsumables(inv, player, enemy, 0, 0, true)
for _, s := range selected {
if s.Def.Tier > 2 {
t.Errorf("easy fight should use T1-T2, got %s (T%d)", s.Def.Name, s.Def.Tier)
@@ -101,8 +101,8 @@ func TestSelectConsumables_ArenaRoundEscalation(t *testing.T) {
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Sapphire Elixir")
r1 := SelectConsumables(inv, player, enemy, 1, 0)
r4 := SelectConsumables(inv, player, enemy, 4, 0)
r1 := SelectConsumables(inv, player, enemy, 1, 0, true)
r4 := SelectConsumables(inv, player, enemy, 4, 0, true)
maxTierR1 := 0
for _, s := range r1 {

View File

@@ -0,0 +1,718 @@
package plugin
// C2 — player-initiated duels (engagement plan N6).
//
// `!duel @user [stake]` — a staked, no-death arena bout between two real
// characters, resolved through the auto-resolve combat engine. Both players
// escrow the stake on accept; the winner takes 70% of the pooled 2×stake and
// the community pot rakes the remaining 30% (a real pot sink). Records land in
// adventure_rival_records, so one W/L history and one 7-day per-pair cooldown
// covers both duel kinds (the bot-driven RPS rival and this).
//
// Why this is a wrapper and not a combat-engine change: the turn engine is an
// N-players-vs-1-monster model — there is no seat for a second real character
// on the enemy side (see the C2 feasibility note in the engagement plan). So a
// duel does not seat two PCs against each other. Instead each fighter is built
// as their real player Combatant (full kit: weapon, subclass passives, extra
// attacks, race traits) and the *opponent* is synthesised as a monster-shaped
// enemy stat block from their own sheet. That is asymmetric — the "player" side
// runs its full kit while the synthesised enemy is a flat stat block — so a
// single bout would systematically favour whoever is the attacker. The duel
// runs the bout in BOTH orientations and decides on the aggregate HP margin,
// which cancels that edge symmetrically. To-hit is faithful either way (both
// sides roll d20 + AttackBonus vs AC); only damage-per-hit is folded into the
// synthesised enemy's flat Attack. Class-vs-class PvP balance is explicitly out
// of scope (bragging rights, capped stakes).
import (
"fmt"
"math"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"github.com/google/uuid"
"maunium.net/go/mautrix/id"
)
const (
duelMinLevel = 3 // both duellists must be at least this level
duelAcceptWindow = 24 * time.Hour // an unanswered challenge expires and refunds
duelStakePerLvl = 500 // stake cap = challenger level × this
duelMinStake = 100 // floor so a "duel" always has something on it
duelWinnerPct = 0.70 // winner's cut of the pooled 2×stake; pot rakes the rest
)
// advDuelChallenge is one outstanding duel invitation. Unlike the RPS rival
// challenge it carries no per-round score — a combat duel resolves in a single
// step on accept. The challenger's stake is already escrowed (debited) when the
// row exists; the challenged player's is debited on accept.
type advDuelChallenge struct {
ChallengeID string
ChallengerID id.UserID
ChallengedID id.UserID
Stake int
ExpiresAt time.Time
CreatedAt time.Time
}
// ── Persistence ──────────────────────────────────────────────────────────────
func insertDuelChallenge(c *advDuelChallenge) {
db.Exec("duel: insert challenge",
`INSERT INTO adventure_duel_challenges
(challenge_id, challenger_id, challenged_id, stake, expires_at)
VALUES (?, ?, ?, ?, ?)`,
c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID), c.Stake, c.ExpiresAt,
)
}
func deleteDuelChallenge(challengeID string) {
db.Exec("duel: delete challenge",
`DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID)
}
// claimDuelChallenge atomically deletes the row and reports whether THIS caller
// is the one that removed it. It is the single serialization point for a
// challenge's terminal fate: accept, decline and the expiry ticker all race to
// claim it, and exactly one wins. Only the winner moves euros — that is what
// stops a stake being both resolved and refunded across the 24h boundary, or a
// double `!duel decline` double-refunding. Mirrors communityPotDebit's
// RowsAffected gate.
func claimDuelChallenge(challengeID string) bool {
res := db.ExecResult("duel: claim challenge",
`DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID)
if res == nil {
return false
}
n, _ := res.RowsAffected()
return n == 1
}
func scanDuelChallenge(row interface{ Scan(...any) error }) (*advDuelChallenge, error) {
c := &advDuelChallenge{}
err := row.Scan(&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
if err != nil {
return nil, err
}
return c, nil
}
// pendingDuelForChallenged returns the live (unexpired) challenge awaiting this
// player's answer, or nil. Expired rows are ignored here and swept by the
// ticker, so a player is never shown a challenge they can no longer accept.
func pendingDuelForChallenged(userID id.UserID) *advDuelChallenge {
c := &advDuelChallenge{}
err := db.Get().QueryRow(`
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
FROM adventure_duel_challenges
WHERE challenged_id = ? AND expires_at > CURRENT_TIMESTAMP
ORDER BY created_at DESC LIMIT 1`, string(userID)).Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
if err != nil {
return nil
}
return c
}
// duelInvolves reports whether the player has any live challenge in flight, as
// challenger or challenged. Used to enforce one duel at a time per player — the
// escrow makes overlapping challenges a way to double-commit the same euros.
func duelInvolves(userID id.UserID) bool {
var n int
db.Get().QueryRow(`
SELECT COUNT(*) FROM adventure_duel_challenges
WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
string(userID), string(userID)).Scan(&n)
return n > 0
}
func loadExpiredDuelChallenges() []advDuelChallenge {
rows, err := db.Get().Query(`
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
FROM adventure_duel_challenges WHERE expires_at <= CURRENT_TIMESTAMP`)
if err != nil {
return nil
}
defer rows.Close()
var out []advDuelChallenge
for rows.Next() {
if c, err := scanDuelChallenge(rows); err == nil {
out = append(out, *c)
}
}
return out
}
// ── Stake / gating ───────────────────────────────────────────────────────────
// duelMaxStake caps the stake at level × duelStakePerLvl so a strong player
// can't farm a runaway pot off a weaker one, per the plan's imbalance cap.
func duelMaxStake(level int) int { return level * duelStakePerLvl }
// duelPayout splits the pooled 2×stake: the winner takes duelWinnerPct, the
// community pot rakes the remainder. Both fighters escrowed stake, so the
// winner's net gain is winnerShare stake and the pot is a real sink.
func duelPayout(stake int) (winnerShare, potShare int) {
pool := stake * 2
winnerShare = int(math.Round(float64(pool) * duelWinnerPct))
potShare = pool - winnerShare
return
}
// duelEligible reports whether a character can duel at all: alive, set up, and
// at least duelMinLevel. Returns a player-facing reason when it can't.
func (p *AdventurePlugin) duelEligible(char *AdventureCharacter) (int, string) {
if char == nil {
return 0, "no adventurer"
}
if !char.Alive {
return 0, "dead"
}
lvl := rivalLevelForUser(char)
if lvl < duelMinLevel {
return lvl, fmt.Sprintf("below level %d", duelMinLevel)
}
return lvl, ""
}
// ── Combat synthesis ─────────────────────────────────────────────────────────
// buildDuelist derives a fighter's full player Combatant — the same build a
// zone encounter uses, so weapon, subclass passives, extra attacks and race
// traits all apply — then resets it to full health. A duel is a fresh arena
// bout, not a continuation of a wounded expedition, so wound carry-over is
// dropped (StartHP=0 = "enter at MaxHP"). Mood 50 is the neutral band: no
// initiative bias, no enemy-attack tilt.
//
// The armed-ability slot is left empty: actively-armed one-shots (a Berserker's
// rage) don't fire, but always-on passives (Extra Attack, Sneak Attack, Divine
// Strike, race traits) still layer in via the class/subclass passive builders.
func (p *AdventurePlugin) buildDuelist(userID id.UserID) (Combatant, error) {
var noMonster DnDMonsterTemplate
player, _, _, err := p.buildZoneCombatants(userID, noMonster, 1, 50, "")
if err != nil {
return Combatant{}, err
}
player.Stats.StartHP = 0
return player, nil
}
// duelPerHitDamage is a fighter's expected damage on a single landed hit, used
// to size the flat Attack of their synthesised enemy stat block. It mirrors the
// player damage path's terms that the enemy path can't express: weapon dice
// average (or the legacy flat Attack for a weaponless build), the always-on
// per-hit riders (Divine Strike, Sneak Attack, Hunter's Mark, rage), then the
// multiplicative damage bonus. Crit chance and once-per-fight openers are not
// modelled — a coarse mean is enough for a capped-stakes bout decided over two
// orientations.
func duelPerHitDamage(pc Combatant) float64 {
base := float64(pc.Stats.Attack)
if w := pc.Stats.Weapon; w != nil {
if pc.Stats.TwoHandedMode && w.HasProperty(PropVersatile) && w.VersaCount > 0 {
base = float64(w.VersaCount)*(float64(w.VersaSides)+1)/2 +
float64(pc.Stats.AbilityModForDamage) + float64(w.MagicBonus)
} else {
base = avgWeaponDamage(w, pc.Stats.AbilityModForDamage)
}
}
riders := float64(pc.Mods.DivineStrikePerHit)
riders += float64(pc.Mods.SneakAttackDie) * 3.5
riders += float64(pc.Mods.HuntersMarkDie) * 3.5
if pc.Mods.BerserkerRage {
riders += float64(pc.Mods.RageMeleeDmg)
}
perHit := (base + riders) * (1 + pc.Mods.DamageBonus)
if perHit < 1 {
perHit = 1
}
return perHit
}
// duelPerRoundProcDamage is the expected per-round damage from a fighter's
// companion strikes — a Cleric's spiritual weapon and a Beastmaster's pet —
// which land once per round on a proc roll rather than per weapon hit. The live
// fighter rolls these natively in the engine; folding their expectation into the
// synth enemy's round damage keeps both orientations comparable for those
// builds (each formula is Dmg + d5, mean +3).
func duelPerRoundProcDamage(pc Combatant) float64 {
dmg := pc.Mods.SpiritWeaponProc * (float64(pc.Mods.SpiritWeaponDmg) + 3)
dmg += pc.Mods.PetAttackProc * (float64(pc.Mods.PetAttackDmg) + 3)
return dmg
}
// synthDuelEnemy turns a fighter's player Combatant into the monster-shaped
// enemy the opposite fighter faces. HP, AC and AttackBonus carry over verbatim
// (to-hit stays faithful — the enemy rolls d20 + AttackBonus vs the attacker's
// AC exactly as the player does). DamageReduct carries over too, so a
// Barbarian's / Monk's flat mitigation is as tanky as the enemy as it is as the
// live PC. Offense is the lossy term: the enemy path deals a flat Attack, so a
// whole round — weapon swings (per-hit × (1 + extra attacks)) plus the expected
// companion-strike damage — is folded into one enemy Attack, preserving expected
// round damage while widening variance (a wash across the two orientations).
//
// Deliberately NOT modelled on the enemy side (accepted scope — the plan frames
// duels as bragging rights and puts class-vs-class PvP balance explicitly out of
// scope): reactive one-shots the enemy engine has no channel for — heal items,
// wards, the Sovereign death-save, the arcane ward buffer — and spell damage,
// which buildZoneCombatants never wires for the live caster either, so a caster
// fights weapon-only in both orientations. Defense is dropped: the weapon-damage
// path players use reads DamageReduct, not the enemy's Defense stat.
func synthDuelEnemy(pc Combatant) Combatant {
swings := 1 + pc.Mods.ExtraAttacks
roundDmg := duelPerHitDamage(pc)*float64(swings) + duelPerRoundProcDamage(pc)
return Combatant{
Name: pc.Name,
IsPlayer: false,
Stats: CombatStats{
MaxHP: pc.Stats.MaxHP,
AC: pc.Stats.AC,
AttackBonus: pc.Stats.AttackBonus,
Attack: int(math.Round(roundDmg)),
},
Mods: CombatModifiers{DamageReduct: pc.Mods.DamageReduct},
}
}
// duelResult is the two-orientation verdict. challengerMargin/challengedMargin
// are aggregate remaining-HP fractions across both bouts (0..2 each); the higher
// wins. decisive is true when one fighter won both orientations outright — pure
// narration, the margin is what decides.
type duelResult struct {
challengerWins bool
draw bool
decisive bool
challengerFrac float64
challengedFrac float64
}
func hpFraction(end, start int) float64 {
if start <= 0 {
return 0
}
if end < 0 {
end = 0
}
return float64(end) / float64(start)
}
// resolveDuel runs the bout both ways and decides on the aggregate HP margin.
// Orientation 1 seats the challenger as the live fighter against the challenged
// player's stat block; orientation 2 swaps them. Each fighter's margin sums the
// HP fraction they kept as the live fighter in one bout and as the stat block in
// the other, so the attacker's-kit advantage is applied to both fighters once
// and cancels. rng is threaded for the seeded characterization/unit tests; nil
// is production (package-global RNG).
func resolveDuel(challenger, challenged Combatant, rng *rand.Rand) duelResult {
r1 := simulateCombatWithRNG(challenger, synthDuelEnemy(challenged), defaultCombatPhases, rng)
r2 := simulateCombatWithRNG(challenged, synthDuelEnemy(challenger), defaultCombatPhases, rng)
cFrac := hpFraction(r1.PlayerEndHP, r1.PlayerStartHP) + hpFraction(r2.EnemyEndHP, r2.EnemyStartHP)
dFrac := hpFraction(r1.EnemyEndHP, r1.EnemyStartHP) + hpFraction(r2.PlayerEndHP, r2.PlayerStartHP)
res := duelResult{challengerFrac: cFrac, challengedFrac: dFrac}
switch {
case cFrac > dFrac:
res.challengerWins = true
case dFrac > cFrac:
res.challengerWins = false
default:
res.draw = true
}
// Decisive = the winner also took both orientations outright.
if !res.draw {
if res.challengerWins {
res.decisive = r1.PlayerWon && !r2.PlayerWon
} else {
res.decisive = r2.PlayerWon && !r1.PlayerWon
}
}
return res
}
// ── Command surface ──────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleDuelCmd(ctx MessageContext, args string) error {
args = strings.TrimSpace(args)
fields := strings.Fields(args)
if len(fields) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
"⚔️ **Duels** — challenge another adventurer to a staked, no-death bout.\n"+
"`!duel @user [stake]` — throw down · `!duel accept` / `!duel decline` · `!duel status`")
}
switch strings.ToLower(fields[0]) {
case "accept":
return p.acceptDuel(ctx)
case "decline":
return p.declineDuel(ctx)
case "status":
return p.duelStatusCmd(ctx)
default:
return p.issueDuel(ctx, fields)
}
}
func (p *AdventurePlugin) issueDuel(ctx MessageContext, fields []string) error {
targetID, ok := p.ResolveUser(fields[0], ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve that user. Usage: `!duel @user [stake]`")
}
if targetID == ctx.Sender {
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't duel yourself.")
}
// Serialise the one-duel gate, balance check and escrow so two rapid `!duel`
// from the same challenger can't both pass duelInvolves and double-debit.
lock := p.advUserLock(ctx.Sender)
lock.Lock()
defer lock.Unlock()
challenger, err := loadAdvCharacter(ctx.Sender)
if err != nil || challenger == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer. Type `!adventure` to create one.")
}
lvl, reason := p.duelEligible(challenger)
if reason != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason))
}
challenged, err := loadAdvCharacter(targetID)
if err != nil || challenged == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no adventurer to duel.", p.DisplayName(targetID)))
}
if _, reason := p.duelEligible(challenged); reason != "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s can't duel right now (%s).", p.DisplayName(targetID), reason))
}
if challenged.BabysitActive {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s left word with the Babysitting Service — no duels while they're away.", p.DisplayName(targetID)))
}
// One duel at a time per player, either side — the escrow makes overlaps a
// double-commit of the same euros.
if duelInvolves(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "You already have a duel in flight. Resolve it first.")
}
if duelInvolves(targetID) {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s already has a duel in flight.", p.DisplayName(targetID)))
}
// Shared 7-day per-pair cooldown across both duel kinds (records table).
if last := lastDuelBetween(ctx.Sender, targetID); !last.IsZero() && time.Since(last) < rivalSamePairCooldown {
wait := rivalSamePairCooldown - time.Since(last)
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You've dueled %s too recently. Try again %s.", p.DisplayName(targetID), formatDuration(wait)))
}
stake, err := p.parseDuelStake(fields, lvl)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, err.Error())
}
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < stake {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You can't cover a €%d stake — you have €%d.", stake, int(bal)))
}
// Escrow the challenger's stake now (debited to nowhere; the pot/winner split
// redistributes it on resolution, and expiry/decline refunds it).
if !p.euro.Debit(ctx.Sender, float64(stake), "duel_escrow") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. Try again.")
}
ch := &advDuelChallenge{
ChallengeID: uuid.NewString(),
ChallengerID: ctx.Sender,
ChallengedID: targetID,
Stake: stake,
ExpiresAt: time.Now().UTC().Add(duelAcceptWindow),
}
insertDuelChallenge(ch)
p.SendDM(targetID, fmt.Sprintf(
"⚔️ **%s challenges you to a duel!**\n\nStake: €%d each — winner takes 70%% of the pot.\n"+
"No death, no hospital bill. Just bragging rights.\n\n`!duel accept` or `!duel decline` — you have 24 hours.",
p.DisplayName(ctx.Sender), stake))
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"⚔️ You throw down the gauntlet before **%s** for €%d. They have 24 hours to answer.",
p.DisplayName(targetID), stake))
}
// parseDuelStake reads the optional stake argument, defaulting to the cap for
// the challenger's level and clamping into [duelMinStake, duelMaxStake].
func (p *AdventurePlugin) parseDuelStake(fields []string, level int) (int, error) {
maxStake := duelMaxStake(level)
if len(fields) < 2 {
return maxStake, nil
}
raw := strings.TrimPrefix(fields[1], "€")
stake := 0
if _, err := fmt.Sscanf(raw, "%d", &stake); err != nil || stake <= 0 {
return 0, fmt.Errorf("that's not a valid stake. Usage: `!duel @user [stake]`")
}
if stake < duelMinStake {
return 0, fmt.Errorf("minimum stake is €%d", duelMinStake)
}
if stake > maxStake {
return 0, fmt.Errorf("your stake is capped at €%d (level %d × €%d)", maxStake, level, duelStakePerLvl)
}
return stake, nil
}
func (p *AdventurePlugin) acceptDuel(ctx MessageContext) error {
// Serialise a player's own accept so a double-submit can't debit twice.
lock := p.advUserLock(ctx.Sender)
lock.Lock()
defer lock.Unlock()
ch := pendingDuelForChallenged(ctx.Sender)
if ch == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to accept.")
}
// Cheap validation first — these leave the challenge open (the player can
// revive/earn and retry, or decline to release the challenger's escrow).
challenged, err := loadAdvCharacter(ctx.Sender)
if err != nil || challenged == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer.")
}
if _, reason := p.duelEligible(challenged); reason != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason))
}
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < ch.Stake {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You can't cover the €%d stake — you have €%d. `!duel decline` to back out.", ch.Stake, int(bal)))
}
// The challenger may have died or dropped below the gate during the 24h
// window. If so, cancel the duel: claim the row (so the ticker/decline can't
// also refund) and hand the challenger their escrow back.
challenger, cerr := loadAdvCharacter(ch.ChallengerID)
if cerr != nil || challenger == nil {
if claimDuelChallenge(ch.ChallengeID) {
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_gone")
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Your challenger is no longer available. The duel is off.")
}
if _, reason := p.duelEligible(challenger); reason != "" {
if claimDuelChallenge(ch.ChallengeID) {
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_ineligible")
p.SendDM(ch.ChallengerID, fmt.Sprintf(
"⚔️ Your duel with **%s** was called off — you can't fight right now. Your €%d stake is refunded.",
p.DisplayName(ctx.Sender), ch.Stake))
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("**%s** can't duel right now (%s). The challenge is off.", p.DisplayName(ch.ChallengerID), reason))
}
// Build both fighters BEFORE debiting the challenged player, so the deep
// sheet-loading / combat code (the panic-prone part) runs while nobody but
// the challenger is committed. A build failure refunds only the challenger.
challengerC, err1 := p.buildDuelist(ch.ChallengerID)
challengedC, err2 := p.buildDuelist(ctx.Sender)
if err1 != nil || err2 != nil {
if claimDuelChallenge(ch.ChallengeID) {
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error")
}
return p.SendReply(ctx.RoomID, ctx.EventID, "The duel couldn't be staged. The challenger's stake is refunded.")
}
// Commit: claim the row (exactly one path wins), then take the challenged
// player's stake. From here both stakes are held and resolution is pure
// arithmetic — no further build or DB read that could panic mid-payout.
if !claimDuelChallenge(ch.ChallengeID) {
return p.SendReply(ctx.RoomID, ctx.EventID, "That duel is no longer available.")
}
if !p.euro.Debit(ctx.Sender, float64(ch.Stake), "duel_escrow") {
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error")
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. The duel is off.")
}
return p.settleDuel(ctx, ch, challengerC, challengedC)
}
func (p *AdventurePlugin) declineDuel(ctx MessageContext) error {
// Same lock as accept so a player's own accept/decline can't interleave; the
// claim is the real guard, but the lock keeps the read→claim window tight.
lock := p.advUserLock(ctx.Sender)
lock.Lock()
defer lock.Unlock()
ch := pendingDuelForChallenged(ctx.Sender)
if ch == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.")
}
if !claimDuelChallenge(ch.ChallengeID) {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.")
}
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_declined")
p.SendDM(ch.ChallengerID, fmt.Sprintf(
"🛡️ **%s** declined your duel. Your €%d stake is refunded.", p.DisplayName(ctx.Sender), ch.Stake))
return p.SendReply(ctx.RoomID, ctx.EventID, "You decline the duel. No harm done.")
}
func (p *AdventurePlugin) duelStatusCmd(ctx MessageContext) error {
if ch := pendingDuelForChallenged(ctx.Sender); ch != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"⚔️ **%s** has challenged you for €%d. `!duel accept` / `!duel decline` (expires %s).",
p.DisplayName(ch.ChallengerID), ch.Stake, formatDuration(time.Until(ch.ExpiresAt))))
}
// Any challenge we issued that's still pending?
c := &advDuelChallenge{}
err := db.Get().QueryRow(`
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
FROM adventure_duel_challenges
WHERE challenger_id = ? AND expires_at > CURRENT_TIMESTAMP
ORDER BY created_at DESC LIMIT 1`, string(ctx.Sender)).Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
if err == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"⚔️ You've challenged **%s** for €%d. Waiting on their answer (expires %s).",
p.DisplayName(c.ChallengedID), c.Stake, formatDuration(time.Until(c.ExpiresAt))))
}
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no duels in flight. `!duel @user [stake]` to start one.")
}
// ── Resolution ───────────────────────────────────────────────────────────────
// settleDuel resolves the two-orientation bout, splits the pooled 2×stake,
// records the result, and DMs both players. By the time it runs the challenge
// row is claimed, both fighters are built, and both stakes are held — so from
// here it is pure arithmetic and message sends, nothing that can panic and
// strand the pool. On an exact HP-margin tie both stakes are refunded.
func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, challengerC, challengedC Combatant) error {
res := resolveDuel(challengerC, challengedC, nil)
if res.draw {
// Exact HP-margin tie: refund both, record nothing, no cooldown burned.
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_draw")
p.euro.Credit(ch.ChallengedID, float64(ch.Stake), "duel_refund_draw")
draw := fmt.Sprintf("⚔️ **%s** and **%s** fought to a standstill. Both stakes refunded.",
p.DisplayName(ch.ChallengerID), p.DisplayName(ch.ChallengedID))
p.SendDM(ch.ChallengerID, draw)
p.SendDM(ch.ChallengedID, draw)
return p.SendReply(ctx.RoomID, ctx.EventID, draw)
}
winnerID, loserID := ch.ChallengedID, ch.ChallengerID
if res.challengerWins {
winnerID, loserID = ch.ChallengerID, ch.ChallengedID
}
winnerShare, potShare := duelPayout(ch.Stake)
p.euro.Credit(winnerID, float64(winnerShare), "duel_win")
if potShare > 0 {
communityPotAdd(potShare)
// The rake came out of a pool both funded; attribute it half each.
trackTaxPaid(winnerID, potShare/2)
trackTaxPaid(loserID, potShare-potShare/2)
}
upsertRivalRecord(winnerID, loserID, true)
upsertRivalRecord(loserID, winnerID, false)
// One BULLETIN dispatch per settled duel (from the winner's side, so it
// fires once). Character names only; no-op unless the seam is enabled.
if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" {
ts := nowUnix()
disc := fmt.Sprintf("rival:%d", ts)
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), ts),
EventType: "rival_result",
Tier: "bulletin",
Subject: wn,
Opponent: ln,
Outcome: "won",
OccurredAt: ts,
}, winnerID, loserID)
}
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
return nil
}
func (p *AdventurePlugin) announceDuel(ctx MessageContext, winnerID, loserID id.UserID, stake, winnerShare, potShare int, decisive bool) {
winName, loseName := p.DisplayName(winnerID), p.DisplayName(loserID)
flair := pickDuelFlavor(duelWinFlavor)
if decisive {
flair = pickDuelFlavor(duelDecisiveFlavor)
}
p.SendDM(winnerID, fmt.Sprintf(
"⚔️ **You beat %s!**\n\n*%s*\n\nYou take €%d (net +€%d). €%d went to the community pot.",
loseName, flair, winnerShare, winnerShare-stake, potShare))
p.SendDM(loserID, fmt.Sprintf(
"⚔️ **%s bested you.**\n\n*%s*\n\nYou're down €%d — but you walk away, no worse for wear.",
winName, pickDuelFlavor(duelLossFlavor), stake))
// Broadcast the result to the games room for bragging rights — unless the
// duel was accepted there, in which case the SendReply below already covers
// it and a second line would just be noise.
if gr := gamesRoom(); gr != "" && gr != ctx.RoomID {
verb := "defeated"
if decisive {
verb = "decisively defeated"
}
p.SendMessage(gr, fmt.Sprintf("⚔️ **%s** %s **%s** in a duel for €%d.", winName, verb, loseName, stake))
}
// Confirm in the room the challenge was accepted from too.
p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("⚔️ **%s** wins the duel, taking €%d.", winName, winnerShare))
}
// expireDuelChallenges refunds and clears any challenge whose 24h window lapsed.
// Rides the 1-minute event ticker (no new goroutine); only the challenger was
// escrowed, so only they are refunded.
func (p *AdventurePlugin) expireDuelChallenges() {
for _, ch := range loadExpiredDuelChallenges() {
// Claim before refunding: if an accept/decline already took this row at
// the 24h boundary, we must not also refund the challenger.
if !claimDuelChallenge(ch.ChallengeID) {
continue
}
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_expired")
p.SendDM(ch.ChallengerID, fmt.Sprintf(
"⌛ **%s** never answered your duel. Your €%d stake is refunded.",
p.DisplayName(ch.ChallengedID), ch.Stake))
}
}
func duelIneligibleSelf(reason string) string {
switch reason {
case "dead":
return "You can't duel while dead. Visit `!hospital` first."
case "no adventurer":
return "You have no adventurer. Type `!adventure` to create one."
default:
return fmt.Sprintf("You need to be at least level %d to duel.", duelMinLevel)
}
}
// ── Flavor ───────────────────────────────────────────────────────────────────
var duelWinFlavor = []string{
"Steel rang, and the crowd knew its champion.",
"A clean finish — you left them nothing to answer with.",
"They fought well. You fought better.",
}
var duelDecisiveFlavor = []string{
"It was over before it began. Utterly one-sided.",
"You never gave them a foothold. A rout.",
"The arena barely had time to hush.",
}
var duelLossFlavor = []string{
"You'll want that one back. Next time.",
"A hard lesson — but only your pride is bleeding.",
"Close, in places. Not close enough.",
}
func pickDuelFlavor(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}

View File

@@ -0,0 +1,274 @@
package plugin
import (
"math"
"math/rand/v2"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func newDuelTestDB(t *testing.T) {
t.Helper()
db.Close()
if err := db.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// duelSeed builds a deterministic RNG so the two-orientation resolver is
// reproducible across runs — the production path passes nil (global RNG).
func duelSeed(i uint64) *rand.Rand { return rand.New(rand.NewPCG(0x1d4e11, i)) }
func TestDuelPayout(t *testing.T) {
// Both escrow 2000 → pool 4000 → winner 70% (2800), pot 30% (1200).
w, pot := duelPayout(2000)
if w != 2800 || pot != 1200 {
t.Fatalf("duelPayout(2000) = (%d, %d), want (2800, 1200)", w, pot)
}
// The pot always gets exactly what the winner doesn't; nothing is minted
// or lost against the pooled 2×stake.
for _, stake := range []int{100, 137, 999, 6000} {
w, pot := duelPayout(stake)
if w+pot != stake*2 {
t.Errorf("duelPayout(%d): winner+pot = %d, want pool %d", stake, w+pot, stake*2)
}
if w <= pot {
t.Errorf("duelPayout(%d): winner %d should exceed pot %d", stake, w, pot)
}
}
}
func TestDuelMaxStake(t *testing.T) {
if got := duelMaxStake(5); got != 2500 {
t.Fatalf("duelMaxStake(5) = %d, want 2500", got)
}
}
func TestDuelPerHitDamage(t *testing.T) {
// 1d8 (avg 4.5) + ability mod 3 = 7.5 base, + Divine Strike 2 = 9.5,
// × (1 + 0.25 damage bonus) = 11.875.
pc := Combatant{
Stats: CombatStats{
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
AbilityModForDamage: 3,
},
Mods: CombatModifiers{DivineStrikePerHit: 2, DamageBonus: 0.25},
}
got := duelPerHitDamage(pc)
if math.Abs(got-11.875) > 1e-9 {
t.Fatalf("duelPerHitDamage = %v, want 11.875", got)
}
// Weaponless build falls back to the flat Attack stat.
bare := Combatant{Stats: CombatStats{Attack: 9}}
if got := duelPerHitDamage(bare); got != 9 {
t.Fatalf("weaponless duelPerHitDamage = %v, want 9", got)
}
// A build that computes to <1 is floored to 1 (a fight always deals damage).
weak := Combatant{Stats: CombatStats{Attack: 0}}
if got := duelPerHitDamage(weak); got != 1 {
t.Fatalf("floored duelPerHitDamage = %v, want 1", got)
}
}
func TestSynthDuelEnemy(t *testing.T) {
pc := Combatant{
Name: "Aria",
IsPlayer: true,
Stats: CombatStats{
MaxHP: 123,
AC: 17,
AttackBonus: 8,
Defense: 5,
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
AbilityModForDamage: 3,
},
Mods: CombatModifiers{ExtraAttacks: 1}, // two swings/round
}
e := synthDuelEnemy(pc)
if e.IsPlayer {
t.Error("synthesised enemy must not be flagged IsPlayer")
}
if e.Ability != nil {
t.Error("v1 synth enemy carries no MonsterAbility")
}
// HP / AC / to-hit carry over verbatim: to-hit stays faithful both ways.
if e.Stats.MaxHP != 123 || e.Stats.AC != 17 || e.Stats.AttackBonus != 8 {
t.Errorf("stat carryover wrong: got HP=%d AC=%d AB=%d", e.Stats.MaxHP, e.Stats.AC, e.Stats.AttackBonus)
}
// Damage: per-hit (7.5) × swings (2) folded into one flat Attack = 15.
if e.Stats.Attack != 15 {
t.Errorf("folded Attack = %d, want 15 (7.5 × 2 swings)", e.Stats.Attack)
}
}
// TestResolveDuel_StrongerWinsRegardlessOfRole pins that a clearly superior
// build wins whether it issues or receives the challenge — the two-orientation
// resolver must not hand the win to the attacker seat.
func TestResolveDuel_StrongerWinsRegardlessOfRole(t *testing.T) {
strong := Combatant{
Name: "Champion", IsPlayer: true,
Stats: CombatStats{
MaxHP: 220, AC: 19, AttackBonus: 12,
Weapon: &WeaponProfile{DamageCount: 2, DamageSides: 8}, AbilityModForDamage: 5, WeaponProficient: true,
},
}
weak := Combatant{
Name: "Novice", IsPlayer: true,
Stats: CombatStats{
MaxHP: 45, AC: 11, AttackBonus: 3,
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 4}, AbilityModForDamage: 0, WeaponProficient: true,
},
}
for i := uint64(0); i < 50; i++ {
if r := resolveDuel(strong, weak, duelSeed(i)); r.draw || !r.challengerWins {
t.Fatalf("seed %d: strong-as-challenger should win, got %+v", i, r)
}
if r := resolveDuel(weak, strong, duelSeed(i)); r.draw || r.challengerWins {
t.Fatalf("seed %d: strong-as-challenged should win, got %+v", i, r)
}
}
}
// TestResolveDuel_IdenticalFightersAreUnbiased is the fairness guarantee that
// justifies running both orientations: with two identical builds neither the
// challenger nor the challenged seat may have a systematic edge. A single-
// orientation model (attacker uses full kit, defender is a stat block) would
// skew hard toward the challenger; the both-orientations margin cancels it.
func TestResolveDuel_IdenticalFightersAreUnbiased(t *testing.T) {
fighter := Combatant{
Name: "Mirror", IsPlayer: true,
Stats: CombatStats{
MaxHP: 110, AC: 15, AttackBonus: 7,
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 3, WeaponProficient: true,
},
}
const n = 1200
challengerWins, draws := 0, 0
for i := uint64(0); i < n; i++ {
r := resolveDuel(fighter, fighter, duelSeed(i))
switch {
case r.draw:
draws++
case r.challengerWins:
challengerWins++
}
}
decided := n - draws
if decided == 0 {
t.Fatal("all identical-fighter duels drew — resolver isn't discriminating")
}
// Unbiased by construction (challenger wins iff D1 > D2 for iid D); allow a
// generous ±10% band so this stays a bias check, not a flaky coin-count.
rate := float64(challengerWins) / float64(decided)
if rate < 0.40 || rate > 0.60 {
t.Fatalf("challenger win rate %.3f over %d decided duels — systematic bias (want ~0.5)", rate, decided)
}
}
// TestClaimDuelChallenge pins the serialization primitive that guards every
// euro path: exactly one caller may claim a challenge, so accept/decline/expire
// can never both move money for the same row.
func TestClaimDuelChallenge(t *testing.T) {
newDuelTestDB(t)
ch := &advDuelChallenge{
ChallengeID: "duel-1",
ChallengerID: id.UserID("@a:x"),
ChallengedID: id.UserID("@b:x"),
Stake: 1000,
ExpiresAt: time.Now().UTC().Add(time.Hour),
}
insertDuelChallenge(ch)
if pendingDuelForChallenged(id.UserID("@b:x")) == nil {
t.Fatal("challenge should be pending for the challenged player before any claim")
}
if !duelInvolves(id.UserID("@a:x")) || !duelInvolves(id.UserID("@b:x")) {
t.Fatal("duelInvolves should see both parties while the challenge is live")
}
if !claimDuelChallenge("duel-1") {
t.Fatal("first claim must succeed")
}
if claimDuelChallenge("duel-1") {
t.Fatal("second claim must fail — the row is gone, so no double refund/payout")
}
if pendingDuelForChallenged(id.UserID("@b:x")) != nil {
t.Fatal("claimed challenge must no longer be pending")
}
}
func TestLoadExpiredDuelChallenges(t *testing.T) {
newDuelTestDB(t)
insertDuelChallenge(&advDuelChallenge{
ChallengeID: "live", ChallengerID: "@a:x", ChallengedID: "@b:x",
Stake: 500, ExpiresAt: time.Now().UTC().Add(time.Hour),
})
insertDuelChallenge(&advDuelChallenge{
ChallengeID: "stale", ChallengerID: "@c:x", ChallengedID: "@d:x",
Stake: 500, ExpiresAt: time.Now().UTC().Add(-time.Hour),
})
got := loadExpiredDuelChallenges()
if len(got) != 1 || got[0].ChallengeID != "stale" {
t.Fatalf("loadExpiredDuelChallenges = %+v, want only the stale one", got)
}
}
// TestSynthDuelEnemy_PortsDefenseAndProcs pins the fidelity carry-overs added
// after review: a tank's DamageReduct rides onto the synth enemy, and companion
// procs fold their expected damage into its round Attack.
func TestSynthDuelEnemy_PortsDefenseAndProcs(t *testing.T) {
pc := Combatant{
Stats: CombatStats{
MaxHP: 100, AC: 15, AttackBonus: 6,
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 2, // 6.5/hit
},
Mods: CombatModifiers{
DamageReduct: 0.6, // Barbarian-style mitigation
SpiritWeaponProc: 0.5, SpiritWeaponDmg: 4, // expected 0.5×(4+3)=3.5/round
},
}
e := synthDuelEnemy(pc)
if e.Mods.DamageReduct != 0.6 {
t.Errorf("DamageReduct not ported: got %v, want 0.6", e.Mods.DamageReduct)
}
// One swing (6.5) + proc expectation (3.5) = 10 → rounded 10.
if e.Stats.Attack != 10 {
t.Errorf("folded Attack = %d, want 10 (6.5 weapon + 3.5 proc)", e.Stats.Attack)
}
}
func TestParseDuelStake(t *testing.T) {
p := &AdventurePlugin{}
const level = 5 // cap = 2500
// No stake → default to the cap.
if got, err := p.parseDuelStake([]string{"@u"}, level); err != nil || got != 2500 {
t.Fatalf("default stake = (%d, %v), want (2500, nil)", got, err)
}
// Explicit in-band stake, with and without the € prefix.
if got, err := p.parseDuelStake([]string{"@u", "€1500"}, level); err != nil || got != 1500 {
t.Fatalf("€1500 stake = (%d, %v), want (1500, nil)", got, err)
}
// Over the cap is rejected.
if _, err := p.parseDuelStake([]string{"@u", "9999"}, level); err == nil {
t.Error("stake above cap should error")
}
// Below the floor is rejected.
if _, err := p.parseDuelStake([]string{"@u", "10"}, level); err == nil {
t.Error("stake below the minimum should error")
}
// Garbage is rejected.
if _, err := p.parseDuelStake([]string{"@u", "lots"}, level); err == nil {
t.Error("non-numeric stake should error")
}
}

View File

@@ -23,98 +23,136 @@ type advActiveEvent struct {
ExpiresAt time.Time
}
// ── In-memory schedule ───────────────────────────────────────────────────────
// When a player acts on a given day, the next ticker iteration assigns them
// a one-shot roll-minute 60180 minutes in the future. At that minute, the
// 0.5% trigger roll fires. Each player rolls at most once per UTC day.
// ── Event anchors ────────────────────────────────────────────────────────────
// N1/A6 — events used to roll at 0.5%/player/day from a deferred ticker slot,
// which put one sighting in front of a daily player roughly every 200 days.
// They now roll at moments the player is demonstrably present and reading a
// DM: the end-of-day digest, a sale at Thom's, and an arena cashout. Each
// player still sees at most one event per UTC day.
//
// The per-anchor chances below put a player who hits all three at ~1 event
// per week; see TestAnchoredEventWeeklyRate.
//
// K — the three social/economic anchors miss the pure grind loop: a player who
// only mines/forages/fishes (now automatic, done by walking) and clears
// single-day dungeons, but never sells, never enters the arena, and never runs
// a multi-day expedition to a Night camp, would roll for zero events. The
// fourth anchor, ZoneClear, fires on a foreground single-day zone clear — the
// climax DM that player *is* reading — so they get the same rough cadence.
// Autopilot walks and mid-zone region clears deliberately do not fire it (see
// zoneCmdAdvance / the full-clear branch of advanceOnceWithOpts). The per-day
// slot still caps everyone at one event, so a player who happens to hit two
// anchors in a day can't double up. This rate is the tuning knob for item K —
// bump it if telemetry shows the grind-loop player still sees too few.
const (
advEventChanceDigest = 0.08
advEventChanceSell = 0.05
advEventChanceArena = 0.05
advEventChanceZoneClear = 0.08
)
var (
advEventScheduleMu sync.Mutex
advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll
advEventRolled map[string]bool // userID -> already rolled today
advEventScheduleDay string // "2006-01-02" the maps belong to
advEventFiredMu sync.Mutex
advEventFired map[string]bool // userID -> an event already fired today
advEventFiredDay string // "2006-01-02" the map belongs to
)
// claimDailyEventSlot reserves today's single event slot for `userID`.
// Returns false when the player already had one fire today.
func claimDailyEventSlot(userID id.UserID, day string) bool {
advEventFiredMu.Lock()
defer advEventFiredMu.Unlock()
if advEventFiredDay != day {
advEventFired = make(map[string]bool)
advEventFiredDay = day
}
if advEventFired[string(userID)] {
return false
}
advEventFired[string(userID)] = true
return true
}
// releaseDailyEventSlot hands the slot back when the trigger bailed before
// anything reached the player, so a later anchor the same day can still fire.
func releaseDailyEventSlot(userID id.UserID) {
advEventFiredMu.Lock()
defer advEventFiredMu.Unlock()
delete(advEventFired, string(userID))
}
// maybeFireAnchoredEvent rolls `chance` at one of the A6 anchor moments and,
// on a hit, triggers the player's one mid-day event for today.
func (p *AdventurePlugin) maybeFireAnchoredEvent(userID id.UserID, chance float64) {
if rand.Float64() >= chance {
return
}
if !claimDailyEventSlot(userID, time.Now().UTC().Format("2006-01-02")) {
return
}
if !p.tryTriggerEvent(userID) {
releaseDailyEventSlot(userID)
}
}
// ── Event Ticker ─────────────────────────────────────────────────────────────
func (p *AdventurePlugin) eventTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
dateKey := now.Format("2006-01-02")
currentMinute := now.Hour()*60 + now.Minute()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
// Expire stale pending events every tick
expireAdvPendingEvents()
// Expire stale pending events every tick
expireAdvPendingEvents()
// Auto-play any combat sessions past their 1h timeout.
p.reapExpiredCombatSessions()
advEventScheduleMu.Lock()
if advEventScheduleDay != dateKey {
advEventSchedule = make(map[string]int)
advEventRolled = make(map[string]bool)
advEventScheduleDay = dateKey
}
// Latch away party members onto autopilot so one absent player
// can't hold a co-op fight hostage (N3/P5). Solo fights are never
// listed — they answer to the reaper above.
p.nudgeStalledPartyTurns()
// Schedule deferred rolls for any newly-acted players
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: events: failed to load chars", "err", err)
advEventScheduleMu.Unlock()
continue
}
for _, c := range chars {
uid := string(c.UserID)
if !c.Alive || advEventRolled[uid] {
continue
}
if _, scheduled := advEventSchedule[uid]; scheduled {
continue
}
if !c.HasActedToday() {
continue
}
// Assign a one-shot roll 60180 minutes from now, capped to 23:50 UTC.
rollMinute := currentMinute + 60 + rand.IntN(121)
if rollMinute > 23*60+50 {
rollMinute = 23*60 + 50
}
advEventSchedule[uid] = rollMinute
slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute)
}
// Reclaim invites nobody answered (N3/P6b). Every read already
// filters on the TTL; this just stops the rows accumulating.
purgeExpiredInvites()
// Find players whose roll-minute has arrived
var toRoll []id.UserID
for uid, minute := range advEventSchedule {
if minute <= currentMinute && !advEventRolled[uid] {
toRoll = append(toRoll, id.UserID(uid))
advEventRolled[uid] = true
}
}
advEventScheduleMu.Unlock()
// World boss (N6/C3): auto-spawn the monthly Siege on the 1st and
// resolve one whose 72h window has lapsed. Own dedup inside.
p.worldBossTick()
for _, uid := range toRoll {
p.tryTriggerEvent(uid)
// Duels (N6/C2): refund and clear any challenge whose 24h accept
// window lapsed.
p.expireDuelChallenges()
}
}
}
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
// Load character — must be alive and have acted today
// tryTriggerEvent fires the player's mid-day event. Reports whether an event
// actually reached them — a false return means the caller's daily slot was
// never spent. The trigger roll itself lives in maybeFireAnchoredEvent.
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool {
// Load character — must be alive.
char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.Alive || !char.HasActedToday() {
return
if err != nil || char == nil || !char.Alive {
return false
}
// Already has an active event?
active, _ := loadAdvActiveEvent(userID)
if active != nil {
return
return false
}
// 0.5% chance
if rand.Float64() >= 0.005 {
return
// Mid-fight: a turn-based session locks the run. Don't drop a random
// overworld event into a live fight — the player can't act on it without
// finishing the fight first, and the trigger DM talks over the combat feed.
if hasActiveCombatSession(userID) {
return false
}
// Determine today's activity for filtering
@@ -123,7 +161,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
// Pick an event
event := advPickRandomEvent(userID, activityType)
if event == nil {
return
return false
}
// Insert into DB
@@ -132,14 +170,16 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
if err != nil {
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err)
return
return false
}
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
displayName, _ := loadDisplayName(userID)
// DM the player
triggerDM := advSubstituteFlavor(event.TriggerDM, map[string]string{
"{name}": char.DisplayName,
"{name}": displayName,
})
if err := p.SendDM(userID, triggerDM); err != nil {
slog.Error("adventure: events: failed to send trigger DM", "user", userID, "err", err)
@@ -149,10 +189,11 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
gr := gamesRoom()
if gr != "" {
roomLine := advSubstituteFlavor(advEventRoomTriggerWrapper, map[string]string{
"{trigger_room_line}": advSubstituteFlavor(event.TriggerRoomLine, map[string]string{"{name}": char.DisplayName}),
"{trigger_room_line}": advSubstituteFlavor(event.TriggerRoomLine, map[string]string{"{name}": displayName}),
})
_ = p.SendMessage(id.RoomID(gr), roomLine)
}
return true
}
// handleEventRespond processes `!adventure respond`.
@@ -208,6 +249,7 @@ func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
}
checkAdvLevelUp(char, xpSkill)
_ = saveAdvCharacter(char)
_ = upsertPlayerMetaSkillState(ctx.Sender, skillStateFromAdvChar(char))
}
}

View File

@@ -0,0 +1,120 @@
package plugin
import (
"math/rand/v2"
"testing"
"maunium.net/go/mautrix/id"
)
// TestAnchoredEventWeeklyRate pins the A6 rebalance: a player who reads an
// end-of-day digest, sells at Thom's, and cashes out of the arena every day
// should see roughly one mid-day event per week. The old 0.5%/day roll put
// that at one per ~200 days.
//
// The simulation drives its own seeded RNG rather than the package-global
// one, so it measures the policy — the chance constants and the one-event-
// per-day cap — and never flakes on RNG ordering elsewhere in the suite.
func TestAnchoredEventWeeklyRate(t *testing.T) {
anchors := []float64{advEventChanceDigest, advEventChanceSell, advEventChanceArena}
const weeks = 20000
rng := rand.New(rand.NewPCG(0x5EED, 0xA6))
events := 0
for range weeks {
for range 7 {
firedToday := false
for _, chance := range anchors {
if firedToday {
break // one event per player per UTC day
}
if rng.Float64() < chance {
firedToday = true
}
}
if firedToday {
events++
}
}
}
perWeek := float64(events) / weeks
if perWeek < 0.8 || perWeek > 1.5 {
t.Errorf("anchored events = %.3f/week, want ~1 (0.81.5)", perWeek)
}
}
// TestZoneClearAnchorRate pins item K's fourth anchor for the population the
// other three miss: a grind-loop player who never sells, never enters the
// arena, and never runs a multi-day expedition, but clears single-day zones.
// Modelled at two foreground clears per active day (a plausible dungeon
// session) they should land in the same ~1 event/week band as the fully-
// engaged player — that is the whole point of adding the anchor. The clears/day
// assumption and advEventChanceZoneClear are the two tuning knobs; see the K
// note in adventure_events.go.
func TestZoneClearAnchorRate(t *testing.T) {
const clearsPerDay = 2
const weeks = 20000
rng := rand.New(rand.NewPCG(0x5EED, 0x2C)) // "ZC"
events := 0
for range weeks {
for range 7 {
firedToday := false
for range clearsPerDay {
if firedToday {
break // one event per player per UTC day
}
if rng.Float64() < advEventChanceZoneClear {
firedToday = true
}
}
if firedToday {
events++
}
}
}
perWeek := float64(events) / weeks
if perWeek < 0.8 || perWeek > 1.5 {
t.Errorf("zone-clear anchor = %.3f/week, want ~1 (0.81.5)", perWeek)
}
}
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
uid := id.UserID("@events-slot:example")
const day = "2026-07-09"
advEventFiredMu.Lock()
advEventFired = nil
advEventFiredDay = ""
advEventFiredMu.Unlock()
if !claimDailyEventSlot(uid, day) {
t.Fatal("first claim of the day should succeed")
}
if claimDailyEventSlot(uid, day) {
t.Error("second claim on the same day should be refused")
}
// A trigger that bailed before reaching the player hands the slot back.
releaseDailyEventSlot(uid)
if !claimDailyEventSlot(uid, day) {
t.Error("claim after release should succeed")
}
// A new UTC day resets everyone.
if !claimDailyEventSlot(uid, "2026-07-10") {
t.Error("claim on a new day should succeed")
}
}
// TestClaimDailyEventSlot_PerUser — one player's event doesn't consume another's.
func TestClaimDailyEventSlot_PerUser(t *testing.T) {
const day = "2026-07-11"
if !claimDailyEventSlot(id.UserID("@events-a:example"), day) {
t.Fatal("player A should claim")
}
if !claimDailyEventSlot(id.UserID("@events-b:example"), day) {
t.Error("player B should claim independently of A")
}
}

View File

@@ -28,6 +28,17 @@ var babysitDiaperLines = []string{
"The diapers. They happened. They were handled. Moving on.",
}
// babysitHighlightLines fire on auto-babysit days when the haul was unusually
// good — making the babysitter feel like a companion rather than a silent
// insurance product. Each line takes one %s slot for the focused skill name.
var babysitHighlightLines = []string{
"The babysitter says your kid was a natural at %s today. They want it noted.",
"Solid day on the %s circuit, apparently. The babysitter looked smug about it.",
"Whatever the babysitter did with your %s gear — it worked. The numbers don't lie.",
"The babysitter handed in a %s haul and said nothing. The smug silence said plenty.",
"Your kid is, against the odds, getting alarmingly good at %s under TwinBee's care.",
}
// pickBabysitFlavor returns a random entry from the given pool.
func pickBabysitFlavor(pool []string) string {
if len(pool) == 0 {

View File

@@ -49,6 +49,22 @@ var blacksmithArena = []string{
"The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.",
}
var blacksmithTemperGreetings = []string{
"_sets aside the repair work_ You want me to make something _more_ than it is. That's different. That's the good work.",
"Tempering. _rolls his shoulders_ I don't repair it. I take it apart and I convince it to come back stronger. It doesn't always want to.",
"_wipes the counter clean with one sweep_ Put it down. Right there. Let me see what it could be instead of what it is.",
"Anything can be pushed further. Anything. It's just a question of what you're willing to spend and how much heat it can take before it gives in.",
"_eyes light up_ Oh, you've come for the real thing. Not a patch. A _change_. Show me. Show me everything you've got.",
}
var blacksmithTemperCompletion = []string{
"_lifts it from the quench, steam everywhere_ Feel the weight of it now. It's not the same thing you handed me. It never will be again.",
"_breathing hard_ It fought me. Right to the end it fought me. And now look at it. Look what it became.",
"There. _sets it down with enormous care_ I pushed it further than it wanted to go, and it thanked me for it. They always do.",
"_holds it up, turning it slowly_ I've been doing this a long time and this one still surprised me. Take it before I change my mind about giving it back.",
"It's still hot. It'll be hot for a while. That's what happens when you ask something to become more than it was.",
}
var blacksmithFullCondition = []string{
"_looks it over_ There's nothing to do here. It doesn't need me. _sounds slightly disappointed_ Come back when it does.",
"Full condition. You've been taking care of it. Good. I appreciate that in a person.",
@@ -63,4 +79,3 @@ var blacksmithBrokenCondition = []string{
"_looks at the condition, looks at you, looks back at the condition_ You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.",
"This could have been avoided with regular visits. _slides the cost estimate across the counter without breaking eye contact_",
}

View File

@@ -0,0 +1,519 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// The Hollow King campaign (N5/D1). A light serialized story threaded through
// the zones by way of collectible journal pages. "The Hollow King" is already
// the Forest Shadows (T2) boss; this frames him as a realm-spanning antagonist
// whose fragments turn up wherever players fight elites and open secret rooms.
//
// The fragments are in-world found artifacts — journal entries, torn letters,
// stone inscriptions — not TwinBee's voice. TwinBee's own reactions (D1b) obey
// the first-person voice rules; this text does not.
type journalPage struct {
Title string
Text string
}
// journalPages is the ordered campaign. Reading the discovered pages top to
// bottom tells the fall of a kingdom to the thing its king became. Order is the
// story order; players find them out of sequence, which is why the viewer marks
// gaps.
var journalPages = []journalPage{
{"The Long Winter", "A crown is only a circle of gold until a man decides never to take it off. Ours decided in a winter that would not end, and the frost took its cue from him."},
{"The Last Physician", "The court healers were sent home one by one, each promising the king had years left. The last was not sent home. We heard him thank the king for the honour."},
{"On Shadows", "He stopped casting a shadow before he stopped casting a reflection. The steward struck both from the list of things a servant may notice aloud."},
{"The Quiet Ledger", "Grain still left the granaries; no mouths were fed. I stopped auditing the difference the night the number began to feel like a name."},
{"A Bargain Overheard", "Through the chapel door: the king's voice, and a second that used his own words a breath before he did. Only one of them was asking."},
{"The Hollowing", "They call it a coronation in the records. Those of us who carried the braziers call it what it was. A king was emptied so a crown could keep wearing him."},
{"The First Knights", "His honour guard did not die. That was the mercy offered and the price paid. They stand at the old gate yet, and they still salute a throne no living thing sits on."},
{"Letters Home, Unsent", "\"Tell mother the pay is good and the work is guarding.\" The satchel held forty such letters, every hand different, every promise the same, none of them sent."},
{"The Map Redrawn", "The kingdom did not fall so much as come apart at the seams — a warren here, a drowned temple there, each piece keeping a splinter of him like a tooth in a wound."},
{"Root and Rot", "The forest north of the manor grew wrong and grew fast. The woodsmen say the trees lean toward the ruin at dusk. The woodsmen no longer go at dusk."},
{"The Warren Below", "Even the goblins gave the deep tunnels to him and asked nothing back. When a scavenger yields ground for free, ask what it saw down there."},
{"Water That Remembers", "The temple sank in a single night with the bells still ringing. Divers say the bells ring still, slow, as if something below is counting."},
{"The Manor's Long Guest", "Blackspire changed hands nine times in a decade. Every deed names a different owner. Every household names the same tenant, and none will write it down."},
{"The Forge Unbanked", "The underforge keeps a heat with no fuel and no smith. What it makes, no one has seen leave. What it makes, we are told, was promised elsewhere long ago."},
{"Descent", "The deep roads were a trade route once. Now they are a throat. Everything the surface loses is swallowed the same direction, and the direction has a door at the end."},
{"The Bright Country", "Past the crossing the colours are too kind and the days too long. It is the most beautiful place I have run from. He is patient there; he can afford to be."},
{"What the Dragon Keeps", "The wyrm hoards more than gold. Deep in the lair, behind the coin, a single crown sits on no head and is guarded better than the hoard."},
{"The Portal's Arithmetic", "The abyss gate opens outward. Everyone assumes a door lets things in. This one was built by someone who only ever intended to leave through it."},
{"The Regent's Confession", "I ruled in his name for thirty years and never once saw him rule. I signed what the crown wanted signed. I am writing this so that one honest page exists."},
{"The Names of the Guard", "I have set down every knight's name here so that when this is read, they are grieved as men and not feared as things. It is the only rescue left to attempt."},
{"The Flaw in the Bargain", "The second voice took the king's life and his death both — and a thing that cannot die also cannot be finished. He is not immortal. He is unpaid, and waiting to collect."},
{"How to Call Him", "He answers only where his fragments gather and only to one who has gathered them. Do not do this to avenge us. Do it to end the account. Come with the whole ledger or do not come."},
{"The Empty Throne", "I have seen the seat at the heart of it all. It is not empty. It is occupied by the shape of a man who left, kept warm against his return."},
{"Last Page", "If you are reading in order, you have walked the ruin of everything he emptied to stay. One page is missing from every telling — the one you write by going in. Bring a light. He hates the light. It is the one thing he could never hollow out."},
}
// journalTotalPages is the campaign length; the drop/viewer/finale all read it
// so the story can grow by appending to journalPages alone.
var journalTotalPages = len(journalPages)
// journalPageDropChance is the per-elite-kill probability that a page turns up.
// Deliberately modest: 24 pages is a long-horizon collection, and secret rooms
// (D4) grant pages on top of this. Tunable.
const journalPageDropChance = 0.22
// setJournalPageBit is the in-memory twin of grantJournalPageDB's bitwise OR —
// the single definition of "page N lives in bit N-1". Out-of-range pages are a
// no-op.
func setJournalPageBit(mask int64, page int) int64 {
if page < 1 || page > 63 {
return mask
}
return mask | (int64(1) << (page - 1))
}
func journalPageFound(mask int64, page int) bool {
if page < 1 || page > 63 {
return false
}
return mask&(int64(1)<<(page-1)) != 0
}
func journalPageCount(mask int64) int {
n := 0
for i := 1; i <= journalTotalPages; i++ {
if journalPageFound(mask, i) {
n++
}
}
return n
}
func journalComplete(mask int64) bool {
return journalPageCount(mask) >= journalTotalPages
}
// pickUnfoundJournalPage returns a random not-yet-found page number, or 0 when
// the campaign is already complete.
func pickUnfoundJournalPage(mask int64, rng *rand.Rand) int {
var missing []int
for i := 1; i <= journalTotalPages; i++ {
if !journalPageFound(mask, i) {
missing = append(missing, i)
}
}
if len(missing) == 0 {
return 0
}
return missing[rngIntN(rng, len(missing))]
}
// maybeDropJournalPage rolls a page reward for an elite kill or secret room and,
// on a hit, grants a random unfound page and returns its narration line. Empty
// string means no drop (missed the roll, DB error, or campaign already
// complete). The roll draws from the same RNG the surrounding loot rolls use
// and never touches SimulateCombat's stream, so the combat golden is unmoved.
func (p *AdventurePlugin) maybeDropJournalPage(userID id.UserID, rng *rand.Rand) string {
if rngFloat(rng) >= journalPageDropChance {
return ""
}
return p.grantJournalPage(userID, rng)
}
// grantJournalPage grants a random unfound page unconditionally (used by secret
// rooms, which award a page for certain). Returns "" when already complete or on
// error.
func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) string {
mask, err := loadJournalPages(userID)
if err != nil {
return ""
}
page := pickUnfoundJournalPage(mask, rng)
if page == 0 {
return ""
}
if err := grantJournalPageDB(userID, page); err != nil {
return ""
}
// If the page turned up mid-expedition, drop a log beat so the end-of-day
// digest can have TwinBee react to it. No expedition (legacy !zone, or a
// secret room opened outside a run) simply means no digest to react in.
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "journal",
journalPages[page-1].Title, fmt.Sprintf("page %d", page))
}
return fmt.Sprintf("📖 A torn journal page — _%s_ (page %d of %d). See `!adventure journal`.",
journalPages[page-1].Title, page, journalTotalPages)
}
// --- N5/D4 secret content pass ---------------------------------------------
//
// Every NodeKindSecret room resolves as a no-combat treasure cache (see
// resolveSecretRoom in dnd_zone_combat.go). Finding it — via the perception /
// stat check on the fork, or a cross-zone key — is the reward: a guaranteed
// journal page, a LootBias-weighted treasure roll, and a guaranteed consumable
// cache. The flavor and the key catalog live here with the rest of the
// campaign; the resolution mechanics live next to the other room resolvers.
// secretRoomCacheCount is how many zone-tier consumables a secret room always
// hands over, so the room pays out something visible even for a player who has
// every page and misses the treasure roll.
const secretRoomCacheCount = 2
// crossZoneKey pairs a source secret room with the key item it grants. The key
// is a plain inventory item; a LockKey edge in the destination zone matches its
// lower-cased Name against lock_data.key_id (see evaluateEdgeLock). Keys persist
// in inventory, so the unlock is permanent once earned.
type crossZoneKey struct {
item AdvItem
unlocksIn ZoneID // destination zone, for the grant narration
unlockHint string // what the key opens, surfaced when it's granted
}
// secretRoomKeys — the two cross-zone keys (N5/D4). A Sunken Temple sigil opens
// a sealed reliquary in Manor Blackspire; an Underforge seal opens a vault deep
// in the Underdark throne approach. Keyed by the source secret's node ID.
var secretRoomKeys = map[string]crossZoneKey{
"sunken_temple.coral_reliquary": {
item: AdvItem{Name: "Sunken Sigil", Type: "key", Tier: 2},
unlocksIn: ZoneManorBlackspire,
unlockHint: "a drowned god's mark — Blackspire has a door that remembers it",
},
"underforge.forge_vault": {
item: AdvItem{Name: "Underforge Seal", Type: "key", Tier: 3},
unlocksIn: ZoneUnderdark,
unlockHint: "the forge's own brand — something in the deep roads was promised its work",
},
}
// grantSecretRoomKey hands over the cross-zone key a secret room carries, if it
// carries one and the player isn't already holding it. Idempotent: re-finding
// the room on a later run won't stack duplicate keys. Returns the narration line
// (empty when the room grants no key, the player already has it, or on error).
func (p *AdventurePlugin) grantSecretRoomKey(userID id.UserID, node ZoneNode) string {
key, ok := secretRoomKeys[node.NodeID]
if !ok {
return ""
}
items, err := loadAdvInventory(userID)
if err != nil {
return ""
}
want := strings.ToLower(key.item.Name)
for _, it := range items {
if strings.ToLower(it.Name) == want {
return "" // already earned — don't stack it
}
}
if err := addAdvInventoryItem(userID, key.item); err != nil {
slog.Error("secret room: grant key", "user", userID, "key", key.item.Name, "err", err)
return ""
}
return fmt.Sprintf("🗝 **%s** — %s.", key.item.Name, key.unlockHint)
}
// secretRoomDiscovery is the bespoke in-world discovery line for a secret room,
// keyed by node ID. Not TwinBee's voice — the same in-world register the journal
// pages use. Nodes without an entry fall back to a generic line built from the
// node label.
var secretRoomDiscovery = map[string]string{
"crypt_valdris.secret_chamber": "🔍 **A sealed chamber.** The other grave-robbers pried at the wrong wall. This one gives — Valdris kept something back even from his own tomb.",
"forest_shadows.sapling_shrine": "🔍 **The Sapling Shrine.** A ring of young antlers grown from the dirt, and something votive left at the centre — an offering the King's shell never came back to claim.",
"sunken_temple.coral_reliquary": "🔍 **The Coral Reliquary.** Behind a lattice of living coral, a niche the flood sealed rather than drowned. What the temple hid, it hid well.",
"manor_blackspire.hidden_oratory": "🔍 **The Hidden Oratory.** A chapel with no door in the floor plan, kept for a tenant the deeds refuse to name.",
"manor_blackspire.sealed_reliquary": "🔍 **The Sealed Reliquary.** The drowned god's sigil bites into the lock and turns. Blackspire kept a piece of the temple's secret behind a door only the temple could open.",
"underforge.forge_vault": "🔍 **The Forge Vault.** A strongroom off the unbanked heat, its ledger of commissions all addressed elsewhere and long overdue.",
"underdark.lost_reliquary": "🔍 **A lost reliquary.** A shrine the deep roads swallowed whole, still lit, still waiting on a procession that stopped coming a long age ago.",
"underdark.sealed_forge_vault": "🔍 **The Sealed Vault.** The Underforge's brand fits the seam, and the deep-road door gives up what the forge sent ahead — payment on the old account.",
"feywild_crossing.illusion_garden": "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
"dragons_lair.hoard_pillar": "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
"abyss_portal.reality_seam": "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",
}
// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
// falling back to a label-driven generic when the node has no bespoke entry.
func secretRoomDiscoveryLine(node ZoneNode) string {
if line, ok := secretRoomDiscovery[node.NodeID]; ok {
return line
}
label := node.Label
if label == "" {
label = "a hidden room"
}
return fmt.Sprintf("🔍 **%s** — a room the others walked past. Something was left here for whoever looked closer.", label)
}
// bossEpilogues ties each zone boss's death to the Hollow King arc: a 2-3
// sentence capstone appended to the boss-down moment. Forest of Shadows is the
// King himself — but what falls there is a shell he shed, which is why the arc
// (and the finale) outlives it. In-world narration, not TwinBee's voice.
var bossEpilogues = map[ZoneID]string{
ZoneGoblinWarrens: "Grol dies clutching a coin no goblin minted — a king's face worn smooth by handling. Whatever paid the warren to give up its deep tunnels, it paid in a currency older than these hills.",
ZoneCryptValdris: "Valdris tried to cheat the grave and managed only to furnish it. In his last rattle he says a name that isn't his — _hollow, hollow_ — as if warning you of a colleague who did it better.",
ZoneForestShadows: "The Hollow King falls without weight, a coat slipped from its peg — and the woods do not go quiet. What you felled here was a thing he shed, not the thing he is. Somewhere, the account he owes goes on accruing.",
ZoneSunkenTemple: "The Aboleth's dream breaks and the drowned bells still at last. In the silence you understand what they were counting toward — and that the count did not begin with this temple, and does not end with it.",
ZoneManorBlackspire: "Aldric was hollowed the same way, by the same hand, and made a poor imitation: a lord kept past his death to hold a house for a guest who never came. He thanks you. It is the first thing he has meant in a century.",
ZoneUnderforge: "Thyrak's fires gutter out, and the half-made things on the anvils cool into what they were always going to be — regalia, and soldiers, and a crown with no head to fit. The forge was filling an order placed a long time ago.",
ZoneUnderdark: "Ilvaras ruled the throat that swallows everything downward, toward the door at the bottom of the world. She dies certain she served a queen. She served a direction, and the direction has a name it never told her.",
ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.",
ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.",
ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.",
}
// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for
// zones with none (and for the synthetic arena, which has no ZoneID entry).
func bossEpilogueLine(zoneID ZoneID) string {
return bossEpilogues[zoneID]
}
// writeBossEpilogue appends a zone's campaign capstone (if any) to a
// victory narration. Shared by every boss-down render path — the manual
// turn-based finishes (finishCombatSession, finishPartyWin) and the compact
// autopilot boss resolve — so the D1b epilogue fires no matter how the boss
// was cleared. Caller gates on "this was a boss, not an elite".
func writeBossEpilogue(b *strings.Builder, zoneID ZoneID) {
if ep := bossEpilogueLine(zoneID); ep != "" {
b.WriteString("\n" + ep + "\n")
}
}
// twinBeeJournalReactions are TwinBee's morning/digest reactions to pages found
// during the day — first-person, implicit subject, he/him, one line, curious,
// never expository (feedback_twinbee_voice, feedback_twinbee_is_male). Picked
// deterministically so a re-rendered digest reads the same.
var twinBeeJournalReactions = []string{
"📖 Found a torn page in your kit tonight — been reading it by the fire while you sleep. This king of theirs was not a well man.",
"📖 Another page. Keep turning them up and I keep piecing him together, and I do not much like the shape.",
"📖 Read the new page twice. Whoever wrote it was frightened of something patient. I think we are walking toward it.",
"📖 Slipped the day's page into the others. The story's filling in at the edges, and none of the edges are kind.",
}
// twinBeeJournalReaction picks one reaction line deterministically from the day
// and the number of pages found, so the digest is stable across re-renders.
func twinBeeJournalReaction(day, pagesToday int) string {
if len(twinBeeJournalReactions) == 0 || pagesToday <= 0 {
return ""
}
idx := (day + pagesToday) % len(twinBeeJournalReactions)
if idx < 0 {
idx = -idx
}
return twinBeeJournalReactions[idx]
}
// handleJournalCmd renders the player's collected campaign pages.
func (p *AdventurePlugin) handleJournalCmd(ctx MessageContext) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
return p.SendDM(ctx.Sender, renderJournal(char.JournalPages))
}
// renderJournal builds the `!adventure journal` view: discovered pages in story
// order, with runs of missing pages collapsed to a single "…".
func renderJournal(mask int64) string {
found := journalPageCount(mask)
var b strings.Builder
b.WriteString(fmt.Sprintf("📖 **The Hollow King** — Journal (%d / %d pages)\n",
found, journalTotalPages))
if found == 0 {
b.WriteString("\nYou carry no pages yet. They surface where the realm's fragments gather — in the hands of elites, and behind doors most adventurers walk past.")
return b.String()
}
b.WriteString("\n")
gapOpen := false
for i := 1; i <= journalTotalPages; i++ {
if journalPageFound(mask, i) {
gapOpen = false
jp := journalPages[i-1]
b.WriteString(fmt.Sprintf("\n**%s. %s**\n%s\n", romanNumeral(i), jp.Title, jp.Text))
} else if !gapOpen {
gapOpen = true
b.WriteString("\n…\n")
}
}
if journalComplete(mask) {
b.WriteString("\nThe ledger is whole. Clear both Tier-5 zones and you can follow him to the end — `!expedition start epilogue`.")
} else {
b.WriteString(fmt.Sprintf("\n_%d pages still scattered._", journalTotalPages-found))
}
return b.String()
}
// romanNumeral renders 1..24 as upper-case Roman numerals for the page headers.
// The campaign never exceeds a couple dozen pages, so a small table beats a
// general algorithm here.
func romanNumeral(n int) string {
if n < 1 || n > len(romanNumerals) {
return fmt.Sprintf("%d", n)
}
return romanNumerals[n-1]
}
// ── D1c: the finale ─────────────────────────────────────────────────────────
// finaleTitle is the unique role awarded for the first finale clear.
const finaleTitle = "Kingsbane"
const epilogueEntryFlavor = "You bring the whole ledger to the Empty Throne. The shape kept warm against his return stirs, and stands, and is finally, fully here."
// hollowKingFinaleMonster is the finale stat block: Belaxath's block — the abyss
// boss whose gate "was meant to let one thing come home" — re-dressed as the
// King returned in full, with HP bumped for a capstone. A variant stat block
// with no new mechanics, per the plan. Built on the fly (not registered in
// dndBestiary) because runZoneCombat takes the template directly, exactly as the
// arena bosses do.
func hollowKingFinaleMonster() DnDMonsterTemplate {
m := dndBestiary["boss_belaxath"]
m.ID = "boss_hollow_king_finale"
m.Name = "The Hollow King, Unhoused"
m.HP = int(float64(m.HP) * 1.25)
m.Notes = "Finale encounter — the Hollow King returned in full through the abyss gate. Variant of the Balor stat block; no new mechanics."
return m
}
// epilogueUnlocked gates the finale on the whole ledger plus both Tier-5 clears.
func (p *AdventurePlugin) epilogueUnlocked(char *AdventureCharacter) (bool, string) {
if !journalComplete(char.JournalPages) {
return false, fmt.Sprintf(
"The trail runs cold. You've recovered **%d of %d** journal pages — find them all before you can follow him to the end. (`!adventure journal`)",
journalPageCount(char.JournalPages), journalTotalPages)
}
if !clearedEveryZoneOfTier(db.Get(), char.UserID, ZoneTier(5)) {
return false, "You hold the whole ledger, but not the standing to use it. Clear **both** Tier-5 zones — the Dragon's Lair and the Abyss Portal — then come back to the throne."
}
return true, ""
}
// handleEpilogueEncounter runs the finale: a single auto-resolved boss fight
// against the Hollow King's true form, reached via `!expedition start epilogue`.
// Reward drops only on the first clear (reward-once); later clears are a
// flavour-only rematch. A loss costs a death, as any boss fight does.
func (p *AdventurePlugin) handleEpilogueEncounter(ctx MessageContext) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're in no shape to face him. Mend at `!hospital` first.")
}
if ok, why := p.epilogueUnlocked(char); !ok {
return p.SendDM(ctx.Sender, why)
}
// Busy guards — the finale is a synchronous auto-resolved fight, so refuse if
// anything else is in flight and could collide on HP or run state.
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
return p.SendDM(ctx.Sender, "Finish your expedition before you walk to the Empty Throne.")
}
if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
return p.SendDM(ctx.Sender, "Finish your current zone run first.")
}
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it first.")
}
monster := hollowKingFinaleMonster()
displayName, _ := loadDisplayName(ctx.Sender)
if displayName == "" {
displayName = "You"
}
preHP, _ := dndHPSnapshot(ctx.Sender)
result, err := p.runZoneCombat(ctx.Sender, monster, 5, bossCombatPhases, 50)
if err != nil {
slog.Error("epilogue: combat failed", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Something went wrong at the throne. Try again in a moment.")
}
postHP, maxHP := dndHPSnapshot(ctx.Sender)
nat20s, nat1s := countNat20sAnd1s(result)
intro := fmt.Sprintf("👑 **The Empty Throne — %s** (HP %d, AC %d)\n_%s_",
monster.Name, monster.HP, monster.AC, epilogueEntryFlavor)
phases := RenderCombatLog(result, displayName, monster.Name)
outcome := renderBossOutcome(BossOutcomeInputs{
// Synthetic ZoneArena keeps twinBeeLine's deterministic pickers off any
// real zone's pool, exactly as the arena does.
ZoneID: ZoneArena,
RunID: "epilogue-" + string(ctx.Sender),
RoomIdx: 0,
Monster: monster,
Result: result,
PreHP: preHP,
PostHP: postHP,
MaxHP: maxHP,
PhaseTwoAt: 0.40,
Nat20s: nat20s,
Nat1s: nat1s,
DefeatHeadline: "💀 The Hollow King folds you into the quiet he keeps. The account goes unpaid a while longer.",
VictoryHeadline: fmt.Sprintf("🏆 **The Hollow King falls — and this time stays down.** You finished at **%d/%d HP**.", postHP, maxHP),
})
var tail string
if !result.PlayerWon {
markAdventureDead(ctx.Sender, "adventure", "the Empty Throne")
} else {
tail = p.finishEpilogueWin(ctx.Sender, char.EpilogueCleared)
}
_ = p.sendZoneCombatMessages(ctx.Sender, append([]string{intro}, phases...), joinLootLines(outcome, tail))
return nil
}
// finishEpilogueWin grants the reward on a first clear and returns the reward
// narration; a repeat clear is flavour only. The character is reloaded before
// the title write so runZoneCombat's post-fight HP persist isn't clobbered.
func (p *AdventurePlugin) finishEpilogueWin(userID id.UserID, alreadyCleared bool) string {
if alreadyCleared {
return "_The throne stays empty. You came to be sure. You are sure._"
}
// Latch the once-only flag BEFORE handing out the Legendary + title, so a
// failed write (or a crash) can never leave the reward repeatable. If the
// latch fails, skip the grant entirely — the player re-enters uncleared and
// can try again, rather than pocketing a second Legendary on the retry.
if err := markEpilogueClearedDB(userID); err != nil {
slog.Error("epilogue: mark cleared failed", "user", userID, "err", err)
return "_The account won't quite close — the ledger jams. Try `!expedition start epilogue` again._"
}
var b strings.Builder
b.WriteString("🎖️ **The account is closed.** You are named **" + finaleTitle + "** — the one who unhoused the Hollow King.\n")
if mi, ok := pickMagicItemForRarity(RarityLegendary, nil); ok {
if line := p.dropMagicItemLoot(userID, mi, LootTierLegendary); line != "" {
b.WriteString(line + "\n")
}
} else {
slog.Error("epilogue: no legendary in registry", "user", userID)
}
if fresh, err := loadAdvCharacter(userID); err == nil && fresh != nil {
if fresh.Title != finaleTitle {
fresh.Title = finaleTitle
if err := saveAdvCharacter(fresh); err != nil {
slog.Error("epilogue: title save failed", "user", userID, "err", err)
}
}
}
if gr := gamesRoom(); gr != "" {
if dn, _ := loadDisplayName(userID); dn != "" {
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
"👑 **%s** carried the whole ledger to the Empty Throne and closed the account. The Hollow King is ended. **%s.**",
dn, finaleTitle))
}
}
return b.String()
}
var romanNumerals = []string{
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X",
"XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX",
"XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII", "XXVIII", "XXIX", "XXX",
}

View File

@@ -0,0 +1,300 @@
package plugin
import (
"math/rand/v2"
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
func TestJournalGrant_DBRoundTripIsAtomicOR(t *testing.T) {
townTestDB(t)
u := id.UserID("@page:test.invalid")
// No row yet → zero pages.
if mask, err := loadJournalPages(u); err != nil || mask != 0 {
t.Fatalf("fresh player: mask=%d err=%v, want 0/nil", mask, err)
}
// First grant creates the row (ON CONFLICT INSERT path).
if err := grantJournalPageDB(u, 5); err != nil {
t.Fatalf("grant page 5: %v", err)
}
// Second grant ORs into the existing row without clobbering the first.
if err := grantJournalPageDB(u, 2); err != nil {
t.Fatalf("grant page 2: %v", err)
}
// Re-granting a found page is idempotent.
if err := grantJournalPageDB(u, 5); err != nil {
t.Fatalf("re-grant page 5: %v", err)
}
mask, err := loadJournalPages(u)
if err != nil {
t.Fatalf("load: %v", err)
}
if !journalPageFound(mask, 2) || !journalPageFound(mask, 5) {
t.Fatalf("pages 2 and 5 should be found, mask=%b", mask)
}
if journalPageCount(mask) != 2 {
t.Fatalf("count=%d, want 2 (mask=%b)", journalPageCount(mask), mask)
}
// Overlay populates the character field the viewer reads.
var c AdventureCharacter
c.UserID = u
applyPlayerMetaOverlay(&c)
if c.JournalPages != mask {
t.Fatalf("overlay JournalPages=%b, want %b", c.JournalPages, mask)
}
}
func TestJournalCatalog_WellFormed(t *testing.T) {
if journalTotalPages != len(journalPages) {
t.Fatalf("journalTotalPages %d != len(journalPages) %d", journalTotalPages, len(journalPages))
}
if journalTotalPages < 1 || journalTotalPages > 63 {
t.Fatalf("journal must fit an int64 bitmask (1..63); got %d", journalTotalPages)
}
if journalTotalPages > len(romanNumerals) {
t.Fatalf("more pages (%d) than roman numerals (%d)", journalTotalPages, len(romanNumerals))
}
seen := map[string]bool{}
for i, jp := range journalPages {
if strings.TrimSpace(jp.Title) == "" {
t.Errorf("page %d has empty title", i+1)
}
if strings.TrimSpace(jp.Text) == "" {
t.Errorf("page %d (%q) has empty text", i+1, jp.Title)
}
if seen[jp.Title] {
t.Errorf("duplicate page title %q", jp.Title)
}
seen[jp.Title] = true
}
}
func TestJournalBitmask_GrantQueryCount(t *testing.T) {
var mask int64
if journalPageCount(mask) != 0 {
t.Fatalf("empty mask should count 0")
}
if journalPageFound(mask, 1) {
t.Fatalf("page 1 should be unfound on empty mask")
}
mask = setJournalPageBit(mask, 3)
mask = setJournalPageBit(mask, 1)
if !journalPageFound(mask, 3) || !journalPageFound(mask, 1) {
t.Fatalf("pages 1 and 3 should be found")
}
if journalPageFound(mask, 2) {
t.Fatalf("page 2 should still be unfound")
}
if got := journalPageCount(mask); got != 2 {
t.Fatalf("count = %d, want 2", got)
}
// Re-setting a found page is idempotent.
if again := setJournalPageBit(mask, 3); again != mask {
t.Fatalf("re-granting page 3 changed the mask")
}
}
func TestPickUnfoundJournalPage_ExhaustsToZero(t *testing.T) {
rng := rand.New(rand.NewPCG(1, 2))
var mask int64
found := map[int]bool{}
for i := 0; i < journalTotalPages; i++ {
page := pickUnfoundJournalPage(mask, rng)
if page < 1 || page > journalTotalPages {
t.Fatalf("pick %d out of range", page)
}
if found[page] {
t.Fatalf("pick returned already-found page %d", page)
}
found[page] = true
mask = setJournalPageBit(mask, page)
}
if !journalComplete(mask) {
t.Fatalf("mask should be complete after %d picks", journalTotalPages)
}
if page := pickUnfoundJournalPage(mask, rng); page != 0 {
t.Fatalf("pick on complete mask = %d, want 0", page)
}
}
func TestRenderJournal_EmptyPartialFull(t *testing.T) {
// Empty.
empty := renderJournal(0)
if !strings.Contains(empty, "0 / ") {
t.Errorf("empty journal should show 0 found:\n%s", empty)
}
if strings.Contains(empty, "…") {
t.Errorf("empty journal should not render a gap marker:\n%s", empty)
}
// Partial with a gap: pages 1 and 3 found, 2 missing → exactly one "…".
var partial int64
partial = setJournalPageBit(partial, 1)
partial = setJournalPageBit(partial, 3)
pv := renderJournal(partial)
if !strings.Contains(pv, journalPages[0].Title) || !strings.Contains(pv, journalPages[2].Title) {
t.Errorf("partial journal missing a found page title:\n%s", pv)
}
if strings.Contains(pv, journalPages[1].Title) {
t.Errorf("partial journal leaked an unfound page's text:\n%s", pv)
}
if !strings.Contains(pv, "…") {
t.Errorf("partial journal with a gap should render '…':\n%s", pv)
}
// Full: every title present, no gap, completion line.
var full int64
for i := 1; i <= journalTotalPages; i++ {
full = setJournalPageBit(full, i)
}
fv := renderJournal(full)
if strings.Contains(fv, "…") {
t.Errorf("complete journal should have no gaps:\n%s", fv)
}
for _, jp := range journalPages {
if !strings.Contains(fv, jp.Title) {
t.Errorf("complete journal missing page %q", jp.Title)
}
}
if !journalComplete(full) {
t.Fatalf("full mask should be complete")
}
}
func TestJournalDropChance_Sane(t *testing.T) {
if journalPageDropChance <= 0 || journalPageDropChance >= 1 {
t.Fatalf("drop chance %v out of (0,1)", journalPageDropChance)
}
}
func TestBossEpilogues_EveryRegisteredZoneHasOne(t *testing.T) {
for _, z := range allZones() {
if strings.TrimSpace(bossEpilogueLine(z.ID)) == "" {
t.Errorf("zone %s (%s) has no boss epilogue", z.ID, z.Display)
}
}
// The synthetic arena is not a campaign zone — it must have none.
if bossEpilogueLine(ZoneArena) != "" {
t.Errorf("arena should have no boss epilogue")
}
if bossEpilogueLine(ZoneID("nonexistent")) != "" {
t.Errorf("unknown zone should have no epilogue")
}
}
func TestHollowKingFinaleMonster_ClonesBelaxathWithBump(t *testing.T) {
base := dndBestiary["boss_belaxath"]
if base.ID == "" {
t.Fatal("boss_belaxath missing from bestiary — finale clones it")
}
m := hollowKingFinaleMonster()
if m.ID != "boss_hollow_king_finale" {
t.Errorf("finale ID = %q", m.ID)
}
if m.Name == base.Name || m.Name == "" {
t.Errorf("finale name should differ from Belaxath: %q", m.Name)
}
if m.HP <= base.HP {
t.Errorf("finale HP %d should exceed base %d (capstone bump)", m.HP, base.HP)
}
// No new mechanics: the ability and defensive profile carry over unchanged.
if m.Ability != base.Ability {
t.Errorf("finale ability pointer changed — should reuse Belaxath's, no new mechanics")
}
if m.AC != base.AC || m.CR != base.CR {
t.Errorf("finale AC/CR drifted from base: AC %d/%d CR %v/%v", m.AC, base.AC, m.CR, base.CR)
}
}
func TestEpilogueRewardOnce_FlagRoundTrip(t *testing.T) {
townTestDB(t)
u := id.UserID("@finale:test.invalid")
if got, err := loadEpilogueCleared(u); err != nil || got {
t.Fatalf("fresh player: cleared=%v err=%v, want false/nil", got, err)
}
if err := markEpilogueClearedDB(u); err != nil {
t.Fatalf("mark cleared: %v", err)
}
if got, err := loadEpilogueCleared(u); err != nil || !got {
t.Fatalf("after mark: cleared=%v err=%v, want true/nil", got, err)
}
// Idempotent.
if err := markEpilogueClearedDB(u); err != nil {
t.Fatalf("re-mark: %v", err)
}
var c AdventureCharacter
c.UserID = u
applyPlayerMetaOverlay(&c)
if !c.EpilogueCleared {
t.Fatalf("overlay did not carry EpilogueCleared")
}
}
func TestEpilogueUnlocked_Gates(t *testing.T) {
townTestDB(t)
p := &AdventurePlugin{}
// Incomplete journal → refused, page-count message.
partial := &AdventureCharacter{UserID: id.UserID("@u1:test.invalid")}
partial.JournalPages = setJournalPageBit(0, 1)
ok, why := p.epilogueUnlocked(partial)
if ok {
t.Fatalf("incomplete journal should not unlock")
}
if !strings.Contains(why, "journal pages") {
t.Errorf("expected page-count refusal, got: %s", why)
}
// Complete journal but no Tier-5 clears → refused, T5 message.
full := &AdventureCharacter{UserID: id.UserID("@u2:test.invalid")}
for i := 1; i <= journalTotalPages; i++ {
full.JournalPages = setJournalPageBit(full.JournalPages, i)
}
ok, why = p.epilogueUnlocked(full)
if ok {
t.Fatalf("no T5 clears should not unlock")
}
if !strings.Contains(why, "Tier-5") {
t.Errorf("expected Tier-5 refusal, got: %s", why)
}
}
func TestFinishEpilogueWin_RepeatIsFlavorOnly(t *testing.T) {
// The already-cleared branch is pure flavour: no reward, no client calls.
p := &AdventurePlugin{}
repeat := p.finishEpilogueWin(id.UserID("@u:test.invalid"), true)
if strings.Contains(repeat, finaleTitle) {
t.Errorf("a repeat clear must not re-award the title: %s", repeat)
}
if strings.TrimSpace(repeat) == "" {
t.Errorf("a repeat clear should still say something")
}
}
func TestTwinBeeJournalReaction_DeterministicAndGuarded(t *testing.T) {
if twinBeeJournalReaction(3, 0) != "" {
t.Errorf("zero pages should produce no reaction")
}
first := twinBeeJournalReaction(3, 2)
if first == "" {
t.Fatalf("a found page should produce a reaction")
}
if again := twinBeeJournalReaction(3, 2); again != first {
t.Errorf("reaction not deterministic: %q vs %q", first, again)
}
// Voice guard: TwinBee never speaks of himself in the third person
// (feedback_twinbee_voice). None of the pooled lines may contain "TwinBee".
for _, line := range twinBeeJournalReactions {
if strings.Contains(line, "TwinBee") {
t.Errorf("third-person TwinBee reference in reaction: %q", line)
}
}
}

View File

@@ -79,7 +79,7 @@ var ClosingSuccess = []string{
"in business and out of the deeper healthcare plans.\n\n" +
"Next action: {reset_time} ({time_until})\n" +
"Morning DM: {morning_time} UTC\n" +
"Evening summary: {summary_time} UTC — TwinBee will be there.\n\n" +
"Evening summary: {summary_time} UTC — I will be there.\n\n" +
"So will everyone else who showed up today.",
}
@@ -171,7 +171,7 @@ var ClosingFailure = []string{
"So will you, which is the important part.\n\n" +
"Next action: {reset_time} ({time_until})\n" +
"Tomorrow's choices at {morning_time} UTC.\n" +
"TwinBee had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
"I had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
"Rest. You've earned the rest, at least.",
"─────────────────────────────\n" +

View File

@@ -924,4 +924,4 @@ var DungeonExceptional = map[int][]string{
"in a dungeon. Write it down. " +
"The dungeon will tell this story differently.",
},
}
}

View File

@@ -52,6 +52,41 @@ var nurseJoyNudge = []string{
"Type `!hospital` to check in.",
}
// nurseJoyHaggleFlavor — Nurse Joy's reaction to the player's CHA haggle roll.
// Indexed by hospitalHaggleOutcome (haggleFumble=0, haggleFail=1, haggleWin=2, haggleCrit=3).
var nurseJoyHaggleFlavor = [][]string{
// haggleFumble — nat 1
{
"_you have drooled on the clipboard_ Oh sweetie. _gently dabs at it_ That's okay! We see this all the time. _has not seen this all the time._ Full price, but on the house emotionally!",
"You opened your mouth and a single, low groan came out. _she nods supportively_ I'll take that as 'fine with the full bill!' Wonderful negotiating!",
"_you tried to wink and both eyes closed_ Bless. We'll just go with the standard rate, sugar. Don't strain yourself.",
"You attempted charm. You produced a wet, rattling exhale. _Nurse Joy beams._ \"That's a yes to the full price! Great chat!\"",
"_you mumbled what you thought was a compliment about her hair_ That was a tube. That was the oxygen tube, honey. Full price!",
},
// haggleFail — d20+CHA below DC
{
"_smiles, doesn't blink_ I hear you, I do, but our pricing is set by the regional billing council and they are not sentimental people. Full bill it is!",
"That was a nice try! Really. _pats your hand_ Unfortunately I don't actually have the authority to discount anything, but it's so cute that you asked!",
"_tilts head sympathetically_ Oh, I would if I could! I really would. _she would not_ Standard rate, sugar.",
"Mm, no, that one didn't quite land for me. _still smiling_ But you're so brave for trying! Standard pricing applies.",
},
// haggleWin — pass
{
"_leans in conspiratorially_ You know what? You've been so polite. _types something_ I'm going to push this through as a 'goodwill adjustment.' Half off! Don't tell Derek.",
"_genuinely charmed_ Oh you're sweet. Okay, okay, I can flag this as a hardship case. Fifty percent off! _winks, has never winked at anyone before_",
"You know, the system has this little box for 'compassionate review' and I'm just _click_ going to go ahead and check that. There. Half price! Our little secret.",
"_giggles_ You're trouble. Okay — half off, but only because I like you, and because Derek is on lunch.",
},
// haggleCrit — nat 20
{
"_completely disarmed_ Oh my god. _puts down clipboard_ I haven't laughed like that on shift in YEARS. You know what? This one's on the house. Derek is going to have a complete meltdown and I do not care.",
"_dabs at her eye, recovering_ Sir. Ma'am. Whatever-you-are. That was the single most charming thing a corpse has ever said to me. Bill: waived. Walk it off.",
"_actually blushing_ I — okay. Okay! You know what? Free. It's free. _whispers to the ceiling_ Sorry Derek.",
"_stares at you for a long moment_ I'm calling it a clinical trial. You're the trial. There is no charge. Get out of here before I change my mind. _winks for real this time_",
"_to the orderlies_ Tear up the bill. _to you_ You, sugar, are a treasure. Discharge papers, no charge, complimentary water for the road.",
},
}
var nurseJoyAlive = []string{
"_looks up from clipboard_ You don't appear to be dead! That's great news! We love when people aren't dead. Come back if that changes! _cheerfully_",
"_tilts head_ Our records show you're... alive? That's wonderful! We don't actually treat alive people here. It's a whole different department. _waves vaguely_",
@@ -63,8 +98,22 @@ var nurseJoyAlreadyRevived = "You seem to have recovered on your own! " +
// ── Room Announcements ───────────────────────────────────────────────────<E29480><E29480>
var hospitalDischargeAnnounce = "🏥 %s has been discharged from St. Guildmore's Memorial Hospital. " +
"Recovered. Back in action. The bill has been described as \"a lot.\""
// hospitalDischargeBillDescriptors are the variants used for the
// "The bill has been described as ..." sentence in the discharge announcement.
// Some are bare adjectives ("devastating"); others are full quoted phrases.
var hospitalDischargeBillDescriptors = []string{
"\"inhumane but probably normal by USA standards.\"",
"devastating.",
"ruinous.",
"soul-crushing.",
"\"proof that someone, somewhere, hates me specifically.\"",
"\"tearing to shreds what little hope I had left for humanity.\"",
"\"what I imagine my greatest enemies would charge me.\"",
"\"enough to make me reconsider the whole 'surviving' part.\"",
}
var hospitalDischargeAnnouncePrefix = "🏥 %s has been discharged from St. Guildmore's Memorial Hospital. " +
"Recovered. Back in action. The bill has been described as %s"
var hospitalDitchAnnounce = "🏥 %s was brought into St. Guildmore's on a stretcher. " +
"They have been returned to the ditch. They'll be fine tomorrow."

View File

@@ -24,6 +24,31 @@ var mistyAcceptLines = []string{
const mistyDeclineLine = "She nods once and walks away. She doesn't look back."
// ── Misty — Arc Beats (D2) ─────────────────────────────────────────────────
//
// Three deepening dialogue beats keyed to MistyEncounterCount (5 / 15 / 30),
// prepended to the encounter opening the one time the counter lands on a
// threshold. Fiction only: nothing here connects a donation to a combat
// outcome — the arena effects are a hidden discovery mechanic and stay hidden.
var mistyArcBeats = map[int]string{
5: "It's you again. I was hoping it would be. I know your face now -- you're one of the few who ever stops. " +
"Most people look right through me. You never have.",
15: "Can I tell you something? I wasn't always out here asking strangers on the road. " +
"I had a home once. A garden that got away from me every summer. Someone who used to wait up. " +
"It's gone now, all of it. But you keep reminding me there are still kind people in the world. That's not nothing.",
30: "I don't think of you as a stranger anymore. Is it strange to say that? " +
"I catch myself watching the road for you now -- not for the gold, though I won't pretend it doesn't help. " +
"Just to see you're still standing. Whatever's out there, I hope it knows better than to take you from me too.",
}
// mistyArcBeat returns the deepening dialogue for the exact encounter count at
// which a Misty stage lands, or "" for every other encounter.
func mistyArcBeat(encounterCount int) string {
return mistyArcBeats[encounterCount]
}
// ── Arina — Opening Lines ──────────────────────────────────────────────────
var arinaOpenings = []string{

View File

@@ -275,6 +275,7 @@ var PetCatDeflect = []string{
"Your cat is already somewhere else.\n\nIt blinks.\n\nNot at you.\n\nAt nothing.\n\n" +
"The way cats do.",
}
// Fires randomly in the morning DM. Cat has left something.
// Results in a defense boost for the day.
// The cat is proud. The cat will never stop doing this.

View File

@@ -40,6 +40,11 @@ var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excel
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
// robbieLeftConsumable is appended when Robbie leaves a little something on his
// every-10th-visit (D2). Takes the item name.
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
// ── Room Announcements ───────────────────────────────────────────────────────
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +

View File

@@ -353,13 +353,13 @@ var SummaryDungeonExceptional = []string{
}
var SummaryDungeonDeath = []string{
"Died in {location}. Back in {hours}h. The {location} remains standing.",
"{location}: death. Equipment damaged. Healthcare involved. {hours}h respawn.",
"Did not survive {location}. The {location} did. {hours}h recovery.",
"Killed in {location}. American healthcare has the rest. {hours}h.",
"{location} wins this round. {name} recovers in {hours}h.",
"Dead. {location}. {hours} hours. The equipment is worse.",
"{name} lost the argument with {location}. Healthcare is mediating. {hours}h.",
"Died in {location}. Back in {duration}. The {location} remains standing.",
"{location}: death. Equipment damaged. Healthcare involved. {duration} respawn.",
"Did not survive {location}. The {location} did. {duration} recovery.",
"Killed in {location}. American healthcare has the rest. {duration}.",
"{location} wins this round. {name} recovers in {duration}.",
"Dead. {location}. {duration}. The equipment is worse.",
"{name} lost the argument with {location}. Healthcare is mediating. {duration}.",
}
var SummaryDungeonEmpty = []string{
@@ -381,11 +381,11 @@ var SummaryMiningSuccess = []string{
}
var SummaryMiningDeath = []string{
"Cave-in. {location}. Healthcare called. {hours}h.",
"Died in {location}. Mining death. Rarer than dungeon death. Still death. {hours}h.",
"{location} expressed structural concerns physically. {name}: {hours}h recovery.",
"The {location} ceiling had a perspective. {name}: medical. {hours}h.",
"Dead in {location}. The ore is still in there. {hours}h.",
"Cave-in. {location}. Healthcare called. {duration}.",
"Died in {location}. Mining death. Rarer than dungeon death. Still death. {duration}.",
"{location} expressed structural concerns physically. {name}: {duration} recovery.",
"The {location} ceiling had a perspective. {name}: medical. {duration}.",
"Dead in {location}. The ore is still in there. {duration}.",
}
var SummaryMiningEmpty = []string{
@@ -412,10 +412,10 @@ var SummaryForagingEmpty = []string{
}
var SummaryForagingDeath = []string{
"Died foraging. In {location}. Yes, foraging. {hours}h.",
"{location} produced a fatal outcome via non-combat means. {hours}h.",
"Dead in {location}. Bear/hornets/river/tree/mushroom involvement. {hours}h.",
"Foraging death. {location}. The forest won. {hours}h.",
"Died foraging. In {location}. Yes, foraging. {duration}.",
"{location} produced a fatal outcome via non-combat means. {duration}.",
"Dead in {location}. Bear/hornets/river/tree/mushroom involvement. {duration}.",
"Foraging death. {location}. The forest won. {duration}.",
}
var SummaryForagingHornets = []string{
@@ -442,7 +442,7 @@ var SummaryResting = []string{
"Resting today. XP: zero. Loot: zero. Alive: yes.",
"No action. Hovel. Wall. Nothing. Home.",
"Sat this one out. The dungeons noticed. The dungeons don't care.",
"Rest day. TwinBee noticed.",
"Rest day. I noticed.",
}
var SummaryDead = []string{
@@ -468,7 +468,7 @@ var SummaryStandoutDeath = []string{
"💀 {name} lost to {location}. The rats/goblins/trolls send their regards.",
"💀 Notable loss: {name} in {location}. Healthcare is familiar with the file.",
"💀 {name} did not survive {location} today. A learning experience. Expensive.",
"💀 {location} claimed {name}. {hours}h recovery. The dungeon has been noted.",
"💀 {location} claimed {name}. {duration} recovery. The dungeon has been noted.",
}
var SummaryStandoutTreasure = []string{
@@ -487,8 +487,8 @@ var SummaryStandoutHornets = []string{
var TwinBeeSummarySuccess = []string{
"Visited {location}. Retrieved {loot} worth €{value}. Returned in excellent condition.",
"{location}: success. {loot} recovered at €{value}. Standard operation.",
"Cleared {location}. {loot}, €{value}. TwinBee has reported this as typical.",
"{loot} worth €{value} from {location}. TwinBee is unsurprised.",
"Cleared {location}. {loot}, €{value}. I have reported this as typical.",
"{loot} worth €{value} from {location}. I am unsurprised.",
"Professional operation in {location}. {loot}, €{value}. No further comment.",
}
@@ -497,11 +497,11 @@ var TwinBeeSummaryWithdrawal = []string{
"{location}: early departure. Strategic positioning. Returns tomorrow.",
"Reconnaissance of {location} complete. Phase Two: tomorrow.",
"Withdrew from {location} on schedule. The schedule has been updated.",
"{location} assessed. TwinBee returns tomorrow. The {location} has been warned.",
"{location} assessed. I return tomorrow. The {location} has been warned.",
}
var TwinBeeSummaryEmpty = []string{
"Visited {location}. Nothing there today. TwinBee notes this for the record.",
"{location}: empty. The dungeon has failed to provide. TwinBee has noted it.",
"Nothing found in {location}. TwinBee is filing feedback with the dungeon.",
"Visited {location}. Nothing there today. I note this for the record.",
"{location}: empty. The dungeon has failed to provide. I have noted it.",
"Nothing found in {location}. I am filing feedback with the dungeon.",
}

View File

@@ -7,268 +7,268 @@ package plugin
// from victories, looked at the right way, which is TwinBee's way.
var TwinBeeSuccess = []string{
"TwinBee descended into {location} with characteristic enthusiasm " +
"I descended into {location} with characteristic enthusiasm " +
"and the kind of confidence that makes dungeon monsters briefly " +
"reconsider their career choices. Several reconsidered too slowly. " +
"TwinBee has returned with {loot} worth €{value} and a new personal philosophy " +
"I have returned with {loot} worth €{value} and a new personal philosophy " +
"regarding the optimum angle of attack on a Stone Golem. " +
"The philosophy works. It has been field-tested. Just now.",
"Another flawless operation. TwinBee entered {location}, assessed the situation " +
"Another flawless operation. I entered {location}, assessed the situation " +
"with professional precision, and extracted {loot} worth €{value} " +
"with minimal collateral damage to TwinBee specifically. " +
"with minimal collateral damage to me specifically. " +
"The dungeon has been informed it performed adequately. " +
"TwinBee does not give compliments freely. This was a compliment.",
"I do not give compliments freely. This was a compliment.",
"TwinBee has returned from {location} victorious, which is to say " +
"TwinBee has returned from {location}, because for TwinBee these are the same thing. " +
"{loot} worth €{value}. {xp} XP that TwinBee does not need " +
"but accepts graciously, as a gift, from the dungeon. " +
"I have returned from {location} victorious, which is to say " +
"I have returned from {location}, because for me these are the same thing. " +
"{loot} worth €{value}. {xp} XP that I do not need " +
"but accept graciously, as a gift, from the dungeon. " +
"The dungeon can try harder next time. It probably won't. They never do.",
"TwinBee located {loot} worth €{value} in {location} after what TwinBee " +
"is describing in the post-mission debrief as 'a series of increasingly correct decisions.' " +
"I located {loot} worth €{value} in {location} after what I am " +
"describing in the post-mission debrief as 'a series of increasingly correct decisions.' " +
"The monsters involved have been described as 'adequate opposition' " +
"in the same debrief. TwinBee is generous with these things.",
"in the same debrief. I am generous with these things.",
"In. Retrieved {loot}. Out. €{value}. {xp} XP. " +
"TwinBee has the efficiency of someone who has done this many times " +
"I have the efficiency of someone who has done this many times " +
"and the confidence of someone who has never once considered " +
"that it might not go well. These two qualities, combined, produce results. " +
"The results are in your share of the haul.",
"TwinBee reports: {location} cleared, {loot} secured, €{value} assessed, " +
"one near-miss that TwinBee is not classifying as a near-miss " +
"because TwinBee does not have near-misses, TwinBee has " +
"I report: {location} cleared, {loot} secured, €{value} assessed, " +
"one near-miss that I am not classifying as a near-miss " +
"because I do not have near-misses, I have " +
"'moments of dynamic tactical adjustment that resolved favorably.' " +
"The moment resolved favorably. Obviously.",
"The {location} boss encountered TwinBee today. " +
"This is how TwinBee prefers to frame the encounter — " +
"the boss encountered TwinBee, not the other way around — " +
"The {location} boss encountered me today. " +
"This is how I prefer to frame the encounter — " +
"the boss encountered me, not the other way around — " +
"because it accurately reflects the power dynamics. " +
"{loot} worth €{value}. The boss has been encountered.",
"TwinBee went to {location} and came back with {loot} worth €{value}, " +
"which is what TwinBee does, and {xp} XP, which TwinBee acknowledges " +
"I went to {location} and came back with {loot} worth €{value}, " +
"which is what I do, and {xp} XP, which I acknowledge " +
"with the quiet grace of someone for whom excellence " +
"has long since stopped being a surprise.",
"Professional assessment of today's {location} operation: successful. " +
"Loot: {loot}, €{value}. Opposition: handled. Duration: optimal. " +
"TwinBee's equipment: immaculate. TwinBee: as expected.",
"My equipment: immaculate. Me: as expected.",
"TwinBee extracted {loot} worth €{value} from {location} " +
"I extracted {loot} worth €{value} from {location} " +
"with the efficiency of a professional and the cheerfulness of someone " +
"who genuinely enjoys doing this, which TwinBee does, " +
"which is why TwinBee is better at it than everyone else, " +
"which TwinBee is.",
"who genuinely enjoys doing this, which I do, " +
"which is why I am better at it than everyone else, " +
"which I am.",
"The monsters in {location} gave it their best today. " +
"TwinBee noticed this and respected it for the duration of the encounter, " +
"I noticed this and respected it for the duration of the encounter, " +
"which was brief. {loot} worth €{value}. {xp} XP. " +
"TwinBee has returned in excellent spirits, which is not news, " +
"I have returned in excellent spirits, which is not news, " +
"but it is accurate.",
"TwinBee's route through {location} was, according to TwinBee's own review, " +
"My route through {location} was, according to my own review, " +
"'essentially perfect.' No argument has been presented to the contrary. " +
"TwinBee returned with {loot} worth €{value} and a complete absence " +
"of anything that went wrong, which is TwinBee's preferred kind of absence.",
"I returned with {loot} worth €{value} and a complete absence " +
"of anything that went wrong, which is my preferred kind of absence.",
"A lesser adventurer would have found {location} challenging today. " +
"TwinBee found it brisk. {loot}, €{value}, {xp} XP, " +
"I found it brisk. {loot}, €{value}, {xp} XP, " +
"and the mild satisfaction of a problem that turned out " +
"to be an appropriately-sized problem. TwinBee appreciates appropriate sizing.",
"to be an appropriately-sized problem. I appreciate appropriate sizing.",
"TwinBee entered {location} from the north approach, which TwinBee has determined " +
"I entered {location} from the north approach, which I have determined " +
"is the correct approach, and exited with {loot} worth €{value}. " +
"The southern approach is noted. It is not the correct approach. " +
"TwinBee has opinions about approaches and they are correct opinions.",
"I have opinions about approaches and they are correct opinions.",
"Operational summary: {location} visited, opposition encountered, opposition handled, " +
"{loot} extracted at €{value} assessed value, {xp} XP collected, " +
"TwinBee returned in the condition TwinBee left in, " +
"which is optimal, because TwinBee is always in optimal condition.",
"I returned in the condition I left in, " +
"which is optimal, because I am always in optimal condition.",
}
var TwinBeeExceptional = []string{
"OUTSTANDING PERFORMANCE. TwinBee located and neutralised the dungeon boss " +
"— which TwinBee describes as 'a scheduling conflict that has now been resolved' — " +
"OUTSTANDING PERFORMANCE. I located and neutralised the dungeon boss " +
"— which I describe as 'a scheduling conflict that has now been resolved' — " +
"and recovered {loot} worth €{value}. " +
"This is, according to TwinBee, a typical Tuesday. " +
"TwinBee has had many exceptional Tuesdays. The dungeons are aware.",
"This is, by my reckoning, a typical Tuesday. " +
"I have had many exceptional Tuesdays. The dungeons are aware.",
"TwinBee found {loot} worth €{value} in {location} after what TwinBee " +
"is calling 'a brief but educational exchange' with something very large " +
"I found {loot} worth €{value} in {location} after what I am " +
"calling 'a brief but educational exchange' with something very large " +
"that is no longer a going concern. Bonus XP noted. " +
"TwinBee noted it first. TwinBee notes everything first.",
"I noted it first. I note everything first.",
"The inner chamber. TwinBee reached the inner chamber. " +
"TwinBee notes that reaching the inner chamber requires " +
"The inner chamber. I reached the inner chamber. " +
"I note that reaching the inner chamber requires " +
"the successful navigation of everything between here and the inner chamber, " +
"which TwinBee navigated successfully, and then {loot} worth €{value} " +
"was in the inner chamber, and TwinBee took it. " +
"which I navigated successfully, and then {loot} worth €{value} " +
"was in the inner chamber, and I took it. " +
"The inner chamber is now available to be an outer chamber.",
"A boss encounter. TwinBee's assessment of the boss: 'interesting.' " +
"The boss's assessment of TwinBee: insufficient time to complete. " +
"{loot} worth €{value}. TwinBee has forwarded the boss's personnel file " +
"A boss encounter. My assessment of the boss: 'interesting.' " +
"The boss's assessment of me: insufficient time to complete. " +
"{loot} worth €{value}. I have forwarded the boss's personnel file " +
"to the relevant afterlife authorities with a positive reference.",
"EXCEPTIONAL. TwinBee's word, used sparingly. " +
"EXCEPTIONAL. My word, used sparingly. " +
"Today merits it. {loot} worth €{value} from the deepest chamber " +
"of {location}, retrieved past opposition that TwinBee describes " +
"of {location}, retrieved past opposition that I describe " +
"as 'the most interesting opposition I've faced this week,' " +
"which is high praise from TwinBee and accurately assesses the week so far.",
"which is high praise from me and accurately assesses the week so far.",
"TwinBee found something the dungeon wasn't offering publicly. " +
"TwinBee finds things dungeons aren't offering publicly. " +
"I found something the dungeon wasn't offering publicly. " +
"I find things dungeons aren't offering publicly. " +
"It's a skill. {loot} worth €{value}. {xp} XP. " +
"The dungeon has updated its offering. TwinBee approves of the update " +
"The dungeon has updated its offering. I approve of the update " +
"from a position of already having taken the thing.",
"A critical run. Every encounter resolved correctly. Every chamber yielded. " +
"Every roll, every decision, every approach: correct. " +
"{loot} worth €{value}. {xp} XP. " +
"TwinBee submits this run as the reference standard for {location}. " +
"I submit this run as the reference standard for {location}. " +
"Future runs should aspire to this. They will not reach it.",
"The boss was large. TwinBee is TwinBee. " +
"The boss was large. I am here. " +
"These two facts met in {location} and produced {loot} worth €{value} " +
"and a story that TwinBee is telling to everyone and will continue telling " +
"until a better story replaces it, which will also be TwinBee's story.",
"and a story that I am telling to everyone and will continue telling " +
"until a better story replaces it, which will also be my story.",
}
var TwinBeeWithdrawal = []string{
"TwinBee has completed today's operation and is taking the remainder " +
"I have completed today's operation and am taking the remainder " +
"of the afternoon for strategic planning. " +
"This is unrelated to {location}. {location} is unrelated to everything. " +
"TwinBee has already moved on and suggests you do the same.",
"I have already moved on and suggest you do the same.",
"TwinBee assessed {location} with characteristic thoroughness, " +
"I assessed {location} with characteristic thoroughness, " +
"identified that today's objectives had been sufficiently advanced " +
"by the reconnaissance phase, and withdrew in good order " +
"before the situation became interesting in ways TwinBee did not schedule. " +
"This is called professionalism. TwinBee has it.",
"before the situation became interesting in ways I did not schedule. " +
"This is called professionalism. I have it.",
"Today's operation at {location} concluded earlier than projected. " +
"TwinBee encountered something very large, evaluated the encounter on its merits, " +
"I encountered something very large, evaluated the encounter on its merits, " +
"and determined that returning tomorrow with more information " +
"was the superior strategic outcome. " +
"TwinBee was not running. TwinBee does not run. " +
"TwinBee was moving with purpose in the opposite direction. These are different.",
"I was not running. I do not run. " +
"I was moving with purpose in the opposite direction. These are different.",
"TwinBee left {location} at a time of TwinBee's choosing, " +
"I left {location} at a time of my own choosing, " +
"which happened to coincide with the arrival of something " +
"that TwinBee is describing in the official report as 'a scheduling conflict.' " +
"that I am describing in the official report as 'a scheduling conflict.' " +
"The conflict has been noted. It will be addressed. Tomorrow. " +
"At a time that suits TwinBee.",
"At a time that suits me.",
"TACTICAL WITHDRAWAL EXECUTED. {location} has been informed, " +
"via the medium of TwinBee's departure, that today was not the day. " +
"TwinBee knows which days are the days. Today was for gathering intelligence. " +
"via the medium of my departure, that today was not the day. " +
"I know which days are the days. Today was for gathering intelligence. " +
"The intelligence gathered is: {location} still exists and should watch itself.",
"TwinBee retreated from {location} in the same way the tide retreats from the shore — " +
"I retreated from {location} in the same way the tide retreats from the shore — " +
"deliberately, inevitably, and with the full understanding that it will be back, " +
"and the shore knows this, and the shore is thinking about it right now.",
"Something in {location} today required TwinBee to make a decision. " +
"TwinBee made the decision. The decision was: not today. " +
"TwinBee's decisions are correct decisions by definition, " +
"Something in {location} today required me to make a decision. " +
"I made the decision. The decision was: not today. " +
"My decisions are correct decisions by definition, " +
"which means 'not today' was correct, which will be proven correct " +
"when TwinBee returns tomorrow and the thing is no longer a factor. " +
"These things always work out when TwinBee decides they will.",
"when I return tomorrow and the thing is no longer a factor. " +
"These things always work out when I decide they will.",
"The mission objectives for today's {location} operation have been " +
"reclassified as 'Phase One of a Two-Phase Operation,' " +
"where Phase One is reconnaissance and Phase Two is tomorrow. " +
"Phase One is complete. Phase One was, in retrospect, always the plan.",
"TwinBee encountered something in {location} that TwinBee is describing " +
"I encountered something in {location} that I am describing " +
"as 'an opportunity to demonstrate strategic patience.' " +
"TwinBee demonstrated strategic patience. The patience was demonstrated " +
"I demonstrated strategic patience. The patience was demonstrated " +
"at significant velocity in the direction of the exit, " +
"but the patience was present throughout.",
"Official statement from TwinBee regarding today's {location} operation: " +
"Official statement from me regarding today's {location} operation: " +
"'Everything went according to plan.' " +
"The plan has been updated to reflect what happened. " +
"The updated plan describes what happened as planned. " +
"TwinBee's plans are always accurate.",
"My plans are always accurate.",
"TwinBee is back. The thing in {location} is also still there. " +
"TwinBee is aware of the thing. The thing is aware of TwinBee. " +
"This is a stalemate that TwinBee will resolve on TwinBee's timeline, " +
"I am back. The thing in {location} is also still there. " +
"I am aware of the thing. The thing is aware of me. " +
"This is a stalemate that I will resolve on my own timeline, " +
"which is different from the thing's timeline, " +
"and TwinBee's timeline is the one that matters.",
"and my timeline is the one that matters.",
"The {location} situation has been described by TwinBee as " +
"'temporarily inconclusive,' which is a phrase TwinBee uses " +
"The {location} situation has been described, by me, as " +
"'temporarily inconclusive,' which is a phrase I use " +
"when something very large expressed a strong position " +
"and TwinBee has chosen to revisit the position on better terms. " +
"The terms will be better. TwinBee will see to it.",
"and I have chosen to revisit the position on better terms. " +
"The terms will be better. I will see to it.",
"Today, TwinBee gathered extensive tactical data on {location}. " +
"Today, I gathered extensive tactical data on {location}. " +
"The data indicates that {location} is well-defended. " +
"The data was gathered by going in and then coming back out, " +
"which is a time-honoured data gathering methodology " +
"that TwinBee has employed before and will employ again, ideally less urgently.",
"that I have employed before and will employ again, ideally less urgently.",
"TwinBee's return today is best understood as a 'victory lap around the outside,' " +
"which is a phrase TwinBee has just coined and will be using going forward. " +
"My return today is best understood as a 'victory lap around the outside,' " +
"which is a phrase I have just coined and will be using going forward. " +
"A victory lap around the outside of {location} has been completed. " +
"TwinBee is home. The inside of {location} is tomorrow's victory lap.",
"I am home. The inside of {location} is tomorrow's victory lap.",
"Something the size of a building expressed interest in TwinBee at depth. " +
"TwinBee expressed equal and opposite interest in being elsewhere. " +
"Both interests were satisfied. TwinBee is elsewhere. " +
"Something the size of a building expressed interest in me at depth. " +
"I expressed equal and opposite interest in being elsewhere. " +
"Both interests were satisfied. I am elsewhere. " +
"The something is where it was. The relationship is stable.",
}
var TwinBeeEmpty = []string{
"TwinBee has returned from {location} and wishes it to be known " +
"that the dungeon was empty, which TwinBee attributes entirely to their reputation " +
"preceding them. The monsters left. They knew. " +
"TwinBee respects this. TwinBee also found nothing. These facts are unrelated.",
"I have returned from {location} and wish it to be known " +
"that the dungeon was empty, which I attribute entirely to my reputation " +
"preceding me. The monsters left. They knew. " +
"I respect this. I also found nothing. These facts are unrelated.",
"Intelligence suggested {location} would be productive today. " +
"TwinBee's intelligence was incorrect. " +
"TwinBee's intelligence is reviewing its methodology. " +
"My intelligence was incorrect. " +
"My intelligence is reviewing its methodology. " +
"The dungeon contained no loot, no monsters worth discussing, " +
"and one confused bat that TwinBee has agreed not to mention publicly. " +
"and one confused bat that I have agreed not to mention publicly. " +
"Moving on.",
"{location} was cleared. By whom is unclear. TwinBee suspects a rival. " +
"TwinBee does not have rivals, technically, " +
"but is prepared to invent one for the purposes of this grievance. " +
"{location} was cleared. By whom is unclear. I suspect a rival. " +
"I do not have rivals, technically, " +
"but am prepared to invent one for the purposes of this grievance. " +
"No loot. No comment. The rival has been noted.",
"The {location} run today yielded nothing of material value, " +
"which TwinBee attributes to the universe's temporary misalignment " +
"with TwinBee's interests. The universe will correct itself. " +
"TwinBee is giving it until tomorrow.",
"which I attribute to the universe's temporary misalignment " +
"with my interests. The universe will correct itself. " +
"I am giving it until tomorrow.",
"Empty. {location} was empty today, which is {location}'s problem. " +
"TwinBee showed up. TwinBee was prepared. TwinBee had the right equipment. " +
"I showed up. I was prepared. I had the right equipment. " +
"The dungeon failed to provide an adequate challenge or adequate reward. " +
"This reflects poorly on the dungeon.",
"TwinBee cleared four rooms of {location} and found nothing worth the trip. " +
"TwinBee is not saying the trip was not worth it — TwinBee does not say that — " +
"I cleared four rooms of {location} and found nothing worth the trip. " +
"I am not saying the trip was not worth it — I do not say that — " +
"but the material outcome was zero and the XP was minimal " +
"and TwinBee would like to register this as feedback for the dungeon.",
"and I would like to register this as feedback for the dungeon.",
"The treasure was already gone when TwinBee arrived, " +
"which means someone got there before TwinBee, " +
"The treasure was already gone when I arrived, " +
"which means someone got there before me, " +
"which is a sentence that has never previously been true " +
"and which TwinBee is treating as a statistical anomaly " +
"and which I am treating as a statistical anomaly " +
"rather than a pattern that might develop.",
"No monsters. No treasure. No meaningful opposition. " +
"Just TwinBee in {location} swinging at things that weren't there. " +
"TwinBee has described this run as 'character-building.' " +
"TwinBee's character was already built. " +
"Just me in {location} swinging at things that weren't there. " +
"I have described this run as 'character-building.' " +
"My character was already built. " +
"This run has contributed nothing to the structure.",
}
@@ -529,51 +529,38 @@ var RespawnDM = []string{
}
var IdleShameDM = []string{
"{name}, you didn't leave the house today.\n\n" +
"Your adventurer sat in their hovel, stared at the wall,\n" +
"and achieved absolutely nothing. No loot. No XP. No death,\n" +
"which is honestly the nicest thing that can be said about today.\n\n" +
"Tomorrow's choices arrive at 08:00 UTC.\n" +
"Please, for the love of everything, try to be brave.",
"{name}, hey. Didn't see you out there today.\n\n" +
"I swung by the hovel — door was shut, no answer.\n" +
"Hope you're alright. The dungeon will keep. The mine will keep.\n" +
"They've been there a while; they're patient.\n\n" +
"Tomorrow: 08:00 UTC. We'll be ready when you are.",
"No action today, {name}.\n\n" +
"The morning DM was sent. The morning DM was not answered.\n" +
"The dungeon was there. The mine was there. The forest was there.\n" +
"You were also there, presumably, somewhere, not responding.\n\n" +
"TwinBee noticed. The daily summary has noted your absence.\n" +
"Tomorrow: 08:00 UTC. The options will be there. Please be there too.",
"Quiet day for you, {name}.\n\n" +
"The morning DM went out and didn't come back. That happens.\n" +
"Real life sometimes shows up uninvited and stays for dinner.\n" +
"I picked up a little extra in the field — covered for you.\n\n" +
"Tomorrow morning the choices reset. Drop by when you can.",
"{name}. You rested today.\n\n" +
"'Rested' is the word being used. It is the charitable word.\n" +
"The uncharitable word is 'nothing.' You did nothing.\n" +
"The dungeons are still there. The mines are still there.\n" +
"The foraging areas are still there. Your XP is still where you left it.\n\n" +
"08:00 UTC tomorrow. An adventure awaits.\n" +
"The adventure has been waiting since you ignored it this morning.",
"{name}, no sign of you in the field today.\n\n" +
"The forest is still there. So's the mine. So's that one rusted sword\n" +
"you keep meaning to replace. Nothing important moved.\n\n" +
"08:00 UTC tomorrow — fresh menu, same patient narrator.",
"Today passed without you, {name}.\n\n" +
"TwinBee went to a dungeon. The dungeon was real.\n" +
"Your share of TwinBee's haul was distributed to people who showed up.\n" +
"You did not show up. You did not get a share.\n" +
"TwinBee noticed. TwinBee says nothing.\n" +
"TwinBee's silence is louder than most things.\n\n" +
"Tomorrow: 08:00 UTC. Show up.",
"Missed you out there, {name}.\n\n" +
"I went on a haul today and saved a corner of the splits for you,\n" +
"then ate it on principle when you didn't show. (Sorry. House rule.)\n" +
"Nothing's lost that can't be earned back.\n\n" +
"Tomorrow: 08:00 UTC. The dungeons are warm.",
"A day of rest, {name}. Unearned, but rest.\n\n" +
"The hovel is comfortable. The wall is familiar.\n" +
"Nothing in the hovel tried to kill you, which is the one advantage the hovel has.\n" +
"The hovel also paid you nothing and taught you nothing and advanced nothing.\n" +
"The hovel is comfortable and useless. So was today.\n\n" +
"Tomorrow is 08:00 UTC. The choices will be new ones.\n" +
"Make one of them. Any of them. Please.",
"A quiet day, {name}. Those happen.\n\n" +
"You weren't on the trail. The trail didn't fall apart without you.\n" +
"It'll be there tomorrow, and the day after, and the one after that.\n\n" +
"08:00 UTC. Whenever you're back, we pick up where we left off.",
"Where were you today, {name}?\n\n" +
"The bot sent the DM. The DM was delivered.\n" +
"The DM sat there, unread or unresponded-to, while your adventurer\n" +
"sat in the hovel and the day happened without them.\n" +
"XP: not gained. Loot: not found. Death: at least avoided.\n\n" +
"Low bar. You cleared the low bar by doing nothing.\n" +
"Tomorrow: 08:00 UTC. Clear a higher bar.",
"{name}. Hope you had a good rest day.\n\n" +
"I held down the fort. Nothing dramatic, nothing lost —\n" +
"the world declined to end on your behalf, which feels generous.\n\n" +
"When you're ready, the morning DM hits at 08:00 UTC. No rush.",
}
var OnboardingDM = []string{
@@ -597,8 +584,8 @@ var OnboardingDM = []string{
var MorningDM = []string{
"⚔️ Good morning, {name}. Another day, another opportunity for regret.\n\n" +
"{character_sheet}\n\n" +
"Where are you headed? TwinBee would choose the dungeon.\n" +
"TwinBee would also survive it. You are not TwinBee. Choose wisely.",
"Where are you headed? I would choose the dungeon.\n" +
"I would also survive it. You are not TwinBee. Choose wisely.",
"⚔️ {name}. You're alive. The options are:\n\n" +
"{character_sheet}\n\n" +
@@ -622,6 +609,6 @@ var MorningDM = []string{
"{character_sheet}\n\n" +
"Or don't. You could rest. You could do nothing.\n" +
"The bot will send you a disappointed DM at midnight.\n" +
"TwinBee will know. TwinBee always knows.\n" +
"I will know. I always know.\n" +
"Reply to choose.",
}

View File

@@ -0,0 +1,351 @@
package plugin
import (
"fmt"
"strings"
"testing"
"time"
"maunium.net/go/mautrix/id"
)
// ── Equipment Mastery ──────────────────────────────────────────────────────
func TestAdvEquipmentMasteryBonus(t *testing.T) {
cases := []struct {
name string
used map[EquipmentSlot]int
want float64
}{
{"empty equip", nil, 0},
{"all sub-50", map[EquipmentSlot]int{SlotWeapon: 49, SlotArmor: 1}, 0},
{"one slot crossed first threshold", map[EquipmentSlot]int{SlotWeapon: 50}, 1},
{"one slot at all three thresholds", map[EquipmentSlot]int{SlotWeapon: 250}, 3},
{"mixed: 100 + 50", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 50}, 3},
{"all 5 slots maxed = 15 raw, capped at 10", map[EquipmentSlot]int{
SlotWeapon: 999, SlotArmor: 999, SlotHelmet: 999, SlotBoots: 999, SlotTool: 999,
}, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{}
for slot, used := range tc.used {
equip[slot] = &AdvEquipment{ActionsUsed: used}
}
got := advEquipmentMasteryBonus(equip)
if got != tc.want {
t.Errorf("got %.1f, want %.1f", got, tc.want)
}
})
}
}
func TestAdvSlotRelevantToActivity(t *testing.T) {
cases := []struct {
slot EquipmentSlot
activity AdvActivityType
want bool
}{
// Combat exercises the combat loadout.
{SlotWeapon, AdvActivityDungeon, true},
{SlotArmor, AdvActivityDungeon, true},
{SlotHelmet, AdvActivityDungeon, true},
{SlotBoots, AdvActivityDungeon, true},
{SlotTool, AdvActivityDungeon, false}, // a dungeon shouldn't master a pickaxe
// Each harvest activity exercises only the tool.
{SlotTool, AdvActivityMining, true},
{SlotTool, AdvActivityForaging, true},
{SlotTool, AdvActivityFishing, true},
{SlotWeapon, AdvActivityMining, false}, // foraging shouldn't master a sword
{SlotArmor, AdvActivityForaging, false},
{SlotHelmet, AdvActivityFishing, false},
{SlotBoots, AdvActivityMining, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%s_%s", tc.slot, tc.activity), func(t *testing.T) {
if got := advSlotRelevantToActivity(tc.slot, tc.activity); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestAdvMasteryRowSegment(t *testing.T) {
cases := []struct {
used int
want string
}{
{0, ""},
{1, "1/50"},
{49, "49/50"},
{50, "50/100 ✦"},
{99, "99/100 ✦"},
{100, "100/250 ✦✦"},
{249, "249/250 ✦✦"},
{250, "250 ✦✦✦"},
{500, "500 ✦✦✦"},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("used=%d", tc.used), func(t *testing.T) {
if got := advMasteryRowSegment(tc.used); got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestAdvMasteryRollup(t *testing.T) {
cases := []struct {
name string
used map[EquipmentSlot]int
wantCrossed int
wantMaxed int
wantBonus float64
wantAnyProgress bool
}{
{"empty", nil, 0, 0, 0, false},
{"sub-50 only", map[EquipmentSlot]int{SlotWeapon: 30}, 0, 0, 0, true},
{"one slot 1 threshold", map[EquipmentSlot]int{SlotWeapon: 50}, 1, 0, 1, true},
{"one slot maxed", map[EquipmentSlot]int{SlotWeapon: 250}, 3, 1, 3, true},
{"two slots, 5 thresholds", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 250}, 5, 1, 5, true},
{"all maxed = 15 raw, capped at 10", map[EquipmentSlot]int{
SlotWeapon: 999, SlotArmor: 999, SlotHelmet: 999, SlotBoots: 999, SlotTool: 999,
}, 15, 5, 10, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{}
for slot, used := range tc.used {
equip[slot] = &AdvEquipment{ActionsUsed: used}
}
got := advMasteryRollup(equip)
if got.ThresholdsCrossed != tc.wantCrossed {
t.Errorf("ThresholdsCrossed: got %d, want %d", got.ThresholdsCrossed, tc.wantCrossed)
}
if got.MaxedSlots != tc.wantMaxed {
t.Errorf("MaxedSlots: got %d, want %d", got.MaxedSlots, tc.wantMaxed)
}
if got.Bonus != tc.wantBonus {
t.Errorf("Bonus: got %.1f, want %.1f", got.Bonus, tc.wantBonus)
}
if got.AnyProgress != tc.wantAnyProgress {
t.Errorf("AnyProgress: got %v, want %v", got.AnyProgress, tc.wantAnyProgress)
}
})
}
}
func TestRenderAdvMasteryAggregate(t *testing.T) {
cases := []struct {
name string
used map[EquipmentSlot]int
wantEmpty bool
wantSubstring []string
wantNot []string
}{
{"no progress → empty", nil, true, nil, nil},
{"sub-threshold progress → building up", map[EquipmentSlot]int{SlotWeapon: 30},
false, []string{"building up", "first threshold at 50"}, []string{"+0%", "loot quality"}},
{"one threshold crossed → singular threshold", map[EquipmentSlot]int{SlotWeapon: 50},
false, []string{"+1% loot quality", "1 threshold crossed"}, []string{"thresholds", "maxed", "cap reached"}},
{"three thresholds, no maxed → plural threshold no maxed", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 50},
false, []string{"+3% loot quality", "3 thresholds crossed"}, []string{"maxed", "cap reached"}},
{"one maxed + extras → mention maxed slot", map[EquipmentSlot]int{SlotWeapon: 250, SlotArmor: 50},
false, []string{"+4% loot quality", "4 thresholds crossed", "1 slot maxed"}, []string{"cap reached", "slots maxed"}},
{"capped → flag cap reached", map[EquipmentSlot]int{
SlotWeapon: 250, SlotArmor: 250, SlotHelmet: 250, SlotBoots: 250, SlotTool: 250,
}, false, []string{"+10% loot quality", "15 thresholds crossed", "5 slots maxed", "cap reached"}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{}
for slot, used := range tc.used {
equip[slot] = &AdvEquipment{ActionsUsed: used}
}
got := renderAdvMasteryAggregate(equip)
if tc.wantEmpty {
if got != "" {
t.Fatalf("expected empty, got %q", got)
}
return
}
if got == "" {
t.Fatalf("expected non-empty, got empty")
}
for _, s := range tc.wantSubstring {
if !strings.Contains(got, s) {
t.Errorf("expected to contain %q, got %q", s, got)
}
}
for _, s := range tc.wantNot {
if strings.Contains(got, s) {
t.Errorf("should not contain %q, got %q", s, got)
}
}
})
}
}
// ── Treasure Rank Ordering ─────────────────────────────────────────────────
func TestAdvTreasureRank_Ordering(t *testing.T) {
t5 := &AdvTreasureDef{Key: "a", Tier: 5, Bonuses: []advTreasureBonusDef{{Type: "all_skills", Value: 5}}}
t3multi := &AdvTreasureDef{Key: "b", Tier: 3, Bonuses: []advTreasureBonusDef{
{Type: "combat_level", Value: 1}, {Type: "death_chance", Value: -1},
}}
t3single := &AdvTreasureDef{Key: "c", Tier: 3, Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 5}}}
t3same := &AdvTreasureDef{Key: "d", Tier: 3, Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 5}}}
if !advTreasureRankBetter(advTreasureRank(t5), advTreasureRank(t3multi)) {
t.Error("T5 should beat T3 regardless of bonus count")
}
if !advTreasureRankBetter(advTreasureRank(t3multi), advTreasureRank(t3single)) {
t.Error("equal tier: more bonuses should win")
}
if advTreasureRankBetter(advTreasureRank(t3single), advTreasureRank(t3same)) {
t.Error("equal tier and bonus count: should NOT be strictly better (no churn)")
}
if advTreasureRankBetter(advTreasureRank(t3same), advTreasureRank(t3single)) {
t.Error("equal tier and bonus count, reversed: also not strictly better")
}
}
func TestAdvTreasureIrreplaceable(t *testing.T) {
special := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{{Type: "special_respawn_halve", Value: 1}}}
mixed := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{
{Type: "all_skills", Value: 5}, {Type: "special_monthly_death_bypass", Value: 1},
}}
regular := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 10}}}
none := &AdvTreasureDef{}
if !advTreasureIrreplaceable(special) {
t.Error("special_* bonus should mark irreplaceable")
}
if !advTreasureIrreplaceable(mixed) {
t.Error("any special_* among bonuses should mark irreplaceable")
}
if advTreasureIrreplaceable(regular) {
t.Error("regular bonuses should not be irreplaceable")
}
if advTreasureIrreplaceable(none) {
t.Error("zero bonuses is not irreplaceable")
}
if advTreasureIrreplaceable(nil) {
t.Error("nil should not be irreplaceable")
}
}
// ── Crafting Teaser Boundaries ─────────────────────────────────────────────
func TestRenderCraftingTeaser_BracketBoundaries(t *testing.T) {
cases := []struct {
name string
foraging int
craftsSucceeded int
wantEmpty bool
wantContainsAny []string // any of these (OR)
wantNotContains []string
}{
{"foraging 6 — out of pre-window", 6, 0, true, nil, nil},
{"foraging 7 — pre-unlock with 3 levels left, plural", 7, 0, false, []string{"3 Foraging level"}, []string{"unlocks in 1 ", "unlocks in 0"}},
{"foraging 9 — pre-unlock with 1 level, singular", 9, 0, false, []string{"1 Foraging level"}, []string{"levels"}},
{"foraging 10, no crafts yet — unlocked nudge", 10, 0, false, []string{"Crafting is unlocked"}, []string{"unlocks in"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := craftingTeaserText(tc.foraging, tc.craftsSucceeded, "")
if tc.wantEmpty {
if got != "" {
t.Errorf("expected empty, got %q", got)
}
return
}
if got == "" {
t.Fatalf("expected non-empty teaser, got empty")
}
for _, s := range tc.wantContainsAny {
if strings.Contains(got, s) {
goto matched
}
}
t.Errorf("expected any of %v, got %q", tc.wantContainsAny, got)
matched:
for _, bad := range tc.wantNotContains {
if strings.Contains(got, bad) {
t.Errorf("output should not contain %q, got %q", bad, got)
}
}
})
}
}
func TestCraftingReminderWeekday_Spread(t *testing.T) {
// Across many synthetic users in the same week, the per-player weekday
// should land on most weekdays (uniform-ish hash). Asserting "≥ 4 of 7"
// is a loose sanity check that catches a constant or pathological hash.
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
hits := map[int]bool{}
for i := 0; i < 100; i++ {
uid := id.UserID(strings.Repeat("a", i+1) + "@test:local")
hits[craftingReminderWeekday(uid, now)] = true
}
if len(hits) < 5 {
t.Errorf("expected weekday spread across most days, only hit %d distinct: %v", len(hits), hits)
}
}
func TestCraftingReminderWeekday_StableWithinWeek(t *testing.T) {
uid := id.UserID("@stable:test")
mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday
sun := time.Date(2026, 5, 10, 23, 0, 0, 0, time.UTC) // Sunday same ISO week
if craftingReminderWeekday(uid, mon) != craftingReminderWeekday(uid, sun) {
t.Error("weekday should be stable within an ISO week")
}
// Following ISO week — should differ for at least *some* users; not
// guaranteed for every user but we test the rotation property exists.
nextWeek := time.Date(2026, 5, 11, 0, 0, 0, 0, time.UTC)
differs := 0
for i := 0; i < 50; i++ {
u := id.UserID(strings.Repeat("u", i+1) + "@x")
if craftingReminderWeekday(u, mon) != craftingReminderWeekday(u, nextWeek) {
differs++
}
}
if differs == 0 {
t.Error("expected the per-week hash to rotate at least some users between weeks")
}
}
// ── Location Risk/Reward Numbers ───────────────────────────────────────────
func TestCalculateAdvProbabilities_RiskReward_NormalizesAcrossConfigs(t *testing.T) {
// Risk/reward render line depends on these probabilities. Lock in that
// they normalize to 100 across a few realistic configurations beyond
// the existing single-config sum test.
cases := []struct {
name string
char *AdventureCharacter
loc *AdvLocation
}{
{"low-tier with low skill", &AdventureCharacter{ForagingSkill: 3},
&AdvLocation{Activity: AdvActivityForaging, Tier: 1, BaseDeathPct: 5, EmptyPct: 30}},
{"high-tier with high skill", &AdventureCharacter{ForagingSkill: 30},
&AdvLocation{Activity: AdvActivityForaging, Tier: 4, BaseDeathPct: 25, EmptyPct: 20}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotTool: {Tier: 3, Condition: 100},
}
probs := calculateAdvProbabilities(tc.char, equip, tc.loc, &AdvBonusSummary{}, false)
total := probs.DeathPct + probs.EmptyPct + probs.SuccessPct + probs.ExceptionalPct
if total < 99.99 || total > 100.01 {
t.Errorf("probabilities should sum to 100, got %.2f (%+v)", total, probs)
}
if probs.DeathPct < 0 || probs.ExceptionalPct < 0 {
t.Errorf("negative probability: %+v", probs)
}
})
}
}

View File

@@ -0,0 +1,200 @@
package plugin
import (
"fmt"
"math"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N4/E2 item gifting. `!give <item> @user` 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
// a few gifts a day so the feature can't become a twink-funnel for a fresh alt.
// Magic items are deliberately non-giftable: attunement and the BiS economy stay
// personal (gogobee_engagement_plan.md §E2).
const (
giftDailyCap = 3
giftTaxRate = 0.05
)
// giftableTypes is the allowlist of AdvItem.Type values a player may gift.
// Everything in adventure_inventory is unequipped by definition (equipped gear
// lives in adventure_equipment / magic_item_equipped), so "unequipped" needs no
// separate check. Magic items ("magic_item") and raw materials are excluded.
var giftableTypes = map[string]bool{
"consumable": true,
"MasterworkGear": true,
"ArenaGear": true,
}
// isGiftableItem reports whether an inventory item may be gifted, and a reason
// when it may not.
func isGiftableItem(it AdvItem) (bool, string) {
if it.Type == "magic_item" {
return false, "Magic items stay personal — attunement doesn't transfer. Try a consumable or a piece of gear."
}
if !giftableTypes[it.Type] {
return false, "You can only gift consumables and non-magic gear. Raw materials and treasures aren't giftable."
}
return true, ""
}
// pickGiftableItem resolves the item a `!give` query names, preferring a giftable
// match so a non-giftable item (a magic item, raw materials) that also matches
// can't shadow a giftable one and block the gift — the way plain findInventoryMatch
// would, since it returns the first match across the whole inventory. Returns
// (item, "") on success; (nil, "") when nothing matches at all; and (nil, reason)
// when only non-giftable items match, so the caller can explain why.
func pickGiftableItem(inv []AdvItem, query string) (*AdvItem, string) {
match := findInventoryMatch(inv, query)
if match == nil {
return nil, ""
}
if ok, _ := isGiftableItem(*match); ok {
return match, ""
}
giftables := make([]AdvItem, 0, len(inv))
for _, it := range inv {
if ok, _ := isGiftableItem(it); ok {
giftables = append(giftables, it)
}
}
if alt := findInventoryMatch(giftables, query); alt != nil {
return alt, ""
}
_, reason := isGiftableItem(*match)
return nil, reason
}
// giftCountToday returns how many gifts the sender has already sent on the given
// UTC day. The cap is enforced against the persisted log, so a restart can't
// reset someone's daily allowance.
func giftCountToday(sender id.UserID, day string) int {
var n int
_ = db.Get().QueryRow(
`SELECT COUNT(*) FROM adventure_gift_log WHERE sender = ? AND gift_day = ?`,
string(sender), day).Scan(&n)
return n
}
// logGift records a completed gift for the daily cap and the audit trail.
func logGift(sender, recipient id.UserID, itemName string, value int64, day string) error {
_, err := db.Get().Exec(
`INSERT INTO adventure_gift_log (sender, recipient, item_name, value, gift_day)
VALUES (?, ?, ?, ?, ?)`,
string(sender), string(recipient), itemName, value, day)
return err
}
// transferInventoryItem moves one inventory row from one owner to another. Scoped
// to the current owner so a stale id can't move a row that has already changed
// hands, and forces in_vault=0 so a gift always lands in the recipient's active
// pack rather than a phantom vault slot.
func transferInventoryItem(itemID int64, from, to id.UserID) (bool, error) {
res, err := db.Get().Exec(
`UPDATE adventure_inventory SET user_id = ?, in_vault = 0 WHERE id = ? AND user_id = ?`,
string(to), itemID, string(from))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// handleGiveCmd routes `!give <item> @user`.
func (p *AdventurePlugin) handleGiveCmd(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "give"))
// Serialize a sender's own gifts so two near-simultaneous !give commands
// can't both clear the daily cap or double-spend the same item row.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. Your things stay where they are for now.")
}
// The target is the trailing token; everything before it is the item name.
fields := strings.Fields(args)
if len(fields) < 2 {
return p.SendDM(ctx.Sender, "Usage: `!give <item> @user` — e.g. `!give Health Potion @alex`.")
}
targetRaw := fields[len(fields)-1]
itemQuery := strings.TrimSpace(strings.TrimSuffix(args, targetRaw))
target, ok := p.ResolveUser(targetRaw, ctx.RoomID)
if !ok {
return p.SendDM(ctx.Sender, fmt.Sprintf("Couldn't find a user matching %q. Mention them or use their exact name.", targetRaw))
}
if target == ctx.Sender {
return p.SendDM(ctx.Sender, "Regifting to yourself? That's just moving things around.")
}
// Recipient must have completed !setup.
rc, rerr := LoadDnDCharacter(target)
if rerr != nil || rc == nil || rc.PendingSetup {
name := p.DisplayName(target)
return p.SendDM(ctx.Sender, fmt.Sprintf("%s hasn't set up an adventurer yet — they need to run `!setup` before they can receive gifts.", name))
}
day := time.Now().UTC().Format("2006-01-02")
if sent := giftCountToday(ctx.Sender, day); sent >= giftDailyCap {
return p.SendDM(ctx.Sender, fmt.Sprintf("You've already sent %d gifts today (the daily limit). Try again tomorrow.", sent))
}
inv, err := loadAdvInventory(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't reach your inventory. Try again in a moment.")
}
match, reason := pickGiftableItem(inv, itemQuery)
if match == nil {
if reason == "" {
return p.SendDM(ctx.Sender, fmt.Sprintf("No inventory item matches %q. Check `!adventure inventory`.", itemQuery))
}
return p.SendDM(ctx.Sender, reason)
}
tax := int(math.Round(float64(match.Value) * giftTaxRate))
if tax > 0 {
if bal := p.euro.GetBalance(ctx.Sender); bal < float64(tax) {
return p.SendDM(ctx.Sender, fmt.Sprintf("Gifting **%s** carries a €%d handling fee (5%% of its value), and you only have €%.0f.", match.Name, tax, bal))
}
if !p.euro.Debit(ctx.Sender, float64(tax), "adventure_gift_tax") {
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
}
}
moved, err := transferInventoryItem(match.ID, ctx.Sender, target)
if err != nil || !moved {
if tax > 0 {
p.euro.Credit(ctx.Sender, float64(tax), "adventure_gift_refund")
}
return p.SendDM(ctx.Sender, "Couldn't hand the item over. Nothing changed — try again in a moment.")
}
if tax > 0 {
communityPotAdd(tax)
trackTaxPaid(ctx.Sender, tax)
}
_ = logGift(ctx.Sender, target, match.Name, match.Value, day)
senderName := p.DisplayName(ctx.Sender)
recipName := p.DisplayName(target)
_ = p.SendDM(target, fmt.Sprintf("🎁 **%s** sent you **%s**! It's in your inventory now — `!adventure inventory` to take a look.", senderName, match.Name))
remaining := giftDailyCap - giftCountToday(ctx.Sender, day)
feeNote := ""
if tax > 0 {
feeNote = fmt.Sprintf(" (€%d handling fee to the community pot)", tax)
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🎁 Sent **%s** to %s%s. %d gift(s) left today.", match.Name, recipName, feeNote, remaining))
}

View File

@@ -0,0 +1,145 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
func TestIsGiftableItem(t *testing.T) {
cases := []struct {
typ string
want bool
}{
{"consumable", true},
{"MasterworkGear", true},
{"ArenaGear", true},
{"magic_item", false},
{"treasure", false},
{"ore", false},
{"card", false},
}
for _, c := range cases {
got, reason := isGiftableItem(AdvItem{Type: c.typ, Name: "X"})
if got != c.want {
t.Errorf("isGiftableItem(%q) = %v, want %v (reason %q)", c.typ, got, c.want, reason)
}
if !got && reason == "" {
t.Errorf("isGiftableItem(%q) rejected with no reason", c.typ)
}
}
}
// TestPickGiftableItem: a non-giftable item must not shadow a giftable one that
// also matches the query.
func TestPickGiftableItem(t *testing.T) {
inv := []AdvItem{
{ID: 1, Name: "Dragon Scale", Type: "material", Tier: 5, Value: 9000},
{ID: 2, Name: "Dragon Draught", Type: "consumable", Tier: 1, Value: 200},
}
// "dragon" matches the Scale first (higher tier), but the Draught is the
// giftable one — pick it, don't refuse.
got, reason := pickGiftableItem(inv, "dragon")
if got == nil {
t.Fatalf("pickGiftableItem returned nil (reason %q), want the giftable Draught", reason)
}
if got.Name != "Dragon Draught" {
t.Errorf("picked %q, want Dragon Draught", got.Name)
}
// Only a non-giftable match → refuse with a reason.
onlyMagic := []AdvItem{{ID: 3, Name: "Ring of Power", Type: "magic_item", Value: 5000}}
got, reason = pickGiftableItem(onlyMagic, "ring")
if got != nil || reason == "" {
t.Errorf("magic-only match: got=%v reason=%q, want nil with a refusal reason", got, reason)
}
// No match at all → nil, empty reason.
got, reason = pickGiftableItem(inv, "nonexistent")
if got != nil || reason != "" {
t.Errorf("no match: got=%v reason=%q, want nil/empty", got, reason)
}
}
// TestTransferInventoryItem: an item moves owners exactly, and a wrong-owner
// transfer is a no-op.
func TestTransferInventoryItem(t *testing.T) {
vaultTestDB(t)
alice := id.UserID("@alice:test.invalid")
bob := id.UserID("@bob:test.invalid")
if err := addAdvInventoryItem(alice, AdvItem{Name: "Health Potion", Type: "consumable", Tier: 1, Value: 200}); err != nil {
t.Fatal(err)
}
inv, _ := loadAdvInventory(alice)
if len(inv) != 1 {
t.Fatalf("setup: alice has %d items, want 1", len(inv))
}
itemID := inv[0].ID
// Wrong owner can't move it.
if moved, err := transferInventoryItem(itemID, bob, alice); err != nil || moved {
t.Fatalf("wrong-owner transfer moved=%v err=%v, want no-op", moved, err)
}
// Real owner moves it to bob.
moved, err := transferInventoryItem(itemID, alice, bob)
if err != nil || !moved {
t.Fatalf("transfer moved=%v err=%v, want success", moved, err)
}
if a, _ := loadAdvInventory(alice); len(a) != 0 {
t.Errorf("alice still has %d items after gifting", len(a))
}
b, _ := loadAdvInventory(bob)
if len(b) != 1 || b[0].Name != "Health Potion" {
t.Errorf("bob's inventory = %+v, want the potion", b)
}
}
// TestTransferUnvaultsItem: gifting a currently-vaulted item lands it in the
// recipient's active pack, not a phantom vault slot.
func TestTransferUnvaultsItem(t *testing.T) {
vaultTestDB(t)
alice := id.UserID("@alice2:test.invalid")
bob := id.UserID("@bob2:test.invalid")
if err := addAdvInventoryItem(alice, AdvItem{Name: "Elixir", Type: "consumable", Tier: 1, Value: 100}); err != nil {
t.Fatal(err)
}
inv, _ := loadAdvInventory(alice)
if _, err := setItemVaulted(alice, inv[0].ID, true); err != nil {
t.Fatal(err)
}
if _, err := transferInventoryItem(inv[0].ID, alice, bob); err != nil {
t.Fatal(err)
}
// Bob sees it in active inventory (not vault).
if b, _ := loadAdvInventory(bob); len(b) != 1 {
t.Errorf("bob active inventory = %d, want 1 (item un-vaulted on gift)", len(b))
}
if bv, _ := loadAdvVault(bob); len(bv) != 0 {
t.Errorf("bob vault = %d, want 0", len(bv))
}
}
// TestGiftDailyCapCounting: logGift increments the day's count and the count is
// day-scoped.
func TestGiftDailyCapCounting(t *testing.T) {
vaultTestDB(t)
sender := id.UserID("@giver:test.invalid")
recip := id.UserID("@taker:test.invalid")
if n := giftCountToday(sender, "2026-07-10"); n != 0 {
t.Fatalf("initial count = %d, want 0", n)
}
for i := 0; i < giftDailyCap; i++ {
if err := logGift(sender, recip, "Potion", 100, "2026-07-10"); err != nil {
t.Fatal(err)
}
}
if n := giftCountToday(sender, "2026-07-10"); n != giftDailyCap {
t.Errorf("count = %d, want %d", n, giftDailyCap)
}
// A different day is a fresh allowance.
if n := giftCountToday(sender, "2026-07-11"); n != 0 {
t.Errorf("next-day count = %d, want 0", n)
}
}

Some files were not shown because too many files have changed in this diff Show More