21 Commits

Author SHA1 Message Date
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
74 changed files with 9093 additions and 113 deletions

View File

@@ -46,7 +46,9 @@ func main() {
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)") 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)") 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)") userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
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") matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)") classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
@@ -60,6 +62,7 @@ func main() {
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") 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)") 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()") jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
) )
@@ -86,11 +89,51 @@ func main() {
includeLog = *logFlag includeLog = *logFlag
} }
}) })
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses) runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
return return
} }
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers) 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 // followerClasses expands -party / -party-classes into the class of each
@@ -119,7 +162,7 @@ func followerClasses(leaderClass string, party int, spec string) []string {
return out return out
} }
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string) { func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string, companion string) {
dir := dataDir dir := dataDir
if dir == "" { if dir == "" {
var err error var err error
@@ -130,7 +173,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
} }
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers) res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers, companion)
if err != nil { if err != nil {
if res != nil { if res != nil {
if !includeLog { if !includeLog {
@@ -157,7 +200,7 @@ type matrixJob struct {
rep int 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 string) { 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) cs := splitNonEmpty(classes)
ls := parseLevels(levels) ls := parseLevels(levels)
zs := splitNonEmpty(zones) zs := splitNonEmpty(zones)
@@ -188,7 +231,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
var wg sync.WaitGroup var wg sync.WaitGroup
for i := 0; i < jobs; i++ { for i := 0; i < jobs; i++ {
wg.Add(1) wg.Add(1)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses) go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
} }
go func() { go func() {
for _, j := range work { for _, j := range work {
@@ -207,7 +250,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
} }
} }
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 string) { 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() defer wg.Done()
for j := range in { for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep) uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
@@ -233,6 +276,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
if partyClasses != "" { if partyClasses != "" {
args = append(args, "-party-classes", partyClasses) args = append(args, "-party-classes", partyClasses)
} }
if companion != "" {
args = append(args, "-companion", companion)
}
if trace { if trace {
args = append(args, "-trace") args = append(args, "-trace")
} }
@@ -272,7 +318,7 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
} }
} }
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string) (*plugin.SimResult, error) { 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) runner, err := plugin.NewSimRunner(dataDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("init runner: %w", err) return nil, fmt.Errorf("init runner: %w", err)
@@ -296,6 +342,7 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
runner.Euro.Credit(muid, bank, "expedition-sim bankroll") runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
members = append(members, muid) members = append(members, muid)
} }
runner.Companion = companion
return runner.RunPartyExpedition(uid, members, zone, cap, days) return runner.RunPartyExpedition(uid, members, zone, cap, days)
} }

View File

@@ -393,6 +393,49 @@ func runMigrations(d *sql.DB) error {
// character save, so a monotonic false→true flag can't be clobbered. // character save, so a monotonic false→true flag can't be clobbered.
// DEFAULT 0 correct for every pre-existing row, so no bootstrap. // DEFAULT 0 correct for every pre-existing row, so no bootstrap.
`ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`,
// N7/B2 Renown (gogobee_engagement_plan.md §B2). At the L20 cap, overflow
// XP that grantDnDXP used to drop instead accumulates here as cumulative
// prestige XP. renown_xp is monotonic and written by an atomic += (the
// journal_pages pattern), never the bulk character save; renown_level is
// DERIVED from it (renown_xp / renownXPPerLevel) rather than stored, so
// there is no read-modify-write race and no second field to disagree.
// DEFAULT 0 == "no renown", correct for every pre-existing row, no bootstrap.
`ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`,
// The hireable companion (pete_adventure_news_plan.md, "Pete as a
// character"). Pete's roster seat records the class and level he was
// hired at, so a party that levels mid-expedition doesn't silently
// re-roll its hireling into a different class three rooms in. Both are
// empty/0 on every player row and are only ever read for the one seat
// where isCompanionSeat holds — a player's row never consults them.
`ALTER TABLE expedition_party ADD COLUMN companion_class TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE expedition_party ADD COLUMN companion_level INTEGER NOT NULL DEFAULT 0`,
// The companion's spell-slot ledger, "used" per slot level, as a compact
// CSV of six ints (index 0 unused; cantrips cost nothing).
//
// It has to live on the *expedition*, next to the class and level it is a
// pool for. A human caster's slots are dnd_spell_slots rows that persist
// across every fight of the run and only come back at camp, so rationing a
// pool across a 30-room day IS the caster's game. The first cut of this
// parked the companion's ledger on his combat seat instead — and a seat is
// per-session, so he walked into every single fight with a full pool. The
// sim caught it: a level-penalized, gearless hireling out-cleared a human
// cleric of the leader's own level by 15pp.
`ALTER TABLE expedition_party ADD COLUMN companion_slots_used TEXT NOT NULL DEFAULT ''`,
// The companion's body, carried across the run. -1 means "unset" — he is
// at full, which is what a fresh hire is.
//
// He used to re-seat at full max HP for *every* fight, because he has no
// dnd_character row for his HP to persist onto and the close-out skipped
// him ("he arrives fresh next time"). That is an infinite body: a player
// bleeds across a 30-room run and only heals at camp, while the hireling
// soaked half the incoming damage and reset. Measured, it is most of why a
// gearless, level-penalized hireling out-cleared a human cleric of the
// leader's own level — his party fled 5 runs out of 640 where the human
// party fled 56.
`ALTER TABLE expedition_party ADD COLUMN companion_hp INTEGER NOT NULL DEFAULT -1`,
} }
for _, stmt := range columnMigrations { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {
@@ -608,6 +651,15 @@ func RunMaintenance() {
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}}, {"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}}, {"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
// Pete adventure-news queue — reap delivered rows (kept only for
// idempotency); undelivered rows stay so the sender can retry/park them.
{"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}},
// ...and reap permanently-parked rows: the sender exhausts its retries
// within a few hours, so anything still unsent after 30 days is dead
// weight the drain query already skips — drop it so a durable outage
// can't accrete rows forever.
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
// Rate limits — purge entries older than today // Rate limits — purge entries older than today
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}}, {"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
@@ -939,6 +991,46 @@ CREATE TABLE IF NOT EXISTS shade_optout (
user_id TEXT PRIMARY KEY user_id TEXT PRIMARY KEY
); );
-- Pete adventure-news seam: durable outbound queue of game-event facts sent to
-- the Pete news bot. Emit enqueues (INSERT OR IGNORE on guid = idempotency); a
-- background sender drains rows where sent_at IS NULL. Keyed on the fact guid so
-- retries and duplicate emits collapse to one row.
CREATE TABLE IF NOT EXISTS pete_emit_queue (
guid TEXT PRIMARY KEY,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL DEFAULT 0,
sent_at INTEGER
);
-- Players who opted out of being named in Pete's adventure news. Enforced at
-- emit time (anonymize, never delete). Mirrors shade_optout.
CREATE TABLE IF NOT EXISTS news_optout (
user_id TEXT PRIMARY KEY,
opted_out_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Realm-first ledger for adventure news: the first (kind,target) occurrence
-- across all players. INSERT OR IGNORE returns rows-affected>0 exactly once, so
-- the first clear of a zone/boss fires as a PRIORITY realm-first and everyone
-- after as a BULLETIN personal clear. Seeded by the cold-start backfill so a
-- fresh deploy doesn't re-flag historically-cleared content as first-ever.
CREATE TABLE IF NOT EXISTS news_realm_firsts (
kind TEXT NOT NULL,
target TEXT NOT NULL,
first_at INTEGER NOT NULL,
PRIMARY KEY (kind, target)
);
-- Durable runtime config for adventure news (e.g. the emission kill-switch).
-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after
-- 7 days, which would silently revert an operator's !news off back to on.
CREATE TABLE IF NOT EXISTS news_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Birthdays -- Birthdays
CREATE TABLE IF NOT EXISTS birthdays ( CREATE TABLE IF NOT EXISTS birthdays (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,

View File

@@ -0,0 +1,230 @@
// 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"
"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
}
var std *Client
// 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
}
// OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped.
db.Exec("pete emit enqueue",
`INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at)
VALUES (?, ?, unixepoch(), 0, 0)`,
f.GUID, 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)
}
}
}
}()
}
// drain sends up to senderBatch due rows, one at a time.
func (c *Client) drain(ctx context.Context) {
rows, err := db.Get().Query(
`SELECT guid, 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, payload string }
var batch []item
for rows.Next() {
var it item
if err := rows.Scan(&it.guid, &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.send(ctx, []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)
}
}
// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the
// bearer-POST pattern in email_nag.go:sendCode.
func (c *Client) send(ctx context.Context, payload []byte) error {
url := c.cfg.IngestURL + "/api/ingest/adventure"
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
}
// 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

@@ -9,6 +9,7 @@ import (
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -101,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
} }
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) { 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`, `INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
string(userID), achievementID, string(userID), achievementID,
) )
@@ -109,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) slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
return 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) 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 { func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
@@ -1206,9 +1243,39 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return false 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 ────────────────────────────────────────── // ── Expedition achievement helpers ──────────────────────────────────────────
// clearedZoneIDs returns the zones this player has beaten outright. Boss- // clearedZoneIDs returns the zones this player has beaten outright. Boss-

View File

@@ -184,6 +184,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"}, {Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"}, {Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"}, {Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
{Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"},
} }
} }
@@ -247,6 +248,10 @@ func (p *AdventurePlugin) Init() error {
// arrival roll. Both idempotent via JobCompleted gates. // arrival roll. Both idempotent via JobCompleted gates.
bootstrapCasterSpellBackfill() bootstrapCasterSpellBackfill()
bootstrapGrantStarterPet() bootstrapGrantStarterPet()
// Pete adventure-news cold-start: replay the back-catalogue (realm-firsts,
// deaths, single-holder achievements) the first boot the seam is live, so
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
p.bootstrapPeteNewsBackfill()
// Phase R1 orphan-archive used to run here on every Init, but it // Phase R1 orphan-archive used to run here on every Init, but it
// over-archived: it treats any active dnd_zone_run row not linked to // over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which // an active expedition as a legacy `!adventure dungeon` orphan, which
@@ -438,6 +443,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "rivals") { if p.IsCommand(ctx.Body, "rivals") {
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals")) return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
} }
if p.IsCommand(ctx.Body, "news") {
return p.handleNewsCmd(ctx)
}
// 1. Arena commands (work in rooms and DMs) // 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") { if p.IsCommand(ctx.Body, "bail") {
@@ -646,6 +654,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
treasures, _ := loadAdvTreasureBonuses(char.UserID) treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID) buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false) bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
balance := p.euro.GetBalance(char.UserID) balance := p.euro.GetBalance(char.UserID)
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName) text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
@@ -796,6 +805,7 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
ForagingSkill: c.ForagingSkill, ForagingSkill: c.ForagingSkill,
FishingSkill: c.FishingSkill, FishingSkill: c.FishingSkill,
CurrentStreak: c.CurrentStreak, CurrentStreak: c.CurrentStreak,
Renown: renownLevelForUser(c.UserID),
}) })
} }
text := renderAdvLeaderboard(entries) text := renderAdvLeaderboard(entries)

View File

@@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
} }
totalXP := int(float64(run.XPAccumulated) * xpMult) 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. // Arena tax: 10% of earnings to community pot.
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1)) arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
arenaNet := run.Earnings - arenaTax arenaNet := run.Earnings - arenaTax

View File

@@ -1,6 +1,9 @@
package plugin package plugin
import ( import (
"database/sql"
"errors"
"strings"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
@@ -118,6 +121,18 @@ type AdventureCharacter struct {
// N5/D1c the finale reward-once flag. True after the first finale clear; // N5/D1c the finale reward-once flag. True after the first finale clear;
// overlay-read, written by the atomic markEpilogueClearedDB. // overlay-read, written by the atomic markEpilogueClearedDB.
EpilogueCleared bool 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 { type AdvEquipment struct {
@@ -524,6 +539,41 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
return tx.Commit() return tx.Commit()
} }
// 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()
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
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
return createAdvCharacter(userID, localpartOf(userID))
}
// 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 // saveAdvCharacter persists every mutable AdventureCharacter field to
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been // player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
// retired — the row is now read-only after createAdvCharacter seeds it, // retired — the row is now read-only after createAdvCharacter seeds it,

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

@@ -32,6 +32,7 @@ import (
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/peteclient"
"github.com/google/uuid" "github.com/google/uuid"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -613,6 +614,22 @@ func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, c
upsertRivalRecord(winnerID, loserID, true) upsertRivalRecord(winnerID, loserID, true)
upsertRivalRecord(loserID, winnerID, false) 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) p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
return nil return nil
} }

View File

@@ -0,0 +1,101 @@
package plugin
import (
"time"
)
// N7/B3 — the Omen: one rotating world modifier per ISO week
// (gogobee_engagement_plan.md §B3).
//
// The active omen is a pure function of the ISO (year, week): omenTable indexed
// by (year*53 + week) % len, so it advances by exactly one entry each week and
// needs no schema, no ticker state, and no persistence. Every seam reads
// activeOmen() the same way isHolidayToday() is read, and a zero-valued effect
// field means "this omen doesn't touch that seam."
//
// Launch-set rule (§B3): every omen is a buff-with-texture on a NON-combat
// lever — harvest, supplies, expedition mood/threat, arena payout, ingredient
// drops. Nothing touches SimulateCombat or the turn engine: the omen is keyed
// on the real clock, so a combat-affecting omen would make the combat golden
// and the balance corpus week-dependent. The plan's "elites +2 ATK" mutator is
// deliberately omitted for exactly that reason.
type omen struct {
Key string // stable id (tests, logs)
Name string // player-facing name, e.g. "Bountiful Harvest"
TwinBee string // first-person announce blurb (TwinBee voice)
// Effect fields — zero == no effect at that seam.
HarvestYieldBonus int // + units per harvest grant
SupplyFreebiePacks int // + complimentary standard packs at outfitting
StartMoodBonus int // + starting DM mood on a new expedition
ArenaPayoutMult float64 // >1 scales arena net earnings
ConsumableChanceMult float64 // >1 scales the per-win ingredient drop chance
ThreatDriftReduce int // subtract from the daily threat *rise* (floored at hold-steady)
}
// simOmenDisabled neutralizes the weekly Omen for the balance sim, so a corpus
// sweep's results never depend on which wall-clock week it was run in. Set true
// by NewSimRunner (mirrors simAutoArmEnabled). Every seam reads activeOmen(),
// which returns the no-effect omen while this is set.
var simOmenDisabled bool
// omenTable is the weekly rotation. Keep it a set of distinct non-combat levers;
// order is the rotation order. Adding an entry reshuffles the schedule but never
// breaks determinism (still a pure function of the week).
var omenTable = []omen{
{
Key: "bountiful_harvest", Name: "Bountiful Harvest",
TwinBee: "The land's feeling generous this week — every gather I make comes up with a little extra in hand.",
HarvestYieldBonus: 1,
},
{
Key: "quartermasters_blessing", Name: "Quartermaster's Blessing",
TwinBee: "Somebody left the storerooms unlocked. Outfitting an expedition this week? There's a free pack in it, and I'm setting out in good spirits.",
SupplyFreebiePacks: 1,
StartMoodBonus: 5,
},
{
Key: "golden_purse", Name: "Golden Purse",
TwinBee: "The arena crowd's flush this week — purses are paying out fat. Twenty percent over the odds, if you can win it.",
ArenaPayoutMult: 1.20,
},
{
Key: "overflowing_satchels", Name: "Overflowing Satchels",
TwinBee: "Reagents are turning up everywhere I look — twice as often as usual. Good week to stock the crafting shelf.",
ConsumableChanceMult: 2.0,
},
{
Key: "still_waters", Name: "Still Waters",
TwinBee: "It's quiet out there. Whatever's hunting us is slow to rouse this week — the daily dread holds steady instead of creeping up.",
ThreatDriftReduce: 1,
},
}
// omenForWeek returns the omen for an ISO (year, week). Pure and total.
func omenForWeek(year, week int) omen {
idx := ((year*53)+week)%len(omenTable) + len(omenTable)
return omenTable[idx%len(omenTable)]
}
// activeOmen returns this week's omen (UTC ISO week), or a no-effect omen when
// the balance sim has disabled it.
func activeOmen() omen {
if simOmenDisabled {
return omen{Key: "none", Name: "None"}
}
// N7/E4 — a live season's themed omen overrides the weekly rotation. Kept
// behind the simOmenDisabled guard above so the balance sim never sees it.
if s, ok := activeSeason(); ok {
return s.Omen
}
y, w := time.Now().UTC().ISOWeek()
return omenForWeek(y, w)
}
// omenMorningLine is the compact one-liner surfaced in the morning DM (the §B3
// announce seam). Empty is never returned — an omen is always active — but the
// caller may still choose when to show it.
func omenMorningLine(o omen) string {
return "🔮 **The Omen — " + o.Name + ".** _" + o.TwinBee + "_"
}

View File

@@ -0,0 +1,86 @@
package plugin
import "testing"
// TestOmenForWeek_Deterministic — the same ISO week always yields the same omen.
func TestOmenForWeek_Deterministic(t *testing.T) {
a := omenForWeek(2026, 28)
b := omenForWeek(2026, 28)
if a.Key != b.Key {
t.Fatalf("omenForWeek not deterministic: %q vs %q", a.Key, b.Key)
}
}
// TestOmenForWeek_AdvancesEachWeek — consecutive weeks step by exactly one table
// entry, so the schedule rotates rather than sticking or skipping.
func TestOmenForWeek_AdvancesEachWeek(t *testing.T) {
n := len(omenTable)
for w := 1; w <= n; w++ {
cur := omenForWeek(2026, w)
next := omenForWeek(2026, w+1)
wantNext := omenTable[((2026*53)+w+1)%n]
if next.Key != wantNext.Key {
t.Errorf("week %d→%d: got %q, want %q", w, w+1, next.Key, wantNext.Key)
}
if cur.Key == next.Key {
t.Errorf("week %d and %d produced the same omen %q (should advance)", w, w+1, cur.Key)
}
}
}
// TestOmenForWeek_TotalOverYearBoundary — never panics across week/year edges,
// including ISO week 53.
func TestOmenForWeek_TotalOverYearBoundary(t *testing.T) {
for y := 2020; y <= 2030; y++ {
for w := 1; w <= 53; w++ {
o := omenForWeek(y, w)
if o.Key == "" {
t.Fatalf("omenForWeek(%d,%d) returned zero omen", y, w)
}
}
}
}
// TestOmenTable_NonCombatOnly — every omen carries at least one effect and the
// struct has no combat lever, so the launch set can never move the golden or the
// balance corpus. §B3.
func TestOmenTable_NonCombatOnly(t *testing.T) {
for _, o := range omenTable {
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
if !hasEffect {
t.Errorf("omen %q has no active effect", o.Key)
}
if o.Name == "" || o.TwinBee == "" {
t.Errorf("omen %q missing player-facing copy", o.Key)
}
}
}
// TestActiveOmen_SimDisabled — the balance sim neutralizes the omen so corpus
// results are wall-clock-independent. §B3.
func TestActiveOmen_SimDisabled(t *testing.T) {
defer func() { simOmenDisabled = false }()
simOmenDisabled = true
o := activeOmen()
if o.Key != "none" {
t.Errorf("sim-disabled omen = %q, want none", o.Key)
}
if o.HarvestYieldBonus != 0 || o.SupplyFreebiePacks != 0 || o.StartMoodBonus != 0 ||
o.ArenaPayoutMult != 0 || o.ConsumableChanceMult != 0 || o.ThreatDriftReduce != 0 {
t.Errorf("sim-disabled omen must have no effect, got %+v", o)
}
}
// TestOmenKeysUnique — no duplicate keys (a dup would make the rotation land on
// the same omen two of every len(omenTable) weeks).
func TestOmenKeysUnique(t *testing.T) {
seen := map[string]bool{}
for _, o := range omenTable {
if seen[o.Key] {
t.Errorf("duplicate omen key %q", o.Key)
}
seen[o.Key] = true
}
}

View File

@@ -312,6 +312,20 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName)) sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
} }
// N7/B3 the Omen — this week's world modifier, in TwinBee's voice. Rides the
// existing morning DM (no net-new scheduled message); persistent one-liner so
// a mid-week arrival still learns the active omen.
sb.WriteString(omenMorningLine(activeOmen()))
sb.WriteString("\n\n")
// N7/E4 — a live season adds a "what's live this week" banner under the Omen
// line (its themed omen already rode the line above). Same morning DM, no
// net-new scheduled message.
if s, ok := activeSeason(); ok {
sb.WriteString(seasonBannerLine(s))
sb.WriteString("\n\n")
}
// Pick a morning greeting // Pick a morning greeting
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm") greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
displayName, _ := loadDisplayName(userID) displayName, _ := loadDisplayName(userID)
@@ -974,6 +988,7 @@ type AdvLeaderboardEntry struct {
ForagingSkill int ForagingSkill int
FishingSkill int FishingSkill int
CurrentStreak int CurrentStreak int
Renown int // N7/B2 — prestige level, cosmetic marker only
} }
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string { func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
@@ -987,16 +1002,21 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
Score int Score int
Levels string Levels string
Streak int Streak int
Renown int
} }
var entries []entry var entries []entry
for _, c := range chars { for _, c := range chars {
// Renown adds to the ranking score so a capped, prestigious player still
// climbs — it's the only progression they have left past L20.
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10 score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
score += c.Renown * 10
name, _ := loadDisplayName(c.UserID) name, _ := loadDisplayName(c.UserID)
entries = append(entries, entry{ entries = append(entries, entry{
Name: name, Name: name,
Score: score, Score: score,
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill), Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
Streak: c.CurrentStreak, Streak: c.CurrentStreak,
Renown: c.Renown,
}) })
} }
@@ -1028,7 +1048,11 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
if e.Streak >= 7 { if e.Streak >= 7 {
streak = fmt.Sprintf(" 🔥%d", e.Streak) streak = fmt.Sprintf(" 🔥%d", e.Streak)
} }
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak)) renownBadge := ""
if m := renownMarker(e.Renown); m != "" {
renownBadge = " " + m
}
sb.WriteString(fmt.Sprintf("%s **%s**%s — %s (score: %d%s)\n", prefix, e.Name, renownBadge, e.Levels, e.Score, streak))
} }
return sb.String() return sb.String()

View File

@@ -0,0 +1,249 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"maunium.net/go/mautrix/id"
"gogobee/internal/db"
)
// N7/B2 — Renown: prestige past the level cap (gogobee_engagement_plan.md §B2).
//
// A confirmed D&D character caps at L20 (dndMaxLevel). Before N7, grantDnDXP
// silently dropped any XP earned past the cap. Renown reclaims that overflow as
// a prestige track: every renownXPPerLevel of overflow is one Renown level.
//
// Storage is a single cumulative column, player_meta.renown_xp, written by an
// atomic INSERT…ON CONFLICT … += (the journal_pages pattern) so there is no
// read-modify-write race. renown_level is DERIVED (renownLevelFor) rather than
// stored, so nothing can disagree with the XP total.
//
// The reward is deliberately prestige-only — a derived rank title, a cosmetic
// marker on the sheet/leaderboard, and a small capped bundle of *activity*
// bonuses (loot quality, XP, death avoidance). It NEVER grants combat stats:
// the balance corpus (SimulateCombat / the golden) must stay valid, so renown
// touches only the AdvBonusSummary activity levers and only at the activity
// call sites, never loadCombatBonuses.
// renownXPPerLevel is the overflow XP that buys one Renown level. Steep by
// design (§B2): a capped L20 player earns ~750 XP per T5 dungeon win, so a
// Renown level is dozens of endgame clears.
const renownXPPerLevel = 25000
// Renown perk ladder. One small step every renownPerkStepLevels Renown levels,
// capped at renownPerkMaxSteps steps. At the cap the perks total +20% XP / +15%
// loot, matching a streak-30 grant's economic half — §B2's ceiling in total
// power.
//
// The perks are deliberately ONLY the two combat-neutral levers of the
// AdvBonusSummary: combat_stats.go maps DeathModifier→Defense,
// SuccessBonus→Attack and ExceptionalBonus→CritRate, so those *are* combat
// stats and are off-limits (§B2: never combat-stat inflation, the balance
// corpus must stay valid). LootQuality and XPMultiplier are read only by the
// loot/XP economy, never by combat stat derivation — so renown can pay them out
// even through loadCombatBonuses without moving the golden. That is why §B2's
// suggested "death penalty" perk is intentionally NOT granted: it would inflate
// Defense.
const (
renownPerkStepLevels = 3
renownPerkMaxSteps = 10
)
// renownLevelFor derives the Renown level from cumulative overflow XP.
func renownLevelFor(renownXP int) int {
if renownXP <= 0 {
return 0
}
return renownXP / renownXPPerLevel
}
// renownXPIntoLevel returns (progress, cost) toward the next Renown level, for
// display. cost is always renownXPPerLevel.
func renownXPIntoLevel(renownXP int) (int, int) {
if renownXP < 0 {
renownXP = 0
}
return renownXP % renownXPPerLevel, renownXPPerLevel
}
// renownRank is one rung of the derived title ladder: the first Renown level at
// which the rank applies, and its name. A rank promotion (crossing into a new
// rung) is what gets announced in the games room; plain level-ups only DM.
type renownRank struct {
MinLevel int
Name string
}
var renownRanks = []renownRank{
{1, "Renowned"},
{3, "Storied"},
{5, "Illustrious"},
{10, "Fabled"},
{15, "Mythic"},
{20, "Ascendant"},
{30, "Eternal"},
}
// renownRankFor returns the rank name for a Renown level, or "" below level 1.
func renownRankFor(level int) string {
name := ""
for _, r := range renownRanks {
if level >= r.MinLevel {
name = r.Name
}
}
return name
}
// renownMarker is the cosmetic prestige badge shown next to a name on the sheet
// and leaderboard. Empty below Renown 1.
func renownMarker(level int) string {
if level < 1 {
return ""
}
return fmt.Sprintf("✦%d", level)
}
// applyRenownBonuses folds the capped renown perks into an AdvBonusSummary. It
// touches ONLY LootQuality and XPMultiplier — the combat-neutral economy levers
// — so it is safe to call anywhere the summary is built, including
// loadCombatBonuses, without changing any combat stat (see the const block).
func applyRenownBonuses(b *AdvBonusSummary, renownLevel int) {
if b == nil || renownLevel < renownPerkStepLevels {
return
}
steps := renownLevel / renownPerkStepLevels
if steps > renownPerkMaxSteps {
steps = renownPerkMaxSteps
}
b.XPMultiplier += float64(steps) * 2 // → +20% at the cap
b.LootQuality += float64(steps) * 1.5 // → +15% at the cap
}
// ── Persistence ──────────────────────────────────────────────────────────────
// renownAccrueSQL is the atomic cumulative += on player_meta.renown_xp (the
// journal_pages pattern), returning the post-update total. Shared by the
// standalone and transactional accrual paths so the SQL lives in one place.
const renownAccrueSQL = `INSERT INTO player_meta (user_id, renown_xp) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET renown_xp = renown_xp + excluded.renown_xp
RETURNING renown_xp`
// sqlQueryer is the subset of *sql.DB / *sql.Tx that accrueRenownXP needs, so
// the accrual can run standalone or inside grantDnDXP's save transaction.
type sqlQueryer interface {
QueryRow(query string, args ...any) *sql.Row
}
// accrueRenownXP adds delta (> 0) to renown_xp against any queryer and returns
// the cumulative totals before and after. A single statement, safe against
// concurrent grants — no lost update.
func accrueRenownXP(q sqlQueryer, userID id.UserID, delta int) (before, after int, err error) {
if err = q.QueryRow(renownAccrueSQL, string(userID), delta).Scan(&after); err != nil {
return 0, 0, fmt.Errorf("accrue renown_xp: %w", err)
}
return after - delta, after, nil
}
// addRenownXP is the standalone accrual (its own DB write). delta <= 0 is a
// no-op read. grantDnDXP uses the transactional saveDnDCharacterWithOverflow
// instead, so this is the API for callers not already saving a character.
func addRenownXP(userID id.UserID, delta int) (before, after int, err error) {
if delta <= 0 {
cur, e := loadRenownXP(userID)
return cur, cur, e
}
return accrueRenownXP(db.Get(), userID, delta)
}
// saveDnDCharacterWithOverflow persists the character and converts overflow XP
// (> 0) to Renown in one transaction, so a crash can neither drop the overflow
// nor double-credit it. Returns the cumulative renown totals before/after.
func saveDnDCharacterWithOverflow(c *DnDCharacter, overflow int) (before, after int, err error) {
tx, err := db.Get().Begin()
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
if err = saveDnDCharacterExec(tx, c); err != nil {
return 0, 0, err
}
if before, after, err = accrueRenownXP(tx, c.UserID, overflow); err != nil {
return 0, 0, err
}
if err = tx.Commit(); err != nil {
return 0, 0, err
}
return before, after, nil
}
// loadRenownXP reads the cumulative overflow XP. Absent row == 0.
func loadRenownXP(userID id.UserID) (int, error) {
var xp int
err := db.Get().QueryRow(
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&xp)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return xp, err
}
// renownLevelForUser is the per-user Renown level, for callers (leaderboard)
// that don't already hold the overlay. Errors resolve to 0.
func renownLevelForUser(userID id.UserID) int {
xp, err := loadRenownXP(userID)
if err != nil {
return 0
}
return renownLevelFor(xp)
}
// ── Announce ─────────────────────────────────────────────────────────────────
// announceRenown notifies the player of Renown level-ups (DM) and, when the
// gain crossed into a new rank rung, the games room. Event-driven off a grant,
// not a scheduled recap. from/to are Renown levels before/after the grant.
func (p *AdventurePlugin) announceRenown(userID id.UserID, from, to int) {
if p == nil || to <= from {
return
}
gained := to - from
rank := renownRankFor(to)
if p.Client != nil {
msg := fmt.Sprintf("✦ **Renown %d** ✦\n\nYou've earned %s past the level cap — your legend grows.",
to, pluralLevels(gained))
if rank != "" {
msg += fmt.Sprintf("\nRank: **%s**.", rank)
}
if err := p.SendDM(userID, msg); err != nil {
slog.Error("renown: level-up DM failed", "user", userID, "err", err)
}
}
// Games-room shout only on a rank promotion, to keep the room quiet.
if renownRankFor(from) == rank {
return
}
gr := gamesRoom()
if gr == "" {
return
}
name, _ := loadDisplayName(userID)
if name == "" {
name = string(userID)
}
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
"✦ **%s** reached **Renown %d — %s.** A legend of the realm.", name, to, rank))
}
func pluralLevels(n int) string {
if n == 1 {
return "a Renown level"
}
return fmt.Sprintf("%d Renown levels", n)
}

View File

@@ -0,0 +1,238 @@
package plugin
import (
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func newRenownTestDB(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 TestRenownLevelFor(t *testing.T) {
cases := []struct{ xp, want int }{
{0, 0},
{-100, 0},
{renownXPPerLevel - 1, 0},
{renownXPPerLevel, 1},
{renownXPPerLevel*3 + 12, 3},
{renownXPPerLevel * 30, 30},
}
for _, c := range cases {
if got := renownLevelFor(c.xp); got != c.want {
t.Errorf("renownLevelFor(%d) = %d, want %d", c.xp, got, c.want)
}
}
}
func TestRenownXPIntoLevel(t *testing.T) {
into, cost := renownXPIntoLevel(renownXPPerLevel + 500)
if cost != renownXPPerLevel {
t.Errorf("cost = %d, want %d", cost, renownXPPerLevel)
}
if into != 500 {
t.Errorf("into = %d, want 500", into)
}
}
func TestRenownRankLadderMonotonic(t *testing.T) {
// Rank thresholds must strictly increase so renownRankFor is well-defined.
for i := 1; i < len(renownRanks); i++ {
if renownRanks[i].MinLevel <= renownRanks[i-1].MinLevel {
t.Errorf("rank %d threshold %d not > %d", i, renownRanks[i].MinLevel, renownRanks[i-1].MinLevel)
}
}
if renownRankFor(0) != "" {
t.Errorf("rank at 0 should be empty")
}
if renownRankFor(1) != "Renowned" {
t.Errorf("rank at 1 = %q, want Renowned", renownRankFor(1))
}
if renownRankFor(4) != "Storied" {
t.Errorf("rank at 4 = %q, want Storied (threshold 3)", renownRankFor(4))
}
if renownRankFor(1000) != "Eternal" {
t.Errorf("top rank = %q, want Eternal", renownRankFor(1000))
}
}
func TestRenownMarker(t *testing.T) {
if renownMarker(0) != "" {
t.Errorf("marker at 0 should be empty")
}
if got := renownMarker(7); got != "✦7" {
t.Errorf("marker(7) = %q, want ✦7", got)
}
}
// TestApplyRenownBonuses_CombatNeutralAndCapped — renown grants only the two
// combat-neutral economy levers (loot/XP), capped at +15%/+20%, and must never
// touch a lever that combat_stats.go maps to a combat stat. §B2.
func TestApplyRenownBonuses_CombatNeutralAndCapped(t *testing.T) {
// Below the first step: no effect.
b := &AdvBonusSummary{}
applyRenownBonuses(b, 2)
if b.XPMultiplier != 0 || b.LootQuality != 0 {
t.Errorf("renown < step should be inert, got %+v", b)
}
// One step at renown 3.
b = &AdvBonusSummary{}
applyRenownBonuses(b, 3)
if b.XPMultiplier != 2 || b.LootQuality != 1.5 {
t.Errorf("one step wrong: %+v", b)
}
// At renown 30 and beyond, capped at +20% XP / +15% loot.
for _, lvl := range []int{30, 45, 300} {
b = &AdvBonusSummary{}
applyRenownBonuses(b, lvl)
if b.XPMultiplier != 20 || b.LootQuality != 15 {
t.Errorf("renown %d not capped: %+v", lvl, b)
}
}
// Never combat-stat inflation: the levers combat_stats.go reads
// (DeathModifier→Defense, SuccessBonus→Attack, ExceptionalBonus→CritRate)
// and the skill/combat levers must all stay at zero at any renown level.
b = &AdvBonusSummary{}
applyRenownBonuses(b, 300)
if b.DeathModifier != 0 || b.SuccessBonus != 0 || b.ExceptionalBonus != 0 {
t.Errorf("renown leaked into a combat-stat lever: %+v", b)
}
if b.CombatBonus != 0 || b.MiningBonus != 0 || b.ForagingBonus != 0 || b.FishingBonus != 0 {
t.Errorf("renown leaked into combat/skill levers: %+v", b)
}
}
// TestAddRenownXP_Accumulates — cumulative += with correct before/after.
func TestAddRenownXP_Accumulates(t *testing.T) {
newRenownTestDB(t)
uid := id.UserID("@renown_add:example")
if err := createAdvCharacter(uid, "Renowner"); err != nil {
t.Fatal(err)
}
before, after, err := addRenownXP(uid, 10000)
if err != nil {
t.Fatal(err)
}
if before != 0 || after != 10000 {
t.Errorf("first add: before=%d after=%d, want 0/10000", before, after)
}
before, after, err = addRenownXP(uid, 20000)
if err != nil {
t.Fatal(err)
}
if before != 10000 || after != 30000 {
t.Errorf("second add: before=%d after=%d, want 10000/30000", before, after)
}
got, err := loadRenownXP(uid)
if err != nil || got != 30000 {
t.Errorf("loadRenownXP = %d (err %v), want 30000", got, err)
}
if renownLevelForUser(uid) != 1 {
t.Errorf("renownLevelForUser = %d, want 1", renownLevelForUser(uid))
}
}
// TestRenownAtLeast — the B4 achievement gate reads the derived level correctly.
func TestRenownAtLeast(t *testing.T) {
newRenownTestDB(t)
uid := id.UserID("@renown_ach:example")
if err := createAdvCharacter(uid, "Achiever"); err != nil {
t.Fatal(err)
}
d := db.Get()
if renownAtLeast(d, uid, 1) {
t.Errorf("no renown yet, should not meet level 1")
}
if _, _, err := addRenownXP(uid, renownXPPerLevel*5); err != nil {
t.Fatal(err)
}
if !renownAtLeast(d, uid, 5) {
t.Errorf("renown 5 should meet level 5")
}
if renownAtLeast(d, uid, 6) {
t.Errorf("renown 5 should not meet level 6")
}
// Absent player → false, not a crash.
if renownAtLeast(d, id.UserID("@nobody:nowhere.invalid"), 1) {
t.Errorf("absent player should not meet any renown level")
}
}
// TestRenownOverlay — applyPlayerMetaOverlay populates RenownXP and RenownLevel.
func TestRenownOverlay(t *testing.T) {
newRenownTestDB(t)
uid := id.UserID("@renown_overlay:example")
if err := createAdvCharacter(uid, "Overlaid"); err != nil {
t.Fatal(err)
}
if _, _, err := addRenownXP(uid, renownXPPerLevel*5+7); err != nil {
t.Fatal(err)
}
c, err := loadAdvCharacter(uid)
if err != nil || c == nil {
t.Fatalf("load: %v", err)
}
if c.RenownXP != renownXPPerLevel*5+7 {
t.Errorf("overlay RenownXP = %d, want %d", c.RenownXP, renownXPPerLevel*5+7)
}
if c.RenownLevel() != 5 {
t.Errorf("RenownLevel() = %d, want 5", c.RenownLevel())
}
}
// TestGrantDnDXP_OverflowBecomesRenown — at the cap, grantDnDXP routes overflow
// into renown_xp and zeroes dnd_xp (the pre-N7 cap invariant is preserved).
func TestGrantDnDXP_OverflowBecomesRenown(t *testing.T) {
newRenownTestDB(t)
uid := id.UserID("@renown_grant:example")
if err := createAdvCharacter(uid, "Capped"); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: dndMaxLevel,
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12, ArmorClass: 16,
}
c.HPMax = 100
c.HPCurrent = 100
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
events, err := p.grantDnDXP(uid, renownXPPerLevel+5000)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Errorf("got %d level-up events at cap, want 0", len(events))
}
got, _ := LoadDnDCharacter(uid)
if got.Level != dndMaxLevel {
t.Errorf("level = %d, want %d", got.Level, dndMaxLevel)
}
if got.XP != 0 {
t.Errorf("dnd_xp = %d, want 0 (overflow moved to renown)", got.XP)
}
if xp, _ := loadRenownXP(uid); xp != renownXPPerLevel+5000 {
t.Errorf("renown_xp = %d, want %d", xp, renownXPPerLevel+5000)
}
if renownLevelForUser(uid) != 1 {
t.Errorf("renown level = %d, want 1", renownLevelForUser(uid))
}
}

View File

@@ -163,6 +163,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
treasures, _ := loadAdvTreasureBonuses(char.UserID) treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID) buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false) bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
balance := p.euro.GetBalance(char.UserID) balance := p.euro.GetBalance(char.UserID)
holidayLabel := "" holidayLabel := ""

View File

@@ -0,0 +1,199 @@
package plugin
import (
"time"
)
// N7/E4 — Seasonal events (gogobee_engagement_plan.md §E4).
//
// A season is a 1-week skin anchored to a real holiday. Unlike isHolidayToday()
// (a single UTC day, worth a double-action) and unlike the Omen (an ISO-week
// rotation), a season spans a window around its anchor date and layers three
// things on top of the world for that week:
//
// 1. A themed world modifier — a reskinned Omen that OVERRIDES the weekly
// rotation (see activeOmen). It reuses the omen effect fields, so the same
// non-combat rule holds: a season never touches SimulateCombat or the turn
// engine. That is enforced structurally — activeSeason() honours
// simOmenDisabled exactly as activeOmen() does, so the balance sim never
// sees a season through any path, and the season Omen only reaches the sim's
// omen seams behind activeOmen's own simOmenDisabled guard.
// 2. A limited-time curio shelf at Luigi's — a curated selection of existing
// registry items (dailyCuriosStock). No new power enters the game; the shelf
// just changes which items rotate in for the week.
// 3. A themed visitor on the road — a season-gated ambient event that leaves a
// small keepsake and a coin gift (expedition_ambient.go). No combat: the
// ambient seam has never opened a fight and this does not change that.
//
// A season is a pure function of the UTC date and the anchor calendar, so it
// needs no schema, no ticker state, and no persistence — the same discipline as
// the Omen and the holiday calendar.
// seasonHalfWidth is the number of days on each side of the anchor date that the
// season is live. 3 → a 7-day window (anchor ± 3).
const seasonHalfWidth = 3
// seasonVisitor is the themed ambient event a season adds to the road. It never
// resolves combat — it leaves a sellable keepsake and a small coin gift.
type seasonVisitor struct {
Weight int // relative weight in the ambient pick
FlavorPool []string // scene narration lines for the visit
Keepsake string // sellable trophy left behind
KeepsakeValue int64 // coin baseline of the keepsake
Coins int // flat coin gift
}
// season is one holiday skin.
type season struct {
Key string // stable id (tests, logs)
Name string // player-facing, e.g. "Hallowtide"
Emoji string // banner emoji
Anchor func(year int) time.Time // the anchor date for a given year (UTC)
Blurb string // one-line "what's live this week" banner copy
Omen omen // themed override omen (non-combat fields only)
CurioIDs []string // curated registry IDs for Luigi's shelf
Visitor seasonVisitor // the road visitor
}
// fixedAnchor builds an Anchor for a fixed month/day holiday.
func fixedAnchor(month time.Month, day int) func(int) time.Time {
return func(year int) time.Time {
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
}
// seasonTable is the set of anchor holidays that get a skin. Order is match
// order; the windows are disjoint so it never matters, but keep them disjoint.
var seasonTable = []season{
{
Key: "hallowtide", Name: "Hallowtide", Emoji: "🎃",
Anchor: fixedAnchor(time.October, 31),
Blurb: "the veil's thin — spooky curios at Luigi's and things going bump on the road, all week",
Omen: omen{
Key: "hallowtide", Name: "Hallowtide",
TwinBee: "The veil's gone thin for Hallowtide — reagents and oddments are spilling through from somewhere I'd rather not name. I'm grabbing them while they're here.",
ConsumableChanceMult: 2.0,
},
CurioIDs: []string{
"cloak_of_arachnida", "demon_armor", "goggles_of_night",
"slippers_of_spider_climbing", "staff_of_swarming_insects",
"sword_of_life_stealing", "dagger_of_venom", "cloak_of_the_bat",
},
Visitor: seasonVisitor{
Weight: 12,
FlavorPool: []string{
"A gourd-headed thing shuffles out of the dark, sets a little carved lantern on your bedroll, and shuffles right back into it.",
"Something small and many-legged skitters past, drops a trinket at your feet as if in tribute, and is gone before you can look twice.",
"A cold draft carries a whisper and a gift — a carved gourd, still faintly warm, left where you'll find it come morning.",
},
Keepsake: "Carved Gourd Lantern", KeepsakeValue: 60, Coins: 8,
},
},
{
Key: "midwinter", Name: "Midwinter Feast", Emoji: "❄️",
Anchor: fixedAnchor(time.December, 25),
Blurb: "gifts by every door — a free pack for anyone heading out, warm curios at Luigi's, all week",
Omen: omen{
Key: "midwinter", Name: "Midwinter Feast",
TwinBee: "It's Midwinter, and someone's been leaving gifts by the outfitter's door. There's a free pack in it for anyone heading out — and I set off in high spirits.",
SupplyFreebiePacks: 1,
StartMoodBonus: 5,
},
CurioIDs: []string{
"boots_of_the_winterlands", "frost_brand", "ring_of_warmth",
"staff_of_frost", "potion_of_healing", "staff_of_healing",
},
Visitor: seasonVisitor{
Weight: 12,
FlavorPool: []string{
"A bundled figure passes your camp without a word, leaves a frost-glass bauble hanging where the firelight catches it, and trudges on into the snow.",
"You wake to find your pack a little heavier — a wrapped bauble and a handful of coin, and a single set of bootprints leading away.",
"Bells, faint and far off. By the time they fade there's a bauble on your bedroll that wasn't there before.",
},
Keepsake: "Frost-Glass Bauble", KeepsakeValue: 60, Coins: 10,
},
},
{
Key: "sweethearts", Name: "Sweethearts' Revel", Emoji: "💗",
Anchor: fixedAnchor(time.February, 14),
Blurb: "the arena crowd's throwing coin — fat purses, charming curios at Luigi's, all week",
Omen: omen{
Key: "sweethearts", Name: "Sweethearts' Revel",
TwinBee: "The whole town's giddy for Sweethearts' week — the arena crowd's throwing coin like confetti. Win pretty and the purse pays twenty over the odds.",
ArenaPayoutMult: 1.20,
},
CurioIDs: []string{
"eyes_of_charming", "philter_of_love", "staff_of_charming",
"luck_blade", "stone_of_good_luck_luckstone", "pearl_of_power",
"glamoured_studded_leather",
},
Visitor: seasonVisitor{
Weight: 12,
FlavorPool: []string{
"A courier in festival colours finds you even out here, presses a ribbon-wrapped token into your hand with a wink, and hurries back the way they came.",
"A paper heart, pinned to a token and left on your pack — 'from an admirer,' it says, and nothing else.",
"Someone's left a ribbon-wrapped keepsake by the trail marker, addressed to no one and everyone. You pocket it.",
},
Keepsake: "Ribbon-Wrapped Token", KeepsakeValue: 55, Coins: 10,
},
},
{
Key: "first_bloom", Name: "First Bloom", Emoji: "🌷",
Anchor: func(year int) time.Time { return easterDate(year) },
Blurb: "everything's growing eager — fuller harvests, green curios at Luigi's, all week",
Omen: omen{
Key: "first_bloom", Name: "First Bloom",
TwinBee: "First Bloom's on us — everything's growing twice as eager and the woods feel calm with it. Every gather comes up fuller, and the dread's slow to rise.",
HarvestYieldBonus: 1,
ThreatDriftReduce: 1,
},
CurioIDs: []string{
"bag_of_beans", "potion_of_growth", "potion_of_animal_friendship",
"ring_of_animal_influence", "horn_of_valhalla", "sword_of_life_stealing",
},
Visitor: seasonVisitor{
Weight: 12,
FlavorPool: []string{
"A hare the size of a hound lopes up, regards you with unsettling calm, and leaves a pressed blossom on the ground before bounding off.",
"New vines have crept over your gear in the night — and tucked among them, a perfect pressed blossom and a little coin, as if the woods were paying rent.",
"The undergrowth parts on its own, sets a spring blossom at your feet, and closes again. You decide not to question it.",
},
Keepsake: "Pressed Spring Blossom", KeepsakeValue: 50, Coins: 8,
},
},
}
// activeSeason returns the season whose window contains today (UTC), or false.
// Honours simOmenDisabled so the balance sim never observes a season through any
// path — the same neutralisation activeOmen applies to the weekly rotation.
func activeSeason() (season, bool) {
if simOmenDisabled {
return season{}, false
}
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
return seasonForDate(today)
}
// seasonForDate is the pure core of activeSeason, split out for tests. The
// anchor is checked against the target year and its neighbours so a window that
// straddles a year boundary still resolves.
func seasonForDate(today time.Time) (season, bool) {
for _, s := range seasonTable {
for _, yr := range []int{today.Year() - 1, today.Year(), today.Year() + 1} {
a := s.Anchor(yr)
lo := a.AddDate(0, 0, -seasonHalfWidth)
hi := a.AddDate(0, 0, seasonHalfWidth)
if !today.Before(lo) && !today.After(hi) {
return s, true
}
}
}
return season{}, false
}
// seasonBannerLine is the "what's live this week" one-liner surfaced under the
// Omen line in the morning DM. Empty when no season is active.
func seasonBannerLine(s season) string {
return s.Emoji + " **" + s.Name + "** is here — " + s.Blurb + "."
}

View File

@@ -0,0 +1,120 @@
package plugin
import (
"testing"
"time"
)
func date(y int, m time.Month, d int) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
// TestSeasonForDate_ActiveOnAnchor — every season resolves on its own anchor day
// and both window edges (±seasonHalfWidth), and is dark one day past each edge.
func TestSeasonForDate_ActiveOnAnchor(t *testing.T) {
for _, s := range seasonTable {
a := s.Anchor(2026)
for _, off := range []int{-seasonHalfWidth, 0, seasonHalfWidth} {
got, ok := seasonForDate(a.AddDate(0, 0, off))
if !ok || got.Key != s.Key {
t.Errorf("%s: day offset %d → (%v, %q), want (true, %q)",
s.Key, off, ok, got.Key, s.Key)
}
}
for _, off := range []int{-seasonHalfWidth - 1, seasonHalfWidth + 1} {
if got, ok := seasonForDate(a.AddDate(0, 0, off)); ok {
t.Errorf("%s: day offset %d should be dark, got %q", s.Key, off, got.Key)
}
}
}
}
// TestSeasonForDate_WindowsDisjoint — no calendar day resolves to two seasons, so
// the first-match order in seasonForDate never hides overlapping content.
func TestSeasonForDate_WindowsDisjoint(t *testing.T) {
for y := 2024; y <= 2030; y++ {
day := date(y, time.January, 1)
end := date(y+1, time.January, 1)
for day.Before(end) {
matches := 0
for _, s := range seasonTable {
for _, yr := range []int{day.Year() - 1, day.Year(), day.Year() + 1} {
a := s.Anchor(yr)
if !day.Before(a.AddDate(0, 0, -seasonHalfWidth)) &&
!day.After(a.AddDate(0, 0, seasonHalfWidth)) {
matches++
break
}
}
}
if matches > 1 {
t.Fatalf("%s resolves to %d seasons (windows must be disjoint)",
day.Format("2006-01-02"), matches)
}
day = day.AddDate(0, 0, 1)
}
}
}
// TestSeasonTable_CuratedItemsExist — every curated curio ID is a real registry
// item, so a season's shelf never lists a phantom the buyer can't purchase. Guards
// against typos and registry edits (mirrors TestTemperMaterialsAreObtainable).
func TestSeasonTable_CuratedItemsExist(t *testing.T) {
for _, s := range seasonTable {
if len(s.CurioIDs) == 0 {
t.Errorf("%s has no curated curios", s.Key)
}
for _, id := range s.CurioIDs {
if _, ok := magicItemRegistry[id]; !ok {
t.Errorf("%s curio %q is not in magicItemRegistry", s.Key, id)
}
}
}
}
// TestSeasonTable_OmenIsNonCombat — a season's themed omen carries an effect and
// only touches the non-combat omen levers, so overriding the weekly rotation with
// it can never move the combat golden or the balance corpus. §E4/§B3.
func TestSeasonTable_OmenIsNonCombat(t *testing.T) {
for _, s := range seasonTable {
o := s.Omen
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
if !hasEffect {
t.Errorf("%s omen has no active effect", s.Key)
}
if o.Name == "" || o.TwinBee == "" {
t.Errorf("%s omen missing player-facing copy", s.Key)
}
}
}
// TestSeasonTable_VisitorRewardComplete — every season's road visitor has flavor,
// a named keepsake, and a positive keepsake value, so applyAmbientEffect always
// has a real reward to grant.
func TestSeasonTable_VisitorRewardComplete(t *testing.T) {
for _, s := range seasonTable {
v := s.Visitor
if len(v.FlavorPool) == 0 {
t.Errorf("%s visitor has no flavor", s.Key)
}
if v.Keepsake == "" || v.KeepsakeValue <= 0 {
t.Errorf("%s visitor keepsake incomplete: %q value %d", s.Key, v.Keepsake, v.KeepsakeValue)
}
if v.Weight <= 0 {
t.Errorf("%s visitor has non-positive weight %d", s.Key, v.Weight)
}
}
}
// TestActiveSeason_NeutralizedInSim — the balance sim must never observe a season
// through any path; activeSeason honours simOmenDisabled exactly as activeOmen does.
func TestActiveSeason_NeutralizedInSim(t *testing.T) {
prev := simOmenDisabled
simOmenDisabled = true
defer func() { simOmenDisabled = prev }()
if s, ok := activeSeason(); ok {
t.Fatalf("activeSeason returned %q while simOmenDisabled", s.Key)
}
}

View File

@@ -1146,13 +1146,38 @@ func dailyCuriosStock() []MagicItem {
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)) rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] }) rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
n := curiosStockSize target := curiosStockSize
if n > len(ids) { if target > len(ids) {
n = len(ids) target = len(ids)
} }
stock := make([]MagicItem, 0, n) stock := make([]MagicItem, 0, target)
for _, id := range ids[:n] { seen := make(map[string]bool)
// N7/E4 — a live season features its curated items at the front of the shelf,
// displacing the day's rotation. They are existing registry items, so no
// net-new power enters the economy for the week; only which items rotate in.
if s, ok := activeSeason(); ok {
for _, id := range s.CurioIDs {
if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
stock = append(stock, mi)
seen[id] = true
}
}
if len(stock) > target {
target = len(stock)
}
}
// Fill the remaining slots from the day's deterministic rotation.
for _, id := range ids {
if len(stock) >= target {
break
}
if seen[id] {
continue
}
stock = append(stock, magicItemRegistry[id]) stock = append(stock, magicItemRegistry[id])
seen[id] = true
} }
// Stable display order: rarity ascending, then name. // Stable display order: rarity ascending, then name.
sort.Slice(stock, func(i, j int) bool { sort.Slice(stock, func(i, j int) bool {
@@ -1167,7 +1192,11 @@ func dailyCuriosStock() []MagicItem {
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string { func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n") if s, ok := activeSeason(); ok {
sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
} else {
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
}
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance)) sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
factor := p.shopSessionPriceFactor(userID) factor := p.shopSessionPriceFactor(userID)

View File

@@ -0,0 +1,227 @@
package plugin
import (
"fmt"
"log/slog"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// bootstrapPeteNewsBackfill replays the back-catalogue through the news emit
// path the first time the seam comes up enabled, so a launch doesn't open onto
// an empty section. It covers the three tractable, high-signal sources:
//
// 1. Realm-first zone clears — the earliest boss-defeated run of each zone. This
// also SEEDS news_realm_firsts, so a later live clear of an already-cleared
// zone tiers as a BULLETIN repeat, not a mis-announced "first ever".
// 2. Deaths — every player_meta row that carries a death (graveyard content).
// 3. Single-holder achievements — rarity-gated to one holder, matching the live
// milestone rule so routine unlocks don't flood the backlog.
//
// Backfilled facts are backdated to when they happened and marked NoPush (no
// web-push spam on launch). GUIDs are deterministic (keyed on the historical
// timestamp), so a re-run — or a later live emit of the same event — dedups on
// Pete's side. Guarded one-shot; kept (never deleted at close-out) so a fresh
// deploy doesn't ghost-town again. See pete_adventure_news_plan.md gap #7.
func (p *AdventurePlugin) bootstrapPeteNewsBackfill() {
const jobName = "pete_news_backfill_v1"
if db.JobCompleted(jobName, "once") {
return
}
// The backfill IS the launch content, so it should fire the first boot the
// seam is actually live — not get silently consumed on a boot where emission
// is off. Leave the job unmarked until both switches are on.
if !peteclient.Enabled() || !newsEmissionOn() {
return
}
firsts := p.backfillZoneFirsts()
deaths := backfillDeaths()
achv := p.backfillSoloAchievements()
db.MarkJobCompleted(jobName, "once")
slog.Warn("bootstrap: pete news backfill complete",
"zone_firsts", firsts, "deaths", deaths, "achievements", achv)
}
// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY
// realm-first dispatch per zone, attributed to its earliest boss-defeating
// clearer. SQLite returns the user_id from the same row as MIN(completed_at)
// (bare-column min/max rule), so the (zone, first clearer, time) triple is
// consistent. Returns the count emitted.
func (p *AdventurePlugin) backfillZoneFirsts() int {
rows, err := db.Get().Query(
`SELECT zone_id, user_id, MIN(completed_at)
FROM dnd_zone_run
WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0
GROUP BY zone_id`)
if err != nil {
slog.Error("backfill: zone-firsts query", "err", err)
return 0
}
type first struct {
zoneID, userID, completedAt string
}
var firsts []first
for rows.Next() {
var f first
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
slog.Error("backfill: zone-firsts scan", "err", err)
continue
}
firsts = append(firsts, f)
}
rows.Close()
n := 0
for _, f := range firsts {
uid := id.UserID(f.userID)
// Seed the ledger regardless of whether we can render a dispatch, so the
// realm-first tiering is correct even for an unnamed straggler.
claimRealmFirst("zone", f.zoneID)
name := charName(uid)
if name == "" {
continue
}
ts, ok := parseSQLiteTime(f.completedAt)
if !ok {
continue
}
zone := zoneOrFallback(ZoneID(f.zoneID))
lvl := charLevel(uid)
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), f.zoneID, ts.Unix()),
EventType: "zone_first",
Tier: "priority",
Subject: name,
Zone: zone.Display,
Boss: zone.Boss.Name,
Level: lvl,
Outcome: "cleared",
OccurredAt: ts.Unix(),
NoPush: true,
}, uid, "")
n++
}
return n
}
// backfillDeaths emits one death dispatch per player_meta death record. Deaths
// are day-granular (last_death_date), so the dispatch is backdated to that day.
func backfillDeaths() int {
rows, err := db.Get().Query(
`SELECT user_id, last_death_date, death_location
FROM player_meta
WHERE last_death_date != ''`)
if err != nil {
slog.Error("backfill: deaths query", "err", err)
return 0
}
type death struct {
userID, date, location string
}
var deaths []death
for rows.Next() {
var d death
if err := rows.Scan(&d.userID, &d.date, &d.location); err != nil {
slog.Error("backfill: deaths scan", "err", err)
continue
}
deaths = append(deaths, d)
}
rows.Close()
n := 0
for _, d := range deaths {
uid := id.UserID(d.userID)
name := charName(uid)
if name == "" {
continue
}
day, err := time.Parse("2006-01-02", d.date)
if err != nil {
continue
}
lvl := charLevel(uid)
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date),
EventType: "death",
Tier: "priority",
Subject: name,
Zone: d.location,
Level: lvl,
Outcome: "lost",
OccurredAt: day.UTC().Unix(),
NoPush: true,
}, uid, "")
n++
}
return n
}
// backfillSoloAchievements emits a milestone dispatch for every achievement held
// by exactly one player — the same rarity gate the live path uses — so the
// backlog carries the genuinely distinctive unlocks, not routine ones.
func (p *AdventurePlugin) backfillSoloAchievements() int {
rows, err := db.Get().Query(
`SELECT achievement_id, user_id, unlocked_at
FROM achievements
GROUP BY achievement_id
HAVING COUNT(*) = 1`)
if err != nil {
slog.Error("backfill: achievements query", "err", err)
return 0
}
type solo struct {
achID, userID string
unlockedAt int64
}
var solos []solo
for rows.Next() {
var s solo
if err := rows.Scan(&s.achID, &s.userID, &s.unlockedAt); err != nil {
slog.Error("backfill: achievements scan", "err", err)
continue
}
solos = append(solos, s)
}
rows.Close()
n := 0
for _, s := range solos {
uid := id.UserID(s.userID)
name := charName(uid)
if name == "" {
continue
}
label := s.achID
if p.achievements != nil {
for _, a := range p.achievements.achievements {
if a.ID == s.achID {
label = a.Name
break
}
}
}
ts := s.unlockedAt
if ts == 0 {
ts = nowUnix()
}
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(uid, s.achID), s.achID),
EventType: "milestone",
Tier: "bulletin",
Subject: name,
Milestone: label,
OccurredAt: ts,
NoPush: true,
}, uid, "")
n++
}
return n
}

View File

@@ -0,0 +1,174 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// §1 — the cleric fix.
//
// Until this landed, EVERY heal in the combat engine was self-scoped:
// MistyHealProc healed the actor, HealItem fired the actor's own trigger, and
// turnActionEffect.PlayerHeal wrote to the acting seat. There was no action of any
// kind that could touch another seat's HP. A party cleric — the class whose entire
// identity is keeping other people upright — could not put one hit point on a
// friend, and N3 shipped that way without a single test going red, because party
// combat had no golden.
//
// These tests exist so that can never be true again.
// startAllyHealFight seats a healer at 0 and a hurt friend at 1.
func startAllyHealFight(t *testing.T, p *AdventurePlugin, hurtHP int) *CombatSession {
t.Helper()
healer := basePlayer()
friend := basePlayer()
enemy := Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}
// Distinct ids per fight: one active session per player is enforced, so two
// fights sharing a healer would collide.
tag := strings.ReplaceAll(t.Name(), "/", "_")
sess, err := p.startPartyCombatSession("run-"+tag, "enc", "goblin", &enemy, []CombatSeatSetup{
{UserID: healerID(tag), HP: 100, HPMax: 100, Mods: healer.Mods, C: &healer},
{UserID: friendID(tag), HP: hurtHP, HPMax: 100, Mods: friend.Mods, C: &friend},
})
if err != nil {
t.Fatal(err)
}
return sess
}
func healerID(tag string) id.UserID { return id.UserID("@healer-" + tag + ":example.org") }
func friendID(tag string) id.UserID { return id.UserID("@friend-" + tag + ":example.org") }
func TestAllyHeal_LandsOnTheFriendNotTheCaster(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
sess := startAllyHealFight(t, p, 20)
healer, friend := basePlayer(), basePlayer()
players := []*Combatant{&healer, &friend}
enemy := Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}
ct := &combatTurn{sess: sess, players: players, enemy: &enemy, seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
casterBefore, friendBefore := sess.seatHP(0), sess.seatHP(1)
_, err := p.driveCombatRound(ct, PlayerAction{
Kind: ActionCast,
Effect: &turnActionEffect{
Label: "Cure Wounds", Action: "spell_cast",
AllyHeal: 30, AllySeat: 1,
},
})
if err != nil {
t.Fatal(err)
}
if got := sess.seatHP(1); got != friendBefore+30 {
t.Errorf("friend HP = %d, want %d — the heal did not reach them. "+
"This is the bug: a cleric who cannot heal anyone but themselves.",
got, friendBefore+30)
}
if got := sess.seatHP(0); got > casterBefore {
t.Errorf("caster HP = %d (was %d) — the heal landed on the caster instead of the target",
got, casterBefore)
}
}
// A heal cannot exceed the target's ceiling, and cannot raise the dead. Death is
// terminal for the fight — the close-out marks them, the hospital takes them — and
// a heal that resurrected a corpse would quietly rewrite the loss rules every
// close-out path depends on.
func TestAllyHeal_CapsAtMaxAndWillNotRaiseTheDead(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
t.Run("caps at max", func(t *testing.T) {
sess := startAllyHealFight(t, p, 90)
healer, friend := basePlayer(), basePlayer()
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
enemy: &Combatant{Name: "d", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
if _, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 500, AllySeat: 1},
}); err != nil {
t.Fatal(err)
}
if got, want := sess.seatHP(1), sess.seatHPMax(1); got > want {
t.Errorf("friend healed to %d over a max of %d", got, want)
}
})
t.Run("will not raise the dead", func(t *testing.T) {
sess := startAllyHealFight(t, p, 0) // seat 1 is already down
healer, friend := basePlayer(), basePlayer()
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
enemy: &Combatant{Name: "d", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
if _, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 50, AllySeat: 1},
}); err != nil {
t.Fatal(err)
}
if got := sess.seatHP(1); got > 0 {
t.Errorf("a downed seat was healed to %d — healing keeps people up, it does not bring them back", got)
}
})
}
// The target parser resolves against the people in the fight, and leaves anything
// it does not recognise on the string for the spell parser — so every existing
// `!cast` form still means what it always meant.
func TestSplitCastTarget(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
sess := startAllyHealFight(t, p, 50)
healer, friend := basePlayer(), basePlayer()
healer.Name, friend.Name = "Ayla", "Bram"
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
enemy: &Combatant{Name: "d"}, seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
tName := t.Name()
tests := []struct {
name string
in string
wantArgs string
wantSeat int
wantError bool
}{
{"no target", "cure wounds", "cure wounds", -1, false},
{"slot level is not a target", "fireball 3", "fireball 3", -1, false},
{"@mention by display name", "cure wounds @Bram", "cure wounds", 1, false},
// The flag !help has advertised all along, and that parseCombatCast used to
// accept and silently throw away ("reserved for SP3").
{"--target flag", "cure wounds --target @Bram", "cure wounds", 1, false},
{"--target mid-string", "cure wounds --target Bram", "cure wounds", 1, false},
{"--target with no name", "cure wounds --target", "", -1, true},
{"bare display name", "cure wounds Bram", "cure wounds", 1, false},
{"by localpart", "cure wounds @" + friendID(strings.ReplaceAll(tName, "/", "_")).Localpart(), "cure wounds", 1, false},
{"targeting yourself is just casting it", "cure wounds @Ayla", "cure wounds", -1, false},
{"@mention of a stranger is an error", "cure wounds @nobody", "", -1, true},
{"an unknown bare word is left for the spell parser", "mass cure wounds", "mass cure wounds", -1, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
args, seat, errMsg := splitCastTarget(ct, 0, tc.in)
if tc.wantError {
if errMsg == "" {
t.Fatalf("splitCastTarget(%q) gave no error; a mistyped @mention would silently waste the slot", tc.in)
}
return
}
if errMsg != "" {
t.Fatalf("splitCastTarget(%q) errored: %s", tc.in, errMsg)
}
if args != tc.wantArgs || seat != tc.wantSeat {
t.Errorf("splitCastTarget(%q) = (%q, %d), want (%q, %d)",
tc.in, args, seat, tc.wantArgs, tc.wantSeat)
}
})
}
}

View File

@@ -171,7 +171,12 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
treasures, _ := loadAdvTreasureBonuses(userID) treasures, _ := loadAdvTreasureBonuses(userID)
buffs, _ := loadAdvActiveBuffs(userID) buffs, _ := loadAdvActiveBuffs(userID)
hasGrudge := char.GrudgeLocation != "" hasGrudge := char.GrudgeLocation != ""
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge) b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
// which combat stat derivation never reads (see adventure_renown.go).
applyRenownBonuses(b, char.RenownLevel())
return b
} }
// loadConsumableInventory scans inventory for items matching consumable definitions. // loadConsumableInventory scans inventory for items matching consumable definitions.

View File

@@ -105,7 +105,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
// this is a no-op there); mirror it here so the entry banner and the opening // this is a no-op there); mirror it here so the entry banner and the opening
// round resolve against the same ceiling startPartyCombatSession persisted and // round resolve against the same ceiling startPartyCombatSession persisted and
// the rebuilt rounds use. // the rebuilt rounds use.
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats)) enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded // Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
// per seat onto the session and its participant rows, so they survive the // per seat onto the session and its participant rows, so they survive the
@@ -425,7 +425,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
// returns the spell plus the resolved slot level. errMsg is non-empty and // returns the spell plus the resolved slot level. errMsg is non-empty and
// player-facing on any validation failure. It performs NO resource spend — // player-facing on any validation failure. It performs NO resource spend —
// the caller debits the slot only once the round is about to resolve. // the caller debits the slot only once the round is about to resolve.
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) { //
// It takes the seat, not just the user, because "do you know this spell" is a
// question about a combatant and only *usually* a question about a database row:
// the hired companion has no rows and answers it from his synthetic sheet.
func parseCombatCast(sess *CombatSession, seat int, userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
tokens := strings.Fields(args) tokens := strings.Fields(args)
upcast := 0 upcast := 0
var spellTokens []string var spellTokens []string
@@ -473,7 +477,7 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx) return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
} }
} }
known, prepared, err := playerKnowsSpell(userID, spell.ID) known, prepared, err := seatKnowsSpell(sess, seat, userID, spell.ID)
if err != nil { if err != nil {
return SpellDefinition{}, 0, "Couldn't check your spell list." return SpellDefinition{}, 0, "Couldn't check your spell list."
} }
@@ -526,12 +530,92 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
// the roster so the buff is live for the round's enemy turn. Before P5 that // the roster so the buff is live for the round's enemy turn. Before P5 that
// delta went to the session's embedded copy — seat 0 — so a party member // delta went to the session's embedded copy — seat 0 — so a party member
// buffing themselves would have buffed the leader. // buffing themselves would have buffed the leader.
// splitCastTarget peels an optional ally target off the end of a `!cast` argument
// — `cure wounds @alex`, or just `cure wounds alex` — and resolves it to a seat.
//
// It resolves against the people *in this fight* rather than the room, which is
// both cheaper (no ResolveUser round-trip, no RoomID to thread down here) and
// more correct: the only legal target of a combat heal is somebody sitting in the
// combat. Anything it does not recognise is left on the string for the spell
// parser, so `!cast cure wounds` and `!cast fireball 3` behave exactly as before.
//
// Both spellings work: the `--target @alex` flag that `!help` has advertised all
// along (and that parseCombatCast has been silently swallowing since SP2 —
// "reserved for SP3, accept and ignore"), and a plain trailing `@alex`.
//
// Returns (remainingArgs, seat, errMsg). seat is -1 when no target was named.
func splitCastTarget(ct *combatTurn, caster int, args string) (string, int, string) {
args = strings.TrimSpace(args)
if args == "" || !ct.isParty() {
return args, -1, ""
}
fields := strings.Fields(args)
// `--target <who>` anywhere in the string.
explicit, name := false, ""
for i := 0; i < len(fields); i++ {
if !strings.EqualFold(fields[i], "--target") {
continue
}
if i+1 >= len(fields) {
return args, -1, "`--target` needs a name: `!cast cure wounds --target @alex`."
}
explicit, name = true, strings.TrimPrefix(fields[i+1], "@")
fields = append(fields[:i], fields[i+2:]...)
break
}
if name == "" {
last := fields[len(fields)-1]
// A bare number is a slot level (`!cast fireball 3`), never a target.
if _, err := strconv.Atoi(last); err == nil {
return args, -1, ""
}
explicit = strings.HasPrefix(last, "@")
name = strings.TrimPrefix(last, "@")
if name == "" {
return args, -1, ""
}
fields = fields[:len(fields)-1]
}
// From here `fields` is the spell tokens with the target removed.
rest := strings.Join(fields, " ")
for i, c := range ct.players {
uid := ct.sess.seatUserID(i)
if !strings.EqualFold(c.Name, name) &&
!strings.EqualFold(uid, name) &&
!strings.EqualFold(id.UserID(uid).Localpart(), name) {
continue
}
if i == caster {
// Targeting yourself is just casting it on yourself, which is what the
// engine does by default. Drop the target and carry on.
return rest, -1, ""
}
return rest, i, ""
}
// An explicit @mention that matches nobody in the fight is a mistake worth
// naming — silently casting it on yourself would waste the slot.
if explicit {
return args, -1, fmt.Sprintf("**%s** isn't in this fight. Cast it on someone who is.", name)
}
return args, -1, ""
}
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) { func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
noop := func(bool) {} noop := func(bool) {}
uid := id.UserID(ct.sess.seatUserID(seat)) uid := id.UserID(ct.sess.seatUserID(seat))
advChar, _ := loadAdvCharacter(uid) // §1 — a heal may name somebody else in the fight: `!cast cure wounds @alex`.
c, err := p.ensureCharForDnDCmd(uid, advChar) // Split the target off before the spell is parsed, so the spell parser sees
// the same string it always has.
args, targetSeat, targetErr := splitCastTarget(ct, seat, args)
if targetErr != "" {
return PlayerAction{}, noop, targetErr
}
c, err := p.seatCastSheet(ct.sess, uid)
if err != nil || c == nil { if err != nil || c == nil {
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet." return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
} }
@@ -540,7 +624,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)) "%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
} }
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args)) spell, slotLevel, errMsg := parseCombatCast(ct.sess, seat, uid, c, strings.TrimSpace(args))
if errMsg != "" { if errMsg != "" {
return PlayerAction{}, noop, errMsg return PlayerAction{}, noop, errMsg
} }
@@ -551,7 +635,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
refund := func(ok bool) { refund := func(ok bool) {
if !ok && spell.Level > 0 { if !ok && spell.Level > 0 {
_ = refundSpellSlot(uid, slotLevel) _ = refundSeatSlot(ct.sess, seat, uid, slotLevel)
} }
} }
@@ -568,7 +652,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
return PlayerAction{}, noop, fmt.Sprintf( return PlayerAction{}, noop, fmt.Sprintf(
"%s has no effect the turn-based engine can apply yet.", spell.Name) "%s has no effect the turn-based engine can apply yet.", spell.Name)
} }
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
return PlayerAction{}, noop, msg return PlayerAction{}, noop, msg
} }
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d) ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
@@ -590,7 +674,7 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
return PlayerAction{}, noop, fmt.Sprintf( return PlayerAction{}, noop, fmt.Sprintf(
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name) "%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
} }
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(ct.sess, seat, uid, spell, slotLevel); msg != "" {
return PlayerAction{}, noop, msg return PlayerAction{}, noop, msg
} }
// Park the Necromancy kill-heal stash on the casting seat. The // Park the Necromancy kill-heal stash on the casting seat. The
@@ -612,6 +696,20 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
PlayerHeal: out.PlayerHeal, PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip, EnemySkip: out.EnemySkip,
} }
// §1 — redirect the heal onto the named ally. The roll is the same; only
// the body it lands on changes. This is the line that makes a cleric a
// cleric: until it existed, every heal in the engine was a self-heal, and
// the class whose whole job is keeping other people upright could not put
// a single hit point on a friend.
if targetSeat >= 0 && targetSeat != seat {
if out.PlayerHeal <= 0 {
return PlayerAction{}, refund, fmt.Sprintf(
"%s isn't something you can cast on someone else. Drop the target to cast it yourself.", spell.Name)
}
eff.AllyHeal, eff.AllySeat = out.PlayerHeal, targetSeat
eff.PlayerHeal = 0
eff.Label = fmt.Sprintf("%s → %s (+%d HP)", spell.Name, ct.players[targetSeat].Name, out.PlayerHeal)
}
// Concentration AOE damage spells linger: the burst lands this round // Concentration AOE damage spells linger: the burst lands this round
// (EnemyDamage) and the same value re-ticks every round_end after, via // (EnemyDamage) and the same value re-ticks every round_end after, via
// the engine's concentration aura. spiritual_weapon already covers the // the engine's concentration aura. spiritual_weapon already covers the
@@ -640,18 +738,30 @@ func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
// success the caller owns the slot and must refundSpellSlot if the round itself // success the caller owns the slot and must refundSpellSlot if the round itself
// errors. Material components (rare in a fight) are not refunded if the slot // errors. Material components (rare in a fight) are not refunded if the slot
// debit then fails — matching the auto-resolve cast path. // debit then fails — matching the auto-resolve cast path.
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string { func (p *AdventurePlugin) chargeSpellCost(sess *CombatSession, seat int, userID id.UserID, spell SpellDefinition, slotLevel int) string {
_, _, companion := seatCompanionLoadout(sess, userID)
// The companion carries no purse — he has no wallet to debit and no inventory
// to stock one from, so a component cost is not a price he can pay but a spell
// he cannot cast. Refusing here (rather than letting the debit fail on an empty
// account) keeps that an explicit rule instead of an accident of his balance.
if spell.MaterialCost > 0 { if spell.MaterialCost > 0 {
if companion {
return fmt.Sprintf("%s needs a component %s doesn't carry.", spell.Name, companionDisplayName)
}
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") { if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost) return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
} }
} }
if spell.Level > 0 { if spell.Level > 0 {
ok, serr := consumeSpellSlot(userID, slotLevel) ok, serr := consumeSeatSlot(sess, seat, userID, slotLevel)
if serr != nil { if serr != nil {
return "Couldn't consume slot: " + serr.Error() return "Couldn't consume slot: " + serr.Error()
} }
if !ok { if !ok {
if companion {
return fmt.Sprintf("%s is out of level-%d energy.", companionDisplayName, slotLevel)
}
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID)) return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
} }
} }

View File

@@ -189,6 +189,22 @@ type Combatant struct {
Mods CombatModifiers Mods CombatModifiers
IsPlayer bool IsPlayer bool
Ability *MonsterAbility // non-nil for monsters with a special ability Ability *MonsterAbility // non-nil for monsters with a special ability
// SeatWeight is what this seat costs the enemy: 1.0 is a full peer of the
// leader, and less than that is a seat that brings less to the fight. Zero
// means "unset" and reads as 1.0, so every existing call site — and every solo
// fight — is unchanged.
//
// The enemy's HP bump and its action economy scale on the SUM of these rather
// than on a seat count. A seat count charges the boss the same for an
// under-levelled friend, a hired NPC, and a peer, which is why hiring a
// below-median body was measurably worse than going alone: he cost a full
// seat's worth of boss and did not give a full seat's worth back.
//
// It is derived from the seat's identity (level, and whether it is a hireling),
// NOT from fight state — so every per-round rebuild recomputes the same number
// and there is nothing to persist. See seatWeight.
SeatWeight float64
} }
type CombatPhase struct { type CombatPhase struct {
@@ -220,7 +236,14 @@ type CombatEvent struct {
// //
// It exists so a party's play-by-play can name the right person. Solo events // It exists so a party's play-by-play can name the right person. Solo events
// are all seat 0, and the omitempty tag keeps the field out of every solo // are all seat 0, and the omitempty tag keeps the field out of every solo
// turn_log_json — rows written before N3/P5 decode unchanged. // turn_log_json — rows written before N3/P5 decode unchanged, and a fight in
// flight across a deploy resumes byte-identically (TestP5Fields_StayOffSoloRows).
//
// The omitempty makes seat 0 and "no seat" identical on the wire, which is
// fine for persistence and actively misleading in a diagnostic trace — it hid
// a companion who never swung, making the fight look like it had one seat.
// Do NOT fix that here; the wire format is load-bearing. The sim's trace
// serializes through simTraceEvent, which always emits the seat.
Seat int `json:"Seat,omitempty"` Seat int `json:"Seat,omitempty"`
} }

View File

@@ -68,7 +68,7 @@ func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatP
// scaled action economy to actually threaten each member. Solo is unchanged // scaled action economy to actually threaten each member. Solo is unchanged
// (scale 1.0), so SimulateCombat and the golden do not move. // (scale 1.0), so SimulateCombat and the golden do not move.
if len(players) > 1 { if len(players) > 1 {
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players)) enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players))
} }
enemyStart := enemy.Stats.MaxHP enemyStart := enemy.Stats.MaxHP
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
@@ -307,11 +307,26 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
// between — a per-round coin-flip for the extra action is the only way to land // between — a per-round coin-flip for the extra action is the only way to land
// the band in the gap. The single draw is taken only for a party, so the solo RNG // the band in the gap. The single draw is taken only for a party, so the solo RNG
// stream is untouched. // stream is untouched.
// §2(b): the budget counts the seats still STANDING, re-derived every round —
// not the seats that walked in.
//
// It used to read len(st.actors), which includes the dead. So a party that lost a
// member kept paying for them: the survivor faced a boss still swinging at
// two-player cadence, alone. That is a death spiral with the arrow pointing the
// wrong way — the moment a party is losing, the engine hits it harder — and it is
// the single nastiest thing the companion sweep turned up, because it is not a
// companion bug at all. It has been live for every human party since N3.
//
// A corpse does not threaten anybody, and the enemy has no reason to keep spending
// actions on one.
func enemyActionsThisRound(st *combatState) int { func enemyActionsThisRound(st *combatState) int {
n := len(st.actors) if livingActors(st) < 2 {
if n < 2 {
return 1 return 1
} }
// The summed weight of the seats still standing — not a head count of them.
// A seat that brings half a peer buys the boss half a peer's worth of extra
// swings, and a seat that is down buys none at all.
n := livingWeight(st)
exp := partyActionExpectation(n) exp := partyActionExpectation(n)
base := int(exp) base := int(exp)
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac { if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
@@ -320,6 +335,29 @@ func enemyActionsThisRound(st *combatState) int {
return base return base
} }
// livingActors counts the seats still standing.
func livingActors(st *combatState) int {
n := 0
for _, a := range st.actors {
if a.playerHP > 0 {
n++
}
}
return n
}
// livingWeight is livingActors in the currency the enemy actually charges in: the
// summed SeatWeight of the seats still standing.
func livingWeight(st *combatState) float64 {
total := 0.0
for _, a := range st.actors {
if a.playerHP > 0 {
total += combatantWeight(a.c)
}
}
return total
}
// partyActionExpectation is the expected number of enemy attack-actions per round // partyActionExpectation is the expected number of enemy attack-actions per round
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each // against a party of n. A single enemy swing (the pre-P8 behaviour) let each
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band // member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
@@ -333,17 +371,108 @@ func enemyActionsThisRound(st *combatState) int {
// N≥3 follows 2N1, the point that gave fighter ~90% / cleric-led ~72% at // N≥3 follows 2N1, the point that gave fighter ~90% / cleric-led ~72% at
// HP ×1.15. The whole curve is monotonic by party size and never drops a // HP ×1.15. The whole curve is monotonic by party size and never drops a
// composition below its solo clear rate — bringing a friend is never a penalty. // composition below its solo clear rate — bringing a friend is never a penalty.
func partyActionExpectation(n int) float64 { // It takes a fractional party size — the summed SeatWeight of the living seats,
// not a head count — and interpolates linearly between the integer knots the P8
// sweep actually tuned: (1, 1.0), (2, 2.4), and 2n1 from 3 up. Every integer
// input therefore returns exactly what it always returned, so a solo fight and a
// party of peers are byte-identical and the balance corpus is untouched. Only a
// roster of *unequal* seats lands between the knots, which is the entire point:
// a half-strength body should buy the boss half a body's worth of extra swings.
func partyActionExpectation(n float64) float64 {
switch { switch {
case n < 2: case n <= 1:
return 1 return 1
case n == 2: case n <= 2:
return 2.4 // (1, 1.0) → (2, 2.4)
return 1 + 1.4*(n-1)
case n <= 3:
// (2, 2.4) → (3, 5.0); the original curve stepped here, so the segment is
// steeper than the 2n1 line it joins.
return 2.4 + 2.6*(n-2)
default: default:
return float64(2*n - 1) return 2*n - 1
} }
} }
// seatWeight is what one seat costs the enemy, relative to the leader.
//
// **Level, not stats.** A power score built from HP × damage would rank a cleric
// below a fighter and quietly make every mixed *human* party easier — the support
// classes would stop paying their way. Level is the class-neutral measure of what
// a body is worth, and it is exactly the axis on which the two seats that violated
// the peer assumption differ: an under-levelled friend, and a hireling who arrives
// a level down by design.
//
// A peer of the leader weighs 1.0, so a party of equals is unchanged. Nobody weighs
// more than a peer: out-levelling the leader does not make the boss harder for
// everyone else.
func seatWeight(seatLevel, leaderLevel int, companion bool) float64 {
if leaderLevel <= 0 || seatLevel <= 0 {
return 1
}
w := float64(seatLevel) / float64(leaderLevel)
if w > 1 {
w = 1
}
if companion {
// The layers a player accumulates and a hireling never will: no subclass, no
// magic items, no armed ability, and gear that is never Masterwork. Levels
// cannot see any of that, so it is priced here.
w *= companionSeatWeight
}
if w < seatWeightFloor {
w = seatWeightFloor
}
return w
}
const (
// companionSeatWeight is the hireling discount — the one tunable in this model.
companionSeatWeight = 0.65
// seatWeightFloor stops a badly under-levelled seat from becoming free. A body
// in the fight is always worth something to the enemy: it is one more thing that
// has to be killed.
seatWeightFloor = 0.35
)
// partyWeight sums the seats' weights. An unset weight (0) reads as a full peer,
// which is what every combatant built before this existed is.
func partyWeight(players []*Combatant) float64 {
total := 0.0
for _, c := range players {
total += combatantWeight(c)
}
return total
}
func combatantWeight(c *Combatant) float64 {
if c == nil {
return 0
}
if c.SeatWeight <= 0 {
return 1
}
return c.SeatWeight
}
// partyWeightOf is partyWeight for a value slice.
func partyWeightOf(players []Combatant) float64 {
total := 0.0
for i := range players {
total += combatantWeight(&players[i])
}
return total
}
// seatSetupWeight is partyWeight for a roster that is still being seated.
func seatSetupWeight(seats []CombatSeatSetup) float64 {
total := 0.0
for _, s := range seats {
total += combatantWeight(s.C)
}
return total
}
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo // partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
// (roster < 2) returns 1.0, so the solo path — and the characterization golden // (roster < 2) returns 1.0, so the solo path — and the characterization golden
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy // and the d8prereq corpus — is byte-for-byte untouched. With the action economy
@@ -355,18 +484,27 @@ func partyActionExpectation(n int) float64 {
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led), // three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths — // clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
// not the 100% faceroll a single enemy swing produced. // not the 100% faceroll a single enemy swing produced.
func partyEnemyHPScale(rosterSize int) float64 { // Like partyActionExpectation it takes the summed SeatWeight rather than a head
if rosterSize < 2 { // count, and ramps between the same knots: weight 1 (solo) pays nothing, weight 2
// (a peer at the leader's side) pays the full 1.15 the P8 sweep tuned. Integer
// inputs are byte-exact, so solo and a party of peers are unchanged; a roster
// carrying a below-median body pays proportionally less, because it brought
// proportionally less.
func partyEnemyHPScale(weight float64) float64 {
if weight <= 1 {
return 1.0 return 1.0
} }
return 1.15 if weight >= 2 {
return 1.15
}
return 1.0 + 0.15*(weight-1)
} }
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding // scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
// rule, so every call site (the auto-resolve engine, the party session's initial // rule, so every call site (the auto-resolve engine, the party session's initial
// persist, and the per-turn rebuild) agrees on the same number. // persist, and the per-turn rebuild) agrees on the same number.
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int { func scaledEnemyMaxHP(baseMaxHP int, weight float64) int {
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize)) return int(float64(baseMaxHP) * partyEnemyHPScale(weight))
} }
// enemyActionPlan is the shared action budget both combat engines resolve an // enemyActionPlan is the shared action budget both combat engines resolve an

View File

@@ -142,11 +142,22 @@ func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
if got := partyActionExpectation(2); got != 2.4 { if got := partyActionExpectation(2); got != 2.4 {
t.Fatalf("duo action expectation = %v, want 2.4", got) t.Fatalf("duo action expectation = %v, want 2.4", got)
} }
// The curve now takes a fractional weight rather than a head count, so that a
// below-median seat costs the enemy less than a peer does. Every INTEGER input
// must still return exactly what it always returned — that is what keeps solo
// and a party of peers byte-identical, and the balance corpus with them.
for n := 3; n <= 5; n++ { for n := 3; n <= 5; n++ {
if got, want := partyActionExpectation(n), float64(2*n-1); got != want { if got, want := partyActionExpectation(float64(n)), float64(2*n-1); got != want {
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want) t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
} }
} }
// A duo carrying a half-strength body sits between soloing and a true duo.
if got := partyActionExpectation(1.5); got <= 1 || got >= 2.4 {
t.Fatalf("weight 1.5 action expectation = %v, want strictly between 1 and 2.4", got)
}
if got := partyEnemyHPScale(1.5); got <= 1.0 || got >= 1.15 {
t.Fatalf("weight 1.5 HP scale = %v, want strictly between 1.0 and 1.15", got)
}
if got := partyEnemyHPScale(3); got != 1.15 { if got := partyEnemyHPScale(3); got != 1.15 {
t.Fatalf("party HP scale = %v, want 1.15", got) t.Fatalf("party HP scale = %v, want 1.15", got)
} }

View File

@@ -0,0 +1,159 @@
package plugin
import (
"flag"
"fmt"
"math/rand/v2"
"os"
"path/filepath"
"strings"
"testing"
)
// Party characterization — the net that did not exist.
//
// TestCombatCharacterization pins SOLO combat exhaustively, and it has been the
// tripwire for every balance change since. Party combat had nothing. The whole
// N-body layer — initiative order, per-seat resolution, enemy targeting, the P8
// action-economy and HP scaling, downed-seat handling — shipped unpinned.
//
// What that cost: N3 shipped a Cleric class that cannot heal a single ally (no
// action in the engine can target another seat) and not one test went red. The
// hired companion then stood in fights doing nothing, and the unit tests stayed
// green through that too. It took a 1500-run expedition sweep to see either.
//
// So: pin it. Any change to the party engine now has to state, in the diff, what
// it moved. Regenerate ONLY on purpose:
//
// go test ./internal/plugin -run TestPartyCharacterization -update
//
// This golden covers simulateParty — the auto-resolve engine, which decides most
// expedition rooms and which the balance harness is built on. The turn engine's
// party half (manual play + boss fights) needs DB fixtures and is pinned by
// combat_turn_party_test.go; widening THAT into a golden is the follow-up.
var updatePartyGolden = flag.Bool("update-party", false, "rewrite the party characterization golden")
// partyScenario is one pinned N-body fight.
type partyScenario struct {
name string
seats []Combatant
enemy Combatant
phases []CombatPhase
}
// seatOfClass shapes a seat that stands in for a class archetype. These are
// deliberately engine-level (stat blocks, not sheets): the golden pins what the
// ENGINE does with a roster, not what the character layer feeds it.
func seatOfClass(name string, hp, atk, def, speed int) Combatant {
c := basePlayer()
c.Name = name
c.Stats.MaxHP = hp
c.Stats.Attack = atk
c.Stats.Defense = def
c.Stats.Speed = speed
return c
}
func partyCharacterizationScenarios() []partyScenario {
tank := seatOfClass("Tank", 160, 14, 12, 8)
striker := seatOfClass("Striker", 90, 22, 6, 14)
support := seatOfClass("Support", 110, 11, 9, 10)
// The seat this whole plan is about: a body that is real but below median.
// If scaling ever stops overcharging for it, THIS line is what moves.
weak := seatOfClass("Weak", 70, 8, 5, 9)
// A seat that will fall early. Its corpse must not keep buffing the enemy —
// when §2 lands, this scenario is the one that proves it.
glass := seatOfClass("Glass", 12, 18, 0, 16)
return []partyScenario{
{"duo/even", []Combatant{tank, striker}, tankyEnemy(), dungeonCombatPhases},
{"duo/tank+support", []Combatant{tank, support}, tankyEnemy(), dungeonCombatPhases},
{"duo/median+weak", []Combatant{tank, weak}, tankyEnemy(), dungeonCombatPhases},
{"duo/glass falls early", []Combatant{tank, glass}, hardHitEnemy(), dungeonCombatPhases},
{"trio/even", []Combatant{tank, striker, support}, tankyEnemy(), dungeonCombatPhases},
{"trio/one weak seat", []Combatant{tank, striker, weak}, tankyEnemy(), dungeonCombatPhases},
{"trio/two glass seats", []Combatant{tank, glass, glass}, hardHitEnemy(), dungeonCombatPhases},
{"duo/vs ability enemy", []Combatant{tank, striker},
abilityEnemy("Wither", "poison", "Duel"), dungeonCombatPhases},
// The degenerate case. A one-seat roster MUST stay bit-identical to solo —
// it is the invariant the entire balance corpus rests on, and the reason
// the solo golden is allowed to stay untouched while this file grows.
{"solo/one-seat roster", []Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases},
}
}
// formatPartyResult prints the seat on EVERY line. The solo formatter does not
// (it cannot; there is only one seat), and CombatEvent.Seat is `omitempty`, so a
// JSON trace renders seat 0 and "no seat" identically — which sent me chasing a
// phantom "the companion never swings" bug for an hour. A party golden that
// could not tell you WHO acted would be worth very little.
func formatPartyResult(r PartyCombatResult) string {
var b strings.Builder
fmt.Fprintf(&b, "result: won=%v timedOut=%v rounds=%d survivors=%v\n",
r.PlayerWon, r.TimedOut, r.TotalRounds, r.AnySurvivor())
fmt.Fprintf(&b, " enemy: start=%d entry=%d end=%d\n", r.EnemyStartHP, r.EnemyEntryHP, r.EnemyEndHP)
for i, s := range r.Seats {
fmt.Fprintf(&b, " seat[%d]: start=%d entry=%d end=%d\n",
i, s.PlayerStartHP, s.PlayerEntryHP, s.PlayerEndHP)
}
for i, e := range r.Events {
fmt.Fprintf(&b, " [%02d] r%d seat=%d %-12s %-8s %-16s dmg=%-5d php=%-4d ehp=%-4d roll=%d/%d desc=%q\n",
i, e.Round, e.Seat, e.Phase, e.Actor, e.Action, e.Damage, e.PlayerHP, e.EnemyHP,
e.Roll, e.RollAgainst, e.Desc)
}
return b.String()
}
func TestPartyCharacterization(t *testing.T) {
var b strings.Builder
for _, sc := range partyCharacterizationScenarios() {
for _, seed := range charSeeds {
rng := rand.New(rand.NewPCG(seed, 0xC0FFEE))
res := simulatePartyWithRNG(sc.seats, sc.enemy, sc.phases, rng)
fmt.Fprintf(&b, "=== %s seed=%d ===\n", sc.name, seed)
b.WriteString(formatPartyResult(res))
b.WriteString("\n")
}
}
got := b.String()
path := filepath.Join("testdata", "party_characterization.golden")
if *updatePartyGolden {
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
t.Log("party golden rewritten:", path)
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read party golden (run with -update-party to create it): %v", err)
}
if string(want) != got {
t.Fatalf("PARTY ENGINE BEHAVIOUR MOVED.\n\n%s\n\n"+
"If this was deliberate, say so in the commit and regenerate:\n"+
" go test ./internal/plugin -run TestPartyCharacterization -update-party",
firstDiff(string(want), got))
}
}
// The one-seat roster is the solo engine. If this ever fails, the N-body path has
// stopped being a superset of the path the entire balance corpus was measured on,
// and every baseline in the repo is suspect.
func TestPartyCharacterization_OneSeatIsStillSolo(t *testing.T) {
for _, seed := range charSeeds {
solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases,
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases,
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
if solo.PlayerWon != party.PlayerWon || solo.TotalRounds != party.TotalRounds ||
solo.EnemyEndHP != party.EnemyEndHP || solo.PlayerEndHP != party.Seats[0].PlayerEndHP {
t.Fatalf("seed %d: a one-seat roster diverged from solo\n solo: won=%v rounds=%d ehp=%d php=%d\n party: won=%v rounds=%d ehp=%d php=%d",
seed,
solo.PlayerWon, solo.TotalRounds, solo.EnemyEndHP, solo.PlayerEndHP,
party.PlayerWon, party.TotalRounds, party.EnemyEndHP, party.Seats[0].PlayerEndHP)
}
}
}

View File

@@ -55,6 +55,18 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
// so does the bookkeeping that outlives the fight — a Berserker who raged and // so does the bookkeeping that outlives the fight — a Berserker who raged and
// lost is still exhausted. Both fan out; neither is the owner's alone. // lost is still exhausted. Both fan out; neither is the owner's alone.
for seat := range sess.RosterSize() { for seat := range sess.RosterSize() {
// The companion has no sheet, so none of the sheet-keyed bookkeeping below
// applies to him — and postCombatBookkeepingForSeat logs at ERROR for a
// seat with no sheet, which would file one for every party fight he is ever
// hired for. But his HP is not bookkeeping: it is the fight's result. It
// lands on his roster row, because that is the only row he has.
//
// He used to be skipped outright, and "he arrives fresh next time" was the
// stated intent. It is a free lunch — see companionSeatHP.
if isCompanionUser(sess.seatUserID(seat)) {
_ = setCompanionHPForRun(sess.RunID, sess.seatHP(seat))
continue
}
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat)) persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
p.postCombatBookkeepingForSeat(sess, seat) p.postCombatBookkeepingForSeat(sess, seat)
} }
@@ -100,6 +112,16 @@ func (p *AdventurePlugin) finishPartyWin(
uid := id.UserID(sess.seatUserID(seat)) uid := id.UserID(sess.seatUserID(seat))
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
// The hired companion takes no cut. He earns no XP (there is no sheet to
// put it on), rolls no loot (dropZoneLoot writes real inventory rows for
// whatever id it is handed, and a bot with a magic sword is nobody's
// intent), and takes no death row. His seat renders nothing: he is not
// reading this. He was paid up front.
if isCompanionSeat(uid) {
out[seat] = ""
continue
}
// A member who went down before the killing blow still earns the kill. // A member who went down before the killing blow still earns the kill.
p.grantSeatWinXP(uid, hp, hpMax, monster, tier) p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
@@ -148,7 +170,17 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence) line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
out := make([]string, sess.RosterSize()) out := make([]string, sess.RosterSize())
for seat := range sess.RosterSize() { for seat := range sess.RosterSize() {
markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display) uid := id.UserID(sess.seatUserID(seat))
// The companion does not die. markAdventureDead is a silent no-op for him
// today (no player_meta row to mark), but relying on that accident is how
// he ends up in the graveyard the first time someone gives him a row —
// and emitDeathNews would have the news bot file a death notice about
// itself. Say it out loud instead.
if isCompanionSeat(uid) {
out[seat] = ""
continue
}
markAdventureDead(uid, "zone", zone.Display)
var b strings.Builder var b strings.Builder
if line != "" && seat == 0 { if line != "" && seat == 0 {
b.WriteString(line + "\n") b.WriteString(line + "\n")
@@ -220,9 +252,9 @@ func endRunOnLoss(owner id.UserID, runID string, death bool) {
} }
} }
_ = abandonZoneRun(owner) _ = abandonZoneRun(owner)
reason := "combat flee" reason := lossCombatFlee
if death { if death {
reason = "combat death" reason = lossCombatDeath
} }
forceExtractExpeditionForRunLoss(owner, reason) forceExtractExpeditionForRunLoss(owner, reason)
} }

View File

@@ -33,7 +33,9 @@ func fightRoster(sender id.UserID) []id.UserID {
if err != nil || e == nil { if err != nil || e == nil {
return []id.UserID{sender} return []id.UserID{sender}
} }
return expeditionAudience(e) // Seats, not audience: the hired companion fights even though he never
// receives a DM about it.
return expeditionSeats(e)
} }
// buildFightSeats turns a roster into the seats that will actually sit down, and // buildFightSeats turns a roster into the seats that will actually sit down, and
@@ -56,9 +58,40 @@ func (p *AdventurePlugin) buildFightSeats(
senderSkip = why senderSkip = why
} }
} }
// Parallel to `seats`, so a skipped member leaves no gap: what a seat costs the
// enemy is priced from these once the roster is final.
var levels []int
var companions []bool
for i, uid := range roster { for i, uid := range roster {
leader := i == 0 leader := i == 0
// The hired companion. He must be handled before everything below:
// dndHPSnapshot returns (0,0) for a user with no sheet, so the very next
// check would quietly sit him out of every fight he was paid for, and
// buildZoneCombatants would then fail on him anyway.
//
// He is latched onto autopilot at seat time rather than after the away-player
// deadline — nobody is going to type for him, and waiting three minutes to
// discover that would stall the fight and then announce him to the party as
// an absent player.
if isCompanionSeat(uid) {
expID := companionExpeditionFor(roster[0])
class, level := companionLoadout(expID)
player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
// He carries his wounds between fights, like everyone else. Seating him at
// full max HP — which is what this did — hands the party an infinite body:
// he soaks a share of every fight's incoming and then resets, while the
// humans beside him bleed all the way to camp.
seats = append(seats, CombatSeatSetup{
UserID: uid,
HP: companionSeatHP(expID, player.Stats.MaxHP),
HPMax: player.Stats.MaxHP,
Mods: player.Mods, C: &player, EngineDriven: true,
})
levels, companions = append(levels, level), append(companions, true)
continue
}
// Both refusals below are cheap and neither needs the build, so they run // Both refusals below are cheap and neither needs the build, so they run
// before it: consuming a seat's armed ability and *then* sitting them out // before it: consuming a seat's armed ability and *then* sitting them out
// would spend their rage on a fight they never joined. // would spend their rage on a fight they never joined.
@@ -94,7 +127,7 @@ func (p *AdventurePlugin) buildFightSeats(
trySimAutoArm(c) trySimAutoArm(c)
armed = consumeArmedAbility(c) armed = consumeArmedAbility(c)
} }
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed) player, e, dc, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
if err != nil { if err != nil {
if leader { if leader {
return nil, nil, "", "Couldn't set up the fight: " + err.Error() return nil, nil, "", "Couldn't set up the fight: " + err.Error()
@@ -113,7 +146,17 @@ func (p *AdventurePlugin) buildFightSeats(
seats = append(seats, CombatSeatSetup{ seats = append(seats, CombatSeatSetup{
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed, UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
}) })
lvl := 0
if dc != nil {
lvl = dc.Level
}
levels, companions = append(levels, lvl), append(companions, false)
} }
// Price each seat against the leader. It runs here, over the seats that were
// actually seated — a member who was skipped (downed, busy elsewhere) never
// joined the fight and must not be charged to the enemy.
applySeatWeights(seatCombatants(seats), levels, companions)
return seats, enemy, senderSkip, "" return seats, enemy, senderSkip, ""
} }
@@ -159,6 +202,12 @@ func (p *AdventurePlugin) announcePartyFightStart(
names[i] = c.Name names[i] = c.Name
} }
for seat, uid := range sess.SeatUserIDs() { for seat, uid := range sess.SeatUserIDs() {
// Seat-keyed fan-out, so it bypasses expeditionAudience's filter — the
// companion sits down but is never written to. (He also has no magic items
// to line up, and activeMagicItemsLine would go looking for them.)
if isCompanionUser(uid) {
continue
}
var b strings.Builder var b strings.Builder
b.WriteString(header) b.WriteString(header)
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat))) b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))

View File

@@ -166,6 +166,15 @@ func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (
return fail(notYourTurnMsg(players, acting, waiting)) return fail(notYourTurnMsg(players, acting, waiting))
} }
// An engine-driven seat has nobody to hand the wheel back to. A command
// arriving from one is not a player returning to the keyboard — it is a driver
// impersonating a seat, which is precisely the confusion that used to strand
// the companion: his own auto-played turn came back through here looking like
// a keystroke and cleared the latch that was moving him. Refuse it outright.
if sess.seatIsEngineDriven(seat) {
return fail("That seat isn't yours to play.")
}
// They typed, so they are here. Hand back the wheel. // They typed, so they are here. Hand back the wheel.
sess.actorStatusesPtr(seat).Autopilot = false sess.actorStatusesPtr(seat).Autopilot = false
@@ -315,6 +324,33 @@ func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct)) p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
} }
// driveEngineSeat plays one engine-driven seat's turn and persists the result.
// It is the seat-driver equivalent of a player typing `!attack`, for a seat that
// will never type anything.
//
// It exists because the alternative — dispatching a combat command as that seat —
// sends the turn back through beginCombatTurn, which reads any command from a
// seat as "that player is back". For a human that is correct. For a seat with no
// human it is fatal: it drops the latch that is the only thing moving them.
func (p *AdventurePlugin) driveEngineSeat(sess *CombatSession, seat int) error {
players, enemy, err := p.partyCombatantsForSession(sess)
if err != nil {
return fmt.Errorf("rebuild for engine seat: %w", err)
}
ct := &combatTurn{
sess: sess, players: players, enemy: enemy,
seat: seat, uid: id.UserID(sess.seatUserID(seat)),
}
if _, err := p.runAutoSeatTurn(ct, seat); err != nil {
return err
}
if err := saveCombatSession(sess); err != nil {
return fmt.Errorf("save after engine seat turn: %w", err)
}
p.closePartyRound(ct)
return nil
}
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past // turnDeadlineLapsed reports whether the fight has sat on one member's turn past
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the // partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
// save that parked the fight on this seat's player_turn was the last one — so it // save that parked the fight on this seat's player_turn was the last one — so it
@@ -387,6 +423,12 @@ func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEven
names := ct.seatNames() names := ct.seatNames()
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy) acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
for seat, uid := range ct.sess.SeatUserIDs() { for seat, uid := range ct.sess.SeatUserIDs() {
// The combat fan-out is seat-keyed, so it does not pass through
// expeditionAudience and does not inherit its companion filter. He fights;
// he is not written to about it.
if isCompanionUser(uid) {
continue
}
// Rendered once per reader: the flavor pool speaks in the second person, // Rendered once per reader: the flavor pool speaks in the second person,
// so each member's own events must be theirs and nobody else's. // so each member's own events must be theirs and nobody else's.
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat) body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)

View File

@@ -0,0 +1,174 @@
package plugin
import (
"maunium.net/go/mautrix/id"
)
// Seat-scoped spellbook.
//
// Every spell lookup in the engine — known list, prepared flag, slot pool, slot
// spend — is keyed on a Matrix user id and answered by a `dnd_*` table. That was
// fine while every combatant was a player. The hired companion is not: he has no
// row in dnd_character, dnd_known_spells or dnd_spell_slots, deliberately, because
// a sheet on disk for him is the thing that would turn him into a real character
// everywhere (player_meta, the leaderboard, !stats). His sheet is synthesized per
// fight and thrown away.
//
// So every one of those lookups returned "nothing" for him, and the picker's very
// first line — `c, _ := LoadDnDCharacter(uid); if c == nil { return "attack" }` —
// sent him to swing a mace, every turn, forever. A hired Cleric could not heal.
// Role-fill hands a lone fighter a Cleric, so that was the *common* case.
//
// These are the seat-scoped forms of those lookups. A human seat delegates to the
// DB functions verbatim — same queries, same order, so solo combat and the balance
// corpus are untouched. A companion seat is answered from his in-memory sheet and
// a slot ledger on his seat's persisted statuses.
//
// Nothing here may write a row for the companion. That invariant is what
// TestCompanion_SheetIsBelowMedianAndNeverPersisted pins, and the auto-migration
// inside ensureCharForDnDCmd would violate it silently: handed a user with no
// sheet, it *builds one at level 1 and saves it*. Hence seatCastSheet.
// seatCompanionLoadout returns the class and level a companion seat fights as,
// and whether the seat is the companion at all.
func seatCompanionLoadout(sess *CombatSession, uid id.UserID) (DnDClass, int, bool) {
if sess == nil || !isCompanionSeat(uid) {
return "", 0, false
}
class, level := companionLoadoutForRun(sess.RunID)
return class, level, true
}
// seatCastSheet resolves the character behind a seat for the !cast path.
//
// A human goes through ensureCharForDnDCmd, exactly as before — including its
// auto-migration for a player who predates Adv 2.0. The companion must NOT: that
// migration would mint and persist a level-1 dnd_character row for him, quietly
// making him a player and throwing away the class and level he was hired at. He
// gets his synthetic sheet instead, built from the same class-priority pipeline a
// real character of his level uses.
func (p *AdventurePlugin) seatCastSheet(sess *CombatSession, uid id.UserID) (*DnDCharacter, error) {
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
return companionSheet(class, level), nil
}
advChar, _ := loadAdvCharacter(uid)
return p.ensureCharForDnDCmd(uid, advChar)
}
// seatPickSheet is seatCastSheet for the auto-picker, which reads a sheet to
// decide a turn and must never create one. Humans get LoadDnDCharacter, which is
// what the picker has always called; a miss returns nil and the caller swings.
func seatPickSheet(sess *CombatSession, uid id.UserID) *DnDCharacter {
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
return companionSheet(class, level)
}
c, _ := LoadDnDCharacter(uid)
return c
}
// companionKnownSpells is the companion's spell list: the same default kit
// ensureSpellsForCharacter grants a real character of that class and level, every
// entry prepared (which is also what that function does — preparation is SP4 and
// until it lands every granted spell is auto-prepared so !cast works).
//
// He gets the player kit on purpose. His below-median comes from the level penalty,
// the never-Masterwork gear and the absent subclass/magic items — the layers a
// player accumulates. Handing him a bespoke, weaker spell list would be a second
// nerf hidden in a different file, and it would drift away from the tuned list the
// moment anyone touched one and not the other.
func companionKnownSpells(class DnDClass, level int) []knownSpellRow {
ids := defaultKnownSpells(class, level)
out := make([]knownSpellRow, 0, len(ids))
for _, sid := range ids {
out = append(out, knownSpellRow{SpellID: sid, Source: "companion", Prepared: true})
}
return out
}
// companionSlotPool is his slot table (total, used) — the class/level progression
// every caster shares, less what he has already spent. `used` is the expedition's
// ledger, NOT a per-fight one: he rations one pool across the run and gets it back
// at camp, exactly as a human caster does.
func companionSlotPool(class DnDClass, level int, used [6]int) map[int][2]int {
out := map[int][2]int{}
for lvl, total := range slotsForClassLevel(class, level) {
spent := 0
if lvl >= 0 && lvl < len(used) {
spent = min(used[lvl], total)
}
out[lvl] = [2]int{total, spent}
}
return out
}
// seatKnownSpells is listKnownSpells for a seat.
func seatKnownSpells(sess *CombatSession, seat int, uid id.UserID) ([]knownSpellRow, error) {
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
return companionKnownSpells(class, level), nil
}
return listKnownSpells(uid)
}
// seatSpellSlots is getSpellSlots for a seat.
func seatSpellSlots(sess *CombatSession, seat int, uid id.UserID) (map[int][2]int, error) {
if class, level, ok := seatCompanionLoadout(sess, uid); ok {
return companionSlotPool(class, level, companionSlotsForRun(sess.RunID)), nil
}
return getSpellSlots(uid)
}
// seatKnowsSpell is playerKnowsSpell for a seat: (known, prepared, err).
func seatKnowsSpell(sess *CombatSession, seat int, uid id.UserID, spellID string) (bool, bool, error) {
if _, _, ok := seatCompanionLoadout(sess, uid); ok {
known, err := seatKnownSpells(sess, seat, uid)
if err != nil {
return false, false, err
}
for _, k := range known {
if k.SpellID == spellID {
return true, k.Prepared, nil
}
}
return false, false, nil
}
return playerKnowsSpell(uid, spellID)
}
// consumeSeatSlot is consumeSpellSlot for a seat. The companion's spend lands on
// the expedition's ledger — one pool for the whole run, refilled at camp — so he
// rations his slots the way a human caster has to. Keying it by expedition also
// keeps two parties who have each hired him from sharing a pool, which anything
// keyed on his (single, shared) user id would have done.
func consumeSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) (bool, error) {
class, level, ok := seatCompanionLoadout(sess, uid)
if !ok {
return consumeSpellSlot(uid, slotLevel)
}
used := companionSlotsForRun(sess.RunID)
if slotLevel < 1 || slotLevel >= len(used) {
return false, nil
}
pair, exists := companionSlotPool(class, level, used)[slotLevel]
if !exists || pair[0]-pair[1] <= 0 {
return false, nil
}
used[slotLevel]++
if err := setCompanionSlotsForRun(sess.RunID, used); err != nil {
return false, err
}
return true, nil
}
// refundSeatSlot is refundSpellSlot for a seat: the rollback half of the above,
// called when the round the slot was spent on failed to resolve.
func refundSeatSlot(sess *CombatSession, seat int, uid id.UserID, slotLevel int) error {
if _, _, ok := seatCompanionLoadout(sess, uid); !ok {
return refundSpellSlot(uid, slotLevel)
}
used := companionSlotsForRun(sess.RunID)
if slotLevel < 1 || slotLevel >= len(used) || used[slotLevel] <= 0 {
return nil
}
used[slotLevel]--
return setCompanionSlotsForRun(sess.RunID, used)
}

View File

@@ -0,0 +1,277 @@
package plugin
import (
"strings"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// The hired companion casts.
//
// He could not, and the reason was one line: every spell lookup in the engine is
// keyed on a user id and answered by a dnd_* table, and he has no rows in any of
// them — deliberately, because a sheet on disk is what would turn him into a real
// character everywhere. So the picker's first statement (`LoadDnDCharacter(uid)`)
// came back nil and returned "attack", every turn, for the whole fight.
//
// Role-fill hands a lone martial a Cleric. So the common case of the feature was a
// healer who could not heal, in a party engine that had only just learned to let
// anyone heal anyone (§1). These pin both halves: that he picks the heal, and that
// picking it leaves no trace of him in the database.
// hireForFight seeds an expedition with the companion hired into it, and returns
// the run id his loadout is resolved against.
func hireForFight(t *testing.T, expID string, owner id.UserID, class DnDClass, level int) string {
t.Helper()
seedExpedition(t, expID, owner, "active")
seatLeaderFixture(t, expID)
if err := hireCompanion(expID, class, level); err != nil {
t.Fatalf("hireCompanion: %v", err)
}
return "run-" + expID
}
// petePartyFight seats a hurt leader and the companion, and returns the turn.
func petePartyFight(t *testing.T, p *AdventurePlugin, runID string, leader id.UserID, leaderHP int) *combatTurn {
t.Helper()
monster := dndBestiary["goblin"]
lead, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
lead.Name = "lead"
pete, _, _ := p.companionCombatant(ClassCleric, 6, monster, 2, 0)
pete.Name = companionDisplayName
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
sess, err := p.startPartyCombatSession(runID, "enc", "goblin", &enemy, []CombatSeatSetup{
{UserID: leader, HP: leaderHP, HPMax: 100, Mods: lead.Mods, C: &lead},
{UserID: companionUserID(), HP: 60, HPMax: 60, Mods: pete.Mods, C: &pete, EngineDriven: true},
})
if err != nil {
t.Fatal(err)
}
return &combatTurn{
sess: sess, players: []*Combatant{&lead, &pete}, enemy: &enemy,
seat: 1, uid: companionUserID(),
}
}
// The headline: a hired Cleric, watching the leader bleed out, casts a heal on him.
// Before the seat-scoped spellbook this returned ("attack", "") — he swung a mace
// at the boss while the man who paid for him died.
func TestCompanionSpells_HiredClericHealsTheLeader(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-heal", leader, ClassCleric, 6)
ct := petePartyFight(t, p, runID, leader, 20) // leader at 20/100 — well under the 45% bar
kind, arg := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
if kind != "cast" {
t.Fatalf("the hired cleric picked %q %q with the leader at 20%% HP — he must heal", kind, arg)
}
if !strings.Contains(arg, "@lead") {
t.Fatalf("cast arg = %q, want the heal aimed at the leader's seat", arg)
}
// And it lands on the leader, not on himself.
action, settle, msg := p.castActionForSeat(ct, 1, arg)
if msg != "" {
t.Fatalf("castActionForSeat refused the companion's own pick: %s", msg)
}
settle(true)
eff := action.Effect
if eff == nil || eff.AllyHeal <= 0 {
t.Fatalf("effect = %+v, want an ally heal", eff)
}
if eff.AllySeat != 0 {
t.Errorf("heal landed on seat %d, want seat 0 (the leader)", eff.AllySeat)
}
if eff.PlayerHeal != 0 {
t.Errorf("the heal also healed the caster (%d HP) — it was redirected, not copied", eff.PlayerHeal)
}
}
// His slots are spent on his seat, and he leaves no rows behind.
//
// This is the invariant with teeth. castActionForSeat used to load the caster via
// 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: a dnd_character row, a player_meta seed, a spellbook, and a
// level-1 chassis in place of the level he was hired at.
func TestCompanionSpells_SpendsHisSeatNotTheDatabase(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-rows", leader, ClassCleric, 6)
ct := petePartyFight(t, p, runID, leader, 20)
action, settle, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead")
if msg != "" {
t.Fatalf("the hired cleric could not cast cure wounds: %s", msg)
}
settle(true)
if action.Effect == nil || action.Effect.AllyHeal <= 0 {
t.Fatalf("effect = %+v, want an ally heal", action.Effect)
}
// The slot came off the expedition's ledger — which is where it has to live, so
// that it is still spent in the NEXT fight of the same run.
if used := companionSlotsForRun(ct.sess.RunID); used[1] != 1 {
t.Errorf("companion spent %v level-1 slots on the run's ledger, want 1", used[1])
}
for _, table := range []string{"dnd_character", "dnd_known_spells", "dnd_spell_slots", "player_meta"} {
var n int
if err := db.Get().QueryRow(
`SELECT COUNT(*) FROM `+table+` WHERE user_id = ?`, string(companionUserID()),
).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
if n != 0 {
t.Errorf("casting wrote %d %s row(s) for the companion — he is not a player", n, table)
}
}
}
// He runs dry like anybody else, and then he swings. A companion with an infinite
// spell pool is the "carry" the whole design says he must never be.
func TestCompanionSpells_RunsOutOfSlots(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-dry", leader, ClassCleric, 6)
ct := petePartyFight(t, p, runID, leader, 20)
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
if total <= 0 {
t.Fatal("a level-6 cleric has no level-1 slots — the slot table did not resolve")
}
for i := range total {
if ok, err := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil || !ok {
t.Fatalf("slot %d/%d: consume = %v (%v), want it to succeed", i+1, total, ok, err)
}
}
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); ok {
t.Fatalf("the companion cast a %d-th level-1 spell out of a %d-slot pool", total+1, total)
}
// The refund half: a round that fails to resolve gives the slot back.
if err := refundSeatSlot(ct.sess, 1, companionUserID(), 1); err != nil {
t.Fatal(err)
}
if ok, _ := consumeSeatSlot(ct.sess, 1, companionUserID(), 1); !ok {
t.Error("a refunded slot was not castable again")
}
}
// His pool does NOT refill between fights, and it DOES come back at camp.
//
// This is the one the sweep caught and the unit tests did not. The first cut of
// the spellbook parked his ledger on his combat seat — and a seat is per-session,
// so every fight opened a fresh one and he walked in with full slots. A human
// cleric rations a single pool across the whole run; rationing it IS the caster's
// game. Handed an infinite pool, a gearless level-penalized hireling out-cleared a
// same-level human cleric by 15pp in the sim.
func TestCompanionSpells_PoolIsRationedAcrossTheRun(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-ration", leader, ClassCleric, 6)
// Fight one: spend every level-1 slot he owns.
first := petePartyFight(t, p, runID, leader, 20)
total := companionSlotPool(ClassCleric, 6, [6]int{})[1][0]
for range total {
if ok, err := consumeSeatSlot(first.sess, 1, companionUserID(), 1); err != nil || !ok {
t.Fatalf("consume: %v (%v)", ok, err)
}
}
// Fight two, same run — a NEW combat session, which is exactly what used to
// hand him a fresh pool.
if err := markCombatSessionExpired(first.sess.SessionID); err != nil {
t.Fatal(err)
}
second := petePartyFight(t, p, runID, leader, 20)
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); ok {
t.Error("the companion's spell slots refilled between fights — he is an infinite caster")
}
// ...and camp gives them back, the same way it does for every human.
if err := refreshCompanionSlots("exp-ration"); err != nil {
t.Fatal(err)
}
if ok, _ := consumeSeatSlot(second.sess, 1, companionUserID(), 1); !ok {
t.Error("camp did not restore the companion's slots — his pool only ever goes down")
}
}
// He carries his wounds between fights, and camp patches him up.
//
// He used to re-seat at full max HP for every single fight — "he arrives fresh
// next time" was the close-out's stated intent — which is an infinite body. A
// player bleeds across a 30-room run and only heals at camp; the hireling soaked
// his share of every fight and then reset. In the sim his party fled 5 runs out of
// 640 where the same party with a *human* cleric fled 56.
func TestCompanion_CarriesWoundsBetweenFights(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-hp", leader, "active")
seatLeaderFixture(t, "exp-hp")
if err := hireCompanion("exp-hp", ClassCleric, 6); err != nil {
t.Fatal(err)
}
// A fresh hire is at full.
if got := companionSeatHP("exp-hp", 100); got != 100 {
t.Errorf("a fresh hire seats at %d/100 HP, want full", got)
}
// He walks out of a fight on 30 HP; he walks into the next one on 30 HP.
if err := setCompanionHP("exp-hp", 30); err != nil {
t.Fatal(err)
}
if got := companionSeatHP("exp-hp", 100); got != 30 {
t.Errorf("he re-seated at %d/100 HP after ending a fight on 30 — the wound did not carry", got)
}
// Dropped in a fight, he comes back on his feet but barely — not as a corpse
// (there is no companion-death rule) and not at full.
if err := setCompanionHP("exp-hp", 0); err != nil {
t.Fatal(err)
}
if got := companionSeatHP("exp-hp", 100); got != 1 {
t.Errorf("after being dropped he seats at %d/100 HP, want 1", got)
}
// Camp puts him right, exactly as it does every human.
if err := refreshCompanionHP("exp-hp"); err != nil {
t.Fatal(err)
}
if got := companionSeatHP("exp-hp", 100); got != 100 {
t.Errorf("camp left him on %d/100 HP — his body only ever goes down", got)
}
}
// A hired martial is still a martial: the spellbook is the class's, not a blanket
// grant, so hiring a Fighter does not quietly buy a caster.
func TestCompanionSpells_MartialHasNoSpellbook(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
leader := id.UserID("@lead:example.org")
runID := hireForFight(t, "exp-mart", leader, ClassFighter, 6)
ct := petePartyFight(t, p, runID, leader, 20)
if known, _ := seatKnownSpells(ct.sess, 1, companionUserID()); len(known) != 0 {
t.Errorf("a hired Fighter knows %d spells, want none", len(known))
}
if _, _, msg := p.castActionForSeat(ct, 1, "cure_wounds @lead"); msg == "" {
t.Error("a hired Fighter cast cure wounds")
}
kind, _ := p.pickAutoCombatActionForSeat(companionUserID(), ct.sess, 1)
if kind != "attack" {
t.Errorf("a hired Fighter picked %q, want attack", kind)
}
}

View File

@@ -0,0 +1,108 @@
package plugin
import "testing"
// §2(a) — the enemy charges for what a seat BRINGS, not for the fact that it sat
// down.
//
// Both scaling levers (the +15% boss HP and the 1 → 2.4 attacks-a-round action
// economy) counted seats. 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: hiring the
// companion was *worse than going alone* (66.1% against solo's 69.0%) once his
// free full-heal was taken away.
//
// The invariant that makes this safe to ship: every seat that IS a peer still
// weighs exactly 1.0, so solo and a party of equals are byte-identical and the
// balance corpus does not move. Only an unequal roster lands between the knots.
func TestSeatWeight_APeerWeighsExactlyOne(t *testing.T) {
// The leader, and a friend of the leader's level: both full price.
if got := seatWeight(10, 10, false); got != 1.0 {
t.Errorf("a peer weighs %v, want exactly 1.0 — a party of equals must not move", got)
}
// Out-levelling the leader does not make the boss harder for everybody else.
if got := seatWeight(14, 10, false); got != 1.0 {
t.Errorf("a higher-level friend weighs %v, want 1.0 (capped)", got)
}
}
func TestSeatWeight_TheUnderLevelledAndTheHiredCostLess(t *testing.T) {
// The under-levelled friend — the case that has nothing to do with the
// companion and has been live since parties shipped.
half := seatWeight(5, 10, false)
if half >= 1.0 || half <= seatWeightFloor {
t.Errorf("a level-5 friend of a level-10 leader weighs %v, want between the floor and 1.0", half)
}
// The hireling pays the same level penalty AND the discount for everything a
// player accrues that he never will (subclass, magic items, Masterwork gear).
hired := seatWeight(9, 10, true)
peerAtSameLevel := seatWeight(9, 10, false)
if hired >= peerAtSameLevel {
t.Errorf("the hireling weighs %v and a human of his level weighs %v — he must cost the enemy less",
hired, peerAtSameLevel)
}
// But a body is never free: it is still one more thing the boss has to kill.
if got := seatWeight(1, 20, true); got < seatWeightFloor {
t.Errorf("a hopelessly under-levelled hireling weighs %v, below the floor %v", got, seatWeightFloor)
}
}
// The levers themselves: integer knots byte-exact, fractions in between.
func TestSeatWeight_ScalingIsExactAtThePeerKnots(t *testing.T) {
// Solo and a party of peers reproduce the pre-§2(a) numbers exactly. This is
// the whole safety argument — if either of these drifts, the corpus is invalid.
for _, tc := range []struct {
weight float64
acts float64
hp float64
}{
{1, 1, 1.0}, // solo
{2, 2.4, 1.15}, // a duo of peers
{3, 5, 1.15}, // a trio of peers
} {
if got := partyActionExpectation(tc.weight); got != tc.acts {
t.Errorf("weight %v: enemy actions = %v, want exactly %v", tc.weight, got, tc.acts)
}
if got := partyEnemyHPScale(tc.weight); got != tc.hp {
t.Errorf("weight %v: enemy HP scale = %v, want exactly %v", tc.weight, got, tc.hp)
}
}
// A leader plus a hireling is strictly cheaper than a leader plus a peer, and
// strictly dearer than soloing. Bringing him must cost the boss *something* —
// that is what stops a free body from becoming a carry.
duoWithHire := 1 + seatWeight(9, 10, true)
if a := partyActionExpectation(duoWithHire); a <= 1 || a >= 2.4 {
t.Errorf("leader + hireling buys the enemy %v actions, want strictly between 1 and 2.4", a)
}
if h := partyEnemyHPScale(duoWithHire); h <= 1.0 || h >= 1.15 {
t.Errorf("leader + hireling scales boss HP by %v, want strictly between 1.0 and 1.15", h)
}
}
// A seat that is DOWN buys the enemy nothing. §2(b) fixed the head-count half of
// this; the weight half has to hold too, or a corpse keeps paying for swings.
func TestSeatWeight_TheDeadBuyTheEnemyNothing(t *testing.T) {
alive := &Combatant{SeatWeight: 1}
hire := &Combatant{SeatWeight: 0.6}
st := &combatState{actors: []*actor{
{c: alive, playerHP: 40},
{c: hire, playerHP: 0}, // dropped
}}
if got := livingWeight(st); got != 1 {
t.Errorf("living weight with a downed hireling = %v, want 1 (only the survivor pays)", got)
}
st.actors[1].playerHP = 20
if got := livingWeight(st); got != 1.6 {
t.Errorf("living weight with the hireling up = %v, want 1.6", got)
}
}
// An unset weight reads as a full peer, so every combatant built before this
// existed — and every test that builds one by hand — is priced exactly as before.
func TestSeatWeight_UnsetIsAPeer(t *testing.T) {
if got := combatantWeight(&Combatant{}); got != 1 {
t.Errorf("a combatant with no weight set counts %v, want 1", got)
}
}

View File

@@ -121,7 +121,7 @@ type ActorStatuses struct {
// fight, set when its turn deadline lapses once (see partyTurnDeadline). // fight, set when its turn deadline lapses once (see partyTurnDeadline).
// Without the latch an away member taxes the party the full deadline every // Without the latch an away member taxes the party the full deadline every
// single round; with it, they cost one wait and then resolve instantly. Any // single round; with it, they cost one wait and then resolve instantly. Any
// combat command from that member clears it. // combat command from that member clears it — they typed, so they are back.
// //
// Like the Buff* deltas it is session-layer state with no combatState // Like the Buff* deltas it is session-layer state with no combatState
// counterpart, so snapshotActor carries it over from the prior snapshot // counterpart, so snapshotActor carries it over from the prior snapshot
@@ -129,6 +129,24 @@ type ActorStatuses struct {
// fight has no turn deadline, only the 1h session reaper. // fight has no turn deadline, only the 1h session reaper.
Autopilot bool `json:"autopilot,omitempty"` Autopilot bool `json:"autopilot,omitempty"`
// EngineDriven marks a seat that has NO human behind it and never will: a
// hired companion today, an NPC ally or a Pete-led expedition tomorrow. It is
// the answer to "who owns this turn", and it is deliberately NOT the same
// question as Autopilot.
//
// Autopilot means "a human is away"; it is provisional, and a keystroke ends
// it. EngineDriven means "there is nobody to come back", and nothing clears it.
// Collapsing the two is a bug with teeth: the expedition autopilot drives a
// party by dispatching each seat's turn as a command from that seat, so an
// engine seat's own auto-played move arrived back at beginCombatTurn looking
// exactly like a player returning to the keyboard, and cleared the very latch
// that was moving it. The seat then stood in the fight doing nothing for the
// rest of the fight — while the enemy it had inflated by 15% killed the party.
//
// So the property lives on the seat, not on an identity check, and no command
// path can unset it.
EngineDriven bool `json:"engine_driven,omitempty"`
// Debuffs the enemy has stacked onto this character specifically. // Debuffs the enemy has stacked onto this character specifically.
PlayerAtkDrain int `json:"player_atk_drain,omitempty"` PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"` PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
@@ -256,7 +274,8 @@ func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
st.ArcaneWardHP = playerMods.ArcaneWardHP st.ArcaneWardHP = playerMods.ArcaneWardHP
st.HealChargesLeft = playerMods.HealItemCharges st.HealChargesLeft = playerMods.HealItemCharges
st.ArmedAbility = seat.ArmedAbility st.ArmedAbility = seat.ArmedAbility
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 || st.EngineDriven = seat.EngineDriven
return st.EngineDriven || st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 || st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
st.ArmedAbility != "" st.ArmedAbility != ""
} }
@@ -387,16 +406,30 @@ func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
// turn silently rather than blocking the round on a corpse. // turn silently rather than blocking the round on a corpse.
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 } func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker // seatIsEngineDriven reports whether a seat has no human behind it at all — a
// by a lapsed turn deadline. // hired companion, an NPC ally. Permanent for the life of the fight; no command
// clears it. Contrast seatIsAutopiloted, which is a human who stepped away.
func (s *CombatSession) seatIsEngineDriven(seat int) bool {
return s.actorStatusesForSeat(seat).EngineDriven
}
// seatIsAutopiloted reports whether the engine, rather than a person, decides
// this seat's action — because its human is away (a lapsed turn deadline) or
// because it never had one.
//
// Every driver in the codebase reads this, which is exactly why the two reasons
// share one accessor: the picker does not care WHY nobody is typing. What differs
// is who is allowed to take the wheel back, and that question is asked in
// beginCombatTurn against seatIsEngineDriven — never here.
func (s *CombatSession) seatIsAutopiloted(seat int) bool { func (s *CombatSession) seatIsAutopiloted(seat int) bool {
return s.actorStatusesForSeat(seat).Autopilot st := s.actorStatusesForSeat(seat)
return st.Autopilot || st.EngineDriven
} }
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without // seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
// waiting on its player: it is down (forfeits silently) or latched onto // waiting on its player: it is down (forfeits silently), latched onto autopilot,
// autopilot. driveCombatRound keeps stepping while this holds, so a round only // or has no human to wait for. driveCombatRound keeps stepping while this holds,
// comes to rest on a live human's turn. // so a round only comes to rest on a live human's turn.
func (s *CombatSession) seatNeedsNoHuman(seat int) bool { func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat) return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
} }
@@ -491,7 +524,7 @@ func (p *AdventurePlugin) startPartyCombatSession(
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild // Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
// in partyCombatantsForSession applies the identical scalar to the enemy's // in partyCombatantsForSession applies the identical scalar to the enemy's
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift. // Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats)) enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats))
owner := seats[0] owner := seats[0]
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID, sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
owner.HP, owner.HPMax, enemyHP, enemyHP) owner.HP, owner.HPMax, enemyHP, enemyHP)
@@ -553,6 +586,12 @@ type CombatSeatSetup struct {
// if they armed nothing. It is persisted onto the seat's statuses so every // if they armed nothing. It is persisted onto the seat's statuses so every
// later rebuild can re-apply the ability without re-spending it. // later rebuild can re-apply the ability without re-spending it.
ArmedAbility string ArmedAbility string
// EngineDriven seats this combatant with no human behind it — the hired
// companion today. It resolves from the opening round rather than after the
// 3-minute away-player deadline (nobody is coming, so waiting out a deadline
// would idle the fight and then announce him to the party as absent), and no
// command can hand the wheel back to a player who does not exist.
EngineDriven bool
} }
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil). // getActiveCombatSession returns the player's in-flight fight, or (nil, nil).

View File

@@ -154,13 +154,36 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
seats := sess.SeatUserIDs() seats := sess.SeatUserIDs()
players := make([]*Combatant, len(seats)) players := make([]*Combatant, len(seats))
levels := make([]int, len(seats))
companions := make([]bool, len(seats))
var enemy Combatant var enemy Combatant
for seat, uid := range seats { for seat, uid := range seats {
// The ability this seat armed was consumed once, at fight start, and its // The ability this seat armed was consumed once, at fight start, and its
// id parked on their statuses. Re-applying it here — not re-consuming — // id parked on their statuses. Re-applying it here — not re-consuming —
// is what makes a rage last the whole fight instead of one round. // is what makes a rage last the whole fight instead of one round.
st := sess.actorStatusesForSeat(seat) st := sess.actorStatusesForSeat(seat)
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
// The hired companion has no sheet to load — by design, because a
// player_meta row for him is the thing that would turn him into a real
// character everywhere. Synthesize his seat instead. This branch is
// load-bearing: buildZoneCombatants would fail on him, and one
// unbuildable seat fails the whole rebuild, which is what every human's
// !attack in the fight depends on.
if isCompanionUser(uid) {
class, level := companionLoadoutForRun(sess.RunID)
player, en, _ := p.companionCombatant(class, level, monster, int(zone.Tier), run.DMMood)
applySessionBuffs(&player, st)
players[seat] = &player
levels[seat], companions[seat] = level, true
if seat == 0 {
// Unreachable today (seat 0 is always the leader), but if it ever
// isn't, the enemy still has to come from somewhere.
enemy = en
}
continue
}
player, e, dc, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err) return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
} }
@@ -171,22 +194,47 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
// stat deltas are applied here — and only that seat's own. // stat deltas are applied here — and only that seat's own.
applySessionBuffs(&player, st) applySessionBuffs(&player, st)
players[seat] = &player players[seat] = &player
if dc != nil {
levels[seat] = dc.Level
}
if seat == 0 { if seat == 0 {
// The enemy build reads only (monster, tier, dmMood): every seat // The enemy build reads only (monster, tier, dmMood): every seat
// rebuilds the identical stat block, so seat 0's copy is the fight's. // rebuilds the identical stat block, so seat 0's copy is the fight's.
// Only the *player* half of the build varies by seat. // Only the *player* half of the build varies by seat.
enemy = e enemy = e
// Party-only enemy HP bump, re-derived each turn from the template so
// it never compounds. Matches the scalar startPartyCombatSession used
// for the initial persist; solo (roster 1) scales by 1.0.
if sess.IsParty() {
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
}
} }
} }
// What each seat costs the enemy, priced against the leader. This runs after
// the loop and not inside it, because a seat's weight is relative to seat 0's
// level and the enemy's HP is scaled off the *summed* weight — neither is known
// until every seat is built.
applySeatWeights(players, levels, companions)
// Party-only enemy HP bump, re-derived each turn from the template so it never
// compounds. Matches the scalar startPartyCombatSession used for the initial
// persist; solo (one seat, weight 1) scales by 1.0.
if sess.IsParty() {
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeight(players))
}
return players, &enemy, nil return players, &enemy, nil
} }
// applySeatWeights prices every seat against the leader's level. Seat 0 is the
// leader and always weighs a full 1.0.
func applySeatWeights(players []*Combatant, levels []int, companions []bool) {
if len(players) == 0 {
return
}
leaderLevel := levels[0]
for i, c := range players {
if c == nil {
continue
}
c.SeatWeight = seatWeight(levels[i], leaderLevel, companions[i])
}
}
// seatFightStartMods re-derives the modifiers a finished fight's close-out still // seatFightStartMods re-derives the modifiers a finished fight's close-out still
// needs: the Berserker's rage flag, which decides whether the character owes a // needs: the Berserker's rage flag, which decides whether the character owes a
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash. // point of exhaustion, and the Necromancy Mage's Grim Harvest stash.

View File

@@ -65,6 +65,19 @@ type turnActionEffect struct {
EnemyDamage int EnemyDamage int
PlayerHeal int PlayerHeal int
EnemySkip bool // control spell: enemy forfeits its attack this round EnemySkip bool // control spell: enemy forfeits its attack this round
// AllyHeal heals ANOTHER seat instead of the caster — the thing the engine
// could not do until §1, and the reason a party cleric was a cleric in name
// only. Every heal in the engine wrote to the acting seat: MistyHealProc,
// HealItem, PlayerHeal above. Nothing could put a hit point on a friend, so
// the class whose entire identity is keeping other people alive could not.
// N3 shipped that way and no test noticed, because there was no party golden.
//
// Zero means no ally heal, which keeps every existing construction of this
// struct meaning exactly what it meant before. AllySeat is only read when
// AllyHeal > 0, so its zero value is never mistaken for "seat 0".
AllyHeal int
AllySeat int
// ConcentrationDmg arms a per-round aura tick when a concentration damage // ConcentrationDmg arms a per-round aura tick when a concentration damage
// spell is cast: EnemyDamage is the burst that lands this round, this is // spell is cast: EnemyDamage is the burst that lands this round, this is
// what re-ticks at every round_end after. Zero for one-shot spells; a // what re-ticks at every round_end after. Zero for one-shot spells; a
@@ -560,6 +573,25 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
hpCap := max(1, st.hpMax-st.maxHPDrain) hpCap := max(1, st.hpMax-st.maxHPDrain)
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
} }
// §1 — heal somebody else. The caster's cursor stays where it is; only the
// target's HP moves.
//
// A downed seat is NOT raised. Death in this engine is terminal for the fight
// (the close-out marks them, the hospital takes them), and a heal that
// resurrected a corpse would quietly rewrite the loss rules every close-out
// path depends on. Healing keeps people up; it does not bring them back.
if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) {
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
before := tgt.playerHP
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
Seat: eff.AllySeat, Desc: eff.Label,
})
}
}
// Arm / replace the concentration aura. A new concentration cast overwrites // Arm / replace the concentration aura. A new concentration cast overwrites
// the old one (5e: one concentration at a time); non-concentration casts // the old one (5e: one concentration at a time); non-concentration casts
// leave any running aura alone. // leave any running aura alone.

View File

@@ -332,8 +332,22 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
return c, nil return c, nil
} }
// sqlExecer is the subset of *sql.DB / *sql.Tx that saveDnDCharacterExec needs,
// so the upsert can run either standalone or inside a caller's transaction.
type sqlExecer interface {
Exec(query string, args ...any) (sql.Result, error)
}
// SaveDnDCharacter upserts the row. // SaveDnDCharacter upserts the row.
func SaveDnDCharacter(c *DnDCharacter) error { func SaveDnDCharacter(c *DnDCharacter) error {
return saveDnDCharacterExec(db.Get(), c)
}
// saveDnDCharacterExec runs the dnd_character upsert against any executor. Used
// directly by SaveDnDCharacter and inside grantDnDXP's overflow→renown
// transaction (adventure_renown.go), so the character save and the renown
// credit commit atomically.
func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
pending := 0 pending := 0
if c.PendingSetup { if c.PendingSetup {
pending = 1 pending = 1
@@ -346,7 +360,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
if c.OnboardingSent { if c.OnboardingSent {
onboard = 1 onboard = 1
} }
_, err := db.Get().Exec(` _, err := ex.Exec(`
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp, INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
str_score, dex_score, con_score, int_score, wis_score, cha_score, str_score, dex_score, con_score, int_score, wis_score, cha_score,
hp_current, hp_max, temp_hp, armor_class, hp_current, hp_max, temp_hp, armor_class,

View File

@@ -1,7 +1,9 @@
package plugin package plugin
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand/v2" "math/rand/v2"
@@ -27,9 +29,11 @@ import (
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with // - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
// a "Phase 11" note — they require the boss turn-based engine. // a "Phase 11" note — they require the boss turn-based engine.
// //
// Cross-player targeting (--target @user) is deliberately deferred. SP2 // Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human
// supports self-target only. Heals on self work; ally buffs queue with the // on your expedition — see dnd_cast_target.go. Only heals may name somebody else
// caster as the target until SP3 wires up the multi-player resolution. // out of combat: 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. In-combat targeting is splitCastTarget (combat_cmd.go).
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob). // PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
// Keep this minimal — the combat layer resolves the actual numbers on fire. // Keep this minimal — the combat layer resolves the actual numbers on fire.
@@ -97,6 +101,13 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
return p.dndCastDrop(ctx, c) return p.dndCastDrop(ctx, c)
} }
// An ally target — `--target @alex`, or a trailing `@alex` — comes off the
// string before the spell parser sees it, exactly as in combat. It is
// resolved (and refused) further down, once we know the spell is a heal: a
// target on anything else is a mistake, and refusing it early would cost us
// the "which spell?" half of the error message.
args, targetName := splitOutOfCombatTarget(args)
// Parse spell name + optional --upcast N. // Parse spell name + optional --upcast N.
tokens := strings.Fields(args) tokens := strings.Fields(args)
upcast := 0 upcast := 0
@@ -111,11 +122,6 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
} }
i++ i++
} }
case "--target":
// Reserved for SP3 — accept and ignore for now.
if i+1 < len(tokens) {
i++
}
default: default:
spellTokens = append(spellTokens, tokens[i]) spellTokens = append(spellTokens, tokens[i])
} }
@@ -195,6 +201,23 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
} }
} }
// Resolve an ally target now — BEFORE anything is spent. Out of combat only a
// heal can land on somebody else: UTILITY resolves on the caster and
// everything else queues as a PendingCast for *this* caster's next fight, so
// a target on those would be accepted and then silently dropped.
var targetUser id.UserID
if targetName != "" {
if spell.Effect != EffectSpellHeal {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s isn't a healing spell — outside a fight you can only cast those on someone else. Drop the target to cast it on yourself.", spell.Name))
}
uid, errMsg := resolveCastTargetOnExpedition(ctx.Sender, targetName)
if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg)
}
targetUser = uid // empty when they named themselves — self-heal as usual
}
// Material cost (Revivify, Raise Dead). // Material cost (Revivify, Raise Dead).
if spell.MaterialCost > 0 { if spell.MaterialCost > 0 {
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") { if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
@@ -220,6 +243,9 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
// debited above; if SaveDnDCharacter fails we refund. // debited above; if SaveDnDCharacter fails we refund.
switch spell.Effect { switch spell.Effect {
case EffectSpellHeal: case EffectSpellHeal:
if targetUser != "" {
return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel)
}
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel) return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
case EffectUtility: case EffectUtility:
return p.resolveUtility(ctx, c, spell, slotLevel) return p.resolveUtility(ctx, c, spell, slotLevel)
@@ -231,7 +257,10 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
// ── Out-of-combat: HEAL ────────────────────────────────────────────────────── // ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error { // rollOutOfCombatHeal is what the spell is worth in the caster's hands. It is a
// property of the caster (their WIS, their domain), never of the body it lands
// on, so an ally heal rolls exactly the same as a self-heal.
func rollOutOfCombatHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
dice, faces, _ := parseDamageDice(spell.DamageDice) dice, faces, _ := parseDamageDice(spell.DamageDice)
if dice == 0 { if dice == 0 {
dice, faces = 1, 8 // safety fallback dice, faces = 1, 8 // safety fallback
@@ -253,6 +282,11 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
} }
heal += abilityModifier(c.WIS) heal += abilityModifier(c.WIS)
heal += lifeDomainHealBonus(c, spell, slotLevel) heal += lifeDomainHealBonus(c, spell, slotLevel)
return heal
}
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
heal := rollOutOfCombatHeal(c, spell, slotLevel)
before := c.HPCurrent before := c.HPCurrent
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal) c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
@@ -272,6 +306,51 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
renderSlotsBrief(ctx.Sender))) renderSlotsBrief(ctx.Sender)))
} }
// resolveAllyHealOutOfCombat puts the heal on somebody else's sheet. The slot is
// already spent, so every failure below refunds it: a heal that lands on nobody
// must not cost the caster anything.
func (p *AdventurePlugin) resolveAllyHealOutOfCombat(ctx MessageContext, c *DnDCharacter, target id.UserID, spell SpellDefinition, slotLevel int) error {
refund := func(msg string) error {
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.SendDM(ctx.Sender, msg)
}
heal := rollOutOfCombatHeal(c, spell, slotLevel)
before, after, maxHP, err := healPartyMember(target, heal)
targetName := charName(target)
if targetName == "" {
targetName = target.Localpart()
}
casterName := charName(ctx.Sender)
if casterName == "" {
casterName = ctx.Sender.Localpart()
}
switch {
case errors.Is(err, errHealTargetFull):
return refund(fmt.Sprintf("**%s** is already at full health (%d/%d). Slot kept.", targetName, before, maxHP))
case errors.Is(err, errHealTargetDown):
// The same rule the combat path holds: a heal is not a resurrection.
return refund(fmt.Sprintf("**%s** is down — a heal won't bring them back. Slot kept.", targetName))
case errors.Is(err, sql.ErrNoRows):
return refund(fmt.Sprintf("**%s** hasn't run `!setup` yet, so there's no sheet to heal.", targetName))
case err != nil:
return refund("Couldn't apply the heal: " + err.Error())
}
_ = p.SendDM(target, fmt.Sprintf(
"🩹 **%s** casts **%s** on you — %d HP back (%d → %d / %d).",
casterName, spell.Name, after-before, before, after, maxHP))
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🩹 **%s** on **%s** — restored %d HP (%d → %d / %d). %s",
spell.Name, targetName, after-before, before, after, maxHP,
renderSlotsBrief(ctx.Sender)))
}
// ── Out-of-combat: UTILITY ────────────────────────────────────────────────── // ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error { func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
@@ -676,6 +755,7 @@ func renderCastHelp(c *DnDCharacter) string {
var b strings.Builder var b strings.Builder
b.WriteString("**Cast a spell**\n\n") b.WriteString("**Cast a spell**\n\n")
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n") b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
b.WriteString(" `!cast <heal> @friend` to heal someone on your expedition.\n")
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n") b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
b.WriteString("Run `!spells` for your full list.\n\n") b.WriteString("Run `!spells` for your full list.\n\n")
if pc, ok := decodePendingCast(c.PendingCast); ok { if pc, ok := decodePendingCast(c.PendingCast); ok {

View File

@@ -0,0 +1,139 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// The whole `!cast cure wounds @friend` path, out of combat, through the real
// handler: parse → class/known/prepared gates → slot debit → the ally's sheet.
//
// The seams are unit-tested next door; this exists for the one thing they cannot
// see — that the slot ledger and the HP write agree about whether the heal
// happened. A refund that does not fire is a slot a player paid for nothing, and
// a refund that fires twice is a free heal.
// castingCleric turns an existing sheet into a cleric who knows and has prepared
// Cure Wounds, with slots to spend.
func castingCleric(t *testing.T, uid id.UserID) {
t.Helper()
c, err := LoadDnDCharacter(uid)
if err != nil || c == nil {
t.Fatalf("load %s: %v", uid, err)
}
c.Class = ClassCleric
c.Level = 5
c.WIS = 16
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
if err := setSpellSlotsForCharacter(c); err != nil {
t.Fatal(err)
}
if err := addKnownSpell(uid, "cure_wounds", "class", true); err != nil {
t.Fatal(err)
}
if err := setSpellPrepared(uid, "cure_wounds", true); err != nil {
t.Fatal(err)
}
}
func slotsUsed(t *testing.T, uid id.UserID, level int) int {
t.Helper()
slots, err := getSpellSlots(uid)
if err != nil {
t.Fatal(err)
}
return slots[level][1]
}
func setCharHP(t *testing.T, uid id.UserID, hp int) {
t.Helper()
c, err := LoadDnDCharacter(uid)
if err != nil || c == nil {
t.Fatalf("load %s: %v", uid, err)
}
c.HPCurrent = hp
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
}
func TestCastAlly_OutOfCombat_HealsTheFriendAndSpendsOneSlot(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "e2eheal")
castingCleric(t, leader)
setCharHP(t, member, 5) // of 20
p := &AdventurePlugin{}
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
t.Fatalf("cast: %v", err)
}
mc, _ := LoadDnDCharacter(member)
if mc.HPCurrent <= 5 {
t.Fatalf("the friend is still on %d HP; the heal landed on nobody", mc.HPCurrent)
}
// The caster must NOT have healed themselves — the bug this whole section exists for.
if lc, _ := LoadDnDCharacter(leader); lc.HPCurrent != lc.HPMax {
t.Fatalf("caster HP moved to %d/%d; the heal landed on the caster", lc.HPCurrent, lc.HPMax)
}
if used := slotsUsed(t, leader, 1); used != 1 {
t.Fatalf("level-1 slots used = %d, want exactly 1", used)
}
}
// Naming a full-HP ally must cost nothing: the slot is debited before the target
// is touched, so this is the refund path firing for real.
func TestCastAlly_OutOfCombat_FullHPAllyRefundsTheSlot(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "e2efull")
castingCleric(t, leader)
p := &AdventurePlugin{}
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
t.Fatalf("cast: %v", err)
}
if used := slotsUsed(t, leader, 1); used != 0 {
t.Fatalf("level-1 slots used = %d after healing a full-HP ally; the slot was not refunded", used)
}
}
// A target nobody can reach is refused before anything is spent.
func TestCastAlly_OutOfCombat_StrangerCostsNothing(t *testing.T) {
setupEmptyTestDB(t)
leader, _ := seatedMember(t, "e2estranger")
castingCleric(t, leader)
p := &AdventurePlugin{}
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @nobody"); err != nil {
t.Fatalf("cast: %v", err)
}
if used := slotsUsed(t, leader, 1); used != 0 {
t.Fatalf("level-1 slots used = %d after naming a stranger; nothing should have been spent", used)
}
}
// A target on a spell that cannot use one is refused, rather than quietly
// dropping the target and firing the spell at the caster — which is what the old
// "accept and ignore" parse did.
func TestCastAlly_OutOfCombat_TargetOnANonHealIsRefused(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "e2enonheal")
castingCleric(t, leader)
if err := addKnownSpell(leader, "guiding_bolt", "class", true); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "guiding bolt @"+member.Localpart()); err != nil {
t.Fatalf("cast: %v", err)
}
if used := slotsUsed(t, leader, 1); used != 0 {
t.Fatalf("level-1 slots used = %d; a targeted non-heal must be refused before it is paid for", used)
}
if lc, _ := LoadDnDCharacter(leader); lc.PendingCast != "" {
t.Fatalf("guiding bolt queued as %q; the target should have refused the cast outright", lc.PendingCast)
}
}

View File

@@ -0,0 +1,156 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"strings"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Out-of-combat ally targeting for `!cast` — the other half of §1.
//
// splitCastTarget (combat_cmd.go) resolves a target against the people *in the
// fight*. Out of combat there is no fight, so the equivalent set is the people
// on your expedition: the party is who you are standing next to. Healing a
// stranger across the world is not a thing a cleric at camp can do, and scoping
// it to the party keeps the two `!cast` paths answering the same question.
//
// Only heals may name somebody else. Everything else `!cast` can do out of
// combat either resolves on the caster (UTILITY) or queues as a PendingCast for
// **the caster's** next fight — an ally target on those would be silently
// dropped, which is how `--target` came to be swallowed in the first place.
// splitOutOfCombatTarget peels an ally target off a `!cast` argument, using the
// same two spellings the combat path accepts: the `--target @alex` flag, and a
// plain trailing `@alex`. The bare form requires the `@` here (unlike in combat,
// where the roster disambiguates) so a trailing spell word is never mistaken for
// a name.
//
// Returns (remainingArgs, name). name is empty when no target was named.
func splitOutOfCombatTarget(args string) (string, string) {
fields := strings.Fields(args)
for i := 0; i < len(fields); i++ {
if !strings.EqualFold(fields[i], "--target") {
continue
}
if i+1 >= len(fields) {
return args, ""
}
name := strings.TrimPrefix(fields[i+1], "@")
fields = append(fields[:i], fields[i+2:]...)
return strings.Join(fields, " "), name
}
if len(fields) == 0 {
return args, ""
}
if last := fields[len(fields)-1]; strings.HasPrefix(last, "@") {
if name := strings.TrimPrefix(last, "@"); name != "" {
return strings.Join(fields[:len(fields)-1], " "), name
}
}
return args, ""
}
// resolveCastTargetOnExpedition maps a named target to a human on the caster's
// active expedition. errMsg is non-empty and player-facing on any failure; a
// caster who names somebody must never fall through to healing themselves,
// because that quietly burns the slot on the wrong body.
func resolveCastTargetOnExpedition(caster id.UserID, name string) (id.UserID, string) {
exp, _, err := activeExpeditionFor(caster)
if err != nil {
return "", "Couldn't look up your expedition."
}
if exp == nil {
return "", "You're not travelling with anyone. `!cast` on yourself, or join a party first."
}
seats, err := partyHumans(exp.ID, exp.UserID)
if err != nil {
return "", "Couldn't look up your party."
}
for _, s := range seats {
uid := string(s.UserID)
if strings.EqualFold(uid, name) || strings.EqualFold(id.UserID(uid).Localpart(), name) {
if s.UserID == caster {
// Naming yourself is just casting it on yourself.
return "", ""
}
return s.UserID, ""
}
// Character name, which is what players actually call each other.
if n := charName(s.UserID); n != "" && strings.EqualFold(n, name) {
if s.UserID == caster {
return "", ""
}
return s.UserID, ""
}
}
if strings.EqualFold(name, companionDisplayName) {
return "", companionDisplayName + " patches himself up at camp. Save the slot."
}
return "", fmt.Sprintf("**%s** isn't on your expedition. You can only heal someone you're travelling with.", name)
}
// errHealTargetFull / errHealTargetDown are the two ways an ally heal declines
// to spend the slot. Both are recoverable: the caller refunds.
var (
errHealTargetFull = errors.New("target already at full HP")
errHealTargetDown = errors.New("target is down")
)
// healPartyMember applies a heal to somebody else's sheet.
//
// It does NOT take the target's advUserLock. Gifting sets the precedent
// (adventure_gifting.go): mutate the other player's row with one guarded
// statement rather than a read-modify-write under two locks. Two clerics healing
// each other at the same instant would otherwise take those locks in opposite
// orders and deadlock the pair of them.
//
// The clamp lives in SQL for the same reason — read, add, cap and write are one
// statement, so a heal landing between a concurrent heal's read and write cannot
// push anybody past their maximum.
func healPartyMember(target id.UserID, amount int) (before, after, maxHP int, err error) {
tx, err := db.Get().Begin()
if err != nil {
return 0, 0, 0, err
}
defer func() { _ = tx.Rollback() }()
err = tx.QueryRow(
`SELECT hp_current, hp_max FROM dnd_character WHERE user_id = ?`,
string(target)).Scan(&before, &maxHP)
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, 0, sql.ErrNoRows
}
if err != nil {
return 0, 0, 0, err
}
switch {
case before <= 0:
// A heal is not a resurrection — the same rule the combat path holds.
return before, before, maxHP, errHealTargetDown
case before >= maxHP:
return before, before, maxHP, errHealTargetFull
}
if _, err = tx.Exec(
`UPDATE dnd_character
SET hp_current = MIN(hp_max, hp_current + ?),
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND hp_current > 0`,
amount, string(target)); err != nil {
return before, before, maxHP, err
}
if err = tx.QueryRow(
`SELECT hp_current FROM dnd_character WHERE user_id = ?`,
string(target)).Scan(&after); err != nil {
return before, before, maxHP, err
}
if err = tx.Commit(); err != nil {
return before, before, maxHP, err
}
return before, after, maxHP, nil
}

View File

@@ -0,0 +1,102 @@
package plugin
import (
"errors"
"testing"
"maunium.net/go/mautrix/id"
)
// The out-of-combat half of §1. `--target` was parsed and thrown away here since
// SP2 ("reserved for SP3, accept and ignore"), so a party cleric standing over a
// bleeding friend between fights could do precisely nothing for them.
func TestSplitOutOfCombatTarget(t *testing.T) {
cases := []struct {
name, args, wantRest, wantTarget string
}{
{"flag", "cure wounds --target @alex", "cure wounds", "alex"},
{"flag mid-string", "cure wounds --target @alex --upcast 3", "cure wounds --upcast 3", "alex"},
{"flag without the @", "cure wounds --target alex", "cure wounds", "alex"},
{"trailing mention", "cure wounds @alex", "cure wounds", "alex"},
{"no target", "cure wounds", "cure wounds", ""},
// A trailing bare word is a spell word, not a name — the `@` is what makes
// it a target out of combat. Getting this wrong eats half of "cure wounds".
{"bare trailing word is not a target", "healing word", "healing word", ""},
{"upcast digit is not a target", "cure wounds --upcast 3", "cure wounds --upcast 3", ""},
{"dangling flag", "cure wounds --target", "cure wounds --target", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rest, target := splitOutOfCombatTarget(tc.args)
if rest != tc.wantRest || target != tc.wantTarget {
t.Fatalf("splitOutOfCombatTarget(%q) = (%q, %q), want (%q, %q)",
tc.args, rest, target, tc.wantRest, tc.wantTarget)
}
})
}
}
// The target set is the expedition, not the world: you can heal the person you
// are travelling with, and nobody else.
func TestResolveCastTarget_OnlyThePartyIsReachable(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "casttarget")
uid, errMsg := resolveCastTargetOnExpedition(leader, member.Localpart())
if errMsg != "" || uid != member {
t.Fatalf("leader → member = (%q, %q); want the member, no error", uid, errMsg)
}
// Somebody with a sheet, but not on this expedition.
stranger := id.UserID("@stranger-casttarget:example.org")
zoneCmdTestCharacter(t, stranger, 1)
if _, errMsg := resolveCastTargetOnExpedition(leader, stranger.Localpart()); errMsg == "" {
t.Fatal("healed a stranger across the world; only the party is reachable")
}
// Naming yourself is just casting it on yourself: no target, no error. A
// refusal here would make `!cast cure wounds @me` cost a slot for nothing.
if uid, errMsg := resolveCastTargetOnExpedition(leader, leader.Localpart()); uid != "" || errMsg != "" {
t.Fatalf("self-target = (%q, %q); want the self-heal path, silently", uid, errMsg)
}
}
func TestHealPartyMember_ClampsAtMaxAndWillNotRaiseTheDead(t *testing.T) {
setupEmptyTestDB(t)
target := id.UserID("@heal-target:example.org")
zoneCmdTestCharacter(t, target, 1) // HPMax 20
setHP := func(hp int) {
c, err := LoadDnDCharacter(target)
if err != nil || c == nil {
t.Fatalf("load: %v", err)
}
c.HPCurrent = hp
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
}
// A heal bigger than the wound tops out at max rather than overflowing.
setHP(15)
before, after, maxHP, err := healPartyMember(target, 100)
if err != nil || before != 15 || after != 20 || maxHP != 20 {
t.Fatalf("overheal = (%d → %d / %d, %v); want clamped to 20", before, after, maxHP, err)
}
// Already full: decline so the caller can refund. Spending a slot to heal
// zero HP is the kind of thing players never forgive.
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetFull) {
t.Fatalf("healing a full-HP ally = %v; want errHealTargetFull", err)
}
// Down: a heal is not a resurrection. Same rule the combat path holds.
setHP(0)
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetDown) {
t.Fatalf("healing a downed ally = %v; want errHealTargetDown", err)
}
if c, _ := LoadDnDCharacter(target); c == nil || c.HPCurrent != 0 {
t.Fatal("the downed ally was raised by a heal")
}
}

View File

@@ -1,10 +1,12 @@
package plugin package plugin
import ( import (
"fmt"
"log/slog" "log/slog"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
@@ -305,6 +307,13 @@ func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*D
c := autoBuildCharacter(userID, char) c := autoBuildCharacter(userID, char)
c.AutoMigrated = true c.AutoMigrated = true
c.PendingSetup = false c.PendingSetup = false
// Seed the canonical player_meta row before handing back a confirmed
// character. Auto-migration otherwise mints a player_meta-less character
// (the camcast straggler) that fails every legacy-layer command with
// "sql: no rows". No-op for legacy players who already have the row.
if err := ensurePlayerMetaSeed(userID); err != nil {
return nil, false, fmt.Errorf("seed player_meta: %w", err)
}
if err := SaveDnDCharacter(c); err != nil { if err := SaveDnDCharacter(c); err != nil {
return nil, false, err return nil, false, err
} }
@@ -567,4 +576,80 @@ func markAdventureDead(userID id.UserID, source, location string) {
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("dnd: kill on combat loss", "user", userID, "err", err) slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
} }
emitDeathNews(userID, location)
}
// emitDeathNews files a PRIORITY death dispatch to Pete's adventure news. No-op
// unless the seam is enabled. Uses the character name (never the Matrix handle);
// skips silently if the name is unknown.
func emitDeathNews(userID id.UserID, location string) {
// Gate before the char lookups: markAdventureDead fires per-member on a
// party wipe and in the sim harness, so skip the DB reads when disabled.
if !peteclient.Enabled() || !newsEmissionOn() {
return
}
name := charName(userID)
if name == "" {
return
}
lvl := charLevel(userID)
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
EventType: "death",
Tier: "priority",
Subject: name,
Zone: location,
Level: lvl,
Outcome: "lost",
OccurredAt: ts,
}, userID, "")
}
// emitRetreatNews files a BULLETIN when an expedition ends with the player
// walking out alive.
//
// Until this existed the news had no way to say "it went badly but nobody
// died", so it never said it. Pete's whole taxonomy was arrival, companion_hire,
// death, milestone, rival_result and zone_first — every one of them a win, a
// death, or an introduction. A run that simply fell apart emitted nothing, and
// the feed showed a realm where adventurers only ever triumph or die.
//
// That was not a rare gap. Before §6, 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 look unlucky in the
// feed — they looked absent.
//
// Bulletin, not priority: a retreat is a bad day, not a funeral. A death already
// files its own priority dispatch from markAdventureDead, and a wipe that killed
// someone must not ALSO be reported as a retreat — hence the reason gate rather
// than an "expedition ended" catch-all. An idle reap is excluded too: a player
// who closed their laptop did not flee anything, and Pete announcing that they
// were driven from the field would be a lie about a person by name.
func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) {
switch reason {
case lossCombatRetreat, lossCombatFlee:
default:
return // a death tells its own story; an idle reap is not a story
}
if !peteclient.Enabled() || !newsEmissionOn() {
return
}
name := charName(userID)
if name == "" {
return
}
zone := zoneOrFallback(zoneID)
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("retreat:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
EventType: "retreat",
Tier: "bulletin",
Subject: name,
Zone: zone.Display,
Level: charLevel(userID),
Count: day, // the day they got to before it fell apart
Outcome: "retreated",
OccurredAt: ts,
}, userID, "")
} }

View File

@@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
if isHol, _ := isHolidayToday(); isHol { if isHol, _ := isHolidayToday(); isHol {
startMood = 55 startMood = 55
} }
if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
startMood += b
}
exp := &Expedition{ exp := &Expedition{
ID: newExpeditionID(), ID: newExpeditionID(),
UserID: string(userID), UserID: string(userID),

View File

@@ -325,6 +325,13 @@ func applyCampRest(e *Expedition, kind string) string {
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase { if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
_ = refreshAllResources(uid) _ = refreshAllResources(uid)
_ = refreshSpellSlots(uid) _ = refreshSpellSlots(uid)
// The companion sleeps at the same fire. His slots and his wounds are not
// dnd_spell_slots / dnd_character rows — he has none — so nothing above
// reaches him, and without these two his pool and his body would only ever
// go down. Camp is where a caster gets their slots back and a body gets
// patched up, and he is at the camp.
_ = refreshCompanionSlots(e.ID)
_ = refreshCompanionHP(e.ID)
_ = ReplenishHarvestNodes(e) _ = ReplenishHarvestNodes(e)
} }

View File

@@ -95,6 +95,10 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
return p.expeditionCmdParty(ctx) return p.expeditionCmdParty(ctx)
case "leave": case "leave":
return p.expeditionCmdLeave(ctx) return p.expeditionCmdLeave(ctx)
case "hire":
return p.expeditionCmdHire(ctx, rest)
case "dismiss":
return p.expeditionCmdDismiss(ctx)
case "extract": case "extract":
return p.handleExtractCmd(ctx, "") return p.handleExtractCmd(ctx, "")
case "resume": case "resume":
@@ -122,7 +126,9 @@ func expeditionHelpText() string {
b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n") b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n") b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
b.WriteString("`!expedition party` — who's with you\n") b.WriteString("`!expedition party` — who's with you\n")
b.WriteString("`!expedition leave` — turn back for town (members only)\n\n") b.WriteString("`!expedition leave` — turn back for town (members only)\n")
b.WriteString("`!expedition hire [class]` — pay Pete to fill the role you're missing\n")
b.WriteString("`!expedition dismiss` — send Pete home\n\n")
b.WriteString("**Mid-expedition:**\n") b.WriteString("**Mid-expedition:**\n")
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n") b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n") b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
@@ -384,6 +390,9 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
if isHol, _ := isHolidayToday(); isHol { if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++ suppliesPurchase.StandardPacks++
} }
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
suppliesPurchase.StandardPacks += pk
}
supplies := makeSupplies(zone.Tier, suppliesPurchase) supplies := makeSupplies(zone.Tier, suppliesPurchase)
// Debit coins; bail on debit failure (race / cap). // Debit coins; bail on debit failure (race / cap).

View File

@@ -95,21 +95,40 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
return e, tax, nil return e, tax, nil
} }
// The reasons a run can end badly. They were bare strings at four call sites;
// they are constants now because the news seam has to tell them apart — a
// retreat is a story and a death is a different story, and an idle reap is
// neither.
const (
lossCombatDeath = "combat death"
lossCombatRetreat = "combat retreat" // solo: ran out the phase clock and withdrew
lossCombatFlee = "combat flee" // party: the turn engine broke off
lossIdleTimeout = "run idle-timeout (§4.3 stale-run reap)"
)
// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based // forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based
// elite/boss death or flee, exploration combat death, patrol-interrupt // elite/boss death or flee, exploration combat death, patrol-interrupt
// death) into the forced-extract flow. Those sites already abandon the // death) into the forced-extract flow. Those sites already abandon the
// zone run, but without flipping the wrapping expedition the ambient // zone run, but without flipping the wrapping expedition the ambient
// ticker keeps DMing about a dungeon the player walked away from. No-op // ticker keeps DMing about a dungeon the player walked away from. No-op
// when there is no active expedition for this user. // when there is no active expedition for this user.
//
// It is also the one chokepoint every bad ending passes through, which makes it
// where the retreat dispatch is filed. Read the expedition BEFORE the extract:
// forcedExtractExpedition stamps it 'abandoned' and zeroes the live fields, so
// afterwards there is no day count left to report.
func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) { func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
exp, err := getActiveExpedition(userID) exp, err := getActiveExpedition(userID)
if err != nil || exp == nil { if err != nil || exp == nil {
return return
} }
day, zoneID := exp.CurrentDay, exp.ZoneID
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil { if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
slog.Warn("expedition: force-extract on run loss", slog.Warn("expedition: force-extract on run loss",
"user", userID, "expedition", exp.ID, "reason", reason, "err", err) "user", userID, "expedition", exp.ID, "reason", reason, "err", err)
return
} }
emitRetreatNews(userID, reason, zoneID, day)
} }
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down, // finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
@@ -177,6 +196,9 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" { if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
lines = append(lines, sl) lines = append(lines, sl)
} }
// News: boss down means the zone is cleared. Realm-first → PRIORITY,
// repeat → BULLETIN. No-op unless the Pete seam is enabled.
emitZoneClearNews(userID, exp)
return lines return lines
} }

View File

@@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
if isHol, _ := isHolidayToday(); isHol { if isHol, _ := isHolidayToday(); isHol {
qty++ qty++
} }
if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen
qty += b
}
tier := zoneTierFromID(res.ZoneID) tier := zoneTierFromID(res.ZoneID)
for i := 0; i < qty; i++ { for i := 0; i < qty; i++ {
item := AdvItem{ item := AdvItem{

View File

@@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
return 0, "", nil return 0, "", nil
} }
delta, reason := dailyThreatDrift(e.DMMood) delta, reason := dailyThreatDrift(e.DMMood)
// N7/B3 the Omen — a "still waters" week subtracts from the daily threat
// *rise* only (guarded on delta > 0), floored so the worst case is threat
// holding steady for the day; it never turns a rise into active decay.
if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 {
if delta -= r; delta < 0 {
delta = 0
}
}
if delta == 0 { if delta == 0 {
return 0, reason, nil return 0, reason, nil
} }

View File

@@ -161,6 +161,16 @@ func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) error {
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display)) b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
if c.Level >= dndMaxLevel { if c.Level >= dndMaxLevel {
b.WriteString("XP: capped at L20.") b.WriteString("XP: capped at L20.")
if rl := advChar.RenownLevel(); rl > 0 {
into, cost := renownXPIntoLevel(advChar.RenownXP)
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
if rank := renownRankFor(rl); rank != "" {
b.WriteString(fmt.Sprintf(" — _%s_", rank))
}
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
} else {
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
}
} else { } else {
next := dndXPToNextLevel(c.Level) next := dndXPToNextLevel(c.Level)
pct := int(100.0 * float64(c.XP) / float64(next)) pct := int(100.0 * float64(c.XP) / float64(next))

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
@@ -290,6 +291,22 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
// Idempotent — !setup confirm after a respec wipe will repopulate. // Idempotent — !setup confirm after a respec wipe will repopulate.
_ = ensureSpellsForCharacter(c) _ = ensureSpellsForCharacter(c)
// A new character walked through the gates — file a welcome dispatch to
// Pete. GUID keyed on the user alone so a later respec/re-confirm can't
// re-announce the same arrival. No-op unless the seam is enabled.
if name := charName(ctx.Sender); name != "" {
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: "arrival:" + eventToken(ctx.Sender, "arrival"),
EventType: "arrival",
Tier: "bulletin",
Subject: name,
ClassRace: classRaceLabel(c),
Level: c.Level,
OccurredAt: ts,
}, ctx.Sender, "")
}
return p.SendDM(ctx.Sender, renderSetupComplete(c)) return p.SendDM(ctx.Sender, renderSetupComplete(c))
} }

View File

@@ -79,7 +79,20 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
if adv != nil && adv.DisplayName != "" { if adv != nil && adv.DisplayName != "" {
name = adv.DisplayName name = adv.DisplayName
} }
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display)) renownLevel := 0
if adv != nil {
renownLevel = adv.RenownLevel()
}
nameLine := name
if m := renownMarker(renownLevel); m != "" {
nameLine = fmt.Sprintf("%s %s", name, m)
}
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", nameLine, c.Level, ri.Display, ci.Display))
if renownLevel > 0 {
if rank := renownRankFor(renownLevel); rank != "" {
b.WriteString(fmt.Sprintf(" _Renown %d — %s_\n", renownLevel, rank))
}
}
if c.Subclass != "" { if c.Subclass != "" {
if si, ok := subclassInfo(c.Subclass); ok { if si, ok := subclassInfo(c.Subclass); ok {
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display)) b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))

View File

@@ -127,15 +127,26 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain}) events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
} }
// Cap at L20 — overflow XP is silently dropped. // N7/B2 Renown — persist the character; at the cap, overflow XP that used to
if c.Level >= dndMaxLevel { // be dropped converts to Renown in the SAME transaction as the save, so a
// crash can neither lose the overflow nor double-credit it on the next grant.
// Renown is title + cosmetic + capped loot/XP perks, never combat stats, so
// the balance corpus stays valid.
var renownFrom, renownTo int
if c.Level >= dndMaxLevel && c.XP > 0 {
overflow := c.XP
c.XP = 0 c.XP = 0
} from, to, err := saveDnDCharacterWithOverflow(c, overflow)
if err != nil {
if err := SaveDnDCharacter(c); err != nil { return events, err
}
renownFrom, renownTo = renownLevelFor(from), renownLevelFor(to)
} else if err := SaveDnDCharacter(c); err != nil {
return events, err return events, err
} }
p.announceRenown(userID, renownFrom, renownTo)
if len(events) > 0 { if len(events) > 0 {
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the // Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
// superiority cap from 4 → 5; reconcile any level-gated subclass pool // superiority cap from 4 → 5; reconcile any level-gated subclass pool

View File

@@ -1207,9 +1207,9 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
// seat off HP, which for a solo walker is the same `!TimedOut` rule. // seat off HP, which for a solo walker is the same `!TimedOut` rule.
closeOutZoneLoss(pres, seated, zone, "zone") closeOutZoneLoss(pres, seated, zone, "zone")
if !result.TimedOut { if !result.TimedOut {
forceExtractExpeditionForRunLoss(userID, "combat death") forceExtractExpeditionForRunLoss(userID, lossCombatDeath)
} else { } else {
forceExtractExpeditionForRunLoss(userID, "combat retreat") forceExtractExpeditionForRunLoss(userID, lossCombatRetreat)
} }
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" { if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
ob.WriteString(line) ob.WriteString(line)

View File

@@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{
// rollZoneIngredient draws one crafting ingredient from a random legacy // rollZoneIngredient draws one crafting ingredient from a random legacy
// gathering table at the zone's tier. Returns nil on the common no-drop path. // gathering table at the zone's tier. Returns nil on the common no-drop path.
func rollZoneIngredient(zoneTier int) *AdvItem { func rollZoneIngredient(zoneTier int) *AdvItem {
if rand.Float64() >= advIngredientDropChance { chance := advIngredientDropChance
if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen
chance *= m
}
if rand.Float64() >= chance {
return nil return nil
} }
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))] act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]

View File

@@ -323,7 +323,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
// but only when this run is the active expedition's current run so // but only when this run is the active expedition's current run so
// a standalone (non-expedition) stale run still reaps cleanly. // a standalone (non-expedition) stale run still reaps cleanly.
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID { if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)") forceExtractExpeditionForRunLoss(userID, lossIdleTimeout)
} }
return nil, nil return nil, nil
} }

View File

@@ -0,0 +1,290 @@
//go:build prodexercise
package plugin
// Headless smoke-exercise of the N-series features (parties aside — those have
// their own sim path) against a COPY of the prod DB. Nothing here touches the
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
// before opening it, so even the copy is left pristine.
//
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
//
// Every outbound DM / room message a handler would have sent to Matrix is
// captured via the existing MessageSink seam and dumped with t.Log, so the run
// shows exactly what a player would see. Each feature is wrapped so a panic or
// error in one is reported and the battery continues.
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func TestExerciseNewFeaturesProd(t *testing.T) {
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
if srcDir == "" {
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
}
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
t.Skipf("no gogobee.db in %s", srcDir)
}
tmp := t.TempDir()
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
}
db.Close()
if err := db.Init(tmp); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
// so the same DB copy exercises identically regardless of which week we run.
simOmenDisabled = true
simAutoArmEnabled = true
euro := NewEuroPlugin(nil)
xp := NewXPPlugin(nil)
p := NewAdventurePlugin(nil, euro, xp)
sink := &captureSink{}
p.Sink = sink
mark := 0
drain := func() {
for _, m := range sink.msgs[mark:] {
who := string(m.ToUser)
if who == "" {
who = "room:" + string(m.ToRoom)
}
t.Logf(" → [%s]\n%s", who, indent(m.Text))
}
mark = len(sink.msgs)
}
exercise := func(name string, fn func()) {
defer func() {
if r := recover(); r != nil {
t.Logf(" !! %s PANICKED: %v", name, r)
mark = len(sink.msgs)
}
}()
t.Logf("──────── %s ────────", name)
fn()
drain()
}
chars := runnableChars(t)
if len(chars) == 0 {
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
}
t.Logf("runnable characters: %d", len(chars))
for _, c := range chars {
healToFull(t, c.uid)
euro.Credit(c.uid, 5000, "exercise bankroll")
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
}
ctxFor := func(uid id.UserID, body string) MessageContext {
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
}
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
exercise("N7/B2 Renown — standing + prestige announce", func() {
for _, c := range chars {
xp, _ := loadRenownXP(c.uid)
lvl := renownLevelForUser(c.uid)
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
}
// Push the first character up a renown level to fire the announce path.
c0 := chars[0]
before, after, err := addRenownXP(c0.uid, 500)
if err != nil {
t.Logf(" addRenownXP: %v", err)
return
}
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
})
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
boss, err := p.spawnWorldBoss("exercise-siege")
if err != nil {
t.Logf(" spawnWorldBoss: %v", err)
}
if boss != nil {
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
}
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
for _, c := range chars {
t.Logf(" -- %s takes a bout --", c.uid)
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
}
t.Logf(" -- pool after the raid --")
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
})
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
if len(chars) < 2 {
t.Log(" need 2 characters for a duel; skipping")
return
}
a, b := chars[0], chars[1]
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
})
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
exercise("N6/D3 Shadow — standing vs the rival", func() {
for _, c := range chars {
p.handleShadowCmd(ctxFor(c.uid, ""))
}
})
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
exercise("N5/D1a Journal — collected campaign pages", func() {
for _, c := range chars {
p.handleJournalCmd(ctxFor(c.uid, ""))
}
})
// ── N4/E3 Town registries ───────────────────────────────────────────────
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
p.handleTownCmd(ctxFor(chars[0].uid, ""))
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
})
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
for _, c := range chars {
p.handleVaultCmd(ctxFor(c.uid, ""), "")
}
})
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
exercise("N4/E2 Gifting — !give <item> @user", func() {
if len(chars) < 2 {
t.Log(" need 2 characters to gift; skipping")
return
}
giver, receiver := chars[0], chars[1]
item := firstInventoryItem(giver.uid)
if item == "" {
t.Logf(" %s has no inventory items to gift", giver.uid)
return
}
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
p.handleGiveCmd(ctxFor(giver.uid, body))
})
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
exercise("N7/B4 Achievements — !achievements", func() {
ach := NewAchievementsPlugin(nil, nil)
ach.Sink = sink
p.SetAchievements(ach)
for _, c := range chars {
ach.handleAchievements(ctxFor(c.uid, ""))
}
})
}
// ---- helpers ---------------------------------------------------------------
type exChar struct {
uid id.UserID
class string
level int
}
// runnableChars lists characters that have both a completed dnd_character (class
// set) and the legacy adventure_characters row the expedition/feature paths load.
func runnableChars(t *testing.T) []exChar {
t.Helper()
d := db.Get()
rows, err := d.Query(`
SELECT d.user_id, d.class, d.dnd_level
FROM dnd_character d
JOIN adventure_characters a ON a.user_id = d.user_id
WHERE d.class != ''`)
if err != nil {
t.Fatalf("query chars: %v", err)
}
defer rows.Close()
var out []exChar
for rows.Next() {
var c exChar
var uid string
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
t.Fatal(err)
}
c.uid = id.UserID(uid)
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
return out
}
func healToFull(t *testing.T, uid id.UserID) {
t.Helper()
c, err := LoadDnDCharacter(uid)
if err != nil || c == nil {
t.Logf(" healToFull: load %s: %v", uid, err)
return
}
c.HPCurrent = c.HPMax
c.TempHP = 0
c.Exhaustion = 0
c.ShortRestCharges = c.Level
if err := SaveDnDCharacter(c); err != nil {
t.Logf(" healToFull: save %s: %v", uid, err)
}
}
func firstInventoryItem(uid id.UserID) string {
d := db.Get()
var name string
// adventure_inventory holds the player's consumables/misc, one row per item.
_ = d.QueryRow(
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
string(uid),
).Scan(&name)
return name
}
func copyIfPresent(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
return // wal/shm may be absent — fine
}
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 indent(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
for i, l := range lines {
lines[i] = " " + l
}
return strings.Join(lines, "\n")
}

View File

@@ -230,7 +230,7 @@ func ambientEventMonologue() ambientEvent {
// it's cheap to test in isolation and free of package-level state. // it's cheap to test in isolation and free of package-level state.
func ambientEvents() []ambientEvent { func ambientEvents() []ambientEvent {
always := func(*Expedition) bool { return true } always := func(*Expedition) bool { return true }
return []ambientEvent{ events := []ambientEvent{
ambientEventMonologue(), ambientEventMonologue(),
{ {
Kind: "small_find", Kind: "small_find",
@@ -280,6 +280,19 @@ func ambientEvents() []ambientEvent {
}, },
}, },
} }
// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
// reward come from activeSeason(); the ambient seam is production-only, so
// this never reaches the balance sim.
if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
events = append(events, ambientEvent{
Kind: "season_visitor",
Pool: s.Visitor.FlavorPool,
Weight: s.Visitor.Weight,
Eligible: func(*Expedition) bool { return true },
})
}
return events
} }
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind // pickAmbientEvent runs a weighted pick over eligible events. avoidKind
@@ -395,6 +408,39 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
return "" return ""
} }
return "+1 HP" return "+1 HP"
case "season_visitor":
// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
// the season so the reward matches the current window even if it turned
// over between pick and apply; bail quietly if it just ended.
s, ok := activeSeason()
if !ok {
return ""
}
v := s.Visitor
uid := id.UserID(e.UserID)
if err := addAdvInventoryItem(uid, AdvItem{
Name: v.Keepsake,
Type: "trophy",
Tier: 1,
Value: v.KeepsakeValue,
}); err != nil {
slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
return ""
}
if v.Coins > 0 {
if p.euro != nil {
p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
}
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET coins_earned = coins_earned + ?,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
}
return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
}
return fmt.Sprintf("You keep the %s.", v.Keepsake)
} }
return "" return ""
} }

View File

@@ -0,0 +1,115 @@
package plugin
import (
"errors"
"fmt"
"strings"
)
// The player-facing surface for hiring Pete.
//
// !expedition hire [class] (leader, Day 1 — auto-fills the missing role)
// !expedition dismiss (leader — sends him home, no refund)
//
// He is hired on Day 1 like any other companion, because a body that materializes
// three rooms in is not a party member, it is a cheat code. See
// adventure_companion.go for why he is an NPC seat and not a player.
// expeditionCmdHire brings the correspondent along.
func (p *AdventurePlugin) expeditionCmdHire(ctx MessageContext, rest string) error {
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition. `!expedition start <zone>` first.")
}
if !isLeader {
return p.SendDM(ctx.Sender, "Only the party leader hires. You're along for the ride.")
}
if !inviteWindowOpen(exp) {
return p.SendDM(ctx.Sender,
"This expedition has already set off — Pete joins on Day 1 or not at all.")
}
if companionSeated(exp.ID) {
return p.SendDM(ctx.Sender, "Pete's already with you. `!expedition party` to see the roster.")
}
if p.euro == nil {
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
}
zone := zoneOrFallback(exp.ZoneID)
level := companionPartyLevel(exp.ID)
class, explicit := parseCompanionClass(rest)
if !explicit {
if arg := strings.TrimSpace(rest); arg != "" && !strings.EqualFold(arg, "auto") {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Don't know the class %q. Name one, or just `!expedition hire` and he'll fill whatever you're missing.", arg))
}
class = companionRoleFill(companionPartyClasses(exp.ID))
}
cost := float64(companionHireCost(level, zone.Tier))
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Pete doesn't work for free. His fee for **%s** is **%d** — you have **%.0f**.",
zone.Display, int(cost), balance))
}
if !p.euro.Debit(ctx.Sender, cost, "expedition: hired Pete") {
return p.SendDM(ctx.Sender, "Couldn't debit Pete's fee (try again).")
}
if err := hireCompanion(exp.ID, class, level); err != nil {
p.euro.Credit(ctx.Sender, cost, "expedition: Pete hire refund")
return p.SendDM(ctx.Sender, companionRefusalText(err))
}
emitCompanionHireFact(ctx.Sender, class, level, zone)
ci, _ := classInfo(class)
filled := "You asked for a " + ci.Display + "."
if !explicit {
filled = "He's filling the hole in your roster — **" + ci.Display + "**."
}
return p.SendDM(ctx.Sender, fmt.Sprintf(
"📝 **Pete's coming with you into %s.** %s He's **level %d**, and his fee was **%d**.\n\n"+
"He fights his own turns — you don't command him. He takes no loot and no XP; he's here for the story.\n"+
"`!expedition dismiss` sends him home (no refund — he's already packed).",
zone.Display, filled, level, int(cost)))
}
// expeditionCmdDismiss sends him home mid-run.
func (p *AdventurePlugin) expeditionCmdDismiss(ctx MessageContext) error {
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition.")
}
if !isLeader {
return p.SendDM(ctx.Sender, "Only the party leader can dismiss him.")
}
if err := dismissCompanion(exp.ID); err != nil {
return p.SendDM(ctx.Sender, companionRefusalText(err))
}
return p.SendDM(ctx.Sender,
"📝 **Pete heads back.** He got what he came for. The fee stays spent.")
}
// companionRefusalText turns the companion layer's sentinel errors into copy.
func companionRefusalText(err error) string {
switch {
case errors.Is(err, ErrCompanionAlreadyHired):
return "Pete's already with you."
case errors.Is(err, ErrCompanionOnAssignment):
return "Pete's out on assignment with another party. He'll be back."
case errors.Is(err, ErrCompanionNotHired):
return "Pete isn't with you."
case errors.Is(err, ErrPartyFull):
return "No room — your party's full. Someone has to `!expedition leave` first."
default:
return "Couldn't hire Pete: " + err.Error()
}
}

View File

@@ -82,30 +82,126 @@ func partyMembers(expeditionID string) ([]PartyMember, error) {
return out, rows.Err() return out, rows.Err()
} }
// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what // PartySeatKind is what a seat *is*. The three answers differ in ways that matter
// the fan-out seams (digest, briefing, recap) want: a solo expedition yields // at almost every seam, and conflating any two of them has already cost us a bug:
// just the owner, so a caller can loop unconditionally. // a companion is a seat but not a mouth and not a mailbox; a leader owns the
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) { // expedition row that everyone else references.
members, err := partyMembers(expeditionID) type PartySeatKind int
const (
SeatLeader PartySeatKind = iota
SeatMember
SeatCompanion
)
// PartySeat is one body on an expedition.
type PartySeat struct {
UserID id.UserID
Kind PartySeatKind
}
// IsHuman reports whether there is a person behind this seat — someone who can be
// DM'd, can earn loot, eats supplies, and can die.
func (s PartySeat) IsHuman() bool { return s.Kind != SeatCompanion }
// expeditionParty is THE answer to "who is on this expedition". Every other view
// — who gets mail, who sits down in a fight, who eats — is derived from it.
//
// It ALWAYS includes the owner. That is the whole point, and it is not a
// convenience: a solo expedition has **no expedition_party rows at all** (the
// roster only materializes on the first invite — see partyMembers), so any code
// that answers "who is in this party?" by reading the roster table gets *nobody*
// for a solo player, and then quietly falls back to whatever looked sensible at
// the call site.
//
// That is not hypothetical. It is exactly how the hired companion came out at
// **level 1** for every solo player — the one player the feature exists for. The
// level was averaged over the party; the party read as empty; the fallback was 1.
// He then walked into a tier-4 zone as a level-1 body, died on contact, and left
// the leader fighting a boss that had been inflated on his account. A 1500-run
// sweep is what found it, because nothing else could.
//
// So: there is no way to ask this function for the party and be handed an empty
// list. If the expedition exists, it has at least a leader.
func expeditionParty(expeditionID, ownerID string) ([]PartySeat, error) {
rows, err := partyMembers(expeditionID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(members) == 0 { if len(rows) == 0 {
return []id.UserID{id.UserID(ownerID)}, nil // Solo: no roster rows. The owner IS the party.
if ownerID == "" {
return nil, nil
}
return []PartySeat{{UserID: id.UserID(ownerID), Kind: SeatLeader}}, nil
} }
out := make([]id.UserID, 0, len(members)) out := make([]PartySeat, 0, len(rows))
for _, m := range members { for _, m := range rows {
out = append(out, id.UserID(m.UserID)) kind := SeatMember
switch {
case isCompanionUser(m.UserID):
kind = SeatCompanion
case m.IsLeader():
kind = SeatLeader
}
out = append(out, PartySeat{UserID: id.UserID(m.UserID), Kind: kind})
} }
return out, nil return out, nil
} }
// partySize is the number of seated players: 1 for a solo expedition. // partyHumans is the party minus the companion: everyone with a person behind
// them. This is the set that gets mail, earns loot, eats supplies, and can die.
func partyHumans(expeditionID, ownerID string) ([]PartySeat, error) {
seats, err := expeditionParty(expeditionID, ownerID)
if err != nil {
return nil, err
}
out := seats[:0]
for _, s := range seats {
if s.IsHuman() {
out = append(out, s)
}
}
return out, nil
}
// partyMemberIDs is the whole party as ids, leader first — every body, companion
// included. Callers that want only people want partyHumans.
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
seats, err := expeditionParty(expeditionID, ownerID)
if err != nil {
return nil, err
}
out := make([]id.UserID, 0, len(seats))
for _, s := range seats {
out = append(out, s.UserID)
}
return out, nil
}
// partySize is the number of seated *players*: 1 for a solo expedition.
//
// A hired companion is not counted, and both of this function's consumers want
// it that way:
//
// - expeditionBurnRatePct scales the daily supply burn by party size. Pete
// never bought a pack — members buy their own on !expedition accept, and he
// accepts nothing — so counting him would bill the leader a 60% higher burn
// for a mouth that brought its own rations. He is on expenses.
// - the "your party is still waiting on you" gate blocks a leader from
// starting a new run while an extracted party is pending. A roster holding
// nobody but Pete is not a party waiting on anyone, and counting him would
// lock the leader out of the game until they abandoned the run.
//
// The combat roster is a different question with a different answer: Pete IS a
// body in the fight, so CombatSession.RosterSize() counts him and the enemy-HP
// scalar feels him. Seats and mouths are not the same set.
func partySize(expeditionID string) (int, error) { func partySize(expeditionID string) (int, error) {
var n int var n int
err := db.Get().QueryRow( err := db.Get().QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`, `SELECT COUNT(*) FROM expedition_party
expeditionID).Scan(&n) WHERE expedition_id = ? AND user_id <> ?`,
expeditionID, string(companionUserID())).Scan(&n)
if err != nil { if err != nil {
return 0, err return 0, err
} }

View File

@@ -246,6 +246,10 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID)))) b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
} }
for _, m := range members { for _, m := range members {
if isCompanionUser(m.UserID) {
b.WriteString(companionRosterLine(exp.ID))
continue
}
role := "member" role := "member"
if m.IsLeader() { if m.IsLeader() {
role = "leader" role = "leader"
@@ -260,6 +264,9 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max)) b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
if inviteWindowOpen(exp) { if inviteWindowOpen(exp) {
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._") b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
if !companionSeated(exp.ID) {
b.WriteString("\n_Short a body? `!expedition hire` brings Pete along._")
}
} }
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }

View File

@@ -108,13 +108,45 @@ func seatedExpeditionFor(userID id.UserID) (*Expedition, error) {
// degrades to the owner rather than dropping the message — a player who misses // degrades to the owner rather than dropping the message — a player who misses
// their briefing because SQLite hiccuped is a worse outcome than a member who // their briefing because SQLite hiccuped is a worse outcome than a member who
// misses theirs. // misses theirs.
//
// A hired companion is dropped here, and this is the chokepoint that keeps him
// out of every DM seam at once: the briefing, the recap, the digest, the
// extraction notice — and, crucially, the per-member side effects that ride the
// fan-out rather than the message. maybeRollPetArrivalOnEmerge would offer Pete
// a pet and park a pending interaction awaiting a reply that never comes;
// maybeFireAnchoredEvent would claim him a daily event slot and DM him a
// choice. He is not a person; he does not get mail. See isCompanionSeat.
func expeditionAudience(e *Expedition) []id.UserID { func expeditionAudience(e *Expedition) []id.UserID {
if e == nil || e.UserID == "" {
return nil
}
seats, err := partyHumans(e.ID, e.UserID)
if err != nil {
slog.Warn("expedition: party roster read failed, DMing owner only",
"expedition", e.ID, "err", err)
return []id.UserID{id.UserID(e.UserID)}
}
out := make([]id.UserID, 0, len(seats))
for _, s := range seats {
out = append(out, s.UserID)
}
return out
}
// expeditionSeats is every body that sits down in a fight: the whole roster,
// hired companion included. It is deliberately NOT expeditionAudience — that one
// drops the companion because he does not get mail, and a combat roster built
// from it would seat everyone the leader paid for except the one he paid for.
//
// Mail and seats are different sets. Anything that sends is an audience;
// anything that fights is a seat.
func expeditionSeats(e *Expedition) []id.UserID {
if e == nil || e.UserID == "" { if e == nil || e.UserID == "" {
return nil return nil
} }
ids, err := partyMemberIDs(e.ID, e.UserID) ids, err := partyMemberIDs(e.ID, e.UserID)
if err != nil { if err != nil {
slog.Warn("expedition: party roster read failed, DMing owner only", slog.Warn("expedition: party roster read failed, seating owner only",
"expedition", e.ID, "err", err) "expedition", e.ID, "err", err)
return []id.UserID{id.UserID(e.UserID)} return []id.UserID{id.UserID(e.UserID)}
} }

View File

@@ -34,6 +34,11 @@ func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") ==
type SimRunner struct { type SimRunner struct {
P *AdventurePlugin P *AdventurePlugin
Euro *EuroPlugin Euro *EuroPlugin
// Companion hires Pete into the party on Day 1: "" = none, "auto" = fill the
// missing role, or an explicit class id. He takes a combat seat and no loot,
// which is exactly the thing the sweep is here to measure — whether an extra
// below-median body lifts the trailing case without carrying it.
Companion string
} }
// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs // NewSimRunner initializes a fresh sqlite DB in dataDir and constructs
@@ -49,6 +54,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
// etc. fire the way a competent prod player would set them up. // etc. fire the way a competent prod player would set them up.
// Without this the sim under-counts class survival. // Without this the sim under-counts class survival.
simAutoArmEnabled = true simAutoArmEnabled = true
// N7/B3 — neutralize the weekly Omen so corpus results don't depend on which
// wall-clock week the sweep runs in (the sim's synthetic clock never reaches
// activeOmen's time.Now()).
simOmenDisabled = true
euro := &EuroPlugin{} euro := &EuroPlugin{}
p := &AdventurePlugin{euro: euro} p := &AdventurePlugin{euro: euro}
return &SimRunner{P: p, Euro: euro}, nil return &SimRunner{P: p, Euro: euro}, nil
@@ -127,6 +136,38 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
return c, nil return c, nil
} }
// PrepareRealCharacter readies an already-persisted character (loaded from a
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
// nothing: the character keeps its real race/class/subclass/level, ability
// scores, equipment, spellbook and inventory. It only (a) heals to full — a
// player rests before heading out, so we don't handicap a wounded prod snapshot
// — and (b) tops the bankroll up to `bank` so the "heavy" supply preset always
// affords itself regardless of the player's live coin balance. Returns the
// loaded character. The DB copy is disposable; the live prod file is untouched.
func (s *SimRunner) PrepareRealCharacter(uid id.UserID, bank float64) (*DnDCharacter, error) {
c, err := LoadDnDCharacter(uid)
if err != nil {
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
}
if c == nil {
return nil, fmt.Errorf("no character for %s in db copy", uid)
}
if c.Class == "" {
return nil, fmt.Errorf("%s has no class (setup incomplete) — nothing to simulate", uid)
}
c.HPCurrent = c.HPMax
c.TempHP = 0
c.Exhaustion = 0
c.ShortRestCharges = c.Level
if err := SaveDnDCharacter(c); err != nil {
return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
}
if bal := s.Euro.GetBalance(uid); bal < bank {
s.Euro.Credit(uid, bank-bal, "expedition-sim real-char bankroll top-up")
}
return c, nil
}
// stockSimConsumables drops a small tier-appropriate bundle of potions // stockSimConsumables drops a small tier-appropriate bundle of potions
// + a couple offensive items into the synthetic player's inventory so // + a couple offensive items into the synthetic player's inventory so
// SelectConsumables / setupAutoHealFromInventory have something to fire // SelectConsumables / setupAutoHealFromInventory have something to fire
@@ -364,7 +405,42 @@ type SimCombatSummary struct {
// SetSimIncludeTrace(true) has been called, and only on the LAST // SetSimIncludeTrace(true) has been called, and only on the LAST
// combat per expedition (the boss room) to keep JSONL size bounded. // combat per expedition (the boss room) to keep JSONL size bounded.
// Used by J2 caster-survival analysis. // Used by J2 caster-survival analysis.
Events []CombatEvent `json:",omitempty"` Events []simTraceEvent `json:",omitempty"`
}
// simTraceEvent is CombatEvent with the seat ALWAYS emitted.
//
// CombatEvent.Seat is `omitempty` for wire-compat reasons that are correct for
// persistence and wrong for a trace: seat 0 and "no seat" serialize identically,
// so a party trace silently reads as a solo fight. That is not hypothetical — it
// is what made a hired companion who never took a turn look, in the JSON, like a
// fight that only ever had one seat, and it cost an hour of chasing the wrong bug.
// A diagnostic that cannot tell you who acted is not a diagnostic.
type simTraceEvent struct {
Round int
Seat int // always present, even when 0
Phase string
Actor string
Action string
Damage int
PlayerHP int
EnemyHP int
Roll int
RollAgainst int
Desc string `json:",omitempty"`
}
// simTraceEvents converts a TurnLog for the trace.
func simTraceEvents(events []CombatEvent) []simTraceEvent {
out := make([]simTraceEvent, len(events))
for i, e := range events {
out[i] = simTraceEvent{
Round: e.Round, Seat: e.Seat, Phase: e.Phase, Actor: e.Actor, Action: e.Action,
Damage: e.Damage, PlayerHP: e.PlayerHP, EnemyHP: e.EnemyHP,
Roll: e.Roll, RollAgainst: e.RollAgainst, Desc: e.Desc,
}
}
return out
} }
// simIncludeTrace gates per-round event capture on SimCombatSummary. // simIncludeTrace gates per-round event capture on SimCombatSummary.
@@ -480,6 +556,22 @@ func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneI
return res, fmt.Errorf("expedition vanished while seating the party") return res, fmt.Errorf("expedition vanished while seating the party")
} }
} }
// Hire the companion on Day 1, after the humans are seated so the role fill
// sees the party it is filling a hole in. He is not in `members` — he buys no
// packs and takes no seat in the human roster — but fightRoster reads
// expeditionSeats, so he shows up in every fight from here on.
if s.Companion != "" {
class, ok := parseCompanionClass(s.Companion)
if !ok {
class = companionRoleFill(companionPartyClasses(exp.ID))
}
if err := hireCompanion(exp.ID, class, companionPartyLevel(exp.ID)); err != nil {
res.Outcome = "halted"
res.StopCode = "companion:" + err.Error()
return res, err
}
}
roster := append([]id.UserID{uid}, members...) roster := append([]id.UserID{uid}, members...)
res.PartySize = len(roster) res.PartySize = len(roster)
for _, m := range members { for _, m := range members {
@@ -906,7 +998,7 @@ func simCombatSummaries(uid id.UserID) []SimCombatSummary {
out = append(out, s) out = append(out, s)
} }
if simIncludeTrace && len(out) > 0 { if simIncludeTrace && len(out) > 0 {
out[len(out)-1].Events = lastEvents out[len(out)-1].Events = simTraceEvents(lastEvents)
} }
return out return out
} }
@@ -1002,6 +1094,18 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
turn.Sender = id.UserID(cur.seatUserID(seat)) turn.Sender = id.UserID(cur.seatUserID(seat))
} }
// An engine-driven seat is never dispatched as a chat command. There is no
// character for the handlers to load, and a command arriving from that
// seat's id reads to beginCombatTurn as a player returning to the keyboard
// — which is how the driver used to clear the very latch that was moving
// the seat. Drive it directly instead.
if cur.seatIsEngineDriven(seat) {
if err := p.driveEngineSeat(cur, seat); err != nil {
return false, fmt.Errorf("engine seat %d: %w", seat, err)
}
continue
}
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat) kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
var dispatchErr error var dispatchErr error
switch kind { switch kind {
@@ -1106,8 +1210,16 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio
// which is seat 0. Driving a party member's turn off the leader's HP would heal // which is seat 0. Driving a party member's turn off the leader's HP would heal
// the wrong person and re-arm the wrong aura. // the wrong person and re-arm the wrong aura.
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) { func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
c, _ := LoadDnDCharacter(uid) if sess == nil {
if c == nil || sess == nil { return "attack", ""
}
// seatPickSheet, not LoadDnDCharacter: the hired companion has no character row
// and never will, so the raw load returned nil for him and this function bailed
// to "attack" on its first line — every turn, for the whole fight. That one line
// is why a hired Cleric swung a mace while the party died. He fights off the
// same synthetic sheet the rest of his seat is built from.
c := seatPickSheet(sess, uid)
if c == nil {
return "attack", "" return "attack", ""
} }
st := sess.actorStatusesForSeat(seat) st := sess.actorStatusesForSeat(seat)
@@ -1121,6 +1233,20 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
} }
} }
} }
// §1 — a healer with a hurt friend heals the friend. This is what a competent
// player does, and until the engine could target another seat it was not an
// option the picker even had. It runs before the damage picks: a party member
// about to die is a more urgent use of a slot than another Fireball.
// A healer heals whoever is worst off — the friend bleeding out beside them, or
// themselves. An empty target is a self-cast, and `!cast <spell>` with no
// @mention is exactly how a player writes that.
if id, target := simPickHeal(c, uid, sess, seat); id != "" {
if target == "" {
return "cast", id
}
return "cast", id + " @" + target
}
if isSpellcaster(c) && !simMartialFirstClass(c.Class) { if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral // Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
// bonus-action attack each round via SpiritWeaponProc/Dmg mods — // bonus-action attack each round via SpiritWeaponProc/Dmg mods —
@@ -1128,7 +1254,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
// otherwise never spends an L2 slot on it. Force the pick once // otherwise never spends an L2 slot on it. Force the pick once
// per fight (BuffSpiritProc==0) so the picker doesn't pretend // per fight (BuffSpiritProc==0) so the picker doesn't pretend
// it's not a damage option. // it's not a damage option.
if id := simPickSpiritualWeapon(c, uid, st); id != "" { if id := simPickSpiritualWeapon(c, sess, seat, uid, st); id != "" {
return "cast", id return "cast", id
} }
// Once a concentration aura is up, a competent caster maintains it and // Once a concentration aura is up, a competent caster maintains it and
@@ -1136,7 +1262,7 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
// slot to re-arm the same aura — so the picker excludes concentration // slot to re-arm the same aura — so the picker excludes concentration
// spells while one is active. // spells while one is active.
auraActive := st.ConcentrationDmg > 0 auraActive := st.ConcentrationDmg > 0
if id := simPickSpell(c, uid, auraActive); id != "" { if id := simPickSpell(c, sess, seat, uid, auraActive); id != "" {
return "cast", id return "cast", id
} }
} }
@@ -1171,14 +1297,14 @@ func simMartialFirstClass(class DnDClass) bool {
// above 2nd, so spending a precious L5 to add a single d8 to the proc is // above 2nd, so spending a precious L5 to add a single d8 to the proc is
// not worth burning the bigger slot's damage potential elsewhere; sim // not worth burning the bigger slot's damage potential elsewhere; sim
// behaves like a competent player and saves the high slot. // behaves like a competent player and saves the high slot.
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string { func simPickSpiritualWeapon(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, st ActorStatuses) string {
if c == nil || c.Class != ClassCleric { if c == nil || c.Class != ClassCleric {
return "" return ""
} }
if st.BuffSpiritProc > 0 { if st.BuffSpiritProc > 0 {
return "" return ""
} }
known, err := listKnownSpells(uid) known, err := seatKnownSpells(sess, seat, uid)
if err != nil { if err != nil {
return "" return ""
} }
@@ -1192,7 +1318,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
if !prepared { if !prepared {
return "" return ""
} }
slots, _ := getSpellSlots(uid) slots, _ := seatSpellSlots(sess, seat, uid)
const simMaxSlot = 5 const simMaxSlot = 5
for sl := 2; sl <= simMaxSlot; sl++ { for sl := 2; sl <= simMaxSlot; sl++ {
pair, ok := slots[sl] pair, ok := slots[sl]
@@ -1225,12 +1351,111 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
// - Among feasible candidates, prefer higher slot level (preserves // - Among feasible candidates, prefer higher slot level (preserves
// high-slot supremacy and burns the big slots first); tie-break on // high-slot supremacy and burns the big slots first); tie-break on
// expected damage from the dice string. // expected damage from the dice string.
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string { //
known, err := listKnownSpells(uid) // simHealAllyThresholdPct is how hurt a friend has to be before a healer spends a
// slot on them rather than on damage. Deliberately lower than the self-heal
// threshold: a heal is a wasted turn if the target was going to live anyway, and
// the party's damage output is what ends the fight.
const simHealAllyThresholdPct = 45
// simPickHeal returns the heal spell a competent healer would cast this turn, and
// the seat to put it on — which may be the healer's own. An empty spell id means
// nobody needs healing, or this character cannot heal.
//
// Returning target "" means "cast it on yourself": the caller drops the @mention,
// and the engine's ordinary self-heal path takes it.
//
// It considers the caster's own seat, and it runs for a solo fight, and BOTH of
// those are recent. Until now the rule skipped `i == seat` and bailed on
// `!sess.IsParty()`, which together meant something nobody had said out loud: **no
// autopiloted caster in this game had ever cast a heal on themselves.** A solo
// cleric carried cure_wounds for a whole 30-room run and used it exactly never,
// while dying with a full pool of level-1 slots. That is a strong candidate for
// why the class corpus has cleric at 4656% against fighter's 82%
// ([[project_d8prereq_baseline]], §6 of the combat-engine plan) — the picker was
// not playing the class.
//
// The healer heals whoever is worst off. Usually that is the friend bleeding out
// next to them. Sometimes it is them.
func simPickHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
if sess == nil || c == nil || !isSpellcaster(c) {
return "", ""
}
// Who is worst off? Only living seats — the engine will not raise the downed,
// and a heal aimed at a corpse is a lost turn.
worst, worstPct := -1, 101
for i := range sess.RosterSize() {
hp, hpMax := sess.seatHP(i), sess.seatHPMax(i)
if hp <= 0 || hpMax <= 0 {
continue
}
if pct := hp * 100 / hpMax; pct < worstPct {
worst, worstPct = i, pct
}
}
if worst < 0 || worstPct >= simHealAllyThresholdPct {
return "", ""
}
// Healing yourself is not an ally-target at all — it is the plain self-heal the
// engine has always had. Hand the caller no target and let it say so.
if worst == seat {
return simPickHealSpell(c, uid, sess, seat), ""
}
best := simPickHealSpell(c, uid, sess, seat)
if best == "" {
return "", ""
}
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
// the seats in this fight, so this round-trips without a room lookup.
return best, id.UserID(sess.seatUserID(worst)).Localpart()
}
// simPickHealSpell is the cheapest heal this caster can actually cast right now,
// or "". Burning a 5th-level slot to top somebody up is not what a competent
// player does, so it takes the lowest castable slot.
func simPickHealSpell(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) string {
known, err := seatKnownSpells(sess, seat, uid)
if err != nil {
return ""
}
slots, _ := seatSpellSlots(sess, seat, uid)
best, bestLevel := "", 99
for _, k := range known {
if !k.Prepared {
continue
}
sp, ok := lookupSpell(k.SpellID)
if !ok || sp.Effect != EffectSpellHeal || sp.CastTime == CastReaction {
continue
}
onList := false
for _, cl := range sp.Classes {
if cl == c.Class {
onList = true
break
}
}
if !onList || sp.Level == 0 {
continue // heals are slot spells; a level-0 "heal" is not a thing we cast
}
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
continue
}
if sp.Level < bestLevel {
best, bestLevel = sp.ID, sp.Level
}
}
return best
}
func simPickSpell(c *DnDCharacter, sess *CombatSession, seat int, uid id.UserID, auraActive bool) string {
known, err := seatKnownSpells(sess, seat, uid)
if err != nil || len(known) == 0 { if err != nil || len(known) == 0 {
return "" return ""
} }
slots, _ := getSpellSlots(uid) slots, _ := seatSpellSlots(sess, seat, uid)
type cand struct { type cand struct {
id string id string
slot int slot int

303
internal/plugin/pete.go Normal file
View File

@@ -0,0 +1,303 @@
package plugin
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// This file is gogobee's game-side entry to Pete's adventure news. Plugins call
// emitFact at event chokepoints; it enforces the kill-switch + per-player
// opt-out, then hands a durable fact to peteclient. gogobee supplies facts only
// — Pete owns the voice. See pete_adventure_news_plan.md / _voice.md.
const (
newsEnabledKey = "adventure_news_enabled"
newsGUIDSaltKey = "adventure_news_guid_salt"
anonName = "an adventurer"
)
// newsEmissionOn is the runtime kill-switch (default on). An operator flips it
// with `!news off` without a redeploy; FEATURE_PETE_NEWS is the source-level
// master switch checked separately by peteclient.Enabled. Persisted in the
// durable news_config table (NOT api_cache, which RunMaintenance prunes).
func newsEmissionOn() bool {
var v string
err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsEnabledKey).Scan(&v)
if err != nil {
return true // no row → default on
}
return v != "0"
}
// setNewsEmission persists the runtime kill-switch.
func setNewsEmission(on bool) {
v := "1"
if !on {
v = "0"
}
db.Exec("news emission set",
`INSERT INTO news_config (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
newsEnabledKey, v)
}
// isNewsOptedOut reports whether a player asked not to be named in the news.
func isNewsOptedOut(userID id.UserID) bool {
var one int
err := db.Get().QueryRow(`SELECT 1 FROM news_optout WHERE user_id = ?`, string(userID)).Scan(&one)
return err == nil
}
// setNewsOptout records or clears a player's opt-out. Opting out anonymizes
// their name in future dispatches ("an adventurer…"); it does not stop events
// being reported. Idempotent either direction.
func setNewsOptout(userID id.UserID, out bool) {
if out {
db.Exec("news optout set",
`INSERT OR IGNORE INTO news_optout (user_id, opted_out_at) VALUES (?, unixepoch())`,
string(userID))
return
}
db.Exec("news optout clear", `DELETE FROM news_optout WHERE user_id = ?`, string(userID))
}
var (
guidSaltOnce sync.Once
guidSalt []byte
)
// newsGUIDSalt returns the secret, server-side salt that keys every public event
// token. It is 32 random bytes, generated once and persisted in the durable
// news_config table, cached in-process. Secret from anyone outside the server
// (external users never see it), stable across restarts (so a re-emit or the
// cold-start backfill reproduces the same GUID). No operator action needed.
func newsGUIDSalt() []byte {
guidSaltOnce.Do(func() {
var stored string
if err := db.Get().QueryRow(
`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&stored); err == nil {
if b, e := hex.DecodeString(stored); e == nil && len(b) > 0 {
guidSalt = b
return
}
}
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand should never fail; if it does, refuse to fall back to a
// predictable salt (that would reopen the enumeration attack). Leave the
// salt empty and let eventToken sort it out.
return
}
salt := hex.EncodeToString(b)
// Persist. OR IGNORE so a concurrent first-writer wins cleanly; we then
// re-read the row so every caller agrees on the same salt.
db.Exec("news guid salt seed",
`INSERT OR IGNORE INTO news_config (key, value) VALUES (?, ?)`, newsGUIDSaltKey, salt)
_ = db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt)
guidSalt, _ = hex.DecodeString(salt)
})
return guidSalt
}
// eventToken is the per-event, one-way identifier embedded in a public GUID
// (which becomes a PUBLIC permalink path on Pete). It is HMAC-SHA256 over the
// Matrix user id AND an event-specific discriminator, keyed by the secret
// newsGUIDSalt.
//
// Two properties matter and both come from HMAC being a PRF under a secret key:
// - Not computable from a Matrix handle. An attacker who knows @alice:server
// cannot derive any of her tokens without the salt — so her events (including
// ones anonymized after `!news optout`) can't be enumerated from her handle.
// - Per-event, so tokens are mutually unlinkable. Even for the same user, two
// events yield unrelated tokens; a story that names her (opted-in, or a duel
// opponent) exposes only that one event's token and can't be walked back to
// her other, anonymized events.
//
// The discriminator must uniquely identify the logical event so a re-emit or the
// backfill reproduces the same token (idempotency rides on the GUID).
func eventToken(userID id.UserID, discriminator string) string {
mac := hmac.New(sha256.New, newsGUIDSalt())
mac.Write([]byte(userID))
mac.Write([]byte{0}) // domain separator: keep id and discriminator from bleeding
mac.Write([]byte(discriminator))
return hex.EncodeToString(mac.Sum(nil)[:9]) // 18 hex chars — ample, non-reversible
}
// charName returns a player's in-game character name, never their Matrix handle.
// Empty when unknown — callers skip the emit rather than fall back to a handle.
func charName(userID id.UserID) string {
name, _ := loadDisplayName(userID)
return strings.TrimSpace(name)
}
// charLevel returns a player's current character level for a news Fact, or 0 if
// no character loads (Level is omitempty, so 0 is dropped from the payload).
func charLevel(userID id.UserID) int {
if dc, _ := LoadDnDCharacter(userID); dc != nil {
return dc.Level
}
return 0
}
// classRaceLabel renders a character's race + class for the arrival fact, e.g.
// "Elf Ranger".
func classRaceLabel(c *DnDCharacter) string {
ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class)
return strings.TrimSpace(ri.Display + " " + ci.Display)
}
// emitFact is the single game-side entry to Pete's news. It enforces the runtime
// kill-switch and per-player opt-out (anonymize the name, never drop the event),
// derives the actors allow-list from the FINAL names (so Pete's fact-guard and
// the rendered content agree), then durably queues the fact.
//
// subjectUser/opponentUser are the Matrix users behind Subject/Opponent (either
// may be empty for realm-level events). They are used ONLY for the opt-out
// lookup and are never sent.
func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) {
if !peteclient.Enabled() || !newsEmissionOn() {
return
}
if subjectUser != "" && isNewsOptedOut(subjectUser) {
f.Subject = anonName
}
if opponentUser != "" && isNewsOptedOut(opponentUser) {
f.Opponent = anonName
}
var actors []string
for _, n := range []string{f.Subject, f.Opponent} {
if n != "" {
actors = append(actors, n)
}
}
f.Actors = actors
peteclient.Emit(f)
}
// handleNewsCmd backs `!news`. Players toggle whether they're named in Pete's
// dispatches; admins flip the realm-wide emission kill-switch. Replies in-room
// so the effect is visible where it was invoked.
//
// !news → show your status + what the command does
// !news optout → anonymize your name in future dispatches
// !news optin → be named again
// !news on|off → (admin) master kill-switch for all emission
func (p *AdventurePlugin) handleNewsCmd(ctx MessageContext) error {
arg := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "news")))
switch arg {
case "optout", "opt-out", "anonymize", "anon":
setNewsOptout(ctx.Sender, true)
return p.SendReply(ctx.RoomID, ctx.EventID,
"Done — Pete will refer to you as \"an adventurer\" in the news from now on. `!news optin` to be named again.")
case "optin", "opt-in":
setNewsOptout(ctx.Sender, false)
return p.SendReply(ctx.RoomID, ctx.EventID,
"You're back on the record — Pete will use your character name in future dispatches.")
case "on", "enable":
if !p.IsAdmin(ctx.Sender) {
return nil
}
setNewsEmission(true)
return p.SendReply(ctx.RoomID, ctx.EventID, "📣 Adventure news emission is now ON.")
case "off", "disable":
if !p.IsAdmin(ctx.Sender) {
return nil
}
setNewsEmission(false)
return p.SendReply(ctx.RoomID, ctx.EventID, "🔇 Adventure news emission is now OFF. No new dispatches will be sent.")
case "", "status", "help":
named := "using your character name"
if isNewsOptedOut(ctx.Sender) {
named = "anonymized (\"an adventurer\")"
}
msg := "📰 **Pete's Adventure News** reports realm happenings — deaths, first-clears, arrivals, duels.\n" +
"You are currently " + named + ".\n" +
"`!news optout` to be anonymized · `!news optin` to be named again."
if p.IsAdmin(ctx.Sender) {
state := "ON"
if !newsEmissionOn() {
state = "OFF"
}
msg += "\n_(admin)_ emission is **" + state + "** · `!news on` / `!news off` to toggle."
}
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown option. Try `!news`, `!news optout`, or `!news optin`.")
}
}
// nowUnix is the event timestamp helper (kept in one place so the emit sites
// read uniformly).
func nowUnix() int64 { return time.Now().Unix() }
// claimRealmFirst records the first occurrence of a (kind,target) across the
// realm and reports whether THIS call was the first — true exactly once. Used to
// tier realm-firsts (PRIORITY) apart from repeat clears (BULLETIN). The backfill
// pre-seeds this ledger so historical firsts aren't re-announced after a deploy.
func claimRealmFirst(kind, target string) bool {
res := db.ExecResult("news realm-first claim",
`INSERT OR IGNORE INTO news_realm_firsts (kind, target, first_at) VALUES (?, ?, unixepoch())`,
kind, target)
if res == nil {
return false
}
n, err := res.RowsAffected()
return err == nil && n > 0
}
// emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The
// realm's first clear of a zone is a PRIORITY "zone_first"; later clears are a
// BULLETIN "zone_clear". The event_type and the GUID prefix track the tier so
// Pete templates and labels a repeat as a repeat, not a first-ever. Character
// name only; no-op unless the seam is enabled.
func emitZoneClearNews(userID id.UserID, exp *Expedition) {
if !peteclient.Enabled() || !newsEmissionOn() {
return
}
// Claim the realm-first BEFORE the name guard, so an unnamed straggler's
// genuine first clear still seeds news_realm_firsts. Otherwise the next
// named clearer would claim it and be mis-announced as the first-ever.
// Mirrors backfillZoneFirsts, which claims before its own name check.
eventType, tier := "zone_clear", "bulletin"
if claimRealmFirst("zone", string(exp.ZoneID)) {
eventType, tier = "zone_first", "priority"
}
name := charName(userID)
if name == "" {
return
}
zone := zoneOrFallback(exp.ZoneID)
region := ""
if IsMultiRegionZone(exp.ZoneID) {
if r, ok := CurrentRegion(exp); ok {
region = r.Name
}
}
lvl := charLevel(userID)
ts := nowUnix()
disc := fmt.Sprintf("%s:%d", exp.ZoneID, ts)
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("%s:%s:%s:%d", eventType, eventToken(userID, disc), exp.ZoneID, ts),
EventType: eventType,
Tier: tier,
Subject: name,
Zone: zone.Display,
Region: region,
Boss: zone.Boss.Name,
Level: lvl,
Outcome: "cleared",
OccurredAt: ts,
}, userID, "")
}

View File

@@ -0,0 +1,162 @@
package plugin
import (
"encoding/json"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// The retreat bulletin: the news can finally say "it went badly and nobody
// died". Before this, every event type Pete could file was a win, a death, or an
// introduction — so a run that simply fell apart was reported as nothing at all,
// and a class that retreated a third of the time just looked absent from the
// feed.
//
// The reason gate is the whole safety property. A death must not ALSO be filed
// as a retreat (it already files its own priority dispatch), and an idle reap
// must not be filed at all — Pete telling the realm that a named player was
// driven from the field, when in truth they closed their laptop, is a lie about
// a person by name.
func retreatFactFor(t *testing.T, uid id.UserID) map[string]any {
t.Helper()
var payload string
err := db.Get().QueryRow(
`SELECT payload FROM pete_emit_queue WHERE guid LIKE 'retreat:%'`).Scan(&payload)
if err != nil {
t.Fatalf("no retreat fact queued: %v", err)
}
var f map[string]any
if err := json.Unmarshal([]byte(payload), &f); err != nil {
t.Fatal(err)
}
return f
}
func TestRetreatNews_ReasonGate(t *testing.T) {
cases := []struct {
reason string
want int
why string
}{
{lossCombatRetreat, 1, "a solo walker who ran out the clock and withdrew is news"},
{lossCombatFlee, 1, "a party that broke off is news"},
{lossCombatDeath, 0, "a death already files its own priority dispatch — do not double-report it as a retreat"},
{lossIdleTimeout, 0, "an idle reap is not a retreat; the player closed their laptop, they did not flee"},
}
for _, tc := range cases {
t.Run(tc.reason, func(t *testing.T) {
setupEmptyTestDB(t)
enablePeteSeam(t)
uid := id.UserID("@retreat:example.org")
zoneCmdTestCharacter(t, uid, 10)
emitRetreatNews(uid, tc.reason, ZoneID("underforge"), 3)
if got := queuedCount(t, "retreat:%"); got != tc.want {
t.Fatalf("reason %q queued %d retreat facts, want %d — %s",
tc.reason, got, tc.want, tc.why)
}
})
}
}
// The bulletin has to carry enough for Pete to write a sentence: who, where, how
// far they got. A retreat with no day count is just "someone left".
func TestRetreatNews_CarriesTheStory(t *testing.T) {
setupEmptyTestDB(t)
enablePeteSeam(t)
uid := id.UserID("@retreat-story:example.org")
zoneCmdTestCharacter(t, uid, 10)
emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 3)
f := retreatFactFor(t, uid)
if f["event_type"] != "retreat" {
t.Errorf("event_type = %v, want retreat", f["event_type"])
}
// Bulletin, not priority: a retreat is a bad day, not a funeral.
if f["tier"] != "bulletin" {
t.Errorf("tier = %v, want bulletin", f["tier"])
}
if f["outcome"] != "retreated" {
t.Errorf("outcome = %v, want retreated", f["outcome"])
}
if f["count"] != float64(3) {
t.Errorf("count = %v, want 3 (the day it fell apart)", f["count"])
}
if f["zone"] == nil || f["zone"] == "" {
t.Error("no zone — Pete cannot say where it happened")
}
if f["subject"] == nil || f["subject"] == "" {
t.Error("no subject — Pete cannot say who it happened to")
}
// GUID prefix must equal the event type; Pete's taxonomy keys off it.
if guid, _ := f["guid"].(string); len(guid) < 8 || guid[:8] != "retreat:" {
t.Errorf("guid %q must be prefixed with the event type", guid)
}
}
// The wiring, not just the emitter. Everything above calls emitRetreatNews
// directly, which proves nothing about the game: the fact only ships if the real
// run-loss chokepoint actually calls it, on a real expedition, and reads the day
// count BEFORE forcedExtractExpedition stamps the row 'abandoned' and takes the
// live fields with it. Drive the chokepoint.
func TestRetreatNews_WiredToTheRunLossChokepoint(t *testing.T) {
setupEmptyTestDB(t)
enablePeteSeam(t)
uid := id.UserID("@retreat-wired:example")
campTestCharacter(t, uid, 1)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
forceExtractExpeditionForRunLoss(uid, lossCombatRetreat)
if got := queuedCount(t, "retreat:%"); got != 1 {
t.Fatalf("the run-loss chokepoint queued %d retreat facts, want 1 — "+
"emitRetreatNews passes its own unit tests but is not actually wired to the game", got)
}
f := retreatFactFor(t, uid)
// The day must survive the extract. Read it after forcedExtractExpedition and
// it is gone — the row is 'abandoned' and the live fields are zeroed.
if f["count"] != float64(exp.CurrentDay) {
t.Errorf("count = %v, want %d — the day count was read after the extract wiped it",
f["count"], exp.CurrentDay)
}
// And the expedition really did end; a bulletin about a run still in progress
// would be worse than no bulletin.
if still, _ := getActiveExpedition(uid); still != nil {
t.Error("expedition still active after a run-loss extract")
}
}
// The opt-out covers the new event type too. A player who asked not to be named
// must not be named when they lose — that is precisely when they'd mind most.
func TestRetreatNews_HonoursOptOut(t *testing.T) {
setupEmptyTestDB(t)
enablePeteSeam(t)
uid := id.UserID("@retreat-shy:example.org")
zoneCmdTestCharacter(t, uid, 10)
setNewsOptout(uid, true)
emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 2)
f := retreatFactFor(t, uid)
if f["subject"] != anonName {
t.Fatalf("subject = %v, want %q — the opt-out must cover retreats", f["subject"], anonName)
}
actors, _ := f["actors"].([]any)
for _, a := range actors {
if a != anonName {
t.Fatalf("actors leaked %v past the opt-out", a)
}
}
}

View File

@@ -0,0 +1,216 @@
package plugin
import (
"crypto/sha256"
"encoding/hex"
"os"
"sync"
"testing"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// enablePeteSeam turns on the emit seam for a test and restores it to disabled
// afterwards, so a leaked-enabled singleton can't affect other tests.
func enablePeteSeam(t *testing.T) {
t.Helper()
os.Setenv("FEATURE_PETE_NEWS", "true")
os.Setenv("PETE_INGEST_URL", "http://127.0.0.1:0")
os.Setenv("PETE_INGEST_TOKEN", "tok")
peteclient.Init()
t.Cleanup(func() {
os.Unsetenv("FEATURE_PETE_NEWS")
os.Unsetenv("PETE_INGEST_URL")
os.Unsetenv("PETE_INGEST_TOKEN")
peteclient.Init()
})
}
func queuedCount(t *testing.T, likeGUID string) int {
t.Helper()
var n int
if err := db.Get().QueryRow(
`SELECT COUNT(*) FROM pete_emit_queue WHERE guid LIKE ?`, likeGUID).Scan(&n); err != nil {
t.Fatalf("queue count: %v", err)
}
return n
}
// TestNewsOptoutRoundTrip: opting out records the player, opting in clears them,
// both idempotently.
func TestNewsOptoutRoundTrip(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
u := id.UserID("@zapp:example.org")
if isNewsOptedOut(u) {
t.Fatal("fresh player should not be opted out")
}
setNewsOptout(u, true)
if !isNewsOptedOut(u) {
t.Error("opt-out not recorded")
}
setNewsOptout(u, true) // idempotent
if !isNewsOptedOut(u) {
t.Error("second opt-out flipped state")
}
setNewsOptout(u, false)
if isNewsOptedOut(u) {
t.Error("opt-in did not clear")
}
setNewsOptout(u, false) // idempotent
if isNewsOptedOut(u) {
t.Error("second opt-in flipped state")
}
}
// TestPeteNewsBackfill: the one-shot replays deaths + solo achievements + zone
// realm-firsts through the emit queue, seeds the realm-first ledger, and is
// idempotent on a second boot.
func TestPeteNewsBackfill(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
enablePeteSeam(t)
// A named, dead adventurer.
db.Exec("seed death", `INSERT INTO player_meta (user_id, display_name, last_death_date, death_location)
VALUES (?, ?, ?, ?)`, "@zapp:x", "Zapp", "2026-06-01", "the Underforge")
// A single-holder achievement.
db.Exec("seed achv", `INSERT INTO achievements (user_id, achievement_id, unlocked_at)
VALUES (?, ?, ?)`, "@zapp:x", "adv_first_blood", 1717200000)
// A completed, boss-defeated zone run → realm-first.
db.Exec("seed run", `INSERT INTO dnd_zone_run (run_id, user_id, zone_id, total_rooms, boss_defeated, completed_at)
VALUES (?, ?, ?, ?, 1, ?)`, "r1", "@zapp:x", "goblin_warren", 5, "2026-05-20 12:00:00")
p := &AdventurePlugin{}
p.bootstrapPeteNewsBackfill()
if got := queuedCount(t, "death:%"); got != 1 {
t.Errorf("death dispatches queued = %d, want 1", got)
}
if got := queuedCount(t, "milestone:%"); got != 1 {
t.Errorf("achievement dispatches queued = %d, want 1", got)
}
if got := queuedCount(t, "zone_first:%"); got != 1 {
t.Errorf("zone-first dispatches queued = %d, want 1", got)
}
// Realm-first ledger seeded so a later live clear tiers as a repeat.
if claimRealmFirst("zone", "goblin_warren") {
t.Error("realm-first ledger not seeded — a live clear would mis-announce as first-ever")
}
// Second boot: job gate holds, nothing re-queued.
p.bootstrapPeteNewsBackfill()
if got := queuedCount(t, "%"); got != 3 {
t.Errorf("total queued after re-run = %d, want 3 (idempotent)", got)
}
}
// TestEventToken: the public GUID token is salted (not a bare sha256 an attacker
// can recompute from the handle), stable per logical event (idempotency), and
// per-event so a user's tokens don't link to each other.
func TestEventToken(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
// The salt is cached process-wide via sync.Once; reset it so it derives (and
// persists) against this test's fresh DB rather than an earlier test's.
guidSaltOnce = sync.Once{}
guidSalt = nil
u := id.UserID("@alice:example.org")
// Not the old unsalted digest: an attacker with the handle can't recompute it.
bare := sha256.Sum256([]byte(u))
if got := eventToken(u, "arrival"); got == hex.EncodeToString(bare[:9]) {
t.Error("token matches unsalted sha256(handle) — enumeration attack reopened")
}
// Stable for the same (user, discriminator): a re-emit dedups.
if eventToken(u, "arrival") != eventToken(u, "arrival") {
t.Error("token not stable for the same event — idempotency broken")
}
// Per-event: two different events for the same user yield unrelated tokens, so
// a named event can't be walked back to the user's anonymized events.
if eventToken(u, "arrival") == eventToken(u, "death:123") {
t.Error("tokens collide across events — linkage attack reopened")
}
// Different users, same discriminator, still differ.
if eventToken(u, "arrival") == eventToken(id.UserID("@bob:example.org"), "arrival") {
t.Error("token collides across users")
}
// The salt persisted, so it survives a restart (a re-emit after reboot dedups).
var salt string
if err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt); err != nil || salt == "" {
t.Fatalf("guid salt not persisted: %v", err)
}
}
// TestEmitZoneClearTaxonomy: the realm's first clear of a zone emits a PRIORITY
// zone_first; a later clear emits a BULLETIN zone_clear — distinct event_type and
// GUID prefix, so Pete never files a repeat as a first-ever.
func TestEmitZoneClearTaxonomy(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
enablePeteSeam(t)
db.Exec("seed player", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`,
"@zapp:x", "Zapp")
exp := &Expedition{ZoneID: "goblin_warren"}
emitZoneClearNews(id.UserID("@zapp:x"), exp) // realm-first
emitZoneClearNews(id.UserID("@zapp:x"), exp) // repeat
if got := queuedCount(t, "zone_first:%"); got != 1 {
t.Errorf("zone_first queued = %d, want 1 (the realm-first)", got)
}
if got := queuedCount(t, "zone_clear:%"); got != 1 {
t.Errorf("zone_clear queued = %d, want 1 (the repeat)", got)
}
}
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
func TestNewsEmissionKillSwitch(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
if !newsEmissionOn() {
t.Error("emission should default ON")
}
setNewsEmission(false)
if newsEmissionOn() {
t.Error("emission still ON after disable")
}
setNewsEmission(true)
if !newsEmissionOn() {
t.Error("emission still OFF after enable")
}
}

View File

@@ -1349,6 +1349,9 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
if cleared, err := loadEpilogueCleared(uid); err == nil { if cleared, err := loadEpilogueCleared(uid); err == nil {
c.EpilogueCleared = cleared c.EpilogueCleared = cleared
} }
if xp, err := loadRenownXP(uid); err == nil {
c.RenownXP = xp
}
if s, err := loadHouseState(uid); err == nil { if s, err := loadHouseState(uid); err == nil {
c.HouseTier = s.Tier c.HouseTier = s.Tier
c.HouseLoanBalance = s.LoanBalance c.HouseLoanBalance = s.LoanBalance

View File

@@ -0,0 +1,81 @@
package plugin
import (
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// TestAutoMigrationSeedsPlayerMeta guards the camcast straggler regression: a
// brand-new player whose first-ever adventure action auto-migrates a character
// (e.g. !rest before !setup) must come out with a canonical player_meta seed,
// or every legacy-layer command (expeditions, arena, world boss, town…) fails
// with "sql: no rows". Before the ensurePlayerMetaSeed guard this produced a
// confirmed dnd_character with zero player_meta rows.
func TestAutoMigrationSeedsPlayerMeta(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@fresh-automigrant:example.org")
p := &AdventurePlugin{euro: &EuroPlugin{}}
p.Sink = &captureSink{}
// First-ever adventure action for a player with no character at all.
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatalf("handleDnDShortRest: %v", err)
}
d := db.Get()
var dndRows, pmRows, equipRows, pending int
d.QueryRow(`SELECT COUNT(*) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&dndRows)
d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows)
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows)
d.QueryRow(`SELECT COALESCE(MAX(pending_setup),-1) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&pending)
if dndRows != 1 {
t.Fatalf("expected 1 auto-migrated dnd_character, got %d", dndRows)
}
if pending != 0 {
t.Errorf("auto-migrated character should be confirmed (pending_setup=0), got %d", pending)
}
if pmRows != 1 {
t.Errorf("player_meta not seeded for auto-migrant: got %d rows, want 1 (camcast straggler regression)", pmRows)
}
// createAdvCharacter seeds tier-0 gear in all five slots.
if equipRows != len(allSlots) {
t.Errorf("tier-0 equipment not seeded: got %d rows, want %d", equipRows, len(allSlots))
}
// The character must now load through the legacy layer that camcast choked on.
if _, err := loadAdvCharacter(uid); err != nil {
t.Errorf("loadAdvCharacter after auto-migration: %v (this is the exact failure camcast hit)", err)
}
}
// TestEnsurePlayerMetaSeedIdempotent proves the guard never duplicates a legacy
// player's equipment — createAdvCharacter's tier-0 insert has no conflict guard,
// so the seed must be conditional on player_meta being absent.
func TestEnsurePlayerMetaSeedIdempotent(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@legacy:example.org")
// Seed once (fresh), then again (should be a no-op).
if err := ensurePlayerMetaSeed(uid); err != nil {
t.Fatalf("first seed: %v", err)
}
if err := ensurePlayerMetaSeed(uid); err != nil {
t.Fatalf("second seed: %v", err)
}
d := db.Get()
var pmRows, equipRows int
d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows)
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows)
if pmRows != 1 {
t.Errorf("player_meta rows = %d, want 1 (no duplication)", pmRows)
}
if equipRows != len(allSlots) {
t.Errorf("equipment rows = %d, want %d (guard must not re-insert gear)", equipRows, len(allSlots))
}
}

View File

@@ -638,6 +638,14 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
// SendReply sends a reply to a specific event. // SendReply sends a reply to a specific event.
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error { func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
// The sink is the headless capture seam: a reply is an outbound room
// message, so — like SendMessage/SendDM — it diverts here when installed and
// the live client is never touched. Without this branch, reply-only handlers
// (duels, !town, !rivals, !achievements) hit a nil client under the sink.
if b != nil && b.Sink != nil {
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
return err
}
content := textContent(text) content := textContent(text)
if eventID != "" { if eventID != "" {
content.RelatesTo = &event.RelatesTo{ content.RelatesTo = &event.RelatesTo{

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
package plugin
import (
"maunium.net/go/mautrix/id"
)
// §6 — a caster casts in an auto-resolved fight.
//
// An ordinary room fight is runZoneCombatRoster → SimulateCombat on an 8-round
// clock: one breath, no turn engine, no action picker. The only spell that has
// ever landed there is a PendingCast the player queued BY HAND with `!cast`
// before walking in. Under autopilot nobody queues one — so for every room of
// every expedition, a caster fought with a weapon and nothing else.
//
// A cleric is the extreme case: no Extra Attack, a mace, and its whole kit
// unusable. Measured on the room path at L10, a wounded cleric loses 8% of
// ORDINARY room fights (a fighter loses 0.0%), and a room loss ends the entire
// expedition — "the fight drags on, X outlasts you, you retreat". That is why
// cleric fled 167 of 500 runs in the corpus while fighter, ranger and paladin
// fled none, and it is the whole of the "cleric is a weak class" gap.
//
// The tell: dnd_class_balance.go — the harness the class corpus was tuned on —
// ALWAYS hands a caster their best damage spell (pickBestDamageSpell +
// applyHarnessSpellCast) before it simulates. So the numbers we balanced against
// modelled a caster who casts, and the live room modelled one who does not. This
// closes that gap: the character fights with the kit it actually owns.
//
// It is NOT a new mechanic. A folded-in spell is additive pre-damage, exactly as
// a hand-queued PendingCast has always been, which is what keeps this comparable
// to the corpus rather than a fresh invention.
// autoCastForAutoResolve picks the caster's best damage spell, spends the slot,
// and folds the damage into the Combatant pair. Reports whether it cast.
//
// It reads the seat's ACTUAL remaining slots, not the class slot table.
// pickBestDamageSpell (the harness one) reads slotsForClassLevel — the
// theoretical pool — which is right for a one-fight harness and would be an
// infinite spell here: the same "no row to persist onto, so it arrives fresh"
// bug that gave the companion an unlimited body and an unlimited slot pool.
// A leveled spell cast here costs a real slot and stays spent until a rest.
func (p *AdventurePlugin) autoCastForAutoResolve(
uid id.UserID,
c *DnDCharacter,
playerStats *CombatStats,
playerMods *CombatModifiers,
enemyStats *CombatStats,
) bool {
// A hand-queued spell wins: the player already chose, applyPendingCast owns
// it, and casting a second one would be two spells in one action.
if c == nil || c.PendingCast != "" || !isSpellcaster(c) {
return false
}
spell, slot, ok := pickAutoResolveSpell(uid, c)
if !ok {
return false
}
// Leveled spells cost a slot. Spend it BEFORE the damage lands, and bail if
// the debit fails — a spell that could not be paid for must not be cast.
if slot > 0 {
spent, err := consumeSpellSlot(uid, slot)
if err != nil || !spent {
return false
}
}
applyHarnessSpellCast(c, spell, slot, playerStats, playerMods, enemyStats)
return true
}
// roomSlotCap is the highest slot level an ordinary room may spend. Cantrips are
// free and always available; anything above this is reserved for the elite and
// the boss.
//
// The first cut of §6 had no cap — "never upcast" was supposed to be enough. It
// was not, and the sweep said so: room deaths fell for every caster (mage 131 →
// 105) while elite and boss deaths exploded (mage 7 → 38 and 19 → 61, sorcerer
// 14 → 51 and 15 → 65). Casters were winning the trash rooms and arriving at the
// thing that matters with an empty pool. Native-level is not a reserve, because
// a mage's native-level spells ARE its boss kit; only a level cap is.
//
// 2 is where the two demands stop overlapping. Every caster owns a damage spell
// at or below it (a cleric's guiding_bolt and inflict_wounds are both 1st), so
// the rooms still get a real spell — but a fireball, a spirit_guardians, a
// flame_strike, and every slot the turn engine would upcast into stays in the
// caster's pocket until there is something worth spending it on.
const roomSlotCap = 2
// pickAutoResolveSpell is the best damage spell this caster can actually cast
// right now: known, prepared, on their list, at or under the room slot cap, and
// backed by a slot they still hold.
//
// It casts at the spell's NATIVE level and never upcasts. simPickSpell upcasts
// aggressively — correct there, because it runs in the turn engine at an elite
// or a boss, which is what the big slots are for. A trash room is not, and a
// picker that nukes a goblin with a 5th-level slot would leave the caster
// swinging a stick at the thing that actually matters.
func pickAutoResolveSpell(uid id.UserID, c *DnDCharacter) (SpellDefinition, int, bool) {
known, err := listKnownSpells(uid)
if err != nil || len(known) == 0 {
return SpellDefinition{}, 0, false
}
slots, err := getSpellSlots(uid)
if err != nil {
return SpellDefinition{}, 0, false
}
var best SpellDefinition
bestSlot := 0
bestScore := -1.0
for _, k := range known {
if !k.Prepared {
continue
}
sp, ok := lookupSpell(k.SpellID)
if !ok {
continue
}
switch sp.Effect {
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto:
default:
continue
}
// The auto-resolve engine has no reaction window, same as everywhere else.
if sp.CastTime == CastReaction {
continue
}
onList := false
for _, cl := range sp.Classes {
if cl == c.Class {
onList = true
break
}
}
if !onList {
continue
}
// A cantrip is free; a leveled spell needs a slot still in hand, and the
// room only gets to reach for the small ones.
if sp.Level > 0 {
if sp.Level > roomSlotCap {
continue
}
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
continue
}
}
if score := spellExpectedDamage(sp, sp.Level, c.Level); score > bestScore {
best, bestSlot, bestScore = sp, sp.Level, score
}
}
return best, bestSlot, bestScore >= 0
}

View File

@@ -0,0 +1,254 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// §6 — the caster casts in an auto-resolved room fight.
//
// Before this, the ONLY spell that could land in an ordinary room was one the
// player hand-queued with `!cast` before walking in. On autopilot nobody queues
// one, so a cleric fought every room of every expedition with a mace, lost room
// fights a fighter never loses, and a room loss ends the whole expedition.
func autocastCleric(t *testing.T, tag string) (id.UserID, *DnDCharacter) {
t.Helper()
p := &AdventurePlugin{}
s := &SimRunner{P: p}
uid := id.UserID("@autocast-" + tag + ":example.org")
c, err := s.BuildCharacter(uid, ClassCleric, 10)
if err != nil {
t.Fatal(err)
}
return uid, c
}
func usedSlots(t *testing.T, uid id.UserID) int {
t.Helper()
slots, err := getSpellSlots(uid)
if err != nil {
t.Fatal(err)
}
total := 0
for _, pair := range slots {
total += pair[1]
}
return total
}
func TestAutoCast_CasterCastsAndPaysForIt(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
uid, c := autocastCleric(t, "pays")
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 100, AC: 14}
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("a level-10 cleric with a full slot pool cast nothing")
}
// It has to be PAID for. pickBestDamageSpell reads the theoretical class slot
// table; if we had reused it here the spell would be infinite — the same
// free-lunch bug that gave the companion an unlimited body. The slot is spent
// whether or not the spell connects, exactly as it is at a real table.
if used := usedSlots(t, uid); used != 1 {
t.Fatalf("slots used = %d, want exactly 1 — the spell must cost a real slot", used)
}
}
// The slot has to buy damage. Not on any GIVEN cast — under the room slot cap a
// cleric reaches for inflict_wounds, which is an attack-roll spell and is
// allowed to miss — but a caster who spends its pool and never scratches
// anything would be strictly worse than swinging the mace.
func TestAutoCast_SpellActuallyDealsDamage(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
uid, c := autocastCleric(t, "damage")
landed := 0
for i := 0; i < 40; i++ {
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 1000, AC: 10}
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
continue
}
if mods.SpellPreDamage > 0 || enemy.MaxHP < 1000 {
landed++
}
}
if landed == 0 {
t.Fatal("40 casts against AC 10 and not one dealt a point of damage — " +
"the caster is spending slots on air")
}
}
// The pool is finite: cast far more times than the caster has slots, and the
// ledger must converge rather than run forever. This is the free-lunch guard —
// reusing the harness's pickBestDamageSpell (which reads the theoretical class
// slot table, not the row) would cast an unlimited leveled spell every room.
//
// It converges BELOW the full pool, and that is correct: a cleric owns no damage
// spell at slot 2 or 4 (guiding_bolt and inflict_wounds are L1, spirit_guardians
// L3, flame_strike L5), and a trash room never upcasts. Those slots are not
// wasted — the turn engine upcasts them at the elite and the boss, which is what
// they are for.
func TestAutoCast_SlotPoolIsFiniteAndConverges(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
uid, c := autocastCleric(t, "finite")
before, err := getSpellSlots(uid)
if err != nil {
t.Fatal(err)
}
pool := 0
for _, pair := range before {
pool += pair[0]
}
if pool == 0 {
t.Fatal("test cleric has no slots at all")
}
cast := func(n int) {
for i := 0; i < n; i++ {
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 1000, AC: 14}
p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy)
}
}
cast(pool + 10)
drained := usedSlots(t, uid)
if drained == 0 {
t.Fatal("the cleric never spent a slot — it is not really casting")
}
if drained > pool {
t.Fatalf("slots used = %d > pool %d — slots are being conjured", drained, pool)
}
// Dry means dry: another twenty rooms must not find another leveled slot.
cast(20)
if after := usedSlots(t, uid); after != drained {
t.Fatalf("slots used went %d → %d after the pool was dry — a leveled spell is being cast for free",
drained, after)
}
// And a dry caster still throws a cantrip rather than reverting to a stick.
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 1000, AC: 14}
if !p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("an out-of-slots caster cast nothing at all; a cantrip is free")
}
}
// A hand-queued spell wins. applyPendingCast already owns that cast; auto-casting
// on top of it would fire two spells in one action.
func TestAutoCast_HandQueuedSpellWins(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
uid, c := autocastCleric(t, "queued")
c.PendingCast = encodePendingCast(PendingCast{SpellID: "guiding_bolt", SlotLevel: 1})
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 100, AC: 14}
if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("auto-cast fired on top of a hand-queued spell — that is two spells in one action")
}
if used := usedSlots(t, uid); used != 0 {
t.Fatalf("slots used = %d; the auto-cast must not spend anything when the player already queued", used)
}
}
// A martial has no spellbook and must be left exactly as it was — the balance
// corpus rests on the martials not moving.
func TestAutoCast_MartialIsUntouched(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
s := &SimRunner{P: p}
uid := id.UserID("@autocast-fighter:example.org")
c, err := s.BuildCharacter(uid, ClassFighter, 10)
if err != nil {
t.Fatal(err)
}
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 100, AC: 14}
if p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy) {
t.Fatal("a fighter cast a spell")
}
if mods.SpellPreDamage != 0 {
t.Fatalf("fighter picked up %d spell pre-damage", mods.SpellPreDamage)
}
}
// Never upcast in a trash room: the big slots are what the elite and the boss
// are for, and the turn engine spends them there.
func TestAutoCast_DoesNotBurnTheBigSlots(t *testing.T) {
setupEmptyTestDB(t)
uid, c := autocastCleric(t, "native")
spell, slot, ok := pickAutoResolveSpell(uid, c)
if !ok {
t.Fatal("cleric picked nothing")
}
if slot != spell.Level {
t.Fatalf("%s cast at slot %d, native level %d — an ordinary room must not upcast",
spell.ID, slot, spell.Level)
}
}
// The boss kit survives the walk in. This is the regression the first cut of §6
// shipped and the sweep caught: with no cap, a caster spent its whole pool on
// goblins and reached the boss empty — room deaths fell but boss deaths tripled
// (mage 19 → 61, sorcerer 15 → 65). "Never upcast" is not a reserve, because a
// mage's native-level spells ARE its boss kit.
//
// So: grind a full expedition's worth of rooms, and every slot above the cap
// must still be sitting there untouched when the dragon opens its eyes.
func TestAutoCast_ReservesTheBossSlots(t *testing.T) {
for _, class := range []DnDClass{ClassMage, ClassSorcerer, ClassCleric, ClassWarlock} {
t.Run(string(class), func(t *testing.T) {
setupEmptyTestDB(t)
p := &AdventurePlugin{}
s := &SimRunner{P: p}
uid := id.UserID("@autocast-reserve-" + string(class) + ":example.org")
c, err := s.BuildCharacter(uid, class, 12)
if err != nil {
t.Fatal(err)
}
// A long expedition: far more rooms than the caster has slots.
for i := 0; i < 60; i++ {
var mods CombatModifiers
stats := CombatStats{MaxHP: 100, AC: 14}
enemy := CombatStats{MaxHP: 1000, AC: 14}
p.autoCastForAutoResolve(uid, c, &stats, &mods, &enemy)
}
slots, err := getSpellSlots(uid)
if err != nil {
t.Fatal(err)
}
for lvl, pair := range slots {
if lvl <= roomSlotCap {
continue
}
if pair[1] != 0 {
t.Errorf("level-%d slots: %d of %d spent in ordinary rooms — "+
"that is the boss's kit, and the caster will arrive empty",
lvl, pair[1], pair[0])
}
}
})
}
}

View File

@@ -51,6 +51,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
advChar *AdventureCharacter advChar *AdventureCharacter
equip map[EquipmentSlot]*AdvEquipment equip map[EquipmentSlot]*AdvEquipment
mods CombatModifiers mods CombatModifiers
// companion marks the hired NPC seat: it fights, but it owns none of the
// character-scoped effects the close-out loop applies, and its dndChar /
// advChar / equip are nil precisely so a missed guard panics loudly here
// rather than silently writing rows for a bot.
companion bool
} }
var ( var (
@@ -58,6 +63,10 @@ func (p *AdventurePlugin) runZoneCombatRoster(
builds []seatBuild builds []seatBuild
seated []id.UserID seated []id.UserID
enemy Combatant enemy Combatant
// Parallel to players: what each seat is worth, priced once the roster is
// final. A member who sat the encounter out is in none of these.
levels []int
companions []bool
) )
for i, uid := range roster { for i, uid := range roster {
@@ -69,6 +78,29 @@ func (p *AdventurePlugin) runZoneCombatRoster(
return PartyCombatResult{}, nil, err return PartyCombatResult{}, nil, err
} }
// The hired companion fights here too — the auto-resolve path is where
// most expedition rooms are actually decided. He is synthesized rather
// than loaded, and his seatBuild is flagged so the close-out loop below
// gives him no XP, no loot, and no post-combat persistence.
if isCompanionSeat(uid) {
expID := companionExpeditionFor(roster[0])
class, level := companionLoadout(expID)
player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood)
// The wounds he carries into this fight. His synthetic sheet is always
// written at full HP, so applyDnDHPScaling left StartHP at the
// "enter at MaxHP" sentinel and he healed himself for free between every
// room of the run. See companionSeatHP.
player.Stats.StartHP = companionSeatHP(expID, player.Stats.MaxHP)
if leader {
enemy = e
}
players = append(players, player)
builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true})
seated = append(seated, uid)
levels, companions = append(levels, level), append(companions, true)
continue
}
advChar, err := loadAdvCharacter(uid) advChar, err := loadAdvCharacter(uid)
if err != nil || advChar == nil { if err != nil || advChar == nil {
if leader { if leader {
@@ -120,6 +152,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
// Combatant once, before the fight runs. The queued spell can also debuff // Combatant once, before the fight runs. The queued spell can also debuff
// the shared enemy, so every seat's cast lands on the one stat block. // the shared enemy, so every seat's cast lands on the one stat block.
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats) applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
// §6 — and if they queued nothing, they still cast. This engine has no
// action picker, so before this a caster on autopilot swung a weapon for
// the whole fight and their spellbook may as well not have existed. The
// slot is really spent; see autoCastForAutoResolve.
p.autoCastForAutoResolve(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods) setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
players = append(players, player) players = append(players, player)
@@ -127,8 +164,21 @@ func (p *AdventurePlugin) runZoneCombatRoster(
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods, uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
}) })
seated = append(seated, uid) seated = append(seated, uid)
lvl := 0
if dndChar != nil {
lvl = dndChar.Level
}
levels, companions = append(levels, lvl), append(companions, false)
} }
// What each seat costs the enemy, priced against the leader — over the seats
// that were actually seated, so a member left behind is not charged.
ptrs := make([]*Combatant, len(players))
for i := range players {
ptrs[i] = &players[i]
}
applySeatWeights(ptrs, levels, companions)
res := simulateParty(players, enemy, phases) res := simulateParty(players, enemy, phases)
dumpCombatEventsIfDebug( dumpCombatEventsIfDebug(
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)), fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
@@ -137,6 +187,19 @@ func (p *AdventurePlugin) runZoneCombatRoster(
for i, b := range builds { for i, b := range builds {
seatRes := res.Seats[i] seatRes := res.Seats[i]
// The companion swings and then goes back to filing copy: no inventory to
// deduct from, no sheet to persist to, no XP to earn. Every call below
// would either write rows for a bot or log an error about the rows it
// hasn't got.
//
// His HP is the exception, and it is not bookkeeping — it is the fight's
// result. Without it he re-enters the next room at full while the humans
// beside him carry every wound to camp.
if b.companion {
_ = setCompanionHP(companionExpeditionFor(roster[0]), seatRes.PlayerEndHP)
continue
}
// Remove the actual heal items consumed during combat (one inventory item // Remove the actual heal items consumed during combat (one inventory item
// per heal_item event this seat fired). Cheapest-tier first. // per heal_item event this seat fired). Cheapest-tier first.
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes)) consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
@@ -193,6 +256,12 @@ func (p *AdventurePlugin) closeOutZoneWin(
) (leaderDrop string, downed []id.UserID) { ) (leaderDrop string, downed []id.UserID) {
party := len(seated) > 1 party := len(seated) > 1
for i, uid := range seated { for i, uid := range seated {
// The companion takes no cut and cannot die. He is not in the downed list
// either: that list is what the room narration mourns, and the party did
// not lose a friend when the hireling took a nap.
if isCompanionSeat(uid) {
continue
}
if res.Seats[i].PlayerEndHP > 0 { if res.Seats[i].PlayerEndHP > 0 {
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite) drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
if i == 0 { if i == 0 {
@@ -217,6 +286,12 @@ func (p *AdventurePlugin) closeOutZoneWin(
// fight. // fight.
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) { func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
for i, uid := range seated { for i, uid := range seated {
// The companion is not killed and is not counted among the dead — he is
// not in the graveyard, and a wipe that lists him would have the news bot
// reporting its own funeral.
if isCompanionSeat(uid) {
continue
}
if res.Seats[i].PlayerEndHP <= 0 { if res.Seats[i].PlayerEndHP <= 0 {
markAdventureDead(uid, deathSource, zone.Display) markAdventureDead(uid, deathSource, zone.Display)
killed = append(killed, uid) killed = append(killed, uid)

View File

@@ -13,6 +13,7 @@ import (
"gogobee/internal/bot" "gogobee/internal/bot"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/dreamclient" "gogobee/internal/dreamclient"
"gogobee/internal/peteclient"
"gogobee/internal/plugin" "gogobee/internal/plugin"
"gogobee/internal/util" "gogobee/internal/util"
"gogobee/internal/version" "gogobee/internal/version"
@@ -55,6 +56,11 @@ func main() {
} }
db.RecordStartup(version.Version, version.Commit) db.RecordStartup(version.Version, version.Commit)
// Pete adventure-news seam: wire the outbound fact client from the
// environment (no-op unless FEATURE_PETE_NEWS=true). The background sender
// is started once the run context exists (below).
peteclient.Init()
// Create Matrix session. AUTH_MODE selects the transport: // Create Matrix session. AUTH_MODE selects the transport:
// masdevice (default) — MAS OAuth device grant over /sync. // masdevice (default) — MAS OAuth device grant over /sync.
// appservice — as_token auth over Synapse transaction push. // appservice — as_token auth over Synapse transaction push.
@@ -97,6 +103,9 @@ func main() {
cancel() cancel()
}() }()
// Drain the Pete adventure-news emit queue in the background until shutdown.
peteclient.StartSender(ctx)
// Show the bot online from boot (appservice mode; no-op under masdevice, where // Show the bot online from boot (appservice mode; no-op under masdevice, where
// /sync refreshes presence implicitly). Safe before the listener — outbound only. // /sync refreshes presence implicitly). Safe before the listener — outbound only.
sess.StartPresence(ctx) sess.StartPresence(ctx)