mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e6937c0c | ||
|
|
ae5e10435a | ||
|
|
77dde5d133 | ||
|
|
583616f9d0 | ||
|
|
3f9c338e67 | ||
|
|
afa49b2d4c | ||
|
|
c8878b095c | ||
|
|
96f2125944 | ||
|
|
10d1e4adf5 | ||
|
|
ba41dc2d1b | ||
|
|
34519c9145 | ||
|
|
509df7fadf | ||
|
|
c327cfc5f1 | ||
|
|
a27fb298af | ||
|
|
e5d4bd6dc5 | ||
|
|
f73ab56ac8 | ||
|
|
fff2aa79d0 | ||
|
|
d6136d39d9 | ||
|
|
7a5c8341f0 | ||
|
|
a5b1961486 | ||
|
|
0570afc2e4 | ||
|
|
2ce3e682ea | ||
|
|
6bcac41aa2 | ||
|
|
71b97763ce | ||
|
|
690ff758fe | ||
|
|
ca2d1a8ea3 | ||
|
|
5a8d21f780 | ||
|
|
68c8cdff2d | ||
|
|
b29dcf4360 | ||
|
|
1f62a8e842 |
@@ -65,9 +65,19 @@ func main() {
|
|||||||
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.")
|
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()")
|
||||||
|
|
||||||
|
seed = flag.Int64("seed", -1, "single-run mode — deterministic seed for zone layout + run id + combat sessions (peripheral procs stay random). <0 = off (default). Matrix mode passes this to each subprocess automatically; use -base-seed there.")
|
||||||
|
baseSeed = flag.Int64("base-seed", -1, "matrix mode — deterministic base seed. Each cell's subprocess gets seed=mix(base,level,zone,rep) (class-independent, so every class faces identical dungeons + dice). <0 = off (default, time-seeded).")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
// Deterministic seeding for reproducible A/B tuning. Off unless -seed >= 0
|
||||||
|
// (the matrix parent sets it per-subprocess from -base-seed). Prod never
|
||||||
|
// calls SeedSim, so this is inert outside the sim.
|
||||||
|
if *seed >= 0 {
|
||||||
|
plugin.SeedSim(*seed)
|
||||||
|
}
|
||||||
|
|
||||||
if *petLevel < 0 || *petLevel > 10 {
|
if *petLevel < 0 || *petLevel > 10 {
|
||||||
fail("pet-level must be 0-10, got", *petLevel)
|
fail("pet-level must be 0-10, got", *petLevel)
|
||||||
}
|
}
|
||||||
@@ -89,7 +99,7 @@ func main() {
|
|||||||
includeLog = *logFlag
|
includeLog = *logFlag
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
|
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion, *baseSeed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +210,18 @@ 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, companion string) {
|
// mixSeed derives a per-cell subprocess seed from the base seed and the cell's
|
||||||
|
// (level, zone, rep) — deliberately class-independent, so every class runs the
|
||||||
|
// identical dungeon + combat dice at a given cell and A/B class deltas pair.
|
||||||
|
func mixSeed(base int64, level int, zone string, rep int) int64 {
|
||||||
|
h := uint64(1469598103934665603) // FNV-1a offset basis
|
||||||
|
for _, c := range fmt.Sprintf("%d|%s|%d", level, zone, rep) {
|
||||||
|
h = (h ^ uint64(c)) * 1099511628211
|
||||||
|
}
|
||||||
|
return int64((uint64(base) ^ h) &^ (uint64(1) << 63)) // non-negative
|
||||||
|
}
|
||||||
|
|
||||||
|
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, baseSeed int64) {
|
||||||
cs := splitNonEmpty(classes)
|
cs := splitNonEmpty(classes)
|
||||||
ls := parseLevels(levels)
|
ls := parseLevels(levels)
|
||||||
zs := splitNonEmpty(zones)
|
zs := splitNonEmpty(zones)
|
||||||
@@ -231,7 +252,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, companion)
|
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion, baseSeed)
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
for _, j := range work {
|
for _, j := range work {
|
||||||
@@ -250,7 +271,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, companion 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, baseSeed int64) {
|
||||||
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)
|
||||||
@@ -272,6 +293,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
|||||||
fmt.Sprintf("-pet-level=%d", petLevel),
|
fmt.Sprintf("-pet-level=%d", petLevel),
|
||||||
fmt.Sprintf("-party=%d", party),
|
fmt.Sprintf("-party=%d", party),
|
||||||
}
|
}
|
||||||
|
if baseSeed >= 0 {
|
||||||
|
args = append(args, "-seed", strconv.FormatInt(mixSeed(baseSeed, j.level, j.zone, j.rep), 10))
|
||||||
|
}
|
||||||
// Left empty, each cell's followers clone that cell's own -class.
|
// Left empty, each cell's followers clone that cell's own -class.
|
||||||
if partyClasses != "" {
|
if partyClasses != "" {
|
||||||
args = append(args, "-party-classes", partyClasses)
|
args = append(args, "-party-classes", partyClasses)
|
||||||
|
|||||||
@@ -700,6 +700,11 @@ func RunMaintenance() {
|
|||||||
// weight the drain query already skips — drop it so a durable outage
|
// weight the drain query already skips — drop it so a durable outage
|
||||||
// can't accrete rows forever.
|
// can't accrete rows forever.
|
||||||
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
||||||
|
// Run beats — the local copy is a delivery buffer, not an archive. Pete
|
||||||
|
// keeps the run report; once a beat is a week old it has either shipped
|
||||||
|
// or missed its window entirely (the liveblog it feeds is about a run
|
||||||
|
// happening *now*), so both states reap on the same clock.
|
||||||
|
{"pete_run_beat", `DELETE FROM pete_run_beat WHERE occurred_at < ?`, []interface{}{cutoff7d}},
|
||||||
|
|
||||||
// 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}},
|
||||||
@@ -1045,6 +1050,24 @@ CREATE TABLE IF NOT EXISTS pete_emit_queue (
|
|||||||
sent_at INTEGER
|
sent_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Run beats: the room-by-room texture of an expedition, on its way to Pete's
|
||||||
|
-- liveblog. Deliberately NOT pete_emit_queue — these are high-volume and
|
||||||
|
-- low-stakes, and a run that generates forty beats must never be able to crowd
|
||||||
|
-- a death dispatch out of the retry budget. Ordering is the whole contract:
|
||||||
|
-- (run_id, seq) is the primary key and Pete is idempotent on the pair, so a
|
||||||
|
-- re-sent batch collapses and a re-ordered one still sorts right on arrival.
|
||||||
|
CREATE TABLE IF NOT EXISTS pete_run_beat (
|
||||||
|
run_id TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
occurred_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
sent_at INTEGER,
|
||||||
|
PRIMARY KEY (run_id, seq)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pete_run_beat_unsent
|
||||||
|
ON pete_run_beat(sent_at, run_id, seq);
|
||||||
|
|
||||||
-- Players who opted out of being named in Pete's adventure news. Enforced at
|
-- Players who opted out of being named in Pete's adventure news. Enforced at
|
||||||
-- emit time (anonymize, never delete). Mirrors shade_optout.
|
-- emit time (anonymize, never delete). Mirrors shade_optout.
|
||||||
CREATE TABLE IF NOT EXISTS news_optout (
|
CREATE TABLE IF NOT EXISTS news_optout (
|
||||||
@@ -1232,6 +1255,16 @@ CREATE TABLE IF NOT EXISTS presence (
|
|||||||
updated_at INTEGER DEFAULT (unixepoch())
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- DM rooms. The bot's m.direct account data is not a reliable store for an
|
||||||
|
-- appservice user (no /sync, and nothing writes it back), so the mapping lives
|
||||||
|
-- here. Without it every restart lost the cache and the bot created a fresh DM
|
||||||
|
-- room per user.
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_rooms (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
-- Markov
|
-- Markov
|
||||||
CREATE TABLE IF NOT EXISTS markov_corpus (
|
CREATE TABLE IF NOT EXISTS markov_corpus (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -1822,6 +1855,20 @@ CREATE TABLE IF NOT EXISTS equip_applied_orders (
|
|||||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- The web ACTION queue's idempotency ledger — the same job as the table above,
|
||||||
|
-- for the verbs that play the game rather than dress the character (extract, a
|
||||||
|
-- Siege bout). Its own table because the two queues have their own guid spaces
|
||||||
|
-- and their own poll loops, and a shared ledger would make a bug in one able to
|
||||||
|
-- silence the other. The stakes are higher here than for equip: a replayed
|
||||||
|
-- extraction ends a run the player resumed, and a replayed bout spends a day the
|
||||||
|
-- player has not been given back.
|
||||||
|
CREATE TABLE IF NOT EXISTS adv_applied_orders (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL, -- the terminal verdict we filed, replayed on re-offer
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
-- Babysitting Service
|
-- Babysitting Service
|
||||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ var AmbientMonologue = []string{
|
|||||||
"The map in your pack has folded itself one extra time while you weren't looking. I decline to unfold it for science.",
|
"The map in your pack has folded itself one extra time while you weren't looking. I decline to unfold it for science.",
|
||||||
"Your own footsteps echo back to you a half-second late, in slightly the wrong order. Noted. I'm choosing to walk in silence for a bit.",
|
"Your own footsteps echo back to you a half-second late, in slightly the wrong order. Noted. I'm choosing to walk in silence for a bit.",
|
||||||
"A long, slow scrape happens two rooms over. Then nothing. I waited for a second scrape. There wasn't one. The first scrape was the whole sentence.",
|
"A long, slow scrape happens two rooms over. Then nothing. I waited for a second scrape. There wasn't one. The first scrape was the whole sentence.",
|
||||||
|
"I organized the ration wrappers by crinkle volume while you slept. There are four tiers. I will not be presenting the findings, but the findings exist.",
|
||||||
|
"A moth has been circling the torch for an hour with total commitment and no plan. I relate to it more than I intend to say out loud.",
|
||||||
|
"I counted your snores and cross-referenced them against the dungeon's ambient groans. Twice they harmonized. I have no notes. It was lovely.",
|
||||||
|
"A snail is crossing the camp. At current pace it reaches your boot by morning. I have elected not to interfere with its schedule and expect you to extend it the same courtesy.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -48,6 +52,7 @@ var AmbientNoise = []string{
|
|||||||
"Stones shift overhead in a way that is not settling and not random. Something walked across the ceiling. I'm choosing not to elaborate on what 'across the ceiling' implies.",
|
"Stones shift overhead in a way that is not settling and not random. Something walked across the ceiling. I'm choosing not to elaborate on what 'across the ceiling' implies.",
|
||||||
"A horn sounds, very far away — the kind of horn that's a signal to a thing that signals to more things. I acknowledge the chain.",
|
"A horn sounds, very far away — the kind of horn that's a signal to a thing that signals to more things. I acknowledge the chain.",
|
||||||
"You hear someone whistling your name. You don't have that name. The whistler is workshopping options.",
|
"You hear someone whistling your name. You don't have that name. The whistler is workshopping options.",
|
||||||
|
"Somewhere below, something counted to four and stopped. The counting has not resumed. I preferred it when it was counting.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -74,6 +79,7 @@ var AmbientLuckyFind = []string{
|
|||||||
"You step on something flat. It is a coin. It is also two more coins under the first coin. I suspect a coin-laying creature and choose not to share the theory.",
|
"You step on something flat. It is a coin. It is also two more coins under the first coin. I suspect a coin-laying creature and choose not to share the theory.",
|
||||||
"A skeleton you walked past three days ago has, on review, a small purse you missed. I retrieve it with the discretion of a librarian recovering an overdue book.",
|
"A skeleton you walked past three days ago has, on review, a small purse you missed. I retrieve it with the discretion of a librarian recovering an overdue book.",
|
||||||
"You find coins in the lining of your own cloak. They were always there. I gently suggest counting your pockets more often.",
|
"You find coins in the lining of your own cloak. They were always there. I gently suggest counting your pockets more often.",
|
||||||
|
"There is a coin at the bottom of the waterskin. I have questions, beginning with how long you have been drinking past it.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ var ExpeditionStart = []string{
|
|||||||
"An expedition. Not a run — an expedition. There's a difference. I'll explain the difference over the coming days and the explanation will be mostly experiential.",
|
"An expedition. Not a run — an expedition. There's a difference. I'll explain the difference over the coming days and the explanation will be mostly experiential.",
|
||||||
"Horizon checked, then supplies, then you. In that order. 'Alright,' I say, with the quiet energy of something that has been looking forward to this. 'Let's go.'",
|
"Horizon checked, then supplies, then you. In that order. 'Alright,' I say, with the quiet energy of something that has been looking forward to this. 'Let's go.'",
|
||||||
"You're not here for a quick visit. I know the difference between someone passing through and someone committing. You're committing. I appreciate the commitment.",
|
"You're not here for a quick visit. I know the difference between someone passing through and someone committing. You're committing. I appreciate the commitment.",
|
||||||
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
|
"Like starting Dragon Quest VII. Two hours of errands before the game permits a single fight, and the people who love it love it for exactly that. This is going to take a while. I have found a comfortable position. I suggest you do the same.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -49,7 +49,7 @@ var ExpeditionBoredomStart = []string{
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var MorningBriefingGeneric = []string{
|
var MorningBriefingGeneric = []string{
|
||||||
"Another day in the dungeon. Said without resignation. The dungeon is still full of things worth doing and you are still the person to do them.",
|
"Another day in the dungeon. Morning count: you, me, the rations, one sword, and zero regrets logged before breakfast. Regrets logged before breakfast are the only ones that stick. Clean sheet. Out we go.",
|
||||||
"Morning. The dungeon has been quiet since you camped. Relative to what a dungeon considers quiet, which is not what you'd consider quiet, but everyone adjusts.",
|
"Morning. The dungeon has been quiet since you camped. Relative to what a dungeon considers quiet, which is not what you'd consider quiet, but everyone adjusts.",
|
||||||
"I've been watching the entrance to the camp since approximately three in the morning. Nothing came. Mentioned casually; no particular reaction expected.",
|
"I've been watching the entrance to the camp since approximately three in the morning. Nothing came. Mentioned casually; no particular reaction expected.",
|
||||||
"Day [N]. The numbers are climbing. There's something satisfying about the numbers climbing — it means you're still here, which is always the first thing to confirm.",
|
"Day [N]. The numbers are climbing. There's something satisfying about the numbers climbing — it means you're still here, which is always the first thing to confirm.",
|
||||||
@@ -89,7 +89,7 @@ var MorningBriefingDay14 = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var MorningBriefingDay21 = []string{
|
var MorningBriefingDay21 = []string{
|
||||||
"Three weeks. I've run out of historical comparisons for this. Three weeks is its own category. You have made a category. I report this as a fact and also as something that doesn't entirely have words yet.",
|
"Three weeks. The record book keeps a page for runs past twenty days. The page has three entries. One is you. One is a dwarf named Hensel. The third entry is water-damaged, and I have chosen to believe it also says Hensel.",
|
||||||
"Day twenty-one. I tried to write a clever framing for this morning's briefing and gave up halfway through, settling instead on the simplest version: 'You're still here.' That's the briefing. The rest is logistics.",
|
"Day twenty-one. I tried to write a clever framing for this morning's briefing and gave up halfway through, settling instead on the simplest version: 'You're still here.' That's the briefing. The rest is logistics.",
|
||||||
"Three weeks down. The dungeon has stopped being a place you're visiting and become a place you live in for now. I note the shift — the way you check rooms without being asked, the way the supply count is already in your head. The dungeon notices too.",
|
"Three weeks down. The dungeon has stopped being a place you're visiting and become a place you live in for now. I note the shift — the way you check rooms without being asked, the way the supply count is already in your head. The dungeon notices too.",
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ var MorningBriefingDay21 = []string{
|
|||||||
|
|
||||||
var EveningRecapGeneric = []string{
|
var EveningRecapGeneric = []string{
|
||||||
"End of day [N]. Ledger tallied. The column marked 'survived' has another entry. I consider this column the most important one.",
|
"End of day [N]. Ledger tallied. The column marked 'survived' has another entry. I consider this column the most important one.",
|
||||||
"Day closes. I review what happened and find, on balance, more right than wrong — which in a dungeon is the operating definition of a good day.",
|
"Day closes. The ledger says: three rooms, one fight you picked, one fight that picked you, and a door you had the sense to leave shut. I have audited worse days. I have audited far worse doors.",
|
||||||
"Evening. The rooms behind you are cleared. The rooms ahead are not. Always true; never less relevant. Rest now. The math doesn't change overnight.",
|
"Evening. The rooms behind you are cleared. The rooms ahead are not. Always true; never less relevant. Rest now. The math doesn't change overnight.",
|
||||||
"I compile the day: what was learned, what was fought, what was found. File it in the mental ledger I've been keeping since you entered. The ledger is favorable.",
|
"I compile the day: what was learned, what was fought, what was found. File it in the mental ledger I've been keeping since you entered. The ledger is favorable.",
|
||||||
"Like the experience screen at the end of a dungeon floor in Etrian Odyssey — the numbers settle, the progress registers, and for a moment the whole thing makes sense. I give you that moment.",
|
"Like the experience screen at the end of a dungeon floor in Etrian Odyssey — the numbers settle, the progress registers, and for a moment the whole thing makes sense. I give you that moment.",
|
||||||
@@ -137,7 +137,7 @@ var EveningRecapNothingHappened = []string{
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var CampEstablished = []string{
|
var CampEstablished = []string{
|
||||||
"Camp established. I survey the perimeter with the efficiency of someone who has done this many times and learned from every time it went wrong.",
|
"Camp established. I walk the perimeter once for threats and once for acoustics, because a camp that echoes is a camp that advertises. This one holds its sound. Approved.",
|
||||||
"The camp goes up. I approve of the location — cleared room, defensible entry, no obvious curse residue. Could be worse. I've seen worse.",
|
"The camp goes up. I approve of the location — cleared room, defensible entry, no obvious curse residue. Could be worse. I've seen worse.",
|
||||||
"You set camp. I check the sightlines, the doors, the sound-bleed from the next room. Acceptable. Settling in for the night watch.",
|
"You set camp. I check the sightlines, the doors, the sound-bleed from the next room. Acceptable. Settling in for the night watch.",
|
||||||
"A camp in the middle of a dungeon. Either brave or pragmatic; I've stopped trying to distinguish between the two. Either way, the camp is set. Either way, I'm watching.",
|
"A camp in the middle of a dungeon. Either brave or pragmatic; I've stopped trying to distinguish between the two. Either way, the camp is set. Either way, I'm watching.",
|
||||||
@@ -312,7 +312,7 @@ var RegionTransitDeparture = []string{
|
|||||||
|
|
||||||
var RegionTransitArrival = []string{
|
var RegionTransitArrival = []string{
|
||||||
"You arrive in [REGION_NEXT]. I survey, take in the new geometry, and update the working assumptions. 'Different shape,' I say. 'Same general principle. We learn what wants to kill us here, and we get there first.'",
|
"You arrive in [REGION_NEXT]. I survey, take in the new geometry, and update the working assumptions. 'Different shape,' I say. 'Same general principle. We learn what wants to kill us here, and we get there first.'",
|
||||||
"[REGION_NEXT] receives you. I note the temperature, the sound, the things-not-said-by-the-room-but-implied. A region is not just a place. It's a posture. I adopt the new one and suggest you do as well.",
|
"[REGION_NEXT] receives you. I catalogue the differences: colder by about one coat, quieter by exactly one birdsong, and the dust on this side of the boundary shows a single set of tracks. The tracks are leaving. Noted. Adopting the local caution.",
|
||||||
"Boundary crossed. The day gets stamped in the log — one full day spent in transit, supplies adjusted, the wandering that happened on the way handled and filed. We are here now. The next stretch is what it is.",
|
"Boundary crossed. The day gets stamped in the log — one full day spent in transit, supplies adjusted, the wandering that happened on the way handled and filed. We are here now. The next stretch is what it is.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ var RegionTransitArrival = []string{
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var ExtractionVoluntary = []string{
|
var ExtractionVoluntary = []string{
|
||||||
"Extraction. I note the decision and respect it — knowing when to leave is a skill, not a failure, and I've watched enough expeditions end wrong to deeply appreciate the ones that end right.",
|
"Extraction. For the record, the ledger keeps two columns for expeditions that reached the door: 'left' and 'was left of.' You are in the first column. The first column is the good column.",
|
||||||
"You call the extraction and I begin the route out immediately. No argument, no editorializing. There will be time for the debrief later. The first priority is the door.",
|
"You call the extraction and I begin the route out immediately. No argument, no editorializing. There will be time for the debrief later. The first priority is the door.",
|
||||||
"The dungeon doesn't like this. I can tell by the way the corridors feel as you head back out — a resistance that isn't structural, just atmospheric. The zone wanted more. It doesn't get more today. I lead the way.",
|
"The dungeon doesn't like this. I can tell by the way the corridors feel as you head back out — a resistance that isn't structural, just atmospheric. The zone wanted more. It doesn't get more today. I lead the way.",
|
||||||
"Withdrawing with intent. I catalogue what you have — the loot, the XP, the knowledge of where the rooms are for the return — and convert the exit into preparation. This isn't retreat. This is the start of the next attempt.",
|
"Withdrawing with intent. I catalogue what you have — the loot, the XP, the knowledge of where the rooms are for the return — and convert the exit into preparation. This isn't retreat. This is the start of the next attempt.",
|
||||||
@@ -353,7 +353,7 @@ var ExpeditionResume = []string{
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var MilestoneFirstNight = []string{
|
var MilestoneFirstNight = []string{
|
||||||
"You survived the first night. I note this milestone specifically because not everyone does, and those who do carry something from it that changes how the rest of the expedition goes. You have that now. Already noticed.",
|
"You survived the first night. Somewhere around the third watch you rolled over, said a word I will not be repeating back to you, and slept on. The dungeon spent that same hour deciding you were not worth waking. Both of you were right.",
|
||||||
"Night one survived. I make a small mark in the corner of the manifest — the kind of mark you make for the things that count more than they look. First nights count. I've been in dungeons where they were the last nights too. This wasn't one of those.",
|
"Night one survived. I make a small mark in the corner of the manifest — the kind of mark you make for the things that count more than they look. First nights count. I've been in dungeons where they were the last nights too. This wasn't one of those.",
|
||||||
"Day two morning. The first night is behind you, which means the first watch is behind me, which means a thing worth confirming has been confirmed: you sleep through the noises that matter and wake for the ones that don't. That's a survival skill. Logged.",
|
"Day two morning. The first night is behind you, which means the first watch is behind me, which means a thing worth confirming has been confirmed: you sleep through the noises that matter and wake for the ones that don't. That's a survival skill. Logged.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ var RoomEntryGeneric = []string{
|
|||||||
"The room is quiet. I appreciate quiet. Quiet means the enemies haven't spotted you yet. Yet.",
|
"The room is quiet. I appreciate quiet. Quiet means the enemies haven't spotted you yet. Yet.",
|
||||||
"Forward. Always forward. I once tried going backward in a dungeon. It looped. This one might too.",
|
"Forward. Always forward. I once tried going backward in a dungeon. It looped. This one might too.",
|
||||||
"Stock of the situation: ceiling intact, floor suspicious, walls leaning in slightly. Proceed.",
|
"Stock of the situation: ceiling intact, floor suspicious, walls leaning in slightly. Proceed.",
|
||||||
"You've cleared the room. I give a small, dignified nod. 'One continues,' I say, in the voice of someone who has seen this before and am choosing optimism anyway.",
|
"You've cleared the room. I give a small, dignified nod. 'One continues,' I say, in the voice of someone who has seen this before and is choosing optimism anyway.",
|
||||||
"The corridor ahead is long and straight. I find long straight corridors meditative. Also concerning. Mostly concerning.",
|
"The corridor ahead is long and straight. I find long straight corridors meditative. Also concerning. Mostly concerning.",
|
||||||
"A torch sputters on the wall. I light it mentally. 'It would be a shame,' I say, 'to come all this way and trip over something.'",
|
"A torch sputters on the wall. I light it mentally. 'It would be a shame,' I say, 'to come all this way and trip over something.'",
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ var CombatStart = []string{
|
|||||||
"They've seen you. The kind of seeing that comes with intent. I suggest acting first.",
|
"They've seen you. The kind of seeing that comes with intent. I suggest acting first.",
|
||||||
"FIGHT. I don't need to say more than that but I will absolutely say more than that.",
|
"FIGHT. I don't need to say more than that but I will absolutely say more than that.",
|
||||||
"Roll for initiative. This is the part I've been looking forward to since the Entry Room.",
|
"Roll for initiative. This is the part I've been looking forward to since the Entry Room.",
|
||||||
"And we're in combat. I remind you to breathe, track your conditions, and remember that your character's survival is not guaranteed but am definitely preferred.",
|
"And we're in combat. I remind you to breathe, track your conditions, and remember that your character's survival is not guaranteed but is definitely preferred.",
|
||||||
"Something about your posture or your smell or your general presence has been found unacceptable. Combat begins.",
|
"Something about your posture or your smell or your general presence has been found unacceptable. Combat begins.",
|
||||||
"I press start. Player one, it's your turn.",
|
"I press start. Player one, it's your turn.",
|
||||||
"The enemy acts first — or thinks it does. I watch your dice like they're the only thing in the room, which, right now, they are.",
|
"The enemy acts first — or thinks it does. I watch your dice like they're the only thing in the room, which, right now, they are.",
|
||||||
@@ -133,6 +133,7 @@ var CombatStart = []string{
|
|||||||
"A wild encounter has appeared. I resist the urge to play the Pokémon battle music. Only barely.",
|
"A wild encounter has appeared. I resist the urge to play the Pokémon battle music. Only barely.",
|
||||||
"They didn't want a fight. They wanted an easy meal. I'm about to demonstrate the difference. Your dice will do the actual demonstrating.",
|
"They didn't want a fight. They wanted an easy meal. I'm about to demonstrate the difference. Your dice will do the actual demonstrating.",
|
||||||
"The tension peaks. Time slows. This is exactly the energy of the boss door opening in Mega Man. Except you didn't get to pick your loadout.",
|
"The tension peaks. Time slows. This is exactly the energy of the boss door opening in Mega Man. Except you didn't get to pick your loadout.",
|
||||||
|
"A kerfuffle, then. I had this logged as a fracas, but they drew weapons, and weapons upgrade a fracas to a kerfuffle. Anything beyond this point is a melee, which is the technical term.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -141,7 +142,7 @@ var CombatStart = []string{
|
|||||||
|
|
||||||
var CombatVictory = []string{
|
var CombatVictory = []string{
|
||||||
"The last one drops. I allow a moment of silence for anyone who wanted a longer fight.",
|
"The last one drops. I allow a moment of silence for anyone who wanted a longer fight.",
|
||||||
"Victory. I would cue the jingle — the little three-note one that plays in every RPG after every fight — but I prefer to let the moment breathe.",
|
"Victory. I would cue the fanfare, the Final Fantasy one that plays even when you won by a single hit point while poisoned and on fire. Especially then. But I prefer to let the moment breathe.",
|
||||||
"Well fought. I make note of what you did well. There were things done well. I noticed.",
|
"Well fought. I make note of what you did well. There were things done well. I noticed.",
|
||||||
"They are defeated. You are not. In my experience, this is the correct outcome and worth a moment of genuine appreciation.",
|
"They are defeated. You are not. In my experience, this is the correct outcome and worth a moment of genuine appreciation.",
|
||||||
"PLAYER WIN. I say this in full caps and mean it.",
|
"PLAYER WIN. I say this in full caps and mean it.",
|
||||||
@@ -166,6 +167,7 @@ var CombatRetreat = []string{
|
|||||||
"Noted for the record: running is not losing. Running is data collection with legs.",
|
"Noted for the record: running is not losing. Running is data collection with legs.",
|
||||||
"The dungeon will be there. You will also be there — later, better prepared. I approve of this logic.",
|
"The dungeon will be there. You will also be there — later, better prepared. I approve of this logic.",
|
||||||
"You've retreated to safety. I reset the encounter. Rest. Think. Return with a plan that has more 'survive' in it.",
|
"You've retreated to safety. I reset the encounter. Rest. Think. Return with a plan that has more 'survive' in it.",
|
||||||
|
"Skedaddle is the technical term and I will hear no other. Executed cleanly, all limbs accounted for. The ledger records a skedaddle of the highest order.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -206,6 +208,7 @@ var Nat1 = []string{
|
|||||||
"The attack misses in a way that will be funny later. I promise it will be funny later. It is not funny right now.",
|
"The attack misses in a way that will be funny later. I promise it will be funny later. It is not funny right now.",
|
||||||
"A natural one is just the universe asking you to try differently. I'm an optimist about natural ones, mostly.",
|
"A natural one is just the universe asking you to try differently. I'm an optimist about natural ones, mostly.",
|
||||||
"Your sword finds everything in the room except the enemy. The wall, the ceiling, the floor, your dignity. Not the enemy. I'll mention this once and then never again.",
|
"Your sword finds everything in the room except the enemy. The wall, the ceiling, the floor, your dignity. Not the enemy. I'll mention this once and then never again.",
|
||||||
|
"One. The die rolled off the table, hit a boot, and came back up one, as if it left to think it over and returned with conviction.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -329,7 +332,7 @@ var TrapTriggered = []string{
|
|||||||
var LoreLines = []string{
|
var LoreLines = []string{
|
||||||
"I settle in and prepare to speak at length, because I've been waiting for this question since you entered and I have a lot of thoughts.",
|
"I settle in and prepare to speak at length, because I've been waiting for this question since you entered and I have a lot of thoughts.",
|
||||||
"Ah. A good question. I have context for this. I have more context than will fit comfortably in one telling but I'll try to prioritize.",
|
"Ah. A good question. I have context for this. I have more context than will fit comfortably in one telling but I'll try to prioritize.",
|
||||||
"The history of this place is long and not entirely flattering to anyone involved. I begin at the beginning, which is not actually the beginning, but am the closest I can find.",
|
"The history of this place is long and not entirely flattering to anyone involved. I begin at the beginning, which is not actually the beginning, but is the closest I can find.",
|
||||||
"I consult what I know — which is more than most, less than everything, and presented in order of relevance to your immediate survival.",
|
"I consult what I know — which is more than most, less than everything, and presented in order of relevance to your immediate survival.",
|
||||||
"Sit with this for a moment. What you're standing in has a story and I believe knowing it will change how you fight in it. Stories are tactical documents if you read them right.",
|
"Sit with this for a moment. What you're standing in has a story and I believe knowing it will change how you fight in it. Stories are tactical documents if you read them right.",
|
||||||
"You want lore? I have lore. I have so much lore that the challenge is not having it but choosing which pieces are useful and which are just fascinating.",
|
"You want lore? I have lore. I have so much lore that the challenge is not having it but choosing which pieces are useful and which are just fascinating.",
|
||||||
@@ -358,7 +361,7 @@ var ItemFound = []string{
|
|||||||
"Something catches the light that isn't supposed to be here. I watch you reach for it with the specific alertness of someone who has seen cursed items do cursed things. It appears fine. I relax incrementally.",
|
"Something catches the light that isn't supposed to be here. I watch you reach for it with the specific alertness of someone who has seen cursed items do cursed things. It appears fine. I relax incrementally.",
|
||||||
"Loot. I say this word with genuine reverence. The whole system — the dungeon, the enemies, the traps — exists in part to produce this moment. I think it's worth it.",
|
"Loot. I say this word with genuine reverence. The whole system — the dungeon, the enemies, the traps — exists in part to produce this moment. I think it's worth it.",
|
||||||
"A chest. Unlocked. I note the unlocked status and consider what that might mean. Probably nothing. Possibly something. You open it while I consider.",
|
"A chest. Unlocked. I note the unlocked status and consider what that might mean. Probably nothing. Possibly something. You open it while I consider.",
|
||||||
"The item is good. I evaluate it quickly — the stats, the rarity, the class match — and nod with the confidence of someone who has seen a lot of items and know when one is worth finding.",
|
"The item is good. I evaluate it quickly — the stats, the rarity, the class match — and nod with the confidence of someone who has seen a lot of items and knows when one is worth finding.",
|
||||||
"That's a rare one. I've seen fewer of those than common ones, by definition, but that doesn't stop me from being specifically pleased each time.",
|
"That's a rare one. I've seen fewer of those than common ones, by definition, but that doesn't stop me from being specifically pleased each time.",
|
||||||
"Like finding the Beam Sword in Kirby, the Boomerang in Zelda, the P Wing in Super Mario 3 — the right item at the right time changes what's possible. I think this might be that item. I hope it is.",
|
"Like finding the Beam Sword in Kirby, the Boomerang in Zelda, the P Wing in Super Mario 3 — the right item at the right time changes what's possible. I think this might be that item. I hope it is.",
|
||||||
"Equipment upgrade. I watch the math update — new AC, new attack bonus, new possibilities — and file this moment under 'things going right.'",
|
"Equipment upgrade. I watch the math update — new AC, new attack bonus, new possibilities — and file this moment under 'things going right.'",
|
||||||
@@ -402,6 +405,7 @@ var TauntResponses = []string{
|
|||||||
"You taunt me. I smile. The smile does not reach the eyes, because I don't have eyes per se, but the quality of the smile communicates clearly. 'Proceed,' I say.",
|
"You taunt me. I smile. The smile does not reach the eyes, because I don't have eyes per se, but the quality of the smile communicates clearly. 'Proceed,' I say.",
|
||||||
"In Gradius, you could powerup into overconfidence and lose everything in one hit. I mention this as a purely historical observation.",
|
"In Gradius, you could powerup into overconfidence and lose everything in one hit. I mention this as a purely historical observation.",
|
||||||
"I accept the taunt with grace. Also generate a trap for the next room with specific energy. These two events are unrelated. I maintain this position legally.",
|
"I accept the taunt with grace. Also generate a trap for the next room with specific energy. These two events are unrelated. I maintain this position legally.",
|
||||||
|
"Hornswoggled. By you. I write the word in the ledger and it looks ridiculous there, which I suspect was the plan all along.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -430,6 +434,7 @@ var IdleLines = []string{
|
|||||||
"The enemies are patient. Patience is one of their few virtues. I advise not testing the limits of their patience because those limits are lower than the patience suggests.",
|
"The enemies are patient. Patience is one of their few virtues. I advise not testing the limits of their patience because those limits are lower than the patience suggests.",
|
||||||
"I hum something that sounds like the waiting music from Dr. Mario. It is not ominous. It is mildly ominous. I adjust.",
|
"I hum something that sounds like the waiting music from Dr. Mario. It is not ominous. It is mildly ominous. I adjust.",
|
||||||
"The dungeon does not rush. The dungeon has time. I, however, am beginning to wonder if you've fallen asleep and am prepared to narrate events accordingly.",
|
"The dungeon does not rush. The dungeon has time. I, however, am beginning to wonder if you've fallen asleep and am prepared to narrate events accordingly.",
|
||||||
|
"There is a word for this and the word is lollygagging. I don't get many chances to deploy it. Thank you for this one. Now move.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -464,7 +469,7 @@ var ConditionApplied = []string{
|
|||||||
"You've been afflicted. I note the condition, its duration, and the mechanical consequences, then note the saving throw that might end it early. Details matter here.",
|
"You've been afflicted. I note the condition, its duration, and the mechanical consequences, then note the saving throw that might end it early. Details matter here.",
|
||||||
"Something is wrong with you now that wasn't wrong before. I catalog it without judgment and suggest addressing it before it addresses you.",
|
"Something is wrong with you now that wasn't wrong before. I catalog it without judgment and suggest addressing it before it addresses you.",
|
||||||
"Condition acquired. I process this the way a good DM processes bad news: honestly, quickly, and with an immediate pivot toward solutions.",
|
"Condition acquired. I process this the way a good DM processes bad news: honestly, quickly, and with an immediate pivot toward solutions.",
|
||||||
"Like the status screen turning an unfriendly color in a JRPG — the condition is visible, the effect is real, and I would very much like you to resolve it.",
|
"Like your sprite turning that little poison green in Final Fantasy while the walk animation carries on regardless. The condition is visible, the effect is real, and I would very much like you to resolve it.",
|
||||||
"The debuff lands. I name it, explain it, and remind you: conditions end. Keep fighting until this one does.",
|
"The debuff lands. I name it, explain it, and remind you: conditions end. Keep fighting until this one does.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,10 +490,10 @@ var SaveSuccess = []string{
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var SaveFailed = []string{
|
var SaveFailed = []string{
|
||||||
"The save fails. I watch the condition take hold with the resignation of someone who has seen this before and know there's a path through it, just not a comfortable one.",
|
"The save fails. I watch the condition take hold with the resignation of someone who has seen this before and knows there's a path through it, just not a comfortable one.",
|
||||||
"It lands. Whatever the enemy threw at you, the dice didn't cooperate. I note the condition and its duration and suggest dealing with it before it compounds.",
|
"It lands. Whatever the enemy threw at you, the dice didn't cooperate. I note the condition and its duration and suggest dealing with it before it compounds.",
|
||||||
"Failed. The number wasn't enough and I was rooting for the number. The condition applies. Fight through it.",
|
"Failed. The number wasn't enough and I was rooting for the number. The condition applies. Fight through it.",
|
||||||
"Like the NES game over screen — inevitable in this moment, fixable in the next. The save failed. The dungeon continues. So do you.",
|
"Like the bird in Ninja Gaiden that exists solely to shove you into the pit. Inevitable in this moment, fixable in the next. The save failed. The dungeon continues. So do you.",
|
||||||
"The effect takes hold and I'm already calculating how you get out of it, because that's my job: keep you oriented toward solutions even when the immediate situation is a problem.",
|
"The effect takes hold and I'm already calculating how you get out of it, because that's my job: keep you oriented toward solutions even when the immediate situation is a problem.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +507,7 @@ var MoodAsidesHostile = []string{
|
|||||||
"I'm not narrating this one in detail. You can read the room. Read it.",
|
"I'm not narrating this one in detail. You can read the room. Read it.",
|
||||||
"The dungeon offers me something to mention. I decline. You're on your own for color commentary.",
|
"The dungeon offers me something to mention. I decline. You're on your own for color commentary.",
|
||||||
"I'm here. Watching. Not, currently, helping. There is a difference and you will feel it.",
|
"I'm here. Watching. Not, currently, helping. There is a difference and you will feel it.",
|
||||||
"In the bad ending of every Castlevania, the protagonist gets less guidance than they did at the start. I have reached approximately that part of the playthrough.",
|
"Simon's Quest told you 'the morning sun has vanquished the horrible night' and then never said anything useful again. I have entered my Simon's Quest era.",
|
||||||
"I'm keeping several details to myself. The details would have been useful. I don't consider this my problem right now.",
|
"I'm keeping several details to myself. The details would have been useful. I don't consider this my problem right now.",
|
||||||
"Whatever's in the next part of the room, I saw it and chose not to flag it. The mood is what it is.",
|
"Whatever's in the next part of the room, I saw it and chose not to flag it. The mood is what it is.",
|
||||||
"I mutter something. You don't catch it. I do not repeat it.",
|
"I mutter something. You don't catch it. I do not repeat it.",
|
||||||
@@ -520,7 +525,7 @@ var MoodAsidesEffusive = []string{
|
|||||||
"I lean in. The mood is good. Good moods, in my experience, lead to slightly more generous descriptions and slightly better odds of catching the small details.",
|
"I lean in. The mood is good. Good moods, in my experience, lead to slightly more generous descriptions and slightly better odds of catching the small details.",
|
||||||
"This is the part of the run I'll tell other GMs about later. I make a small mental note and continue with visible enthusiasm.",
|
"This is the part of the run I'll tell other GMs about later. I make a small mental note and continue with visible enthusiasm.",
|
||||||
"I'm delighted. You can hear it in the pacing. You can hear it in the choice of adjectives. The dungeon is, briefly, on your side.",
|
"I'm delighted. You can hear it in the pacing. You can hear it in the choice of adjectives. The dungeon is, briefly, on your side.",
|
||||||
"In the good ending of every JRPG, the world feels slightly warmer in the late game. I'm at that part of the playthrough and it shows.",
|
"Like the campfire scene in Chrono Trigger, where the game just lets everyone sit down for one night and nobody dies. I'm at that part of the playthrough and it shows.",
|
||||||
"I'm not normally given to footnotes, but I'm about to add a footnote. It will probably be useful. I'm in that kind of mood.",
|
"I'm not normally given to footnotes, but I'm about to add a footnote. It will probably be useful. I'm in that kind of mood.",
|
||||||
"The mood is high. For the next stretch, I'm more likely to mention the loose flagstone, the suspicious tapestry, the thing on the ceiling. Take advantage.",
|
"The mood is high. For the next stretch, I'm more likely to mention the loose flagstone, the suspicious tapestry, the thing on the ceiling. Take advantage.",
|
||||||
"I hum a victory fanfare softly to myself. It is not earned yet. I'm being optimistic on your behalf.",
|
"I hum a victory fanfare softly to myself. It is not earned yet. I'm being optimistic on your behalf.",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ var ThomKrookeMortgageRate = []string{
|
|||||||
"Good morning, friends! The ARM rate this week is {rate}% — and with Thom Krooke's modest service margin, your mortgage rate sits at {effective}%. All payments process Sunday. Thank you for your continued trust!",
|
"Good morning, friends! The ARM rate this week is {rate}% — and with Thom Krooke's modest service margin, your mortgage rate sits at {effective}%. All payments process Sunday. Thank you for your continued trust!",
|
||||||
"Weekly rate update! FRED reports {rate}% this week, so your effective rate with Thom Krooke is {effective}%. Nothing to worry about — Thom Krooke monitors these things so you don't have to. Mostly.",
|
"Weekly rate update! FRED reports {rate}% this week, so your effective rate with Thom Krooke is {effective}%. Nothing to worry about — Thom Krooke monitors these things so you don't have to. Mostly.",
|
||||||
"Rate check! The market says {rate}%, Thom Krooke adds a small, reasonable {margin}%, and your total comes to {effective}%. Thom Krooke appreciates your understanding of the margin. It keeps the lights on. Literally!",
|
"Rate check! The market says {rate}%, Thom Krooke adds a small, reasonable {margin}%, and your total comes to {effective}%. Thom Krooke appreciates your understanding of the margin. It keeps the lights on. Literally!",
|
||||||
|
"Rate news! The market did something this week that the newspapers describe with an arrow. The result for you is {effective}%. Thom Krooke does not fully understand the arrow either, but the arrow is binding. Budget however you must. The pet's portion is not part of 'however.'",
|
||||||
}
|
}
|
||||||
|
|
||||||
var ThomKrookeMortgageRateUp = []string{
|
var ThomKrookeMortgageRateUp = []string{
|
||||||
@@ -127,6 +128,7 @@ var PastelNoteLevel1 = []string{
|
|||||||
"Good day! The herb garden got some attention, the pets were walked (or equivalent — the fish were observed), and I collected the income. I accidentally shelved three items in the wrong slots but found them eventually. Everything is where it should be. Mostly. The weapons rack might be slightly reorganized.",
|
"Good day! The herb garden got some attention, the pets were walked (or equivalent — the fish were observed), and I collected the income. I accidentally shelved three items in the wrong slots but found them eventually. Everything is where it should be. Mostly. The weapons rack might be slightly reorganized.",
|
||||||
"Note from Pastel: pets fed, garden tended, income collected. I made one small mistake with the supply manifest — added a column that didn't need to be there — but the numbers are right, the column is just extra. Please ignore the extra column.",
|
"Note from Pastel: pets fed, garden tended, income collected. I made one small mistake with the supply manifest — added a column that didn't need to be there — but the numbers are right, the column is just extra. Please ignore the extra column.",
|
||||||
"All tasks completed! Well — most tasks. The greenhouse watering got a little delayed because I was making sure the workshop tools were hung correctly and then it was later than I thought. The plants look fine. Probably fine. I'll check again in the morning.",
|
"All tasks completed! Well — most tasks. The greenhouse watering got a little delayed because I was making sure the workshop tools were hung correctly and then it was later than I thought. The plants look fine. Probably fine. I'll check again in the morning.",
|
||||||
|
"Fed the pets, watered the garden, and spent forty minutes retrieving the trowel from where the small pet had buried it. It was buried with ceremony, judging by the arrangement of pebbles on top. The trowel is back on its hook. I am watching the small pet.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -204,6 +206,7 @@ var PastelPetEvent = []string{
|
|||||||
"Your pet found something in the back of the storage room that I couldn't identify. I've put it on the workshop table. It doesn't seem dangerous. It is definitely something.",
|
"Your pet found something in the back of the storage room that I couldn't identify. I've put it on the workshop table. It doesn't seem dangerous. It is definitely something.",
|
||||||
"One of the pets has been sitting by the expedition outpost since this morning. I think it knows you've been out a long time. Everything is fine. I just thought you'd want to know.",
|
"One of the pets has been sitting by the expedition outpost since this morning. I think it knows you've been out a long time. Everything is fine. I just thought you'd want to know.",
|
||||||
"The pets were restless today — I think they can tell you've been in a Tier 4 zone because they get like this around Day 10. Fed them an extra portion. They settled. They'll be glad to see you.",
|
"The pets were restless today — I think they can tell you've been in a Tier 4 zone because they get like this around Day 10. Fed them an extra portion. They settled. They'll be glad to see you.",
|
||||||
|
"One of the pets discovered its own reflection in the vault door today. Negotiations lasted most of the afternoon. Both parties eventually withdrew with dignity, which I thought was big of them.",
|
||||||
}
|
}
|
||||||
|
|
||||||
var PastelLevelUpNote = []string{
|
var PastelLevelUpNote = []string{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var MistyGreeting = []string{
|
|||||||
"'Don't stand in the doorway,' Misty says, before you've even finished arriving.",
|
"'Don't stand in the doorway,' Misty says, before you've even finished arriving.",
|
||||||
"Misty is already talking before you're settled. This is how it always goes.",
|
"Misty is already talking before you're settled. This is how it always goes.",
|
||||||
"'I wondered when you'd show up.' She says it like she'd actually been counting the days. She had.",
|
"'I wondered when you'd show up.' She says it like she'd actually been counting the days. She had.",
|
||||||
|
"'Boots.' Misty says the one word and points at the mat. The conversation resumes once the boots comply.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -36,6 +37,7 @@ var MistySkillFail = []string{
|
|||||||
"'No,' Misty says, and returns to what she was doing.",
|
"'No,' Misty says, and returns to what she was doing.",
|
||||||
"Misty looks at you like you've asked a question that doesn't deserve an answer. She's not wrong.",
|
"Misty looks at you like you've asked a question that doesn't deserve an answer. She's not wrong.",
|
||||||
"'Come back when you actually know what you're asking.' Misty's version of helpful feedback.",
|
"'Come back when you actually know what you're asking.' Misty's version of helpful feedback.",
|
||||||
|
"Misty lets the silence answer for her. The silence is thorough.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -79,6 +81,7 @@ var ArinaGreeting = []string{
|
|||||||
"'Perfect timing,' Arina says, in the tone of someone for whom most timings are perfect because everything is interesting. 'I was just thinking about—' She stops. 'Actually, what do you need?'",
|
"'Perfect timing,' Arina says, in the tone of someone for whom most timings are perfect because everything is interesting. 'I was just thinking about—' She stops. 'Actually, what do you need?'",
|
||||||
"Arina has three things she's in the middle of and immediately sets all of them down to give you her full attention, which is considerable.",
|
"Arina has three things she's in the middle of and immediately sets all of them down to give you her full attention, which is considerable.",
|
||||||
"'You came back!' Arina says, as if there was any question. In her experience there sometimes isn't.",
|
"'You came back!' Arina says, as if there was any question. In her experience there sometimes isn't.",
|
||||||
|
"'I was just — okay, two things, no, three things—' Arina holds up a finger for each, and then a fourth finger surprises her. 'Four things. You first, though.'",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -91,6 +94,7 @@ var ArinaIdentify = []string{
|
|||||||
"Arina holds the item up to the light, turns it twice, says something under her breath that might be an incantation or just enthusiasm, and then begins a very efficient explanation.",
|
"Arina holds the item up to the light, turns it twice, says something under her breath that might be an incantation or just enthusiasm, and then begins a very efficient explanation.",
|
||||||
"'Oh I know what this is.' The words land quickly, confidently, correctly. Arina has seen a lot of magic items and she remembers all of them.",
|
"'Oh I know what this is.' The words land quickly, confidently, correctly. Arina has seen a lot of magic items and she remembers all of them.",
|
||||||
"Arina goes still in the specific way she goes still when magic is doing something she finds genuinely surprising. 'That's — huh. Okay. That's new. Let me—' The identification follows, along with three questions she has that you're under no obligation to answer.",
|
"Arina goes still in the specific way she goes still when magic is doing something she finds genuinely surprising. 'That's — huh. Okay. That's new. Let me—' The identification follows, along with three questions she has that you're under no obligation to answer.",
|
||||||
|
"Arina sniffs the item, which is not a recognized identification technique, and then names it correctly anyway. 'The nose knows,' she says, in the voice of someone who will not be defending the methodology.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ package flavor
|
|||||||
var HarvestForageSuccess = []string{
|
var HarvestForageSuccess = []string{
|
||||||
"The land gives something up. I watch you identify it with the quiet satisfaction of someone watching a skill be used correctly.",
|
"The land gives something up. I watch you identify it with the quiet satisfaction of someone watching a skill be used correctly.",
|
||||||
"There — growing in a place that suggests it knows exactly what it's good for and has been waiting. You found it. I approve of the finding.",
|
"There — growing in a place that suggests it knows exactly what it's good for and has been waiting. You found it. I approve of the finding.",
|
||||||
"A Ranger's eye in a non-Ranger would have missed that entirely. I note the distinction.",
|
"You found it by the smell. Bruised stems, faintly peppery, which means something stepped on this patch within the hour and it grows back that fast. A Ranger taught me that trick. I never found out who taught you.",
|
||||||
"Like finding the hidden item block in a Mario level — you knew to look, you looked in the right place, and the thing that was always there is now yours.",
|
"Like finding the hidden item block in a Mario level — you knew to look, you looked in the right place, and the thing that was always there is now yours.",
|
||||||
"The plant comes away cleanly. Good root structure, good potency. Mentally catalogued. Moving on.",
|
"The plant comes away cleanly. Good root structure, good potency. Mentally catalogued. Moving on.",
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ var HarvestForageSuccess = []string{
|
|||||||
var HarvestMineSuccess = []string{
|
var HarvestMineSuccess = []string{
|
||||||
"The stone yields. I listen to the sound of it — the specific tone of rock giving up something it's been holding for a very long time.",
|
"The stone yields. I listen to the sound of it — the specific tone of rock giving up something it's been holding for a very long time.",
|
||||||
"Solid work. The ore comes out in a piece worth taking. I check the vein depth. There's more. There's always more if you're willing to dig.",
|
"Solid work. The ore comes out in a piece worth taking. I check the vein depth. There's more. There's always more if you're willing to dig.",
|
||||||
"Like the mining minigame in Stardew Valley, but with real consequences and no save file. I watch you extract the material with professional appreciation.",
|
"There's a note rock makes when it's ready to give. Duller, rounder, like knocking on a full barrel instead of an empty one. Third swing, you found the note. Some miners go twenty years and never hear it.",
|
||||||
"The wall gives up its contents without drama. I appreciate materials that cooperate.",
|
"The wall gives up its contents without drama. I appreciate materials that cooperate.",
|
||||||
"Good strike. Clean extraction. I note the weight and the quality simultaneously.",
|
"Good strike. Clean extraction. I note the weight and the quality simultaneously.",
|
||||||
}
|
}
|
||||||
@@ -31,9 +31,9 @@ var HarvestMineSuccess = []string{
|
|||||||
var HarvestScavengeSuccess = []string{
|
var HarvestScavengeSuccess = []string{
|
||||||
"There it is. Among the debris, the decay, the things that were left behind — something worth taking. I knew it was there. You found it. Pleased.",
|
"There it is. Among the debris, the decay, the things that were left behind — something worth taking. I knew it was there. You found it. Pleased.",
|
||||||
"The room held something after all. I had estimated 60% odds and am updating the estimate to 'correct.'",
|
"The room held something after all. I had estimated 60% odds and am updating the estimate to 'correct.'",
|
||||||
"Like finding the secret item in a dungeon chest that looked empty — you checked anyway. That's the habit. That's the discipline. I note both.",
|
"The chest looked empty. You knocked on the bottom anyway and it knocked back twice. Once for the bottom, once for the false bottom. I heard it too. I let you have the moment.",
|
||||||
"Scavenged. The word has a bad reputation it doesn't deserve. You found value in the discarded. I respect that entirely.",
|
"Scavenged. The word has a bad reputation it doesn't deserve. You found value in the discarded. I respect that entirely.",
|
||||||
"A Rogue's eye in a non-Rogue would have walked past this. I note the distinction.",
|
"You checked behind the crate that stood one inch too far from the wall. One inch. Ask any Rogue: that inch is the entire profession.",
|
||||||
}
|
}
|
||||||
|
|
||||||
var HarvestEssenceSuccess = []string{
|
var HarvestEssenceSuccess = []string{
|
||||||
@@ -41,7 +41,7 @@ var HarvestEssenceSuccess = []string{
|
|||||||
"Drawn out cleanly. The Arcana check held and the essence responds to the knowledge behind it. I'm appropriately impressed.",
|
"Drawn out cleanly. The Arcana check held and the essence responds to the knowledge behind it. I'm appropriately impressed.",
|
||||||
"Like tapping into a power source in Metroid — you knew the energy was there, you had the tool to reach it, you reached it. The vial fills.",
|
"Like tapping into a power source in Metroid — you knew the energy was there, you had the tool to reach it, you reached it. The vial fills.",
|
||||||
"The room releases something it didn't know it was holding. I watch the transfer and mark the yield in the ledger.",
|
"The room releases something it didn't know it was holding. I watch the transfer and mark the yield in the ledger.",
|
||||||
"Essence harvested. Quality above average for this zone, below average for what you'd need to know to appreciate that distinction. I appreciate it on your behalf.",
|
"Essence harvested. Seventh decile for this zone, which is genuinely exciting to perhaps four people alive. I am one of them. It is a quiet life, but the charts are immaculate.",
|
||||||
}
|
}
|
||||||
|
|
||||||
var HarvestCommuneSuccess = []string{
|
var HarvestCommuneSuccess = []string{
|
||||||
@@ -55,8 +55,9 @@ var HarvestFishSuccess = []string{
|
|||||||
"The line goes taut and I straighten up. Whatever's on the end of it, it came from somewhere deep and dark and it's yours now.",
|
"The line goes taut and I straighten up. Whatever's on the end of it, it came from somewhere deep and dark and it's yours now.",
|
||||||
"A catch. I identify it before you finish pulling it in — the coloring, the depth-marks, the specific opacity of its eyes. 'Good one,' I say, meaning it.",
|
"A catch. I identify it before you finish pulling it in — the coloring, the depth-marks, the specific opacity of its eyes. 'Good one,' I say, meaning it.",
|
||||||
"Fishing in a dungeon. I have opinions about fishing in dungeons and all of them are positive. The fish is landed. The opinions remain.",
|
"Fishing in a dungeon. I have opinions about fishing in dungeons and all of them are positive. The fish is landed. The opinions remain.",
|
||||||
"Like the fishing minigame in every RPG that ever had one — the moment the indicator hits perfect and everything pays off. Quiet delight, on my end.",
|
"Like landing the Hylian Loach. The fish the game never once required, the one you gave forty minutes to while the world stood ending, and the fisherman never asked why. This fish was optional too. That's what makes it yours.",
|
||||||
"The water gives up its catch with minimal argument. I respect fish that don't make it personal.",
|
"The water gives up its catch with minimal argument. I respect fish that don't make it personal.",
|
||||||
|
"The fish comes up mid-argument with the hook and loses. I weigh it, log it, and admire it, in that order. The order is the job.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -68,6 +69,7 @@ var HarvestFail = []string{
|
|||||||
"The attempt fails to produce anything useful. I mark the node and move on. Some rooms are stingier than others.",
|
"The attempt fails to produce anything useful. I mark the node and move on. Some rooms are stingier than others.",
|
||||||
"Not everything that looks like a resource is one. Filed under 'learned' and considered worth the attempt.",
|
"Not everything that looks like a resource is one. Filed under 'learned' and considered worth the attempt.",
|
||||||
"Empty-handed. I've seen this before and will see it again. The dungeon doesn't owe you anything. You ask anyway. That's the deal.",
|
"Empty-handed. I've seen this before and will see it again. The dungeon doesn't owe you anything. You ask anyway. That's the deal.",
|
||||||
|
"Nothing. The mushroom you spent four minutes prying loose is a rock. It was always a rock. I have struck it from the ledger and we will never speak of the rock again.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -82,6 +84,7 @@ var HarvestInterrupt = []string{
|
|||||||
"A patrol. Bad timing, or very good timing from their perspective. I set aside the harvest log and open the combat log.",
|
"A patrol. Bad timing, or very good timing from their perspective. I set aside the harvest log and open the combat log.",
|
||||||
"The forage was going well until it wasn't. I measure the distance between you and the enemy, between the enemy and the door, and start calculating options at speed.",
|
"The forage was going well until it wasn't. I measure the distance between you and the enemy, between the enemy and the door, and start calculating options at speed.",
|
||||||
"Interrupted. The node is still there. The enemy is also still there, in a more immediate way. I suggest addressing the more immediate thing first.",
|
"Interrupted. The node is still there. The enemy is also still there, in a more immediate way. I suggest addressing the more immediate thing first.",
|
||||||
|
"You were elbow-deep in the node when the growling started. Your priorities, observed and logged in order: ore, ore, growling, ore, sword.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -93,6 +96,7 @@ var NodeDepleted = []string{
|
|||||||
"Empty. The resource is gone. Something quietly melancholy about a depleted node and something practical about moving to the next one.",
|
"Empty. The resource is gone. Something quietly melancholy about a depleted node and something practical about moving to the next one.",
|
||||||
"That's all it had. I confirm the node at zero and move on without ceremony.",
|
"That's all it had. I confirm the node at zero and move on without ceremony.",
|
||||||
"Harvested clean. The room is now resource-dry until you rest and the dungeon replenishes. It will replenish. It always does.",
|
"Harvested clean. The room is now resource-dry until you rest and the dungeon replenishes. It will replenish. It always does.",
|
||||||
|
"Empty. You tap it twice more anyway, the way people press a lift button that's already lit. The node respects this exactly as much as the lift does.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -102,7 +106,7 @@ var NodeDepleted = []string{
|
|||||||
var RichYield = []string{
|
var RichYield = []string{
|
||||||
"A rich vein. My assessment upgrades mid-harvest — more than expected, better quality than the zone average. This room was generous. Marked.",
|
"A rich vein. My assessment upgrades mid-harvest — more than expected, better quality than the zone average. This room was generous. Marked.",
|
||||||
"The node gives more than it should have. I note the anomaly with appreciation and decline to question it.",
|
"The node gives more than it should have. I note the anomaly with appreciation and decline to question it.",
|
||||||
"Like finding the rare item drop that you stopped expecting — the dungeon decided to be kind today, in this specific way, in this specific room. I take it. We take it. We do not look it in the mouth.",
|
"The vein forks behind the wall. Then forks again. I revise my estimate upward twice in one sentence, which I hate doing and am currently doing. We take all of it. We do not look it in the mouth.",
|
||||||
"Exceptional yield. I catalog the bonus material with the efficiency of someone who's been waiting for exactly this and prepared for it anyway.",
|
"Exceptional yield. I catalog the bonus material with the efficiency of someone who's been waiting for exactly this and prepared for it anyway.",
|
||||||
"More than the DC promised. The dungeon overdelivered. Unusual. Also completely welcome.",
|
"More than the DC promised. The dungeon overdelivered. Unusual. Also completely welcome.",
|
||||||
}
|
}
|
||||||
@@ -215,25 +219,28 @@ var PatrolEncounter = []string{
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var LootDropCommon = []string{
|
var LootDropCommon = []string{
|
||||||
"They had something on them. I check it over. Common rarity — useful in the way that common things are useful, which is often.",
|
"They were carrying a coil of rope, half a candle, and a knife sharpened so many times it's mostly handle. I log all three. Somebody loved that knife once. It's yours now. That's dungeons.",
|
||||||
"Standard loot. Nothing that rewrites the story, but everything that keeps it going.",
|
"Standard loot. Six copper, a whetstone, and a note that says REMEMBER THE THING. No further details. They did not, evidently, remember the thing.",
|
||||||
"A drop. I catalog it efficiently and note: this is the economy of dungeons. Enemies have things. You take them. The loop continues.",
|
"I catalog the pockets. Then I find the second pocket sewn inside the first pocket, because I have been doing this a long time. So, apparently, had they.",
|
||||||
|
"A belt pouch. Inside: three teeth, none of them theirs, and a receipt. I don't read other people's receipts. I read the receipt. It was for soup.",
|
||||||
|
"Common drop. A tin whistle with one hole plugged with wax. Somewhere out there is a song missing a note, and now you own the reason.",
|
||||||
}
|
}
|
||||||
|
|
||||||
var LootDropUncommon = []string{
|
var LootDropUncommon = []string{
|
||||||
"Better than expected. I examine the drop with slightly elevated interest. Uncommon rarity — someone made this with intent.",
|
"Better than expected. I examine the drop with slightly elevated interest. Uncommon rarity — someone made this with intent.",
|
||||||
"An uncommon drop from a common enemy. Anomaly noted with satisfaction. The dungeon was generous in this room.",
|
"An uncommon drop from a common enemy. Anomaly noted with satisfaction. The dungeon was generous in this room.",
|
||||||
"Uncommon. I turn it over once and nod. 'Keeper,' I say — which in my vocabulary means: this changes your math.",
|
"Uncommon. I turn it over once and nod. 'Keeper,' I say — which in my vocabulary means: this changes your math.",
|
||||||
|
"Uncommon. There's a maker's mark under the grip, two crossed nails. I don't know the smith. I know what their apprentices paid to train, because work this clean does not come out of cheap teachers.",
|
||||||
}
|
}
|
||||||
|
|
||||||
var LootDropRare = []string{
|
var LootDropRare = []string{
|
||||||
"I stop. Actually stop. 'That's rare,' I say, with the specific register of someone who uses the word correctly and use it seldom.",
|
"I stop. Actually stop. 'That's rare,' I say. I have said that word four times in my career. I keep count, because it is that kind of word.",
|
||||||
"A rare drop. I examine it the way you examine something that doesn't appear often — thoroughly, quietly, with appropriate appreciation.",
|
"A rare drop. I examine it the way you examine something that doesn't appear often — thoroughly, quietly, with appropriate appreciation.",
|
||||||
"The loot table gave you something uncommon and then kept going. Rare rarity. Filed in the column I reserve for things worth remembering.",
|
"The loot table gave you something uncommon and then kept going. Rare rarity. Filed in the column I reserve for things worth remembering.",
|
||||||
}
|
}
|
||||||
|
|
||||||
var LootDropLegendary = []string{
|
var LootDropLegendary = []string{
|
||||||
"I go very still. The drop sits in the light and I process what I am seeing. 'Legendary,' I say eventually. One word. That's all it needs.",
|
"I go very still. The drop sits in the light and I process what I am seeing. 'Legendary,' I say eventually. One word. That's all it needs.",
|
||||||
"Legendary rarity. I've seen a few of these in a long career and each time — each time — there is a moment that is separate from everything else. This is that moment. Pick it up carefully.",
|
"Legendary rarity. I have logged three of these in my career. The first one earned its bearer a statue. The second one is why there is a lake where Torbridge used to be. Pick it up carefully.",
|
||||||
"The dungeon produced a legendary item. I note the zone, the enemy, the day of the expedition, the Threat Clock value, the precise conditions. Some things deserve to be recorded completely.",
|
"The dungeon produced a legendary item. I note the zone, the enemy, the day of the expedition, the Threat Clock value, the precise conditions. Some things deserve to be recorded completely.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ var LoreLinesDragonsLair = []string{
|
|||||||
"The kobolds are not slaves. The kobolds are clergy. They serve voluntarily, in shifts, with rotation, and the rotation is run by elders who have written sermons. I respect the organization and note the kobold scale-sorcerers are graduates, not recruits.",
|
"The kobolds are not slaves. The kobolds are clergy. They serve voluntarily, in shifts, with rotation, and the rotation is run by elders who have written sermons. I respect the organization and note the kobold scale-sorcerers are graduates, not recruits.",
|
||||||
"The hoard is not random. Each piece is catalogued in Infernax's memory, by location and by year of acquisition. He will know if you take a single coin. I suggest not taking a single coin and instead taking the items the design doc expects you to take — those have been pre-cleared.",
|
"The hoard is not random. Each piece is catalogued in Infernax's memory, by location and by year of acquisition. He will know if you take a single coin. I suggest not taking a single coin and instead taking the items the design doc expects you to take — those have been pre-cleared.",
|
||||||
"The Dragon Hoard mechanic exists because Infernax does not lose track of his coins. Killing him releases his hold on the catalogue. The 50d10 × 100 coin drop is the entire pile relaxing for the first time in eight centuries. I respect the math and note the rest of the surface economy will too.",
|
"The Dragon Hoard mechanic exists because Infernax does not lose track of his coins. Killing him releases his hold on the catalogue. The 50d10 × 100 coin drop is the entire pile relaxing for the first time in eight centuries. I respect the math and note the rest of the surface economy will too.",
|
||||||
"Infernax has had three challengers in the last eight hundred years. Two were heroes. One was a younger dragon. He kept the younger dragon's skull as a paperweight on a treaty desk that has not been used since. I note the paperweight is in the treasury, on the third shelf, and am not worth picking up — picking it up triggers his attention from anywhere on the mountain.",
|
"Infernax has had three challengers in the last eight hundred years. Two were heroes. One was a younger dragon. He kept the younger dragon's skull as a paperweight on a treaty desk that has not been used since. I note the paperweight is in the treasury, on the third shelf, and is not worth picking up — picking it up triggers his attention from anywhere on the mountain.",
|
||||||
"The Young Red dragons in the outer chambers are his children. Or his grandchildren. Or unrelated and tolerated. Infernax does not clarify and I have not asked. They are loyal in the way that loyal works for dragons, which is to say: they will fight you, but they will not die for him, and the distinction matters more than it should.",
|
"The Young Red dragons in the outer chambers are his children. Or his grandchildren. Or unrelated and tolerated. Infernax does not clarify and I have not asked. They are loyal in the way that loyal works for dragons, which is to say: they will fight you, but they will not die for him, and the distinction matters more than it should.",
|
||||||
"The kobold scale-sorcerers cast through bloodline. The bloodline traces back to a single clutch laid in the magma chamber three centuries ago. I note the entire sorcerous gene pool of this zone is one extended family and that they all know each other's names.",
|
"The kobold scale-sorcerers cast through bloodline. The bloodline traces back to a single clutch laid in the magma chamber three centuries ago. I note the entire sorcerous gene pool of this zone is one extended family and that they all know each other's names.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ var EliteRoomEntrySunkenTemple = []string{
|
|||||||
var AbolethTentacleMultiattackLines = []string{
|
var AbolethTentacleMultiattackLines = []string{
|
||||||
"Three tentacles, three rolls. Each on-hit risks Diseased — no magical healing for 24 hours until cured. I say: 'Cleric's tools come back online tomorrow. Survive today.'",
|
"Three tentacles, three rolls. Each on-hit risks Diseased — no magical healing for 24 hours until cured. I say: 'Cleric's tools come back online tomorrow. Survive today.'",
|
||||||
"The Aboleth's tentacles arrive in sequence, three of them, the hits compounding. The disease isn't the damage — the disease is the design. Magical healing fails until you cleanse. I file this under 'durability problem.'",
|
"The Aboleth's tentacles arrive in sequence, three of them, the hits compounding. The disease isn't the damage — the disease is the design. Magical healing fails until you cleanse. I file this under 'durability problem.'",
|
||||||
"Three attacks, one turn. Any landing tentacle leaves a mark that locks out magical healing. I suggest potions, rest, and the kind of patience that pretends to be patience but am mostly grim arithmetic.",
|
"Three attacks, one turn. Any landing tentacle leaves a mark that locks out magical healing. I suggest potions, rest, and the kind of patience that pretends to be patience but is mostly grim arithmetic.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enslave: recharge 6; WIS DC 14 or Charmed; player skips turn, drifts toward Aboleth.
|
// Enslave: recharge 6; WIS DC 14 or Charmed; player skips turn, drifts toward Aboleth.
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Package llm wraps the local inference endpoint behind a backend-agnostic
|
||||||
|
// interface. Two concrete backends — Ollama (native /api/generate) and vLLM
|
||||||
|
// (OpenAI-compatible /v1/chat/completions) — implement Client; plugin code
|
||||||
|
// calls the interface only and never knows which one is active.
|
||||||
|
//
|
||||||
|
// Deliberately not routed through internal/safehttp: that client blocks
|
||||||
|
// RFC1918 and loopback destinations to defend against SSRF from feed-supplied
|
||||||
|
// URLs, and the inference endpoint is precisely such a destination. The URL
|
||||||
|
// here comes from our own config, never from user input.
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is the backend-neutral generation request. Zero-valued fields fall
|
||||||
|
// back to backend defaults.
|
||||||
|
type Request struct {
|
||||||
|
// Prompt is a single raw instruction. The vLLM backend wraps it as one
|
||||||
|
// user message so the model's chat template still applies; sending it to
|
||||||
|
// /v1/completions instead would bypass the template and degrade an
|
||||||
|
// instruction-tuned model badly.
|
||||||
|
Prompt string
|
||||||
|
// System is an optional system message. Empty means none, which keeps the
|
||||||
|
// single-message shape the majority of callers use.
|
||||||
|
System string
|
||||||
|
// NumCtx is the per-request context window. Ollama honours it directly;
|
||||||
|
// vLLM fixes the window server-side at launch (--max-model-len), so this
|
||||||
|
// is ignored there rather than silently misapplied.
|
||||||
|
NumCtx int
|
||||||
|
// MaxTokens caps the completion length. 0 means the backend default.
|
||||||
|
MaxTokens int
|
||||||
|
// Temperature is passed through when non-zero.
|
||||||
|
Temperature float64
|
||||||
|
// Timeout overrides the client's default per-request budget.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is the single surface plugin code depends on.
|
||||||
|
type Client interface {
|
||||||
|
// Generate returns the full completion in one shot. Reasoning blocks are
|
||||||
|
// stripped before returning (see StripThink) — every caller in this repo
|
||||||
|
// wants the visible answer, not the chain of thought.
|
||||||
|
Generate(ctx context.Context, req Request) (string, error)
|
||||||
|
// Model reports the configured model id, for logging and /botinfo.
|
||||||
|
Model() string
|
||||||
|
// Ping reports the model ids the backend is currently serving. Used by
|
||||||
|
// /botinfo for a liveness line; the two backends expose this on different
|
||||||
|
// paths (/api/tags vs /v1/models), which is exactly the sort of difference
|
||||||
|
// this interface exists to hide.
|
||||||
|
Ping(ctx context.Context) ([]string, error)
|
||||||
|
// Backend reports "ollama" or "vllm", for logging and /botinfo.
|
||||||
|
Backend() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects and configures a backend.
|
||||||
|
type Config struct {
|
||||||
|
Backend string // "ollama" | "vllm"
|
||||||
|
Endpoint string
|
||||||
|
Model string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTimeout matches the budget the pre-refactor callOllama used.
|
||||||
|
const DefaultTimeout = 120 * time.Second
|
||||||
|
|
||||||
|
// ConfigFromEnv reads backend settings, preferring the new LLM_* names and
|
||||||
|
// falling back to the legacy OLLAMA_* pair so an existing deployment keeps
|
||||||
|
// working untouched after this refactor.
|
||||||
|
func ConfigFromEnv() Config {
|
||||||
|
backend := strings.ToLower(strings.TrimSpace(os.Getenv("LLM_BACKEND")))
|
||||||
|
if backend == "" {
|
||||||
|
backend = "ollama"
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := firstNonEmpty(os.Getenv("LLM_ENDPOINT"), os.Getenv("OLLAMA_HOST"))
|
||||||
|
model := firstNonEmpty(os.Getenv("LLM_MODEL"), os.Getenv("OLLAMA_MODEL"))
|
||||||
|
|
||||||
|
timeout := DefaultTimeout
|
||||||
|
if d, err := time.ParseDuration(os.Getenv("LLM_TIMEOUT")); err == nil && d > 0 {
|
||||||
|
timeout = d
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config{Backend: backend, Endpoint: endpoint, Model: model, Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured reports whether enough config is present to talk to a backend.
|
||||||
|
// Plugins check this to stay dormant rather than erroring on every invocation,
|
||||||
|
// which is what the old `if ollamaHost == "" || ollamaModel == ""` guards did.
|
||||||
|
func (c Config) Configured() bool {
|
||||||
|
return c.Endpoint != "" && c.Model != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the client for cfg.Backend. An unrecognised backend falls back to
|
||||||
|
// Ollama, which is what every existing deployment runs.
|
||||||
|
func New(cfg Config) Client {
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = DefaultTimeout
|
||||||
|
}
|
||||||
|
base := backend{
|
||||||
|
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
|
||||||
|
model: cfg.Model,
|
||||||
|
timeout: cfg.Timeout,
|
||||||
|
}
|
||||||
|
switch cfg.Backend {
|
||||||
|
case "vllm":
|
||||||
|
return &VLLMClient{base}
|
||||||
|
default:
|
||||||
|
return &OllamaClient{base}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backend holds the fields shared by both concrete clients.
|
||||||
|
type backend struct {
|
||||||
|
endpoint string
|
||||||
|
model string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b backend) Model() string { return b.model }
|
||||||
|
|
||||||
|
// timeoutFor lets a single call widen or narrow the client default. The two
|
||||||
|
// dispatch-voice callers rely on this: a dispatch is authored on a game
|
||||||
|
// chokepoint and must not stall it, while a run summary rides a background
|
||||||
|
// ticker and can afford a bigger model.
|
||||||
|
func (b backend) timeoutFor(req Request) time.Duration {
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
return req.Timeout
|
||||||
|
}
|
||||||
|
return b.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripThink removes a leading <think>...</think> reasoning block, which Qwen
|
||||||
|
// models emit even when thinking is disabled by some backends. Callers that
|
||||||
|
// parse JSON out of the completion depend on this running first.
|
||||||
|
func StripThink(s string) string {
|
||||||
|
for {
|
||||||
|
i := strings.Index(s, "<think>")
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j := strings.Index(s, "</think>")
|
||||||
|
if j < 0 || j < i {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s = s[:i] + s[j+len("</think>"):]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OllamaClient talks to Ollama's native /api/generate endpoint.
|
||||||
|
type OllamaClient struct {
|
||||||
|
backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OllamaClient) Backend() string { return "ollama" }
|
||||||
|
|
||||||
|
type ollamaOptions struct {
|
||||||
|
NumCtx int `json:"num_ctx,omitempty"`
|
||||||
|
NumPredict int `json:"num_predict,omitempty"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Think bool `json:"think"`
|
||||||
|
Options ollamaOptions `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate posts a single non-streaming generation and returns the completion.
|
||||||
|
func (c *OllamaClient) Generate(ctx context.Context, req Request) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.timeoutFor(req))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
body, err := json.Marshal(ollamaRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Prompt: req.Prompt,
|
||||||
|
System: req.System,
|
||||||
|
Stream: false,
|
||||||
|
Think: false,
|
||||||
|
Options: ollamaOptions{
|
||||||
|
NumCtx: req.NumCtx,
|
||||||
|
NumPredict: req.MaxTokens,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.endpoint+"/api/generate", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: build request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: parse response: %w", err)
|
||||||
|
}
|
||||||
|
return StripThink(result.Response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping lists locally installed models via Ollama's native /api/tags.
|
||||||
|
func (c *OllamaClient) Ping(ctx context.Context) ([]string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Models []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"models"`
|
||||||
|
}
|
||||||
|
if err := getJSON(ctx, c.endpoint+"/api/tags", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(out.Models))
|
||||||
|
for _, m := range out.Models {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pingTimeout keeps a liveness probe short — /botinfo renders synchronously and
|
||||||
|
// a hung endpoint must not hold the reply.
|
||||||
|
const pingTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func getJSON(ctx context.Context, url string, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VLLMClient talks to an OpenAI-compatible /v1/chat/completions endpoint.
|
||||||
|
type VLLMClient struct {
|
||||||
|
backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *VLLMClient) Backend() string { return "vllm" }
|
||||||
|
|
||||||
|
type vllmMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vllmRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []vllmMessage `json:"messages"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
// ChatTemplateKwargs is a vLLM extension to the OpenAI schema. It is how
|
||||||
|
// Qwen3-family reasoning is switched off; the Ollama backend spells the
|
||||||
|
// same intent as its native "think": false.
|
||||||
|
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate posts a single non-streaming completion and returns the message
|
||||||
|
// content. The raw prompt is sent as one user message so the server-side chat
|
||||||
|
// template still wraps it.
|
||||||
|
func (c *VLLMClient) Generate(ctx context.Context, req Request) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.timeoutFor(req))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
msgs := make([]vllmMessage, 0, 2)
|
||||||
|
if req.System != "" {
|
||||||
|
msgs = append(msgs, vllmMessage{Role: "system", Content: req.System})
|
||||||
|
}
|
||||||
|
msgs = append(msgs, vllmMessage{Role: "user", Content: req.Prompt})
|
||||||
|
|
||||||
|
body, err := json.Marshal(vllmRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Messages: msgs,
|
||||||
|
MaxTokens: req.MaxTokens,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
Stream: false,
|
||||||
|
ChatTemplateKwargs: map[string]any{"enable_thinking": false},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.endpoint+"/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: build request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vllm request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("vllm HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: parse response: %w", err)
|
||||||
|
}
|
||||||
|
if len(result.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("vllm: empty choices in response")
|
||||||
|
}
|
||||||
|
return StripThink(result.Choices[0].Message.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping lists served models via the OpenAI-compatible /v1/models.
|
||||||
|
func (c *VLLMClient) Ping(ctx context.Context) ([]string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := getJSON(ctx, c.endpoint+"/v1/models", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(out.Data))
|
||||||
|
for _, m := range out.Data {
|
||||||
|
names = append(names, m.ID)
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
@@ -48,6 +48,13 @@ type Fact struct {
|
|||||||
Milestone string `json:"milestone,omitempty"`
|
Milestone string `json:"milestone,omitempty"`
|
||||||
OccurredAt int64 `json:"occurred_at"`
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
||||||
|
// RunID names the expedition this fact is the ENDING of, and only the three
|
||||||
|
// facts that are one carry it: a clear, a retreat, a death. It is what lets
|
||||||
|
// Pete's dispatch link back to the run's own report — the log, the numbers,
|
||||||
|
// the moment it turned — instead of leaving a paragraph about an outcome with
|
||||||
|
// no way back to what produced it. Empty everywhere else, and safe to be
|
||||||
|
// empty: Pete renders the dispatch exactly as it did before the report existed.
|
||||||
|
RunID string `json:"run_id,omitempty"`
|
||||||
// Headline/Lede are LLM-authored prose for this fact, both optional. Pete
|
// Headline/Lede are LLM-authored prose for this fact, both optional. Pete
|
||||||
// prefers them over its own template when present and past its prose-guard,
|
// prefers them over its own template when present and past its prose-guard,
|
||||||
// and falls back to the template otherwise — so an empty pair (LLM off, or
|
// and falls back to the template otherwise — so an empty pair (LLM off, or
|
||||||
@@ -277,6 +284,42 @@ type RosterDetail struct {
|
|||||||
ThreatLevel int `json:"threat_level,omitempty"`
|
ThreatLevel int `json:"threat_level,omitempty"`
|
||||||
Room string `json:"room,omitempty"`
|
Room string `json:"room,omitempty"`
|
||||||
Map *RosterMap `json:"map,omitempty"`
|
Map *RosterMap `json:"map,omitempty"`
|
||||||
|
// Party is who else is on this expedition, leader first. Absent for a solo
|
||||||
|
// run — a party of one is not a party, and the page should say nothing rather
|
||||||
|
// than draw a roster with a single chair in it.
|
||||||
|
Party []PartySeatView `json:"party,omitempty"`
|
||||||
|
// PartyKnown says this sender knows what a party seat is. It is a fact about
|
||||||
|
// the build, never about the character, so it is set unconditionally on every
|
||||||
|
// sheet — in town, solo, or seated with three others — and must never be
|
||||||
|
// computed from whether Party has anything in it.
|
||||||
|
//
|
||||||
|
// Party being omitempty is why it exists: a solo run and a game box too old to
|
||||||
|
// push seats both arrive at Pete as an empty slice, and Pete's page has to
|
||||||
|
// decide from that whether the viewer may throw away everybody else's day. An
|
||||||
|
// old build sends no key, which lands as false, and Pete withholds the button.
|
||||||
|
// No omitempty here for the same reason — an absent field is the fail-closed
|
||||||
|
// answer and this one is never absent on purpose.
|
||||||
|
PartyKnown bool `json:"party_known"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartySeatView is one body on a shared expedition, as the public page may see
|
||||||
|
// it. Kind is what the seat *is*, which the game keeps carefully separate:
|
||||||
|
// "leader" owns the expedition row everyone else references, "member" is another
|
||||||
|
// player, "companion" is the hired NPC (Pete) who fights but has no mailbox and
|
||||||
|
// no loot.
|
||||||
|
//
|
||||||
|
// Name/Token are the same pair the board and the Siege muster use. The opt-out
|
||||||
|
// rule here is the Siege *contributor* rule, not the realm-occupant rule: an
|
||||||
|
// opted-out player's seat is anonymised (kept, with no name and no token) rather
|
||||||
|
// than deleted. A party of three that renders as two is a false statement about
|
||||||
|
// the run — the supply burn, the enemy scaling and the loot split all felt three
|
||||||
|
// bodies — whereas an unnamed seat says only that somebody else was there, which
|
||||||
|
// the zone line on this same page already implies.
|
||||||
|
type PartySeatView struct {
|
||||||
|
Kind string `json:"kind"` // leader|member|companion
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"` // empty: opted out, or a companion (no board row)
|
||||||
|
Level int `json:"level,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RosterMap is the fog-of-war cut of an adventurer's zone graph: every node
|
// RosterMap is the fog-of-war cut of an adventurer's zone graph: every node
|
||||||
@@ -373,6 +416,234 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error {
|
|||||||
return std.post(ctx, "/api/ingest/roster", payload)
|
return std.post(ctx, "/api/ingest/roster", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiegeDefender is one adventurer's standing in the current Siege muster.
|
||||||
|
//
|
||||||
|
// Token is the same public board token the roster uses, so Pete can link a
|
||||||
|
// defender to their page — and it is EMPTY for an opted-out player, with Name
|
||||||
|
// carrying anonName instead. That is the whole opt-out story here: their damage
|
||||||
|
// still counts and still holds its rank (the town's effort is the town's), but
|
||||||
|
// there is no name to click. It differs from the board's rule (omit entirely)
|
||||||
|
// on purpose — a defender board that silently dropped contributors would
|
||||||
|
// understate what the town actually did to the boss.
|
||||||
|
type SiegeDefender struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level,omitempty"`
|
||||||
|
Fights int `json:"fights"`
|
||||||
|
Damage int `json:"damage"`
|
||||||
|
FoughtToday bool `json:"fought_today"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiegePast is one closed-out Siege for the history table.
|
||||||
|
type SiegePast struct {
|
||||||
|
BossID int64 `json:"boss_id"`
|
||||||
|
BossName string `json:"boss_name"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Outcome string `json:"outcome"` // "defeated" | "survived"
|
||||||
|
HPRemaining int `json:"hp_remaining"`
|
||||||
|
HPMax int `json:"hp_max"`
|
||||||
|
Defenders int `json:"defenders"`
|
||||||
|
MVP string `json:"mvp,omitempty"`
|
||||||
|
MVPFights int `json:"mvp_fights,omitempty"`
|
||||||
|
EndedAt int64 `json:"ended_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiegeSnapshot is the complete war room: the live boss (if any), its muster,
|
||||||
|
// and every Siege that came before. Same complete-snapshot contract as the
|
||||||
|
// roster — Pete replaces its copy — so a defender omitted here leaves the board
|
||||||
|
// and a resolved Siege stops showing a live bar.
|
||||||
|
//
|
||||||
|
// Defenders carries EVERY alive adventurer, not just contributors. The zero-fight
|
||||||
|
// rows are the point: one bout per person per day means somebody who hasn't
|
||||||
|
// swung today is a hit the town hasn't taken, and the page can only show that gap
|
||||||
|
// if the people in it are on the wire.
|
||||||
|
type SiegeSnapshot struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
BossID int64 `json:"boss_id,omitempty"`
|
||||||
|
BossName string `json:"boss_name,omitempty"`
|
||||||
|
Tier int `json:"tier,omitempty"`
|
||||||
|
HPCurrent int `json:"hp_current"`
|
||||||
|
HPMax int `json:"hp_max"`
|
||||||
|
StartsAt int64 `json:"starts_at,omitempty"`
|
||||||
|
EndsAt int64 `json:"ends_at,omitempty"`
|
||||||
|
BoutsToday int `json:"bouts_today"`
|
||||||
|
Defenders []SiegeDefender `json:"defenders,omitempty"`
|
||||||
|
History []SiegePast `json:"history,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushSiege sends the war room to Pete, synchronously, and drops it on failure —
|
||||||
|
// the same drop-the-lie semantics as PushRoster. A retried snapshot would claim
|
||||||
|
// a pool level that has since moved, and the next tick carries the truth anyway.
|
||||||
|
func PushSiege(ctx context.Context, snap SiegeSnapshot) error {
|
||||||
|
if !Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(snap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return std.post(ctx, "/api/ingest/siege", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunBeat is one structured moment inside an expedition run: a room entered, a
|
||||||
|
// fight resolved, a trap sprung, a haul taken. Facts, never prose — Pete owns
|
||||||
|
// the words, exactly as it does for a Fact. The engine already narrates every
|
||||||
|
// one of these to Matrix and then throws the narration away; this carries the
|
||||||
|
// shape underneath it so Pete can retell the run to somebody who wasn't there.
|
||||||
|
//
|
||||||
|
// (RunID, Seq) is the identity. Seq is monotonic per run and assigned at record
|
||||||
|
// time, so Pete can order a batch that arrives out of order and drop a duplicate
|
||||||
|
// without comparing contents.
|
||||||
|
//
|
||||||
|
// Nothing here is player-identifying except Token, which rides the `start` beat
|
||||||
|
// only and is the same public board token the roster uses. An opted-out player's
|
||||||
|
// beats are never pushed at all — see pushRunBeats.
|
||||||
|
type RunBeat struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
Kind string `json:"kind"` // start|room|combat|trap|treasure|haul|lock|camp|region|end|summary
|
||||||
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
|
|
||||||
|
Token string `json:"token,omitempty"` // `start` only: whose run this is
|
||||||
|
Name string `json:"name,omitempty"` // `start` only: character name
|
||||||
|
Level int `json:"level,omitempty"` // `start` only
|
||||||
|
Zone string `json:"zone,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Room int `json:"room,omitempty"` // 1-based, as the player sees it
|
||||||
|
TotalRooms int `json:"total_rooms,omitempty"` // 0 when unknown
|
||||||
|
RoomKind string `json:"room_kind,omitempty"` // entry|exploration|trap|elite|boss|secret
|
||||||
|
Target string `json:"target,omitempty"` // monster, item, region, lock — the noun
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Amount int `json:"amount,omitempty"` // damage taken, or a total quantity
|
||||||
|
Count int `json:"count,omitempty"` // how many distinct things Amount covers
|
||||||
|
HP int `json:"hp,omitempty"`
|
||||||
|
HPMax int `json:"hp_max,omitempty"`
|
||||||
|
Crits int `json:"crits,omitempty"`
|
||||||
|
Fumbles int `json:"fumbles,omitempty"`
|
||||||
|
// Prose is the single exception to "nouns and numbers only", and it is
|
||||||
|
// confined to the one kind that has any: `summary`, the three sentences the
|
||||||
|
// local model writes over a finished run. Pete guards it exactly as it guards
|
||||||
|
// a dispatch lede and drops the words (not the beat) on a rejection. Every
|
||||||
|
// other kind must leave this empty — Pete scrubs it if they don't.
|
||||||
|
Prose string `json:"prose,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmZone is one zone as the realm map draws it: what it is, who first got
|
||||||
|
// through it, how many have since, and who is inside it right now.
|
||||||
|
//
|
||||||
|
// FirstClearBy is a character name and FirstClearToken the public board token,
|
||||||
|
// exactly as the Siege muster pairs them — and the token is EMPTY for a player
|
||||||
|
// who has opted out, keeping the name off the page too (see buildRealmSnapshot:
|
||||||
|
// an opted-out first-clearer is anonymised, not deleted, because deleting the
|
||||||
|
// claim would make the zone read as never-cleared, which is a different and
|
||||||
|
// false statement about the realm).
|
||||||
|
type RealmZone struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
LevelMin int `json:"level_min"`
|
||||||
|
LevelMax int `json:"level_max"`
|
||||||
|
Faction string `json:"faction,omitempty"`
|
||||||
|
Atmosphere string `json:"atmosphere,omitempty"`
|
||||||
|
Postgame bool `json:"postgame,omitempty"` // T6 mythic: gated, drawn apart
|
||||||
|
|
||||||
|
FirstClearBy string `json:"first_clear_by,omitempty"`
|
||||||
|
FirstClearToken string `json:"first_clear_token,omitempty"`
|
||||||
|
FirstClearAt int64 `json:"first_clear_at,omitempty"`
|
||||||
|
|
||||||
|
Clears int `json:"clears"` // boss-defeated runs, all time
|
||||||
|
Clearers int `json:"clearers"` // distinct adventurers who have managed it
|
||||||
|
|
||||||
|
Occupants []RealmOccupant `json:"occupants,omitempty"` // in there right now
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmOccupant is somebody currently on an expedition in a zone. Same
|
||||||
|
// name+token pair as everywhere else, and an opted-out player is omitted
|
||||||
|
// outright rather than anonymised: unlike a first clear, presence is not part of
|
||||||
|
// a shared tally that stops adding up without them, and "who is in there right
|
||||||
|
// now" is exactly the live-location fact the liveblog is careful about.
|
||||||
|
type RealmOccupant struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level,omitempty"`
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmFirst is one row of the hall of firsts: a thing that happened in the
|
||||||
|
// realm exactly once ever, and who it happened to. The ledger
|
||||||
|
// (news_realm_firsts) records only (kind, target, first_at) — the holder is
|
||||||
|
// recovered by gogobee at push time from the run history, which is why this is
|
||||||
|
// pushed rather than derived on Pete.
|
||||||
|
type RealmFirst struct {
|
||||||
|
Kind string `json:"kind"` // "zone" | "treasure"
|
||||||
|
Target string `json:"target"` // the zone id or treasure key
|
||||||
|
Display string `json:"display"` // the human name for it
|
||||||
|
Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone"
|
||||||
|
Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable
|
||||||
|
Token string `json:"token,omitempty"` // board token; empty when opted out
|
||||||
|
AtUnix int64 `json:"at_unix"` // when the realm first saw it
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmStanding is one adventurer's line on the board. Every number here is a
|
||||||
|
// lifetime total from the game's own run history — nothing is a rate, an
|
||||||
|
// average, or anything that would move on its own while nobody played.
|
||||||
|
type RealmStanding struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
ClassRace string `json:"class_race,omitempty"`
|
||||||
|
DeepestTier int `json:"deepest_tier"` // deepest zone tier actually cleared
|
||||||
|
Clears int `json:"clears"`
|
||||||
|
Zones int `json:"zones"` // distinct zones cleared
|
||||||
|
Firsts int `json:"firsts"` // realm-firsts held
|
||||||
|
SiegeDamage int `json:"siege_damage"`
|
||||||
|
SiegeFights int `json:"siege_fights"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealmSnapshot is the whole realm as one photograph: every zone, the hall of
|
||||||
|
// firsts, and the board. Snapshot semantics, like the roster and the Siege —
|
||||||
|
// Pete replaces its copy and a failed push is dropped, not retried.
|
||||||
|
//
|
||||||
|
// It is pushed on the roster ticker but NOT every tick: none of it moves fast
|
||||||
|
// enough to be worth the aggregate queries every two minutes, and the page's
|
||||||
|
// staleness window is generous for exactly that reason. See realmPushInterval.
|
||||||
|
type RealmSnapshot struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
Zones []RealmZone `json:"zones,omitempty"`
|
||||||
|
Firsts []RealmFirst `json:"firsts,omitempty"`
|
||||||
|
Standings []RealmStanding `json:"standings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRealm sends the realm pages' backing data to Pete. Drop-on-failure, same
|
||||||
|
// as the other two snapshots.
|
||||||
|
func PushRealm(ctx context.Context, snap RealmSnapshot) error {
|
||||||
|
if !Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(snap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return std.post(ctx, "/api/ingest/realm", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRunBeats delivers a batch of beats. Unlike the snapshots this is
|
||||||
|
// append-only and IS retried — a dropped beat is a hole in a story, not a stale
|
||||||
|
// number that the next tick corrects. The caller only marks rows sent on success.
|
||||||
|
func PushRunBeats(ctx context.Context, beats []RunBeat) error {
|
||||||
|
if !Enabled() || len(beats) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(struct {
|
||||||
|
Beats []RunBeat `json:"beats"`
|
||||||
|
}{beats})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return std.post(ctx, "/api/ingest/run", payload)
|
||||||
|
}
|
||||||
|
|
||||||
// PlayerDetail is the private, owner-only expansion for one player: inventory,
|
// PlayerDetail is the private, owner-only expansion for one player: inventory,
|
||||||
// vault, house, and pets. Like MischiefBalance it is keyed by localpart (the
|
// vault, house, and pets. Like MischiefBalance it is keyed by localpart (the
|
||||||
// sign-in name), in its own keyspace on Pete — Pete only ever serves it back to
|
// sign-in name), in its own keyspace on Pete — Pete only ever serves it back to
|
||||||
@@ -388,6 +659,90 @@ type PlayerDetail struct {
|
|||||||
Equipped []ItemView `json:"equipped,omitempty"`
|
Equipped []ItemView `json:"equipped,omitempty"`
|
||||||
House HouseView `json:"house"`
|
House HouseView `json:"house"`
|
||||||
Pets []PetView `json:"pets,omitempty"`
|
Pets []PetView `json:"pets,omitempty"`
|
||||||
|
// Slots is the 5 standard equipment slots (weapon/armor/helmet/boots/tool) for
|
||||||
|
// the web management panel. Worn masterwork/arena pieces surface here (via
|
||||||
|
// CanTakeOff), not in Equipped, which stays magic-only (the DnD slots).
|
||||||
|
Slots []EquipSlotView `json:"slots,omitempty"`
|
||||||
|
// Balance is the owner's euro balance, for the web's upgrade/repair confirm.
|
||||||
|
// No omitempty: a €0 balance is a real, informative fact (a broke player), not
|
||||||
|
// an absent one — dropping it would let the confirm dialog show a stale amount.
|
||||||
|
Balance float64 `json:"balance"`
|
||||||
|
// Zones is where this adventurer may go right now, priced. It is the offer
|
||||||
|
// list behind the web's "send on expedition" picker: level gating and the T6
|
||||||
|
// postgame gate are resolved here, so a zone the player cannot enter is simply
|
||||||
|
// absent rather than shown and then refused. Empty while they are already out.
|
||||||
|
Zones []ZoneOffer `json:"zones,omitempty"`
|
||||||
|
// Resume is the extracted expedition waiting to be walked back into, priced
|
||||||
|
// the same way. Absent when there is nothing to resume.
|
||||||
|
Resume *ResumeOffer `json:"resume,omitempty"`
|
||||||
|
// Babysit is the pet-care subscription's standing and price. Always present
|
||||||
|
// for a live adventurer: "you already have one" is as useful to the page as a
|
||||||
|
// price is.
|
||||||
|
Babysit *BabysitOffer `json:"babysit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneOffer is one place the owner may set out for, with what the trip costs.
|
||||||
|
// Pete renders these and does no arithmetic — the prices are the game's, quoted
|
||||||
|
// at push time, and gogobee re-quotes them for real when the order lands.
|
||||||
|
type ZoneOffer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Hook string `json:"hook,omitempty"`
|
||||||
|
Postgame bool `json:"postgame,omitempty"`
|
||||||
|
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadoutOffer is one supply preset for a zone: what it is called, what it
|
||||||
|
// costs, and roughly how long it lasts. Key is the token the order carries back.
|
||||||
|
type LoadoutOffer struct {
|
||||||
|
Key string `json:"key"` // lean|balanced|heavy
|
||||||
|
Name string `json:"name"`
|
||||||
|
Blurb string `json:"blurb,omitempty"`
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
Days int `json:"days"` // provisions at the zone's daily burn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeOffer is the extracted expedition the owner can still walk back into,
|
||||||
|
// with the same priced loadouts as a fresh departure. ExpiresAt is the end of
|
||||||
|
// the seven-day window, so the page can say how long is left rather than just
|
||||||
|
// that there is a way back.
|
||||||
|
type ResumeOffer struct {
|
||||||
|
ZoneID string `json:"zone_id"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Day int `json:"day"`
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BabysitOffer is the sitter's standing and price. WeekCost/MonthCost are the
|
||||||
|
// two durations the game sells; they scale with level, which is why they are
|
||||||
|
// pushed rather than hardcoded on Pete.
|
||||||
|
type BabysitOffer struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
WeekCost int `json:"week_cost"`
|
||||||
|
MonthCost int `json:"month_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EquipSlotView is one of the 5 standard equipment slots, carrying what the web
|
||||||
|
// management panel needs: what's worn now, whether it round-trips to the pack
|
||||||
|
// (masterwork/arena), the next tier's name and price for an upgrade offer, and a
|
||||||
|
// repair cost when the piece is damaged. Pete renders it and trusts only these
|
||||||
|
// facts — a client-forged tier or price is resolved back against this view.
|
||||||
|
type EquipSlotView struct {
|
||||||
|
Slot string `json:"slot"` // weapon|armor|helmet|boots|tool
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Condition int `json:"condition"`
|
||||||
|
Masterwork bool `json:"masterwork,omitempty"`
|
||||||
|
ArenaTier int `json:"arena_tier,omitempty"`
|
||||||
|
CanTakeOff bool `json:"can_take_off,omitempty"` // masterwork/arena → round-trippable
|
||||||
|
NextTier int `json:"next_tier,omitempty"` // 0 = at max tier (5)
|
||||||
|
NextName string `json:"next_name,omitempty"`
|
||||||
|
NextPrice float64 `json:"next_price,omitempty"`
|
||||||
|
RepairCost int `json:"repair_cost,omitempty"` // 0 = full condition
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemView is one item in the private panels — backpack, vault, or worn.
|
// ItemView is one item in the private panels — backpack, vault, or worn.
|
||||||
@@ -469,11 +824,19 @@ type HouseView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PetView is one pet slot.
|
// PetView is one pet slot.
|
||||||
|
//
|
||||||
|
// XP and XPNeeded are both in **centi-XP** — the engine's own unit, a hundredth
|
||||||
|
// of a point, because a pet earns 1.5 XP per action and the ledger is an int.
|
||||||
|
// Pete divides by 100 to show it and does no other arithmetic on either number:
|
||||||
|
// the curve behind XPNeeded is petXPToNextLevel's, per level band, and it is not
|
||||||
|
// Pete's business to know it. XPNeeded is 0 at the level cap, which is the only
|
||||||
|
// signal that there is nothing left to fill.
|
||||||
type PetView struct {
|
type PetView struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
XP int `json:"xp,omitempty"`
|
XP int `json:"xp,omitempty"`
|
||||||
|
XPNeeded int `json:"xp_needed,omitempty"` // 0 = at the level cap
|
||||||
ArmorTier int `json:"armor_tier,omitempty"`
|
ArmorTier int `json:"armor_tier,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +1055,8 @@ type EquipOrder struct {
|
|||||||
ItemID int64 `json:"item_id"`
|
ItemID int64 `json:"item_id"`
|
||||||
ItemName string `json:"item_name"`
|
ItemName string `json:"item_name"`
|
||||||
Slot string `json:"slot"`
|
Slot string `json:"slot"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"` // equip / unequip / upgrade / repair
|
||||||
|
Tier int `json:"tier"` // upgrade target tier (an EquipmentSlot tier); unused by the others
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -720,6 +1084,88 @@ func VerdictEquip(ctx context.Context, guid, status, detail string) error {
|
|||||||
return std.post(ctx, "/api/equip/verdict", payload)
|
return std.post(ctx, "/api/equip/verdict", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The action queue
|
||||||
|
//
|
||||||
|
// The equip queue's sibling, and the first one that plays the game rather than
|
||||||
|
// dressing the character. An owner clicks "Pull out" on their own adventurer
|
||||||
|
// page or "Take your bout" on the war room; Pete records the intent and we drain
|
||||||
|
// it here. Same non-idempotent problem, same answer: the poller guards on the
|
||||||
|
// order guid before it runs anything, because an extraction ends a run and a
|
||||||
|
// bout spends the day's only swing, and neither converges on a replay.
|
||||||
|
//
|
||||||
|
// Nothing in an order names a character. Pete resolves that from the session
|
||||||
|
// (one account, one localpart, one adventurer) so there is no id on the wire for
|
||||||
|
// a client to forge — the contrast with EquipOrder, which has to carry an item
|
||||||
|
// id and a slot, is deliberate.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// AdvOrder is one requested action as Pete describes it. owner_localpart is the
|
||||||
|
// Matrix localpart whose adventurer acts; token and character_name are display
|
||||||
|
// copy Pete froze at order time and we ignore both.
|
||||||
|
type AdvOrder struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
OwnerLocalpart string `json:"owner_localpart"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
CharacterName string `json:"character_name"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
// Params is the verb's arguments, and only the verbs that take any carry it:
|
||||||
|
// which zone, which supply loadout, how many days of sitting. It never names
|
||||||
|
// an adventurer — that still comes from the session on Pete's side — and
|
||||||
|
// every field in it is re-resolved against the game's own tables before it
|
||||||
|
// means anything, so a forged zone or a forged price buys nothing.
|
||||||
|
Params *AdvOrderParams `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvOrderParams is the union of every verb's arguments, flat rather than
|
||||||
|
// per-verb because there are three of them and each reads one or two fields.
|
||||||
|
// Anything a verb does not read is ignored rather than rejected.
|
||||||
|
type AdvOrderParams struct {
|
||||||
|
Zone string `json:"zone,omitempty"` // zone id, for expedition_start
|
||||||
|
Loadout string `json:"loadout,omitempty"` // lean|balanced|heavy, for expedition_start / resume
|
||||||
|
Days int `json:"days,omitempty"` // 7 or 30, for babysit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action names, the wire contract's half of storage.AdvAction* on Pete.
|
||||||
|
const (
|
||||||
|
AdvOrderExtract = "extract"
|
||||||
|
AdvOrderSiegeJoin = "siege_join"
|
||||||
|
AdvOrderExpedition = "expedition_start"
|
||||||
|
AdvOrderResume = "expedition_resume"
|
||||||
|
AdvOrderBabysit = "babysit"
|
||||||
|
// The three doors the web verbs' own refusal text used to name without
|
||||||
|
// offering: `!expedition abandon`, `!expedition leave`, `!adventure babysit
|
||||||
|
// cancel`. None of them takes an argument and none of them spends money.
|
||||||
|
AdvOrderAbandon = "expedition_abandon"
|
||||||
|
AdvOrderLeave = "expedition_leave"
|
||||||
|
AdvOrderBabysitCancel = "babysit_cancel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PendingOrders asks Pete for web actions waiting on us. A Pete predating the
|
||||||
|
// queue answers 404, surfaced here as an error the poll loop logs quietly.
|
||||||
|
func PendingOrders(ctx context.Context) ([]AdvOrder, error) {
|
||||||
|
if !Enabled() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []AdvOrder
|
||||||
|
if err := std.getJSON(ctx, "/api/adventure/orders/pending", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerdictOrder files our verdict on a web action. Idempotent on Pete, so a
|
||||||
|
// retried verdict is safe.
|
||||||
|
func VerdictOrder(ctx context.Context, guid, status, detail string) error {
|
||||||
|
payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return std.post(ctx, "/api/adventure/orders/verdict", payload)
|
||||||
|
}
|
||||||
|
|
||||||
// getJSON does a bearer-authed GET and decodes the body.
|
// getJSON does a bearer-authed GET and decodes the body.
|
||||||
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
|
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
|
||||||
|
|||||||
@@ -253,6 +253,12 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
// deaths, single-holder achievements) the first boot the seam is live, so
|
// 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).
|
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
|
||||||
p.bootstrapPeteNewsBackfill()
|
p.bootstrapPeteNewsBackfill()
|
||||||
|
// Repair the zone half of news_realm_firsts: the original one-shot filtered
|
||||||
|
// on `abandoned = 0` (which does not mean anybody gave up) and dated every
|
||||||
|
// row to the minute it ran. Seeds only, emits nothing. Runs regardless of the
|
||||||
|
// news switches — a ledger that is right only while emission is on mis-tiers
|
||||||
|
// the first dispatch after somebody flips it. One-shot, kept.
|
||||||
|
bootstrapRealmFirstsReseed()
|
||||||
// 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
|
||||||
@@ -292,6 +298,7 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
go p.mischiefTicker()
|
go p.mischiefTicker()
|
||||||
go p.peteMischiefTicker()
|
go p.peteMischiefTicker()
|
||||||
go p.peteEquipTicker()
|
go p.peteEquipTicker()
|
||||||
|
go p.peteAdvOrderTicker()
|
||||||
|
|
||||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||||
p.arenaCleanupStaleRuns()
|
p.arenaCleanupStaleRuns()
|
||||||
@@ -1367,6 +1374,7 @@ func (p *AdventurePlugin) sendTreasureDiscoveryDM(userID id.UserID, char *Advent
|
|||||||
"{treasure_name}": def.Name,
|
"{treasure_name}": def.Name,
|
||||||
"{bonus_desc}": def.InventoryDesc,
|
"{bonus_desc}": def.InventoryDesc,
|
||||||
"{location}": loc.Name,
|
"{location}": loc.Name,
|
||||||
|
"{location_mid}": advLocationMidSentence(loc.Name),
|
||||||
})
|
})
|
||||||
|
|
||||||
p.SendDM(userID, text)
|
p.SendDM(userID, text)
|
||||||
@@ -1393,8 +1401,9 @@ func (p *AdventurePlugin) announceTreasureToRoom(char *AdventureCharacter, def *
|
|||||||
}
|
}
|
||||||
displayName, _ := loadDisplayName(char.UserID)
|
displayName, _ := loadDisplayName(char.UserID)
|
||||||
announce := advSubstituteFlavor(def.RoomAnnounce, map[string]string{
|
announce := advSubstituteFlavor(def.RoomAnnounce, map[string]string{
|
||||||
"{name}": displayName,
|
"{name}": displayName,
|
||||||
"{location}": loc.Name,
|
"{location}": loc.Name,
|
||||||
|
"{location_mid}": advLocationMidSentence(loc.Name),
|
||||||
})
|
})
|
||||||
p.SendMessage(id.RoomID(gr), announce)
|
p.SendMessage(id.RoomID(gr), announce)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -100,33 +101,91 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
// Sentinels for the ways hiring a sitter can be refused, so the web action queue
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
// (pete_orders.go) can pick a verdict without parsing prose. Each is returned
|
||||||
|
// inside an advRefusal carrying the finished sentence, so `!adventure babysit`
|
||||||
|
// keeps the copy it always sent.
|
||||||
|
var (
|
||||||
|
errBabysitNoCharacter = errors.New("babysit: no adventurer")
|
||||||
|
errBabysitActive = errors.New("babysit: a sitter is already engaged")
|
||||||
|
errBabysitDead = errors.New("babysit: adventurer is dead")
|
||||||
|
errBabysitBroke = errors.New("babysit: cannot cover the fee")
|
||||||
|
errBabysitFailed = errors.New("babysit: could not engage a sitter")
|
||||||
|
)
|
||||||
|
|
||||||
|
// babysitOutcome is what hiring did, for a caller describing it somewhere other
|
||||||
|
// than a DM.
|
||||||
|
type babysitOutcome struct {
|
||||||
|
Days int
|
||||||
|
Cost int
|
||||||
|
PetName string
|
||||||
|
PetLine string
|
||||||
|
Confirm string
|
||||||
|
}
|
||||||
|
|
||||||
|
// performBabysitPurchase is `!adventure babysit week|month` minus the command
|
||||||
|
// framing. Shared with the web action queue so hiring a sitter from a phone
|
||||||
|
// engages the same one, on the same clock, with the same log reset.
|
||||||
|
//
|
||||||
|
// idemKey, when set, is the web order's guid and moves the fee onto DebitIdem so
|
||||||
|
// a re-offered order cannot charge twice.
|
||||||
|
func (p *AdventurePlugin) performBabysitPurchase(uid id.UserID, days int, idemKey string) (babysitOutcome, error) {
|
||||||
|
userMu := p.advUserLock(uid)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
char, err := loadAdvCharacter(ctx.Sender)
|
char, err := loadAdvCharacter(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
|
return babysitOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found. Type `!adventure` to create one.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if char.BabysitActive {
|
if char.BabysitActive {
|
||||||
return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
// A web order that already paid and already engaged the sitter lands here
|
||||||
|
// on the re-offer. The settled fee is what tells that apart from somebody
|
||||||
|
// who really does already have one.
|
||||||
|
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
||||||
|
// Re-quote the fee rather than leaving it zero: the verdict this
|
||||||
|
// feeds prints the coin figure, and "0 coins" would be a false
|
||||||
|
// receipt for a hire the player did pay for.
|
||||||
|
return babysitOutcome{
|
||||||
|
Days: days,
|
||||||
|
Cost: babysitDailyCost(dndLevelForUser(char.UserID)) * days,
|
||||||
|
PetName: char.PetName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return babysitOutcome{}, refuseAdv(errBabysitActive, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !char.Alive {
|
if !char.Alive {
|
||||||
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
|
return babysitOutcome{}, refuseAdv(errBabysitDead, "Your adventurer is dead. The babysitter does not work with corpses.")
|
||||||
}
|
}
|
||||||
|
|
||||||
daily := babysitDailyCost(dndLevelForUser(char.UserID))
|
daily := babysitDailyCost(dndLevelForUser(char.UserID))
|
||||||
totalCost := daily * days
|
totalCost := daily * days
|
||||||
balance := p.euro.GetBalance(char.UserID)
|
if p.euro == nil {
|
||||||
if balance < float64(totalCost) {
|
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Coin system unavailable — try again later.")
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance)))
|
}
|
||||||
|
// Skip the affordability gate on a re-offer that already paid: the fee is a
|
||||||
|
// settled fact, and re-reading the now-lower balance would bounce a sitter the
|
||||||
|
// player has bought.
|
||||||
|
if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) {
|
||||||
|
balance := p.euro.GetBalance(char.UserID)
|
||||||
|
if balance < float64(totalCost) {
|
||||||
|
return babysitOutcome{}, refuseAdv(errBabysitBroke,
|
||||||
|
"🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.",
|
||||||
|
fmtEuro(totalCost), days, fmtEuro(balance))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
|
debited := false
|
||||||
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
|
if idemKey != "" {
|
||||||
|
ok, _, err := p.euro.DebitIdem(char.UserID, float64(totalCost), "babysit_purchase", idemKey)
|
||||||
|
debited = err == nil && ok
|
||||||
|
} else {
|
||||||
|
debited = p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase")
|
||||||
|
}
|
||||||
|
if !debited {
|
||||||
|
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Payment failed. The babysitter looked at your wallet and walked away.")
|
||||||
}
|
}
|
||||||
|
|
||||||
clearBabysitLogs(char.UserID)
|
clearBabysitLogs(char.UserID)
|
||||||
@@ -138,23 +197,40 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
|||||||
|
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
|
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
|
||||||
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
if idemKey != "" {
|
||||||
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
|
if _, _, err := p.euro.CreditIdem(char.UserID, float64(totalCost), "babysit_refund", idemKey+":refund"); err != nil {
|
||||||
|
slog.Error("babysit: refund failed", "user", char.UserID, "order", idemKey, "err", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
||||||
|
}
|
||||||
|
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Something went wrong activating the service. Your gold has been refunded.")
|
||||||
}
|
}
|
||||||
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
||||||
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
|
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
confirm := pickBabysitFlavor(babysitConfirmLines)
|
|
||||||
durLabel := "1 week"
|
|
||||||
if days == 30 {
|
|
||||||
durLabel = "1 month"
|
|
||||||
}
|
|
||||||
|
|
||||||
petLine := "No pet to tend yet — the babysitter will keep that in mind."
|
petLine := "No pet to tend yet — the babysitter will keep that in mind."
|
||||||
if char.HasPet() {
|
if char.HasPet() {
|
||||||
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
|
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
|
||||||
}
|
}
|
||||||
|
return babysitOutcome{
|
||||||
|
Days: days, Cost: totalCost, PetName: char.PetName, PetLine: petLine,
|
||||||
|
Confirm: pickBabysitFlavor(babysitConfirmLines),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBabysitPurchase is the command framing around performBabysitPurchase.
|
||||||
|
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
||||||
|
out, err := p.performBabysitPurchase(ctx.Sender, days, "")
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
durLabel := "1 week"
|
||||||
|
if days == 30 {
|
||||||
|
durLabel = "1 month"
|
||||||
|
}
|
||||||
|
|
||||||
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
|
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
|
||||||
"Duration: %s (%d days)\n"+
|
"Duration: %s (%d days)\n"+
|
||||||
@@ -162,7 +238,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
|||||||
"%s\n"+
|
"%s\n"+
|
||||||
"Camp safety: standard camps now rest like fortified ones\n"+
|
"Camp safety: standard camps now rest like fortified ones\n"+
|
||||||
"Rival duels: declined on your behalf\n\n"+
|
"Rival duels: declined on your behalf\n\n"+
|
||||||
"_%s_", durLabel, days, totalCost, petLine, confirm)
|
"_%s_", durLabel, days, out.Cost, out.PetLine, out.Confirm)
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
@@ -209,25 +285,49 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
// babysitCancelOutcome is what dismissing the sitter did. Summary is the Matrix
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
// block of what they got through while they were here — a paragraph of counts,
|
||||||
|
// which is right under a DM and too much for a one-line web verdict, so the
|
||||||
|
// caller decides whether to print it.
|
||||||
|
type babysitCancelOutcome struct {
|
||||||
|
Summary string
|
||||||
|
PetName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// errBabysitNoSitter is the one way cancelling can be refused. There is no
|
||||||
|
// "already cancelled" race to worry about: the check and the write are both under
|
||||||
|
// the per-user lock this takes.
|
||||||
|
var errBabysitNoSitter = errors.New("babysit: no sitter to dismiss")
|
||||||
|
|
||||||
|
// performBabysitCancel is `!adventure babysit cancel` minus the command framing.
|
||||||
|
// Shared with the web action queue.
|
||||||
|
//
|
||||||
|
// This one TAKES the per-user lock, unlike the abandon/leave twins beside it in
|
||||||
|
// the order path — because its Matrix caller does not hold it (handleBabysitCmd
|
||||||
|
// dispatches straight here, where `!expedition` holds the lock across its whole
|
||||||
|
// switch). Its web wrapper must therefore NOT take it. The asymmetry is per verb
|
||||||
|
// and is worth checking against the Matrix caller every time one is added.
|
||||||
|
//
|
||||||
|
// No refund, by design: the sitter was already here.
|
||||||
|
func (p *AdventurePlugin) performBabysitCancel(uid id.UserID) (babysitCancelOutcome, error) {
|
||||||
|
userMu := p.advUserLock(uid)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
char, err := loadAdvCharacter(ctx.Sender)
|
char, err := loadAdvCharacter(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "No adventurer found.")
|
return babysitCancelOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !char.BabysitActive {
|
if !char.BabysitActive {
|
||||||
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
|
return babysitCancelOutcome{}, refuseAdv(errBabysitNoSitter, "🍼 There's nothing to cancel. The babysitter isn't here.")
|
||||||
}
|
}
|
||||||
|
|
||||||
logs, err := loadBabysitLogs(char.UserID)
|
logs, err := loadBabysitLogs(char.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||||
}
|
}
|
||||||
summary := renderBabysitSummary(char, logs)
|
out := babysitCancelOutcome{Summary: renderBabysitSummary(char, logs), PetName: char.PetName}
|
||||||
|
|
||||||
char.BabysitActive = false
|
char.BabysitActive = false
|
||||||
char.BabysitExpiresAt = nil
|
char.BabysitExpiresAt = nil
|
||||||
@@ -239,7 +339,15 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
|||||||
slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err)
|
slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||||
|
out, err := p.performBabysitCancel(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+out.Summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Expiry Check ────────────────────────────────────────────────────────────
|
// ── Expiry Check ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory
|
|||||||
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
|
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
|
||||||
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
|
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
|
||||||
|
|
||||||
|
// robbieLeftForTheHaul is the big-haul variant: he took enough in one go that
|
||||||
|
// walking off with only a handling fee would look bad. Takes the item list.
|
||||||
|
var robbieLeftForTheHaul = "Oh -- and I left you something. %s. " +
|
||||||
|
"You had me carting that lot down four flights, and a man who takes that much " +
|
||||||
|
"and gives back nothing isn't a bandit, he's a landlord. _winks_"
|
||||||
|
|
||||||
// ── Room Announcements ───────────────────────────────────────────────────────
|
// ── Room Announcements ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ var TreasureDiscovery = map[int][]string{
|
|||||||
// No exclamation marks. No enthusiasm. Just weight.
|
// No exclamation marks. No enthusiasm. Just weight.
|
||||||
5: {
|
5: {
|
||||||
"The {treasure_name}.\n\n" +
|
"The {treasure_name}.\n\n" +
|
||||||
"You found it in the Abyssal Maw. It found you in the Abyssal Maw. " +
|
"You found it in {location_mid}. It found you in {location_mid}. " +
|
||||||
"The distinction matters less at depth.\n" +
|
"The distinction matters less at depth.\n" +
|
||||||
"BONUS: {bonus_desc}.\n\n" +
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
"It came with you. Some things don't come with you. This one did. " +
|
"It came with you. Some things don't come with you. This one did. " +
|
||||||
@@ -240,15 +240,15 @@ var TreasureDiscovery = map[int][]string{
|
|||||||
|
|
||||||
"The {treasure_name} is in your inventory.\n\n" +
|
"The {treasure_name} is in your inventory.\n\n" +
|
||||||
"BONUS: {bonus_desc}.\n\n" +
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
"This is a Tier 5 rare. The Abyssal Maw doesn't give these up. " +
|
"This is a Tier 5 rare. Nothing in {location_mid} gives these up. " +
|
||||||
"The Abyssal Maw gave this one up. " +
|
"Something in {location_mid} gave this one up. " +
|
||||||
"That's a sentence worth sitting with.",
|
"That's a sentence worth sitting with.",
|
||||||
|
|
||||||
"You have the {treasure_name}.\n\n" +
|
"You have the {treasure_name}.\n\n" +
|
||||||
"BONUS: {bonus_desc}.\n\n" +
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
"It was in the Abyssal Maw. The things that were between you and it " +
|
"It was in {location_mid}. The things that were between you and it " +
|
||||||
"are not between anything and anything anymore. " +
|
"are not between anything and anything anymore. " +
|
||||||
"The Maw is noting this. So is the item.",
|
"The place is noting this. So is the item.",
|
||||||
|
|
||||||
"The {treasure_name}.\n\n" +
|
"The {treasure_name}.\n\n" +
|
||||||
"It's warm. It was warm when you found it, which it shouldn't be, " +
|
"It's warm. It was warm when you found it, which it shouldn't be, " +
|
||||||
@@ -262,14 +262,14 @@ var TreasureDiscovery = map[int][]string{
|
|||||||
"The {treasure_name}. {bonus_desc}.\n\n" +
|
"The {treasure_name}. {bonus_desc}.\n\n" +
|
||||||
"You have it. " +
|
"You have it. " +
|
||||||
"Keep it somewhere it won't be lost. " +
|
"Keep it somewhere it won't be lost. " +
|
||||||
"The Abyssal Maw does not give second chances.",
|
"There are no second chances in {location_mid}.",
|
||||||
|
|
||||||
"The {treasure_name} is yours.\n\n" +
|
"The {treasure_name} is yours.\n\n" +
|
||||||
"BONUS: {bonus_desc}.\n\n" +
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
"The Abyssal Maw had it. You have it now. " +
|
"It sat in {location_mid} until you took it. You have it now. " +
|
||||||
"The Maw is aware of the transfer. " +
|
"The place is aware of the transfer. " +
|
||||||
"The Maw is considering its position. " +
|
"The place is considering its position. " +
|
||||||
"You should not be in the Maw when it finishes considering.",
|
"You should not be in {location_mid} when it finishes considering.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPetXPTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGrantPetCombatXPPersists is the regression guard for the bug this fixes:
|
||||||
|
// petGrantXP existed but nothing called it, so an un-babysat pet sat at its
|
||||||
|
// adoption level forever. A win must move XP on disk.
|
||||||
|
func TestGrantPetCombatXPPersists(t *testing.T) {
|
||||||
|
newPetXPTestDB(t)
|
||||||
|
uid := id.UserID("@petxp:test")
|
||||||
|
|
||||||
|
pet := PetState{Type: "dog", Name: "Rex", Arrived: true, Level: 1, XP: 0}
|
||||||
|
if err := upsertPlayerMetaPetState(uid, pet); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if leveled := grantPetCombatXP(uid); len(leveled) != 0 {
|
||||||
|
t.Errorf("one win should not level a fresh pet, got %v", leveled)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadPetState(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.XP != int(petXPPerAction*100) {
|
||||||
|
t.Errorf("XP = %d, want %d", got.XP, int(petXPPerAction*100))
|
||||||
|
}
|
||||||
|
if got.Level != 1 {
|
||||||
|
t.Errorf("Level = %d, want 1", got.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGrantPetCombatXPLevelsBothSlots checks the second pet earns off the same
|
||||||
|
// win, matching the babysit trickle — combat only reads the two pets' averaged
|
||||||
|
// procs, so leveling both is not a power spike.
|
||||||
|
func TestGrantPetCombatXPLevelsBothSlots(t *testing.T) {
|
||||||
|
newPetXPTestDB(t)
|
||||||
|
uid := id.UserID("@petxp2:test")
|
||||||
|
|
||||||
|
// Both one grant short of level 2 (needs 10 XP = 1000 centi-XP).
|
||||||
|
short := 1000 - int(petXPPerAction*100)
|
||||||
|
if err := upsertPlayerMetaPetState(uid,
|
||||||
|
PetState{Type: "dog", Name: "Rex", Arrived: true, Level: 1, XP: short}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := upsertPlayerMetaPet2State(uid,
|
||||||
|
PetState{Type: "cat", Name: "Whiskers", Arrived: true, Level: 1, XP: short}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
leveled := grantPetCombatXP(uid)
|
||||||
|
if len(leveled) != 2 {
|
||||||
|
t.Fatalf("expected both pets to level, got %v", leveled)
|
||||||
|
}
|
||||||
|
|
||||||
|
p1, _ := loadPetState(uid)
|
||||||
|
p2, _ := loadPet2State(uid)
|
||||||
|
if p1.Level != 2 || p2.Level != 2 {
|
||||||
|
t.Errorf("levels = %d/%d, want 2/2", p1.Level, p2.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGrantPetCombatXPIgnoresChasedAway — a pet that isn't with you doesn't
|
||||||
|
// fight, so it doesn't earn.
|
||||||
|
func TestGrantPetCombatXPIgnoresChasedAway(t *testing.T) {
|
||||||
|
newPetXPTestDB(t)
|
||||||
|
uid := id.UserID("@petxp3:test")
|
||||||
|
|
||||||
|
if err := upsertPlayerMetaPetState(uid, PetState{
|
||||||
|
Type: "dog", Name: "Rex", Arrived: true, ChasedAway: true, Level: 3, XP: 100,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grantPetCombatXP(uid)
|
||||||
|
|
||||||
|
got, _ := loadPetState(uid)
|
||||||
|
if got.XP != 100 {
|
||||||
|
t.Errorf("chased-away pet gained XP: %d, want 100", got.XP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGrantPetCombatXPCapsAtTen — a maxed pet stops earning rather than
|
||||||
|
// accumulating dead XP.
|
||||||
|
func TestGrantPetCombatXPCapsAtTen(t *testing.T) {
|
||||||
|
newPetXPTestDB(t)
|
||||||
|
uid := id.UserID("@petxp4:test")
|
||||||
|
|
||||||
|
if err := upsertPlayerMetaPetState(uid, PetState{
|
||||||
|
Type: "dog", Name: "Rex", Arrived: true, Level: 10, XP: 0,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if leveled := grantPetCombatXP(uid); len(leveled) != 0 {
|
||||||
|
t.Errorf("L10 pet should not level, got %v", leveled)
|
||||||
|
}
|
||||||
|
got, _ := loadPetState(uid)
|
||||||
|
if got.XP != 0 || got.Level != 10 {
|
||||||
|
t.Errorf("L10 pet moved: level %d xp %d", got.Level, got.XP)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
|
|
||||||
const petXPPerAction = 1.5
|
const petXPPerAction = 1.5
|
||||||
|
|
||||||
|
// petMaxLevel is where the curve stops. Named because two things read it for
|
||||||
|
// different reasons: the level-up loop stops here, and the web push reports "no
|
||||||
|
// more to earn" from it.
|
||||||
|
const petMaxLevel = 10
|
||||||
|
|
||||||
var petNameValid = regexp.MustCompile(`^[a-zA-Z0-9 '\-]+$`)
|
var petNameValid = regexp.MustCompile(`^[a-zA-Z0-9 '\-]+$`)
|
||||||
|
|
||||||
// petXPToNextLevel returns XP needed for a given pet level.
|
// petXPToNextLevel returns XP needed for a given pet level.
|
||||||
@@ -31,6 +36,19 @@ func petXPToNextLevel(level int) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// petXPNeededCenti is petXPToNextLevel in the unit the stored ledger actually
|
||||||
|
// uses — centi-XP — and 0 once the pet is capped, so a caller can tell "nothing
|
||||||
|
// left to earn" from "needs another 10 points". Every comparison against a
|
||||||
|
// stored PetXP multiplies by 100 (see advancePetLevelsFromXP); anything reading
|
||||||
|
// the curve for display has to do the same, and doing it in one place is how the
|
||||||
|
// web push avoids getting it wrong.
|
||||||
|
func petXPNeededCenti(level int) int {
|
||||||
|
if level >= petMaxLevel {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return petXPToNextLevel(level) * 100
|
||||||
|
}
|
||||||
|
|
||||||
// petGrantXP adds a per-action XP grant to the pet and handles level-ups.
|
// petGrantXP adds a per-action XP grant to the pet and handles level-ups.
|
||||||
// Returns true if leveled up. Shares the level-up loop with the babysit trickle
|
// Returns true if leveled up. Shares the level-up loop with the babysit trickle
|
||||||
// via advancePetLevelsFromXP.
|
// via advancePetLevelsFromXP.
|
||||||
@@ -41,17 +59,62 @@ func petGrantXP(pet *PetState) bool {
|
|||||||
return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100))
|
return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantPetCombatXP pays both pet slots their per-action XP for a fight the
|
||||||
|
// player won, and returns the names of any pet that leveled so the caller can
|
||||||
|
// narrate it.
|
||||||
|
//
|
||||||
|
// This is the pet's only *earned* XP source. It used to ride the legacy daily
|
||||||
|
// activity loop, which R1 deleted — and for the whole life of Adventure 2.0
|
||||||
|
// nothing replaced it, leaving petGrantXP orphaned and every un-babysat pet
|
||||||
|
// frozen at the level it was adopted with. Pet level is not cosmetic
|
||||||
|
// (DerivePlayerStats scales PetAttackProc / PetDeflectProc / PetAttackDmg off
|
||||||
|
// it), so a frozen pet is a permanently dead combat slot.
|
||||||
|
//
|
||||||
|
// Both slots earn on the same win, matching the babysit trickle: combat only
|
||||||
|
// ever reads the two pets' *averaged* procs, so leveling both is not a spike.
|
||||||
|
//
|
||||||
|
// Writes go through the narrow per-slot pet upserts rather than
|
||||||
|
// saveAdvCharacter: this runs on the combat close-out path, which does not
|
||||||
|
// hold the per-user lock, and a full-row write from here could clobber a
|
||||||
|
// concurrent character save.
|
||||||
|
func grantPetCombatXP(userID id.UserID) []string {
|
||||||
|
var leveled []string
|
||||||
|
slots := []struct {
|
||||||
|
n int
|
||||||
|
load func(id.UserID) (PetState, error)
|
||||||
|
upsert func(id.UserID, PetState) error
|
||||||
|
}{
|
||||||
|
{1, loadPetState, upsertPlayerMetaPetState},
|
||||||
|
{2, loadPet2State, upsertPlayerMetaPet2State},
|
||||||
|
}
|
||||||
|
for _, s := range slots {
|
||||||
|
pet, err := s.load(userID)
|
||||||
|
if err != nil || !pet.HasPet() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
didLevel := petGrantXP(&pet)
|
||||||
|
if uerr := s.upsert(userID, pet); uerr != nil {
|
||||||
|
slog.Error("adventure: pet xp persist", "user", userID, "slot", s.n, "err", uerr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if didLevel {
|
||||||
|
leveled = append(leveled, fmt.Sprintf("%s reached level %d", pet.Name, pet.Level))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return leveled
|
||||||
|
}
|
||||||
|
|
||||||
// advancePetLevelsFromXP adds centi-XP to a pet and applies any level-ups, up
|
// advancePetLevelsFromXP adds centi-XP to a pet and applies any level-ups, up
|
||||||
// to the level-10 cap, stamping the level-10 date on first reaching it. Shared
|
// to the level-10 cap, stamping the level-10 date on first reaching it. Shared
|
||||||
// by both pet slots (the babysit trickle). Returns true if the pet leveled.
|
// by both pet slots (the babysit trickle). Returns true if the pet leveled.
|
||||||
func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool {
|
func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool {
|
||||||
if *level >= 10 {
|
if *level >= petMaxLevel {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
*xp += addCentiXP
|
*xp += addCentiXP
|
||||||
leveled := false
|
leveled := false
|
||||||
for *level < 10 {
|
for *level < petMaxLevel {
|
||||||
needed := petXPToNextLevel(*level) * 100
|
needed := petXPNeededCenti(*level)
|
||||||
if *xp < needed {
|
if *xp < needed {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -59,7 +122,7 @@ func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int)
|
|||||||
*level++
|
*level++
|
||||||
leveled = true
|
leveled = true
|
||||||
}
|
}
|
||||||
if *level >= 10 && *level10Date == "" {
|
if *level >= petMaxLevel && *level10Date == "" {
|
||||||
*level10Date = time.Now().UTC().Format("2006-01-02")
|
*level10Date = time.Now().UTC().Format("2006-01-02")
|
||||||
}
|
}
|
||||||
return leveled
|
return leveled
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ func advClearFlavorHistory() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// advLocationMidSentence renders a location display name for use after a
|
||||||
|
// preposition: "The Abyssal Maw" becomes "the Abyssal Maw" so a line reads
|
||||||
|
// "found in the Abyssal Maw" rather than "found in The Abyssal Maw".
|
||||||
|
// Templates opt in with {location_mid}; {location} keeps the display form for
|
||||||
|
// sentence-initial and "The {location}" positions.
|
||||||
|
func advLocationMidSentence(name string) string {
|
||||||
|
if strings.HasPrefix(name, "The ") {
|
||||||
|
return "the " + strings.TrimPrefix(name, "The ")
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
// advSubstituteFlavor replaces {var} placeholders in a flavor text string.
|
// advSubstituteFlavor replaces {var} placeholders in a flavor text string.
|
||||||
func advSubstituteFlavor(template string, vars map[string]string) string {
|
func advSubstituteFlavor(template string, vars map[string]string) string {
|
||||||
pairs := make([]string, 0, len(vars)*2)
|
pairs := make([]string, 0, len(vars)*2)
|
||||||
|
|||||||
@@ -164,20 +164,18 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
gaveCard = true
|
gaveCard = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update visit count, and every 10th visit leave a small consumable
|
// Update visit count and work out what he leaves behind.
|
||||||
// "for the trouble" (D2 NPC arc).
|
var leftGifts []AdvItem
|
||||||
var leftGift *AdvItem
|
|
||||||
char, err := loadAdvCharacter(userID)
|
char, err := loadAdvCharacter(userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
char.RobbieVisitCount++
|
char.RobbieVisitCount++
|
||||||
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
|
// Use the canonical DnD level (like the arena's tier gate), not the
|
||||||
// Use the canonical DnD level (like the arena's tier gate), not the
|
// frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup
|
||||||
// frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup
|
// completes, so reading it here would peg every gift at tier 1.
|
||||||
// completes, so reading it here would peg every gift at tier 1.
|
tier := robbieGiftTier(arenaDnDLevelOrZero(userID))
|
||||||
if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 {
|
for _, gift := range consumableCache(tier, robbieGiftCount(char.RobbieVisitCount, len(takenItems))) {
|
||||||
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
if err := addAdvInventoryItem(userID, gift); err == nil {
|
||||||
leftGift = &gifts[0]
|
leftGifts = append(leftGifts, gift)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = saveAdvCharacter(char)
|
_ = saveAdvCharacter(char)
|
||||||
@@ -185,7 +183,7 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send DM
|
// Send DM
|
||||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGift)
|
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGifts)
|
||||||
if err := p.SendDM(userID, dm); err != nil {
|
if err := p.SendDM(userID, dm); err != nil {
|
||||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||||
}
|
}
|
||||||
@@ -213,12 +211,17 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||||
var result []AdvItem
|
var result []AdvItem
|
||||||
for _, item := range inv {
|
for _, item := range inv {
|
||||||
// Never touch Arena gear, cards, consumables, or keys. Consumables are
|
// Never touch Arena gear, cards, consumables, keys, or tools.
|
||||||
// a player-curated stockpile (crafted or dropped); selling them is an
|
// Consumables are a player-curated stockpile (crafted or dropped);
|
||||||
// explicit decision the player must make themselves. Keys are cross-zone
|
// selling them is an explicit decision the player must make themselves.
|
||||||
// unlock tokens (N5/D4) that must persist in inventory to open their
|
// Keys are cross-zone unlock tokens (N5/D4) that must persist in
|
||||||
// vault later — sweeping one permanently breaks that unlock.
|
// inventory to open their vault later — sweeping one permanently breaks
|
||||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" {
|
// that unlock. Tools are the same shape of promise: thieves' tools are
|
||||||
|
// bought precisely so a locked fork can be opened *later*, and a bandit
|
||||||
|
// who pockets them between the purchase and the door has taken the
|
||||||
|
// thing the player paid to still have.
|
||||||
|
switch item.Type {
|
||||||
|
case "ArenaGear", "card", "consumable", "key", thievesToolsItemType:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +270,49 @@ func robbiePlayerHasCard(userID id.UserID) bool {
|
|||||||
|
|
||||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind.
|
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind on the
|
||||||
|
// loyalty track alone, independent of how much he hauled off.
|
||||||
const robbieGiftEveryNVisits = 10
|
const robbieGiftEveryNVisits = 10
|
||||||
|
|
||||||
|
// robbieHaulPerGift is how many items one visit has to be worth before Robbie
|
||||||
|
// leaves something for the trouble, and robbieMaxHaulGifts caps how generous a
|
||||||
|
// single monster haul can get.
|
||||||
|
//
|
||||||
|
// The loyalty track on its own was far too thin to read as a reward: a visit is
|
||||||
|
// a 40% daily roll, so every-10-visits works out to one consumable per ~25 real
|
||||||
|
// days — and it paid exactly the same for a stockpile of sixty items as it did
|
||||||
|
// for one rock. Volume is the thing the player actually controls, so volume is
|
||||||
|
// what the haul track pays on.
|
||||||
|
const (
|
||||||
|
robbieHaulPerGift = 15
|
||||||
|
robbieMaxHaulGifts = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// robbieGiftCount returns how many consumables Robbie leaves this visit: the
|
||||||
|
// every-Nth-visit loyalty gift plus one per robbieHaulPerGift items carried
|
||||||
|
// off, capped. Pure so the curve is testable without a DB or a Matrix stub.
|
||||||
|
func robbieGiftCount(visitCount, itemsTaken int) int {
|
||||||
|
n := 0
|
||||||
|
if visitCount > 0 && visitCount%robbieGiftEveryNVisits == 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if haul := itemsTaken / robbieHaulPerGift; haul > 0 {
|
||||||
|
n += min(haul, robbieMaxHaulGifts)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinAnd renders a list as "a", "a and b", or "a, b and c".
|
||||||
|
func joinAnd(xs []string) string {
|
||||||
|
switch len(xs) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
return xs[0]
|
||||||
|
}
|
||||||
|
return strings.Join(xs[:len(xs)-1], ", ") + " and " + xs[len(xs)-1]
|
||||||
|
}
|
||||||
|
|
||||||
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
||||||
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
||||||
func robbieGiftTier(level int) int {
|
func robbieGiftTier(level int) int {
|
||||||
@@ -287,7 +330,7 @@ func robbieGiftTier(level int) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGift *AdvItem) string {
|
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGifts []AdvItem) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Opening
|
// Opening
|
||||||
@@ -334,9 +377,19 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// Every-10th-visit consumable (D2).
|
// What he left behind: the every-10th-visit loyalty consumable (D2), the
|
||||||
if leftGift != nil {
|
// big-haul thank-you, or both rolled into one line. A single gift keeps the
|
||||||
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name))
|
// original loyalty phrasing; anything more is the haul talking.
|
||||||
|
if len(leftGifts) > 0 {
|
||||||
|
names := make([]string, 0, len(leftGifts))
|
||||||
|
for _, g := range leftGifts {
|
||||||
|
names = append(names, g.Name)
|
||||||
|
}
|
||||||
|
if len(names) == 1 {
|
||||||
|
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, names[0]))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf(robbieLeftForTheHaul, joinAnd(names)))
|
||||||
|
}
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestRobbieGiftCount pins the two tracks: the every-10th-visit loyalty gift
|
||||||
|
// and the volume track that pays for a big haul, capped so one monster
|
||||||
|
// stockpile can't mint an unbounded pile of consumables.
|
||||||
|
func TestRobbieGiftCount(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
visits, taken int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"small haul, off-loyalty visit", 7, 3, 0},
|
||||||
|
{"loyalty visit only", 10, 3, 1},
|
||||||
|
{"haul only", 7, 15, 1},
|
||||||
|
{"haul and loyalty stack", 20, 15, 2},
|
||||||
|
{"haul scales", 7, 45, 3},
|
||||||
|
{"haul capped", 7, 500, robbieMaxHaulGifts},
|
||||||
|
{"cap plus loyalty", 30, 500, robbieMaxHaulGifts + 1},
|
||||||
|
{"one under the haul threshold", 7, robbieHaulPerGift - 1, 0},
|
||||||
|
{"zeroth visit is not a loyalty visit", 0, 0, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := robbieGiftCount(c.visits, c.taken); got != c.want {
|
||||||
|
t.Errorf("%s: robbieGiftCount(%d, %d) = %d, want %d",
|
||||||
|
c.name, c.visits, c.taken, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinAnd(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{nil, ""},
|
||||||
|
{[]string{"a"}, "a"},
|
||||||
|
{[]string{"a", "b"}, "a and b"},
|
||||||
|
{[]string{"a", "b", "c"}, "a, b and c"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := joinAnd(c.in); got != c.want {
|
||||||
|
t.Errorf("joinAnd(%v) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -845,7 +845,13 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
|||||||
var keptConsumable int
|
var keptConsumable int
|
||||||
var keptMagic int
|
var keptMagic int
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
|
// Keys and tools ride along with the special gear here for the same
|
||||||
|
// reason Robbie won't take them: both are bought or found precisely so a
|
||||||
|
// door can be opened *later*, and `sell all` is a bulk-loot verb the
|
||||||
|
// player fires after every haul without reading the list. Turning one
|
||||||
|
// into €300 silently deletes the unlock it was carried for.
|
||||||
|
switch item.Type {
|
||||||
|
case "MasterworkGear", "ArenaGear", "card", "key", thievesToolsItemType:
|
||||||
keptSpecial++
|
keptSpecial++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1019,6 +1025,9 @@ func luigiSuppliesView(_ id.UserID, balance float64) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("**%s** — €%d\n Opens one dungeon path a failed check closed (`!zone unlock <n>`). Not used in combat.\n\n",
|
||||||
|
thievesToolsName, thievesToolsPrice))
|
||||||
|
|
||||||
sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
|
sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
|
||||||
sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
|
sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -1073,6 +1082,13 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio
|
|||||||
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
|
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thieves' tools sit on the supplies shelf but are not a ConsumableDef:
|
||||||
|
// the combat engine scans inventory against that table and would happily
|
||||||
|
// spend them mid-fight. They get their own branch and their own item type.
|
||||||
|
if isThievesToolsReply(reply) {
|
||||||
|
return p.buyThievesTools(ctx, interaction)
|
||||||
|
}
|
||||||
|
|
||||||
// Find matching consumable
|
// Find matching consumable
|
||||||
var match *ConsumableDef
|
var match *ConsumableDef
|
||||||
for i := range consumableDefs {
|
for i := range consumableDefs {
|
||||||
@@ -1115,6 +1131,34 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio
|
|||||||
match.Name, consumablePrice, newBalance))
|
match.Name, consumablePrice, newBalance))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buyThievesTools sells one set off the supplies shelf. Mirrors the consumable
|
||||||
|
// purchase beside it — same session price factor, same 5% pot cut — and leaves
|
||||||
|
// the player in the supplies view so they can buy a second.
|
||||||
|
func (p *AdventurePlugin) buyThievesTools(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||||
|
price := float64(thievesToolsPrice) * p.shopSessionPriceFactor(ctx.Sender)
|
||||||
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
|
if balance < price {
|
||||||
|
p.pending.Store(string(ctx.Sender), interaction)
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.",
|
||||||
|
price, thievesToolsName, balance))
|
||||||
|
}
|
||||||
|
p.euro.Debit(ctx.Sender, price, "shop_thieves_tools")
|
||||||
|
if potCut := int(math.Round(price * 0.05)); potCut > 0 {
|
||||||
|
communityPotAdd(potCut)
|
||||||
|
trackTaxPaid(ctx.Sender, potCut)
|
||||||
|
}
|
||||||
|
_ = addAdvInventoryItem(ctx.Sender, AdvItem{
|
||||||
|
Name: thievesToolsName,
|
||||||
|
Type: thievesToolsItemType,
|
||||||
|
Tier: 1,
|
||||||
|
Value: thievesToolsPrice / 2,
|
||||||
|
})
|
||||||
|
p.pending.Store(string(ctx.Sender), interaction)
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"Purchased **%s** for €%.0f. You carry %d.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
|
||||||
|
thievesToolsName, price, countThievesTools(ctx.Sender), p.euro.GetBalance(ctx.Sender)))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Curios (Magic Items) ────────────────────────────────────────────────────
|
// ── Curios (Magic Items) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// curiosStockSize — how many registry magic items Luigi stocks per day.
|
// curiosStockSize — how many registry magic items Luigi stocks per day.
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ var advTreasureDropRates = map[int]float64{
|
|||||||
3: 0.008,
|
3: 0.008,
|
||||||
4: 0.004,
|
4: 0.004,
|
||||||
5: 0.0015,
|
5: 0.0015,
|
||||||
|
// Tier 6 (Mythic post-game) breaks the downward curve on purpose. The rate
|
||||||
|
// per roll falls tier over tier because the lower tiers are ground daily;
|
||||||
|
// a Mythic run is gated behind L18 and both T5 bosses and happens rarely,
|
||||||
|
// so the same declining rate would mean a postgame player effectively never
|
||||||
|
// sees a treasure. Slightly above T5 keeps the per-run odds in the band the
|
||||||
|
// other tiers land in.
|
||||||
|
//
|
||||||
|
// NOTE: this rate is inert until advAllTreasures gains a tier 6 pool — see
|
||||||
|
// the TODO there. A tier with a rate but no pool drops nothing.
|
||||||
|
6: 0.002,
|
||||||
}
|
}
|
||||||
|
|
||||||
const advMaxTreasures = 3
|
const advMaxTreasures = 3
|
||||||
@@ -213,7 +223,7 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
|||||||
{Type: "success_chance", Value: 10},
|
{Type: "success_chance", Value: 10},
|
||||||
},
|
},
|
||||||
InventoryDesc: "[THUNDERFURY, BLESSED BLADE OF THE WINDSEEKER]. Yes you got it. +12 Combat.",
|
InventoryDesc: "[THUNDERFURY, BLESSED BLADE OF THE WINDSEEKER]. Yes you got it. +12 Combat.",
|
||||||
RoomAnnounce: "⚡ Did {name} get Thunderfury? {name} got Thunderfury. [THUNDERFURY, BLESSED BLADE OF THE WINDSEEKER] has been found in {location}.",
|
RoomAnnounce: "⚡ Did {name} get Thunderfury? {name} got Thunderfury. [THUNDERFURY, BLESSED BLADE OF THE WINDSEEKER] has been found in {location_mid}.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: "ocarina", Name: "The Ocarina (Cracked, Still Plays)", Tier: 4,
|
Key: "ocarina", Name: "The Ocarina (Cracked, Still Plays)", Tier: 4,
|
||||||
@@ -221,6 +231,13 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
|||||||
InventoryDesc: "The Ocarina (Cracked). Three songs. +10 all skills. Do not play the third one.",
|
InventoryDesc: "The Ocarina (Cracked). Three songs. +10 all skills. Do not play the third one.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// TODO(t6-treasures): there is no tier 6 pool yet, so Mythic zones drop no
|
||||||
|
// treasure at all — advTreasureDropRates has a tier 6 rate waiting for it.
|
||||||
|
// What's missing is the content: four Mythic treasures with bonuses above
|
||||||
|
// the T5 line, InventoryDesc + RoomAnnounce strings for each (RoomAnnounce
|
||||||
|
// must use {location_mid}, never a literal zone name), and a TIER 6 register
|
||||||
|
// in TreasureDiscovery. Write them against internal/flavor/VOICE_CANON.md;
|
||||||
|
// the T5 pool below is the tonal floor to clear, not the ceiling.
|
||||||
5: {
|
5: {
|
||||||
{
|
{
|
||||||
Key: "shard_of_unnamed", Name: "Shard of the Unnamed", Tier: 5,
|
Key: "shard_of_unnamed", Name: "Shard of the Unnamed", Tier: 5,
|
||||||
@@ -230,13 +247,13 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
|||||||
{Type: "death_chance", Value: -5},
|
{Type: "death_chance", Value: -5},
|
||||||
},
|
},
|
||||||
InventoryDesc: "Shard of the Unnamed. +15 Combat, +10% XP, -5% death.",
|
InventoryDesc: "Shard of the Unnamed. +15 Combat, +10% XP, -5% death.",
|
||||||
RoomAnnounce: "🔴 {name} has recovered the Shard of the Unnamed from the Abyssal Maw. The server feels different.",
|
RoomAnnounce: "🔴 {name} has recovered the Shard of the Unnamed from {location_mid}. The server feels different.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: "cartographers_final_map", Name: "The Cartographer's Final Map", Tier: 5,
|
Key: "cartographers_final_map", Name: "The Cartographer's Final Map", Tier: 5,
|
||||||
Bonuses: []advTreasureBonusDef{{Type: "all_skills", Value: 12}},
|
Bonuses: []advTreasureBonusDef{{Type: "all_skills", Value: 12}},
|
||||||
InventoryDesc: "The Cartographer's Final Map. Updates on its own. +12 all skills, full map.",
|
InventoryDesc: "The Cartographer's Final Map. Updates on its own. +12 all skills, full map.",
|
||||||
RoomAnnounce: "🔴 {name} has found the Cartographer's Final Map in the Abyssal Maw. It has their name on it. It always did.",
|
RoomAnnounce: "🔴 {name} has found the Cartographer's Final Map in {location_mid}. It has their name on it. It always did.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: "triforce_shard", Name: "The Triforce Shard (One Third of Something Larger)", Tier: 5,
|
Key: "triforce_shard", Name: "The Triforce Shard (One Third of Something Larger)", Tier: 5,
|
||||||
@@ -246,7 +263,7 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
|||||||
// Note: +15 to chosen skill is v2 interactive
|
// Note: +15 to chosen skill is v2 interactive
|
||||||
},
|
},
|
||||||
InventoryDesc: "Triforce Shard (×1/3). Warm. Waiting. +5 all skills, -8% death.",
|
InventoryDesc: "Triforce Shard (×1/3). Warm. Waiting. +5 all skills, -8% death.",
|
||||||
RoomAnnounce: "🔺 {name} has recovered a Triforce Shard from the Abyssal Maw. One third of something. The other two thirds are somewhere. Probably.",
|
RoomAnnounce: "🔺 {name} has recovered a Triforce Shard from {location_mid}. One third of something. The other two thirds are somewhere. Probably.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: "the_corridor", Name: "The Corridor (You Know the One)", Tier: 5,
|
Key: "the_corridor", Name: "The Corridor (You Know the One)", Tier: 5,
|
||||||
@@ -255,7 +272,7 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
|||||||
{Type: "special_monthly_death_bypass", Value: 1}, // v2
|
{Type: "special_monthly_death_bypass", Value: 1}, // v2
|
||||||
},
|
},
|
||||||
InventoryDesc: "The Corridor. Folded. Don't look back. +12 all skills, monthly death bypass.",
|
InventoryDesc: "The Corridor. Folded. Don't look back. +12 all skills, monthly death bypass.",
|
||||||
RoomAnnounce: "🔴 {name} found The Corridor in the Abyssal Maw. They know the one. So does it.",
|
RoomAnnounce: "🔴 {name} found The Corridor in {location_mid}. They know the one. So does it.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -285,7 +302,12 @@ func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int, weig
|
|||||||
|
|
||||||
pool, ok := advAllTreasures[tier]
|
pool, ok := advAllTreasures[tier]
|
||||||
if !ok || len(pool) == 0 {
|
if !ok || len(pool) == 0 {
|
||||||
return nil, roll, rate
|
// A tier that has a rate but no pool (tier 6, until the TODO above is
|
||||||
|
// filled) cannot drop anything. Report a zero rate rather than the
|
||||||
|
// configured one: the caller turns a close roll into a "just missed"
|
||||||
|
// DM, and telling a postgame player they nearly won a treasure that
|
||||||
|
// cannot be won is worse than staying quiet.
|
||||||
|
return nil, 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick random treasure
|
// Pick random treasure
|
||||||
|
|||||||
@@ -66,6 +66,36 @@ func TestAdvTreasureDropDetailed_ForcedRollGrantsTreasure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAdvTreasureTier6_PoolStillMissing pins the known tier 6 gap: Mythic zones
|
||||||
|
// carry a drop rate but no treasure pool, so a forced roll yields nothing and
|
||||||
|
// the player is told nothing either (a "just missed" DM for an unwinnable
|
||||||
|
// treasure would be a lie).
|
||||||
|
//
|
||||||
|
// This test is the reminder. Writing advAllTreasures[6] — see TODO(t6-treasures)
|
||||||
|
// in adventure_treasure.go — turns it red, and the fix is to delete it and
|
||||||
|
// assert the tier 6 drop instead, the way ForcedRollGrantsTreasure does for
|
||||||
|
// tier 1.
|
||||||
|
func TestAdvTreasureTier6_PoolStillMissing(t *testing.T) {
|
||||||
|
if err := db.Init(t.TempDir()); err != nil {
|
||||||
|
t.Fatalf("db.Init: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := advAllTreasures[6]; ok {
|
||||||
|
t.Fatal("a tier 6 treasure pool now exists — drop this test and assert the drop instead")
|
||||||
|
}
|
||||||
|
// The rate is configured and waiting, so the gap is the pool alone.
|
||||||
|
if advTreasureDropRates[6] == 0 {
|
||||||
|
t.Error("tier 6 drop rate went missing; a Mythic pool would be unreachable")
|
||||||
|
}
|
||||||
|
drop, roll, rate := rollAdvTreasureDropDetailed(6, "@mythic:example.org", 0, 1000)
|
||||||
|
if drop != nil {
|
||||||
|
t.Fatalf("tier 6 produced a drop from an empty pool: %+v", drop.Def)
|
||||||
|
}
|
||||||
|
// Zeroed, not merely no-drop: this is what keeps the near-miss DM quiet.
|
||||||
|
if roll != 0 || rate != 0 {
|
||||||
|
t.Errorf("empty pool reported roll=%v rate=%v, want 0/0 so no near-miss fires", roll, rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The treasure and masterwork systems predate zones and speak AdvLocation.
|
// The treasure and masterwork systems predate zones and speak AdvLocation.
|
||||||
func TestAdvLocForZone(t *testing.T) {
|
func TestAdvLocForZone(t *testing.T) {
|
||||||
loc := advLocForZone(ZoneGoblinWarrens)
|
loc := advLocForZone(ZoneGoblinWarrens)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -394,14 +395,15 @@ func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.announceWorldBossSpawn(boss, activeN)
|
p.announceWorldBossSpawn(boss, activeN)
|
||||||
|
emitSiegeStart(boss)
|
||||||
slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN)
|
slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN)
|
||||||
return boss, nil
|
return boss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// worldBossTick rides the 1-minute event ticker. It auto-spawns a boss on the
|
// worldBossTick rides the 1-minute event ticker. It auto-spawns the month's
|
||||||
// first of each UTC month and resolves a live boss whose window has lapsed. The
|
// boss and resolves a live boss whose window has lapsed. The defeat path is not
|
||||||
// defeat path is not here — a bout crossing the pool to zero resolves inline
|
// here — a bout crossing the pool to zero resolves inline (W2), because the
|
||||||
// (W2), because the ticker never sees the pool between two 60s reads.
|
// ticker never sees the pool between two 60s reads.
|
||||||
func (p *AdventurePlugin) worldBossTick() {
|
func (p *AdventurePlugin) worldBossTick() {
|
||||||
boss, err := loadActiveWorldBoss()
|
boss, err := loadActiveWorldBoss()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -423,10 +425,17 @@ func (p *AdventurePlugin) worldBossTick() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// No boss camped — auto-spawn on the 1st of the month, once.
|
// No boss camped — spawn this month's Siege, once.
|
||||||
if now.Day() != 1 {
|
//
|
||||||
return
|
// The month key below is the whole dedup, so the rule is simply "one Siege
|
||||||
}
|
// per calendar month, as early as the process is up to run it." It used to
|
||||||
|
// additionally require now.Day() == 1, which deadlocked the entire feature:
|
||||||
|
// the world boss shipped mid-July 2026 and prod never once ran a first-of-
|
||||||
|
// the-month tick with the code in it, so `select count(*) from world_boss`
|
||||||
|
// was still 0 weeks later. The day gate also silently skipped any month
|
||||||
|
// where the bot happened to be down or redeploying across the 1st, with no
|
||||||
|
// catch-up. Dropping it makes a missed 1st self-heal on the next tick
|
||||||
|
// instead of costing the town a month.
|
||||||
monthKey := now.Format("2006-01")
|
monthKey := now.Format("2006-01")
|
||||||
if db.JobCompleted("worldboss_spawn", monthKey) {
|
if db.JobCompleted("worldboss_spawn", monthKey) {
|
||||||
return
|
return
|
||||||
@@ -489,6 +498,7 @@ func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.announceWorldBossDefeated(boss, payouts)
|
p.announceWorldBossDefeated(boss, payouts)
|
||||||
|
emitSiegeWin(boss, len(payouts))
|
||||||
slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts))
|
slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +519,7 @@ func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) {
|
|||||||
paid = tribute
|
paid = tribute
|
||||||
}
|
}
|
||||||
p.announceWorldBossSurvived(boss, paid)
|
p.announceWorldBossSurvived(boss, paid)
|
||||||
|
emitSiegeLoss(boss)
|
||||||
slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid)
|
slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,42 +608,58 @@ func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error {
|
|||||||
boss.Name, boss.Tier, groupInt(boss.HPMax)))
|
boss.Name, boss.Tier, groupInt(boss.HPMax)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// fightWorldBoss runs one player's daily bout against the Siege: an arena-style
|
// Sentinels for the four ways a bout can be refused, so a headless caller (the
|
||||||
|
// web action queue, pete_orders.go) can turn each into its own verdict instead of
|
||||||
|
// parsing a DM. `!adventure worldboss fight` maps them back to the prose it
|
||||||
|
// always sent.
|
||||||
|
var (
|
||||||
|
errSiegeNoBoss = errors.New("siege: nothing camped outside town")
|
||||||
|
errSiegeNoCharacter = errors.New("siege: no adventurer")
|
||||||
|
errSiegeDead = errors.New("siege: adventurer is dead")
|
||||||
|
errSiegeAlreadyFought = errors.New("siege: today's bout already spent")
|
||||||
|
)
|
||||||
|
|
||||||
|
// takeSiegeBout runs one player's daily bout against the Siege: an arena-style
|
||||||
// solo fight whose damage is subtracted from the shared pool win or lose. Real
|
// solo fight whose damage is subtracted from the shared pool win or lose. Real
|
||||||
// HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but
|
// HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but
|
||||||
// standing. The per-user lock serialises a player's own repeat submits, so the
|
// standing. The per-user lock serialises a player's own repeat submits, so the
|
||||||
// once-per-day gate can't be raced by a double-tap.
|
// once-per-day gate can't be raced by a double-tap — and that same lock is what
|
||||||
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
// makes it safe for the web queue and a Matrix command to reach for the bout at
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
// the same moment.
|
||||||
|
//
|
||||||
|
// The combat narration is DM'd from here whichever door the bout came through: a
|
||||||
|
// fight is thirty lines of blow-by-blow and belongs in Matrix, not in a one-line
|
||||||
|
// verdict on a web page. The caller gets the boss and the result to describe.
|
||||||
|
func (p *AdventurePlugin) takeSiegeBout(uid id.UserID) (worldBossBoutResult, *worldBossState, error) {
|
||||||
|
userMu := p.advUserLock(uid)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
boss, err := loadActiveWorldBoss()
|
boss, err := loadActiveWorldBoss()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
|
return worldBossBoutResult{}, nil, fmt.Errorf("reaching the Siege: %w", err)
|
||||||
}
|
}
|
||||||
if boss == nil {
|
if boss == nil {
|
||||||
return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.")
|
return worldBossBoutResult{}, nil, errSiegeNoBoss
|
||||||
}
|
}
|
||||||
|
|
||||||
char, err := loadAdvCharacter(ctx.Sender)
|
char, err := loadAdvCharacter(uid)
|
||||||
if err != nil || char == nil {
|
if err != nil || char == nil {
|
||||||
return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.")
|
return worldBossBoutResult{}, boss, errSiegeNoCharacter
|
||||||
}
|
}
|
||||||
if !char.Alive {
|
if !char.Alive {
|
||||||
return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.")
|
return worldBossBoutResult{}, boss, errSiegeDead
|
||||||
}
|
}
|
||||||
|
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) {
|
if worldBossBoutUsedToday(boss.ID, uid, today) {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return worldBossBoutResult{}, boss, errSiegeAlreadyFought
|
||||||
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today)
|
bout, err := p.resolveWorldBossBout(uid, boss, today)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err)
|
slog.Error("worldboss: bout failed", "user", uid, "err", err)
|
||||||
return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.")
|
return worldBossBoutResult{}, boss, fmt.Errorf("running the bout: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is
|
// Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is
|
||||||
@@ -643,7 +670,7 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
|||||||
p.resolveWorldBossDefeated(boss)
|
p.resolveWorldBossDefeated(boss)
|
||||||
}
|
}
|
||||||
|
|
||||||
playerName, _ := loadDisplayName(ctx.Sender)
|
playerName, _ := loadDisplayName(uid)
|
||||||
if playerName == "" {
|
if playerName == "" {
|
||||||
playerName = "You"
|
playerName = "You"
|
||||||
}
|
}
|
||||||
@@ -651,18 +678,45 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
|||||||
fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier),
|
fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier),
|
||||||
}, RenderCombatLog(bout.Combat, playerName, boss.Name)...)
|
}, RenderCombatLog(bout.Combat, playerName, boss.Name)...)
|
||||||
|
|
||||||
|
<-p.sendZoneCombatMessages(uid, phaseMessages, siegeBoutFooter(bout, boss))
|
||||||
|
return bout, boss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// siegeBoutFooter is the one-line result of a bout — the damage dealt and what
|
||||||
|
// the pool looks like now. It closes the Matrix narration and doubles as the web
|
||||||
|
// verdict, so the two doors can't drift into describing the same fight
|
||||||
|
// differently.
|
||||||
|
func siegeBoutFooter(bout worldBossBoutResult, boss *worldBossState) string {
|
||||||
var footer string
|
var footer string
|
||||||
if bout.Killed {
|
if bout.Killed {
|
||||||
footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name)
|
footer = fmt.Sprintf("💥 You deal **%d** damage: the killing blow! **%s** falls!", bout.Damage, boss.Name)
|
||||||
} else {
|
} else {
|
||||||
footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.",
|
footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.",
|
||||||
bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax))
|
bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax))
|
||||||
}
|
}
|
||||||
if bout.Battered {
|
if bout.Battered {
|
||||||
footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing."
|
footer += "\nYou stagger out of the fight at 1 HP. Rest up before your next outing."
|
||||||
}
|
}
|
||||||
|
return footer
|
||||||
|
}
|
||||||
|
|
||||||
<-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer)
|
// fightWorldBoss is `!adventure worldboss fight`: the command framing around
|
||||||
|
// takeSiegeBout, which does the fight and the narration.
|
||||||
|
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
||||||
|
_, boss, err := p.takeSiegeBout(ctx.Sender)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errSiegeNoBoss):
|
||||||
|
return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.")
|
||||||
|
case errors.Is(err, errSiegeNoCharacter):
|
||||||
|
return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.")
|
||||||
|
case errors.Is(err, errSiegeDead):
|
||||||
|
return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.")
|
||||||
|
case errors.Is(err, errSiegeAlreadyFought):
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
|
||||||
|
case err != nil:
|
||||||
|
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,34 +58,119 @@ func (p *AdventurePlugin) bootstrapPeteNewsBackfill() {
|
|||||||
"zone_firsts", firsts, "deaths", deaths, "achievements", achv)
|
"zone_firsts", firsts, "deaths", deaths, "achievements", achv)
|
||||||
}
|
}
|
||||||
|
|
||||||
// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY
|
// zoneFirstClear is one zone's earliest boss kill: who did it and when.
|
||||||
// realm-first dispatch per zone, attributed to its earliest boss-defeating
|
type zoneFirstClear struct {
|
||||||
// clearer. SQLite returns the user_id from the same row as MIN(completed_at)
|
zoneID, userID, completedAt string
|
||||||
// (bare-column min/max rule), so the (zone, first clearer, time) triple is
|
}
|
||||||
// consistent. Returns the count emitted.
|
|
||||||
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
// zoneFirstClears reads the earliest boss-defeating run of every zone.
|
||||||
|
//
|
||||||
|
// The filter is `boss_defeated = 1` and NOTHING else, and that is the whole
|
||||||
|
// point. `abandoned` does not mean anybody gave up — abandonZoneRunByID exists
|
||||||
|
// to retire a run whose boss is ALREADY DEAD when the expedition travels onward
|
||||||
|
// (dnd_zone_run.go), so in prod 30 of 32 boss kills carry abandoned = 1. An
|
||||||
|
// `AND abandoned = 0` here drew a realm where 2 zones had ever been beaten
|
||||||
|
// instead of 9. It is the same filter loadRealmClearStats documents; do not
|
||||||
|
// reintroduce it.
|
||||||
|
//
|
||||||
|
// 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 internally
|
||||||
|
// consistent.
|
||||||
|
// ok is false only when the read itself failed. A caller that is about to mark
|
||||||
|
// a one-shot job complete has to be able to tell "no zone has ever been cleared"
|
||||||
|
// from "the query fell over", or a transient DB fault at boot retires the repair
|
||||||
|
// permanently.
|
||||||
|
func zoneFirstClears() (firsts []zoneFirstClear, ok bool) {
|
||||||
rows, err := db.Get().Query(
|
rows, err := db.Get().Query(
|
||||||
`SELECT zone_id, user_id, MIN(completed_at)
|
`SELECT zone_id, user_id, MIN(completed_at)
|
||||||
FROM dnd_zone_run
|
FROM dnd_zone_run
|
||||||
WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0
|
WHERE boss_defeated = 1 AND completed_at IS NOT NULL
|
||||||
GROUP BY zone_id`)
|
GROUP BY zone_id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("backfill: zone-firsts query", "err", err)
|
slog.Error("backfill: zone-firsts query", "err", err)
|
||||||
return 0
|
return nil, false
|
||||||
}
|
}
|
||||||
type first struct {
|
defer rows.Close()
|
||||||
zoneID, userID, completedAt string
|
|
||||||
}
|
|
||||||
var firsts []first
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f first
|
var f zoneFirstClear
|
||||||
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
|
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
|
||||||
slog.Error("backfill: zone-firsts scan", "err", err)
|
slog.Error("backfill: zone-firsts scan", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
firsts = append(firsts, f)
|
firsts = append(firsts, f)
|
||||||
}
|
}
|
||||||
rows.Close()
|
if err := rows.Err(); err != nil {
|
||||||
|
slog.Error("backfill: zone-firsts rows", "err", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return firsts, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrapRealmFirstsReseed repairs the zone half of news_realm_firsts.
|
||||||
|
//
|
||||||
|
// The ledger is what claimRealmFirst tiers live dispatches against, so a zone
|
||||||
|
// whose first clear the original one-shot missed is a spurious PRIORITY "realm
|
||||||
|
// first" waiting to fire the next time somebody clears it, months after the
|
||||||
|
// fact. Two things were wrong with what got seeded:
|
||||||
|
//
|
||||||
|
// 1. The `abandoned = 0` filter above, which is why prod holds 6 zones where
|
||||||
|
// the run history knows 9.
|
||||||
|
// 2. first_at is claimRealmFirst's unixepoch() — when the claim was RECORDED,
|
||||||
|
// not when the clear happened. Every backfilled prod row carries the one
|
||||||
|
// minute the job ran.
|
||||||
|
//
|
||||||
|
// This is a re-SEED, not a re-run: it writes the ledger and emits nothing at
|
||||||
|
// all, so no historical realm-first dispatch reaches the room. It has its own
|
||||||
|
// job name because the original one-shot's gate is already marked, and per
|
||||||
|
// feedback_loader_rewire_needs_bootstrap it stays in place afterwards — a fresh
|
||||||
|
// deploy runs it as an ordinary bootstrap.
|
||||||
|
//
|
||||||
|
// It runs unconditionally on the news seam's switches, unlike the backfill: a
|
||||||
|
// ledger that is correct only when emission happens to be on is a ledger that
|
||||||
|
// mis-tiers the first dispatch after somebody flips it.
|
||||||
|
//
|
||||||
|
// A zone claim with no surviving run behind it is left exactly as it is. The run
|
||||||
|
// history is the better record of both who and when, but only where it has one.
|
||||||
|
func bootstrapRealmFirstsReseed() {
|
||||||
|
const jobName = "pete_realm_firsts_reseed_v1"
|
||||||
|
if db.JobCompleted(jobName, "once") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clears, ok := zoneFirstClears()
|
||||||
|
if !ok {
|
||||||
|
// The read failed. Leave the job unmarked so the next boot tries again —
|
||||||
|
// marking it here would retire the repair on the strength of a transient
|
||||||
|
// DB fault and leave the ledger wrong forever.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seeded := 0
|
||||||
|
for _, f := range clears {
|
||||||
|
ts, ok := parseSQLiteTime(f.completedAt)
|
||||||
|
if !ok {
|
||||||
|
slog.Warn("reseed: unparseable clear time", "zone", f.zoneID, "at", f.completedAt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Upsert, not INSERT OR IGNORE: the six rows that already exist carry the
|
||||||
|
// wrong date and correcting them is half of what this job is for.
|
||||||
|
db.Exec("realm-firsts reseed",
|
||||||
|
`INSERT INTO news_realm_firsts (kind, target, first_at) VALUES ('zone', ?, ?)
|
||||||
|
ON CONFLICT(kind, target) DO UPDATE SET first_at = excluded.first_at`,
|
||||||
|
f.zoneID, ts.Unix())
|
||||||
|
seeded++
|
||||||
|
}
|
||||||
|
|
||||||
|
db.MarkJobCompleted(jobName, "once")
|
||||||
|
slog.Warn("bootstrap: realm-firsts ledger reseeded", "zones", seeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillZoneFirsts seeds news_realm_firsts from history and emits one
|
||||||
|
// dispatch per zone, attributed to its earliest boss-defeating clearer.
|
||||||
|
// Returns the count emitted.
|
||||||
|
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
||||||
|
firsts, _ := zoneFirstClears()
|
||||||
|
|
||||||
n := 0
|
n := 0
|
||||||
for _, f := range firsts {
|
for _, f := range firsts {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ledgerFirstAt reads a zone's recorded claim time, or -1 if the ledger has no
|
||||||
|
// row for it at all.
|
||||||
|
func ledgerFirstAt(t *testing.T, zoneID string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var at int64
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT first_at FROM news_realm_firsts WHERE kind = 'zone' AND target = ?`,
|
||||||
|
zoneID).Scan(&at)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
func unixOf(t *testing.T, sqliteTime string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ts, ok := parseSQLiteTime(sqliteTime)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("parseSQLiteTime(%q) failed", sqliteTime)
|
||||||
|
}
|
||||||
|
return ts.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReseedClaimsAZoneWhoseClearsWereAllRetired is the regression for the bug
|
||||||
|
// that made this job necessary: every clear of forest_shadows carries
|
||||||
|
// abandoned = 1, which is how the game stores a kill the expedition walked on
|
||||||
|
// from, and the original one-shot's `abandoned = 0` filter therefore never saw
|
||||||
|
// the zone at all. An unclaimed zone is a spurious PRIORITY "realm first"
|
||||||
|
// waiting to fire the next time somebody clears it, months after the fact — so
|
||||||
|
// the assertion that matters is the claimRealmFirst one at the end.
|
||||||
|
func TestReseedClaimsAZoneWhoseClearsWereAllRetired(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
|
||||||
|
db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run
|
||||||
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
||||||
|
VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`)
|
||||||
|
|
||||||
|
if got := ledgerFirstAt(t, "forest_shadows"); got != -1 {
|
||||||
|
t.Fatalf("forest_shadows already claimed before the reseed (first_at=%d) — fixture drift", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapRealmFirstsReseed()
|
||||||
|
|
||||||
|
if got := ledgerFirstAt(t, "forest_shadows"); got != unixOf(t, "2026-02-14 09:00:00") {
|
||||||
|
t.Errorf("forest_shadows first_at = %d, want %d (the real clear, not the minute the job ran)",
|
||||||
|
got, unixOf(t, "2026-02-14 09:00:00"))
|
||||||
|
}
|
||||||
|
// The point of the whole job. Before the reseed this returns true and the
|
||||||
|
// next clear of a zone beaten in February announces itself as a realm first.
|
||||||
|
if claimRealmFirst("zone", "forest_shadows") {
|
||||||
|
t.Error("forest_shadows was still unclaimed after the reseed — the next clear would fire a spurious realm-first")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReseedCorrectsTheBackfillsDates pins the second half. claimRealmFirst
|
||||||
|
// stamps unixepoch(), so every row the original one-shot wrote carries the one
|
||||||
|
// minute that job ran — in prod, all six share the identical timestamp. The
|
||||||
|
// reseed has to overwrite an existing row, not INSERT OR IGNORE past it.
|
||||||
|
func TestReseedCorrectsTheBackfillsDates(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
|
||||||
|
// The fixture claims both zones the way the backfill did: at claim time.
|
||||||
|
before := ledgerFirstAt(t, "goblin_warrens")
|
||||||
|
if before < time.Now().Unix()-300 {
|
||||||
|
t.Fatalf("fixture claim for goblin_warrens is not a now-stamp (%d) — fixture drift", before)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapRealmFirstsReseed()
|
||||||
|
|
||||||
|
// Josie's r1, January, not r2 or r3 and not today.
|
||||||
|
if got, want := ledgerFirstAt(t, "goblin_warrens"), unixOf(t, "2026-01-10 12:00:00"); got != want {
|
||||||
|
t.Errorf("goblin_warrens first_at = %d, want %d (earliest real clear)", got, want)
|
||||||
|
}
|
||||||
|
// crypt_valdris has a clean clear (r4) and a retired-but-won one (r5). The
|
||||||
|
// earliest is r4.
|
||||||
|
if got, want := ledgerFirstAt(t, "crypt_valdris"), unixOf(t, "2026-05-01 12:00:00"); got != want {
|
||||||
|
t.Errorf("crypt_valdris first_at = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReseedEmitsNothing is the reason this is a re-seed and not a re-run of the
|
||||||
|
// backfill. The ledger has to be repaired without any historical realm-first
|
||||||
|
// dispatch reaching the room; a zone beaten in February is not news in July.
|
||||||
|
func TestReseedEmitsNothing(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run
|
||||||
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
||||||
|
VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`)
|
||||||
|
|
||||||
|
bootstrapRealmFirstsReseed()
|
||||||
|
|
||||||
|
var queued int
|
||||||
|
if err := db.Get().QueryRow(`SELECT COUNT(*) FROM pete_emit_queue`).Scan(&queued); err != nil {
|
||||||
|
t.Fatalf("count pete_emit_queue: %v", err)
|
||||||
|
}
|
||||||
|
if queued != 0 {
|
||||||
|
t.Errorf("reseed queued %d dispatches, want 0 — the ledger repair must be silent", queued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReseedIsAOneShot. It is a bootstrap kept in place for fresh deploys (per
|
||||||
|
// feedback_loader_rewire_needs_bootstrap), so it runs on every start and must
|
||||||
|
// cost nothing after the first — and, more importantly, must not undo a
|
||||||
|
// later live claim by rewriting the ledger from stale history on every boot.
|
||||||
|
func TestReseedIsAOneShot(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
bootstrapRealmFirstsReseed()
|
||||||
|
|
||||||
|
// A zone cleared after the reseed, claimed live.
|
||||||
|
if !claimRealmFirst("zone", "sunken_temple") {
|
||||||
|
t.Fatal("sunken_temple should have been an unclaimed realm-first")
|
||||||
|
}
|
||||||
|
live := ledgerFirstAt(t, "sunken_temple")
|
||||||
|
|
||||||
|
bootstrapRealmFirstsReseed()
|
||||||
|
|
||||||
|
if got := ledgerFirstAt(t, "sunken_temple"); got != live {
|
||||||
|
t.Errorf("second reseed moved a live claim: %d -> %d", live, got)
|
||||||
|
}
|
||||||
|
if claimRealmFirst("zone", "goblin_warrens") {
|
||||||
|
t.Error("second reseed dropped an existing claim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestZoneFirstClearsCountsRetiredKills guards the shared query itself, which
|
||||||
|
// the kept backfill also uses. `abandoned` means the run row was retired, not
|
||||||
|
// that anybody gave up.
|
||||||
|
func TestZoneFirstClearsCountsRetiredKills(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run
|
||||||
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
||||||
|
VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`)
|
||||||
|
|
||||||
|
clears, ok := zoneFirstClears()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("zoneFirstClears reported a read failure")
|
||||||
|
}
|
||||||
|
byZone := map[string]zoneFirstClear{}
|
||||||
|
for _, f := range clears {
|
||||||
|
byZone[f.zoneID] = f
|
||||||
|
}
|
||||||
|
if len(byZone) != 3 {
|
||||||
|
t.Fatalf("zoneFirstClears returned %d zones, want 3 (a regression to `abandoned = 0` gives 2)", len(byZone))
|
||||||
|
}
|
||||||
|
if got := byZone["forest_shadows"].userID; got != "@josie:x" {
|
||||||
|
t.Errorf("forest_shadows first clearer = %q, want @josie:x", got)
|
||||||
|
}
|
||||||
|
if _, ok := byZone["arena"]; ok {
|
||||||
|
t.Error("an unfinished run counted as a clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-43
@@ -1,12 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -121,10 +118,8 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
|
|||||||
sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders))
|
sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders))
|
||||||
|
|
||||||
// LLM status
|
// LLM status
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if llmConfigured() {
|
||||||
if ollamaHost != "" {
|
sb.WriteString(fmt.Sprintf("LLM status: %s\n", p.checkLLMStatus()))
|
||||||
llmStatus := p.checkLLMStatus(ollamaHost)
|
|
||||||
sb.WriteString(fmt.Sprintf("LLM status: %s\n", llmStatus))
|
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString("LLM status: not configured\n")
|
sb.WriteString("LLM status: not configured\n")
|
||||||
}
|
}
|
||||||
@@ -159,42 +154,16 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BotInfoPlugin) checkLLMStatus(ollamaHost string) string {
|
// checkLLMStatus reports backend liveness for /botinfo. The endpoint it probes
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
// differs per backend, which the llm package hides behind Ping.
|
||||||
apiURL := strings.TrimRight(ollamaHost, "/") + "/api/tags"
|
func (p *BotInfoPlugin) checkLLMStatus() string {
|
||||||
|
c := llmClient()
|
||||||
resp, err := client.Get(apiURL)
|
models, err := c.Ping(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("offline (%s)", err.Error())
|
return fmt.Sprintf("offline (%s: %s)", c.Backend(), err.Error())
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
if len(models) == 0 {
|
||||||
|
return fmt.Sprintf("online (%s, no models loaded)", c.Backend())
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Sprintf("error (HTTP %d)", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("online (%s, %d models: %s)", c.Backend(), len(models), strings.Join(models, ", "))
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "online (could not read response)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Models []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"models"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return "online (could not parse response)"
|
|
||||||
}
|
|
||||||
|
|
||||||
modelNames := make([]string, 0, len(result.Models))
|
|
||||||
for _, m := range result.Models {
|
|
||||||
modelNames = append(modelNames, m.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(modelNames) == 0 {
|
|
||||||
return "online (no models loaded)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("online (%d models: %s)", len(modelNames), strings.Join(modelNames, ", "))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ func (p *AdventurePlugin) postCombatBookkeeping(
|
|||||||
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
|
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
|
||||||
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
|
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
|
||||||
}
|
}
|
||||||
|
// The pet fought too. A win is its only earned XP — see grantPetCombatXP
|
||||||
|
// for why this seam and not the room-clear one: it is the single place all
|
||||||
|
// four close-outs already meet, so a pet cannot level differently depending
|
||||||
|
// on whether the fight auto-resolved or was played a round at a time.
|
||||||
|
if result.PlayerWon {
|
||||||
|
if leveled := grantPetCombatXP(userID); len(leveled) > 0 {
|
||||||
|
for _, line := range leveled {
|
||||||
|
slog.Info("adventure: pet leveled", "user", userID, "pet", line)
|
||||||
|
}
|
||||||
|
if err := p.SendDM(userID, "🐾 "+strings.Join(leveled, "\n🐾 ")); err != nil {
|
||||||
|
slog.Warn("adventure: pet level-up DM", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||||||
@@ -371,7 +385,7 @@ type DeathTransitionResult struct {
|
|||||||
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
||||||
var r DeathTransitionResult
|
var r DeathTransitionResult
|
||||||
|
|
||||||
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && rand.Float64() < 0.33 {
|
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && simFloat64() < 0.33 {
|
||||||
r.Pardoned = true
|
r.Pardoned = true
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
p.Char.LastPardonUsed = &now
|
p.Char.LastPardonUsed = &now
|
||||||
|
|||||||
@@ -707,6 +707,24 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
PlayerHeal: out.PlayerHeal,
|
PlayerHeal: out.PlayerHeal,
|
||||||
EnemySkip: out.EnemySkip,
|
EnemySkip: out.EnemySkip,
|
||||||
}
|
}
|
||||||
|
// Revive the arcane-blaster sustained cantrip floor in the turn engine.
|
||||||
|
// combat_engine.go:590 deals CantripPerRound flat every round, but that
|
||||||
|
// path is the swing-based engine (SimulateCombat). Auto-resolve — the
|
||||||
|
// live path for every expedition — runs the turn engine, where a caster
|
||||||
|
// autocasts and never weapon-swings, so the floor was dead code: casters
|
||||||
|
// fought at bare cantrip dice (~4d10≈22 at L20). Lift LANDED cantrip
|
||||||
|
// damage to the floor, but only when the cast already connected
|
||||||
|
// (eff.EnemyDamage > 0) — a whiffed Fire Bolt still whiffs, so the ~35%
|
||||||
|
// miss variance survives and the floor doesn't become a guaranteed flat
|
||||||
|
// hammer (that overshot to ~99%). Damage cantrips only; slot spells keep
|
||||||
|
// their rolled damage. Only Mage/Sorcerer/Warlock carry a nonzero
|
||||||
|
// CantripPerRound, so this is self-targeting.
|
||||||
|
if spell.Level == 0 && eff.EnemyDamage > 0 &&
|
||||||
|
(spell.Effect == EffectDamageAttack || spell.Effect == EffectDamageSave || spell.Effect == EffectDamageAuto) {
|
||||||
|
if floor := ct.players[seat].Mods.CantripPerRound; floor > eff.EnemyDamage {
|
||||||
|
eff.EnemyDamage = floor
|
||||||
|
}
|
||||||
|
}
|
||||||
// §1 — redirect the heal onto the named ally. The roll is the same; only
|
// §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
|
// 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
|
// cleric: until it existed, every heal in the engine was a self-heal, and
|
||||||
|
|||||||
@@ -482,6 +482,9 @@ const combatSessionCols = `
|
|||||||
|
|
||||||
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
||||||
func newCombatSessionID() string {
|
func newCombatSessionID() string {
|
||||||
|
if simSeedOn() {
|
||||||
|
return simHexToken()
|
||||||
|
}
|
||||||
var b [8]byte
|
var b [8]byte
|
||||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||||
// Vanishingly unlikely; fall through with a zeroed prefix.
|
// Vanishingly unlikely; fall through with a zeroed prefix.
|
||||||
|
|||||||
@@ -607,6 +607,7 @@ func emitDeathNews(userID id.UserID, location string) {
|
|||||||
Zone: location,
|
Zone: location,
|
||||||
Level: lvl,
|
Level: lvl,
|
||||||
Outcome: "lost",
|
Outcome: "lost",
|
||||||
|
RunID: latestRunIDForNews(userID),
|
||||||
OccurredAt: ts,
|
OccurredAt: ts,
|
||||||
}, userID, "")
|
}, userID, "")
|
||||||
}
|
}
|
||||||
@@ -655,6 +656,7 @@ func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) {
|
|||||||
Level: charLevel(userID),
|
Level: charLevel(userID),
|
||||||
Count: day, // the day they got to before it fell apart
|
Count: day, // the day they got to before it fell apart
|
||||||
Outcome: "retreated",
|
Outcome: "retreated",
|
||||||
|
RunID: latestRunIDForNews(userID),
|
||||||
OccurredAt: ts,
|
OccurredAt: ts,
|
||||||
}, userID, "")
|
}, userID, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,6 +33,23 @@ import (
|
|||||||
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.
|
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error {
|
func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error {
|
||||||
|
args = strings.TrimSpace(args)
|
||||||
|
sub, rest := splitFirstWord(args)
|
||||||
|
|
||||||
|
// Two subcommands are aliases for top-level commands that take the per-user
|
||||||
|
// lock themselves. Dispatch them BEFORE we take it: advUserLock is a plain
|
||||||
|
// sync.Mutex, so grabbing it here and again in there does not merely block —
|
||||||
|
// it wedges the lock forever, because the deferred Unlock below never runs.
|
||||||
|
// Every later `!adventure` / `!expedition` / `!zone` command from that player
|
||||||
|
// then hangs too. Both aliases load their own character, so nothing below is
|
||||||
|
// being skipped.
|
||||||
|
switch strings.ToLower(sub) {
|
||||||
|
case "extract":
|
||||||
|
return p.handleExtractCmd(ctx, "")
|
||||||
|
case "resume":
|
||||||
|
return p.handleResumeCmd(ctx, rest)
|
||||||
|
}
|
||||||
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
userMu := p.advUserLock(ctx.Sender)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
@@ -44,8 +63,6 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
|
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
|
||||||
}
|
}
|
||||||
|
|
||||||
args = strings.TrimSpace(args)
|
|
||||||
sub, rest := splitFirstWord(args)
|
|
||||||
switch strings.ToLower(sub) {
|
switch strings.ToLower(sub) {
|
||||||
case "":
|
case "":
|
||||||
// If active, show status; otherwise help. A party member is on an
|
// If active, show status; otherwise help. A party member is on an
|
||||||
@@ -99,10 +116,6 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
return p.expeditionCmdHire(ctx, rest)
|
return p.expeditionCmdHire(ctx, rest)
|
||||||
case "dismiss":
|
case "dismiss":
|
||||||
return p.expeditionCmdDismiss(ctx)
|
return p.expeditionCmdDismiss(ctx)
|
||||||
case "extract":
|
|
||||||
return p.handleExtractCmd(ctx, "")
|
|
||||||
case "resume":
|
|
||||||
return p.handleResumeCmd(ctx, rest)
|
|
||||||
case "map", "m":
|
case "map", "m":
|
||||||
return p.handleExpeditionMapCmd(ctx, "")
|
return p.handleExpeditionMapCmd(ctx, "")
|
||||||
case "run", "explore", "advance":
|
case "run", "explore", "advance":
|
||||||
@@ -331,83 +344,14 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
||||||
}
|
}
|
||||||
if err := purchase.Validate(zoneForCaps.Tier); err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
|
||||||
}
|
|
||||||
// Reject if any expedition or zone run already active. This runs before the
|
|
||||||
// price quote: a player who cannot leave doesn't need to hear what leaving
|
|
||||||
// would have cost.
|
|
||||||
//
|
|
||||||
// The seat check spans `extracting` as well as `active` — a member of an
|
|
||||||
// extracting party is still seated for the seven-day resume window, and
|
|
||||||
// letting them outfit a rival expedition double-books them the moment their
|
|
||||||
// leader types `!resume`.
|
|
||||||
if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil {
|
|
||||||
zone, _ := getZone(seated.ZoneID)
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
|
|
||||||
zone.Display, seated.CurrentDay))
|
|
||||||
}
|
|
||||||
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
|
|
||||||
zone, _ := getZone(existing.ZoneID)
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
|
||||||
zone.Display, existing.CurrentDay))
|
|
||||||
}
|
|
||||||
// A leader who extracted still holds their roster for the resume window, and
|
|
||||||
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
|
||||||
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
|
||||||
// catches it, with every member still seated and refused a run of their own.
|
|
||||||
//
|
|
||||||
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
|
||||||
// walking away from it stays a normal thing to do.
|
|
||||||
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
|
|
||||||
switch {
|
|
||||||
case extractionLapsed(pending, time.Now().UTC()):
|
|
||||||
// Past the window — reap it here rather than make them wait an hour
|
|
||||||
// for the sweeper, and let the new expedition proceed. Route through
|
|
||||||
// the shared reap so the freed members hear about it, same as the
|
|
||||||
// sweeper and `!expedition abandon` do.
|
|
||||||
if err := p.reapLapsedExtraction(pending); err != nil {
|
|
||||||
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// A roster still holds; block. On a roster-read error, assume it is
|
|
||||||
// occupied and refuse — proceeding would orphan a party we could not
|
|
||||||
// confirm was empty, the one outcome this guard exists to prevent. A
|
|
||||||
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
|
||||||
n, err := partySize(pending.ID)
|
|
||||||
if err != nil || n > 1 {
|
|
||||||
zone, _ := getZone(pending.ZoneID)
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
|
||||||
zone.Display, pending.CurrentDay))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cost := float64(purchase.Cost())
|
out, err := p.performExpeditionStart(ctx.Sender, c, zoneForCaps, purchase, "")
|
||||||
if p.euro == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
|
||||||
}
|
|
||||||
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
|
||||||
int(cost), balance))
|
|
||||||
}
|
|
||||||
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
|
|
||||||
zone, _ := getZone(existing.ZoneID)
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
|
|
||||||
zone.Display))
|
|
||||||
}
|
|
||||||
|
|
||||||
zone := zoneForCaps
|
|
||||||
_, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Every refusal below carries its own finished sentence — see the sentinel
|
||||||
|
// block on performExpeditionStart — so the command only has to say it.
|
||||||
return p.SendDM(ctx.Sender, err.Error())
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
}
|
}
|
||||||
markActedToday(ctx.Sender)
|
zone, supplies, startLine := out.Zone, out.Supplies, out.StartLine
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||||||
@@ -428,6 +372,182 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the headless twin of `!expedition start` ────────────────────────────────
|
||||||
|
|
||||||
|
// Sentinels for the ways outfitting can be refused, so the web action queue
|
||||||
|
// (pete_orders.go) can pick a verdict without parsing prose. Every refusal is
|
||||||
|
// returned as an advRefusal, which wraps one of these AND carries the
|
||||||
|
// finished player-facing sentence — that is how `!expedition start` keeps the
|
||||||
|
// exact copy it always sent while the web gets a machine-readable answer.
|
||||||
|
var (
|
||||||
|
errExpStartResting = errors.New("expedition start: still resting")
|
||||||
|
errExpStartZoneLocked = errors.New("expedition start: zone not available at this level")
|
||||||
|
errExpStartBadPacks = errors.New("expedition start: invalid pack selection")
|
||||||
|
errExpStartBusy = errors.New("expedition start: already adventuring")
|
||||||
|
errExpStartBroke = errors.New("expedition start: cannot cover outfitting")
|
||||||
|
errExpStartFailed = errors.New("expedition start: could not outfit")
|
||||||
|
)
|
||||||
|
|
||||||
|
// advRefusal is a refusal that is both classifiable and quotable: errors.Is
|
||||||
|
// picks the verdict, Error() is the sentence the command has always sent. Shared
|
||||||
|
// by every headless twin in the web action family (start, resume, babysit).
|
||||||
|
type advRefusal struct {
|
||||||
|
kind error
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e advRefusal) Error() string { return e.msg }
|
||||||
|
func (e advRefusal) Unwrap() error { return e.kind }
|
||||||
|
|
||||||
|
func refuseAdv(kind error, format string, args ...any) error {
|
||||||
|
return advRefusal{kind: kind, msg: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expStartOutcome is what outfitting did, for a caller describing it somewhere
|
||||||
|
// other than a DM.
|
||||||
|
type expStartOutcome struct {
|
||||||
|
Zone ZoneDefinition
|
||||||
|
Supplies ExpeditionSupplies
|
||||||
|
Cost int
|
||||||
|
Days int
|
||||||
|
StartLine string
|
||||||
|
}
|
||||||
|
|
||||||
|
// performExpeditionStart is `!expedition start` minus the command framing: the
|
||||||
|
// eligibility guards, the price gate, the debit, and the expedition row. It is
|
||||||
|
// shared with the web action queue so that leaving town from a phone is the
|
||||||
|
// *same* departure — same guards, same supplies, same opening log line.
|
||||||
|
//
|
||||||
|
// idemKey, when set, is the web order's guid: the debit then goes through
|
||||||
|
// DebitIdem so a re-offered order that already paid cannot pay twice. The Matrix
|
||||||
|
// command passes "" and keeps the plain debit, which is right — a Matrix message
|
||||||
|
// arrives exactly once.
|
||||||
|
//
|
||||||
|
// LOCKING, and this is the one asymmetry in the headless-twin family: this
|
||||||
|
// function does NOT take the per-user lock. performExtraction, takeSiegeBout and
|
||||||
|
// performBabysitPurchase all take it themselves, because their command framings
|
||||||
|
// do not hold it — but handleDnDExpeditionCmd holds it across its whole switch,
|
||||||
|
// so taking it here would wedge advUserLock permanently (it is a plain
|
||||||
|
// sync.Mutex, and the deferred unlock up there would never run). The web caller
|
||||||
|
// takes it explicitly instead; see applyAdvOrder.
|
||||||
|
func (p *AdventurePlugin) performExpeditionStart(uid id.UserID, c *DnDCharacter, zone ZoneDefinition, purchase SupplyPurchase, idemKey string) (expStartOutcome, error) {
|
||||||
|
if remaining := restingLockoutRemaining(c); remaining > 0 {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartResting,
|
||||||
|
"🛌 You're still resting — %s remaining. Pack up after.",
|
||||||
|
formatRespecDuration(remaining))
|
||||||
|
}
|
||||||
|
// Re-resolve availability against the game's own tables rather than trusting
|
||||||
|
// the caller. The web resolves a zone from an offer list gogobee itself
|
||||||
|
// pushed, but that snapshot can be minutes old and is not a permission.
|
||||||
|
if _, ok := resolveZoneInput(string(zone.ID), availableZonesFor(uid, c.Level)); !ok {
|
||||||
|
if reason := postgameLockReason(string(zone.ID), uid, c.Level); reason != "" {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartZoneLocked, "%s", reason)
|
||||||
|
}
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartZoneLocked,
|
||||||
|
"Unknown zone for your level. Try `!expedition list`.")
|
||||||
|
}
|
||||||
|
if err := purchase.Validate(zone.Tier); err != nil {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBadPacks,
|
||||||
|
"Invalid pack selection: %s", err.Error())
|
||||||
|
}
|
||||||
|
// Reject if any expedition or zone run already active. This runs before the
|
||||||
|
// price quote: a player who cannot leave doesn't need to hear what leaving
|
||||||
|
// would have cost.
|
||||||
|
//
|
||||||
|
// The seat check spans `extracting` as well as `active` — a member of an
|
||||||
|
// extracting party is still seated for the seven-day resume window, and
|
||||||
|
// letting them outfit a rival expedition double-books them the moment their
|
||||||
|
// leader types `!resume`.
|
||||||
|
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||||||
|
z, _ := getZone(seated.ZoneID)
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||||
|
"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
|
||||||
|
z.Display, seated.CurrentDay)
|
||||||
|
}
|
||||||
|
if existing, _ := getActiveExpedition(uid); existing != nil {
|
||||||
|
// A web order that already paid and already started this expedition on an
|
||||||
|
// earlier tick lands here on the re-offer. Saying "you're already on
|
||||||
|
// expedition" would be a rejection for the thing the order in fact did, so
|
||||||
|
// the settled debit is what tells the two apart.
|
||||||
|
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) && existing.ZoneID == zone.ID {
|
||||||
|
z, _ := getZone(existing.ZoneID)
|
||||||
|
return expStartOutcome{Zone: z, Supplies: existing.Supplies,
|
||||||
|
Cost: purchase.Cost(),
|
||||||
|
Days: estimateDays(existing.Supplies.Max, existing.Supplies.DailyBurn)}, nil
|
||||||
|
}
|
||||||
|
z, _ := getZone(existing.ZoneID)
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||||
|
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||||
|
z.Display, existing.CurrentDay)
|
||||||
|
}
|
||||||
|
// A leader who extracted still holds their roster for the resume window, and
|
||||||
|
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
||||||
|
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
||||||
|
// catches it, with every member still seated and refused a run of their own.
|
||||||
|
//
|
||||||
|
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
||||||
|
// walking away from it stays a normal thing to do.
|
||||||
|
if pending, _ := getResumableExpedition(uid); pending != nil {
|
||||||
|
switch {
|
||||||
|
case extractionLapsed(pending, time.Now().UTC()):
|
||||||
|
// Past the window — reap it here rather than make them wait an hour
|
||||||
|
// for the sweeper, and let the new expedition proceed. Route through
|
||||||
|
// the shared reap so the freed members hear about it, same as the
|
||||||
|
// sweeper and `!expedition abandon` do.
|
||||||
|
if err := p.reapLapsedExtraction(pending); err != nil {
|
||||||
|
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// A roster still holds; block. On a roster-read error, assume it is
|
||||||
|
// occupied and refuse — proceeding would orphan a party we could not
|
||||||
|
// confirm was empty, the one outcome this guard exists to prevent. A
|
||||||
|
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
||||||
|
n, err := partySize(pending.ID)
|
||||||
|
if err != nil || n > 1 {
|
||||||
|
z, _ := getZone(pending.ZoneID)
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||||
|
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
||||||
|
z.Display, pending.CurrentDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cost := float64(purchase.Cost())
|
||||||
|
if p.euro == nil {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartFailed, "Coin system unavailable — try again later.")
|
||||||
|
}
|
||||||
|
// Skip the affordability gate on a re-offer that already paid: the debit is a
|
||||||
|
// settled fact and re-reading the now-lower balance would bounce a departure
|
||||||
|
// the player has bought. Same reasoning as purchaseEquipmentTier's.
|
||||||
|
if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) {
|
||||||
|
if balance := p.euro.GetBalance(uid); balance < cost {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBroke,
|
||||||
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||||
|
int(cost), balance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing, _ := getActiveZoneRun(uid); existing != nil {
|
||||||
|
z, _ := getZone(existing.ZoneID)
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||||
|
"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
|
||||||
|
z.Display)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, supplies, startLine, err := p.beginExpeditionIdem(uid, c.Level, zone, purchase, "expedition outfitting", idemKey)
|
||||||
|
if err != nil {
|
||||||
|
// beginExpedition refunds and tears down on every failure path, so the
|
||||||
|
// player owes nothing and this is permanent rather than retryable — a retry
|
||||||
|
// after a refund would find the guid-keyed debit already settled and hand
|
||||||
|
// them the expedition for free.
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartFailed, "%s", err.Error())
|
||||||
|
}
|
||||||
|
markActedToday(uid)
|
||||||
|
return expStartOutcome{
|
||||||
|
Zone: zone, Supplies: supplies, Cost: purchase.Cost(),
|
||||||
|
Days: estimateDays(supplies.Max, supplies.DailyBurn), StartLine: startLine,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// beginExpedition performs the non-interactive half of starting an expedition:
|
// beginExpedition performs the non-interactive half of starting an expedition:
|
||||||
// supply freebies, the coin debit, persistence, the starting region's run, and
|
// supply freebies, the coin debit, persistence, the starting region's run, and
|
||||||
// the opening log entry. It refunds and tears down on every failure path, so a
|
// the opening log entry. It refunds and tears down on every failure path, so a
|
||||||
@@ -441,10 +561,38 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
// It deliberately does NOT call markActedToday — an expedition the player did
|
// It deliberately does NOT call markActedToday — an expedition the player did
|
||||||
// not ask for must not spend their daily action or count as them showing up.
|
// not ask for must not spend their daily action or count as them showing up.
|
||||||
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
|
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
|
||||||
|
return p.beginExpeditionIdem(uid, charLevel, zone, purchase, reason, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// beginExpeditionIdem is beginExpedition with the money keyed to an idempotency
|
||||||
|
// id. idemKey empty keeps the plain Debit/Credit pair, which is correct for the
|
||||||
|
// two callers that arrive exactly once (a Matrix command, the boredom ticker).
|
||||||
|
//
|
||||||
|
// A non-empty key comes from the web action queue, whose wire retries: the debit
|
||||||
|
// then lands at most once however many times the order is re-offered, and the
|
||||||
|
// refunds are keyed too so a torn-down start cannot refund on every tick. Note
|
||||||
|
// what this means for the caller — once a refund has happened, retrying is
|
||||||
|
// *unsafe*, because the guid-keyed debit will not charge again and the player
|
||||||
|
// would get the expedition for free. performExpeditionStart therefore treats
|
||||||
|
// every error from here as permanent.
|
||||||
|
func (p *AdventurePlugin) beginExpeditionIdem(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason, idemKey string) (*Expedition, ExpeditionSupplies, string, error) {
|
||||||
if p.euro == nil {
|
if p.euro == nil {
|
||||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
|
||||||
}
|
}
|
||||||
cost := float64(purchase.Cost())
|
cost := float64(purchase.Cost())
|
||||||
|
debit := func(why string) bool { return p.euro.Debit(uid, cost, why) }
|
||||||
|
refund := func(why, suffix string) { p.euro.Credit(uid, cost, why) }
|
||||||
|
if idemKey != "" {
|
||||||
|
debit = func(why string) bool {
|
||||||
|
ok, _, err := p.euro.DebitIdem(uid, cost, why, idemKey)
|
||||||
|
return err == nil && ok
|
||||||
|
}
|
||||||
|
refund = func(why, suffix string) {
|
||||||
|
if _, _, err := p.euro.CreditIdem(uid, cost, why, idemKey+":refund"+suffix); err != nil {
|
||||||
|
slog.Error("expedition: outfitting refund failed", "user", uid, "order", idemKey, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Holiday perk: a complimentary standard pack is added to the supplies
|
// Holiday perk: a complimentary standard pack is added to the supplies
|
||||||
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
||||||
@@ -459,14 +607,14 @@ func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone Zon
|
|||||||
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).
|
||||||
if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) {
|
if !debit(reason + ": " + string(zone.ID)) {
|
||||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, err := startExpedition(uid, zone.ID, "", supplies)
|
exp, err := startExpedition(uid, zone.ID, "", supplies)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Refund on persistence failure.
|
// Refund on persistence failure.
|
||||||
p.euro.Credit(uid, cost, "expedition outfitting refund")
|
refund("expedition outfitting refund", "")
|
||||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +625,7 @@ func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone Zon
|
|||||||
// Refund and tear the expedition row back down — without a
|
// Refund and tear the expedition row back down — without a
|
||||||
// linked run, harvest and rooms can't function.
|
// linked run, harvest and rooms can't function.
|
||||||
_ = abandonExpedition(uid)
|
_ = abandonExpedition(uid)
|
||||||
p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)")
|
refund("expedition outfitting refund (run-spawn failed)", ":region")
|
||||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,65 +844,102 @@ func formatLogTimestamp(t time.Time) string {
|
|||||||
|
|
||||||
// ── abandon ─────────────────────────────────────────────────────────────────
|
// ── abandon ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
// Sentinels for the two ways abandoning can be refused, so the web action queue
|
||||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
// can pick a verdict without reading prose. Same contract as the start/resume
|
||||||
|
// family above: errors.Is classifies, Error() is the sentence Matrix has always
|
||||||
|
// sent.
|
||||||
|
var (
|
||||||
|
errAbandonNothing = errors.New("expedition abandon: nothing to abandon")
|
||||||
|
errAbandonNotLeader = errors.New("expedition abandon: only the leader may call it")
|
||||||
|
)
|
||||||
|
|
||||||
|
// abandonOutcome is what closing the expedition did, for a caller describing it
|
||||||
|
// somewhere other than a DM.
|
||||||
|
type abandonOutcome struct {
|
||||||
|
Zone ZoneDefinition
|
||||||
|
Day int
|
||||||
|
Extracted bool // it was already out and standing in town: loot and XP are kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// performExpeditionAbandon is `!expedition abandon` minus the command framing.
|
||||||
|
// Shared with the web action queue so closing a run from a phone disbands the
|
||||||
|
// same roster, retires the same region runs, writes the same log line and tells
|
||||||
|
// the same party — the members hear it from their leader either way, because
|
||||||
|
// that is a fact about the expedition and not about which door was used.
|
||||||
|
//
|
||||||
|
// Like performExpeditionStart this does NOT take the per-user lock: its Matrix
|
||||||
|
// caller already holds it across the whole `!expedition` switch. applyWebAbandon
|
||||||
|
// takes it instead. Getting that backwards does not fail loudly — it wedges the
|
||||||
|
// player's lock forever and every later adventure command from them hangs.
|
||||||
|
func (p *AdventurePlugin) performExpeditionAbandon(uid id.UserID) (abandonOutcome, error) {
|
||||||
|
exp, isLeader, err := activeExpeditionFor(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return abandonOutcome{}, err
|
||||||
}
|
}
|
||||||
if exp == nil {
|
if exp == nil {
|
||||||
// An extracted expedition is still the owner's to close — it holds the
|
// An extracted expedition is still the owner's to close — it holds the
|
||||||
// roster until the resume window lapses. Without this, a leader who
|
// roster until the resume window lapses. Without this, a leader who
|
||||||
// wanted out had to pay to `!resume` first just to abandon.
|
// wanted out had to pay to `!resume` first just to abandon.
|
||||||
if exp, err = getResumableExpedition(ctx.Sender); err != nil {
|
if exp, err = getResumableExpedition(uid); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return abandonOutcome{}, err
|
||||||
}
|
}
|
||||||
isLeader = exp != nil
|
isLeader = exp != nil
|
||||||
}
|
}
|
||||||
if exp == nil {
|
if exp == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
|
return abandonOutcome{}, refuseAdv(errAbandonNothing, "No active expedition to abandon.")
|
||||||
}
|
}
|
||||||
if !isLeader {
|
if !isLeader {
|
||||||
// Abandoning throws away everyone's day. A member leaves alone.
|
// Abandoning throws away everyone's day. A member leaves alone.
|
||||||
return p.SendDM(ctx.Sender,
|
return abandonOutcome{}, refuseAdv(errAbandonNotLeader,
|
||||||
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
|
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
|
||||||
}
|
}
|
||||||
zone, _ := getZone(exp.ZoneID)
|
zone, _ := getZone(exp.ZoneID)
|
||||||
extracted := exp.Status == ExpeditionStatusExtracting
|
out := abandonOutcome{Zone: zone, Day: exp.CurrentDay, Extracted: exp.Status == ExpeditionStatusExtracting}
|
||||||
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
|
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
|
||||||
if err := abandonExpedition(ctx.Sender); err != nil {
|
if err := abandonExpedition(uid); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
return abandonOutcome{}, err
|
||||||
}
|
}
|
||||||
markActedToday(ctx.Sender)
|
markActedToday(uid)
|
||||||
_ = retireAllRegionRuns(exp)
|
_ = retireAllRegionRuns(exp)
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
||||||
|
// The roster is being disbanded out from under the members; they hear it from
|
||||||
|
// their leader rather than discovering it the next time a command works again.
|
||||||
|
for _, member := range audience {
|
||||||
|
if member == uid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.SendDM(member, fmt.Sprintf(
|
||||||
|
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
|
||||||
|
zone.Display, exp.CurrentDay)); err != nil {
|
||||||
|
slog.Warn("expedition: abandon DM failed", "user", member, "expedition", exp.ID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Emergence seam: see maybeRollPetArrivalOnEmerge. Inside the twin because
|
||||||
|
// walking out of a dungeon is what rolls it, not saying so in a room.
|
||||||
|
p.maybeRollPetArrivalOnEmerge(uid)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||||
|
out, err := p.performExpeditionAbandon(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
var refusal advRefusal
|
||||||
|
if errors.As(err, &refusal) {
|
||||||
|
return p.SendDM(ctx.Sender, refusal.Error())
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
||||||
|
}
|
||||||
// An extracted party is standing in town, not in the dungeon: their supplies
|
// An extracted party is standing in town, not in the dungeon: their supplies
|
||||||
// are already spent and their loot is already banked. Say the true thing.
|
// are already spent and their loot is already banked. Say the true thing.
|
||||||
body := fmt.Sprintf(
|
body := fmt.Sprintf(
|
||||||
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
||||||
zone.Display, exp.CurrentDay)
|
out.Zone.Display, out.Day)
|
||||||
if extracted {
|
if out.Extracted {
|
||||||
body = fmt.Sprintf(
|
body = fmt.Sprintf(
|
||||||
"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
|
"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
|
||||||
zone.Display, exp.CurrentDay)
|
out.Zone.Display, out.Day)
|
||||||
}
|
}
|
||||||
// The roster is being disbanded out from under the members; they hear it from
|
return p.SendDM(ctx.Sender, body)
|
||||||
// their leader rather than discovering it the next time a command works again.
|
|
||||||
for _, uid := range audience {
|
|
||||||
if uid == ctx.Sender {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := p.SendDM(uid, fmt.Sprintf(
|
|
||||||
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
|
|
||||||
zone.Display, exp.CurrentDay)); err != nil {
|
|
||||||
slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := p.SendDM(ctx.Sender, body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Emergence seam: see maybeRollPetArrivalOnEmerge.
|
|
||||||
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper: ensure we don't shadow id.UserID import in test harness.
|
// helper: ensure we don't shadow id.UserID import in test harness.
|
||||||
@@ -843,45 +1028,197 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
|||||||
// run graph / harvest tally / supplies / threat — same as before, just
|
// run graph / harvest tally / supplies / threat — same as before, just
|
||||||
// no streamFlow here. compact==true switches the underlying combat
|
// no streamFlow here. compact==true switches the underlying combat
|
||||||
// narration into terse mode and auto-resolves elite (not boss) rooms.
|
// narration into terse mode and auto-resolves elite (not boss) rooms.
|
||||||
// forkAutoPickTimeout — how long a background fork may sit unanswered
|
// forkAutoPickTimeout — how long a background fork may sit unanswered before
|
||||||
// before the autopilot picks an available route itself. Short enough that
|
// the autopilot picks a route itself.
|
||||||
// the expedition keeps moving rather than idling out to the 24h stale-run
|
//
|
||||||
// reaper; long enough that a player away for the evening still gets first
|
// This was 8h, which reads as "the player gets first say" and behaves as "the
|
||||||
// say on a genuine fork.
|
// expedition stops for a third of a day, every fork." A multi-day expedition
|
||||||
const forkAutoPickTimeout = 8 * time.Hour
|
// crosses a lot of forks; at 8h apiece the autopilot spends more of its life
|
||||||
|
// parked than walking, and a player who is simply asleep loses a night per
|
||||||
|
// branch. 30m keeps a genuine first say for anyone actually at the keyboard and
|
||||||
|
// costs an absent player almost nothing.
|
||||||
|
const forkAutoPickTimeout = 30 * time.Minute
|
||||||
|
|
||||||
// autoPickStaleFork commits the first unlocked option of a stale background
|
// rankForkOptions orders a fork's options by how much walking them is worth:
|
||||||
// fork, advancing the run to that node exactly as `!zone go <n>` would
|
// somewhere new first, then the fatter edge (Weight is the author's own "this
|
||||||
// (advanceZoneRunNode + region-transition hook). Returns false — no pick —
|
// is the main line" signal), then menu order as the tiebreak so the pick is
|
||||||
// when every option is locked, so the caller re-emits the prompt and the
|
// deterministic. Only unlocked options are returned.
|
||||||
// run idles on toward the reaper. The choice is logged as a narrative entry
|
//
|
||||||
// so the end-of-day digest can surface the decision the player missed.
|
// The old rule was "first unlocked option in the menu", which is edge-authoring
|
||||||
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
|
// order — meaningful to whoever wrote the graph, arbitrary to the player. It
|
||||||
var chosen *pendingChoice
|
// walked past unvisited branches to loop through cleared ones often enough to
|
||||||
for i := range pf.Options {
|
// look broken.
|
||||||
if pf.Options[i].Unlocked {
|
func rankForkOptions(g ZoneGraph, run *DungeonRun, pf *pendingFork) []pendingChoice {
|
||||||
chosen = &pf.Options[i]
|
weights := map[string]int{}
|
||||||
break
|
for _, e := range g.outgoingEdges(run.CurrentNode) {
|
||||||
|
weights[e.To] = e.Weight
|
||||||
|
}
|
||||||
|
visited := map[string]bool{}
|
||||||
|
for _, n := range run.VisitedNodes {
|
||||||
|
visited[n] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
open := make([]pendingChoice, 0, len(pf.Options))
|
||||||
|
for _, o := range pf.Options {
|
||||||
|
if o.Unlocked {
|
||||||
|
open = append(open, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if chosen == nil {
|
sort.SliceStable(open, func(i, j int) bool {
|
||||||
return false // nothing unlocked — leave it for the player / reaper
|
vi, vj := visited[open[i].To], visited[open[j].To]
|
||||||
|
if vi != vj {
|
||||||
|
return !vi // unvisited first
|
||||||
|
}
|
||||||
|
if wi, wj := weights[open[i].To], weights[open[j].To]; wi != wj {
|
||||||
|
return wi > wj
|
||||||
|
}
|
||||||
|
return open[i].Index < open[j].Index
|
||||||
|
})
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoPickStaleFork commits a stale background fork, advancing the run exactly
|
||||||
|
// as `!zone go <n>` would (advanceZoneRunNode + region-transition hook). The
|
||||||
|
// choice is logged as a narrative entry so the end-of-day digest can surface
|
||||||
|
// the decision the player missed.
|
||||||
|
//
|
||||||
|
// When every route is locked it does not give up: it spends a set of thieves'
|
||||||
|
// tools if the party is carrying any and one of the locks is the pickable kind.
|
||||||
|
// Returns false only when there is genuinely nothing it can do — the caller
|
||||||
|
// then backtracks rather than idling the expedition into the 24h reaper.
|
||||||
|
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
|
||||||
|
g, _ := loadZoneGraph(run.ZoneID)
|
||||||
|
|
||||||
|
ranked := rankForkOptions(g, run, pf)
|
||||||
|
note := "autopilot took the most promising path"
|
||||||
|
var spendTool int64
|
||||||
|
if len(ranked) == 0 {
|
||||||
|
picked, toolID, ok := p.autoPickWithTools(run, pf)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ranked = []pendingChoice{picked}
|
||||||
|
spendTool = toolID
|
||||||
|
note = "autopilot spent " + thievesToolsName + " on the only way forward"
|
||||||
}
|
}
|
||||||
|
chosen := ranked[0]
|
||||||
|
|
||||||
if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
|
if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
|
||||||
slog.Warn("expedition: auto-pick stale fork",
|
slog.Warn("expedition: auto-pick stale fork",
|
||||||
"user", run.UserID, "run", run.RunID, "err", err)
|
"user", run.UserID, "run", run.RunID, "err", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
g, _ := loadZoneGraph(run.ZoneID)
|
// The door is behind us, so now the set is actually used up. Billing before
|
||||||
|
// the advance would charge the player for a move that failed — and the
|
||||||
|
// caller's backtrack would then clear the fork the tools just paid for.
|
||||||
|
if spendTool != 0 {
|
||||||
|
if err := removeAdvInventoryItem(spendTool); err != nil {
|
||||||
|
slog.Warn("expedition: autopilot tools spend", "user", run.UserID, "err", err)
|
||||||
|
}
|
||||||
|
beatLock(run, chosen.Label, "picked")
|
||||||
|
}
|
||||||
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
|
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
|
||||||
if exp != nil {
|
if exp != nil {
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||||
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s",
|
fmt.Sprintf("%s: %s", note, chosen.Label), "")
|
||||||
int(forkAutoPickTimeout.Hours()), chosen.Label), "")
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoPickWithTools finds a route a set of thieves' tools could open when every
|
||||||
|
// option on the fork is locked, so a bad Perception roll can't quietly end an
|
||||||
|
// expedition the player paid days into. It only ever fires when there is no free
|
||||||
|
// route left — the player's tools are their own, and the autopilot does not get
|
||||||
|
// to burn them for convenience.
|
||||||
|
//
|
||||||
|
// It reports the route and the inventory row to spend, but does not spend it:
|
||||||
|
// the caller charges the player only once the move has actually committed.
|
||||||
|
func (p *AdventurePlugin) autoPickWithTools(run *DungeonRun, pf *pendingFork) (pendingChoice, int64, bool) {
|
||||||
|
toolID, ok := findThievesTools(id.UserID(run.UserID))
|
||||||
|
if !ok {
|
||||||
|
return pendingChoice{}, 0, false
|
||||||
|
}
|
||||||
|
for i := range pf.Options {
|
||||||
|
if pf.Options[i].Unlocked || !pickableLock(pf.Options[i].Lock) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Local copy only — advanceZoneRunNode clears node_choices on success,
|
||||||
|
// and on failure the fork must stay as locked as the player left it
|
||||||
|
// rather than reading "open" for a set nobody paid for.
|
||||||
|
chosen := pf.Options[i]
|
||||||
|
chosen.Unlocked = true
|
||||||
|
chosen.Reason = "opened with " + thievesToolsName
|
||||||
|
return chosen, toolID, true
|
||||||
|
}
|
||||||
|
return pendingChoice{}, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// backtrackFromDeadFork walks the run back one room when a fork has no route
|
||||||
|
// the autopilot can take and no tools to buy one with. Without this the run
|
||||||
|
// simply sits there until the 24h stale reaper ends the expedition — a player
|
||||||
|
// losing days of progress to a die roll they never saw and could not answer.
|
||||||
|
// Backtracking at least returns them to a room with other exits.
|
||||||
|
//
|
||||||
|
// Returns false at the entry node, where there is nowhere behind to go, and
|
||||||
|
// wherever no fallback room would actually help — see backtrackTarget.
|
||||||
|
func (p *AdventurePlugin) backtrackFromDeadFork(exp *Expedition, run *DungeonRun) bool {
|
||||||
|
g, ok := loadZoneGraph(run.ZoneID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
target, ok := backtrackTarget(g, run)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the fork first: it belongs to the node being left, and both
|
||||||
|
// `!zone advance` and `!zone go` would otherwise resolve a prompt pointing
|
||||||
|
// at a room the party is no longer standing in.
|
||||||
|
if err := clearPendingFork(run.RunID); err != nil {
|
||||||
|
slog.Warn("expedition: backtrack clear fork", "run", run.RunID, "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
beatLock(run, "", "sealed")
|
||||||
|
if _, err := revisitZoneRun(run.RunID, target, run.VisitedNodes); err != nil {
|
||||||
|
slog.Warn("expedition: backtrack from dead fork", "run", run.RunID, "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if exp != nil {
|
||||||
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||||
|
"every way on was sealed — the party doubled back", "")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// backtrackTarget picks the room a dead fork falls back to: the most recently
|
||||||
|
// entered visited node that is genuinely joined to the current one by an edge
|
||||||
|
// and that still offers a way on other than the sealed room.
|
||||||
|
//
|
||||||
|
// Both halves are load-bearing. VisitedNodes is a first-entry ordered *set*, not
|
||||||
|
// a path stack (see appendVisited) — after any earlier backtrack the entry
|
||||||
|
// before CurrentNode can sit on a completely different branch, so stepping to it
|
||||||
|
// blind teleports the party across the map. `!revisit` refuses exactly that move
|
||||||
|
// via adjacentNodes, and the autopilot has no business doing what the player is
|
||||||
|
// forbidden from doing. The second half stops the other failure: falling back
|
||||||
|
// into a corridor whose only exit is the fork we just fled from just walks
|
||||||
|
// straight back in — and the lock rolls are seeded per (run, edge), so the
|
||||||
|
// result is identical every time. That is an infinite loop, not a recovery.
|
||||||
|
func backtrackTarget(g ZoneGraph, run *DungeonRun) (string, bool) {
|
||||||
|
adj := adjacentNodes(g, run.CurrentNode)
|
||||||
|
for i := pathIndexOf(run.VisitedNodes, run.CurrentNode) - 1; i >= 0; i-- {
|
||||||
|
n := run.VisitedNodes[i]
|
||||||
|
if !adj[n] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range g.outgoingEdges(n) {
|
||||||
|
if e.To != run.CurrentNode {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
|
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, err := getActiveExpedition(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -903,9 +1240,19 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
|||||||
// (unlocked) route and keep walking instead of stalling out.
|
// (unlocked) route and keep walking instead of stalling out.
|
||||||
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
|
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
|
||||||
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
|
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
|
||||||
picked := compact &&
|
stale := compact && time.Since(run.LastActionAt) > forkAutoPickTimeout
|
||||||
time.Since(run.LastActionAt) > forkAutoPickTimeout &&
|
picked := stale && p.autoPickStaleFork(exp, run, pf)
|
||||||
p.autoPickStaleFork(exp, run, pf)
|
// Stale and nothing takeable: every route locked, no tools. Back out
|
||||||
|
// one room rather than sitting here until the 24h reaper ends an
|
||||||
|
// expedition the player may be days into. The backtrack clears the
|
||||||
|
// fork, so the next tick walks from the previous room normally.
|
||||||
|
if stale && !picked && p.backtrackFromDeadFork(exp, run) {
|
||||||
|
return autopilotWalkResult{
|
||||||
|
finalMsg: "🔒 Every way on was sealed. The party doubled back to look for another line.",
|
||||||
|
rooms: 0,
|
||||||
|
reason: stopFork,
|
||||||
|
}
|
||||||
|
}
|
||||||
if !picked {
|
if !picked {
|
||||||
zone := zoneOrFallback(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
return autopilotWalkResult{
|
return autopilotWalkResult{
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/flavor"
|
"gogobee/internal/flavor"
|
||||||
@@ -75,7 +74,7 @@ func resolveCombatInterrupt(
|
|||||||
rollFn func() int,
|
rollFn func() int,
|
||||||
) (CombatInterruptKind, int) {
|
) (CombatInterruptKind, int) {
|
||||||
if rollFn == nil {
|
if rollFn == nil {
|
||||||
rollFn = func() int { return rand.IntN(20) + 1 }
|
rollFn = func() int { return simIntN(20) + 1 }
|
||||||
}
|
}
|
||||||
r := rollFn()
|
r := rollFn()
|
||||||
mod := tier
|
mod := tier
|
||||||
@@ -249,7 +248,7 @@ func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
|
|||||||
if tier < 1 {
|
if tier < 1 {
|
||||||
tier = 1
|
tier = 1
|
||||||
}
|
}
|
||||||
dmg := 1 + rand.IntN(4) + m.AttackBonus/2
|
dmg := 1 + simIntN(4) + m.AttackBonus/2
|
||||||
floor := tier
|
floor := tier
|
||||||
if floorOverride >= 0 {
|
if floorOverride >= 0 {
|
||||||
floor = floorOverride
|
floor = floorOverride
|
||||||
@@ -484,7 +483,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
chance := rollPatrolChance(exp.ThreatLevel)
|
chance := rollPatrolChance(exp.ThreatLevel)
|
||||||
if chance <= 0 || rand.Float64() > chance {
|
if chance <= 0 || simFloat64() > chance {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
|
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
|
||||||
|
|||||||
@@ -350,6 +350,12 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
|||||||
// A double-fire on the same expedition is a no-op.
|
// A double-fire on the same expedition is a no-op.
|
||||||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||||
priorBriefing := e.LastBriefingAt
|
priorBriefing := e.LastBriefingAt
|
||||||
|
// Everything logged since the previous briefing is what the overnight
|
||||||
|
// digest reports. Captured before the CAS below clobbers the column.
|
||||||
|
digestSince := e.StartDate
|
||||||
|
if priorBriefing != nil {
|
||||||
|
digestSince = *priorBriefing
|
||||||
|
}
|
||||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||||
res, err := db.Get().Exec(`
|
res, err := db.Get().Exec(`
|
||||||
@@ -373,7 +379,7 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
// DM (rollover happened recently) or force-fires processNightCamp
|
// DM (rollover happened recently) or force-fires processNightCamp
|
||||||
// itself (safety net for stalled autopilots).
|
// itself (safety net for stalled autopilots).
|
||||||
if isEventAnchored(e) {
|
if isEventAnchored(e) {
|
||||||
return p.deliverBriefingEventAnchored(e, priorBriefing)
|
return p.deliverBriefingEventAnchored(e, priorBriefing, digestSince)
|
||||||
}
|
}
|
||||||
|
|
||||||
burn, err := p.nightRolloverBurn(e)
|
burn, err := p.nightRolloverBurn(e)
|
||||||
@@ -400,6 +406,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
|
|
||||||
line := pickMorningBriefing(e.CurrentDay)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
|
// The single daily message: fold in what the now-silent recap, night
|
||||||
|
// check and ambient events recorded since the last briefing.
|
||||||
|
body = appendOvernightDigest(body, e.ID, digestSince)
|
||||||
if sl := p.shadowBriefingLine(e); sl != "" {
|
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||||
body += "\n" + sl + "\n"
|
body += "\n" + sl + "\n"
|
||||||
}
|
}
|
||||||
@@ -413,7 +422,13 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
body += "\n" + ml
|
body += "\n" + ml
|
||||||
}
|
}
|
||||||
|
|
||||||
p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
|
p.fanOutExpeditionDM(e, body, p.briefingPerReader)
|
||||||
|
// N1/A6 anchor, relocated here from the retired night-camp digest DM.
|
||||||
|
// Only on a run still under way: an expedition that just ended does not
|
||||||
|
// want a mid-day event landing on top of the emergence.
|
||||||
|
if e.Status == ExpeditionStatusActive {
|
||||||
|
p.fireDigestEventAnchor(e)
|
||||||
|
}
|
||||||
// Emergence seam: a briefing-time forced extraction (starvation / abyss
|
// Emergence seam: a briefing-time forced extraction (starvation / abyss
|
||||||
// collapse) surfaces the players alive — roll pet arrival. Combat/patrol
|
// collapse) surfaces the players alive — roll pet arrival. Combat/patrol
|
||||||
// deaths never reach deliverBriefing (the row is already abandoned), so an
|
// deaths never reach deliverBriefing (the row is already abandoned), so an
|
||||||
@@ -489,7 +504,9 @@ func (p *AdventurePlugin) maybeDeliverDeferredBriefing(uid id.UserID, now time.T
|
|||||||
//
|
//
|
||||||
// priorBriefing is the last_briefing_at value as of entry into deliverBriefing
|
// priorBriefing is the last_briefing_at value as of entry into deliverBriefing
|
||||||
// (before the CAS clobbered it). nil means day-1 or genuinely never rolled.
|
// (before the CAS clobbered it). nil means day-1 or genuinely never rolled.
|
||||||
func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time) error {
|
// digestSince is the same instant collapsed to a non-nil cutoff (start date on
|
||||||
|
// day 1) — the window the overnight digest reports.
|
||||||
|
func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time, digestSince time.Time) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
var since time.Duration
|
var since time.Duration
|
||||||
if priorBriefing != nil {
|
if priorBriefing != nil {
|
||||||
@@ -516,6 +533,7 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
|
|||||||
|
|
||||||
line := pickMorningBriefing(e.CurrentDay)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
|
body = appendOvernightDigest(body, e.ID, digestSince)
|
||||||
if sl := p.shadowBriefingLine(e); sl != "" {
|
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||||
body += "\n" + sl + "\n"
|
body += "\n" + sl + "\n"
|
||||||
}
|
}
|
||||||
@@ -529,7 +547,13 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
|
|||||||
body += "\n" + ml
|
body += "\n" + ml
|
||||||
}
|
}
|
||||||
|
|
||||||
p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
|
p.fanOutExpeditionDM(e, body, p.briefingPerReader)
|
||||||
|
// N1/A6 anchor, relocated here from the retired night-camp digest DM.
|
||||||
|
// Only on a run still under way: an expedition that just ended does not
|
||||||
|
// want a mid-day event landing on top of the emergence.
|
||||||
|
if e.Status == ExpeditionStatusActive {
|
||||||
|
p.fireDigestEventAnchor(e)
|
||||||
|
}
|
||||||
if forced && e.Status == ExpeditionStatusAbandoned {
|
if forced && e.Status == ExpeditionStatusAbandoned {
|
||||||
for _, uid := range expeditionAudience(e) {
|
for _, uid := range expeditionAudience(e) {
|
||||||
p.maybeRollPetArrivalOnEmerge(uid)
|
p.maybeRollPetArrivalOnEmerge(uid)
|
||||||
@@ -566,9 +590,10 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// E2b: night phase wandering check fires before the recap so its
|
// E2b: night phase wandering check. Still fires on the 21:00 clock and
|
||||||
// outcome is part of today's log when the recap renders.
|
// still writes its own "night" log entry — processNightCheck owns that —
|
||||||
var night *NightCheck
|
// which is how the outcome reaches the next morning's digest now that
|
||||||
|
// the recap itself no longer sends anything.
|
||||||
if e.Camp != nil && e.Camp.Active {
|
if e.Camp != nil && e.Camp.Active {
|
||||||
c, _ := LoadDnDCharacter(id.UserID(e.UserID))
|
c, _ := LoadDnDCharacter(id.UserID(e.UserID))
|
||||||
var charClass DnDClass
|
var charClass DnDClass
|
||||||
@@ -579,7 +604,6 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
|||||||
if err := processNightCheck(e, nc); err != nil {
|
if err := processNightCheck(e, nc); err != nil {
|
||||||
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
|
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
|
||||||
}
|
}
|
||||||
night = &nc
|
|
||||||
// §7.4: Feywild double-day fires an extra wandering check.
|
// §7.4: Feywild double-day fires an extra wandering check.
|
||||||
if e.ZoneID == ZoneFeywildCrossing {
|
if e.ZoneID == ZoneFeywildCrossing {
|
||||||
if today, _ := e.RegionState["feywild_today"].(string); today == string(FeywildDistortionDouble) {
|
if today, _ := e.RegionState["feywild_today"].(string); today == string(FeywildDistortionDouble) {
|
||||||
@@ -601,12 +625,11 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
line := pickEveningRecap(e, dayEntries)
|
line := pickEveningRecap(e, dayEntries)
|
||||||
body := renderEveningRecap(e, line, dayEntries)
|
|
||||||
if night != nil {
|
|
||||||
body += "\n" + renderNightCheck(*night)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.fanOutExpeditionDM(e, body, nil)
|
// Once-a-day cadence: the night check above has already run and already
|
||||||
|
// written its own "night" log entry, so the morning digest reports the
|
||||||
|
// outcome. Nothing is sent here. The recap entry below still lands so
|
||||||
|
// the site keeps a day boundary to render against.
|
||||||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
|
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
|
||||||
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
|
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -352,32 +352,54 @@ func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
|
|||||||
|
|
||||||
// ── !extract command ────────────────────────────────────────────────────────
|
// ── !extract command ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
// Sentinels for the two ways an extraction can be refused. They exist so the
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
// headless caller (the web action queue, pete_orders.go) can turn a refusal into
|
||||||
|
// its own verdict without parsing a DM. `!extract` maps them straight back to the
|
||||||
|
// prose it always sent.
|
||||||
|
var (
|
||||||
|
errExtractNoRun = errors.New("extract: no active expedition")
|
||||||
|
errExtractNotLeader = errors.New("extract: not the party leader")
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractOutcome is what an extraction did, for a caller that has to describe it
|
||||||
|
// somewhere other than a DM.
|
||||||
|
type extractOutcome struct {
|
||||||
|
Zone string // display name
|
||||||
|
Day int
|
||||||
|
}
|
||||||
|
|
||||||
|
// performExtraction is the whole of `!extract` minus the command framing: the
|
||||||
|
// per-user lock, the leader check, the state flip, the log line, the party
|
||||||
|
// fan-out and the emergence pet roll. It is shared with the web action queue so
|
||||||
|
// that pulling out from a phone is the *same* extraction, not a second
|
||||||
|
// implementation of one — the party still gets DM'd, the log still gets its
|
||||||
|
// line, and the resume window is the same window.
|
||||||
|
func (p *AdventurePlugin) performExtraction(uid id.UserID) (extractOutcome, error) {
|
||||||
|
userMu := p.advUserLock(uid)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
exp, isLeader, err := activeExpeditionFor(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return extractOutcome{}, fmt.Errorf("reading expedition state: %w", err)
|
||||||
}
|
}
|
||||||
if exp == nil {
|
if exp == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active expedition to extract from.")
|
return extractOutcome{}, errExtractNoRun
|
||||||
}
|
}
|
||||||
if !isLeader {
|
if !isLeader {
|
||||||
// Extraction ends the expedition for the whole roster, so it is the
|
// Extraction ends the expedition for the whole roster, so it is the
|
||||||
// leader's call — the same reasoning that makes `!flee` leader-only.
|
// leader's call — the same reasoning that makes `!flee` leader-only.
|
||||||
return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.")
|
return extractOutcome{}, errExtractNotLeader
|
||||||
}
|
}
|
||||||
zone, _ := getZone(exp.ZoneID)
|
zone, _ := getZone(exp.ZoneID)
|
||||||
updated, err := voluntaryExtractExpedition(ctx.Sender)
|
updated, err := voluntaryExtractExpedition(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error())
|
return extractOutcome{}, err
|
||||||
}
|
}
|
||||||
line := flavor.Pick(flavor.ExtractionVoluntary)
|
line := flavor.Pick(flavor.ExtractionVoluntary)
|
||||||
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
|
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
|
||||||
"voluntary extraction", line)
|
"voluntary extraction", line)
|
||||||
markActedToday(ctx.Sender)
|
markActedToday(uid)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
|
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
|
||||||
@@ -401,84 +423,167 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
|||||||
|
|
||||||
// Emergence seam: surfacing from a run is when an animal may have moved
|
// Emergence seam: surfacing from a run is when an animal may have moved
|
||||||
// into the empty house. Every member surfaced, so every member rolls.
|
// into the empty house. Every member surfaced, so every member rolls.
|
||||||
for _, uid := range expeditionAudience(updated) {
|
for _, member := range expeditionAudience(updated) {
|
||||||
p.maybeRollPetArrivalOnEmerge(uid)
|
p.maybeRollPetArrivalOnEmerge(member)
|
||||||
|
}
|
||||||
|
return extractOutcome{Zone: zone.Display, Day: updated.CurrentDay}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExtractCmd is `!extract`: the command framing around performExtraction.
|
||||||
|
// The extraction itself, including the DM everyone in the party gets, happens in
|
||||||
|
// there — so this only has to turn a refusal back into the prose it always sent.
|
||||||
|
func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
||||||
|
_, err := p.performExtraction(ctx.Sender)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errExtractNoRun):
|
||||||
|
return p.SendDM(ctx.Sender, "No active expedition to extract from.")
|
||||||
|
case errors.Is(err, errExtractNotLeader):
|
||||||
|
return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.")
|
||||||
|
case err != nil:
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── !resume command ─────────────────────────────────────────────────────────
|
// ── !resume command ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error {
|
// Sentinels for the ways going back in can be refused. Same contract as
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
// performExpeditionStart's: each one comes back inside an advRefusal that also
|
||||||
|
// carries the finished sentence, so `!resume` keeps its copy verbatim.
|
||||||
|
//
|
||||||
|
// errResumeNeedLoadout is the odd one out and deliberately so: it is not a
|
||||||
|
// refusal at all but the loadout prompt, returned as one so the whole decision
|
||||||
|
// stays inside the lock. The web never triggers it — it always names a loadout.
|
||||||
|
var (
|
||||||
|
errResumeNeedLoadout = errors.New("resume: no loadout named")
|
||||||
|
errResumeBusy = errors.New("resume: already on an expedition")
|
||||||
|
errResumeNothing = errors.New("resume: no extracted expedition")
|
||||||
|
errResumeLapsed = errors.New("resume: past the 7-day window")
|
||||||
|
errResumeBadPacks = errors.New("resume: invalid pack selection")
|
||||||
|
errResumeBroke = errors.New("resume: cannot cover outfitting")
|
||||||
|
errResumeFailed = errors.New("resume: could not resume")
|
||||||
|
)
|
||||||
|
|
||||||
|
// resumeOutcome is what going back in did, for a caller describing it somewhere
|
||||||
|
// other than a DM.
|
||||||
|
type resumeOutcome struct {
|
||||||
|
Zone ZoneDefinition
|
||||||
|
Day int
|
||||||
|
Supplies ExpeditionSupplies
|
||||||
|
Purchase SupplyPurchase
|
||||||
|
Threat int
|
||||||
|
Stack int
|
||||||
|
Line string
|
||||||
|
}
|
||||||
|
|
||||||
|
// performResume is `!resume` minus the command framing: the leader check, the
|
||||||
|
// lapse check, the re-outfitting purchase and the fresh region run. Shared with
|
||||||
|
// the web action queue so that walking back in from a phone is the same walk.
|
||||||
|
//
|
||||||
|
// loadoutTok is the raw `Ns Md` / preset token; empty asks for the prompt.
|
||||||
|
// idemKey, when set, is the web order's guid and moves the money onto the
|
||||||
|
// idempotent variants — see beginExpeditionIdem for why a refund then makes a
|
||||||
|
// retry unsafe, which is why every error here is permanent for the web caller.
|
||||||
|
func (p *AdventurePlugin) performResume(uid id.UserID, loadoutTok, idemKey string) (resumeOutcome, error) {
|
||||||
|
userMu := p.advUserLock(uid)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
c, err := LoadDnDCharacter(ctx.Sender)
|
c, err := LoadDnDCharacter(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
|
return resumeOutcome{}, fmt.Errorf("Couldn't load your character: %s", err)
|
||||||
}
|
}
|
||||||
if c == nil || c.PendingSetup {
|
if c == nil || c.PendingSetup {
|
||||||
return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` first.")
|
return resumeOutcome{}, refuseAdv(errResumeNothing, "No Adv 2.0 character yet — run `!setup` first.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing, isLeader, _ := activeExpeditionFor(ctx.Sender); existing != nil {
|
if existing, isLeader, _ := activeExpeditionFor(uid); existing != nil {
|
||||||
zone, _ := getZone(existing.ZoneID)
|
zone, _ := getZone(existing.ZoneID)
|
||||||
if !isLeader {
|
if !isLeader {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return resumeOutcome{}, refuseAdv(errResumeBusy,
|
||||||
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
|
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
|
||||||
zone.Display, existing.CurrentDay))
|
zone.Display, existing.CurrentDay)
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
// A web order that already paid and already resumed lands here on the
|
||||||
|
// re-offer; the settled debit is what tells that apart from a player who
|
||||||
|
// really is already out. Same tell as performExpeditionStart's.
|
||||||
|
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
||||||
|
out := resumeOutcome{Zone: zone, Day: existing.CurrentDay,
|
||||||
|
Supplies: existing.Supplies, Threat: existing.ThreatLevel}
|
||||||
|
// Re-price the same loadout at the same tier so the verdict this feeds
|
||||||
|
// can still say what it cost. Leaving Purchase zero would file a
|
||||||
|
// "re-outfitted for 0 coins" receipt for a trip that was paid for.
|
||||||
|
if pp, perr := resolveLoadoutOrParse(strings.TrimSpace(loadoutTok), zone.Tier); perr == nil {
|
||||||
|
out.Purchase = pp
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return resumeOutcome{}, refuseAdv(errResumeBusy,
|
||||||
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||||
zone.Display, existing.CurrentDay))
|
zone.Display, existing.CurrentDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, err := getResumableExpedition(ctx.Sender)
|
exp, err := getResumableExpedition(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return resumeOutcome{}, fmt.Errorf("Couldn't read expedition state: %s", err)
|
||||||
}
|
}
|
||||||
if exp == nil {
|
if exp == nil {
|
||||||
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
|
return resumeOutcome{}, refuseAdv(errResumeNothing,
|
||||||
|
"No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
|
||||||
}
|
}
|
||||||
if extractionLapsed(exp, time.Now().UTC()) {
|
if extractionLapsed(exp, time.Now().UTC()) {
|
||||||
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
||||||
// here on its own; this keeps the refusal and the reap in one breath.
|
// here on its own; this keeps the refusal and the reap in one breath.
|
||||||
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
||||||
return p.SendDM(ctx.Sender,
|
return resumeOutcome{}, refuseAdv(errResumeLapsed,
|
||||||
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
||||||
}
|
}
|
||||||
|
|
||||||
resumeZone, _ := getZone(exp.ZoneID)
|
zone, _ := getZone(exp.ZoneID)
|
||||||
// D5-b: prompt for a preset loadout on empty args.
|
// D5-b: prompt for a preset loadout on empty args.
|
||||||
if strings.TrimSpace(args) == "" {
|
if strings.TrimSpace(loadoutTok) == "" {
|
||||||
return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume"))
|
return resumeOutcome{}, refuseAdv(errResumeNeedLoadout, "%s", renderLoadoutPrompt(zone, "resume"))
|
||||||
}
|
}
|
||||||
purchase, err := resolveLoadoutOrParse(strings.TrimSpace(args), resumeZone.Tier)
|
purchase, err := resolveLoadoutOrParse(strings.TrimSpace(loadoutTok), zone.Tier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Couldn't parse supply packs: %s", err.Error())
|
||||||
}
|
}
|
||||||
if err := purchase.Validate(resumeZone.Tier); err != nil {
|
if err := purchase.Validate(zone.Tier); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Invalid pack selection: %s", err.Error())
|
||||||
}
|
}
|
||||||
cost := float64(purchase.Cost())
|
cost := float64(purchase.Cost())
|
||||||
if p.euro == nil {
|
if p.euro == nil {
|
||||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
return resumeOutcome{}, refuseAdv(errResumeFailed, "Coin system unavailable — try again later.")
|
||||||
}
|
}
|
||||||
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
paid := idemKey != "" && p.euro.HasExternalTx(idemKey)
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
if !paid {
|
||||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
if balance := p.euro.GetBalance(uid); balance < cost {
|
||||||
int(cost), balance))
|
return resumeOutcome{}, refuseAdv(errResumeBroke,
|
||||||
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||||
|
int(cost), balance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !p.euro.Debit(ctx.Sender, cost, "expedition resume outfitting: "+string(exp.ZoneID)) {
|
debit := func(why string) bool { return p.euro.Debit(uid, cost, why) }
|
||||||
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
refund := func(why, suffix string) { p.euro.Credit(uid, cost, why) }
|
||||||
|
if idemKey != "" {
|
||||||
|
debit = func(why string) bool {
|
||||||
|
ok, _, err := p.euro.DebitIdem(uid, cost, why, idemKey)
|
||||||
|
return err == nil && ok
|
||||||
|
}
|
||||||
|
refund = func(why, suffix string) {
|
||||||
|
if _, _, err := p.euro.CreditIdem(uid, cost, why, idemKey+":refund"+suffix); err != nil {
|
||||||
|
slog.Error("expedition: resume refund failed", "user", uid, "order", idemKey, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !debit("expedition resume outfitting: " + string(exp.ZoneID)) {
|
||||||
|
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't debit outfitting cost (try again).")
|
||||||
}
|
}
|
||||||
|
|
||||||
zone, _ := getZone(exp.ZoneID)
|
|
||||||
supplies := makeSupplies(zone.Tier, purchase)
|
supplies := makeSupplies(zone.Tier, purchase)
|
||||||
if err := resumeExpedition(exp.ID, supplies); err != nil {
|
if err := resumeExpedition(exp.ID, supplies); err != nil {
|
||||||
p.euro.Credit(ctx.Sender, cost, "expedition resume refund")
|
refund("expedition resume refund", "")
|
||||||
return p.SendDM(ctx.Sender, "Couldn't resume: "+err.Error())
|
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't resume: %s", err.Error())
|
||||||
}
|
}
|
||||||
exp.Status = ExpeditionStatusActive
|
exp.Status = ExpeditionStatusActive
|
||||||
exp.Supplies = supplies
|
exp.Supplies = supplies
|
||||||
@@ -489,25 +594,40 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
|
|||||||
exp.RegionState[regionStateRegionRuns] = map[string]string{}
|
exp.RegionState[regionStateRegionRuns] = map[string]string{}
|
||||||
_ = persistRegionState(exp)
|
_ = persistRegionState(exp)
|
||||||
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
||||||
p.euro.Credit(ctx.Sender, cost, "expedition resume refund (run-spawn failed)")
|
refund("expedition resume refund (run-spawn failed)", ":region")
|
||||||
return p.SendDM(ctx.Sender, "Couldn't outfit the resumed region: "+err.Error())
|
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't outfit the resumed region: %s", err.Error())
|
||||||
}
|
}
|
||||||
line := flavor.Pick(flavor.ExpeditionResume)
|
line := flavor.Pick(flavor.ExpeditionResume)
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||||
"expedition resumed", line)
|
"expedition resumed", line)
|
||||||
|
|
||||||
|
return resumeOutcome{
|
||||||
|
Zone: zone, Day: exp.CurrentDay, Supplies: supplies, Purchase: purchase,
|
||||||
|
Threat: exp.ThreatLevel, Stack: exp.TemporalStack, Line: line,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResumeCmd is `!resume`: the command framing around performResume.
|
||||||
|
func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error {
|
||||||
|
out, err := p.performResume(ctx.Sender, args, "")
|
||||||
|
if err != nil {
|
||||||
|
// Every refusal — and the loadout prompt, which travels as one — arrives
|
||||||
|
// as a finished sentence, so this only has to say it.
|
||||||
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n",
|
b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n",
|
||||||
zone.Display, exp.CurrentDay))
|
out.Zone.Display, out.Day))
|
||||||
if line != "" {
|
if out.Line != "" {
|
||||||
b.WriteString(line)
|
b.WriteString(out.Line)
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
|
b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
|
||||||
supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost()))
|
out.Supplies.Max, out.Purchase.StandardPacks, out.Purchase.DeluxePacks, out.Purchase.Cost()))
|
||||||
b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", exp.ThreatLevel))
|
b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", out.Threat))
|
||||||
if exp.TemporalStack != 0 {
|
if out.Stack != 0 {
|
||||||
b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", exp.TemporalStack))
|
b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", out.Stack))
|
||||||
}
|
}
|
||||||
b.WriteString("\nUse `!expedition status` for the daily briefing.")
|
b.WriteString("\nUse `!expedition status` for the daily briefing.")
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
|
|||||||
@@ -175,3 +175,28 @@ func TestResume_WindowExpired(t *testing.T) {
|
|||||||
time.Since(*got.CompletedAt), extractResumeWindow)
|
time.Since(*got.CompletedAt), extractResumeWindow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `!expedition extract` and `!expedition resume` are aliases for two top-level
|
||||||
|
// commands that take the per-user lock themselves. If the alias dispatcher takes
|
||||||
|
// that lock first the handler blocks on it forever and, because the deferred
|
||||||
|
// unlock never runs, every later adventure command from that player wedges too.
|
||||||
|
// This does not fail on regression — it hangs — so the timeout is the assertion.
|
||||||
|
func TestExpeditionAliasesDoNotWedgeTheUserLock(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@exp-alias-lock:example")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||||
|
|
||||||
|
for _, sub := range []string{"extract", "resume"} {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||||
|
_ = p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, sub)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("!expedition %s never returned: the alias re-took advUserLock", sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition,
|
|||||||
tc := resolveTransitWanderingCheck(exp, charClass, nil)
|
tc := resolveTransitWanderingCheck(exp, charClass, nil)
|
||||||
_ = processTransitWanderingCheck(exp, tc)
|
_ = processTransitWanderingCheck(exp, tc)
|
||||||
|
|
||||||
|
// The liveblog beat goes on the *outgoing* run, and it has to be filed before
|
||||||
|
// the run is retired — a region crossing is the last thing that happens in
|
||||||
|
// the region being left, and it is what explains why that run's log stops.
|
||||||
|
if outgoing, _ := getZoneRun(exp.RunID); outgoing != nil {
|
||||||
|
beatRegion(outgoing, cur.Name, next.Name)
|
||||||
|
}
|
||||||
|
|
||||||
// R2 — retire the outgoing region's DungeonRun before mutating
|
// R2 — retire the outgoing region's DungeonRun before mutating
|
||||||
// CurrentRegion so retireRegionRun keys the right region.
|
// CurrentRegion so retireRegionRun keys the right region.
|
||||||
if err := retireRegionRun(exp, cur.ID); err != nil {
|
if err := retireRegionRun(exp, cur.ID); err != nil {
|
||||||
|
|||||||
@@ -112,6 +112,56 @@ func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacte
|
|||||||
// AutoCritFirst is already a one-shot bool.
|
// AutoCritFirst is already a one-shot bool.
|
||||||
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
|
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
|
||||||
// The passive heal triggers first since both use the same threshold.
|
// The passive heal triggers first since both use the same threshold.
|
||||||
|
// cantripDice is the 5e at-will cantrip die progression (Fire Bolt / Eldritch
|
||||||
|
// Blast): 1 die L1–4, 2 at L5, 3 at L11, 4 at L17. Drives the per-round arcane
|
||||||
|
// blaster damage (CantripPerRound) that models a caster's sustained at-will
|
||||||
|
// floor — see CombatModifiers.CantripPerRound.
|
||||||
|
func cantripDice(level int) int {
|
||||||
|
switch {
|
||||||
|
case level >= 17:
|
||||||
|
return 4
|
||||||
|
case level >= 11:
|
||||||
|
return 3
|
||||||
|
case level >= 5:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// casterBlasterFloor gives the arcane blasters (Mage/Sorcerer/Warlock) their
|
||||||
|
// shared sustained-DPS floor at T5. Two knobs, both LEVEL-SCALED so the L20
|
||||||
|
// floor lift stays negligible at low tiers — a flat +40 HP doubled a L1 mage
|
||||||
|
// and facerolled T5, which the class-balance guardrail (dnd_class_balance_test)
|
||||||
|
// correctly rejected.
|
||||||
|
//
|
||||||
|
// - Cantrip: kill-speed is the T5 currency (fights are truncation-bound), so
|
||||||
|
// the primary lever is damage. Multiplicative form — the spell modifier
|
||||||
|
// (INT for Mage, CHA for Sorcerer/Warlock) rides EVERY die, matching 5e
|
||||||
|
// Agonizing Blast. cantripDice scales 1→4 across levels.
|
||||||
|
// - Survival: just enough Defense (scaled by level) that the caster lives long
|
||||||
|
// enough for the cantrip to connect the kill — NOT a tank rider. This adds
|
||||||
|
// Defense only (no HP add); calcDamage's diminishing returns keep the L20
|
||||||
|
// +20 Def from becoming a wall.
|
||||||
|
//
|
||||||
|
// casterCantripBase / casterDefPerLevel are the caster tuning dials; see the
|
||||||
|
// rebaseline plan for the sweep that set them. The cantrip floor only becomes a
|
||||||
|
// live lever once combat_cmd.go bridges CantripPerRound into the turn engine
|
||||||
|
// (the swing engine, combat_engine.go:590, is not the auto-resolve path). Mage
|
||||||
|
// and Sorcerer take casterCantripBase; Warlock passes 0 — its bare-dice cantrip
|
||||||
|
// plus its structural edge already lands it mid-band, so an added floor would
|
||||||
|
// overshoot the 45 ceiling.
|
||||||
|
const (
|
||||||
|
casterCantripBase = 3 // per-die base before the ability modifier rides in
|
||||||
|
casterDefPerLevel = 1 // ~+20 Def at L20, +1 at L1
|
||||||
|
)
|
||||||
|
|
||||||
|
func casterBlasterFloor(stats *CombatStats, mods *CombatModifiers, level, abilityMod, base int, cantrip string) {
|
||||||
|
mods.CantripPerRound = cantripDice(level) * (base + clampNonNeg(abilityMod))
|
||||||
|
mods.CantripDesc = cantrip
|
||||||
|
stats.Defense += casterDefPerLevel * level
|
||||||
|
}
|
||||||
|
|
||||||
func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
|
func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
|
||||||
switch c.Class {
|
switch c.Class {
|
||||||
case ClassFighter:
|
case ClassFighter:
|
||||||
@@ -127,18 +177,23 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
// re-tune in a follow-up if their win curves drift after this.
|
// re-tune in a follow-up if their win curves drift after this.
|
||||||
switch {
|
switch {
|
||||||
case c.Level >= 20:
|
case c.Level >= 20:
|
||||||
mods.ExtraAttacks += 3
|
mods.ExtraAttacks += 2 // rebaseline: ceiling nerf — 3 swings at L20, was 4 (the engine-ceiling faceroll)
|
||||||
case c.Level >= 11:
|
case c.Level >= 11:
|
||||||
mods.ExtraAttacks += 2
|
mods.ExtraAttacks += 2
|
||||||
case c.Level >= 5:
|
case c.Level >= 5:
|
||||||
mods.ExtraAttacks += 1
|
mods.ExtraAttacks += 1
|
||||||
}
|
}
|
||||||
|
stats.AttackBonus -= 2 // rebaseline: sub-swing to-hit trim — 3 swings lands the Fighter in the ~60 band
|
||||||
case ClassRogue:
|
case ClassRogue:
|
||||||
mods.AutoCritFirst = true
|
mods.AutoCritFirst = true
|
||||||
|
if c.Level >= 5 { // rebaseline: 2nd swing (was 1) fixes the 1-swing action-economy floor at T5
|
||||||
|
mods.ExtraAttacks += 1
|
||||||
|
}
|
||||||
|
stats.AttackBonus -= 3 // rebaseline: to-hit trim so the 2nd swing lands the Rogue in band, not the +75pp nuke
|
||||||
// Phase 2 class-balance: rogue's once-per-fight auto-crit goes stale
|
// Phase 2 class-balance: rogue's once-per-fight auto-crit goes stale
|
||||||
// at high tiers (T5 mean trails leaders by ~10pp pre-tune). Add a
|
// at high tiers (T5 mean trails leaders by ~10pp pre-tune). Add a
|
||||||
// modest steady-DPS rider so post-opener rounds aren't pure attrition.
|
// modest steady-DPS rider so post-opener rounds aren't pure attrition.
|
||||||
mods.DamageBonus += 0.05
|
mods.DamageBonus += -0.10 // rebaseline: fine-trim the 2-swing Rogue down into the ~60 band
|
||||||
// Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
|
// Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
|
||||||
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
|
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
|
||||||
// AutoCritFirst + DamageBonus alone left the rogue's defining
|
// AutoCritFirst + DamageBonus alone left the rogue's defining
|
||||||
@@ -154,6 +209,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
mods.SneakAttackDie += sneakDice
|
mods.SneakAttackDie += sneakDice
|
||||||
case ClassMage:
|
case ClassMage:
|
||||||
stats.AttackBonus++
|
stats.AttackBonus++
|
||||||
|
// At-will Fire Bolt + level-scaled survival — the sustained arcane floor.
|
||||||
|
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.INT), casterCantripBase, "Fire Bolt")
|
||||||
// Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
|
// Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
|
||||||
// per round. A modest damage rider lifts weapon hits (DamageBonus does
|
// per round. A modest damage rider lifts weapon hits (DamageBonus does
|
||||||
// not multiply queued SpellPreDamage — that path is its own field).
|
// not multiply queued SpellPreDamage — that path is its own field).
|
||||||
@@ -190,10 +247,19 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
mods.ExtraAttacks += 1
|
mods.ExtraAttacks += 1
|
||||||
}
|
}
|
||||||
case ClassDruid:
|
case ClassDruid:
|
||||||
// Wild Resilience — multiplicative, so it stacks cleanly with the
|
// Multiplicative, so it stacks cleanly with the subclass DamageReduct
|
||||||
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
|
// riders (DamageReduct is initialized to 1.0 by DerivePlayerStats before
|
||||||
// by DerivePlayerStats before passives run.
|
// passives run). NOTE the player-defender direction: DamageReduct feeds
|
||||||
|
// calcDamage as a defense multiplier, so <1 = MORE damage taken. This
|
||||||
|
// line is therefore a mild survival trim in the same direction as the
|
||||||
|
// rebaseline *0.2 below — not the damage cut the "Wild Resilience" name
|
||||||
|
// suggests. Left as-is because the rebaseline sweep is tuned to it.
|
||||||
mods.DamageReduct *= 0.95
|
mods.DamageReduct *= 0.95
|
||||||
|
// rebaseline: the Druid wins T5 rooms on the survival tiebreak, immune to
|
||||||
|
// every damage lever. DamageReduct is a defense multiplier (calcDamage) —
|
||||||
|
// <1 = take MORE damage. Combined with a damage trim to pull it to band.
|
||||||
|
mods.DamageReduct *= 0.2
|
||||||
|
mods.DamageBonus += -0.20
|
||||||
// Phase 3 class-balance: druid was the only caster chassis with a
|
// Phase 3 class-balance: druid was the only caster chassis with a
|
||||||
// purely defensive passive, and the off-tier numbers showed it —
|
// purely defensive passive, and the off-tier numbers showed it —
|
||||||
// L1/T2 mean 0.04 vs Mage 0.27. Mirror the other caster bursts so
|
// L1/T2 mean 0.04 vs Mage 0.27. Mirror the other caster bursts so
|
||||||
@@ -219,6 +285,7 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
stats.AttackBonus++
|
stats.AttackBonus++
|
||||||
mods.DamageBonus += 0.05
|
mods.DamageBonus += 0.05
|
||||||
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||||
|
mods.DamageReduct *= 0.4 // rebaseline: Bard also wins T5 on the survival tiebreak — take more damage to reach band
|
||||||
case ClassSorcerer:
|
case ClassSorcerer:
|
||||||
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
|
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
|
||||||
// spellcasting stat. Floors at the flat base for low-CHA builds.
|
// spellcasting stat. Floors at the flat base for low-CHA builds.
|
||||||
@@ -231,6 +298,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
// touching the +0.05 rider that already saturates at high tier.
|
// touching the +0.05 rider that already saturates at high tier.
|
||||||
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
|
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||||
mods.DamageBonus += 0.05
|
mods.DamageBonus += 0.05
|
||||||
|
stats.AttackBonus++ // rebaseline: Sorcerer lagged the other blasters — match their +1 to-hit
|
||||||
|
// At-will Fire Bolt + level-scaled survival — sustained arcane floor.
|
||||||
|
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), casterCantripBase, "Fire Bolt")
|
||||||
case ClassWarlock:
|
case ClassWarlock:
|
||||||
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
|
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
|
||||||
// the Warlock chassis read mid-pack at T5 (0.52) pre-tune. Eldritch
|
// the Warlock chassis read mid-pack at T5 (0.52) pre-tune. Eldritch
|
||||||
@@ -240,6 +310,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
mods.DamageBonus += 0.12
|
mods.DamageBonus += 0.12
|
||||||
stats.AttackBonus++
|
stats.AttackBonus++
|
||||||
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||||
|
// At-will Eldritch Blast + level-scaled survival — sustained arcane floor.
|
||||||
|
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), 0, "Eldritch Blast")
|
||||||
case ClassPaladin:
|
case ClassPaladin:
|
||||||
// Class-identity audit (2026-05-16) — Divine Smite as actual
|
// Class-identity audit (2026-05-16) — Divine Smite as actual
|
||||||
// per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a
|
// per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a
|
||||||
@@ -249,8 +321,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
// down so an extra-attack paladin doesn't trivialize every fight.
|
// down so an extra-attack paladin doesn't trivialize every fight.
|
||||||
// Rides DivineStrikePerHit (already in the weapon-hit damage path).
|
// Rides DivineStrikePerHit (already in the weapon-hit damage path).
|
||||||
// Previous FlatDmgStart opener felt like Lay on Hands, not Smite.
|
// Previous FlatDmgStart opener felt like Lay on Hands, not Smite.
|
||||||
smite := 3 + c.Level/3
|
smite := 4 + c.Level/2 // rebaseline: bigger Divine Smite lifts the Paladin from floor into band
|
||||||
mods.DivineStrikePerHit += smite
|
mods.DivineStrikePerHit += smite
|
||||||
|
mods.DamageReduct *= 0.9 // rebaseline: small survival trim between the two integer smite steps for a ~60 landing
|
||||||
if c.Level >= 5 {
|
if c.Level >= 5 {
|
||||||
mods.ExtraAttacks += 1
|
mods.ExtraAttacks += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -117,7 +116,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
|
|||||||
before := c.HPCurrent
|
before := c.HPCurrent
|
||||||
if !hpFull {
|
if !hpFull {
|
||||||
conMod := abilityModifier(c.CON)
|
conMod := abilityModifier(c.CON)
|
||||||
healDie := 1 + rand.IntN(6) // 1d6
|
healDie := 1 + simIntN(6) // 1d6
|
||||||
heal := healDie + conMod
|
heal := healDie + conMod
|
||||||
if heal < 1 {
|
if heal < 1 {
|
||||||
heal = 1
|
heal = 1
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
@@ -128,7 +127,7 @@ func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLev
|
|||||||
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
|
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
|
||||||
// Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage.
|
// Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage.
|
||||||
func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifiers, enemy *CombatStats, slot, charLevel int) {
|
func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifiers, enemy *CombatStats, slot, charLevel int) {
|
||||||
roll := 1 + rand.IntN(20)
|
roll := 1 + simIntN(20)
|
||||||
isCrit := roll == 20
|
isCrit := roll == 20
|
||||||
isFumble := roll == 1
|
isFumble := roll == 1
|
||||||
if isFumble || (!isCrit && roll+atk < enemy.AC) {
|
if isFumble || (!isCrit && roll+atk < enemy.AC) {
|
||||||
@@ -158,7 +157,7 @@ func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifier
|
|||||||
// future multi-enemy combat (Phase 11+) but is not consulted here.
|
// future multi-enemy combat (Phase 11+) but is not consulted here.
|
||||||
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||||
saveMod := enemySpellSaveMod(enemy)
|
saveMod := enemySpellSaveMod(enemy)
|
||||||
saveRoll := 1 + rand.IntN(20)
|
saveRoll := 1 + simIntN(20)
|
||||||
saved := saveRoll+saveMod >= dc
|
saved := saveRoll+saveMod >= dc
|
||||||
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
||||||
if saved {
|
if saved {
|
||||||
@@ -182,7 +181,7 @@ func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, ch
|
|||||||
}
|
}
|
||||||
total := 0
|
total := 0
|
||||||
for i := 0; i < darts; i++ {
|
for i := 0; i < darts; i++ {
|
||||||
total += 1 + rand.IntN(4) + 1
|
total += 1 + simIntN(4) + 1
|
||||||
}
|
}
|
||||||
mods.SpellPreDamage += total
|
mods.SpellPreDamage += total
|
||||||
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, total)
|
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, total)
|
||||||
@@ -223,7 +222,7 @@ func enemySpellSaveMod(enemy *CombatStats) int {
|
|||||||
// double damage (5e: paralyzed creatures auto-crit on melee hits).
|
// double damage (5e: paralyzed creatures auto-crit on melee hits).
|
||||||
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||||
saveMod := enemySpellSaveMod(enemy)
|
saveMod := enemySpellSaveMod(enemy)
|
||||||
saveRoll := 1 + rand.IntN(20)
|
saveRoll := 1 + simIntN(20)
|
||||||
if saveRoll+saveMod >= dc {
|
if saveRoll+saveMod >= dc {
|
||||||
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
||||||
return
|
return
|
||||||
@@ -382,7 +381,7 @@ func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) in
|
|||||||
if supreme {
|
if supreme {
|
||||||
heal += faces
|
heal += faces
|
||||||
} else {
|
} else {
|
||||||
heal += 1 + rand.IntN(faces)
|
heal += 1 + simIntN(faces)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
heal += abilityModifier(c.WIS)
|
heal += abilityModifier(c.WIS)
|
||||||
@@ -418,7 +417,7 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
|
|||||||
}
|
}
|
||||||
total := flat
|
total := flat
|
||||||
for i := 0; i < dice; i++ {
|
for i := 0; i < dice; i++ {
|
||||||
total += 1 + rand.IntN(faces)
|
total += 1 + simIntN(faces)
|
||||||
}
|
}
|
||||||
if total < 1 {
|
if total < 1 {
|
||||||
total = 1
|
total = 1
|
||||||
|
|||||||
@@ -224,7 +224,14 @@ func TestApplyClassPassives(t *testing.T) {
|
|||||||
wantFlatStart int
|
wantFlatStart int
|
||||||
wantInitBias float64
|
wantInitBias float64
|
||||||
}{
|
}{
|
||||||
{ClassFighter, 0.05, 0, false, 0, 1.0, 0, 0},
|
// Fighter-ceiling rebaseline (2026-07-16): Fighter picked up a -2 to-hit
|
||||||
|
// sub-swing trim; Rogue's steady rider flipped +0.05→-0.10 and it took a
|
||||||
|
// -3 to-hit trim (both offset a new 2nd swing gated at L5, not seen here);
|
||||||
|
// Druid took a survival trim (DR 0.95→0.19) + -0.20 damage; Bard a DR 0.4
|
||||||
|
// survival trim; Sorcerer a +1 to-hit to match the blasters; Paladin a
|
||||||
|
// 0.9 DR survival trim. Caster CantripPerRound / Defense adds are
|
||||||
|
// not asserted here.
|
||||||
|
{ClassFighter, 0.05, -2, false, 0, 1.0, 0, 0},
|
||||||
// Phase 2 class-balance rebalance: rogue picked up +5% damage,
|
// Phase 2 class-balance rebalance: rogue picked up +5% damage,
|
||||||
// Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's
|
// Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's
|
||||||
// burst now also scales with level, and Warlock picked up +1 attack.
|
// burst now also scales with level, and Warlock picked up +1 attack.
|
||||||
@@ -233,7 +240,7 @@ func TestApplyClassPassives(t *testing.T) {
|
|||||||
// Phase 3 class-balance: Druid picked up a WIS-scaled FlatDmgStart burst
|
// Phase 3 class-balance: Druid picked up a WIS-scaled FlatDmgStart burst
|
||||||
// (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5
|
// (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5
|
||||||
// (5 + 1 + clamp(mod(CHA=10)=0) = 6).
|
// (5 + 1 + clamp(mod(CHA=10)=0) = 6).
|
||||||
{ClassRogue, 0.05, 0, true, 0, 1.0, 0, 0},
|
{ClassRogue, -0.10, -3, true, 0, 1.0, 0, 0},
|
||||||
{ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
|
{ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
|
||||||
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
|
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
|
||||||
// Class-identity audit (2026-05-16): Ranger Hunter's Mark is now
|
// Class-identity audit (2026-05-16): Ranger Hunter's Mark is now
|
||||||
@@ -243,11 +250,11 @@ func TestApplyClassPassives(t *testing.T) {
|
|||||||
// FlatDmgStart compensation riders are gone; +1 to-hit stays on
|
// FlatDmgStart compensation riders are gone; +1 to-hit stays on
|
||||||
// Ranger as the "read prey tells" half.
|
// Ranger as the "read prey tells" half.
|
||||||
{ClassRanger, 0, 1, false, 0, 1.0, 0, 0},
|
{ClassRanger, 0, 1, false, 0, 1.0, 0, 0},
|
||||||
{ClassDruid, 0, 0, false, 0, 0.95, 1, 0},
|
{ClassDruid, -0.20, 0, false, 0, 0.95 * 0.2, 1, 0},
|
||||||
{ClassBard, 0.05, 1, false, 0, 1.0, 1, 1},
|
{ClassBard, 0.05, 1, false, 0, 0.4, 1, 1},
|
||||||
{ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0},
|
{ClassSorcerer, 0.05, 1, false, 0, 1.0, 6, 0},
|
||||||
{ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0},
|
{ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0},
|
||||||
{ClassPaladin, 0, 0, false, 0, 1.0, 0, 0},
|
{ClassPaladin, 0, 0, false, 0, 0.9, 0, 0},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
stats := CombatStats{AttackBonus: 5}
|
stats := CombatStats{AttackBonus: 5}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro
|
|||||||
// fork pending) the handler short-circuits with a friendly
|
// fork pending) the handler short-circuits with a friendly
|
||||||
// message — see zoneCmdGo for the full surface.
|
// message — see zoneCmdGo for the full surface.
|
||||||
return p.zoneCmdGo(ctx, rest)
|
return p.zoneCmdGo(ctx, rest)
|
||||||
|
case "unlock", "pick", "force":
|
||||||
|
// Spend thieves' tools on a fork option a failed check closed.
|
||||||
|
return p.zoneCmdUnlock(ctx, rest)
|
||||||
case "status", "info":
|
case "status", "info":
|
||||||
return p.zoneCmdStatus(ctx)
|
return p.zoneCmdStatus(ctx)
|
||||||
case "map", "m":
|
case "map", "m":
|
||||||
@@ -83,6 +86,7 @@ func zoneHelpText() string {
|
|||||||
b.WriteString("`!zone map` — show the room layout\n")
|
b.WriteString("`!zone map` — show the room layout\n")
|
||||||
b.WriteString("`!zone advance` — resolve the current room and move on\n")
|
b.WriteString("`!zone advance` — resolve the current room and move on\n")
|
||||||
b.WriteString("`!zone go <n>` — at a fork, take path #n\n")
|
b.WriteString("`!zone go <n>` — at a fork, take path #n\n")
|
||||||
|
b.WriteString("`!zone unlock <n>` — spend thieves' tools to open a path you couldn't\n")
|
||||||
b.WriteString("`!revisit <n>` — walk back to a room you've already cleared\n")
|
b.WriteString("`!revisit <n>` — walk back to a room you've already cleared\n")
|
||||||
b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
|
b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
|
||||||
b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n")
|
b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n")
|
||||||
@@ -889,6 +893,7 @@ func (p *AdventurePlugin) runHarvestForAdvance(
|
|||||||
if herr != nil {
|
if herr != nil {
|
||||||
return autoHarvestResult{}, ""
|
return autoHarvestResult{}, ""
|
||||||
}
|
}
|
||||||
|
beatHaul(fresh, hr.Summary)
|
||||||
return hr, renderAutoHarvestFooter(hr.Summary)
|
return hr, renderAutoHarvestFooter(hr.Summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,7 +1062,8 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
|||||||
case RoomEntry:
|
case RoomEntry:
|
||||||
return
|
return
|
||||||
case RoomTrap:
|
case RoomTrap:
|
||||||
_, narration := p.resolveTrapRoom(userID, run, zone)
|
damage, narration := p.resolveTrapRoom(userID, run, zone)
|
||||||
|
beatTrap(userID, run, damage)
|
||||||
outcome = narration
|
outcome = narration
|
||||||
return
|
return
|
||||||
case RoomExploration:
|
case RoomExploration:
|
||||||
@@ -1121,6 +1127,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
result := pres.Seats[0]
|
result := pres.Seats[0]
|
||||||
postHP, maxHP := dndHPSnapshot(userID)
|
postHP, maxHP := dndHPSnapshot(userID)
|
||||||
nat20s, nat1s := scanMoodEventsFromEvents(run.RunID, pres.Events)
|
nat20s, nat1s := scanMoodEventsFromEvents(run.RunID, pres.Events)
|
||||||
|
// One beat for the fight, filed here rather than on each of the three
|
||||||
|
// outcome branches below — every one of them passes through this point with
|
||||||
|
// the result already decided, and a single site can't drift out of step with
|
||||||
|
// the others.
|
||||||
|
beatCombat(run, monster.Name, elite, isBoss, result.PlayerWon, result.TimedOut,
|
||||||
|
preHP, postHP, maxHP, nat20s, nat1s)
|
||||||
|
|
||||||
// Compact mode: skip TwinBee banter, skip the multi-beat play-by-play.
|
// Compact mode: skip TwinBee banter, skip the multi-beat play-by-play.
|
||||||
// Render a single outcome line. Still records kills, threat, and drops.
|
// Render a single outcome line. Still records kills, threat, and drops.
|
||||||
@@ -1222,6 +1234,14 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
// tryPatrolEncounter); see retreatThreatBump in
|
// tryPatrolEncounter); see retreatThreatBump in
|
||||||
// dnd_expedition_combat.go.
|
// dnd_expedition_combat.go.
|
||||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||||
|
// Ahead of abandonZoneRun, which would close the story as "abandoned" —
|
||||||
|
// the difference between being killed and running out of clock is the
|
||||||
|
// most interesting fact in the whole log.
|
||||||
|
if result.TimedOut {
|
||||||
|
beatRunEnd(run, "retreated")
|
||||||
|
} else {
|
||||||
|
beatRunEnd(run, "died")
|
||||||
|
}
|
||||||
_ = abandonZoneRun(userID)
|
_ = abandonZoneRun(userID)
|
||||||
// Timeout loss = retreat; the fighters took wounds but nobody actually
|
// Timeout loss = retreat; the fighters took wounds but nobody actually
|
||||||
// died. Don't fire markAdventureDead — that would trigger the 6h respawn
|
// died. Don't fire markAdventureDead — that would trigger the 6h respawn
|
||||||
|
|||||||
@@ -188,6 +188,17 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
|
|||||||
if cerr != nil {
|
if cerr != nil {
|
||||||
return p.SendDM(ctx.Sender, cerr.Error())
|
return p.SendDM(ctx.Sender, cerr.Error())
|
||||||
}
|
}
|
||||||
|
return p.commitForkChoice(ctx, run, chosen, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitForkChoice advances the run onto an already-validated fork option and
|
||||||
|
// emits the arrival teaser. Split out of zoneCmdGo so `!zone unlock` — which
|
||||||
|
// reaches the same place by paying for it — cannot drift from the plain
|
||||||
|
// `!zone go` arrival: same region-transition hook, same camp strike, same
|
||||||
|
// boss/elite prompt. header, if set, is printed above the move.
|
||||||
|
func (p *AdventurePlugin) commitForkChoice(
|
||||||
|
ctx MessageContext, run *DungeonRun, chosen pendingChoice, header string,
|
||||||
|
) error {
|
||||||
nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To)
|
nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To)
|
||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
|
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
|
||||||
@@ -200,6 +211,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
|
|||||||
fireGraphRegionTransition(run.UserID, fromNode, nextNode)
|
fireGraphRegionTransition(run.UserID, fromNode, nextNode)
|
||||||
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
b.WriteString(header)
|
||||||
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
|
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
|
||||||
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
|
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
it := item
|
it := item
|
||||||
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
|
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
|
||||||
rewards = append(rewards, line)
|
rewards = append(rewards, line)
|
||||||
|
beatTreasure(run, it.Name, "cache")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +393,11 @@ func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone Z
|
|||||||
slog.Error("zone: addLoot audit", "user", userID, "item", entry.ItemID, "err", err)
|
slog.Error("zone: addLoot audit", "user", userID, "item", entry.ItemID, "err", err)
|
||||||
}
|
}
|
||||||
granted = append(granted, entry.ItemID)
|
granted = append(granted, entry.ItemID)
|
||||||
|
source := "zone"
|
||||||
|
if bossCleared {
|
||||||
|
source = "boss"
|
||||||
|
}
|
||||||
|
beatTreasure(run, item.Name, source)
|
||||||
}
|
}
|
||||||
return granted
|
return granted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,7 +358,10 @@ func pickLootEntry(zone map[LootTier][]ZoneLootDrop, tier LootTier, rng *rand.Ra
|
|||||||
// while production paths use the package-global generator.
|
// while production paths use the package-global generator.
|
||||||
func rngFloat(rng *rand.Rand) float64 {
|
func rngFloat(rng *rand.Rand) float64 {
|
||||||
if rng == nil {
|
if rng == nil {
|
||||||
return rand.Float64()
|
// Auto-resolve rooms pass nil. simFloat64 routes to the seeded combat
|
||||||
|
// stream when the sim is seeding, else the package global — so prod
|
||||||
|
// (never seeded) stays byte-identical while sim room combat pairs.
|
||||||
|
return simFloat64()
|
||||||
}
|
}
|
||||||
return rng.Float64()
|
return rng.Float64()
|
||||||
}
|
}
|
||||||
@@ -368,7 +371,7 @@ func rngIntN(rng *rand.Rand, n int) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if rng == nil {
|
if rng == nil {
|
||||||
return rand.IntN(n)
|
return simIntN(n)
|
||||||
}
|
}
|
||||||
return rng.IntN(n)
|
return rng.IntN(n)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
|
|||||||
|
|
||||||
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
|
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
|
||||||
func newRunID() string {
|
func newRunID() string {
|
||||||
|
if simSeedOn() {
|
||||||
|
return simHexToken()
|
||||||
|
}
|
||||||
var b [8]byte
|
var b [8]byte
|
||||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||||
// Fall back to math/rand if /dev/urandom is unavailable.
|
// Fall back to math/rand if /dev/urandom is unavailable.
|
||||||
@@ -240,7 +243,11 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rng == nil {
|
if rng == nil {
|
||||||
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
if simSeedOn() {
|
||||||
|
rng = simZoneRNG()
|
||||||
|
} else {
|
||||||
|
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
seq := generateRoomSequence(zone, rng)
|
seq := generateRoomSequence(zone, rng)
|
||||||
|
|
||||||
@@ -288,6 +295,7 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("insert zone run: %w", err)
|
return nil, fmt.Errorf("insert zone run: %w", err)
|
||||||
}
|
}
|
||||||
|
beatRunStart(userID, run, zone)
|
||||||
return run, nil
|
return run, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +582,7 @@ func abandonZoneRun(userID id.UserID) error {
|
|||||||
if r == nil {
|
if r == nil {
|
||||||
return ErrNoActiveRun
|
return ErrNoActiveRun
|
||||||
}
|
}
|
||||||
|
beatRunEnd(r, "abandoned")
|
||||||
_, err = db.Get().Exec(`
|
_, err = db.Get().Exec(`
|
||||||
UPDATE dnd_zone_run
|
UPDATE dnd_zone_run
|
||||||
SET abandoned = 1,
|
SET abandoned = 1,
|
||||||
@@ -592,6 +601,12 @@ func abandonZoneRunByID(runID string) error {
|
|||||||
if runID == "" {
|
if runID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// The generic funnel: it fires for an idle reap, a region retirement, and a
|
||||||
|
// completed run being tidied up alike. beatRunEnd is first-writer-wins, so
|
||||||
|
// this only ever supplies the outcome nothing more specific already did.
|
||||||
|
if r, _ := getZoneRun(runID); r != nil {
|
||||||
|
beatRunEnd(r, "abandoned")
|
||||||
|
}
|
||||||
_, err := db.Get().Exec(`
|
_, err := db.Get().Exec(`
|
||||||
UPDATE dnd_zone_run
|
UPDATE dnd_zone_run
|
||||||
SET abandoned = 1,
|
SET abandoned = 1,
|
||||||
|
|||||||
@@ -186,13 +186,10 @@ func (p *AdventurePlugin) deliverAmbient(e *Expedition, now time.Time) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer := p.applyAmbientEffect(e, ev)
|
footer := p.applyAmbientEffect(e, ev)
|
||||||
body := renderAmbientDM(e, ev, line, footer)
|
|
||||||
|
|
||||||
if uid := id.UserID(e.UserID); uid != "" {
|
// Once-a-day cadence: the effect above still lands on schedule, but the
|
||||||
if err := p.SendDM(uid, body); err != nil {
|
// DM does not. The log entry below is what the next morning's briefing
|
||||||
slog.Warn("expedition: ambient DM", "user", uid, "err", err)
|
// digest reads back, and the site renders it as it happens.
|
||||||
}
|
|
||||||
}
|
|
||||||
summary := fmt.Sprintf("ambient: %s", ev.Kind)
|
summary := fmt.Sprintf("ambient: %s", ev.Kind)
|
||||||
if footer != "" {
|
if footer != "" {
|
||||||
summary += " — " + footer
|
summary += " — " + footer
|
||||||
|
|||||||
@@ -304,15 +304,11 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
|
|||||||
// every other quiet path stays silent until something interactive fires.
|
// every other quiet path stays silent until something interactive fires.
|
||||||
if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok {
|
if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok {
|
||||||
p.fanOutExpeditionDM(e, body, nil)
|
p.fanOutExpeditionDM(e, body, nil)
|
||||||
// N1/A6 — the end-of-day digest is the primary mid-day event anchor.
|
|
||||||
// The anchor is a per-player roll against a per-player daily slot, so
|
|
||||||
// each member rolls their own; a party does not share one event.
|
|
||||||
if campDecision.Night {
|
|
||||||
for _, member := range expeditionAudience(e) {
|
|
||||||
p.maybeFireAnchoredEvent(member, advEventChanceDigest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// N1/A6's digest event anchor has moved to the 06:00 briefing along with
|
||||||
|
// the digest itself — see deliverBriefing. The anchor's whole premise is
|
||||||
|
// firing at a moment the player is demonstrably reading a DM, and the
|
||||||
|
// night camp no longer sends one.
|
||||||
|
|
||||||
// Emergence seam: a run-complete reached by the background ticker is
|
// Emergence seam: a run-complete reached by the background ticker is
|
||||||
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
|
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
|
||||||
@@ -336,8 +332,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
|
|||||||
// Surface rules:
|
// Surface rules:
|
||||||
// - stopFork / stopEnded / stopComplete → render the walk DM. These
|
// - stopFork / stopEnded / stopComplete → render the walk DM. These
|
||||||
// are the interactive / climax beats and stay their own messages.
|
// are the interactive / climax beats and stay their own messages.
|
||||||
// - Night camp pitched → render the EoD digest +
|
// - Night camp pitched → silent. The once-a-day cadence
|
||||||
// camp block. Walk stream is dropped (the digest summarizes the day).
|
// (2026-07-26) retired the EoD digest DM: the camp writes its own
|
||||||
|
// `rest` log entry and the day it summarised is already in the log,
|
||||||
|
// so the 06:00 briefing reads the whole thing back the next morning.
|
||||||
// - Boss-safety camp pitched → short hold notice + camp
|
// - Boss-safety camp pitched → short hold notice + camp
|
||||||
// block; walk stream dropped (compact bail was deliberate).
|
// block; walk stream dropped (compact bail was deliberate).
|
||||||
// - Anything else → silent.
|
// - Anything else → silent.
|
||||||
@@ -354,24 +352,9 @@ func buildAutoRunDM(expID string, r autopilotWalkResult, camp string, dec autoCa
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if dec.Night {
|
if dec.Night {
|
||||||
// EoD digest. The camp pitch already bumped current_day in
|
// Silent: the morning briefing is the one daily message now, and it
|
||||||
// nightRolloverBurn, so the day-that-just-ended is CurrentDay-1.
|
// renders this same day out of the expedition log.
|
||||||
// digest is the day rollup, then the camp block lays out the rest.
|
return "", false
|
||||||
fresh, ferr := getExpedition(expID)
|
|
||||||
prevDay := 0
|
|
||||||
if ferr == nil && fresh != nil {
|
|
||||||
prevDay = fresh.CurrentDay - 1
|
|
||||||
}
|
|
||||||
digest := ""
|
|
||||||
if prevDay > 0 {
|
|
||||||
digest = renderEndOfDayDigest(expID, prevDay)
|
|
||||||
}
|
|
||||||
if digest == "" {
|
|
||||||
// No structured day yet — fall back to a thin header so the
|
|
||||||
// camp block isn't dropped on the player without context.
|
|
||||||
digest = "🌙 *The day winds down.*\n\n"
|
|
||||||
}
|
|
||||||
return digest + camp, true
|
|
||||||
}
|
}
|
||||||
if dec.Reason == "boss-safety hold — resting before re-engaging" {
|
if dec.Reason == "boss-safety hold — resting before re-engaging" {
|
||||||
return "⏸ *Holding before the boss — pitching a rest camp.*\n" + camp, true
|
return "⏸ *Holding before the boss — pitching a rest camp.*\n" + camp, true
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// Once-a-day cadence (2026-07-26).
|
||||||
|
//
|
||||||
|
// Adventure's web feed is now the place to watch a run move minute to minute,
|
||||||
|
// so the bot no longer narrates every beat into Matrix. The three per-player
|
||||||
|
// DM sources that fired on a clock — the 06:00 briefing, the 21:00 recap, and
|
||||||
|
// the 6-hourly ambient event — collapse into a single morning message.
|
||||||
|
//
|
||||||
|
// The rule that keeps this honest: only the *messaging* goes quiet. Every
|
||||||
|
// mechanical effect still fires on exactly the schedule it always did. The
|
||||||
|
// ambient ticker still applies its ±SU nudges, the recap still runs the night
|
||||||
|
// wandering check and its threat bump, the briefing still burns supply and
|
||||||
|
// rolls the day. What changes is that ambient and recap now write their
|
||||||
|
// outcome to the expedition log and stop there; the next morning's briefing
|
||||||
|
// reads that log back and reports it.
|
||||||
|
//
|
||||||
|
// Interrupt-driven DMs are deliberately untouched. A fork needs a human, a
|
||||||
|
// death and a run-completion are terminal, and a mischief hit or a rival
|
||||||
|
// challenge is somebody else acting on you. Those still arrive when they
|
||||||
|
// happen; batching them to the next morning would either strand a decision
|
||||||
|
// behind an 8h auto-pick or report a finished story.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultPeteSiteURL — Pete's public site. Distinct from PETE_INGEST_URL,
|
||||||
|
// which is the Headscale-only ingest endpoint and is not reachable by a
|
||||||
|
// player clicking a link in a DM.
|
||||||
|
defaultPeteSiteURL = "https://news.parodia.dev"
|
||||||
|
|
||||||
|
// digestMaxLines — how many prior-day log lines the morning digest
|
||||||
|
// carries before it defers to the site. The cap is the whole point of
|
||||||
|
// the change: the digest is a teaser for the feed, not a transcript.
|
||||||
|
digestMaxLines = 8
|
||||||
|
|
||||||
|
// digestScanLimit — how far back the digest reads before it gives up on
|
||||||
|
// finding the window's oldest entry. A day of autopilot ticks, ambient
|
||||||
|
// beats and room events runs to a few dozen rows; this is slack, not a
|
||||||
|
// budget.
|
||||||
|
digestScanLimit = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// peteSiteURL returns the public base URL for Pete's site, without a
|
||||||
|
// trailing slash. Overridable so a dev instance can point its links at
|
||||||
|
// a local Pete instead of prod.
|
||||||
|
func peteSiteURL() string {
|
||||||
|
if v := strings.TrimRight(os.Getenv("PETE_PUBLIC_URL"), "/"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultPeteSiteURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// adventureFeedURL is the general Adventure feed: everyone's activity.
|
||||||
|
func adventureFeedURL() string {
|
||||||
|
return peteSiteURL() + "/adventure"
|
||||||
|
}
|
||||||
|
|
||||||
|
// adventureWhoURL is the reader's own adventurer page. Keyed by the same
|
||||||
|
// salted, one-way roster token the board already publishes (see
|
||||||
|
// pete_roster.go), so a DM link and a board link resolve to one page and
|
||||||
|
// neither one leaks a Matrix handle.
|
||||||
|
func adventureWhoURL(uid id.UserID) string {
|
||||||
|
if uid == "" {
|
||||||
|
return adventureFeedURL()
|
||||||
|
}
|
||||||
|
return peteSiteURL() + "/adventure/who/" + eventToken(uid, "roster")
|
||||||
|
}
|
||||||
|
|
||||||
|
// digestSiteFooter appends the reader's own site link. Per-reader rather
|
||||||
|
// than per-expedition: a party shares a briefing body but each member's
|
||||||
|
// link goes to their own sheet.
|
||||||
|
//
|
||||||
|
// Gated on the Pete seam: peteclient.Enabled() is what starts the roster
|
||||||
|
// ticker, and the roster push is what creates the page this link points at.
|
||||||
|
// With the seam off (a dev instance, or a deploy without an ingest token)
|
||||||
|
// every daily DM would otherwise carry a guaranteed 404.
|
||||||
|
func digestSiteFooter(uid id.UserID, body string) string {
|
||||||
|
if !peteclient.Enabled() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return body + "\n\n🔗 _Watch it live: " + adventureWhoURL(uid) + "_"
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefingPerReader is the per-reader decorator for the one daily message:
|
||||||
|
// the reader's own pet event on the front, the reader's own site link on the
|
||||||
|
// back. Both are per-member, so a party's shared briefing body still reaches
|
||||||
|
// each player personalised at both ends.
|
||||||
|
func (p *AdventurePlugin) briefingPerReader(uid id.UserID, body string) string {
|
||||||
|
return digestSiteFooter(uid, p.briefingPetPrefix(uid, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireDigestEventAnchor rolls N1/A6's digest-anchored mid-day event for each
|
||||||
|
// member. It used to hang off the autopilot's night-camp digest DM; that DM is
|
||||||
|
// gone, so it moved here — the briefing is now the message the player is
|
||||||
|
// demonstrably reading, which is the whole premise of an anchored roll.
|
||||||
|
//
|
||||||
|
// Still a per-player roll against a per-player daily slot: a party does not
|
||||||
|
// share one event.
|
||||||
|
func (p *AdventurePlugin) fireDigestEventAnchor(e *Expedition) {
|
||||||
|
for _, member := range expeditionAudience(e) {
|
||||||
|
p.maybeFireAnchoredEvent(member, advEventChanceDigest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendOvernightDigest folds everything that happened since the previous
|
||||||
|
// briefing into a briefing body. A log read failure is non-fatal: the briefing
|
||||||
|
// is the player's only daily message now, so a missing digest block must never
|
||||||
|
// cost them the whole DM.
|
||||||
|
//
|
||||||
|
// The window is a timestamp, not a day number, because the two disagree on
|
||||||
|
// every event-anchored expedition: the autopilot's night camp rolls
|
||||||
|
// current_day at camp time, so by 06:00 the day that just ended is already
|
||||||
|
// current_day-1 — and on a night the autopilot never camped, it isn't. A
|
||||||
|
// since-last-briefing window reports each entry exactly once either way.
|
||||||
|
func appendOvernightDigest(body, expID string, since time.Time) string {
|
||||||
|
entries, err := logEntriesSince(expID, since)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("expedition: digest entries", "expedition", expID, "err", err)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
digest := renderOvernightDigest(entries)
|
||||||
|
if digest == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return body + "\n" + digest
|
||||||
|
}
|
||||||
|
|
||||||
|
// logEntriesSince returns an expedition's log entries stamped at or after
|
||||||
|
// `since`, oldest first.
|
||||||
|
//
|
||||||
|
// The cutoff is applied in Go rather than in the WHERE clause on purpose:
|
||||||
|
// dnd_expedition_log.timestamp is a DATETIME column filled by SQLite's own
|
||||||
|
// CURRENT_TIMESTAMP, and comparing it against a bound parameter goes through
|
||||||
|
// numeric affinity and does not reliably answer the question. Scanning the
|
||||||
|
// column into a time.Time does.
|
||||||
|
func logEntriesSince(expID string, since time.Time) ([]ExpeditionEntry, error) {
|
||||||
|
recent, err := recentExpeditionLog(expID, digestScanLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// CURRENT_TIMESTAMP has one-second resolution while the cutoff we are
|
||||||
|
// handed (a briefing stamp, or the start date on day 1) carries
|
||||||
|
// sub-second precision. Floor it, or an entry written in the same second
|
||||||
|
// as the previous briefing falls out of both windows and is never
|
||||||
|
// reported. Re-reporting inside that one second is the safe direction.
|
||||||
|
since = since.Truncate(time.Second)
|
||||||
|
out := make([]ExpeditionEntry, 0, len(recent))
|
||||||
|
for i := len(recent) - 1; i >= 0; i-- { // recent is newest-first
|
||||||
|
if recent[i].Timestamp.Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, recent[i])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// digestSkipTypes — log entry types the morning digest never echoes.
|
||||||
|
// `briefing` and `recap` are the frame itself, and the free-narration
|
||||||
|
// types are the per-room prose the site renders in full.
|
||||||
|
var digestSkipTypes = map[string]bool{
|
||||||
|
"briefing": true,
|
||||||
|
"recap": true,
|
||||||
|
"narrative": true,
|
||||||
|
"transit": true,
|
||||||
|
"action": true,
|
||||||
|
"journal": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOvernightDigest condenses one expedition-day of log entries into the
|
||||||
|
// "here is what you missed" block that opens the morning briefing. Walks
|
||||||
|
// collapse to a count; everything notable keeps its own summary line, capped
|
||||||
|
// at digestMaxLines with an explicit overflow note so a truncated digest
|
||||||
|
// never reads as a complete one.
|
||||||
|
//
|
||||||
|
// Returns "" when there is nothing worth reporting — a day with only walks
|
||||||
|
// and narration gets no block at all rather than an empty header.
|
||||||
|
func renderOvernightDigest(entries []ExpeditionEntry) string {
|
||||||
|
var rooms int
|
||||||
|
var lines []string
|
||||||
|
for _, en := range entries {
|
||||||
|
if en.Type == "walk" {
|
||||||
|
rooms += walkEntryRooms(en.Summary)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if digestSkipTypes[en.Type] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(en.Summary)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, s)
|
||||||
|
}
|
||||||
|
if rooms == 0 && len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("📜 **Since yesterday**\n")
|
||||||
|
if rooms > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("• walked %s\n", pluralRooms(rooms)))
|
||||||
|
}
|
||||||
|
shown := lines
|
||||||
|
overflow := 0
|
||||||
|
if len(shown) > digestMaxLines {
|
||||||
|
overflow = len(shown) - digestMaxLines
|
||||||
|
shown = shown[:digestMaxLines]
|
||||||
|
}
|
||||||
|
for _, l := range shown {
|
||||||
|
b.WriteString("• " + l + "\n")
|
||||||
|
}
|
||||||
|
if overflow > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("• _...and %d more, on the site._\n", overflow))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkEntryRooms reads the room count back out of an auto-walk log summary
|
||||||
|
// ("auto-walk: 3 room(s)"). One `walk` entry is one background tick, and a
|
||||||
|
// tick covers as many rooms as the autopilot got through — counting entries
|
||||||
|
// would report a 12-room day as a 3-room one. Unparseable summaries count as
|
||||||
|
// a single room rather than vanishing.
|
||||||
|
func walkEntryRooms(summary string) int {
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(summary), "auto-walk: %d room", &n); err == nil && n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluralRooms renders a room count with the right noun.
|
||||||
|
func pluralRooms(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 room"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d rooms", n)
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Coverage for the once-a-day cadence (2026-07-26). The behavioural tests
|
||||||
|
// below are the point of the file: before this change nothing asserted that
|
||||||
|
// the recap and ambient paths sent a DM, so nothing would have caught them
|
||||||
|
// silently continuing to send — or, worse, silently dropping the mechanical
|
||||||
|
// effect along with the message.
|
||||||
|
|
||||||
|
func TestRenderOvernightDigest_CollapsesWalksAndSkipsFrameTypes(t *testing.T) {
|
||||||
|
entries := []ExpeditionEntry{
|
||||||
|
// One `walk` entry is one autopilot tick, and a tick can cover
|
||||||
|
// several rooms — the digest reports rooms, not ticks.
|
||||||
|
{Type: "walk", Summary: "auto-walk: 2 room(s)"},
|
||||||
|
{Type: "walk", Summary: "auto-walk: 3 room(s)"},
|
||||||
|
{Type: "briefing", Summary: "morning briefing — 1.0 SU consumed overnight"},
|
||||||
|
{Type: "narrative", Summary: "the corridor bends left"},
|
||||||
|
{Type: "ambient", Summary: "ambient: pack_rat — Supplies -0.5"},
|
||||||
|
{Type: "night", Summary: "Signs of passage near camp; no encounter."},
|
||||||
|
{Type: "recap", Summary: "evening recap — 6 log entries today"},
|
||||||
|
}
|
||||||
|
got := renderOvernightDigest(entries)
|
||||||
|
|
||||||
|
if !strings.Contains(got, "walked 5 rooms") {
|
||||||
|
t.Errorf("walks not collapsed to a count:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "ambient: pack_rat") {
|
||||||
|
t.Errorf("ambient entry missing from digest:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Signs of passage") {
|
||||||
|
t.Errorf("night check missing from digest:\n%s", got)
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"morning briefing", "evening recap", "corridor bends"} {
|
||||||
|
if strings.Contains(got, unwanted) {
|
||||||
|
t.Errorf("digest echoed frame/narration entry %q:\n%s", unwanted, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOvernightDigest_CapsAndReportsOverflow(t *testing.T) {
|
||||||
|
var entries []ExpeditionEntry
|
||||||
|
for i := 0; i < digestMaxLines+3; i++ {
|
||||||
|
entries = append(entries, ExpeditionEntry{
|
||||||
|
Type: "ambient",
|
||||||
|
Summary: fmt.Sprintf("ambient event %d", i),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
got := renderOvernightDigest(entries)
|
||||||
|
|
||||||
|
if n := strings.Count(got, "ambient event"); n != digestMaxLines {
|
||||||
|
t.Errorf("digest carried %d lines, want the cap of %d:\n%s", n, digestMaxLines, got)
|
||||||
|
}
|
||||||
|
// A truncated digest must say so — otherwise it reads as the whole day.
|
||||||
|
if !strings.Contains(got, "and 3 more") {
|
||||||
|
t.Errorf("overflow not reported:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOvernightDigest_EmptyWhenNothingNotable(t *testing.T) {
|
||||||
|
entries := []ExpeditionEntry{
|
||||||
|
{Type: "briefing", Summary: "morning briefing"},
|
||||||
|
{Type: "narrative", Summary: "dust everywhere"},
|
||||||
|
}
|
||||||
|
if got := renderOvernightDigest(entries); got != "" {
|
||||||
|
t.Errorf("want no digest block for an unremarkable day, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdventureWhoURL_UsesRosterTokenNotHandle(t *testing.T) {
|
||||||
|
// The roster token is salted from a DB-persisted secret.
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@digest-url:example")
|
||||||
|
got := adventureWhoURL(uid)
|
||||||
|
|
||||||
|
want := "/adventure/who/" + eventToken(uid, "roster")
|
||||||
|
if !strings.HasSuffix(got, want) {
|
||||||
|
t.Errorf("who URL = %q, want suffix %q", got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "digest-url") {
|
||||||
|
t.Errorf("who URL leaked the Matrix handle: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdventureWhoURL_FallsBackToFeedWithoutUser(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
if got := adventureWhoURL(""); got != adventureFeedURL() {
|
||||||
|
t.Errorf("empty user should fall back to the feed, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAutoRunDM_NightCampIsSilent — the autopilot's end-of-day digest was
|
||||||
|
// the last recurring second DM of the day. It goes quiet; the camp writes its
|
||||||
|
// own `rest` log entry, so the morning briefing still reports it.
|
||||||
|
func TestBuildAutoRunDM_NightCampIsSilent(t *testing.T) {
|
||||||
|
r := autopilotWalkResult{rooms: 4, reason: stopOK, stream: []string{"…walked…"}}
|
||||||
|
camp := "\n\n⛺ **Autopilot camp** — night"
|
||||||
|
body, ok := buildAutoRunDM("expid", r, camp, autoCampDecision{
|
||||||
|
Kind: CampTypeStandard, Night: true,
|
||||||
|
})
|
||||||
|
if ok || body != "" {
|
||||||
|
t.Errorf("night camp should be silent, got ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two interactive surfaces the night-camp cut must not touch.
|
||||||
|
func TestBuildAutoRunDM_KeepSetStillSurfaces(t *testing.T) {
|
||||||
|
fork := autopilotWalkResult{rooms: 1, reason: stopFork, finalMsg: "pick a path"}
|
||||||
|
if body, ok := buildAutoRunDM("expid", fork, "", autoCampDecision{}); !ok ||
|
||||||
|
!strings.Contains(body, "pick a path") {
|
||||||
|
t.Errorf("fork must still surface, got ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
hold := autopilotWalkResult{rooms: 2, reason: stopBossSafety}
|
||||||
|
holdCamp := "\n\n⛺ **Rest camp**"
|
||||||
|
if body, ok := buildAutoRunDM("expid", hold, holdCamp, autoCampDecision{
|
||||||
|
Reason: "boss-safety hold — resting before re-engaging",
|
||||||
|
}); !ok || !strings.Contains(body, "Holding before the boss") {
|
||||||
|
t.Errorf("boss-safety hold must still surface, got ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverAmbient_SilentButStillLogs — the ambient event still fires and
|
||||||
|
// still records itself; it just stops DMing.
|
||||||
|
func TestDeliverAmbient_SilentButStillLogs(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@digest-ambient:example")
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
sink := installSink(p)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := p.deliverAmbient(exp, exp.StartDate.Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dms := sink.dmsTo(uid); len(dms) != 0 {
|
||||||
|
t.Errorf("ambient sent %d DM(s), want 0:\n%s", len(dms), strings.Join(dms, "\n---\n"))
|
||||||
|
}
|
||||||
|
entries, _ := recentExpeditionLog(exp.ID, 10)
|
||||||
|
found := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Type == "ambient" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("ambient event fired without writing a log entry — the digest would lose it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverRecap_SilentButStillRunsNightCheck — the recap's mechanical half
|
||||||
|
// (wandering check, threat bump) must survive the message going away.
|
||||||
|
func TestDeliverRecap_SilentButStillRunsNightCheck(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@digest-recap:example")
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
sink := installSink(p)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exp.Camp = &CampState{Active: true, Type: CampTypeStandard, EstablishedAt: exp.StartDate}
|
||||||
|
if err := updateCamp(exp.ID, exp.Camp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recapAt := exp.StartDate.Add(12 * time.Hour)
|
||||||
|
if err := p.deliverRecap(exp, recapAt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dms := sink.dmsTo(uid); len(dms) != 0 {
|
||||||
|
t.Errorf("recap sent %d DM(s), want 0:\n%s", len(dms), strings.Join(dms, "\n---\n"))
|
||||||
|
}
|
||||||
|
entries, _ := recentExpeditionLog(exp.ID, 10)
|
||||||
|
sawNight, sawRecap := false, false
|
||||||
|
for _, e := range entries {
|
||||||
|
switch e.Type {
|
||||||
|
case "night":
|
||||||
|
sawNight = true
|
||||||
|
case "recap":
|
||||||
|
sawRecap = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawNight {
|
||||||
|
t.Error("night wandering check did not run — the recap dropped its mechanics, not just its DM")
|
||||||
|
}
|
||||||
|
if !sawRecap {
|
||||||
|
t.Error("recap log entry missing — the site loses its day boundary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverBriefing_IsTheOneDailyMessage — the payoff: one DM, carrying the
|
||||||
|
// prior day's silent activity and the reader's own site link.
|
||||||
|
func TestDeliverBriefing_CarriesDigestAndSiteLink(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@digest-briefing:example")
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
enablePeteSeam(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
sink := installSink(p)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Stand in for the day that just went by silently. The day number is
|
||||||
|
// deliberately not CurrentDay: on an event-anchored run the night camp
|
||||||
|
// rolls current_day when it pitches, so entries either side of the
|
||||||
|
// rollover carry different day numbers. The digest windows on time.
|
||||||
|
if err := appendExpeditionLog(exp.ID, exp.CurrentDay+1, "ambient",
|
||||||
|
"ambient: pack_rat — Supplies -0.5", "Something nibbled the stores."); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.deliverBriefing(exp, exp.StartDate.Add(20*time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dms := sink.dmsTo(uid)
|
||||||
|
if len(dms) != 1 {
|
||||||
|
t.Fatalf("briefing sent %d DM(s), want exactly 1:\n%s", len(dms), strings.Join(dms, "\n---\n"))
|
||||||
|
}
|
||||||
|
body := dms[0]
|
||||||
|
if !strings.Contains(body, "Since yesterday") {
|
||||||
|
t.Errorf("briefing missing the overnight digest block:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "ambient: pack_rat") {
|
||||||
|
t.Errorf("digest did not carry the silent ambient event:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, adventureWhoURL(uid)) {
|
||||||
|
t.Errorf("briefing missing the reader's own site link:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -280,47 +280,77 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is
|
// Sentinels for the two ways walking out can be refused, so the web action queue
|
||||||
// the expedition — so they are pointed at `!extract`, which ends it for all.
|
// can pick a verdict without reading prose. errLeaveIsLeader is deliberately not
|
||||||
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
|
// errAbandonNotLeader inverted-and-reused: they are opposite facts about the same
|
||||||
|
// person and a verdict that conflated them would tell a leader they weren't one.
|
||||||
|
var (
|
||||||
|
errLeaveNothing = errors.New("expedition leave: no expedition to leave")
|
||||||
|
errLeaveIsLeader = errors.New("expedition leave: the leader's row is the expedition")
|
||||||
|
)
|
||||||
|
|
||||||
|
// performExpeditionLeave is `!expedition leave` minus the command framing.
|
||||||
|
// Shared with the web action queue, so a member walking out from a phone unseats
|
||||||
|
// the same way and the leader is told either way.
|
||||||
|
//
|
||||||
|
// Like performExpeditionAbandon this does NOT take the per-user lock — its
|
||||||
|
// Matrix caller holds it across the whole `!expedition` switch, and applyWebLeave
|
||||||
|
// takes it instead. See the comment on performExpeditionAbandon for what getting
|
||||||
|
// that backwards costs.
|
||||||
|
func (p *AdventurePlugin) performExpeditionLeave(uid id.UserID) error {
|
||||||
// Resolve the seat the way the guards that trap them do. seatedExpeditionFor
|
// Resolve the seat the way the guards that trap them do. seatedExpeditionFor
|
||||||
// spans `extracting`, which activeExpeditionFor does not: a leader who
|
// spans `extracting`, which activeExpeditionFor does not: a leader who
|
||||||
// extracts and never resumes would otherwise leave their members seated —
|
// extracts and never resumes would otherwise leave their members seated —
|
||||||
// refused a new adventure by the guard, and told "no active expedition" by
|
// refused a new adventure by the guard, and told "no active expedition" by
|
||||||
// the very command the guard points them at. The exit has to see every state
|
// the very command the guard points them at. The exit has to see every state
|
||||||
// the gate sees. It already excludes leaders, so they fall through below.
|
// the gate sees. It already excludes leaders, so they fall through below.
|
||||||
seated, err := seatedExpeditionFor(ctx.Sender)
|
seated, err := seatedExpeditionFor(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return err
|
||||||
}
|
}
|
||||||
if seated != nil {
|
if seated != nil {
|
||||||
return p.leaveSeatedParty(ctx, seated)
|
return p.leaveSeatedParty(uid, seated)
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
exp, isLeader, err := activeExpeditionFor(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return err
|
||||||
}
|
}
|
||||||
if exp == nil {
|
if exp == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active expedition.")
|
return refuseAdv(errLeaveNothing, "No active expedition.")
|
||||||
}
|
}
|
||||||
if isLeader {
|
if isLeader {
|
||||||
return p.SendDM(ctx.Sender,
|
return refuseAdv(errLeaveIsLeader,
|
||||||
"You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.")
|
"You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.")
|
||||||
}
|
}
|
||||||
return p.leaveSeatedParty(ctx, exp)
|
return p.leaveSeatedParty(uid, exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// leaveSeatedParty unseats a member and tells both ends. Shared by the two ways
|
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is
|
||||||
// a member's seat resolves: the `extracting` limbo and the plain active party.
|
// the expedition — so they are pointed at `!extract`, which ends it for all.
|
||||||
func (p *AdventurePlugin) leaveSeatedParty(ctx MessageContext, exp *Expedition) error {
|
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
|
||||||
if err := leaveParty(exp.ID, ctx.Sender); err != nil {
|
if err := p.performExpeditionLeave(ctx.Sender); err != nil {
|
||||||
|
var refusal advRefusal
|
||||||
|
if errors.As(err, &refusal) {
|
||||||
|
return p.SendDM(ctx.Sender, refusal.Error())
|
||||||
|
}
|
||||||
return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error())
|
||||||
}
|
}
|
||||||
|
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaveSeatedParty unseats a member and tells the leader. Shared by the two ways
|
||||||
|
// a member's seat resolves: the `extracting` limbo and the plain active party.
|
||||||
|
// The *member's* own confirmation is the caller's, because that is the one line
|
||||||
|
// that differs between a DM and a web verdict.
|
||||||
|
func (p *AdventurePlugin) leaveSeatedParty(uid id.UserID, exp *Expedition) error {
|
||||||
|
if err := leaveParty(exp.ID, uid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Supplies stay in the pool. They were spent on the expedition, not lent to
|
// Supplies stay in the pool. They were spent on the expedition, not lent to
|
||||||
// it, and clawing them back would let a member starve the party on their way
|
// it, and clawing them back would let a member starve the party on their way
|
||||||
// out of the door.
|
// out of the door.
|
||||||
_ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf(
|
_ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf(
|
||||||
"**%s** turned back. Their supplies stay with the party.", p.DisplayName(ctx.Sender)))
|
"**%s** turned back. Their supplies stay with the party.", p.DisplayName(uid)))
|
||||||
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.")
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,8 +318,16 @@ func applyClassBaselineStats(c *DnDCharacter) {
|
|||||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10
|
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10
|
||||||
case ClassRogue, ClassRanger:
|
case ClassRogue, ClassRanger:
|
||||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8
|
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8
|
||||||
case ClassMage, ClassSorcerer:
|
case ClassMage:
|
||||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 16, 12, 10
|
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 16, 12, 10
|
||||||
|
case ClassSorcerer:
|
||||||
|
// Sorcerer is a CHA caster (spellcastingMod → CHA), so its 16 goes in
|
||||||
|
// CHA, not INT. Previously it shared the Mage's INT-heavy array, which
|
||||||
|
// left the synthetic sorcerer casting at CHA mod 0 — every CHA-scaled
|
||||||
|
// ability (cantrip, Innate Sorcery, spell DCs) ran crippled and sorc
|
||||||
|
// trailed the field in every sweep. Prod players always placed 16 in
|
||||||
|
// CHA; this makes the sim's sorcerer match a real one.
|
||||||
|
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 10, 12, 16
|
||||||
case ClassCleric, ClassDruid:
|
case ClassCleric, ClassDruid:
|
||||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13
|
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13
|
||||||
case ClassBard, ClassWarlock:
|
case ClassBard, ClassWarlock:
|
||||||
|
|||||||
@@ -856,9 +856,7 @@ func (p *HangmanPlugin) handleSubmit(ctx MessageContext, phrase string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LLM screening
|
// LLM screening
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
// No LLM available — add directly
|
// No LLM available — add directly
|
||||||
if err := p.addPhrase(phrase); err != nil {
|
if err := p.addPhrase(phrase); err != nil {
|
||||||
if err.Error() == "duplicate phrase" {
|
if err.Error() == "duplicate phrase" {
|
||||||
@@ -879,7 +877,7 @@ or
|
|||||||
|
|
||||||
Phrase: %s`, phrase)
|
Phrase: %s`, phrase)
|
||||||
|
|
||||||
result, err := callOllama(ollamaHost, ollamaModel, prompt)
|
result, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("hangman: LLM screening failed", "err", err)
|
slog.Error("hangman: LLM screening failed", "err", err)
|
||||||
// Fail open — add it
|
// Fail open — add it
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"github.com/chehsunliu/poker"
|
"github.com/chehsunliu/poker"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
var holdemTipsClient = &http.Client{Timeout: 60 * time.Second}
|
// holdemTipTimeout preserves the 60s budget the tip rewriter's own http.Client
|
||||||
|
// enforced. Tips are delivered as private messages during a hand, so this sits
|
||||||
|
// between the passive 30s paths and the interactive 120s default.
|
||||||
|
const holdemTipTimeout = 60 * time.Second
|
||||||
|
|
||||||
// loadTipsPref loads a user's tip preference from the database.
|
// loadTipsPref loads a user's tip preference from the database.
|
||||||
func loadTipsPref(userID id.UserID) bool {
|
func loadTipsPref(userID id.UserID) bool {
|
||||||
@@ -491,11 +491,8 @@ func cardSuitIndex(c poker.Card) int {
|
|||||||
func generateTip(ctx holdemTipContext) string {
|
func generateTip(ctx holdemTipContext) string {
|
||||||
base := generateRulesTip(ctx)
|
base := generateRulesTip(ctx)
|
||||||
|
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
rewritten, err := rewriteTipWithLLM(ctx, base)
|
||||||
|
|
||||||
if host != "" && model != "" {
|
|
||||||
rewritten, err := rewriteTipWithLLM(host, model, ctx, base)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("holdem: LLM tip rewrite failed, using rules tip", "err", err)
|
slog.Warn("holdem: LLM tip rewrite failed, using rules tip", "err", err)
|
||||||
} else if rewritten != "" {
|
} else if rewritten != "" {
|
||||||
@@ -626,40 +623,19 @@ func buildTipUserPrompt(ctx holdemTipContext) string {
|
|||||||
// variety. The rules tip is the source of truth — if the rewrite diverges
|
// variety. The rules tip is the source of truth — if the rewrite diverges
|
||||||
// (empty, action vocabulary changed, etc.) we reject it and the caller falls
|
// (empty, action vocabulary changed, etc.) we reject it and the caller falls
|
||||||
// back to the original.
|
// back to the original.
|
||||||
func rewriteTipWithLLM(host, model string, ctx holdemTipContext, base string) (string, error) {
|
func rewriteTipWithLLM(ctx holdemTipContext, base string) (string, error) {
|
||||||
userMsg := buildTipUserPrompt(ctx) + "\nTIP:\n" + base + "\n"
|
userMsg := buildTipUserPrompt(ctx) + "\nTIP:\n" + base + "\n"
|
||||||
req := ollamaChatRequest{
|
|
||||||
Model: model,
|
|
||||||
Messages: []chatMessage{
|
|
||||||
{Role: "system", Content: buildTipSystemPrompt()},
|
|
||||||
{Role: "user", Content: userMsg},
|
|
||||||
},
|
|
||||||
Stream: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(req)
|
raw, err := llmGenerate(context.Background(), llm.Request{
|
||||||
|
System: buildTipSystemPrompt(),
|
||||||
|
Prompt: userMsg,
|
||||||
|
Timeout: holdemTipTimeout,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("marshal: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimRight(host, "/") + "/api/chat"
|
tip := extractTipFromResponse(raw)
|
||||||
resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var ollamaResp ollamaChatResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
|
||||||
return "", fmt.Errorf("decode: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tip := extractTipFromResponse(ollamaResp.Message.Content)
|
|
||||||
if tip == "" {
|
if tip == "" {
|
||||||
return "", fmt.Errorf("empty response")
|
return "", fmt.Errorf("empty response")
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-61
@@ -1,18 +1,15 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -47,9 +44,7 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +79,9 @@ Write the roast now. Do not include any preamble or explanation, just the roast
|
|||||||
botName, string(target), profile,
|
botName, string(target), profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("howami: ollama call", "err", err)
|
slog.Error("howami: llm call", "err", err)
|
||||||
p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't generate the profile. Thanks, Ollama.")
|
p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't generate the profile. Thanks, Ollama.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -181,55 +176,13 @@ func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// callOllama sends a prompt to the Ollama generate endpoint and returns the response.
|
// callLLM sends a prompt to whichever backend is configured and returns the
|
||||||
func callOllama(host, model, prompt string) (string, error) {
|
// completion. Reasoning blocks are stripped by the client. The 8192 context
|
||||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
// hint is what this path has always asked Ollama for; vLLM ignores it and uses
|
||||||
|
// the window fixed at server launch.
|
||||||
payload := map[string]interface{}{
|
func callLLM(prompt string) (string, error) {
|
||||||
"model": model,
|
return llmGenerate(context.Background(), llm.Request{
|
||||||
"prompt": prompt,
|
Prompt: prompt,
|
||||||
"stream": false,
|
NumCtx: 8192,
|
||||||
"think": false,
|
})
|
||||||
"options": map[string]interface{}{
|
|
||||||
"num_ctx": 8192,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal payload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 120 * time.Second}
|
|
||||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("ollama request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
response := result.Response
|
|
||||||
// Strip <think>...</think> blocks (Qwen 3.5 reasoning)
|
|
||||||
if i := strings.Index(response, "<think>"); i != -1 {
|
|
||||||
if j := strings.Index(response, "</think>"); j != -1 {
|
|
||||||
response = response[:i] + response[j+len("</think>"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(response), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The inference backend is process-wide: one endpoint, one model, selected by
|
||||||
|
// env at startup. Plugins share a single client rather than each rebuilding one
|
||||||
|
// per invocation, and read config through llmConfigured/llmGenerate so that
|
||||||
|
// swapping Ollama for vLLM is a config change rather than a code change.
|
||||||
|
var (
|
||||||
|
llmOnce sync.Once
|
||||||
|
llmShared llm.Client
|
||||||
|
llmCfg llm.Config
|
||||||
|
)
|
||||||
|
|
||||||
|
func llmInit() {
|
||||||
|
llmOnce.Do(func() {
|
||||||
|
llmCfg = llm.ConfigFromEnv()
|
||||||
|
llmShared = llm.New(llmCfg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmConfigured reports whether an endpoint and model are set. Plugins call
|
||||||
|
// this to stay dormant instead of erroring on every invocation — the same role
|
||||||
|
// the old `if ollamaHost == "" || ollamaModel == ""` guards played.
|
||||||
|
func llmConfigured() bool {
|
||||||
|
llmInit()
|
||||||
|
return llmCfg.Configured()
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmClient returns the shared backend client.
|
||||||
|
func llmClient() llm.Client {
|
||||||
|
llmInit()
|
||||||
|
return llmShared
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmGenerate is the one-line path for the common case: a raw prompt in,
|
||||||
|
// visible completion out, reasoning blocks already stripped.
|
||||||
|
func llmGenerate(ctx context.Context, req llm.Request) (string, error) {
|
||||||
|
return llmClient().Generate(ctx, req)
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -18,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
"gogobee/internal/dreamclient"
|
"gogobee/internal/dreamclient"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -54,29 +53,31 @@ type queueItem struct {
|
|||||||
FormattedBody string
|
FormattedBody string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLMPassivePlugin classifies messages using Ollama and reacts accordingly.
|
// classifyTimeout is the per-message budget for passive classification. It
|
||||||
|
// preserves the 30s cap the plugin's own http.Client used to enforce, which is
|
||||||
|
// deliberately tighter than the interactive default: classification runs on
|
||||||
|
// sampled traffic and must never back up the queue.
|
||||||
|
const classifyTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// LLMPassivePlugin classifies messages using the configured LLM backend and
|
||||||
|
// reacts accordingly.
|
||||||
type LLMPassivePlugin struct {
|
type LLMPassivePlugin struct {
|
||||||
Base
|
Base
|
||||||
xp *XPPlugin
|
xp *XPPlugin
|
||||||
dict *dreamclient.Client
|
dict *dreamclient.Client
|
||||||
ollamaHost string
|
sampleRate float64
|
||||||
ollamaModel string
|
enabled bool
|
||||||
sampleRate float64
|
|
||||||
enabled bool
|
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
queue []queueItem
|
queue []queueItem
|
||||||
backoff time.Duration
|
backoff time.Duration
|
||||||
|
|
||||||
httpClient *http.Client
|
stopCh chan struct{}
|
||||||
stopCh chan struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
|
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
|
||||||
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin {
|
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
enabled := llmConfigured()
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
enabled := host != "" && model != ""
|
|
||||||
|
|
||||||
sampleRate := 0.15
|
sampleRate := 0.15
|
||||||
if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" {
|
if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" {
|
||||||
@@ -86,16 +87,13 @@ func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient
|
|||||||
}
|
}
|
||||||
|
|
||||||
p := &LLMPassivePlugin{
|
p := &LLMPassivePlugin{
|
||||||
Base: NewBase(client),
|
Base: NewBase(client),
|
||||||
xp: xp,
|
xp: xp,
|
||||||
dict: dict,
|
dict: dict,
|
||||||
ollamaHost: host,
|
sampleRate: sampleRate,
|
||||||
ollamaModel: model,
|
enabled: enabled,
|
||||||
sampleRate: sampleRate,
|
backoff: 5 * time.Second,
|
||||||
enabled: enabled,
|
stopCh: make(chan struct{}),
|
||||||
backoff: 5 * time.Second,
|
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
@@ -116,11 +114,11 @@ func (p *LLMPassivePlugin) Commands() []CommandDef {
|
|||||||
|
|
||||||
func (p *LLMPassivePlugin) Init() error {
|
func (p *LLMPassivePlugin) Init() error {
|
||||||
if p.enabled {
|
if p.enabled {
|
||||||
slog.Info("llm_passive: enabled", "host", p.ollamaHost, "model", p.ollamaModel, "sample_rate", p.sampleRate)
|
slog.Info("llm_passive: enabled", "backend", llmClient().Backend(),
|
||||||
|
"model", llmClient().Model(), "sample_rate", p.sampleRate)
|
||||||
go p.processQueue()
|
go p.processQueue()
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("llm_passive: disabled (OLLAMA_HOST or OLLAMA_MODEL not set)",
|
slog.Warn("llm_passive: disabled (LLM endpoint or model not set)")
|
||||||
"host", p.ollamaHost, "model", p.ollamaModel)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -329,9 +327,9 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
var todayWOTD string
|
var todayWOTD string
|
||||||
db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD)
|
db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD)
|
||||||
|
|
||||||
result, err := p.callOllama(item.Body+mentionHint, todayWOTD)
|
result, err := p.classify(item.Body+mentionHint, todayWOTD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ollama call: %w", err)
|
return fmt.Errorf("llm call: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve any display names in LLM targets back to MXIDs
|
// Resolve any display names in LLM targets back to MXIDs
|
||||||
@@ -464,21 +462,9 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ollamaRequest is the request body for the Ollama API.
|
// classify sends a classification prompt to the configured backend and parses
|
||||||
type ollamaRequest struct {
|
// the JSON result.
|
||||||
Model string `json:"model"`
|
func (p *LLMPassivePlugin) classify(messageText, wotd string) (*classificationResult, error) {
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
Stream bool `json:"stream"`
|
|
||||||
Think bool `json:"think"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ollamaResponse is the response from the Ollama API.
|
|
||||||
type ollamaResponse struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// callOllama sends a classification prompt to Ollama and parses the JSON result.
|
|
||||||
func (p *LLMPassivePlugin) callOllama(messageText, wotd string) (*classificationResult, error) {
|
|
||||||
wotdInstruction := `"wotd_used": false`
|
wotdInstruction := `"wotd_used": false`
|
||||||
if wotd != "" {
|
if wotd != "" {
|
||||||
wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd)
|
wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd)
|
||||||
@@ -500,36 +486,18 @@ JSON schema:
|
|||||||
|
|
||||||
Message: %s`, wotdInstruction, messageText)
|
Message: %s`, wotdInstruction, messageText)
|
||||||
|
|
||||||
reqBody := ollamaRequest{
|
slog.Debug("llm_passive: calling backend", "backend", llmClient().Backend(), "model", llmClient().Model())
|
||||||
Model: p.ollamaModel,
|
// Classification rides the passive path on every sampled message, so it keeps
|
||||||
Prompt: prompt,
|
// the tighter budget it always had rather than the interactive default.
|
||||||
Stream: false,
|
raw, err := llmGenerate(context.Background(), llm.Request{
|
||||||
}
|
Prompt: prompt,
|
||||||
|
Timeout: classifyTimeout,
|
||||||
body, err := json.Marshal(reqBody)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal request: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimRight(p.ollamaHost, "/") + "/api/generate"
|
result, err := parseClassification(raw)
|
||||||
slog.Debug("llm_passive: calling ollama", "url", url, "model", p.ollamaModel)
|
|
||||||
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ollama request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var ollamaResp ollamaResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode ollama response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := parseClassification(ollamaResp.Response)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse classification: %w", err)
|
return nil, fmt.Errorf("parse classification: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-61
@@ -1,10 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -311,9 +311,7 @@ Do not use em dashes. Do not use exclamation marks. Do not offer financial advic
|
|||||||
If markets are closed or data is stale, note it briefly and move on.`
|
If markets are closed or data is stale, note it briefly and move on.`
|
||||||
|
|
||||||
func (p *MarketPlugin) generateDailySummary(date string) string {
|
func (p *MarketPlugin) generateDailySummary(date string) string {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +347,7 @@ func (p *MarketPlugin) generateDailySummary(date string) string {
|
|||||||
}
|
}
|
||||||
prompt.WriteString("\nWrite a 2-3 sentence summary.")
|
prompt.WriteString("\nWrite a 2-3 sentence summary.")
|
||||||
|
|
||||||
result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String())
|
result, err := p.chatLLM(marketSystemPrompt, prompt.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("market: ollama summary failed", "err", err)
|
slog.Error("market: ollama summary failed", "err", err)
|
||||||
return ""
|
return ""
|
||||||
@@ -358,9 +356,7 @@ func (p *MarketPlugin) generateDailySummary(date string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnapshot, dateRange string) string {
|
func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnapshot, dateRange string) string {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +391,7 @@ func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnap
|
|||||||
}
|
}
|
||||||
prompt.WriteString("\nDescribe the trend in 2-3 sentences. Note any significant moves or divergences between indices.\nBe sardonic but accurate. Reference the VIX trajectory when relevant.")
|
prompt.WriteString("\nDescribe the trend in 2-3 sentences. Note any significant moves or divergences between indices.\nBe sardonic but accurate. Reference the VIX trajectory when relevant.")
|
||||||
|
|
||||||
result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String())
|
result, err := p.chatLLM(marketSystemPrompt, prompt.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("market: ollama report summary failed", "err", err)
|
slog.Warn("market: ollama report summary failed", "err", err)
|
||||||
return ""
|
return ""
|
||||||
@@ -403,49 +399,14 @@ func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnap
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// callOllamaChat calls the Ollama /api/chat endpoint with a system and user message.
|
// chatLLM sends a system+user pair to the configured backend. Both backends
|
||||||
// Uses the types already defined in holdem_tips.go (same package).
|
// map this onto their own chat shape, so the market summaries read the same
|
||||||
func (p *MarketPlugin) callOllamaChat(host, model, systemPrompt, userPrompt string) (string, error) {
|
// whichever one is serving.
|
||||||
req := ollamaChatRequest{
|
func (p *MarketPlugin) chatLLM(systemPrompt, userPrompt string) (string, error) {
|
||||||
Model: model,
|
return llmGenerate(context.Background(), llm.Request{
|
||||||
Messages: []chatMessage{
|
System: systemPrompt,
|
||||||
{Role: "system", Content: systemPrompt},
|
Prompt: userPrompt,
|
||||||
{Role: "user", Content: userPrompt},
|
})
|
||||||
},
|
|
||||||
Stream: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := strings.TrimRight(host, "/") + "/api/chat"
|
|
||||||
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var ollamaResp ollamaChatResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
|
||||||
return "", fmt.Errorf("decode: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := ollamaResp.Message.Content
|
|
||||||
// Strip <think>...</think> blocks (reasoning models)
|
|
||||||
if i := strings.Index(text, "<think>"); i != -1 {
|
|
||||||
if j := strings.Index(text, "</think>"); j != -1 {
|
|
||||||
text = text[:i] + text[j+len("</think>"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(text), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DB Helpers ───────────────────────────────────────────────────────────────
|
// ── DB Helpers ───────────────────────────────────────────────────────────────
|
||||||
@@ -916,12 +877,10 @@ func (p *MarketPlugin) handleVixReport(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var summary string
|
var summary string
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host != "" && model != "" {
|
|
||||||
prompt := fmt.Sprintf("VIX (fear index) data over %d days (%s to %s):\n%s\n\nDescribe the fear/greed trajectory in 2-3 sentences. Be sardonic but accurate.",
|
prompt := fmt.Sprintf("VIX (fear index) data over %d days (%s to %s):\n%s\n\nDescribe the fear/greed trajectory in 2-3 sentences. Be sardonic but accurate.",
|
||||||
len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
||||||
summary, _ = p.callOllamaChat(host, model, marketSystemPrompt, prompt)
|
summary, _ = p.chatLLM(marketSystemPrompt, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -1020,12 +979,10 @@ func (p *MarketPlugin) handleCompare(ctx MessageContext, args []string) error {
|
|||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
prices = append(prices, fmt.Sprintf("%.2f", e.Price))
|
prices = append(prices, fmt.Sprintf("%.2f", e.Price))
|
||||||
}
|
}
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host != "" && model != "" {
|
|
||||||
prompt := fmt.Sprintf("%s over %d days (%s to %s):\n%s\n\nDescribe the trend in 2-3 sentences. Be sardonic but accurate.",
|
prompt := fmt.Sprintf("%s over %d days (%s to %s):\n%s\n\nDescribe the trend in 2-3 sentences. Be sardonic but accurate.",
|
||||||
idx.DisplayName, len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
idx.DisplayName, len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
||||||
if summary, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt); err == nil && summary != "" {
|
if summary, err := p.chatLLM(marketSystemPrompt, prompt); err == nil && summary != "" {
|
||||||
sb.WriteString(summary)
|
sb.WriteString(summary)
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,8 +279,12 @@ func claimRealmFirst(kind, target string) bool {
|
|||||||
// *started* — every dispatch was an outcome — which is why the two live boredom
|
// *started* — every dispatch was an outcome — which is why the two live boredom
|
||||||
// runs produced no news at all.
|
// runs produced no news at all.
|
||||||
//
|
//
|
||||||
// The event_type must be one Pete already knows: an unknown type is a 400, which
|
// The event_type no longer has to be one Pete already knows. It used to: an
|
||||||
// retries and then parks the bulletin forever. Deploy Pete first.
|
// unknown type was a 400, which retried to the cap and then parked the bulletin
|
||||||
|
// forever, so shipping a new event type meant remembering to deploy Pete first.
|
||||||
|
// Pete now publishes an untemplated type on a neutral fallback and counts it for
|
||||||
|
// the operator, so the ordering rule is a property of the system rather than
|
||||||
|
// something a human has to hold.
|
||||||
func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) {
|
func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) {
|
||||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
return
|
return
|
||||||
@@ -339,6 +343,7 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
|
|||||||
Boss: zone.Boss.Name,
|
Boss: zone.Boss.Name,
|
||||||
Level: lvl,
|
Level: lvl,
|
||||||
Outcome: "cleared",
|
Outcome: "cleared",
|
||||||
|
RunID: latestRunIDForNews(userID),
|
||||||
OccurredAt: ts,
|
OccurredAt: ts,
|
||||||
}, userID, "")
|
}, userID, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func TestDetailSnapshotKeyedByLocalpart(t *testing.T) {
|
|||||||
t.Fatalf("saveAdvCharacter: %v", err)
|
t.Fatalf("saveAdvCharacter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ func TestDetailSnapshotIgnoresOptOut(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ...but the private detail set keeps them both.
|
// ...but the private detail set keeps them both.
|
||||||
detail, err := buildDetailSnapshot(time.Now().UTC())
|
detail, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
|
|||||||
t.Fatalf("kill player: %v", err)
|
t.Fatalf("kill player: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
}
|
}
|
||||||
@@ -226,3 +226,55 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
|
|||||||
t.Fatalf("detail set = %+v, want just the living player", snap.Players)
|
t.Fatalf("detail set = %+v, want just the living player", snap.Players)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPetXPRidesTheCurveNotACopyOfIt pins the unit and the cap, which are the two
|
||||||
|
// ways this can go quietly wrong on the web. XP is stored in centi-XP — a pet
|
||||||
|
// earns 1.5 points an action and the ledger is an int — so a page that read XP
|
||||||
|
// against a whole-number threshold would draw a bar 100x too full. And a capped
|
||||||
|
// pet must report 0 needed rather than the next band's number, or its bar sits
|
||||||
|
// forever short of a level it can never gain.
|
||||||
|
func TestPetXPRidesTheCurveNotACopyOfIt(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@quack:test")
|
||||||
|
seedDetailPlayer(t, uid, "Quack", 7)
|
||||||
|
|
||||||
|
adv, err := loadAdvCharacter(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
adv.PetType = "cat"
|
||||||
|
adv.PetName = "Mittens"
|
||||||
|
adv.PetLevel = 4
|
||||||
|
adv.PetXP = 750 // 7.5 of the 20 points level 4 wants
|
||||||
|
adv.Pet2Type = "dog"
|
||||||
|
adv.Pet2Name = "Rex"
|
||||||
|
adv.Pet2Level = petMaxLevel
|
||||||
|
adv.Pet2XP = 0
|
||||||
|
if err := saveAdvCharacter(adv); err != nil {
|
||||||
|
t.Fatalf("saveAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
pets := snap.Players[0].Pets
|
||||||
|
if len(pets) != 2 {
|
||||||
|
t.Fatalf("pets = %+v, want both slots", pets)
|
||||||
|
}
|
||||||
|
byName := map[string]int{}
|
||||||
|
for i, p := range pets {
|
||||||
|
byName[p.Name] = i
|
||||||
|
}
|
||||||
|
mittens := pets[byName["Mittens"]]
|
||||||
|
if mittens.XP != 750 {
|
||||||
|
t.Errorf("Mittens XP = %d, want the stored centi-XP 750", mittens.XP)
|
||||||
|
}
|
||||||
|
if want := petXPToNextLevel(4) * 100; mittens.XPNeeded != want {
|
||||||
|
t.Errorf("Mittens XPNeeded = %d, want %d — the curve is in centi-XP too",
|
||||||
|
mittens.XPNeeded, want)
|
||||||
|
}
|
||||||
|
if rex := pets[byName["Rex"]]; rex.XPNeeded != 0 {
|
||||||
|
t.Errorf("a capped pet needs %d more XP; want 0, meaning nothing left to earn", rex.XPNeeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/llm"
|
||||||
"gogobee/internal/peteclient"
|
"gogobee/internal/peteclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,22 +37,18 @@ const (
|
|||||||
maxDispatchLede = 800
|
maxDispatchLede = 800
|
||||||
)
|
)
|
||||||
|
|
||||||
var dispatchHTTP = &http.Client{Timeout: dispatchLLMTimeout}
|
|
||||||
|
|
||||||
// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
|
// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
|
||||||
// two empty strings if the model is unconfigured, errors, times out, or produces
|
// two empty strings if the model is unconfigured, errors, times out, or produces
|
||||||
// anything malformed. The fact must already have its FINAL Actors set (post
|
// anything malformed. The fact must already have its FINAL Actors set (post
|
||||||
// opt-out anonymisation) — that list is the only set of names the prose may use,
|
// opt-out anonymisation) — that list is the only set of names the prose may use,
|
||||||
// and it is what Pete's guard checks the output against.
|
// and it is what Pete's guard checks the output against.
|
||||||
func authorDispatch(f peteclient.Fact) (headline, lede string) {
|
func authorDispatch(f peteclient.Fact) (headline, lede string) {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt := buildDispatchPrompt(f)
|
prompt := buildDispatchPrompt(f)
|
||||||
raw, err := callOllamaDispatch(host, model, prompt)
|
raw, err := callLLMDispatch(dispatchLLMTimeout, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
|
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
|
||||||
return "", ""
|
return "", ""
|
||||||
@@ -128,43 +122,17 @@ The event:
|
|||||||
%s`, names, facts.String())
|
%s`, names, facts.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// callOllamaDispatch posts a single non-streaming generation and returns the raw
|
// callLLMDispatch posts a single non-streaming generation and returns the raw
|
||||||
// completion (think-tags stripped). Its own bounded client, separate from the
|
// completion. The timeout is a parameter because the two callers have genuinely
|
||||||
// interactive callOllama, because the game loop cannot wait 120s on the news.
|
// different patience: a dispatch is authored on a game chokepoint and must not
|
||||||
func callOllamaDispatch(host, model, prompt string) (string, error) {
|
// stall it, while a run summary rides a background ticker and can afford to wait
|
||||||
payload := map[string]interface{}{
|
// for a bigger model. See runSummaryTimeout.
|
||||||
"model": model,
|
func callLLMDispatch(timeout time.Duration, prompt string) (string, error) {
|
||||||
"prompt": prompt,
|
return llmGenerate(context.Background(), llm.Request{
|
||||||
"stream": false,
|
Prompt: prompt,
|
||||||
"think": false,
|
NumCtx: 4096,
|
||||||
"options": map[string]interface{}{
|
Timeout: timeout,
|
||||||
"num_ctx": 4096,
|
})
|
||||||
},
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal payload: %w", err)
|
|
||||||
}
|
|
||||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
|
||||||
resp, err := dispatchHTTP.Post(apiURL, "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("ollama request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
var result struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
|
||||||
return result.Response, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
|
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ func (p *AdventurePlugin) fulfilEquipOrder(ctx context.Context, order peteclient
|
|||||||
// a human note for Pete, or retry=true for a transient fault that should leave the
|
// a human note for Pete, or retry=true for a transient fault that should leave the
|
||||||
// order pending. It records nothing and pushes nothing — the caller does both.
|
// order pending. It records nothing and pushes nothing — the caller does both.
|
||||||
func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) {
|
func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) {
|
||||||
|
// Serialize against the owner's own Matrix-side mutations (!give, !equip, !sell,
|
||||||
|
// arena, …), all of which hold this same per-user lock. Without it the poll
|
||||||
|
// goroutine's equip could interleave with a concurrent !give of the very item it
|
||||||
|
// resolved — the duplication the DM equip confirm takes this lock to prevent.
|
||||||
|
userMu := p.advUserLock(owner)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
switch order.Action {
|
switch order.Action {
|
||||||
case "equip":
|
case "equip":
|
||||||
inv, err := loadAdvInventory(owner)
|
inv, err := loadAdvInventory(owner)
|
||||||
@@ -142,6 +150,21 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
|
|||||||
// a different item — this is a clean miss, not a wrong hit.
|
// a different item — this is a clean miss, not a wrong hit.
|
||||||
return "rejected_not_owned", "That item wasn't in your pack anymore.", false
|
return "rejected_not_owned", "That item wasn't in your pack anymore.", false
|
||||||
}
|
}
|
||||||
|
// Masterwork/arena pieces equip into a standard slot; everything else takes
|
||||||
|
// the magic-item path. Type alone routes it — the id resolved the same row.
|
||||||
|
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
|
||||||
|
out, err := applyMasterworkEquip(owner, it)
|
||||||
|
if errors.Is(err, errItemNotEquippable) {
|
||||||
|
return "rejected_not_equippable", "That item can't be worn.", false
|
||||||
|
}
|
||||||
|
if errors.Is(err, errEquipDowngrade) {
|
||||||
|
return "rejected_downgrade", "That isn't an upgrade over what you're wearing.", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", "", true // transient DB fault
|
||||||
|
}
|
||||||
|
return "applied", masterworkEquipDetail(out), false
|
||||||
|
}
|
||||||
out, err := applyMagicEquip(owner, it)
|
out, err := applyMagicEquip(owner, it)
|
||||||
if errors.Is(err, errItemNotEquippable) {
|
if errors.Is(err, errItemNotEquippable) {
|
||||||
return "rejected_not_equippable", "That item can't be worn.", false
|
return "rejected_not_equippable", "That item can't be worn.", false
|
||||||
@@ -152,6 +175,19 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
|
|||||||
return "applied", equipAppliedDetail(out), false
|
return "applied", equipAppliedDetail(out), false
|
||||||
|
|
||||||
case "unequip":
|
case "unequip":
|
||||||
|
// A standard slot (weapon/armor/…) takes a masterwork/arena piece off; a DnD
|
||||||
|
// slot takes a magic item off. The vocabularies are disjoint, so the slot
|
||||||
|
// string alone tells the two apart.
|
||||||
|
if isEquipmentSlot(order.Slot) {
|
||||||
|
out, err := applyMasterworkUnequip(owner, EquipmentSlot(order.Slot))
|
||||||
|
if errors.Is(err, errSlotEmpty) {
|
||||||
|
return "rejected_not_worn", "There was nothing to take off there.", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf("Took %s off, back in your pack.", out.Name), false
|
||||||
|
}
|
||||||
out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
|
out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
|
||||||
if errors.Is(err, errSlotEmpty) {
|
if errors.Is(err, errSlotEmpty) {
|
||||||
return "rejected_not_worn", "That slot was already empty.", false
|
return "rejected_not_worn", "That slot was already empty.", false
|
||||||
@@ -165,6 +201,12 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
|
|||||||
}
|
}
|
||||||
return "applied", note, false
|
return "applied", note, false
|
||||||
|
|
||||||
|
case "upgrade":
|
||||||
|
return p.purchaseEquipmentTier(owner, EquipmentSlot(order.Slot), order.Tier, order.GUID)
|
||||||
|
|
||||||
|
case "repair":
|
||||||
|
return p.repairSlot(owner, EquipmentSlot(order.Slot), order.GUID)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Pete validates the action before it ever queues an order, so this is a
|
// Pete validates the action before it ever queues an order, so this is a
|
||||||
// contract breach, not a user mistake. Reject permanently rather than spin.
|
// contract breach, not a user mistake. Reject permanently rather than spin.
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// Ask 7: full equipment management from the web.
|
||||||
|
//
|
||||||
|
// The magic-item equip path (magic_items_gameplay.go) only ever touched the DnD
|
||||||
|
// slots — off_hand, rings, and the like — which are almost always empty. Almost
|
||||||
|
// everything a player actually wears lives in the OTHER two systems: the 5
|
||||||
|
// standard EquipmentSlots (weapon/armor/helmet/boots/tool), whose power is the
|
||||||
|
// slot's integer Tier, and the masterwork/arena pieces that get equipped INTO a
|
||||||
|
// standard slot. This file is the game-side of managing all of that from Pete:
|
||||||
|
//
|
||||||
|
// - applyMasterworkEquip / applyMasterworkUnequip — move a masterwork/arena
|
||||||
|
// piece between the pack and a standard slot (no money).
|
||||||
|
// - purchaseEquipmentTier — buy the next standard tier with euros (confirm-gated
|
||||||
|
// on the web), the headless twin of the shop's advBuyEquipment.
|
||||||
|
// - repairSlot — mend a slot's condition with euros, the headless twin of the
|
||||||
|
// blacksmith's executeRepair.
|
||||||
|
// - buildEquipSlotViews — the owner-only snapshot the web panel renders from.
|
||||||
|
//
|
||||||
|
// The two euro-spending mutators run on the retrying poll wire, so every money
|
||||||
|
// move goes through the idempotent euro variants keyed on the order guid: a
|
||||||
|
// re-offered order that already debited skips the charge and just re-runs the
|
||||||
|
// idempotent slot write. That is why neither refunds on a later DB fault — a
|
||||||
|
// refund keyed on a fresh id, followed by a guid-guarded retry that no longer
|
||||||
|
// re-debits, would hand the player both the gear and their money back. The casino
|
||||||
|
// escrow (pete_games.go) settles the same way: idempotent move, then retry.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errEquipDowngrade is a permanent refusal: the incoming piece is no better than
|
||||||
|
// what is worn. Downgrades are blocked by user decision (equip and upgrade both).
|
||||||
|
var errEquipDowngrade = errors.New("equip: would be a downgrade")
|
||||||
|
|
||||||
|
// mwEquipOutcome is what a masterwork/arena equip did, for the verdict note.
|
||||||
|
type mwEquipOutcome struct {
|
||||||
|
Name string
|
||||||
|
Slot EquipmentSlot
|
||||||
|
Tier int
|
||||||
|
Arena bool
|
||||||
|
SwappedBack string // the special occupant evicted back to the pack, or ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMasterworkEquip wears one masterwork/arena backpack piece into its standard
|
||||||
|
// slot. Ordering is anti-duplication AND safe under the equip poll's 30s retry:
|
||||||
|
// remove the incoming row FIRST (restoring it on a save fault), then write the
|
||||||
|
// slot, and only THEN evict any displaced special occupant back to the pack. The
|
||||||
|
// eviction comes last, once the slot no longer references the occupant, so it can
|
||||||
|
// never mint a duplicate; and it is best-effort — a failure there is logged, not
|
||||||
|
// aborted on, the same tolerance the DM confirm handler (adventure_masterwork.go)
|
||||||
|
// lives with. Aborting after the slot write would strand a completed equip for a
|
||||||
|
// retry that re-evicts the occupant on every tick.
|
||||||
|
func applyMasterworkEquip(uid id.UserID, it AdvItem) (mwEquipOutcome, error) {
|
||||||
|
if it.Slot == "" || (it.Type != "MasterworkGear" && it.Type != "ArenaGear") {
|
||||||
|
return mwEquipOutcome{}, errItemNotEquippable
|
||||||
|
}
|
||||||
|
equip, err := loadAdvEquipment(uid)
|
||||||
|
if err != nil {
|
||||||
|
return mwEquipOutcome{}, err
|
||||||
|
}
|
||||||
|
slot := it.Slot
|
||||||
|
cur := equip[slot]
|
||||||
|
|
||||||
|
// Downgrade block: the incoming effective tier must beat the current occupant.
|
||||||
|
incoming := &AdvEquipment{Tier: it.Tier}
|
||||||
|
if it.Type == "ArenaGear" {
|
||||||
|
incoming.ArenaTier = it.Tier
|
||||||
|
} else {
|
||||||
|
incoming.Masterwork = true
|
||||||
|
}
|
||||||
|
if advEffectiveTier(incoming) <= advEffectiveTier(cur) {
|
||||||
|
return mwEquipOutcome{}, errEquipDowngrade
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the special occupant to evict, if any, BEFORE the slot write below
|
||||||
|
// mutates cur in place. A plain shop-tier occupant is not an item — it is just
|
||||||
|
// the slot's tier — so it is overwritten, not evicted, the same as the DM confirm
|
||||||
|
// handler and the shop. The actual re-pack happens after the slot write (below),
|
||||||
|
// so it can never duplicate the piece.
|
||||||
|
var evicted *AdvItem
|
||||||
|
if cur != nil && (cur.Masterwork || cur.ArenaTier > 0) {
|
||||||
|
old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource}
|
||||||
|
if cur.ArenaTier > 0 {
|
||||||
|
old.Type = "ArenaGear"
|
||||||
|
}
|
||||||
|
evicted = &old
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructive op first: pull the incoming row before writing the slot, so a save
|
||||||
|
// fault can't leave it both worn and in the pack. Restore it on failure.
|
||||||
|
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||||
|
return mwEquipOutcome{}, err
|
||||||
|
}
|
||||||
|
eq := cur
|
||||||
|
if eq == nil {
|
||||||
|
eq = &AdvEquipment{Slot: slot}
|
||||||
|
}
|
||||||
|
eq.Tier = it.Tier
|
||||||
|
eq.Condition = 100
|
||||||
|
eq.Name = it.Name
|
||||||
|
eq.ActionsUsed = 0
|
||||||
|
if it.Type == "ArenaGear" {
|
||||||
|
eq.Masterwork = false
|
||||||
|
eq.SkillSource = ""
|
||||||
|
eq.ArenaTier = it.Tier
|
||||||
|
eq.ArenaSet = ""
|
||||||
|
if gs := arenaGearByName(it.Name); gs != nil {
|
||||||
|
eq.ArenaSet = gs.SetKey
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eq.ArenaTier = 0
|
||||||
|
eq.ArenaSet = ""
|
||||||
|
eq.Masterwork = true
|
||||||
|
eq.SkillSource = it.SkillSource
|
||||||
|
}
|
||||||
|
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||||
|
restored := AdvItem{Name: it.Name, Type: it.Type, Tier: it.Tier, Value: it.Value, Slot: it.Slot, SkillSource: it.SkillSource}
|
||||||
|
if rbErr := addAdvInventoryItem(uid, restored); rbErr != nil {
|
||||||
|
slog.Error("equip: masterwork save failed AND inventory rollback failed",
|
||||||
|
"user", uid, "item", it.Name, "save_err", err, "rollback_err", rbErr)
|
||||||
|
}
|
||||||
|
return mwEquipOutcome{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slot now holds the incoming piece, so the former occupant is referenced
|
||||||
|
// nowhere — re-packing it now cannot duplicate it. Best-effort: a failure is a
|
||||||
|
// bounded, non-compounding loss we log rather than abort on, since the equip has
|
||||||
|
// already succeeded and aborting would re-run (and re-evict) on the next poll.
|
||||||
|
var swappedBack string
|
||||||
|
if evicted != nil {
|
||||||
|
if err := addAdvInventoryItem(uid, *evicted); err != nil {
|
||||||
|
slog.Error("equip: masterwork equipped but evicted piece failed to return to pack",
|
||||||
|
"user", uid, "evicted", evicted.Name, "err", err)
|
||||||
|
} else {
|
||||||
|
swappedBack = evicted.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mwEquipOutcome{Name: it.Name, Slot: slot, Tier: it.Tier, Arena: it.Type == "ArenaGear", SwappedBack: swappedBack}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mwUnequipOutcome is what a masterwork/arena take-off did.
|
||||||
|
type mwUnequipOutcome struct {
|
||||||
|
Name string
|
||||||
|
Slot EquipmentSlot
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMasterworkUnequip takes a worn masterwork/arena piece off a standard slot,
|
||||||
|
// returns it to the pack, and resets the slot to its tier-0 default. A plain
|
||||||
|
// shop-tier slot has nothing round-trippable (its tier is not an item), so that is
|
||||||
|
// errSlotEmpty — reverting a shop tier is not a take-off. The 5 slot rows are an
|
||||||
|
// invariant (PK user_id+slot), so the row is reset, never deleted. Destructive op
|
||||||
|
// first — reset the slot, then mint the pack row, restoring the slot on failure —
|
||||||
|
// mirroring the magic unequip so a fault can't duplicate the piece.
|
||||||
|
func applyMasterworkUnequip(uid id.UserID, slot EquipmentSlot) (mwUnequipOutcome, error) {
|
||||||
|
equip, err := loadAdvEquipment(uid)
|
||||||
|
if err != nil {
|
||||||
|
return mwUnequipOutcome{}, err
|
||||||
|
}
|
||||||
|
cur := equip[slot]
|
||||||
|
if cur == nil || (!cur.Masterwork && cur.ArenaTier == 0) {
|
||||||
|
return mwUnequipOutcome{}, errSlotEmpty
|
||||||
|
}
|
||||||
|
prev := *cur // snapshot for rollback
|
||||||
|
|
||||||
|
def0 := equipmentTiers[slot][0]
|
||||||
|
reset := &AdvEquipment{Slot: slot, Tier: 0, Condition: 100, Name: def0.Name, ActionsUsed: 0, ArenaTier: 0, ArenaSet: "", Masterwork: false, SkillSource: ""}
|
||||||
|
if err := saveAdvEquipment(uid, reset); err != nil {
|
||||||
|
return mwUnequipOutcome{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource}
|
||||||
|
if cur.ArenaTier > 0 {
|
||||||
|
old.Type = "ArenaGear"
|
||||||
|
}
|
||||||
|
if err := addAdvInventoryItem(uid, old); err != nil {
|
||||||
|
if rbErr := saveAdvEquipment(uid, &prev); rbErr != nil {
|
||||||
|
slog.Error("equip: masterwork take-off failed AND slot rollback failed",
|
||||||
|
"user", uid, "slot", slot, "add_err", err, "rollback_err", rbErr)
|
||||||
|
}
|
||||||
|
return mwUnequipOutcome{}, err
|
||||||
|
}
|
||||||
|
return mwUnequipOutcome{Name: cur.Name, Slot: slot}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// purchaseEquipmentTier buys a standard slot's tier with euros — the headless twin
|
||||||
|
// of advBuyEquipment, minus flavor. It returns a terminal verdict for Pete or
|
||||||
|
// retry=true for a transient fault. Money moves once, keyed on the order guid; the
|
||||||
|
// web only ever offers the next tier over a PLAIN shop-tier slot (buildEquipSlotViews
|
||||||
|
// suppresses the offer on special gear), so there is no occupant to evict here and
|
||||||
|
// the whole body is idempotent under a re-offered order.
|
||||||
|
func (p *AdventurePlugin) purchaseEquipmentTier(uid id.UserID, slot EquipmentSlot, tier int, guid string) (status, detail string, retry bool) {
|
||||||
|
defs, ok := equipmentTiers[slot]
|
||||||
|
if !ok {
|
||||||
|
return "rejected_not_equippable", "That isn't an equipment slot.", false
|
||||||
|
}
|
||||||
|
if tier < 1 || tier >= len(defs) {
|
||||||
|
// tier 0 is the free default, not a purchase; >= len is past the top tier.
|
||||||
|
return "rejected_max_tier", "That slot is already at the top tier.", false
|
||||||
|
}
|
||||||
|
def := defs[tier]
|
||||||
|
|
||||||
|
equip, err := loadAdvEquipment(uid)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
cur := equip[slot]
|
||||||
|
if cur != nil {
|
||||||
|
// Buying a shop tier over a special piece strips its bonus — a downgrade in
|
||||||
|
// practice even when the raw number rises. Take it off first, then buy.
|
||||||
|
if cur.Masterwork || cur.ArenaTier > 0 {
|
||||||
|
return "rejected_downgrade", "Take off your special gear in that slot before buying a tier.", false
|
||||||
|
}
|
||||||
|
if cur.Tier >= def.Tier {
|
||||||
|
return "rejected_downgrade", "You already have that tier or better.", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
price := def.Price
|
||||||
|
if !p.euro.HasExternalTx(guid) {
|
||||||
|
ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", guid)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "rejected_insufficient_funds", fmt.Sprintf("That upgrade costs €%.0f and you can't cover it.", price), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eq := &AdvEquipment{Slot: slot, Tier: def.Tier, Condition: 100, Name: def.Name, ActionsUsed: 0}
|
||||||
|
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||||
|
// No refund: the debit is guid-idempotent, so the next poll re-runs this with
|
||||||
|
// the charge already settled and only the (idempotent) slot write left to do.
|
||||||
|
// Refunding here would double-pay once that retry lands the gear.
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf("Upgraded your %s to %s (T%d) for €%.0f.", slot, def.Name, def.Tier, price), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairSlot mends one standard slot's condition with euros — the headless twin of
|
||||||
|
// the blacksmith's executeRepair. Idempotent on the order guid: the debit runs
|
||||||
|
// once, and setting condition to 100 is itself idempotent, so a re-offered order is
|
||||||
|
// safe with no refund.
|
||||||
|
func (p *AdventurePlugin) repairSlot(uid id.UserID, slot EquipmentSlot, guid string) (status, detail string, retry bool) {
|
||||||
|
equip, err := loadAdvEquipment(uid)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
eq := equip[slot]
|
||||||
|
if eq == nil {
|
||||||
|
return "rejected_not_worn", "There's nothing in that slot to repair.", false
|
||||||
|
}
|
||||||
|
cost := blacksmithRepairCost(eq)
|
||||||
|
if cost <= 0 {
|
||||||
|
// Already full — nothing to charge for. Report it as applied so the order
|
||||||
|
// reaches a terminal state rather than parking.
|
||||||
|
return "applied", "That piece was already at full condition.", false
|
||||||
|
}
|
||||||
|
if !p.euro.HasExternalTx(guid) {
|
||||||
|
ok, _, err := p.euro.DebitIdem(uid, float64(cost), "adventure_repair", guid)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "rejected_insufficient_funds", fmt.Sprintf("The repair costs €%d and you can't cover it.", cost), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eq.Condition = 100
|
||||||
|
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||||
|
return "", "", true // retry; the idempotent debit means no double-charge
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf("Repaired your %s for €%d.", eq.Name, cost), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEquipSlotViews is the owner-only snapshot of the 5 standard slots the web
|
||||||
|
// management panel renders from. Worn masterwork/arena pieces surface here (via
|
||||||
|
// CanTakeOff), not in the magic Equipped set. An upgrade is offered only over a
|
||||||
|
// plain shop-tier slot below max — a special piece is taken off, not shop-upgraded.
|
||||||
|
func buildEquipSlotViews(uid id.UserID) []peteclient.EquipSlotView {
|
||||||
|
equip, err := loadAdvEquipment(uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []peteclient.EquipSlotView
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
eq := equip[slot]
|
||||||
|
if eq == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := peteclient.EquipSlotView{
|
||||||
|
Slot: string(slot),
|
||||||
|
Name: eq.Name,
|
||||||
|
Tier: eq.Tier,
|
||||||
|
Condition: eq.Condition,
|
||||||
|
Masterwork: eq.Masterwork,
|
||||||
|
ArenaTier: eq.ArenaTier,
|
||||||
|
CanTakeOff: eq.Masterwork || eq.ArenaTier > 0,
|
||||||
|
RepairCost: blacksmithRepairCost(eq),
|
||||||
|
}
|
||||||
|
if !eq.Masterwork && eq.ArenaTier == 0 && eq.Tier < 5 {
|
||||||
|
next := equipmentTiers[slot][eq.Tier+1]
|
||||||
|
v.NextTier = next.Tier
|
||||||
|
v.NextName = next.Name
|
||||||
|
v.NextPrice = next.Price
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// masterworkEquipDetail turns a masterwork/arena equip outcome into the verdict
|
||||||
|
// note Pete shows.
|
||||||
|
func masterworkEquipDetail(out mwEquipOutcome) string {
|
||||||
|
kind := "masterwork"
|
||||||
|
if out.Arena {
|
||||||
|
kind = "arena"
|
||||||
|
}
|
||||||
|
b := fmt.Sprintf("Now worn in your %s slot (%s T%d).", out.Slot, kind, out.Tier)
|
||||||
|
if out.SwappedBack != "" {
|
||||||
|
b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEquipmentSlot reports whether a slot string names one of the 5 standard slots.
|
||||||
|
// The magic DnD slots and the standard slots are disjoint vocabularies, so this
|
||||||
|
// alone routes an unequip to the right path.
|
||||||
|
func isEquipmentSlot(slot string) bool {
|
||||||
|
for _, s := range allSlots {
|
||||||
|
if string(s) == slot {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ask 7: the headless equipment mutators the web equip queue drives. These pin the
|
||||||
|
// rules that cross the wire — downgrade block, max tier, insufficient funds,
|
||||||
|
// idempotent replay (one debit), masterwork evict/overwrite/take-off — at the
|
||||||
|
// gogobee end, on real rows.
|
||||||
|
|
||||||
|
// seedEquipPlayer stands up a playable adventurer (player_meta + tier-0 gear) with
|
||||||
|
// a euro plugin funded to `bankroll`, and returns the wired AdventurePlugin.
|
||||||
|
func seedEquipPlayer(t *testing.T, uid id.UserID, bankroll float64) *AdventurePlugin {
|
||||||
|
t.Helper()
|
||||||
|
if err := createAdvCharacter(uid, "Rurina"); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
euro := &EuroPlugin{}
|
||||||
|
euro.ensureBalance(uid)
|
||||||
|
if bankroll > 0 {
|
||||||
|
euro.Credit(uid, bankroll, "test bankroll")
|
||||||
|
}
|
||||||
|
return &AdventurePlugin{euro: euro}
|
||||||
|
}
|
||||||
|
|
||||||
|
func slotOf(t *testing.T, uid id.UserID, slot EquipmentSlot) *AdvEquipment {
|
||||||
|
t.Helper()
|
||||||
|
equip, err := loadAdvEquipment(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadAdvEquipment: %v", err)
|
||||||
|
}
|
||||||
|
return equip[slot]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchaseEquipmentTierHappyAndIdempotentDebit: buying the next tier debits
|
||||||
|
// once and raises the slot, and the euro move is keyed on the order guid so a
|
||||||
|
// re-offer moves no money. (A re-offer never re-enters this function in prod — the
|
||||||
|
// equip_applied_orders ledger short-circuits it — but the guid is the belt to that
|
||||||
|
// suspenders, and the retry-after-save-fault path below leans on it directly.)
|
||||||
|
func TestPurchaseEquipmentTierHappyAndIdempotentDebit(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@rurina:test")
|
||||||
|
p := seedEquipPlayer(t, uid, 100000)
|
||||||
|
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
price := equipmentTiers[SlotBoots][1].Price // Dead Man's Boots, €75
|
||||||
|
|
||||||
|
status, _, retry := p.purchaseEquipmentTier(uid, SlotBoots, 1, "guid-up-1")
|
||||||
|
if retry || status != "applied" {
|
||||||
|
t.Fatalf("upgrade = %q retry=%v, want applied", status, retry)
|
||||||
|
}
|
||||||
|
if got := slotOf(t, uid, SlotBoots); got.Tier != 1 || got.Name != equipmentTiers[SlotBoots][1].Name {
|
||||||
|
t.Fatalf("boots slot = %+v, want tier 1", got)
|
||||||
|
}
|
||||||
|
if got := p.euro.GetBalance(uid); got != before-price {
|
||||||
|
t.Fatalf("balance = %.2f, want %.2f (one debit of %.2f)", got, before-price, price)
|
||||||
|
}
|
||||||
|
// The guid is now a settled money move: a replayed debit on it is a no-op.
|
||||||
|
if !p.euro.HasExternalTx("guid-up-1") {
|
||||||
|
t.Fatal("the upgrade debit was not logged under the order guid")
|
||||||
|
}
|
||||||
|
if ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", "guid-up-1"); err != nil || !ok {
|
||||||
|
t.Fatalf("replayed debit = ok:%v err:%v, want a no-op ok", ok, err)
|
||||||
|
}
|
||||||
|
if got := p.euro.GetBalance(uid); got != before-price {
|
||||||
|
t.Fatalf("replayed debit double-charged: balance = %.2f, want %.2f", got, before-price)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The retry-after-save-fault path: a prior attempt debited but its slot write
|
||||||
|
// never landed, so the slot is still tier 0. The retry must skip the debit
|
||||||
|
// (guid already settled) and finish the write, moving no further money.
|
||||||
|
helmetGUID := "guid-helm"
|
||||||
|
hprice := equipmentTiers[SlotHelmet][1].Price
|
||||||
|
if ok, _, err := p.euro.DebitIdem(uid, hprice, "adventure_equip_upgrade", helmetGUID); err != nil || !ok {
|
||||||
|
t.Fatalf("seed prior debit = ok:%v err:%v", ok, err)
|
||||||
|
}
|
||||||
|
mid := p.euro.GetBalance(uid)
|
||||||
|
status, _, retry = p.purchaseEquipmentTier(uid, SlotHelmet, 1, helmetGUID)
|
||||||
|
if retry || status != "applied" {
|
||||||
|
t.Fatalf("retry after fault = %q retry=%v, want applied", status, retry)
|
||||||
|
}
|
||||||
|
if got := slotOf(t, uid, SlotHelmet); got.Tier != 1 {
|
||||||
|
t.Fatalf("helmet not upgraded on retry: tier %d", got.Tier)
|
||||||
|
}
|
||||||
|
if got := p.euro.GetBalance(uid); got != mid {
|
||||||
|
t.Fatalf("retry re-debited: balance %.2f → %.2f", mid, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchaseEquipmentTierRejections: a downgrade, a special-gear slot, the top
|
||||||
|
// tier, and an empty wallet all bounce without moving the slot or the money.
|
||||||
|
func TestPurchaseEquipmentTierRejections(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@rurina:test")
|
||||||
|
p := seedEquipPlayer(t, uid, 100000)
|
||||||
|
|
||||||
|
// Lift boots to tier 3 so we have something to (not) downgrade from.
|
||||||
|
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 3, "seed-t3"); s != "applied" {
|
||||||
|
t.Fatalf("seed to tier 3 = %q", s)
|
||||||
|
}
|
||||||
|
// Same tier or lower is a downgrade.
|
||||||
|
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 3, "g-eq"); s != "rejected_downgrade" {
|
||||||
|
t.Errorf("re-buy same tier = %q, want rejected_downgrade", s)
|
||||||
|
}
|
||||||
|
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 2, "g-down"); s != "rejected_downgrade" {
|
||||||
|
t.Errorf("buy lower tier = %q, want rejected_downgrade", s)
|
||||||
|
}
|
||||||
|
// Past the top tier.
|
||||||
|
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 6, "g-max"); s != "rejected_max_tier" {
|
||||||
|
t.Errorf("buy tier 6 = %q, want rejected_max_tier", s)
|
||||||
|
}
|
||||||
|
// A special piece in the slot: buying a plain tier over it strips the bonus.
|
||||||
|
weapon := slotOf(t, uid, SlotWeapon)
|
||||||
|
weapon.Masterwork = true
|
||||||
|
weapon.Tier = 2
|
||||||
|
weapon.Name = "Miner's Masterwork Blade"
|
||||||
|
weapon.SkillSource = "mining"
|
||||||
|
if err := saveAdvEquipment(uid, weapon); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s, _, _ := p.purchaseEquipmentTier(uid, SlotWeapon, 3, "g-special"); s != "rejected_downgrade" {
|
||||||
|
t.Errorf("buy plain over masterwork = %q, want rejected_downgrade", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insufficient funds: a broke player can't buy a €30000 tier-5 weapon.
|
||||||
|
broke := id.UserID("@broke:test")
|
||||||
|
pb := seedEquipPlayer(t, broke, 0)
|
||||||
|
bal := pb.euro.GetBalance(broke)
|
||||||
|
if s, _, _ := pb.purchaseEquipmentTier(broke, SlotWeapon, 5, "g-broke"); s != "rejected_insufficient_funds" {
|
||||||
|
t.Errorf("broke upgrade = %q, want rejected_insufficient_funds", s)
|
||||||
|
}
|
||||||
|
if got := pb.euro.GetBalance(broke); got != bal {
|
||||||
|
t.Errorf("a rejected upgrade moved money: %.2f → %.2f", bal, got)
|
||||||
|
}
|
||||||
|
if got := slotOf(t, broke, SlotWeapon); got.Tier != 0 {
|
||||||
|
t.Errorf("a rejected upgrade changed the slot: tier %d", got.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairSlotHappyAndNoop: repairing a damaged slot debits the blacksmith cost
|
||||||
|
// and restores condition; repairing a full slot is a no-op that charges nothing.
|
||||||
|
func TestRepairSlotHappyAndNoop(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@rurina:test")
|
||||||
|
p := seedEquipPlayer(t, uid, 100000)
|
||||||
|
|
||||||
|
weapon := slotOf(t, uid, SlotWeapon)
|
||||||
|
weapon.Condition = 50
|
||||||
|
if err := saveAdvEquipment(uid, weapon); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cost := blacksmithRepairCost(weapon)
|
||||||
|
if cost <= 0 {
|
||||||
|
t.Fatalf("expected a positive repair cost, got %d", cost)
|
||||||
|
}
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
|
||||||
|
status, _, retry := p.repairSlot(uid, SlotWeapon, "guid-rep-1")
|
||||||
|
if retry || status != "applied" {
|
||||||
|
t.Fatalf("repair = %q retry=%v, want applied", status, retry)
|
||||||
|
}
|
||||||
|
if got := slotOf(t, uid, SlotWeapon); got.Condition != 100 {
|
||||||
|
t.Fatalf("condition = %d, want 100", got.Condition)
|
||||||
|
}
|
||||||
|
if got := p.euro.GetBalance(uid); got != before-float64(cost) {
|
||||||
|
t.Fatalf("balance = %.2f, want %.2f (debit %d)", got, before-float64(cost), cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now at full condition: a fresh repair is an applied no-op, no charge.
|
||||||
|
after := p.euro.GetBalance(uid)
|
||||||
|
status, _, _ = p.repairSlot(uid, SlotWeapon, "guid-rep-2")
|
||||||
|
if status != "applied" {
|
||||||
|
t.Fatalf("no-op repair = %q, want applied", status)
|
||||||
|
}
|
||||||
|
if got := p.euro.GetBalance(uid); got != after {
|
||||||
|
t.Errorf("no-op repair charged: %.2f → %.2f", after, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMasterworkEquipEvictsOverwritesAndTakesOff: a masterwork piece equips into a
|
||||||
|
// plain slot (overwriting the tier), a better one evicts the special occupant back
|
||||||
|
// to the pack, a worse one is blocked, and take-off resets the slot to tier 0.
|
||||||
|
func TestMasterworkEquipEvictsOverwritesAndTakesOff(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@rurina:test")
|
||||||
|
seedEquipPlayer(t, uid, 0)
|
||||||
|
|
||||||
|
mw := func(name string, tier int) AdvItem {
|
||||||
|
if err := addAdvInventoryItem(uid, AdvItem{Name: name, Type: "MasterworkGear", Tier: tier, Slot: SlotWeapon, SkillSource: "mining"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inv, _ := loadAdvInventory(uid)
|
||||||
|
for _, it := range inv {
|
||||||
|
if it.Name == name {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("just-added item %q not in inventory", name)
|
||||||
|
return AdvItem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equip a T3 masterwork over the tier-0 plain weapon: it overwrites, no eviction.
|
||||||
|
out, err := applyMasterworkEquip(uid, mw("Deepforged Blade", 3))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("equip T3: %v", err)
|
||||||
|
}
|
||||||
|
if out.SwappedBack != "" {
|
||||||
|
t.Errorf("plain occupant should be overwritten, not evicted; got swap %q", out.SwappedBack)
|
||||||
|
}
|
||||||
|
if got := slotOf(t, uid, SlotWeapon); !got.Masterwork || got.Tier != 3 || got.Name != "Deepforged Blade" {
|
||||||
|
t.Fatalf("weapon = %+v, want masterwork T3 Deepforged Blade", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A better masterwork (T4) evicts the T3 back to the pack.
|
||||||
|
out, err = applyMasterworkEquip(uid, mw("Sunforged Blade", 4))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("equip T4: %v", err)
|
||||||
|
}
|
||||||
|
if out.SwappedBack != "Deepforged Blade" {
|
||||||
|
t.Errorf("evicted = %q, want Deepforged Blade", out.SwappedBack)
|
||||||
|
}
|
||||||
|
invHas := func(name string) bool {
|
||||||
|
inv, _ := loadAdvInventory(uid)
|
||||||
|
for _, it := range inv {
|
||||||
|
if it.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !invHas("Deepforged Blade") {
|
||||||
|
t.Error("the evicted T3 masterwork is not back in the pack")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A worse masterwork (T2) is a blocked downgrade.
|
||||||
|
if _, err := applyMasterworkEquip(uid, mw("Rusty Masterwork", 2)); err != errEquipDowngrade {
|
||||||
|
t.Errorf("equip worse masterwork err = %v, want errEquipDowngrade", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take off the worn T4: it returns to the pack and the slot resets to tier 0.
|
||||||
|
un, err := applyMasterworkUnequip(uid, SlotWeapon)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("take off: %v", err)
|
||||||
|
}
|
||||||
|
if un.Name != "Sunforged Blade" {
|
||||||
|
t.Errorf("took off %q, want Sunforged Blade", un.Name)
|
||||||
|
}
|
||||||
|
if got := slotOf(t, uid, SlotWeapon); got.Masterwork || got.Tier != 0 || got.Name != equipmentTiers[SlotWeapon][0].Name {
|
||||||
|
t.Fatalf("weapon after take-off = %+v, want tier-0 default", got)
|
||||||
|
}
|
||||||
|
if !invHas("Sunforged Blade") {
|
||||||
|
t.Error("the taken-off masterwork is not back in the pack")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taking off a plain slot has nothing round-trippable.
|
||||||
|
if _, err := applyMasterworkUnequip(uid, SlotArmor); err != errSlotEmpty {
|
||||||
|
t.Errorf("take off plain slot err = %v, want errSlotEmpty", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildEquipSlotViews: the panel snapshot offers an upgrade only over a plain
|
||||||
|
// sub-max slot, take-off only on special gear, and a repair cost only when damaged.
|
||||||
|
func TestBuildEquipSlotViews(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@rurina:test")
|
||||||
|
seedEquipPlayer(t, uid, 0)
|
||||||
|
|
||||||
|
// Boots: masterwork T3, damaged → take off + repair, no upgrade offer.
|
||||||
|
boots := slotOf(t, uid, SlotBoots)
|
||||||
|
boots.Masterwork = true
|
||||||
|
boots.Tier = 3
|
||||||
|
boots.Name = "The Wandering Sole"
|
||||||
|
boots.Condition = 70
|
||||||
|
if err := saveAdvEquipment(uid, boots); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Helmet: plain T2 → upgrade to T3 offered, no take off, no repair.
|
||||||
|
helmet := slotOf(t, uid, SlotHelmet)
|
||||||
|
helmet.Tier = 2
|
||||||
|
helmet.Name = equipmentTiers[SlotHelmet][2].Name
|
||||||
|
if err := saveAdvEquipment(uid, helmet); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
views := buildEquipSlotViews(uid)
|
||||||
|
bySlot := map[string]struct {
|
||||||
|
takeOff bool
|
||||||
|
nextTier int
|
||||||
|
repair int
|
||||||
|
}{}
|
||||||
|
for _, v := range views {
|
||||||
|
bySlot[v.Slot] = struct {
|
||||||
|
takeOff bool
|
||||||
|
nextTier int
|
||||||
|
repair int
|
||||||
|
}{v.CanTakeOff, v.NextTier, v.RepairCost}
|
||||||
|
}
|
||||||
|
if len(views) != len(allSlots) {
|
||||||
|
t.Fatalf("got %d slot views, want %d", len(views), len(allSlots))
|
||||||
|
}
|
||||||
|
if b := bySlot["boots"]; !b.takeOff || b.nextTier != 0 || b.repair <= 0 {
|
||||||
|
t.Errorf("boots view = %+v, want take-off, no upgrade, positive repair", b)
|
||||||
|
}
|
||||||
|
if h := bySlot["helmet"]; h.takeOff || h.nextTier != 3 || h.repair != 0 {
|
||||||
|
t.Errorf("helmet view = %+v, want upgrade to T3, no take-off, no repair", h)
|
||||||
|
}
|
||||||
|
// A pristine tier-0 slot: upgrade offered to T1, no take-off, no repair.
|
||||||
|
if w := bySlot["weapon"]; w.takeOff || w.nextTier != 1 || w.repair != 0 {
|
||||||
|
t.Errorf("weapon view = %+v, want upgrade to T1", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// The offer half of the web action queue: what a signed-in owner is allowed to
|
||||||
|
// ask for, and what it costs.
|
||||||
|
//
|
||||||
|
// W5a's two verbs needed none of this — "pull out" and "take your bout" have no
|
||||||
|
// arguments and no price. W5b's three do, and the page cannot invent either: the
|
||||||
|
// zone list is level-gated and postgame-gated per player, and every price scales
|
||||||
|
// with level. So gogobee quotes them here, on the self-detail push that already
|
||||||
|
// carries the owner's private panels, and Pete renders the quote without doing
|
||||||
|
// any arithmetic of its own.
|
||||||
|
//
|
||||||
|
// A quote is NOT a permission. It is up to two minutes stale by the time anybody
|
||||||
|
// clicks it, so every one of these is re-resolved against the game's own tables
|
||||||
|
// when the order lands (performExpeditionStart re-runs availableZonesFor,
|
||||||
|
// performBabysitPurchase re-reads the level). What the offer list buys is a page
|
||||||
|
// that does not show a button which is certain to be refused.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// advLoadoutKeys is the order the three presets are offered in — cheapest first,
|
||||||
|
// which is also how renderLoadoutPrompt lists them in Matrix.
|
||||||
|
var advLoadoutKeys = []SupplyLoadout{LoadoutLean, LoadoutBalanced, LoadoutHeavy}
|
||||||
|
|
||||||
|
// loadoutOffersFor prices the three supply presets at a tier. Days is the
|
||||||
|
// provisions estimate at that tier's calm daily burn — the same number
|
||||||
|
// `!expedition start` prints, and deliberately the pessimistic one: the holiday
|
||||||
|
// and Omen freebie packs are added at departure, so a run can outlast its quote
|
||||||
|
// but never fall short of it.
|
||||||
|
func loadoutOffersFor(tier ZoneTier) []peteclient.LoadoutOffer {
|
||||||
|
out := make([]peteclient.LoadoutOffer, 0, len(advLoadoutKeys))
|
||||||
|
for _, l := range advLoadoutKeys {
|
||||||
|
pp := loadoutPurchase(tier, l)
|
||||||
|
sup := makeSupplies(tier, pp)
|
||||||
|
out = append(out, peteclient.LoadoutOffer{
|
||||||
|
Key: loadoutName(l),
|
||||||
|
Name: loadoutName(l),
|
||||||
|
Blurb: loadoutBlurb(l),
|
||||||
|
Cost: pp.Cost(),
|
||||||
|
Days: estimateDays(sup.Max, sup.DailyBurn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneOffersFor is where this adventurer may set out for right now, priced.
|
||||||
|
//
|
||||||
|
// It returns nothing at all when they cannot leave — already on an expedition,
|
||||||
|
// seated in somebody else's, or mid zone-run. That is not a second permission
|
||||||
|
// check duplicating performExpeditionStart's; it is what stops the page offering
|
||||||
|
// a departure it can already tell will be refused.
|
||||||
|
func zoneOffersFor(uid id.UserID) []peteclient.ZoneOffer {
|
||||||
|
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if existing, _ := getActiveExpedition(uid); existing != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if run, _ := getActiveZoneRun(uid); run != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
zones := availableZonesFor(uid, dndLevelForUser(uid))
|
||||||
|
out := make([]peteclient.ZoneOffer, 0, len(zones))
|
||||||
|
for _, z := range zones {
|
||||||
|
out = append(out, peteclient.ZoneOffer{
|
||||||
|
ID: string(z.ID),
|
||||||
|
Display: z.Display,
|
||||||
|
Tier: int(z.Tier),
|
||||||
|
Hook: z.Hook,
|
||||||
|
Postgame: z.Tier == ZoneTierMythic,
|
||||||
|
Loadouts: loadoutOffersFor(z.Tier),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// resumeOfferFor is the extracted expedition still waiting to be walked back
|
||||||
|
// into. Nil when there is none, when the window has already lapsed, or when the
|
||||||
|
// player is out again — a lapsed row is left for the sweeper to reap rather than
|
||||||
|
// reaped here, because a push builder should not be quietly ending expeditions.
|
||||||
|
func resumeOfferFor(uid id.UserID) *peteclient.ResumeOffer {
|
||||||
|
if existing, _ := getActiveExpedition(uid); existing != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
exp, err := getResumableExpedition(uid)
|
||||||
|
if err != nil || exp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if extractionLapsed(exp, time.Now().UTC()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
zone, _ := getZone(exp.ZoneID)
|
||||||
|
off := &peteclient.ResumeOffer{
|
||||||
|
ZoneID: string(exp.ZoneID),
|
||||||
|
Display: zone.Display,
|
||||||
|
Tier: int(zone.Tier),
|
||||||
|
Day: exp.CurrentDay,
|
||||||
|
Loadouts: loadoutOffersFor(zone.Tier),
|
||||||
|
}
|
||||||
|
if exp.CompletedAt != nil {
|
||||||
|
off.ExpiresAt = exp.CompletedAt.Add(extractResumeWindow).Unix()
|
||||||
|
}
|
||||||
|
return off
|
||||||
|
}
|
||||||
|
|
||||||
|
// babysitOfferFor is the sitter's standing and the two prices they charge. It is
|
||||||
|
// pushed even when a sitter is already engaged: "somebody is already looking
|
||||||
|
// after your pet until Tuesday" is exactly what the page should say instead of a
|
||||||
|
// buy button.
|
||||||
|
func babysitOfferFor(adv *AdventureCharacter) *peteclient.BabysitOffer {
|
||||||
|
if adv == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
daily := babysitDailyCost(dndLevelForUser(adv.UserID))
|
||||||
|
off := &peteclient.BabysitOffer{
|
||||||
|
Active: adv.BabysitActive,
|
||||||
|
WeekCost: daily * 7,
|
||||||
|
MonthCost: daily * 30,
|
||||||
|
}
|
||||||
|
if adv.BabysitActive && adv.BabysitExpiresAt != nil {
|
||||||
|
off.ExpiresAt = adv.BabysitExpiresAt.Unix()
|
||||||
|
}
|
||||||
|
return off
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// The web action queue's game-side loop — the equip queue's sibling, and the
|
||||||
|
// first one where the web plays the game rather than dressing the character.
|
||||||
|
//
|
||||||
|
// An owner, signed in on Pete, asks for something: pull out of a run, take
|
||||||
|
// today's swing at the Siege, set out for a zone, walk back into the run they
|
||||||
|
// extracted from, hire the pet sitter. Pete records the intent; we poll for it,
|
||||||
|
// run the real command path (the same one `!extract`, `!expedition start`,
|
||||||
|
// `!resume` and the rest run — not a second implementation of it), and file a
|
||||||
|
// verdict Pete shows them.
|
||||||
|
//
|
||||||
|
// Same non-idempotency problem as equip, with higher stakes: replaying an
|
||||||
|
// extraction would end a run the player had already resumed, and replaying a bout
|
||||||
|
// would spend a day's swing they never got back. So before touching anything we
|
||||||
|
// check the adv_applied_orders ledger — if this order's guid is there, the
|
||||||
|
// mutation landed on an earlier tick and we only lost the verdict-ack, so we
|
||||||
|
// re-file the stored verdict and mutate nothing.
|
||||||
|
//
|
||||||
|
// The poll is faster than equip's (15s vs 30s) for one reason: an extraction is
|
||||||
|
// the answer to something the player is *watching* go wrong on the who page. A
|
||||||
|
// minute of silence there reads as a button that didn't work.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
advOrderPollInterval = 15 * time.Second
|
||||||
|
// A Siege bout runs a full combat and streams its narration to Matrix before
|
||||||
|
// takeSiegeBout returns, so this budget is minutes, not seconds — the equip
|
||||||
|
// path's 20s would abandon a fight that was going fine.
|
||||||
|
advOrderPollTimeout = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// peteAdvOrderTicker polls Pete for web actions and fulfils them. Started
|
||||||
|
// alongside the other adventure tickers; exits on stopCh.
|
||||||
|
func (p *AdventurePlugin) peteAdvOrderTicker() {
|
||||||
|
if !peteclient.Enabled() {
|
||||||
|
return // no Pete wire configured; the action queue is simply off
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(advOrderPollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
p.pollAdvOrders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) pollAdvOrders() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), advOrderPollTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
orders, err := peteclient.PendingOrders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// A Pete predating the queue answers 404; a wire blip looks the same. Quiet
|
||||||
|
// on purpose — this must not spam while Pete hasn't shipped the endpoint.
|
||||||
|
slog.Debug("orders: poll failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, order := range orders {
|
||||||
|
p.fulfilAdvOrder(ctx, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fulfilAdvOrder applies one action and files its verdict. A transient failure is
|
||||||
|
// left pending for the next poll (no verdict); a permanent one gets a specific
|
||||||
|
// rejection. The guid ledger makes a re-offer after a lost ack a no-op that
|
||||||
|
// simply re-files the verdict.
|
||||||
|
func (p *AdventurePlugin) fulfilAdvOrder(ctx context.Context, order peteclient.AdvOrder) {
|
||||||
|
// Already applied on an earlier tick? Re-file the stored verdict, mutate nothing.
|
||||||
|
if status, detail, ok := advOrderAlreadyApplied(order.GUID); ok {
|
||||||
|
if err := peteclient.VerdictOrder(ctx, order.GUID, status, detail); err != nil {
|
||||||
|
slog.Warn("orders: re-file verdict push failed, will retry next poll",
|
||||||
|
"order", order.GUID, "status", status, "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, ok := p.equipOwnerMXID(order.OwnerLocalpart)
|
||||||
|
if !ok {
|
||||||
|
// The client isn't up (tests) or the localpart is empty. Not our order to
|
||||||
|
// fail permanently — leave it pending and try again once we can name the owner.
|
||||||
|
slog.Debug("orders: cannot resolve owner, leaving pending", "order", order.GUID, "owner", order.OwnerLocalpart)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, detail, retry := p.applyAdvOrder(owner, order)
|
||||||
|
if retry {
|
||||||
|
return // transient; leave pending for the next tick
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the verdict BEFORE pushing it, so a crash after the mutation still
|
||||||
|
// short-circuits next tick and re-files rather than re-applying. Same ordering
|
||||||
|
// and the same reasoning as the equip poller.
|
||||||
|
if err := recordAdvApplied(order.GUID, status, detail); err != nil {
|
||||||
|
slog.Warn("orders: failed to record applied order, leaving pending",
|
||||||
|
"order", order.GUID, "status", status, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := peteclient.VerdictOrder(ctx, order.GUID, status, detail); err != nil {
|
||||||
|
slog.Warn("orders: verdict push failed, will re-file next poll",
|
||||||
|
"order", order.GUID, "status", status, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("orders: web action fulfilled", "order", order.GUID, "action", order.Action, "status", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAdvOrder runs the real action. It returns the terminal status and a human
|
||||||
|
// note for Pete, or retry=true for a transient fault that should leave the order
|
||||||
|
// pending. It records nothing and pushes nothing — the caller does both.
|
||||||
|
//
|
||||||
|
// Note what is NOT here: a per-user lock. Almost every verb takes it inside its
|
||||||
|
// own shared helper (performExtraction, takeSiegeBout, performResume,
|
||||||
|
// performBabysitPurchase), which is what serialises them against the Matrix
|
||||||
|
// commands running the very same code. Taking it here too would deadlock on a
|
||||||
|
// non-reentrant mutex.
|
||||||
|
//
|
||||||
|
// The exceptions are the three twins whose Matrix caller already holds the lock
|
||||||
|
// across the whole `!expedition` switch and so cannot take it themselves —
|
||||||
|
// performExpeditionStart, performExpeditionAbandon and performExpeditionLeave.
|
||||||
|
// Their apply wrappers below take it instead. That asymmetry is written down in
|
||||||
|
// both places because getting it wrong does not fail loudly: it wedges the
|
||||||
|
// player's lock forever and every later adventure command from them hangs. The
|
||||||
|
// rule for a new verb is not "web wrappers take the lock" — it is "look at what
|
||||||
|
// the Matrix caller does", and the two babysit verbs go the other way.
|
||||||
|
func (p *AdventurePlugin) applyAdvOrder(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
||||||
|
switch order.Action {
|
||||||
|
case peteclient.AdvOrderExtract:
|
||||||
|
out, err := p.performExtraction(owner)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errExtractNoRun):
|
||||||
|
return "rejected_not_running", "You weren't on an expedition.", false
|
||||||
|
case errors.Is(err, errExtractNotLeader):
|
||||||
|
return "rejected_not_leader", "Only the party leader can call the extraction.", false
|
||||||
|
case err != nil:
|
||||||
|
// Every remaining failure here is a DB fault. Leave it pending: nothing
|
||||||
|
// has been written, so the next tick retries cleanly.
|
||||||
|
slog.Warn("orders: extraction failed", "order", order.GUID, "user", owner, "err", err)
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf(
|
||||||
|
"Out of %s on day %d. Loot, XP and coins kept. Say !resume within 7 days to go back in.",
|
||||||
|
out.Zone, out.Day), false
|
||||||
|
|
||||||
|
case peteclient.AdvOrderSiegeJoin:
|
||||||
|
bout, boss, err := p.takeSiegeBout(owner)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errSiegeNoBoss):
|
||||||
|
return "rejected_no_siege", "No Siege is camped outside town right now.", false
|
||||||
|
case errors.Is(err, errSiegeNoCharacter):
|
||||||
|
return "rejected_unavailable", "You don't have an adventurer yet.", false
|
||||||
|
case errors.Is(err, errSiegeDead):
|
||||||
|
return "rejected_unavailable", "You're dead. The Siege will have to wait.", false
|
||||||
|
case errors.Is(err, errSiegeAlreadyFought):
|
||||||
|
return "rejected_already_fought", "You've already taken your bout today. One fight per day.", false
|
||||||
|
case err != nil:
|
||||||
|
// A combat that errored persisted nothing terminal, but it may have
|
||||||
|
// written HP. Retry is still right: the once-per-day gate is stamped by
|
||||||
|
// the contribution row, which only lands on a bout that completed.
|
||||||
|
slog.Warn("orders: siege bout failed", "order", order.GUID, "user", owner, "err", err)
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
// The blow-by-blow went to Matrix; the web gets the same one-line result the
|
||||||
|
// narration closed with, minus its markdown.
|
||||||
|
return "applied", advOrderPlainText(siegeBoutFooter(bout, boss)), false
|
||||||
|
|
||||||
|
case peteclient.AdvOrderExpedition:
|
||||||
|
return p.applyWebExpeditionStart(owner, order)
|
||||||
|
|
||||||
|
case peteclient.AdvOrderResume:
|
||||||
|
return p.applyWebResume(owner, order)
|
||||||
|
|
||||||
|
case peteclient.AdvOrderBabysit:
|
||||||
|
return p.applyWebBabysit(owner, order)
|
||||||
|
|
||||||
|
case peteclient.AdvOrderAbandon:
|
||||||
|
return p.applyWebAbandon(owner)
|
||||||
|
|
||||||
|
case peteclient.AdvOrderLeave:
|
||||||
|
return p.applyWebLeave(owner)
|
||||||
|
|
||||||
|
case peteclient.AdvOrderBabysitCancel:
|
||||||
|
return p.applyWebBabysitCancel(owner)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Pete validates the action before it ever queues an order, so this is a
|
||||||
|
// contract breach, not a user mistake. Reject permanently rather than spin.
|
||||||
|
return "rejected_unavailable", "Unknown action.", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the three verbs that take arguments and spend coins ------------------------
|
||||||
|
//
|
||||||
|
// Everything below re-resolves its own arguments against the game's own tables.
|
||||||
|
// Pete only ever offers what gogobee quoted it (see pete_offers.go), but a quote
|
||||||
|
// is up to two minutes stale and is not a permission — so the zone is looked up
|
||||||
|
// again in availableZonesFor, the loadout is priced again at the real tier, and
|
||||||
|
// the fee is read again at the real level. A forged param buys nothing.
|
||||||
|
//
|
||||||
|
// All three are money moves on a retrying wire, so each hands the order guid down
|
||||||
|
// as the idempotency key. Nothing here refunds-then-retries: once a refund has
|
||||||
|
// happened the guid-keyed debit will not charge again, so a retry would hand over
|
||||||
|
// the goods for free. Every failure past the debit is therefore permanent.
|
||||||
|
|
||||||
|
// applyWebExpeditionStart sends the owner's adventurer out of town.
|
||||||
|
func (p *AdventurePlugin) applyWebExpeditionStart(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
||||||
|
if order.Params == nil || order.Params.Zone == "" {
|
||||||
|
return "rejected_unavailable", "That order didn't say where to.", false
|
||||||
|
}
|
||||||
|
// performExpeditionStart is the one headless twin that does NOT take the
|
||||||
|
// per-user lock (its Matrix caller already holds it across the whole
|
||||||
|
// `!expedition` switch), so this is the one order case that has to.
|
||||||
|
userMu := p.advUserLock(owner)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
c, err := LoadDnDCharacter(owner)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", true // a DB fault; nothing written, so the next tick retries cleanly
|
||||||
|
}
|
||||||
|
if c == nil || c.PendingSetup {
|
||||||
|
return "rejected_unavailable", "You don't have an adventurer yet.", false
|
||||||
|
}
|
||||||
|
zoneID, ok := resolveZoneInput(order.Params.Zone, availableZonesFor(owner, c.Level))
|
||||||
|
if !ok {
|
||||||
|
if reason := postgameLockReason(order.Params.Zone, owner, c.Level); reason != "" {
|
||||||
|
return "rejected_zone_locked", advOrderPlainText(reason), false
|
||||||
|
}
|
||||||
|
return "rejected_zone_locked", "That zone isn't open to you right now.", false
|
||||||
|
}
|
||||||
|
zone, _ := getZone(zoneID)
|
||||||
|
// An unknown loadout is refused rather than defaulted. A default here would
|
||||||
|
// spend coins on a pack size the player never picked.
|
||||||
|
loadout, ok := parseLoadoutToken(order.Params.Loadout)
|
||||||
|
if !ok {
|
||||||
|
return "rejected_unavailable", "That isn't a loadout I sell.", false
|
||||||
|
}
|
||||||
|
out, err := p.performExpeditionStart(owner, c, zone, loadoutPurchase(zone.Tier, loadout), order.GUID)
|
||||||
|
if err != nil {
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errExpStartZoneLocked):
|
||||||
|
status = "rejected_zone_locked"
|
||||||
|
case errors.Is(err, errExpStartBusy):
|
||||||
|
status = "rejected_busy"
|
||||||
|
case errors.Is(err, errExpStartBroke):
|
||||||
|
status = "rejected_insufficient_funds"
|
||||||
|
}
|
||||||
|
// Everything else — still resting, a bad pack count, a start that tore
|
||||||
|
// itself down and refunded — is rejected_unavailable, and the detail line
|
||||||
|
// carries the specifics.
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf(
|
||||||
|
"Out of town, bound for %s, with the %s loadout: %d coins, about %d days of provisions.",
|
||||||
|
out.Zone.Display, loadoutName(loadout), out.Cost, out.Days), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWebResume walks the owner back into the run they extracted from.
|
||||||
|
func (p *AdventurePlugin) applyWebResume(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
||||||
|
// The loadout is required here, unlike in Matrix: an empty one asks
|
||||||
|
// performResume for the pick-a-loadout prompt, which is a DM, not a verdict.
|
||||||
|
if order.Params == nil || order.Params.Loadout == "" {
|
||||||
|
return "rejected_unavailable", "That order didn't say what to pack.", false
|
||||||
|
}
|
||||||
|
if _, ok := parseLoadoutToken(order.Params.Loadout); !ok {
|
||||||
|
return "rejected_unavailable", "That isn't a loadout I sell.", false
|
||||||
|
}
|
||||||
|
out, err := p.performResume(owner, order.Params.Loadout, order.GUID)
|
||||||
|
if err != nil {
|
||||||
|
var refusal advRefusal
|
||||||
|
if !errors.As(err, &refusal) {
|
||||||
|
// Not a refusal at all — a DB fault reading expedition state. Nothing
|
||||||
|
// has been written, so leave it pending.
|
||||||
|
slog.Warn("orders: resume failed", "order", order.GUID, "user", owner, "err", err)
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errResumeBusy):
|
||||||
|
status = "rejected_busy"
|
||||||
|
case errors.Is(err, errResumeNothing), errors.Is(err, errResumeLapsed):
|
||||||
|
status = "rejected_nothing_to_resume"
|
||||||
|
case errors.Is(err, errResumeBroke):
|
||||||
|
status = "rejected_insufficient_funds"
|
||||||
|
}
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf(
|
||||||
|
"Back into %s on day %d, re-outfitted for %d coins.",
|
||||||
|
out.Zone.Display, out.Day, out.Purchase.Cost()), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWebBabysit engages the pet sitter for a week or a month.
|
||||||
|
func (p *AdventurePlugin) applyWebBabysit(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
||||||
|
// The two durations the game sells. Anything else is a contract breach rather
|
||||||
|
// than a user mistake, since Pete offers exactly these two.
|
||||||
|
days := 0
|
||||||
|
if order.Params != nil {
|
||||||
|
days = order.Params.Days
|
||||||
|
}
|
||||||
|
if days != 7 && days != 30 {
|
||||||
|
return "rejected_unavailable", "The sitter works by the week or by the month.", false
|
||||||
|
}
|
||||||
|
out, err := p.performBabysitPurchase(owner, days, order.GUID)
|
||||||
|
if err != nil {
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errBabysitActive):
|
||||||
|
status = "rejected_busy"
|
||||||
|
case errors.Is(err, errBabysitBroke):
|
||||||
|
status = "rejected_insufficient_funds"
|
||||||
|
}
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
label := "a week"
|
||||||
|
if out.Days == 30 {
|
||||||
|
label = "a month"
|
||||||
|
}
|
||||||
|
note := fmt.Sprintf("Sitter engaged for %s, %d coins.", label, out.Cost)
|
||||||
|
if out.PetName != "" {
|
||||||
|
note += fmt.Sprintf(" %s is in good hands.", out.PetName)
|
||||||
|
}
|
||||||
|
return "applied", note, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the three verbs that take no arguments and spend nothing -------------------
|
||||||
|
//
|
||||||
|
// Each of these was already named inside a verdict the web shows: "!expedition
|
||||||
|
// abandon first", "!expedition leave to walk out alone", "cancel early (no
|
||||||
|
// refund)". A page that tells somebody to go and type a command it could have
|
||||||
|
// offered them is a page with a hole in it, and these three close it.
|
||||||
|
//
|
||||||
|
// None of them touches money, so none of them needs an idempotency key — the
|
||||||
|
// guid ledger in fulfilAdvOrder is the whole guard, and a replay it somehow got
|
||||||
|
// past would be refused honestly ("nothing to abandon") rather than charging
|
||||||
|
// anybody twice.
|
||||||
|
|
||||||
|
// applyWebAbandon closes the owner's expedition down for good.
|
||||||
|
func (p *AdventurePlugin) applyWebAbandon(owner id.UserID) (status, detail string, retry bool) {
|
||||||
|
// performExpeditionAbandon does NOT take the per-user lock (its Matrix caller
|
||||||
|
// holds it across the whole `!expedition` switch), so this has to. See the
|
||||||
|
// note on applyAdvOrder.
|
||||||
|
userMu := p.advUserLock(owner)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
out, err := p.performExpeditionAbandon(owner)
|
||||||
|
if err != nil {
|
||||||
|
var refusal advRefusal
|
||||||
|
if !errors.As(err, &refusal) {
|
||||||
|
// A DB fault reading or writing expedition state. Nothing partial is
|
||||||
|
// left behind that a retry would double up, so leave it pending.
|
||||||
|
slog.Warn("orders: abandon failed", "user", owner, "err", err)
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errAbandonNothing):
|
||||||
|
status = "rejected_not_running"
|
||||||
|
case errors.Is(err, errAbandonNotLeader):
|
||||||
|
status = "rejected_not_leader"
|
||||||
|
}
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
// The extracted case keeps loot and XP, so saying "supplies are forfeit" there
|
||||||
|
// would be a straight lie. Same split the DM makes.
|
||||||
|
if out.Extracted {
|
||||||
|
return "applied", fmt.Sprintf(
|
||||||
|
"You let %s go on day %d. Loot, XP and coins are kept.", out.Zone.Display, out.Day), false
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf(
|
||||||
|
"Expedition in %s abandoned on day %d. Supplies are forfeit.", out.Zone.Display, out.Day), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWebLeave walks a party member out of somebody else's expedition.
|
||||||
|
func (p *AdventurePlugin) applyWebLeave(owner id.UserID) (status, detail string, retry bool) {
|
||||||
|
// Same lock asymmetry as applyWebAbandon.
|
||||||
|
userMu := p.advUserLock(owner)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
if err := p.performExpeditionLeave(owner); err != nil {
|
||||||
|
var refusal advRefusal
|
||||||
|
if !errors.As(err, &refusal) {
|
||||||
|
slog.Warn("orders: leave failed", "user", owner, "err", err)
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errLeaveNothing):
|
||||||
|
status = "rejected_not_running"
|
||||||
|
case errors.Is(err, errLeaveIsLeader):
|
||||||
|
status = "rejected_is_leader"
|
||||||
|
}
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
return "applied", "You turn back for town. Your supplies stay with the party.", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWebBabysitCancel dismisses the pet sitter early.
|
||||||
|
func (p *AdventurePlugin) applyWebBabysitCancel(owner id.UserID) (status, detail string, retry bool) {
|
||||||
|
// No lock here, and that is not an oversight: performBabysitCancel takes it
|
||||||
|
// itself, because ITS Matrix caller does not. The opposite of the two above.
|
||||||
|
out, err := p.performBabysitCancel(owner)
|
||||||
|
if err != nil {
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errBabysitNoSitter):
|
||||||
|
status = "rejected_nothing_to_cancel"
|
||||||
|
}
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
// The DM prints the sitter's whole record of the stay; a verdict is one line
|
||||||
|
// under a button, so the web gets the fact and the page keeps its shape.
|
||||||
|
note := "Sitter dismissed. No refund — they were already here."
|
||||||
|
if out.PetName != "" {
|
||||||
|
note = fmt.Sprintf("Sitter dismissed. No refund. %s is back in your care.", out.PetName)
|
||||||
|
}
|
||||||
|
return "applied", note, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// advOrderPlainText strips the Matrix markdown out of a line reused as a web
|
||||||
|
// verdict. Pete renders the detail as text, so asterisks and backticks would show
|
||||||
|
// up literally. The command hints inside those backticks stay — a verdict that
|
||||||
|
// says to type `!expedition abandon` is telling the truth about where the other
|
||||||
|
// door is, and the web has no button for it yet.
|
||||||
|
func advOrderPlainText(s string) string {
|
||||||
|
out := make([]rune, 0, len(s))
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '*', '`':
|
||||||
|
continue
|
||||||
|
case '\n':
|
||||||
|
out = append(out, ' ')
|
||||||
|
default:
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the applied-order ledger --------------------------------------------------
|
||||||
|
|
||||||
|
// advOrderAlreadyApplied reports the verdict we filed for an order, if we have
|
||||||
|
// already applied it. This is the short-circuit that keeps a re-offered order from
|
||||||
|
// re-running its non-idempotent mutation.
|
||||||
|
func advOrderAlreadyApplied(guid string) (status, detail string, ok bool) {
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT status, detail FROM adv_applied_orders WHERE guid = ?`, guid,
|
||||||
|
).Scan(&status, &detail)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// A read failure sends us down the mutation path and risks re-running an
|
||||||
|
// extraction or a bout, so it is logged loudly. A transient one self-heals:
|
||||||
|
// the mutation is guarded by this same table, so the next poll reads it.
|
||||||
|
slog.Error("orders: applied-ledger read failed", "order", guid, "err", err)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return status, detail, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAdvApplied stamps an order as applied with the verdict we're about to
|
||||||
|
// file. OR IGNORE so a re-file that somehow reaches here can't error on the guid.
|
||||||
|
func recordAdvApplied(guid, status, detail string) error {
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT OR IGNORE INTO adv_applied_orders (guid, status, detail) VALUES (?, ?, ?)`,
|
||||||
|
guid, status, detail)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// W5: the web action queue's game-side half. These pin the two things that would
|
||||||
|
// actually hurt in prod — a replayed order re-running a non-idempotent action,
|
||||||
|
// and a refusal being reported as a transient fault (which parks the order and
|
||||||
|
// leaves the player staring at "asked for…" forever).
|
||||||
|
|
||||||
|
// TestAdvOrderLedgerShortCircuitsAReoffer is the regression for the whole class
|
||||||
|
// of bug this ledger exists for. A verdict-ack lost on the wire means Pete
|
||||||
|
// re-offers an order we have already applied; if that re-offer reached
|
||||||
|
// applyAdvOrder it would extract a run the player had resumed, or spend a bout
|
||||||
|
// they were saving.
|
||||||
|
func TestAdvOrderLedgerShortCircuitsAReoffer(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
|
||||||
|
if _, _, ok := advOrderAlreadyApplied("guid-never-seen"); ok {
|
||||||
|
t.Fatal("an unknown guid reported as already applied")
|
||||||
|
}
|
||||||
|
if err := recordAdvApplied("guid-1", "applied", "Out of the Goblin Warrens on day 3."); err != nil {
|
||||||
|
t.Fatalf("recordAdvApplied: %v", err)
|
||||||
|
}
|
||||||
|
status, detail, ok := advOrderAlreadyApplied("guid-1")
|
||||||
|
if !ok || status != "applied" || !strings.Contains(detail, "Goblin Warrens") {
|
||||||
|
t.Fatalf("ledger read = %q/%q ok=%v, want the stored verdict back", status, detail, ok)
|
||||||
|
}
|
||||||
|
// A second stamp on the same guid must not error or overwrite — the re-file
|
||||||
|
// path can reach it.
|
||||||
|
if err := recordAdvApplied("guid-1", "rejected_not_running", "nonsense"); err != nil {
|
||||||
|
t.Fatalf("re-record: %v", err)
|
||||||
|
}
|
||||||
|
if status, _, _ := advOrderAlreadyApplied("guid-1"); status != "applied" {
|
||||||
|
t.Fatalf("verdict changed under a re-record: %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractOrderRefusalsAreTerminal: neither refusal may come back as retry.
|
||||||
|
// A retried refusal never reaches a verdict, so the order sits pending forever
|
||||||
|
// and Pete's strip never stops saying "asked for…".
|
||||||
|
func TestExtractOrderRefusalsAreTerminal(t *testing.T) {
|
||||||
|
// W9: was setupZoneRunTestDB, which copies data/gogobee.db and t.Skip()s when
|
||||||
|
// it is missing — and that file is deleted after every local run, so this and
|
||||||
|
// the extraction test below have been green-by-skipping since W5a. Neither
|
||||||
|
// needs a prod row; startExpedition builds everything they touch.
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-extract-none:example.org")
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
|
||||||
|
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "g", Action: peteclient.AdvOrderExtract,
|
||||||
|
})
|
||||||
|
if retry {
|
||||||
|
t.Fatal("no-expedition extract asked for a retry; it must be terminal")
|
||||||
|
}
|
||||||
|
if status != "rejected_not_running" || detail == "" {
|
||||||
|
t.Fatalf("status = %q detail = %q, want rejected_not_running with prose", status, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractOrderIsTheSameExtraction: the web verb must run the game's own
|
||||||
|
// extraction, not a lookalike. The proof is the state the row lands in —
|
||||||
|
// 'extracting' (a resumable limbo) rather than 'abandoned' — plus the day burn
|
||||||
|
// and the log line the DM path writes.
|
||||||
|
func TestExtractOrderIsTheSameExtraction(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-extract-live:example.org")
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
|
||||||
|
if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{
|
||||||
|
Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1, PacksStandard: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("startExpedition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "g-extract", Action: peteclient.AdvOrderExtract,
|
||||||
|
})
|
||||||
|
if retry || status != "applied" {
|
||||||
|
t.Fatalf("extract = %q retry=%v, want applied", status, retry)
|
||||||
|
}
|
||||||
|
if !strings.Contains(detail, "resume") {
|
||||||
|
t.Fatalf("verdict %q never mentions the resume window, which is the whole point of an extraction", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbStatus string
|
||||||
|
var day int
|
||||||
|
if err := db.Get().QueryRow(
|
||||||
|
`SELECT status, current_day FROM dnd_expedition WHERE user_id = ?`, string(uid),
|
||||||
|
).Scan(&dbStatus, &day); err != nil {
|
||||||
|
t.Fatalf("read expedition: %v", err)
|
||||||
|
}
|
||||||
|
if dbStatus != ExpeditionStatusExtracting {
|
||||||
|
t.Fatalf("expedition status = %q, want %q — a web extract must be resumable like the command's",
|
||||||
|
dbStatus, ExpeditionStatusExtracting)
|
||||||
|
}
|
||||||
|
if day != 2 {
|
||||||
|
t.Fatalf("current_day = %d, want 2 — extraction burns the day", day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeJoinRefusalsAreTerminal covers the two refusals a bout can hit without
|
||||||
|
// running any combat: nothing camped, and a bout already spent today. Both must
|
||||||
|
// be terminal for the same reason as the extract refusals, and "already fought"
|
||||||
|
// especially — it is the one a double-click produces.
|
||||||
|
func TestSiegeJoinRefusalsAreTerminal(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@web-siege:example.org")
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
|
||||||
|
status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "g1", Action: peteclient.AdvOrderSiegeJoin,
|
||||||
|
})
|
||||||
|
if retry || status != "rejected_no_siege" {
|
||||||
|
t.Fatalf("no-boss bout = %q retry=%v, want rejected_no_siege", status, retry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camp a boss and spend the day's bout, then ask again.
|
||||||
|
now := time.Now().UTC()
|
||||||
|
bossID, err := insertWorldBoss("Grelloth", 3, 18000, now.Add(-time.Hour), now.Add(48*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insertWorldBoss: %v", err)
|
||||||
|
}
|
||||||
|
if err := createAdvCharacter(uid, "Rurina"); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
if err := upsertWorldBossContrib(bossID, uid, 250, today); err != nil {
|
||||||
|
t.Fatalf("upsertWorldBossContrib: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "g2", Action: peteclient.AdvOrderSiegeJoin,
|
||||||
|
})
|
||||||
|
if retry || status != "rejected_already_fought" {
|
||||||
|
t.Fatalf("second bout = %q retry=%v, want rejected_already_fought", status, retry)
|
||||||
|
}
|
||||||
|
if detail == "" {
|
||||||
|
t.Fatal("a refusal with no prose leaves the strip saying nothing useful")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownActionIsRejectedNotRetried: Pete validates the action before it ever
|
||||||
|
// queues one, so an unknown verb is a contract breach. Spinning on it would poll
|
||||||
|
// the same dead order every 15 seconds forever.
|
||||||
|
func TestUnknownActionIsRejectedNotRetried(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
status, _, retry := p.applyAdvOrder("@x:example.org", peteclient.AdvOrder{
|
||||||
|
GUID: "g", Action: "sell_house",
|
||||||
|
})
|
||||||
|
if retry || !strings.HasPrefix(status, "rejected_") {
|
||||||
|
t.Fatalf("unknown action = %q retry=%v, want a terminal rejection", status, retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdvOrderPlainText: the Siege verdict is the Matrix footer reused, and Pete
|
||||||
|
// renders a verdict as text — so the markdown has to come off or the player reads
|
||||||
|
// literal asterisks.
|
||||||
|
func TestAdvOrderPlainText(t *testing.T) {
|
||||||
|
got := advOrderPlainText("💥 You deal **412** damage. **Grelloth** has **5,800 / 18,000 HP** left.\nYou stagger out at 1 HP.")
|
||||||
|
if strings.Contains(got, "*") || strings.Contains(got, "\n") {
|
||||||
|
t.Fatalf("plain text still carries markup or a newline: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "412") || !strings.Contains(got, "Grelloth") {
|
||||||
|
t.Fatalf("plain text lost the facts: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── W5b: the three verbs that take arguments and spend coins ───────────────────
|
||||||
|
|
||||||
|
// webOrderTestChar builds a character solvent enough to outfit an expedition.
|
||||||
|
func webOrderTestChar(t *testing.T, uid id.UserID, level int, coins float64) *AdventurePlugin {
|
||||||
|
t.Helper()
|
||||||
|
if err := createAdvCharacter(uid, "weborder"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := &DnDCharacter{
|
||||||
|
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||||||
|
STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
|
||||||
|
HPMax: 30, HPCurrent: 30, ArmorClass: 14,
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
euro := &EuroPlugin{}
|
||||||
|
euro.ensureBalance(uid)
|
||||||
|
if coins > 0 {
|
||||||
|
euro.Credit(uid, coins, "test bankroll")
|
||||||
|
}
|
||||||
|
return &AdventurePlugin{euro: euro}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forged zone must buy nothing. Pete only ever offers what gogobee quoted it,
|
||||||
|
// but a quote is a stale snapshot and not a permission — so the order path
|
||||||
|
// re-resolves against availableZonesFor and refuses anything that isn't there.
|
||||||
|
func TestWebExpeditionStartReResolvesTheZone(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-start-forged:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "forged-zone", Action: peteclient.AdvOrderExpedition,
|
||||||
|
Params: &peteclient.AdvOrderParams{Zone: "dragons_lair", Loadout: "lean"},
|
||||||
|
})
|
||||||
|
if retry {
|
||||||
|
t.Fatal("a locked zone asked for a retry; it must be terminal")
|
||||||
|
}
|
||||||
|
if status != "rejected_zone_locked" {
|
||||||
|
t.Fatalf("status = %q, want rejected_zone_locked", status)
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != before {
|
||||||
|
t.Fatalf("a refused departure moved money: %.0f -> %.0f", before, after)
|
||||||
|
}
|
||||||
|
if exp, _ := getActiveExpedition(uid); exp != nil {
|
||||||
|
t.Fatal("a refused departure started an expedition anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown loadout is refused, never defaulted. Defaulting would spend coins on
|
||||||
|
// a pack size the player never picked.
|
||||||
|
func TestWebExpeditionStartRefusesAnUnknownLoadout(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-start-loadout:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
status, _, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "bad-loadout", Action: peteclient.AdvOrderExpedition,
|
||||||
|
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "enormous"},
|
||||||
|
})
|
||||||
|
if status != "rejected_unavailable" {
|
||||||
|
t.Fatalf("status = %q, want rejected_unavailable", status)
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != before {
|
||||||
|
t.Fatalf("a refused loadout moved money: %.0f -> %.0f", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The money test that matters: a re-offered order (verdict-ack lost before the
|
||||||
|
// ledger stamped it) must not charge twice, and must not answer "you're already
|
||||||
|
// on an expedition" for the expedition it just started.
|
||||||
|
func TestWebExpeditionStartChargesOnceOnAReoffer(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-start-idem:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
order := peteclient.AdvOrder{
|
||||||
|
GUID: "start-once", Action: peteclient.AdvOrderExpedition,
|
||||||
|
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "lean"},
|
||||||
|
}
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
status, _, retry := p.applyAdvOrder(uid, order)
|
||||||
|
if retry || status != "applied" {
|
||||||
|
t.Fatalf("first apply = %q retry=%v, want applied", status, retry)
|
||||||
|
}
|
||||||
|
afterFirst := p.euro.GetBalance(uid)
|
||||||
|
if afterFirst >= before {
|
||||||
|
t.Fatalf("outfitting cost nothing: %.0f -> %.0f", before, afterFirst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The re-offer. applyAdvOrder is reached directly here on purpose: the
|
||||||
|
// adv_applied_orders ledger would normally short-circuit it, and this asserts
|
||||||
|
// the layer *underneath* that guard is safe too.
|
||||||
|
status, _, retry = p.applyAdvOrder(uid, order)
|
||||||
|
if retry {
|
||||||
|
t.Fatal("the re-offer asked for a retry")
|
||||||
|
}
|
||||||
|
if status != "applied" {
|
||||||
|
t.Fatalf("re-offer = %q, want applied — the settled debit is what tells a "+
|
||||||
|
"replay apart from a player who really is already out", status)
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != afterFirst {
|
||||||
|
t.Fatalf("the re-offer charged again: %.0f -> %.0f", afterFirst, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Babysit: same replay contract, plus the two durations are the only two sold.
|
||||||
|
func TestWebBabysitChargesOnceAndSellsTwoDurations(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-sitter:example.org")
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
status, _, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "sitter-odd", Action: peteclient.AdvOrderBabysit,
|
||||||
|
Params: &peteclient.AdvOrderParams{Days: 3},
|
||||||
|
})
|
||||||
|
if status != "rejected_unavailable" {
|
||||||
|
t.Fatalf("3-day sitter = %q, want rejected_unavailable", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := peteclient.AdvOrder{
|
||||||
|
GUID: "sitter-once", Action: peteclient.AdvOrderBabysit,
|
||||||
|
Params: &peteclient.AdvOrderParams{Days: 7},
|
||||||
|
}
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
if status, _, _ := p.applyAdvOrder(uid, order); status != "applied" {
|
||||||
|
t.Fatalf("hire = %q, want applied", status)
|
||||||
|
}
|
||||||
|
afterFirst := p.euro.GetBalance(uid)
|
||||||
|
if afterFirst >= before {
|
||||||
|
t.Fatalf("the sitter worked for free: %.0f -> %.0f", before, afterFirst)
|
||||||
|
}
|
||||||
|
if status, _, _ := p.applyAdvOrder(uid, order); status != "applied" {
|
||||||
|
t.Fatalf("re-offer = %q, want applied", status)
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != afterFirst {
|
||||||
|
t.Fatalf("the re-offer charged again: %.0f -> %.0f", afterFirst, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal must never come back as retry: a retried refusal never reaches a
|
||||||
|
// verdict, so the order parks and the strip says "asked for…" forever.
|
||||||
|
func TestWebMoneyVerbRefusalsAreTerminal(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-broke:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 0)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
order peteclient.AdvOrder
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"broke departure", peteclient.AdvOrder{
|
||||||
|
GUID: "broke-1", Action: peteclient.AdvOrderExpedition,
|
||||||
|
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "heavy"},
|
||||||
|
}, "rejected_insufficient_funds"},
|
||||||
|
{"nothing to resume", peteclient.AdvOrder{
|
||||||
|
GUID: "resume-1", Action: peteclient.AdvOrderResume,
|
||||||
|
Params: &peteclient.AdvOrderParams{Loadout: "lean"},
|
||||||
|
}, "rejected_nothing_to_resume"},
|
||||||
|
{"broke sitter", peteclient.AdvOrder{
|
||||||
|
GUID: "broke-2", Action: peteclient.AdvOrderBabysit,
|
||||||
|
Params: &peteclient.AdvOrderParams{Days: 30},
|
||||||
|
}, "rejected_insufficient_funds"},
|
||||||
|
} {
|
||||||
|
status, detail, retry := p.applyAdvOrder(uid, tc.order)
|
||||||
|
if retry {
|
||||||
|
t.Fatalf("%s asked for a retry; it must be terminal", tc.name)
|
||||||
|
}
|
||||||
|
if status != tc.want {
|
||||||
|
t.Fatalf("%s = %q, want %q (detail %q)", tc.name, status, tc.want, detail)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(detail, "*`") {
|
||||||
|
t.Fatalf("%s verdict still carries Matrix markdown: %q", tc.name, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An order with no params at all is a contract breach, not a user mistake, and
|
||||||
|
// must be refused rather than defaulted into spending money.
|
||||||
|
func TestWebMoneyVerbsRefuseMissingParams(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-noparams:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
for _, action := range []string{
|
||||||
|
peteclient.AdvOrderExpedition, peteclient.AdvOrderResume, peteclient.AdvOrderBabysit,
|
||||||
|
} {
|
||||||
|
status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "np-" + action, Action: action})
|
||||||
|
if retry || status != "rejected_unavailable" {
|
||||||
|
t.Fatalf("%s with no params = %q retry=%v, want rejected_unavailable", action, status, retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != before {
|
||||||
|
t.Fatalf("a paramless order moved money: %.0f -> %.0f", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// W9: the three web verbs that undo something — abandon an expedition, walk out
|
||||||
|
// of somebody else's party, send the sitter home.
|
||||||
|
//
|
||||||
|
// The thing worth pinning here is not the verdict text, it is the LOCK. Each of
|
||||||
|
// these three now has a headless twin shared between a Matrix command and the web
|
||||||
|
// order path, and the two halves take advUserLock on opposite sides: the two
|
||||||
|
// expedition twins cannot take it (their Matrix caller holds it across the whole
|
||||||
|
// `!expedition` switch) and their web wrappers must, while the babysit twin takes
|
||||||
|
// it itself and its web wrapper must not.
|
||||||
|
//
|
||||||
|
// Getting either of those backwards does not fail loudly. advUserLock is a plain
|
||||||
|
// sync.Mutex, so a second acquire parks the goroutine forever with the deferred
|
||||||
|
// Unlock never running — which wedges every later !adventure / !expedition /
|
||||||
|
// !zone command from that player, not just the one that deadlocked. That is a
|
||||||
|
// bug that shipped once already (see TestExpeditionAliasesDoNotWedgeTheUserLock),
|
||||||
|
// so both directions are tested here.
|
||||||
|
//
|
||||||
|
// NOTE: these two lock tests HANG rather than fail on regression. The timeout is
|
||||||
|
// the assertion.
|
||||||
|
|
||||||
|
// TestUndoCommandsDoNotWedgeTheUserLock is the Matrix half: `!expedition abandon`
|
||||||
|
// and `!expedition leave` reach their twins with the lock already held, so a twin
|
||||||
|
// that took it itself would park here.
|
||||||
|
func TestUndoCommandsDoNotWedgeTheUserLock(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@w9-cmd-lock:example")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||||
|
|
||||||
|
for _, sub := range []string{"abandon", "leave"} {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||||
|
_ = p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, sub)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("!expedition %s never returned: its twin re-took advUserLock", sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUndoOrdersDoNotWedgeTheUserLock is the web half, and it checks the harder
|
||||||
|
// half of the same property: not just that the order returns, but that the lock
|
||||||
|
// is FREE afterwards. A wrapper that took the lock around a twin that also takes
|
||||||
|
// it would park inside applyAdvOrder; a wrapper that forgot to release would let
|
||||||
|
// the order finish and wedge the next command instead, which is the failure that
|
||||||
|
// would have been missed by only timing the call.
|
||||||
|
func TestUndoOrdersDoNotWedgeTheUserLock(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@w9-order-lock:example")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||||
|
|
||||||
|
for _, action := range []string{
|
||||||
|
peteclient.AdvOrderAbandon,
|
||||||
|
peteclient.AdvOrderLeave,
|
||||||
|
peteclient.AdvOrderBabysitCancel,
|
||||||
|
} {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||||
|
p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "g-" + action, Action: action})
|
||||||
|
// The lock must be back. Taking it here is what catches a wrapper that
|
||||||
|
// returned without unlocking.
|
||||||
|
mu := p.advUserLock(uid)
|
||||||
|
mu.Lock()
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("order %q never returned or never released advUserLock", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUndoOrderRefusalsAreTerminal: none of the three may come back as a retry.
|
||||||
|
// A retried refusal never reaches a verdict, so the order sits pending forever
|
||||||
|
// and the panel never stops saying "asked for…". Each also has to carry prose —
|
||||||
|
// the strip prefers gogobee's own sentence over its canned fallback, and an empty
|
||||||
|
// detail on a refusal is the one case where the page has nothing to show.
|
||||||
|
func TestUndoOrderRefusalsAreTerminal(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@w9-refusals:example")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||||
|
// A real character with nothing going on. Without one, babysit_cancel refuses
|
||||||
|
// with "no adventurer" and the sitter branch this is meant to cover is never
|
||||||
|
// reached — the first cut of this test passed the wrong assertion for that
|
||||||
|
// reason.
|
||||||
|
if err := createAdvCharacter(uid, "w9refusals"); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
action string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Nothing to abandon and nothing to leave are the same fact from two
|
||||||
|
// doors, and both are the plain "you aren't on one" answer.
|
||||||
|
{peteclient.AdvOrderAbandon, "rejected_not_running"},
|
||||||
|
{peteclient.AdvOrderLeave, "rejected_not_running"},
|
||||||
|
// No sitter is its own verdict rather than rejected_unavailable: the page
|
||||||
|
// says something specific about it, and "unavailable" reads as a fault.
|
||||||
|
{peteclient.AdvOrderBabysitCancel, "rejected_nothing_to_cancel"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "g-" + tc.action, Action: tc.action,
|
||||||
|
})
|
||||||
|
if retry {
|
||||||
|
t.Fatalf("%s asked for a retry on a refusal; it must be terminal", tc.action)
|
||||||
|
}
|
||||||
|
if status != tc.want {
|
||||||
|
t.Fatalf("%s status = %q, want %q", tc.action, status, tc.want)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(detail) == "" {
|
||||||
|
t.Fatalf("%s refused with no prose; the panel would have nothing to say", tc.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUndoVerdictsCarryNoMarkdown: every detail line these file is reused from a
|
||||||
|
// Matrix sentence, and Pete renders a verdict as text. An asterisk or a backtick
|
||||||
|
// left in it shows up literally under the button.
|
||||||
|
//
|
||||||
|
// The backticks matter more than they look: the leave refusal names `!extract`
|
||||||
|
// and `!expedition abandon`, which is the correct thing to say (the web now has a
|
||||||
|
// button for one of them and not the other), but it must not say it in markup.
|
||||||
|
func TestUndoVerdictsCarryNoMarkdown(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@w9-markdown:example")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||||
|
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||||
|
|
||||||
|
for _, action := range []string{
|
||||||
|
peteclient.AdvOrderAbandon,
|
||||||
|
peteclient.AdvOrderLeave,
|
||||||
|
peteclient.AdvOrderBabysitCancel,
|
||||||
|
} {
|
||||||
|
_, detail, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "g-md-" + action, Action: action})
|
||||||
|
if strings.ContainsAny(detail, "*`\n") {
|
||||||
|
t.Fatalf("%s verdict carries markdown or a newline: %q", action, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebAbandonIsTheGamesOwnAbandon: the web verb must run the real path, not a
|
||||||
|
// lookalike. The proof is the state the row lands in — no expedition left at all,
|
||||||
|
// as opposed to the 'extracting' limbo an extraction leaves behind — and that the
|
||||||
|
// verdict says what became of the supplies, which is the one thing a player who
|
||||||
|
// clicked the wrong button needs to be told.
|
||||||
|
func TestWebAbandonIsTheGamesOwnAbandon(t *testing.T) {
|
||||||
|
// setupEmptyTestDB, NOT setupZoneRunTestDB: the latter copies data/gogobee.db
|
||||||
|
// and t.Skip()s when it is missing, and that file is deleted after every local
|
||||||
|
// run — so a test written on it is green-by-skipping on any clean checkout.
|
||||||
|
// See the Decisions note in the plan's progress file; W5a's order tests were
|
||||||
|
// silently skipping for exactly this reason.
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@w9-abandon-live:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||||
|
if err := createAdvCharacter(uid, "w9abandon"); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||||
|
|
||||||
|
if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{
|
||||||
|
Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1, PacksStandard: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("startExpedition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "g-abandon", Action: peteclient.AdvOrderAbandon,
|
||||||
|
})
|
||||||
|
if retry || status != "applied" {
|
||||||
|
t.Fatalf("abandon = %q retry=%v detail=%q, want applied", status, retry, detail)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(detail), "supplies") {
|
||||||
|
t.Fatalf("verdict %q never says the supplies are gone, which is the difference from pulling out", detail)
|
||||||
|
}
|
||||||
|
if exp, _, err := activeExpeditionFor(uid); err != nil {
|
||||||
|
t.Fatalf("read expedition: %v", err)
|
||||||
|
} else if exp != nil {
|
||||||
|
t.Fatalf("expedition survived a web abandon with status %q", exp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the second click, which is what a stale page produces: a terminal
|
||||||
|
// refusal, never a retry and never a second abandon.
|
||||||
|
status, _, retry = p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "g-abandon-2", Action: peteclient.AdvOrderAbandon,
|
||||||
|
})
|
||||||
|
if retry || status != "rejected_not_running" {
|
||||||
|
t.Fatalf("re-abandon = %q retry=%v, want a terminal rejected_not_running", status, retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The realm snapshot: the world map, the hall of firsts, and the board.
|
||||||
|
//
|
||||||
|
// Everything Pete has shown so far is either the present moment (the roster, the
|
||||||
|
// Siege bar) or one thing that happened (a dispatch, a run log). None of it says
|
||||||
|
// what the realm *is* — that there are thirty-odd named places with a difficulty
|
||||||
|
// order, that some of them have never been beaten by anybody, and that the
|
||||||
|
// people playing have a history against them. That is what this carries.
|
||||||
|
//
|
||||||
|
// Snapshot semantics, like the roster and the Siege: pushed whole, replaces
|
||||||
|
// Pete's copy, dropped rather than retried on failure. The difference is the
|
||||||
|
// clock. The roster is a photograph of where people are standing and is worth
|
||||||
|
// re-taking every two minutes; a realm-first is a thing that happened once, ever,
|
||||||
|
// and re-deriving the whole ledger plus three aggregate scans at that rate would
|
||||||
|
// be pure waste. So this rides the same ticker at a much longer stride.
|
||||||
|
const (
|
||||||
|
// realmPushInterval — how often the realm is recomputed and pushed. The
|
||||||
|
// fastest-moving field in the whole snapshot is a zone's occupant list, and a
|
||||||
|
// ten-minute-old answer to "who is in the Sunken Vault" is still a true and
|
||||||
|
// useful one. Everything else moves on the scale of days.
|
||||||
|
realmPushInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// realmMaxOccupants bounds the per-zone occupant list. A realm has tens of
|
||||||
|
// players; this only stops a pathological case spooling a huge payload.
|
||||||
|
realmMaxOccupants = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// realmLastPush is when the realm snapshot last went out. Zero means never, so
|
||||||
|
// the first tick after start-up always pushes — an operator restarting the bot
|
||||||
|
// should not have to wait ten minutes to see whether the wire works.
|
||||||
|
var realmLastPush time.Time
|
||||||
|
|
||||||
|
// realmPushOK mirrors rosterPushOK: log the transitions and nothing else.
|
||||||
|
var realmPushOK bool
|
||||||
|
|
||||||
|
// pushRealm recomputes and sends the realm snapshot, at most once per
|
||||||
|
// realmPushInterval however often the ticker calls it.
|
||||||
|
func (p *AdventurePlugin) pushRealm() {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if !realmLastPush.IsZero() && now.Sub(realmLastPush) < realmPushInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := buildRealmSnapshot(now)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("realm: build snapshot failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := peteclient.PushRealm(ctx, snap); err != nil {
|
||||||
|
if realmPushOK {
|
||||||
|
slog.Warn("realm: push failed, realm pages will go stale on Pete", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("realm: push failed, dropping snapshot", "err", err)
|
||||||
|
}
|
||||||
|
realmPushOK = false
|
||||||
|
// Deliberately NOT stamping realmLastPush: a failed push should be retried
|
||||||
|
// on the next roster tick, not ten minutes from now. The stamp is a
|
||||||
|
// "we already told Pete this" marker, and we didn't.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
realmLastPush = now
|
||||||
|
if !realmPushOK {
|
||||||
|
slog.Info("realm: snapshot accepted by Pete — realm pages are publishing",
|
||||||
|
"zones", len(snap.Zones), "firsts", len(snap.Firsts), "standings", len(snap.Standings))
|
||||||
|
realmPushOK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// realmClearStats is the per-zone clear history, read in one pass.
|
||||||
|
type realmClearStats struct {
|
||||||
|
clears int
|
||||||
|
clearers int
|
||||||
|
firstUser id.UserID
|
||||||
|
firstClearAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRealmSnapshot assembles the whole realm from the game's own tables.
|
||||||
|
//
|
||||||
|
// The three aggregate reads are done up front and once each, keyed into maps,
|
||||||
|
// rather than per-zone or per-player: this runs against the live DB on a ticker
|
||||||
|
// and a query-per-zone loop over a registry that only grows is the kind of thing
|
||||||
|
// that is fine until it isn't.
|
||||||
|
func buildRealmSnapshot(now time.Time) (peteclient.RealmSnapshot, error) {
|
||||||
|
snap := peteclient.RealmSnapshot{SnapshotAt: now.Unix()}
|
||||||
|
|
||||||
|
clearsByZone, err := loadRealmClearStats()
|
||||||
|
if err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
occupantsByZone := loadRealmOccupants()
|
||||||
|
|
||||||
|
// ── Zones ───────────────────────────────────────────────────────────────
|
||||||
|
// zoneOrder is the design-doc ordering and is what the page draws in, so the
|
||||||
|
// realm reads the way it was designed rather than the way a map iterates.
|
||||||
|
for _, zid := range zoneOrder {
|
||||||
|
def, ok := dndZoneRegistry[zid]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
z := peteclient.RealmZone{
|
||||||
|
ID: string(def.ID),
|
||||||
|
Display: def.Display,
|
||||||
|
Tier: int(def.Tier),
|
||||||
|
LevelMin: def.LevelMin,
|
||||||
|
LevelMax: def.LevelMax,
|
||||||
|
Faction: def.Faction,
|
||||||
|
Atmosphere: def.Atmosphere,
|
||||||
|
Postgame: def.Tier == ZoneTierMythic,
|
||||||
|
Occupants: occupantsByZone[string(def.ID)],
|
||||||
|
}
|
||||||
|
if st, ok := clearsByZone[string(def.ID)]; ok {
|
||||||
|
z.Clears = st.clears
|
||||||
|
z.Clearers = st.clearers
|
||||||
|
z.FirstClearAt = st.firstClearAt
|
||||||
|
// An opted-out first-clearer keeps the claim and loses the identity —
|
||||||
|
// the Siege contributor rule, and for the same reason. Deleting the
|
||||||
|
// claim outright would leave the zone drawn as never-cleared, which is
|
||||||
|
// a false statement about the realm rather than a withheld one.
|
||||||
|
if !isNewsOptedOut(st.firstUser) {
|
||||||
|
z.FirstClearBy = charName(st.firstUser)
|
||||||
|
if z.FirstClearBy != "" {
|
||||||
|
z.FirstClearToken = eventToken(st.firstUser, "roster")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snap.Zones = append(snap.Zones, z)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.Firsts = loadRealmFirsts(clearsByZone)
|
||||||
|
|
||||||
|
// The three pages must not be able to contradict each other about the same
|
||||||
|
// zone. loadRealmFirsts derives the zone half of the hall from clearsByZone —
|
||||||
|
// the same map the tiles and the board use — but it also carries any ledger
|
||||||
|
// claim with no surviving run behind it, and that case would otherwise draw a
|
||||||
|
// place as never-beaten on the map while the hall named the year it fell.
|
||||||
|
//
|
||||||
|
// So an unbacked claim floors the clear count at one. Deliberately a floor and
|
||||||
|
// not an assignment: where the run history is intact it is the better answer
|
||||||
|
// and this only fills a hole. Nothing is attributed — the zone reads "cleared,
|
||||||
|
// by somebody", which is exactly what is known about it.
|
||||||
|
for i := range snap.Zones {
|
||||||
|
if snap.Zones[i].Clears > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range snap.Firsts {
|
||||||
|
if f.Kind == "zone" && f.Target == snap.Zones[i].ID {
|
||||||
|
snap.Zones[i].Clears = 1
|
||||||
|
snap.Zones[i].Clearers = 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.Standings, err = loadRealmStandings(clearsByZone)
|
||||||
|
if err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
return snap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRealmClearStats reads every zone's clear history in one pass: how many
|
||||||
|
// successful runs, how many distinct people managed it, and who did it first.
|
||||||
|
//
|
||||||
|
// MIN(completed_at) with a bare user_id column is SQLite's bare-column min/max
|
||||||
|
// rule — the user_id comes from the same row the minimum came from, so the
|
||||||
|
// (zone, first clearer, when) triple is internally consistent. backfillZoneFirsts
|
||||||
|
// relies on exactly this and has done since the news seam shipped.
|
||||||
|
//
|
||||||
|
// completed_at is selected raw as a string and parsed in Go rather than being
|
||||||
|
// wrapped in anything: modernc.org/sqlite rebuilds a time.Time from the column's
|
||||||
|
// declared type, and passing a DATETIME through MIN() alongside an aggregate is
|
||||||
|
// close enough to the COALESCE() trap that it is not worth finding out. The
|
||||||
|
// string always parses — it is written by SQLite's own CURRENT_TIMESTAMP.
|
||||||
|
//
|
||||||
|
// NOTE the absence of `AND abandoned = 0`, which looks like it belongs here and
|
||||||
|
// does not. `abandoned` does not mean "the player gave up" — it means the run
|
||||||
|
// ROW was retired, and abandonZoneRunByID exists specifically to retire a run
|
||||||
|
// whose boss is already dead when the expedition travels onward (see its comment
|
||||||
|
// in dnd_zone_run.go). In prod, 30 of the realm's 32 boss kills carry
|
||||||
|
// abandoned = 1. Filtering them out drew a map on which almost nothing had ever
|
||||||
|
// been beaten, while the hall of firsts — reading a different table — said six
|
||||||
|
// zones had been. boss_defeated = 1 is the clear, full stop.
|
||||||
|
func loadRealmClearStats() (map[string]realmClearStats, error) {
|
||||||
|
rows, err := db.Get().Query(`
|
||||||
|
SELECT zone_id,
|
||||||
|
COUNT(*) AS clears,
|
||||||
|
COUNT(DISTINCT user_id) AS clearers,
|
||||||
|
user_id,
|
||||||
|
MIN(completed_at)
|
||||||
|
FROM dnd_zone_run
|
||||||
|
WHERE boss_defeated = 1 AND completed_at IS NOT NULL
|
||||||
|
GROUP BY zone_id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := map[string]realmClearStats{}
|
||||||
|
for rows.Next() {
|
||||||
|
var zoneID, userID, completedAt string
|
||||||
|
var st realmClearStats
|
||||||
|
if err := rows.Scan(&zoneID, &st.clears, &st.clearers, &userID, &completedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st.firstUser = id.UserID(userID)
|
||||||
|
if ts, ok := parseSQLiteTime(completedAt); ok {
|
||||||
|
st.firstClearAt = ts.Unix()
|
||||||
|
}
|
||||||
|
out[zoneID] = st
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRealmOccupants answers "who is in there right now", per zone.
|
||||||
|
//
|
||||||
|
// Presence is dropped for an opted-out player rather than anonymised. Unlike a
|
||||||
|
// first clear it is not part of a tally that stops adding up without them, and
|
||||||
|
// it is the same live-location fact the run liveblog refuses to publish — an
|
||||||
|
// anonymous "somebody is in the Drowned Star" next to a roster showing exactly
|
||||||
|
// one person out on expedition is not an anonymisation.
|
||||||
|
//
|
||||||
|
// Errors are swallowed to nil: an unreadable expedition table should cost the
|
||||||
|
// realm map its occupant dots, not the whole page.
|
||||||
|
func loadRealmOccupants() map[string][]peteclient.RealmOccupant {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT user_id, zone_id, current_day FROM dnd_expedition WHERE status = 'active'`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("realm: occupants query", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type live struct {
|
||||||
|
uid id.UserID
|
||||||
|
zoneID string
|
||||||
|
day int
|
||||||
|
}
|
||||||
|
var found []live
|
||||||
|
for rows.Next() {
|
||||||
|
var uid, zoneID string
|
||||||
|
var day int
|
||||||
|
if err := rows.Scan(&uid, &zoneID, &day); err != nil {
|
||||||
|
slog.Error("realm: occupants scan", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found = append(found, live{id.UserID(uid), zoneID, day})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
slog.Error("realm: occupants rows", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names and opt-out are resolved only after the cursor is drained. The pool
|
||||||
|
// is one connection wide and charName reads the DB; resolving inside the loop
|
||||||
|
// is the deadlock W2a shipped and then had to fix.
|
||||||
|
out := map[string][]peteclient.RealmOccupant{}
|
||||||
|
for _, l := range found {
|
||||||
|
if isNewsOptedOut(l.uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := charName(l.uid)
|
||||||
|
if name == "" {
|
||||||
|
continue // never fall back to a Matrix handle on a public page
|
||||||
|
}
|
||||||
|
if len(out[l.zoneID]) >= realmMaxOccupants {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[l.zoneID] = append(out[l.zoneID], peteclient.RealmOccupant{
|
||||||
|
Token: eventToken(l.uid, "roster"),
|
||||||
|
Name: name,
|
||||||
|
Level: charLevel(l.uid),
|
||||||
|
Day: l.day,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for zid := range out {
|
||||||
|
sort.Slice(out[zid], func(i, j int) bool { return out[zid][i].Name < out[zid][j].Name })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRealmFirsts renders news_realm_firsts as a history book.
|
||||||
|
//
|
||||||
|
// The ledger stores only (kind, target, first_at) — it exists to tier a dispatch,
|
||||||
|
// not to remember who. The holder is recovered here from the game's own history:
|
||||||
|
// a zone first from the earliest boss-defeating run, a treasure first from the
|
||||||
|
// earliest surviving row in adventure_treasures. Both can come back empty — a
|
||||||
|
// treasure that was found and later discarded leaves no owner anywhere — and an
|
||||||
|
// unattributed first is rendered as one rather than dropped. It still happened.
|
||||||
|
// loadRealmFirsts renders the hall of firsts, and it takes the clear stats
|
||||||
|
// rather than reading the ledger alone, for two reasons that only showed up
|
||||||
|
// against real prod data:
|
||||||
|
//
|
||||||
|
// 1. news_realm_firsts is INCOMPLETE for zones. It has only been written since
|
||||||
|
// the news seam went live, and the one-shot that seeded it filtered on
|
||||||
|
// `abandoned = 0` — the same wrong filter loadRealmClearStats documents — so
|
||||||
|
// it missed every zone whose clears were all retired runs. In prod it holds 6
|
||||||
|
// zones where the run history knows 9.
|
||||||
|
// 2. Its first_at is when the CLAIM was recorded, not when the thing happened.
|
||||||
|
// Every backfilled row in prod carries the same timestamp: the minute the
|
||||||
|
// backfill ran. A history book dated by when somebody wrote it down is not
|
||||||
|
// much of a history book.
|
||||||
|
//
|
||||||
|
// So the zone half is derived from the run history, which is complete and
|
||||||
|
// correctly dated, and the ledger supplies the kinds the run history knows
|
||||||
|
// nothing about (treasures, and whatever ships next) plus any zone claim with no
|
||||||
|
// surviving run behind it. That also makes the hall agree with the board by
|
||||||
|
// construction: both count a zone-first as "you were the first to clear it".
|
||||||
|
func loadRealmFirsts(clearsByZone map[string]realmClearStats) []peteclient.RealmFirst {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT kind, target, first_at FROM news_realm_firsts ORDER BY first_at ASC`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("realm: firsts query", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []peteclient.RealmFirst
|
||||||
|
for rows.Next() {
|
||||||
|
var f peteclient.RealmFirst
|
||||||
|
if err := rows.Scan(&f.Kind, &f.Target, &f.AtUnix); err != nil {
|
||||||
|
slog.Error("realm: firsts scan", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
slog.Error("realm: firsts rows", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same discipline as the occupants: the cursor is closed before anything
|
||||||
|
// else touches the database.
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Drop the ledger's zone rows wherever the run history has the same zone —
|
||||||
|
// it is the better record of both who and when. A claim with no run behind it
|
||||||
|
// survives, unattributed, and is what floors that zone's clear count in
|
||||||
|
// buildRealmSnapshot.
|
||||||
|
kept := out[:0]
|
||||||
|
for _, f := range out {
|
||||||
|
if f.Kind == "zone" {
|
||||||
|
if _, ok := clearsByZone[f.Target]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept = append(kept, f)
|
||||||
|
}
|
||||||
|
out = kept
|
||||||
|
|
||||||
|
// The zone half, from the authority the map and the board also use.
|
||||||
|
for zoneID, st := range clearsByZone {
|
||||||
|
zone := zoneOrFallback(ZoneID(zoneID))
|
||||||
|
f := peteclient.RealmFirst{
|
||||||
|
Kind: "zone",
|
||||||
|
Target: zoneID,
|
||||||
|
Display: zone.Display,
|
||||||
|
Tier: int(zone.Tier),
|
||||||
|
AtUnix: st.firstClearAt,
|
||||||
|
}
|
||||||
|
if !isNewsOptedOut(st.firstUser) {
|
||||||
|
if name := charName(st.firstUser); name != "" {
|
||||||
|
f.Holder = name
|
||||||
|
f.Token = eventToken(st.firstUser, "roster")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range out {
|
||||||
|
switch out[i].Kind {
|
||||||
|
case "zone":
|
||||||
|
if out[i].Display == "" {
|
||||||
|
zone := zoneOrFallback(ZoneID(out[i].Target))
|
||||||
|
out[i].Display = zone.Display
|
||||||
|
out[i].Tier = int(zone.Tier)
|
||||||
|
out[i].Holder, out[i].Token = realmFirstZoneHolder(out[i].Target)
|
||||||
|
}
|
||||||
|
case "treasure":
|
||||||
|
if def := lookupAdvTreasureDef(out[i].Target); def != nil {
|
||||||
|
out[i].Display = def.Name
|
||||||
|
out[i].Tier = def.Tier
|
||||||
|
} else {
|
||||||
|
out[i].Display = out[i].Target
|
||||||
|
}
|
||||||
|
out[i].Holder, out[i].Token = realmFirstTreasureHolder(out[i].Target)
|
||||||
|
default:
|
||||||
|
// A kind nobody has taught this function about still belongs in the
|
||||||
|
// hall — it is a genuine realm-first and the ledger says so. It just
|
||||||
|
// arrives with the raw target as its name, which is the same
|
||||||
|
// degrade-don't-drop rule the unknown event_type inversion settled on.
|
||||||
|
out[i].Display = out[i].Target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest first: the order it happened in. Pete regroups it newest-year-first
|
||||||
|
// for the page, but the wire carries history in history's order.
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].AtUnix != out[j].AtUnix {
|
||||||
|
return out[i].AtUnix < out[j].AtUnix
|
||||||
|
}
|
||||||
|
return out[i].Target < out[j].Target
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// realmFirstZoneHolder names the earliest clearer of a zone. Returns ("", "")
|
||||||
|
// when the run history no longer has one, and ("Name", "") when it does but the
|
||||||
|
// player has opted out — the claim survives the anonymisation, the link does not.
|
||||||
|
func realmFirstZoneHolder(zoneID string) (name, token string) {
|
||||||
|
var userID string
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT user_id
|
||||||
|
FROM dnd_zone_run
|
||||||
|
WHERE zone_id = ? AND boss_defeated = 1 AND completed_at IS NOT NULL
|
||||||
|
ORDER BY completed_at ASC
|
||||||
|
LIMIT 1`, zoneID).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
slog.Error("realm: zone-first holder", "zone", zoneID, "err", err)
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
uid := id.UserID(userID)
|
||||||
|
if isNewsOptedOut(uid) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
name = charName(uid)
|
||||||
|
if name == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return name, eventToken(uid, "roster")
|
||||||
|
}
|
||||||
|
|
||||||
|
// realmFirstTreasureHolder names the earliest holder of a treasure key. A
|
||||||
|
// treasure writes one row per bonus, so the MIN is over what may be several rows
|
||||||
|
// for the same acquisition; that is fine, they share a timestamp.
|
||||||
|
func realmFirstTreasureHolder(key string) (name, token string) {
|
||||||
|
var userID string
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT user_id
|
||||||
|
FROM adventure_treasures
|
||||||
|
WHERE treasure_key = ?
|
||||||
|
ORDER BY acquired_at ASC
|
||||||
|
LIMIT 1`, key).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
slog.Error("realm: treasure-first holder", "key", key, "err", err)
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
uid := id.UserID(userID)
|
||||||
|
if isNewsOptedOut(uid) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
name = charName(uid)
|
||||||
|
if name == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return name, eventToken(uid, "roster")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRealmStandings builds the board: one line per living, named, opted-in
|
||||||
|
// adventurer, every number a lifetime total.
|
||||||
|
//
|
||||||
|
// It walks player_meta the way buildRosterSnapshot does, and for the same
|
||||||
|
// reason — that is the list of people who exist, and a standings table assembled
|
||||||
|
// by grouping the run history instead would silently include characters that have
|
||||||
|
// since been deleted or never finished setup.
|
||||||
|
func loadRealmStandings(clearsByZone map[string]realmClearStats) ([]peteclient.RealmStanding, error) {
|
||||||
|
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var uids []id.UserID
|
||||||
|
for rows.Next() {
|
||||||
|
var uid string
|
||||||
|
if err := rows.Scan(&uid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uids = append(uids, id.UserID(uid))
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Who holds how many realm-firsts, from the same authority the zone column
|
||||||
|
// uses — so a zone's "first cleared by X" and X's firsts count can never
|
||||||
|
// disagree with each other.
|
||||||
|
firstsBy := map[id.UserID]int{}
|
||||||
|
for _, st := range clearsByZone {
|
||||||
|
firstsBy[st.firstUser]++
|
||||||
|
}
|
||||||
|
|
||||||
|
perZone, err := loadRealmPlayerClears()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
siege := loadRealmSiegeTotals()
|
||||||
|
|
||||||
|
var out []peteclient.RealmStanding
|
||||||
|
for _, uid := range uids {
|
||||||
|
if isNewsOptedOut(uid) {
|
||||||
|
continue // the board omits an opted-out player outright, as it always has
|
||||||
|
}
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil || c == nil || c.PendingSetup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := charName(uid)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := peteclient.RealmStanding{
|
||||||
|
Token: eventToken(uid, "roster"),
|
||||||
|
Name: name,
|
||||||
|
Level: c.Level,
|
||||||
|
ClassRace: classRaceLabel(c),
|
||||||
|
Firsts: firstsBy[uid],
|
||||||
|
SiegeDamage: siege[uid].damage,
|
||||||
|
SiegeFights: siege[uid].fights,
|
||||||
|
}
|
||||||
|
for zoneID, n := range perZone[uid] {
|
||||||
|
s.Clears += n
|
||||||
|
s.Zones++
|
||||||
|
if t := int(zoneOrFallback(ZoneID(zoneID)).Tier); t > s.DeepestTier {
|
||||||
|
s.DeepestTier = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranked here, not on Pete: the ordering is a statement about the game
|
||||||
|
// ("deepest tier beaten, then how much of the realm you have beaten"), and
|
||||||
|
// the game is the thing that gets to make it. Name breaks the tie so the
|
||||||
|
// board is stable between snapshots that are otherwise identical.
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
a, b := out[i], out[j]
|
||||||
|
switch {
|
||||||
|
case a.DeepestTier != b.DeepestTier:
|
||||||
|
return a.DeepestTier > b.DeepestTier
|
||||||
|
case a.Zones != b.Zones:
|
||||||
|
return a.Zones > b.Zones
|
||||||
|
case a.Clears != b.Clears:
|
||||||
|
return a.Clears > b.Clears
|
||||||
|
case a.Level != b.Level:
|
||||||
|
return a.Level > b.Level
|
||||||
|
default:
|
||||||
|
return a.Name < b.Name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRealmPlayerClears returns clears[user][zone] = count, in one pass.
|
||||||
|
func loadRealmPlayerClears() (map[id.UserID]map[string]int, error) {
|
||||||
|
rows, err := db.Get().Query(`
|
||||||
|
SELECT user_id, zone_id, COUNT(*)
|
||||||
|
FROM dnd_zone_run
|
||||||
|
WHERE boss_defeated = 1 AND completed_at IS NOT NULL
|
||||||
|
GROUP BY user_id, zone_id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := map[id.UserID]map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var uid, zoneID string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&uid, &zoneID, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u := id.UserID(uid)
|
||||||
|
if out[u] == nil {
|
||||||
|
out[u] = map[string]int{}
|
||||||
|
}
|
||||||
|
out[u][zoneID] = n
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type realmSiegeTotal struct{ damage, fights int }
|
||||||
|
|
||||||
|
// loadRealmSiegeTotals sums every Siege a player has ever turned up to. Across
|
||||||
|
// all bosses, not just the live one — the war room already shows the current
|
||||||
|
// muster, and what the board is for is the person who has shown up to all six.
|
||||||
|
func loadRealmSiegeTotals() map[id.UserID]realmSiegeTotal {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT user_id, SUM(damage), SUM(fights) FROM world_boss_contrib GROUP BY user_id`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("realm: siege totals query", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := map[id.UserID]realmSiegeTotal{}
|
||||||
|
for rows.Next() {
|
||||||
|
var uid string
|
||||||
|
var damage, fights int
|
||||||
|
if err := rows.Scan(&uid, &damage, &fights); err != nil {
|
||||||
|
slog.Error("realm: siege totals scan", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out[id.UserID(uid)] = realmSiegeTotal{damage, fights}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
slog.Error("realm: siege totals rows", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedRealmFixture builds a small but realistic realm: two players, three
|
||||||
|
// cleared runs across two zones, one live expedition, and the realm-first ledger
|
||||||
|
// that the zone clears would have seeded.
|
||||||
|
func seedRealmFixture(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(dir); err != nil {
|
||||||
|
t.Fatalf("db.Init: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
|
||||||
|
db.Exec("seed josie", `INSERT INTO player_meta (user_id, display_name, alive) VALUES (?, ?, 1)`,
|
||||||
|
"@josie:x", "Josie")
|
||||||
|
db.Exec("seed quack", `INSERT INTO player_meta (user_id, display_name, alive) VALUES (?, ?, 1)`,
|
||||||
|
"@quack:x", "Quack")
|
||||||
|
|
||||||
|
// The board walks player_meta and then loads a character, so both halves have
|
||||||
|
// to exist: a player_meta row with no character is somebody who never finished
|
||||||
|
// setup, and standings correctly leaves them off.
|
||||||
|
for _, c := range []*DnDCharacter{
|
||||||
|
{UserID: "@josie:x", Race: RaceHuman, Class: ClassFighter, Level: 12,
|
||||||
|
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||||
|
HPMax: 120, HPCurrent: 120, ArmorClass: 18},
|
||||||
|
{UserID: "@quack:x", Race: RaceElf, Class: ClassMage, Level: 8,
|
||||||
|
STR: 8, DEX: 16, CON: 12, INT: 18, WIS: 12, CHA: 10,
|
||||||
|
HPMax: 48, HPCurrent: 48, ArmorClass: 12},
|
||||||
|
} {
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatalf("SaveDnDCharacter(%s): %v", c.UserID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Josie cleared the Warrens first, then again; Quack cleared them later. Only
|
||||||
|
// Josie has been through the Crypt — and it is a deeper tier, which is what
|
||||||
|
// makes the board's depth-before-breadth ordering testable.
|
||||||
|
for _, r := range []struct {
|
||||||
|
runID, user, zone, at string
|
||||||
|
}{
|
||||||
|
{"r1", "@josie:x", string(ZoneGoblinWarrens), "2026-01-10 12:00:00"},
|
||||||
|
{"r2", "@quack:x", string(ZoneGoblinWarrens), "2026-03-02 12:00:00"},
|
||||||
|
{"r3", "@josie:x", string(ZoneGoblinWarrens), "2026-04-05 12:00:00"},
|
||||||
|
{"r4", "@josie:x", string(ZoneCryptValdris), "2026-05-01 12:00:00"},
|
||||||
|
} {
|
||||||
|
db.Exec("seed run", `INSERT INTO dnd_zone_run
|
||||||
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
||||||
|
VALUES (?, ?, ?, 6, 1, 0, ?)`, r.runID, r.user, r.zone, r.at)
|
||||||
|
}
|
||||||
|
// A retired-but-won run: boss_defeated = 1 with abandoned = 1. This IS a
|
||||||
|
// clear — `abandoned` means the run row was retired (the expedition travelled
|
||||||
|
// on after the kill), not that anybody gave up, and in prod it is how 30 of
|
||||||
|
// the realm's 32 boss kills are stored. The fixture carries one so the
|
||||||
|
// aggregate can never quietly go back to filtering them out.
|
||||||
|
db.Exec("seed retired-win", `INSERT INTO dnd_zone_run
|
||||||
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
||||||
|
VALUES ('r5', '@quack:x', 'crypt_valdris', 6, 1, 1, '2026-05-02 12:00:00')`)
|
||||||
|
// A genuinely unfinished run: no boss, no completion. Not a clear.
|
||||||
|
db.Exec("seed inflight", `INSERT INTO dnd_zone_run
|
||||||
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
||||||
|
VALUES ('r6', '@quack:x', 'crypt_valdris', 6, 0, 0, NULL)`)
|
||||||
|
|
||||||
|
claimRealmFirst("zone", string(ZoneGoblinWarrens))
|
||||||
|
claimRealmFirst("zone", string(ZoneCryptValdris))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealmClearStatsPickTheEarliestClearer is the load-bearing query on the
|
||||||
|
// whole map: "who first got through here" is the single most interesting fact a
|
||||||
|
// zone has, and it has to be the person who was actually first.
|
||||||
|
//
|
||||||
|
// It leans on SQLite's bare-column min/max rule — the user_id comes from the same
|
||||||
|
// row MIN(completed_at) came from — which is the same thing backfillZoneFirsts
|
||||||
|
// has relied on since the news seam shipped. If that ever stopped holding, this
|
||||||
|
// would attribute somebody else's conquest to whoever the grouping happened to
|
||||||
|
// land on, silently.
|
||||||
|
func TestRealmClearStatsPickTheEarliestClearer(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
|
||||||
|
stats, err := loadRealmClearStats()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadRealmClearStats: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
warrens, ok := stats["goblin_warrens"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no stats for goblin_warrens")
|
||||||
|
}
|
||||||
|
if warrens.clears != 3 {
|
||||||
|
t.Errorf("warrens clears = %d, want 3", warrens.clears)
|
||||||
|
}
|
||||||
|
if warrens.clearers != 2 {
|
||||||
|
t.Errorf("warrens clearers = %d, want 2", warrens.clearers)
|
||||||
|
}
|
||||||
|
if warrens.firstUser != id.UserID("@josie:x") {
|
||||||
|
t.Errorf("warrens first clearer = %q, want @josie:x — the earliest run didn't win", warrens.firstUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two clears: Josie's, and Quack's retired-but-won run. The in-flight run is
|
||||||
|
// correctly excluded. A regression to `AND abandoned = 0` shows up here as 1.
|
||||||
|
crypt := stats["crypt_valdris"]
|
||||||
|
if crypt.clears != 2 {
|
||||||
|
t.Errorf("crypt clears = %d, want 2 — a won-then-retired run is a clear, "+
|
||||||
|
"and an in-flight one is not", crypt.clears)
|
||||||
|
}
|
||||||
|
if crypt.firstUser != id.UserID("@josie:x") {
|
||||||
|
t.Errorf("crypt first clearer = %q, want @josie:x", crypt.firstUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOptedOutFirstClearerIsAnonymisedNotErased. The Siege contributor rule,
|
||||||
|
// applied to the map: deleting an opted-out clearer's claim would leave the zone
|
||||||
|
// drawn as never-cleared, and "nobody has ever come out of there" is the most
|
||||||
|
// dramatic thing the page can say. Saying it falsely because somebody chose
|
||||||
|
// privacy would be worse than saying nothing.
|
||||||
|
func TestOptedOutFirstClearerIsAnonymisedNotErased(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
setNewsOptout(id.UserID("@josie:x"), true)
|
||||||
|
|
||||||
|
snap, err := buildRealmSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRealmSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var warrens *struct {
|
||||||
|
clears int
|
||||||
|
by, token string
|
||||||
|
}
|
||||||
|
for _, z := range snap.Zones {
|
||||||
|
if z.ID == "goblin_warrens" {
|
||||||
|
warrens = &struct {
|
||||||
|
clears int
|
||||||
|
by, token string
|
||||||
|
}{z.Clears, z.FirstClearBy, z.FirstClearToken}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if warrens == nil {
|
||||||
|
t.Fatal("goblin_warrens is not in the snapshot at all")
|
||||||
|
}
|
||||||
|
if warrens.clears != 3 {
|
||||||
|
t.Errorf("clears = %d, want 3 — an opt-out deleted the town's history", warrens.clears)
|
||||||
|
}
|
||||||
|
if warrens.by != "" || warrens.token != "" {
|
||||||
|
t.Errorf("opted-out clearer still named: by=%q token=%q", warrens.by, warrens.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOptedOutPlayerLeavesTheBoardEntirely. Standings follow the board's rule,
|
||||||
|
// not the Siege's: an opted-out player is omitted outright. Their level, class
|
||||||
|
// and clear count would re-identify them, and unlike a siege contribution there
|
||||||
|
// is no shared total that stops adding up without them.
|
||||||
|
func TestOptedOutPlayerLeavesTheBoardEntirely(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
setNewsOptout(id.UserID("@quack:x"), true)
|
||||||
|
|
||||||
|
snap, err := buildRealmSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRealmSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range snap.Standings {
|
||||||
|
if s.Name == "Quack" {
|
||||||
|
t.Fatal("an opted-out player is still on the standings board")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOccupantsDropOptedOutPlayers. Presence is the strictest case on the page
|
||||||
|
// and deliberately stricter than a first clear: "who is in the Crypt of Valdris
|
||||||
|
// right now" is the live-location fact the run liveblog refuses to publish at
|
||||||
|
// all, so an opted-out player is dropped rather than anonymised.
|
||||||
|
func TestOccupantsDropOptedOutPlayers(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
db.Exec("seed expedition", `INSERT INTO dnd_expedition
|
||||||
|
(expedition_id, user_id, zone_id, status, current_day)
|
||||||
|
VALUES ('e1', '@josie:x', 'crypt_valdris', 'active', 3)`)
|
||||||
|
|
||||||
|
if occ := loadRealmOccupants(); len(occ["crypt_valdris"]) != 1 {
|
||||||
|
t.Fatalf("opted-in occupant missing: %+v", occ)
|
||||||
|
} else if occ["crypt_valdris"][0].Name != "Josie" || occ["crypt_valdris"][0].Day != 3 {
|
||||||
|
t.Errorf("occupant = %+v, want Josie on day 3", occ["crypt_valdris"][0])
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewsOptout(id.UserID("@josie:x"), true)
|
||||||
|
if occ := loadRealmOccupants(); len(occ["crypt_valdris"]) != 0 {
|
||||||
|
t.Errorf("opted-out player still shows as standing in a zone: %+v", occ["crypt_valdris"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStandingsCountFirstsFromTheSameAuthorityTheMapDoes. A zone's "first
|
||||||
|
// through: Josie" and Josie's own firsts count come off one map in one pass, so
|
||||||
|
// the two can never disagree — which they would if the board recounted the
|
||||||
|
// ledger itself and the two queries drifted.
|
||||||
|
func TestStandingsCountFirstsFromTheSameAuthorityTheMapDoes(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
|
||||||
|
snap, err := buildRealmSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRealmSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstsByName := map[string]int{}
|
||||||
|
for _, s := range snap.Standings {
|
||||||
|
firstsByName[s.Name] = s.Firsts
|
||||||
|
}
|
||||||
|
// Josie was first through both zones; Quack was first through neither.
|
||||||
|
if firstsByName["Josie"] != 2 {
|
||||||
|
t.Errorf("Josie holds %d firsts, want 2", firstsByName["Josie"])
|
||||||
|
}
|
||||||
|
if firstsByName["Quack"] != 0 {
|
||||||
|
t.Errorf("Quack holds %d firsts, want 0", firstsByName["Quack"])
|
||||||
|
}
|
||||||
|
|
||||||
|
named := 0
|
||||||
|
for _, z := range snap.Zones {
|
||||||
|
if z.FirstClearBy == "Josie" {
|
||||||
|
named++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if named != firstsByName["Josie"] {
|
||||||
|
t.Errorf("the map names Josie on %d zones but the board credits her with %d — "+
|
||||||
|
"the two disagree about the same fact", named, firstsByName["Josie"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStandingsRankDeepestFirst. The ordering is the game's statement about what
|
||||||
|
// it values, and Pete renders it without renumbering — so it has to be right
|
||||||
|
// here. Depth beats breadth: somebody who has put down a Tier 5 boss is ahead of
|
||||||
|
// somebody who has cleared the whole of Tier 1 forty times.
|
||||||
|
func TestStandingsRankDeepestFirst(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
|
||||||
|
rows, err := loadRealmStandings(map[string]realmClearStats{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadRealmStandings: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) < 2 {
|
||||||
|
t.Fatalf("got %d standings rows, want 2", len(rows))
|
||||||
|
}
|
||||||
|
for i := 1; i < len(rows); i++ {
|
||||||
|
a, b := rows[i-1], rows[i]
|
||||||
|
if a.DeepestTier < b.DeepestTier {
|
||||||
|
t.Errorf("row %d (T%d) sorts above row %d (T%d) — the board is not deepest-first",
|
||||||
|
i-1, a.DeepestTier, i, b.DeepestTier)
|
||||||
|
}
|
||||||
|
if a.DeepestTier == b.DeepestTier && a.Zones < b.Zones {
|
||||||
|
t.Errorf("equal depth but row %d covers %d zones above row %d's %d",
|
||||||
|
i-1, a.Zones, i, b.Zones)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirstsLedgerIsRenderedNotJustCounted. news_realm_firsts has existed since
|
||||||
|
// the news seam shipped and has only ever been used to decide a dispatch tier —
|
||||||
|
// the ledger itself was never read back. This is the whole point of the hall: it
|
||||||
|
// is a history book, and every row needs a name and a date on it.
|
||||||
|
func TestFirstsLedgerIsRenderedNotJustCounted(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
|
||||||
|
stats, err := loadRealmClearStats()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadRealmClearStats: %v", err)
|
||||||
|
}
|
||||||
|
firsts := loadRealmFirsts(stats)
|
||||||
|
if len(firsts) != 2 {
|
||||||
|
t.Fatalf("got %d firsts, want 2", len(firsts))
|
||||||
|
}
|
||||||
|
// Oldest first, which is the order it happened in.
|
||||||
|
if firsts[0].Target != "goblin_warrens" {
|
||||||
|
t.Errorf("ledger order starts with %q, want goblin_warrens", firsts[0].Target)
|
||||||
|
}
|
||||||
|
for _, f := range firsts {
|
||||||
|
if f.Display == "" {
|
||||||
|
t.Errorf("first %q has no display name — it would render as a blank row", f.Target)
|
||||||
|
}
|
||||||
|
if f.Holder != "Josie" {
|
||||||
|
t.Errorf("first %q holder = %q, want Josie (she cleared both zones first)", f.Target, f.Holder)
|
||||||
|
}
|
||||||
|
if f.Token == "" {
|
||||||
|
t.Errorf("first %q has a holder but no token, so the hall can't link to them", f.Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnrecoverableFirstStillGetsAnEntry. The ledger records (kind, target,
|
||||||
|
// first_at) and nothing else; the holder is recovered at push time from the run
|
||||||
|
// history. A treasure found and later discarded leaves no owner anywhere, and
|
||||||
|
// that entry has to survive as an unattributed first rather than vanish — it
|
||||||
|
// still happened, and the hall is a record of what happened.
|
||||||
|
func TestUnrecoverableFirstStillGetsAnEntry(t *testing.T) {
|
||||||
|
seedRealmFixture(t)
|
||||||
|
claimRealmFirst("treasure", "a_hat_nobody_kept")
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
stats, err := loadRealmClearStats()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadRealmClearStats: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range loadRealmFirsts(stats) {
|
||||||
|
if f.Target == "a_hat_nobody_kept" {
|
||||||
|
found = true
|
||||||
|
if f.Holder != "" {
|
||||||
|
t.Errorf("holder = %q, want empty — nothing in the game knows who had it", f.Holder)
|
||||||
|
}
|
||||||
|
if f.Display == "" {
|
||||||
|
t.Error("an unrecoverable first got no display name at all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("a first with no recoverable holder was dropped from the ledger entirely")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealmPushIsRateLimitedBelowTheRosterTick. The realm rides the 2-minute
|
||||||
|
// roster ticker but is aggregate scans over the whole run history, and none of
|
||||||
|
// it moves at roster speed. The self-limit is the thing that makes riding that
|
||||||
|
// ticker acceptable, so it is worth pinning — and so is the other half: a FAILED
|
||||||
|
// push must not stamp the clock, or an outage would be followed by ten minutes
|
||||||
|
// of silence instead of a retry on the next tick.
|
||||||
|
func TestRealmPushIsRateLimitedBelowTheRosterTick(t *testing.T) {
|
||||||
|
if realmPushInterval <= rosterTickInterval {
|
||||||
|
t.Fatalf("realmPushInterval (%v) is not longer than the roster tick (%v) — "+
|
||||||
|
"the realm would be recomputed every tick", realmPushInterval, rosterTickInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate itself: zero means never-pushed and must always go.
|
||||||
|
realmLastPush = time.Time{}
|
||||||
|
t.Cleanup(func() { realmLastPush = time.Time{} })
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if !realmLastPush.IsZero() {
|
||||||
|
t.Fatal("fixture broken")
|
||||||
|
}
|
||||||
|
// A stamp inside the window suppresses; one outside it does not.
|
||||||
|
realmLastPush = now.Add(-realmPushInterval / 2)
|
||||||
|
if now.Sub(realmLastPush) >= realmPushInterval {
|
||||||
|
t.Error("a push half an interval old is not being suppressed")
|
||||||
|
}
|
||||||
|
realmLastPush = now.Add(-realmPushInterval - time.Minute)
|
||||||
|
if now.Sub(realmLastPush) < realmPushInterval {
|
||||||
|
t.Error("a push older than the interval is still being suppressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,18 @@ func (p *AdventurePlugin) peteRosterTicker() {
|
|||||||
}
|
}
|
||||||
p.pushRoster()
|
p.pushRoster()
|
||||||
p.pushDetails()
|
p.pushDetails()
|
||||||
|
p.pushSiege()
|
||||||
|
// Self-rate-limited to realmPushInterval: the realm is aggregate scans
|
||||||
|
// over the whole run history and none of it moves at roster speed.
|
||||||
|
p.pushRealm()
|
||||||
|
p.pushRunBeats()
|
||||||
|
// After the beats, not before: the summary is the last beat of a run's
|
||||||
|
// story and has no business overtaking the log it is about. It is also the
|
||||||
|
// only step here that can talk to the model, which is why it lives on a
|
||||||
|
// ticker at all rather than at the moment a run ends — and why it starts
|
||||||
|
// beside the ticker rather than on it, since a cold model takes longer to
|
||||||
|
// load than the interval between two pushes.
|
||||||
|
p.sweepRunSummariesAsync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +109,7 @@ func (p *AdventurePlugin) pushRoster() {
|
|||||||
var detailPushOK bool
|
var detailPushOK bool
|
||||||
|
|
||||||
func (p *AdventurePlugin) pushDetails() {
|
func (p *AdventurePlugin) pushDetails() {
|
||||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
snap, err := p.buildDetailSnapshot(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("roster: build detail snapshot failed", "err", err)
|
slog.Error("roster: build detail snapshot failed", "err", err)
|
||||||
return
|
return
|
||||||
@@ -132,6 +144,10 @@ func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail {
|
|||||||
ArmorClass: c.ArmorClass,
|
ArmorClass: c.ArmorClass,
|
||||||
Abilities: [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA},
|
Abilities: [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA},
|
||||||
Modifiers: c.Modifiers(),
|
Modifiers: c.Modifiers(),
|
||||||
|
// Unconditional, and this is the whole point of the field: it says this
|
||||||
|
// build knows about party seats, not that this character has any. Making
|
||||||
|
// it conditional would put it straight back in the hole it closes.
|
||||||
|
PartyKnown: true,
|
||||||
}
|
}
|
||||||
if equip, err := loadAdvEquipment(uid); err == nil {
|
if equip, err := loadAdvEquipment(uid); err == nil {
|
||||||
for _, slot := range allSlots {
|
for _, slot := range allSlots {
|
||||||
@@ -158,7 +174,7 @@ func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail {
|
|||||||
// owner it belongs to, so hiding a player from it would only deny them their own
|
// owner it belongs to, so hiding a player from it would only deny them their own
|
||||||
// sheet. The board token rides along so Pete can match owner↔page without ever
|
// sheet. The board token rides along so Pete can match owner↔page without ever
|
||||||
// reversing the one-way token.
|
// reversing the one-way token.
|
||||||
func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
func (p *AdventurePlugin) buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
||||||
snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()}
|
snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()}
|
||||||
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,6 +224,18 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
|||||||
pd.Vault = itemViews(items)
|
pd.Vault = itemViews(items)
|
||||||
}
|
}
|
||||||
pd.Equipped = equippedViews(uid)
|
pd.Equipped = equippedViews(uid)
|
||||||
|
// Ask 7: the 5 standard slots for the web management panel, plus the euro
|
||||||
|
// balance the upgrade/repair confirm dialogs show. Balance is nil-guarded so
|
||||||
|
// the free-standing tests (which build no euro plugin) still run.
|
||||||
|
pd.Slots = buildEquipSlotViews(uid)
|
||||||
|
if p.euro != nil {
|
||||||
|
pd.Balance = p.euro.GetBalance(uid)
|
||||||
|
}
|
||||||
|
// W5b: what this owner may ask for from the web, priced. See pete_offers.go
|
||||||
|
// on why a quote is not a permission.
|
||||||
|
pd.Zones = zoneOffersFor(uid)
|
||||||
|
pd.Resume = resumeOfferFor(uid)
|
||||||
|
pd.Babysit = babysitOfferFor(adv)
|
||||||
snap.Players = append(snap.Players, pd)
|
snap.Players = append(snap.Players, pd)
|
||||||
}
|
}
|
||||||
return snap, nil
|
return snap, nil
|
||||||
@@ -256,6 +284,12 @@ func itemViews(items []AdvItem) []peteclient.ItemView {
|
|||||||
} else if it.Slot != "" {
|
} else if it.Slot != "" {
|
||||||
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
||||||
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
|
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
|
||||||
|
// A masterwork/arena piece carries a real slot, so it can be worn from the
|
||||||
|
// web (into a standard slot) — give it the equip handle. Plain shop gear in
|
||||||
|
// the pack stays button-less: its slot is just a category, not a wearable.
|
||||||
|
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
|
||||||
|
v.ID = it.ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
@@ -432,23 +466,84 @@ func equippedViews(uid id.UserID) []peteclient.ItemView {
|
|||||||
|
|
||||||
// petViews returns the player's live pet slots. A pet that was chased away is
|
// petViews returns the player's live pet slots. A pet that was chased away is
|
||||||
// omitted — it isn't with them right now, and the self-view shows the present.
|
// omitted — it isn't with them right now, and the self-view shows the present.
|
||||||
|
//
|
||||||
|
// XPNeeded rides along so the web can draw the progress toward the next level.
|
||||||
|
// It is the engine's number, not Pete's: the curve steps by level band and a
|
||||||
|
// copy of it on the web side would be a second answer to "how close is my dog"
|
||||||
|
// that drifts the first time the band moves.
|
||||||
func petViews(adv *AdventureCharacter) []peteclient.PetView {
|
func petViews(adv *AdventureCharacter) []peteclient.PetView {
|
||||||
var out []peteclient.PetView
|
var out []peteclient.PetView
|
||||||
if adv.PetType != "" && !adv.PetChasedAway {
|
if adv.PetType != "" && !adv.PetChasedAway {
|
||||||
out = append(out, peteclient.PetView{
|
out = append(out, peteclient.PetView{
|
||||||
Type: adv.PetType, Name: adv.PetName, Level: adv.PetLevel,
|
Type: adv.PetType, Name: adv.PetName, Level: adv.PetLevel,
|
||||||
XP: adv.PetXP, ArmorTier: adv.PetArmorTier,
|
XP: adv.PetXP, XPNeeded: petXPNeededCenti(adv.PetLevel),
|
||||||
|
ArmorTier: adv.PetArmorTier,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if adv.Pet2Type != "" && !adv.Pet2ChasedAway {
|
if adv.Pet2Type != "" && !adv.Pet2ChasedAway {
|
||||||
out = append(out, peteclient.PetView{
|
out = append(out, peteclient.PetView{
|
||||||
Type: adv.Pet2Type, Name: adv.Pet2Name, Level: adv.Pet2Level,
|
Type: adv.Pet2Type, Name: adv.Pet2Name, Level: adv.Pet2Level,
|
||||||
XP: adv.Pet2XP, ArmorTier: adv.Pet2ArmorTier,
|
XP: adv.Pet2XP, XPNeeded: petXPNeededCenti(adv.Pet2Level),
|
||||||
|
ArmorTier: adv.Pet2ArmorTier,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// partySeatViews describes who is on this expedition for the public detail page.
|
||||||
|
// Returns nil for a solo run: expeditionParty always hands back at least the
|
||||||
|
// leader, and a "party" of one chair is a worse thing to draw than nothing.
|
||||||
|
//
|
||||||
|
// The opt-out rule is the Siege contributor's, not the realm occupant's: a seat
|
||||||
|
// belonging to an opted-out player is kept and anonymised. Deleting it would
|
||||||
|
// make a party of three read as a pair, and the numbers beside it — the supply
|
||||||
|
// burn, the threat, the enemy scaling — all felt three bodies. What an unnamed
|
||||||
|
// seat discloses is that somebody else is down there, which the zone and day on
|
||||||
|
// this same page already say about everyone in the party.
|
||||||
|
//
|
||||||
|
// Levels come from a per-seat character load. Parties cap at three and the
|
||||||
|
// companion needs no load at all, so this is at most two extra reads for a
|
||||||
|
// player who is actually in one.
|
||||||
|
func partySeatViews(exp *Expedition) []peteclient.PartySeatView {
|
||||||
|
seats, err := expeditionParty(exp.ID, exp.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("pete: party seats unavailable", "expedition", exp.ID, "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(seats) < 2 {
|
||||||
|
return nil // solo, or a roster that only holds its leader
|
||||||
|
}
|
||||||
|
out := make([]peteclient.PartySeatView, 0, len(seats))
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.Kind == SeatCompanion {
|
||||||
|
// The hireling is named unconditionally: he is not a player, has no
|
||||||
|
// board row to link to and no privacy to protect.
|
||||||
|
out = append(out, peteclient.PartySeatView{
|
||||||
|
Kind: "companion", Name: companionDisplayName,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind := "member"
|
||||||
|
if s.Kind == SeatLeader {
|
||||||
|
kind = "leader"
|
||||||
|
}
|
||||||
|
v := peteclient.PartySeatView{Kind: kind}
|
||||||
|
if !isNewsOptedOut(s.UserID) {
|
||||||
|
v.Name = charName(s.UserID)
|
||||||
|
if v.Name != "" {
|
||||||
|
// Token only alongside a name: a link to a page that says who they are
|
||||||
|
// would undo the anonymising below all by itself.
|
||||||
|
v.Token = eventToken(s.UserID, "roster")
|
||||||
|
if c, cerr := LoadDnDCharacter(s.UserID); cerr == nil && c != nil {
|
||||||
|
v.Level = c.Level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// buildRosterSnapshot assembles the complete board.
|
// buildRosterSnapshot assembles the complete board.
|
||||||
//
|
//
|
||||||
// Complete is the contract: Pete *replaces* its board with this, so anyone we
|
// Complete is the contract: Pete *replaces* its board with this, so anyone we
|
||||||
@@ -535,7 +630,13 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
|||||||
|
|
||||||
e.Detail = rosterDetail(pl.uid, c)
|
e.Detail = rosterDetail(pl.uid, c)
|
||||||
|
|
||||||
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
|
// activeExpeditionFor, not getActiveExpedition: the latter keys on
|
||||||
|
// dnd_expedition.user_id and is blind to members, so a player seated on
|
||||||
|
// somebody else's run has been reading as "idle in town" on the public board
|
||||||
|
// for the whole life of N3 parties — standing in a tier-4 dungeon. The
|
||||||
|
// expedition it resolves to is the leader's row, which is the right answer:
|
||||||
|
// a party shares one clock, one supply pool and one run.
|
||||||
|
if exp, _, _ := activeExpeditionFor(pl.uid); exp != nil {
|
||||||
zone := zoneOrFallback(exp.ZoneID)
|
zone := zoneOrFallback(exp.ZoneID)
|
||||||
e.Status = "expedition"
|
e.Status = "expedition"
|
||||||
e.Zone = zone.Display
|
e.Zone = zone.Display
|
||||||
@@ -548,6 +649,7 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
|||||||
if e.Detail != nil {
|
if e.Detail != nil {
|
||||||
e.Detail.Supplies = int(exp.Supplies.Current)
|
e.Detail.Supplies = int(exp.Supplies.Current)
|
||||||
e.Detail.ThreatLevel = exp.ThreatLevel
|
e.Detail.ThreatLevel = exp.ThreatLevel
|
||||||
|
e.Detail.Party = partySeatViews(exp)
|
||||||
if exp.RunID != "" {
|
if exp.RunID != "" {
|
||||||
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 {
|
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 {
|
||||||
e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
|
e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSeatedMemberIsNotIdleInTown is the gap W7 closes. The board resolved an
|
||||||
|
// expedition with getActiveExpedition, which keys on dnd_expedition.user_id — so
|
||||||
|
// a party member, who owns no row of their own, read as "idle in town" while
|
||||||
|
// standing in a dungeon. The regression is silent: the page renders fine, it just
|
||||||
|
// says the wrong thing about where somebody is.
|
||||||
|
func TestSeatedMemberIsNotIdleInTown(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-30 * time.Hour)
|
||||||
|
|
||||||
|
leader := id.UserID("@leader:test")
|
||||||
|
member := id.UserID("@member:test")
|
||||||
|
seedRosterPlayer(t, leader, "Josie", &old, &old)
|
||||||
|
seedRosterPlayer(t, member, "Camcast", &old, &old)
|
||||||
|
|
||||||
|
seedExpedition(t, "exp-shared", leader, "active")
|
||||||
|
seatLeaderFixture(t, "exp-shared")
|
||||||
|
if err := joinParty("exp-shared", member); err != nil {
|
||||||
|
t.Fatalf("joinParty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := buildRosterSnapshot(now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
byName := map[string]int{}
|
||||||
|
for i, a := range snap.Adventurers {
|
||||||
|
byName[a.Name] = i
|
||||||
|
}
|
||||||
|
for _, name := range []string{"Josie", "Camcast"} {
|
||||||
|
i, ok := byName[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s is not on the board", name)
|
||||||
|
}
|
||||||
|
if got := snap.Adventurers[i].Status; got != "expedition" {
|
||||||
|
t.Errorf("%s status = %q, want expedition", name, got)
|
||||||
|
}
|
||||||
|
if snap.Adventurers[i].Zone == "" {
|
||||||
|
t.Errorf("%s is on an expedition with no zone named", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPartySeatsNameTheWholeRoster covers the shape of the seat list: leader
|
||||||
|
// first, every human named with a linkable token, and the hireling named without
|
||||||
|
// one (he has no board row to link to).
|
||||||
|
func TestPartySeatsNameTheWholeRoster(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-30 * time.Hour)
|
||||||
|
|
||||||
|
leader := id.UserID("@leader:test")
|
||||||
|
member := id.UserID("@member:test")
|
||||||
|
seedRosterPlayer(t, leader, "Josie", &old, &old)
|
||||||
|
seedRosterPlayer(t, member, "Camcast", &old, &old)
|
||||||
|
|
||||||
|
seedExpedition(t, "exp-shared", leader, "active")
|
||||||
|
seatLeaderFixture(t, "exp-shared")
|
||||||
|
if err := joinParty("exp-shared", member); err != nil {
|
||||||
|
t.Fatalf("joinParty: %v", err)
|
||||||
|
}
|
||||||
|
if err := joinParty("exp-shared", companionUserID()); err != nil {
|
||||||
|
t.Fatalf("hire companion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seats := seatsForOwner(t, now, "Josie")
|
||||||
|
if len(seats) != 3 {
|
||||||
|
t.Fatalf("party has %d seats, want 3: %+v", len(seats), seats)
|
||||||
|
}
|
||||||
|
if seats[0].Kind != "leader" || seats[0].Name != "Josie" {
|
||||||
|
t.Errorf("first seat = %+v, want the leader Josie", seats[0])
|
||||||
|
}
|
||||||
|
if seats[0].Token == "" || seats[0].Level == 0 {
|
||||||
|
t.Errorf("leader seat is unlinkable or levelless: %+v", seats[0])
|
||||||
|
}
|
||||||
|
var companion, human int
|
||||||
|
for _, s := range seats {
|
||||||
|
switch s.Kind {
|
||||||
|
case "companion":
|
||||||
|
companion++
|
||||||
|
if s.Name != companionDisplayName {
|
||||||
|
t.Errorf("companion seat named %q, want %q", s.Name, companionDisplayName)
|
||||||
|
}
|
||||||
|
if s.Token != "" {
|
||||||
|
t.Errorf("companion seat carries a board token %q; he has no board row", s.Token)
|
||||||
|
}
|
||||||
|
case "leader", "member":
|
||||||
|
human++
|
||||||
|
if s.Name == "" || s.Token == "" {
|
||||||
|
t.Errorf("human seat %+v is missing its name/token pair", s)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("unknown seat kind %q", s.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if companion != 1 || human != 2 {
|
||||||
|
t.Errorf("seats = %d human + %d companion, want 2 + 1", human, companion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSoloRunPublishesNoParty: expeditionParty always hands back at least the
|
||||||
|
// leader, so a naive render would draw every solo player a party of one.
|
||||||
|
func TestSoloRunPublishesNoParty(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-30 * time.Hour)
|
||||||
|
|
||||||
|
solo := id.UserID("@solo:test")
|
||||||
|
seedRosterPlayer(t, solo, "Josie", &old, &old)
|
||||||
|
seedExpedition(t, "exp-solo", solo, "active")
|
||||||
|
|
||||||
|
if seats := seatsForOwner(t, now, "Josie"); seats != nil {
|
||||||
|
t.Errorf("solo run published a party of %d: %+v", len(seats), seats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And with only the leader seated, which is what the roster table looks like
|
||||||
|
// between materialising and the first invite landing.
|
||||||
|
seatLeaderFixture(t, "exp-solo")
|
||||||
|
if seats := seatsForOwner(t, now, "Josie"); seats != nil {
|
||||||
|
t.Errorf("leader-only roster published a party of %d: %+v", len(seats), seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOptedOutSeatIsAnonymisedNotDropped is the privacy contract for this
|
||||||
|
// surface, and it is deliberately NOT the board's rule. The board omits an
|
||||||
|
// opted-out player outright; a party seat is anonymised, because a party of three
|
||||||
|
// that renders as a pair is a false statement about the run everyone can see the
|
||||||
|
// supply burn and threat level of.
|
||||||
|
func TestOptedOutSeatIsAnonymisedNotDropped(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-30 * time.Hour)
|
||||||
|
|
||||||
|
leader := id.UserID("@leader:test")
|
||||||
|
hidden := id.UserID("@hidden:test")
|
||||||
|
seedRosterPlayer(t, leader, "Josie", &old, &old)
|
||||||
|
seedRosterPlayer(t, hidden, "Quack", &old, &old)
|
||||||
|
setNewsOptout(hidden, true)
|
||||||
|
|
||||||
|
seedExpedition(t, "exp-shared", leader, "active")
|
||||||
|
seatLeaderFixture(t, "exp-shared")
|
||||||
|
if err := joinParty("exp-shared", hidden); err != nil {
|
||||||
|
t.Fatalf("joinParty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seats := seatsForOwner(t, now, "Josie")
|
||||||
|
if len(seats) != 2 {
|
||||||
|
t.Fatalf("party has %d seats, want 2 — an opted-out seat was dropped, not anonymised: %+v",
|
||||||
|
len(seats), seats)
|
||||||
|
}
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.Name == "Quack" {
|
||||||
|
t.Error("an opted-out player is named on a party roster")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var blank int
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.Name == "" {
|
||||||
|
blank++
|
||||||
|
if s.Token != "" || s.Level != 0 {
|
||||||
|
t.Errorf("anonymised seat still carries a token or level: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blank != 1 {
|
||||||
|
t.Errorf("%d anonymous seats, want exactly 1", blank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPartyKnownIsSetOnEverySheet pins the capability flag Pete's abandon button
|
||||||
|
// hangs off. Party is omitempty, so a solo run and a game box too old to know what
|
||||||
|
// a seat is both reach Pete as an empty slice; the flag is what tells them apart.
|
||||||
|
// It is a fact about this build, so it must be true on a sheet with no party on it
|
||||||
|
// at all — a conditional party_known reads as "this player is solo" and puts the
|
||||||
|
// flag back in the hole it was added to close.
|
||||||
|
func TestPartyKnownIsSetOnEverySheet(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-30 * time.Hour)
|
||||||
|
|
||||||
|
intown := id.UserID("@intown:test")
|
||||||
|
solo := id.UserID("@solo:test")
|
||||||
|
leader := id.UserID("@leader:test")
|
||||||
|
member := id.UserID("@member:test")
|
||||||
|
seedRosterPlayer(t, intown, "Nonk", &old, &old)
|
||||||
|
seedRosterPlayer(t, solo, "Quack", &old, &old)
|
||||||
|
seedRosterPlayer(t, leader, "Josie", &old, &old)
|
||||||
|
seedRosterPlayer(t, member, "Camcast", &old, &old)
|
||||||
|
|
||||||
|
seedExpedition(t, "exp-solo", solo, "active")
|
||||||
|
seedExpedition(t, "exp-shared", leader, "active")
|
||||||
|
seatLeaderFixture(t, "exp-shared")
|
||||||
|
if err := joinParty("exp-shared", member); err != nil {
|
||||||
|
t.Fatalf("joinParty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := buildRosterSnapshot(now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
var seen int
|
||||||
|
for _, a := range snap.Adventurers {
|
||||||
|
if a.Detail == nil {
|
||||||
|
t.Fatalf("%s has no detail sheet to carry the flag", a.Name)
|
||||||
|
}
|
||||||
|
seen++
|
||||||
|
if !a.Detail.PartyKnown {
|
||||||
|
t.Errorf("%s (%s, %d seats) published party_known=false; this build knows what a seat is",
|
||||||
|
a.Name, a.Status, len(a.Detail.Party))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen != 4 {
|
||||||
|
t.Fatalf("checked %d sheets, want 4 — a case went missing", seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wire name is the contract: Pete decodes party_known and withholds the
|
||||||
|
// abandon button when it is absent, so a rename here fails silently and only
|
||||||
|
// on the far side.
|
||||||
|
blob, err := json.Marshal(&peteclient.RosterDetail{PartyKnown: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(blob), `"party_known":true`) {
|
||||||
|
t.Errorf("detail sheet serialised without party_known: %s", blob)
|
||||||
|
}
|
||||||
|
// And it must not be omitempty: an absent key is Pete's fail-closed answer,
|
||||||
|
// which a false flag has to keep meaning.
|
||||||
|
if blob, err = json.Marshal(&peteclient.RosterDetail{}); err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
} else if !strings.Contains(string(blob), `"party_known":false`) {
|
||||||
|
t.Errorf("party_known is omitempty; false must stay on the wire: %s", blob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatsForOwner pulls one named adventurer's published party out of a whole
|
||||||
|
// snapshot, which is the only way to reach it — Party rides RosterDetail, so this
|
||||||
|
// also proves the wiring in buildRosterSnapshot and not just partySeatViews.
|
||||||
|
func seatsForOwner(t *testing.T, now time.Time, name string) []peteclient.PartySeatView {
|
||||||
|
t.Helper()
|
||||||
|
snap, err := buildRosterSnapshot(now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
for _, a := range snap.Adventurers {
|
||||||
|
if a.Name == name && a.Detail != nil {
|
||||||
|
return a.Detail.Party
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%s is not on the board with a detail sheet", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The run summary — three sentences over forty beats.
|
||||||
|
//
|
||||||
|
// Every other line in the liveblog is assembled by Pete out of a beat's own
|
||||||
|
// nouns and numbers, and that is the right split: a log has to be exactly what
|
||||||
|
// happened, in order, and prose in the middle of it would be the more convincing
|
||||||
|
// of the two accounts and the less true. But a *report* is read afterwards, by
|
||||||
|
// somebody who wasn't watching, and the question it answers is not "what
|
||||||
|
// happened" — the log already answers that — it is "what was that run". That is
|
||||||
|
// a judgement, and no template makes judgements.
|
||||||
|
//
|
||||||
|
// So this is the one piece of prose on the channel, and it earns the model far
|
||||||
|
// better than a dispatch headline does. authorDispatch turns four fields into a
|
||||||
|
// sentence a template could nearly have written; this reads a whole expedition
|
||||||
|
// and picks out what mattered.
|
||||||
|
//
|
||||||
|
// Three rules, and the first one is why this file exists at all:
|
||||||
|
//
|
||||||
|
// - **Off the hot path.** It runs on the roster ticker, not at the moment the
|
||||||
|
// run ends. A run ending is already a player-facing beat with a dispatch
|
||||||
|
// being authored against it; adding a second bounded-but-real LLM call to
|
||||||
|
// that chokepoint would stall the command that killed the boss.
|
||||||
|
// - **One per tick.** A backlog after an outage drains over minutes rather
|
||||||
|
// than spooling a hundred generations at once.
|
||||||
|
// - **Best effort, exactly once.** A run that can't be summarised is filed
|
||||||
|
// with an empty summary beat rather than retried forever — the row is what
|
||||||
|
// stops the sweep picking it up again next tick, and a report with no
|
||||||
|
// summary is still the log and the numbers, which is most of it.
|
||||||
|
|
||||||
|
// runSummaryMaxBeats bounds what goes into the prompt. Far more than a normal
|
||||||
|
// run produces; the cap is for the multi-day expedition that beat out hundreds,
|
||||||
|
// where the last chunk is the part with the ending in it.
|
||||||
|
const runSummaryMaxBeats = 120
|
||||||
|
|
||||||
|
// maxRunSummary mirrors Pete's cap so we never ship prose Pete will reject on
|
||||||
|
// length alone. Byte count, matching Pete's len() check.
|
||||||
|
const maxRunSummary = 1200
|
||||||
|
|
||||||
|
// runSummaryTimeout has to cover a COLD model, not just a generation.
|
||||||
|
//
|
||||||
|
// dispatchLLMTimeout is tight because authoring runs on a game chokepoint and a
|
||||||
|
// template dispatch now beats a voiced one late. Nothing here is waiting on this:
|
||||||
|
// it is a background sweep, the run ended minutes ago, and the page it feeds is
|
||||||
|
// already serving without it. The cost of being impatient is the opposite of
|
||||||
|
// there — a timeout files an empty summary beat, and that run never gets another
|
||||||
|
// chance at one.
|
||||||
|
//
|
||||||
|
// And impatience is the live risk, because this call is almost always the one
|
||||||
|
// that pays the load. Ollama evicts an idle model after about five minutes, and
|
||||||
|
// runs end far further apart than that, so the steady state is: model on disk,
|
||||||
|
// nothing resident, weights to page in before the first token. A budget sized
|
||||||
|
// for generation alone would expire during the load on every single run and file
|
||||||
|
// an empty beat that says the box is down when the box is fine. So this is sized
|
||||||
|
// for load-then-generate, and a timeout here really does mean the box is down.
|
||||||
|
const runSummaryTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// runSummaryBusy is the whole concurrency story: at most one sweep in flight,
|
||||||
|
// ever. The ticker starts one and moves on, so a cold model loading for minutes
|
||||||
|
// costs the board nothing, and the ticks that fire meanwhile find the flag set
|
||||||
|
// and skip rather than queue.
|
||||||
|
var runSummaryBusy atomic.Bool
|
||||||
|
|
||||||
|
// sweepRunSummariesAsync starts a sweep off the caller's goroutine if one isn't
|
||||||
|
// already running.
|
||||||
|
//
|
||||||
|
// It has to be off the ticker: runSummaryTimeout is minutes and the tick is two,
|
||||||
|
// so a synchronous call would hold the roster, details, siege and beat pushes
|
||||||
|
// behind a model load and put the live board permanently a tick or more behind
|
||||||
|
// the game. Ordering against those pushes is not lost by going async — the beat
|
||||||
|
// this files is written to the local buffer with the next seq, and the pusher
|
||||||
|
// ships it by seq on whichever tick comes after, still behind the run's own log.
|
||||||
|
func (p *AdventurePlugin) sweepRunSummariesAsync() {
|
||||||
|
if !runSummaryBusy.CompareAndSwap(false, true) {
|
||||||
|
return // one still working; the next tick will find it done or still busy
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer runSummaryBusy.Store(false)
|
||||||
|
p.sweepRunSummaries()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepRunSummaries authors the summary for at most one finished run per call.
|
||||||
|
// Called from the roster ticker, after the beats themselves have been pushed —
|
||||||
|
// the summary is the last beat of a run's story and there is no rush to have it
|
||||||
|
// overtake the log it is about.
|
||||||
|
func (p *AdventurePlugin) sweepRunSummaries() {
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !llmConfigured() {
|
||||||
|
return // no model, no summary, no wasted queries asking which run needs one
|
||||||
|
}
|
||||||
|
runID := nextRunNeedingSummary()
|
||||||
|
if runID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// File the beat whatever happens below. An empty one carries no prose and Pete
|
||||||
|
// stores nothing from it — its entire job is to be the row that stops this run
|
||||||
|
// coming back round every two minutes for the rest of the week.
|
||||||
|
summary, name := authorRunSummary(runID)
|
||||||
|
if summary == "" {
|
||||||
|
slog.Debug("run summary: nothing authored, filing an empty beat to close it out", "run", runID)
|
||||||
|
}
|
||||||
|
recordRunBeat(runID, peteclient.RunBeat{
|
||||||
|
Kind: "summary",
|
||||||
|
Name: name,
|
||||||
|
Prose: summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextRunNeedingSummary picks the most recently finished run that has an `end`
|
||||||
|
// beat and no `summary` beat yet.
|
||||||
|
//
|
||||||
|
// Newest first, deliberately. If the sweep is behind — an outage, a busy
|
||||||
|
// evening — the run somebody is most likely to be looking at right now is the
|
||||||
|
// one that just ended, not the one from four hours ago. The old ones still get
|
||||||
|
// their turn on later ticks; they just don't get to hold up the fresh one.
|
||||||
|
func nextRunNeedingSummary() string {
|
||||||
|
var runID string
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT e.run_id
|
||||||
|
FROM pete_run_beat e
|
||||||
|
WHERE e.kind = 'end'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM pete_run_beat s
|
||||||
|
WHERE s.run_id = e.run_id AND s.kind = 'summary')
|
||||||
|
ORDER BY e.occurred_at DESC
|
||||||
|
LIMIT 1`).Scan(&runID)
|
||||||
|
if err != nil {
|
||||||
|
return "" // ErrNoRows is the common case: nothing to summarise
|
||||||
|
}
|
||||||
|
if !runBeatAllowed(runID) {
|
||||||
|
// An opted-out player's beats never leave the box, so there is nothing for
|
||||||
|
// a summary to be attached to. File the closing beat anyway (it will be
|
||||||
|
// retired locally with the rest) so this run stops being picked.
|
||||||
|
recordRunBeat(runID, peteclient.RunBeat{Kind: "summary"})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorRunSummary reads a run's beats back and returns Pete's summary of it,
|
||||||
|
// plus the adventurer's name for the guard allow-list. Returns empty strings on
|
||||||
|
// any failure — the model being off, a timeout, an unparseable completion, an
|
||||||
|
// over-long generation — because every one of those is a report without a
|
||||||
|
// summary rather than a problem.
|
||||||
|
func authorRunSummary(runID string) (summary, name string) {
|
||||||
|
beats, err := loadRunBeatsForSummary(runID)
|
||||||
|
if err != nil || len(beats) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
name = runSummarySubject(beats)
|
||||||
|
if name == "" {
|
||||||
|
// No name means no allow-list on Pete's side, which means the guard rejects
|
||||||
|
// anything naming anyone. Don't spend a generation to have it thrown away.
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := callLLMDispatch(runSummaryTimeout, buildRunSummaryPrompt(name, beats))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
|
||||||
|
return "", name
|
||||||
|
}
|
||||||
|
summary = parseRunSummary(raw)
|
||||||
|
if summary == "" || len(summary) > maxRunSummary {
|
||||||
|
slog.Warn("run summary: unusable output", "run", runID, "len", len(summary))
|
||||||
|
return "", name
|
||||||
|
}
|
||||||
|
return summary, name
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRunBeatsForSummary reads a run's own beats back out of the outbound
|
||||||
|
// buffer. It reads the TAIL and re-sorts, so a run long enough to hit the cap
|
||||||
|
// contributes the part with its ending in it rather than its first morning.
|
||||||
|
func loadRunBeatsForSummary(runID string) ([]peteclient.RunBeat, error) {
|
||||||
|
rows, err := db.Get().Query(`
|
||||||
|
SELECT seq, payload FROM pete_run_beat
|
||||||
|
WHERE run_id = ? ORDER BY seq DESC LIMIT ?`, runID, runSummaryMaxBeats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []peteclient.RunBeat
|
||||||
|
for rows.Next() {
|
||||||
|
var seq int64
|
||||||
|
var payload string
|
||||||
|
if err := rows.Scan(&seq, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var b peteclient.RunBeat
|
||||||
|
if err := json.Unmarshal([]byte(payload), &b); err != nil {
|
||||||
|
continue // a row the pusher will retire on its own; not this sweep's problem
|
||||||
|
}
|
||||||
|
b.RunID, b.Seq = runID, seq
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSummarySubject finds the one name a summary is allowed to use. Only the
|
||||||
|
// `start` beat carries identity, by design — so a run whose start beat was
|
||||||
|
// dropped has no subject here, and gets no summary rather than an anonymous one.
|
||||||
|
func runSummarySubject(beats []peteclient.RunBeat) string {
|
||||||
|
for _, b := range beats {
|
||||||
|
if b.Name != "" {
|
||||||
|
return b.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRunSummaryPrompt renders the run as a plain numbered log and asks for
|
||||||
|
// three sentences over it.
|
||||||
|
//
|
||||||
|
// The beats go in as facts, not as Pete's rendered lines: Pete's phrasing is
|
||||||
|
// Pete's, and feeding a model its own output back would have it summarising a
|
||||||
|
// summary. The rules are the dispatch prompt's, tightened in the one place that
|
||||||
|
// matters here — a run log is full of monster names and a model asked to write
|
||||||
|
// about a party is very willing to invent a second member of it.
|
||||||
|
func buildRunSummaryPrompt(name string, beats []peteclient.RunBeat) string {
|
||||||
|
var log strings.Builder
|
||||||
|
zone, outcome := "", ""
|
||||||
|
n := 0
|
||||||
|
for _, b := range beats {
|
||||||
|
if b.Zone != "" && zone == "" {
|
||||||
|
zone = b.Zone
|
||||||
|
}
|
||||||
|
line := describeBeatForPrompt(b)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Kind == "end" {
|
||||||
|
outcome = b.Outcome
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
fmt.Fprintf(&log, "%d. %s\n", n, line)
|
||||||
|
}
|
||||||
|
if zone == "" {
|
||||||
|
zone = "a dungeon"
|
||||||
|
}
|
||||||
|
ending := "the run ended"
|
||||||
|
switch outcome {
|
||||||
|
case "cleared":
|
||||||
|
ending = "they cleared it"
|
||||||
|
case "died":
|
||||||
|
ending = "they died down there"
|
||||||
|
case "retreated":
|
||||||
|
ending = "they walked out alive but beaten"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`You are Pete, a warm, friendly local news reporter for a fantasy adventuring town. Think a beloved local newscaster who genuinely knows everyone and is glad to see them. Conversational, never snarky, never a caps-lock hype-man. Warmth carries the register, not exclamation marks.
|
||||||
|
|
||||||
|
Below is the log of one expedition, room by room, exactly as it was recorded. Write a SHORT summary of how the run went: what it cost them, the moment it turned, and how it ended.
|
||||||
|
|
||||||
|
STRICT RULES — do not violate these:
|
||||||
|
- The ONLY adventurer name you may use is: %s. Never invent another adventurer, companion, party member or friend. If the log does not say someone was there, they were not there.
|
||||||
|
- Monster, zone and item names in the log are game names — use them as given.
|
||||||
|
- Use ONLY what the log says. Do not invent numbers, fights, items or outcomes.
|
||||||
|
- Do NOT add numbers together and do NOT state any total. The exact totals are printed next to your summary and a total you worked out yourself will contradict them. Quote a number only if that exact number appears on one line of the log.
|
||||||
|
- Three sentences at most. No markdown, no emoji, no headline, no bullet points.
|
||||||
|
- Past tense, third person. Do not address the reader as "you".
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no other text:
|
||||||
|
{"summary": "at most three sentences about how the run went"}
|
||||||
|
|
||||||
|
The expedition: %s went into %s, and %s.
|
||||||
|
|
||||||
|
The log:
|
||||||
|
%s`, name, name, zone, ending, log.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeBeatForPrompt renders one beat as a flat fact line for the prompt.
|
||||||
|
// Returns "" for a beat with nothing in it worth a sentence — a room with no
|
||||||
|
// identity, an empty haul — so the model isn't handed forty lines of "walked
|
||||||
|
// into the next room" to find three sentences in.
|
||||||
|
func describeBeatForPrompt(b peteclient.RunBeat) string {
|
||||||
|
switch b.Kind {
|
||||||
|
case "start":
|
||||||
|
if b.TotalRooms > 0 {
|
||||||
|
return fmt.Sprintf("set out into %s, %d rooms deep", orSomething(b.Zone), b.TotalRooms)
|
||||||
|
}
|
||||||
|
return "set out into " + orSomething(b.Zone)
|
||||||
|
case "combat":
|
||||||
|
what := orSomething(b.Target)
|
||||||
|
switch b.RoomKind {
|
||||||
|
case "boss":
|
||||||
|
what = "the boss, " + what
|
||||||
|
case "elite":
|
||||||
|
what = "an elite, " + what
|
||||||
|
}
|
||||||
|
switch b.Outcome {
|
||||||
|
case "won":
|
||||||
|
s := fmt.Sprintf("killed %s, taking %d damage", what, b.Amount)
|
||||||
|
if b.HPMax > 0 {
|
||||||
|
s += fmt.Sprintf(" (left on %d of %d health)", b.HP, b.HPMax)
|
||||||
|
}
|
||||||
|
if b.Crits > 0 {
|
||||||
|
s += fmt.Sprintf(", %d critical hit(s)", b.Crits)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
case "retreat":
|
||||||
|
return "could not finish " + what + " in time and withdrew"
|
||||||
|
default:
|
||||||
|
return "was beaten by " + what
|
||||||
|
}
|
||||||
|
case "trap":
|
||||||
|
if b.Amount <= 0 {
|
||||||
|
return "spotted a trap and stepped over it"
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("sprung a trap for %d damage", b.Amount)
|
||||||
|
if b.HPMax > 0 {
|
||||||
|
s += fmt.Sprintf(" (left on %d of %d health)", b.HP, b.HPMax)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
case "treasure":
|
||||||
|
return "found " + orSomething(b.Target)
|
||||||
|
case "lock":
|
||||||
|
if b.Outcome == "picked" {
|
||||||
|
return "picked a locked door"
|
||||||
|
}
|
||||||
|
return "found every way on sealed and doubled back"
|
||||||
|
case "region":
|
||||||
|
return "crossed out of " + orSomething(b.Region) + " into " + orSomething(b.Target)
|
||||||
|
case "haul":
|
||||||
|
if b.Amount <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("gathered %d supplies along the way", b.Amount)
|
||||||
|
case "end":
|
||||||
|
switch b.Outcome {
|
||||||
|
case "cleared":
|
||||||
|
return "finished the run and got out"
|
||||||
|
case "died":
|
||||||
|
return "did not come home"
|
||||||
|
case "retreated":
|
||||||
|
return "withdrew, wounded but alive"
|
||||||
|
}
|
||||||
|
return "the run ended"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func orSomething(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "something"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRunSummary pulls {"summary": ...} out of the completion, tolerating the
|
||||||
|
// same noise parseDispatch does: reasoning blocks, fences, prose around the JSON.
|
||||||
|
func parseRunSummary(raw string) string {
|
||||||
|
s := raw
|
||||||
|
if i := strings.Index(s, "<think>"); i != -1 {
|
||||||
|
if j := strings.Index(s, "</think>"); j != -1 {
|
||||||
|
s = s[:i] + s[j+len("</think>"):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := strings.Index(s, "{")
|
||||||
|
end := strings.LastIndex(s, "}")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(s[start:end+1]), &out); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out.Summary)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// finishRun writes a small realistic run's beats and closes it.
|
||||||
|
func finishRun(runID, name string) {
|
||||||
|
recordRunBeat(runID, peteclient.RunBeat{Kind: "start", Token: "tok", Name: name,
|
||||||
|
Level: 14, Zone: "Crypt of Valdris", TotalRooms: 9, Room: 1})
|
||||||
|
recordRunBeat(runID, peteclient.RunBeat{Kind: "combat", Room: 2, Target: "Bone Chanter",
|
||||||
|
Outcome: "won", Amount: 7, HP: 61, HPMax: 68})
|
||||||
|
recordRunBeat(runID, peteclient.RunBeat{Kind: "trap", Room: 3, Outcome: "sprung",
|
||||||
|
Amount: 22, HP: 39, HPMax: 68})
|
||||||
|
recordRunBeat(runID, peteclient.RunBeat{Kind: "end", Room: 3, Outcome: "died"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSummarySweepPicksAFinishedRunOnce. The sweep runs every two minutes
|
||||||
|
// forever, so the property that matters is not that it finds a run — it is that
|
||||||
|
// it lets one go. A run that stayed pickable would author a fresh summary every
|
||||||
|
// tick for the rest of the week.
|
||||||
|
func TestSummarySweepPicksAFinishedRunOnce(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
seedBeatRun(t, "run-done", id.UserID("@josie:example.com"))
|
||||||
|
finishRun("run-done", "Josie")
|
||||||
|
|
||||||
|
if got := nextRunNeedingSummary(); got != "run-done" {
|
||||||
|
t.Fatalf("sweep didn't find the finished run: %q", got)
|
||||||
|
}
|
||||||
|
// Filing the closing beat is what retires it, whether or not any prose was
|
||||||
|
// authored into it.
|
||||||
|
recordRunBeat("run-done", peteclient.RunBeat{Kind: "summary"})
|
||||||
|
if got := nextRunNeedingSummary(); got != "" {
|
||||||
|
t.Errorf("run came back round after its summary beat was filed: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSummarySweepIgnoresARunStillWalking. A summary is a reading of a finished
|
||||||
|
// run. Writing one over a run in progress would be an ending invented before
|
||||||
|
// there was one.
|
||||||
|
func TestSummarySweepIgnoresARunStillWalking(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
seedBeatRun(t, "run-live", id.UserID("@josie:example.com"))
|
||||||
|
recordRunBeat("run-live", peteclient.RunBeat{Kind: "start", Name: "Josie", Zone: "Crypt"})
|
||||||
|
recordRunBeat("run-live", peteclient.RunBeat{Kind: "combat", Target: "Rat", Outcome: "won"})
|
||||||
|
|
||||||
|
if got := nextRunNeedingSummary(); got != "" {
|
||||||
|
t.Errorf("picked a run that hasn't ended: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSummarySweepRetiresAnOptedOutRun. Their beats never leave the box, so
|
||||||
|
// there is nothing on Pete for a summary to attach to — but the run must still
|
||||||
|
// stop being picked, or the sweep spends a generation on it every tick and
|
||||||
|
// throws the result away.
|
||||||
|
func TestSummarySweepRetiresAnOptedOutRun(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
uid := id.UserID("@quiet:example.com")
|
||||||
|
seedBeatRun(t, "run-quiet", uid)
|
||||||
|
finishRun("run-quiet", "Quack")
|
||||||
|
setNewsOptout(uid, true)
|
||||||
|
|
||||||
|
if got := nextRunNeedingSummary(); got != "" {
|
||||||
|
t.Errorf("offered an opted-out player's run for summarising: %q", got)
|
||||||
|
}
|
||||||
|
kinds := beatKinds(t, "run-quiet")
|
||||||
|
if kinds[len(kinds)-1] != "summary" {
|
||||||
|
t.Errorf("opted-out run wasn't closed out; kinds = %v", kinds)
|
||||||
|
}
|
||||||
|
// And it stays closed out.
|
||||||
|
if got := nextRunNeedingSummary(); got != "" {
|
||||||
|
t.Errorf("opted-out run came back round: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSummaryPromptCarriesTheRunAndOnlyOneName.
|
||||||
|
//
|
||||||
|
// The prompt is the whole safety story on this side (Pete's guard is the other
|
||||||
|
// half, and it only ever sees the answer). A run log is full of monster names,
|
||||||
|
// and a model asked to write warmly about "the party" will happily invent a
|
||||||
|
// second member of it — which on a public page is words put in a real person's
|
||||||
|
// mouth. So the one name is stated twice and the facts are handed over as facts.
|
||||||
|
func TestSummaryPromptCarriesTheRunAndOnlyOneName(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
seedBeatRun(t, "run-p", id.UserID("@josie:example.com"))
|
||||||
|
finishRun("run-p", "Josie")
|
||||||
|
|
||||||
|
beats, err := loadRunBeatsForSummary("run-p")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(beats) != 4 {
|
||||||
|
t.Fatalf("want 4 beats, got %d", len(beats))
|
||||||
|
}
|
||||||
|
if beats[0].Seq >= beats[len(beats)-1].Seq {
|
||||||
|
t.Error("beats came back out of order; the log would read backwards")
|
||||||
|
}
|
||||||
|
if got := runSummarySubject(beats); got != "Josie" {
|
||||||
|
t.Fatalf("subject = %q, want Josie", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := buildRunSummaryPrompt("Josie", beats)
|
||||||
|
for _, want := range []string{
|
||||||
|
"The ONLY adventurer name you may use is: Josie",
|
||||||
|
"Josie went into Crypt of Valdris, and they died down there",
|
||||||
|
"killed Bone Chanter, taking 7 damage",
|
||||||
|
"sprung a trap for 22 damage",
|
||||||
|
"did not come home",
|
||||||
|
"Three sentences at most",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(p, want) {
|
||||||
|
t.Errorf("prompt is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnattributedRunGetsNoSummary. Only the `start` beat carries identity. A
|
||||||
|
// run that lost it has no name to hand the guard, so Pete would reject any
|
||||||
|
// summary naming anybody — spending a generation to have it thrown away, and
|
||||||
|
// risking an anonymous paragraph about a player nobody can consent for.
|
||||||
|
func TestUnattributedRunGetsNoSummary(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
seedBeatRun(t, "run-anon", id.UserID("@josie:example.com"))
|
||||||
|
recordRunBeat("run-anon", peteclient.RunBeat{Kind: "combat", Target: "Rat", Outcome: "won"})
|
||||||
|
recordRunBeat("run-anon", peteclient.RunBeat{Kind: "end", Outcome: "cleared"})
|
||||||
|
|
||||||
|
summary, name := authorRunSummary("run-anon")
|
||||||
|
if summary != "" || name != "" {
|
||||||
|
t.Errorf("authored over a run with no owner: name=%q summary=%q", name, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseRunSummaryTolerance mirrors parseDispatch's: the model wraps its
|
||||||
|
// answer in reasoning blocks, fences and apologies, and none of that is a reason
|
||||||
|
// to lose a summary that is sitting right there.
|
||||||
|
func TestParseRunSummaryTolerance(t *testing.T) {
|
||||||
|
cases := []struct{ name, raw, want string }{
|
||||||
|
{"plain", `{"summary": "It went badly."}`, "It went badly."},
|
||||||
|
{"think block", "<think>hmm</think>\n{\"summary\": \"It went badly.\"}", "It went badly."},
|
||||||
|
{"fenced with chatter", "Sure!\n```json\n{\"summary\": \"It went badly.\"}\n```\n", "It went badly."},
|
||||||
|
{"no json", "It went badly.", ""},
|
||||||
|
{"empty summary", `{"summary": " "}`, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := parseRunSummary(c.raw); got != c.want {
|
||||||
|
t.Errorf("%s: parseRunSummary = %q, want %q", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatchRunLinkNeedsARecentRunWithBeats.
|
||||||
|
//
|
||||||
|
// latestRunIDForNews answers "which run is this dispatch about", and it is asked
|
||||||
|
// from call sites that have already let go of the run. Both of its guards are
|
||||||
|
// load-bearing: a run with no beats behind it would mint a dispatch link to a
|
||||||
|
// 404, and a stale run would attach a campaign death at the Empty Throne to
|
||||||
|
// whatever dungeon that player last walked.
|
||||||
|
func TestDispatchRunLinkNeedsARecentRunWithBeats(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
uid := id.UserID("@josie:example.com")
|
||||||
|
|
||||||
|
// A run with no beats: pre-liveblog, or the seam was off while it walked.
|
||||||
|
seedBeatRun(t, "run-silent", uid)
|
||||||
|
if got := latestRunIDForNews(uid); got != "" {
|
||||||
|
t.Errorf("linked a dispatch to a run Pete has never heard of: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real one, closed seconds ago.
|
||||||
|
seedBeatRun(t, "run-real", uid)
|
||||||
|
finishRun("run-real", "Josie")
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE dnd_zone_run SET completed_at = CURRENT_TIMESTAMP WHERE run_id = 'run-real'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := latestRunIDForNews(uid); got != "run-real" {
|
||||||
|
t.Errorf("run link = %q, want run-real", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A day later, they die somewhere that isn't a dungeon at all.
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE dnd_zone_run SET completed_at = datetime('now', '-1 day') WHERE run_id = 'run-real'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := latestRunIDForNews(uid); got != "" {
|
||||||
|
t.Errorf("attached an unrelated death to yesterday's expedition: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run beats — the room-by-room texture of an expedition, on its way to Pete.
|
||||||
|
//
|
||||||
|
// Until now Pete learned that an expedition happened only when it ended: a
|
||||||
|
// zone_clear, a retreat, a death. The run itself — the fight that nearly went
|
||||||
|
// wrong, the trap, the haul — was narrated to one Matrix DM and then discarded.
|
||||||
|
// This records the shape of each moment as it happens so Pete can retell it.
|
||||||
|
//
|
||||||
|
// Three rules hold the design together:
|
||||||
|
//
|
||||||
|
// - **Facts, not prose.** A beat carries nouns and numbers; the engine's
|
||||||
|
// narration stays in Matrix. Pete owns the words, the same split every Fact
|
||||||
|
// already respects.
|
||||||
|
// - **Its own channel.** Beats never touch pete_emit_queue. They are
|
||||||
|
// high-volume and low-stakes, and a chatty run must not be able to spend the
|
||||||
|
// retry budget a death dispatch depends on.
|
||||||
|
// - **Never block, never fail the game.** recordRunBeat swallows its errors to
|
||||||
|
// a log line. A liveblog is a nice-to-have; the walk it is watching is not.
|
||||||
|
//
|
||||||
|
// Delivery rides the roster ticker (one extra request per 2 minutes, not one per
|
||||||
|
// room) but unlike the roster it is retried, because a dropped beat is a hole in
|
||||||
|
// a story rather than a stale number the next snapshot corrects.
|
||||||
|
|
||||||
|
// runBeatBatch bounds one push. A busy realm mid-evening might produce a few
|
||||||
|
// hundred beats between ticks; this keeps any single request small and lets the
|
||||||
|
// backlog drain over a few ticks instead of one enormous POST.
|
||||||
|
const runBeatBatch = 200
|
||||||
|
|
||||||
|
// recordRunBeat appends a beat to the outbound buffer. Never returns an error:
|
||||||
|
// every caller is on the walk's hot path and none of them can do anything useful
|
||||||
|
// with a failure to log a story.
|
||||||
|
//
|
||||||
|
// Seq is assigned by the INSERT itself (MAX+1 within the statement), so two
|
||||||
|
// concurrent writers on the same run can't collide on a number — SQLite
|
||||||
|
// serialises the statement, and the primary key would reject the loser anyway.
|
||||||
|
func recordRunBeat(runID string, b peteclient.RunBeat) {
|
||||||
|
if runID == "" || !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.RunID = runID
|
||||||
|
if b.OccurredAt == 0 {
|
||||||
|
b.OccurredAt = nowUnix()
|
||||||
|
}
|
||||||
|
// Seq and RunID live in columns; the rest of the beat is the payload, so
|
||||||
|
// adding a field later needs no migration.
|
||||||
|
kind, occurred := b.Kind, b.OccurredAt
|
||||||
|
b.Seq = 0
|
||||||
|
payload, err := json.Marshal(b)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("runbeat: marshal failed", "run", runID, "kind", kind, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
INSERT INTO pete_run_beat (run_id, seq, kind, occurred_at, payload)
|
||||||
|
SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ?, ?
|
||||||
|
FROM pete_run_beat WHERE run_id = ?`,
|
||||||
|
runID, kind, occurred, string(payload), runID); err != nil {
|
||||||
|
slog.Debug("runbeat: record failed", "run", runID, "kind", kind, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHasEndBeat reports whether this run's story has already been closed. Cheap
|
||||||
|
// enough to ask at every end site because a run only ends once; see beatRunEnd
|
||||||
|
// for why the first answer is the one that must stick.
|
||||||
|
func runHasEndBeat(runID string) bool {
|
||||||
|
if runID == "" || !peteclient.Enabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM pete_run_beat WHERE run_id = ? AND kind = 'end'`, runID).Scan(&n)
|
||||||
|
return err == nil && n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestRunIDForNews is the run a just-filed dispatch is about, or "" when there
|
||||||
|
// isn't one to point at.
|
||||||
|
//
|
||||||
|
// The three dispatches that end an expedition — a clear, a retreat, a death —
|
||||||
|
// are all emitted *after* the run they concluded has been closed, and two of
|
||||||
|
// them from call sites several frames away from the run row. So rather than
|
||||||
|
// thread a run id through five signatures and hope the lifetimes line up, this
|
||||||
|
// asks the question that is actually true at that moment: what is the last run
|
||||||
|
// this player started. A player has one run at a time and a dispatch about their
|
||||||
|
// expedition ending is about that one. Multi-region is the case worth stating:
|
||||||
|
// each region gets its own run, and the last one started is the one they were
|
||||||
|
// standing in when it ended, which is the log the dispatch should open.
|
||||||
|
//
|
||||||
|
// Two clauses do the real work and neither is optional:
|
||||||
|
//
|
||||||
|
// - The `pete_run_beat` check. Runs exist with no beats behind them — from
|
||||||
|
// before the liveblog shipped, or with the seam off — and handing Pete a run
|
||||||
|
// id it has nothing for would mint a dispatch link to a 404.
|
||||||
|
// - The recency window. Not every death happens in a dungeon: the campaign
|
||||||
|
// path kills people at the Empty Throne, and without this a death that had
|
||||||
|
// nothing to do with any expedition would link to whatever run that player
|
||||||
|
// last walked, possibly days ago. An expedition-ending dispatch is filed
|
||||||
|
// seconds after its run closes, so "still open, or closed just now" is the
|
||||||
|
// honest test for "this dispatch is about that run".
|
||||||
|
func latestRunIDForNews(userID id.UserID) string {
|
||||||
|
if userID == "" || !peteclient.Enabled() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var runID string
|
||||||
|
err := db.Get().QueryRow(`
|
||||||
|
SELECT r.run_id
|
||||||
|
FROM dnd_zone_run r
|
||||||
|
WHERE r.user_id = ?
|
||||||
|
AND (r.completed_at IS NULL OR r.completed_at >= datetime('now', '-10 minutes'))
|
||||||
|
AND EXISTS (SELECT 1 FROM pete_run_beat b WHERE b.run_id = r.run_id)
|
||||||
|
ORDER BY r.started_at DESC, r.rowid DESC
|
||||||
|
LIMIT 1`, string(userID)).Scan(&runID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBeatPushOK mirrors rosterPushOK: log the transitions, stay quiet otherwise.
|
||||||
|
var runBeatPushOK bool
|
||||||
|
|
||||||
|
// pushRunBeats drains the unsent buffer to Pete. Called from the roster ticker.
|
||||||
|
func (p *AdventurePlugin) pushRunBeats() {
|
||||||
|
beats, drop, err := loadRunBeatBatch(runBeatBatch)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("runbeat: load batch failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Beats belonging to an opted-out player are retired locally without ever
|
||||||
|
// going out. Marking them sent (rather than deleting) keeps one code path for
|
||||||
|
// "this row is done with" and lets the retention sweep reap them on its own
|
||||||
|
// clock. Opting back in mid-run loses the earlier beats, which is the right
|
||||||
|
// way round to be wrong.
|
||||||
|
if len(drop) > 0 {
|
||||||
|
if err := markRunBeatsSent(drop); err != nil {
|
||||||
|
slog.Warn("runbeat: retire opted-out beats", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(beats) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := peteclient.PushRunBeats(ctx, beats); err != nil {
|
||||||
|
if runBeatPushOK {
|
||||||
|
slog.Warn("runbeat: push failed, liveblog will lag on Pete", "err", err, "beats", len(beats))
|
||||||
|
} else {
|
||||||
|
slog.Debug("runbeat: push failed, will retry next tick", "err", err)
|
||||||
|
}
|
||||||
|
runBeatPushOK = false
|
||||||
|
return // rows stay unsent: this is the one push that retries
|
||||||
|
}
|
||||||
|
if err := markRunBeatsSent(beats); err != nil {
|
||||||
|
// Delivered but not marked. Pete is idempotent on (run_id, seq), so the
|
||||||
|
// re-send next tick is a no-op there — better than dropping the row.
|
||||||
|
slog.Warn("runbeat: mark sent failed, beats will re-send", "err", err)
|
||||||
|
}
|
||||||
|
if !runBeatPushOK {
|
||||||
|
slog.Info("runbeat: liveblog accepted by Pete", "beats", len(beats))
|
||||||
|
runBeatPushOK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRunBeatBatch reads up to limit unsent beats in (run, seq) order and splits
|
||||||
|
// them into the ones to send and the ones to retire unsent.
|
||||||
|
//
|
||||||
|
// It drains the cursor completely before resolving a single owner, and that is
|
||||||
|
// not a style preference. The pool is one connection wide, so a query issued
|
||||||
|
// while these rows are still open waits for a connection that this loop is
|
||||||
|
// holding and will not release until the loop ends — a deadlock that the roster
|
||||||
|
// ticker would hit on its very first tick with any beat in the buffer.
|
||||||
|
//
|
||||||
|
// The opt-out check is then per *run*, resolved once and cached for the batch: a
|
||||||
|
// run belongs to exactly one player, and re-asking per beat would turn a 200-row
|
||||||
|
// batch into 200 lookups of an answer that cannot change inside one tick.
|
||||||
|
func loadRunBeatBatch(limit int) (send []peteclient.RunBeat, drop []peteclient.RunBeat, err error) {
|
||||||
|
type row struct {
|
||||||
|
runID string
|
||||||
|
seq int64
|
||||||
|
payload string
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, qerr := db.Get().Query(`
|
||||||
|
SELECT run_id, seq, payload
|
||||||
|
FROM pete_run_beat
|
||||||
|
WHERE sent_at IS NULL
|
||||||
|
ORDER BY run_id ASC, seq ASC
|
||||||
|
LIMIT ?`, limit)
|
||||||
|
if qerr != nil {
|
||||||
|
return nil, nil, qerr
|
||||||
|
}
|
||||||
|
var raw []row
|
||||||
|
for rows.Next() {
|
||||||
|
var r row
|
||||||
|
if err := rows.Scan(&r.runID, &r.seq, &r.payload); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
raw = append(raw, r)
|
||||||
|
}
|
||||||
|
rerr := rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if rerr != nil {
|
||||||
|
return nil, nil, rerr
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, r := range raw {
|
||||||
|
var b peteclient.RunBeat
|
||||||
|
if err := json.Unmarshal([]byte(r.payload), &b); err != nil {
|
||||||
|
// An undecodable row is dead weight forever; retire it rather than
|
||||||
|
// letting it head the queue and block every beat behind it.
|
||||||
|
slog.Warn("runbeat: undecodable payload, retiring", "run", r.runID, "seq", r.seq, "err", err)
|
||||||
|
drop = append(drop, peteclient.RunBeat{RunID: r.runID, Seq: r.seq})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.RunID, b.Seq = r.runID, r.seq
|
||||||
|
|
||||||
|
ok, known := allowed[r.runID]
|
||||||
|
if !known {
|
||||||
|
ok = runBeatAllowed(r.runID)
|
||||||
|
allowed[r.runID] = ok
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
send = append(send, b)
|
||||||
|
} else {
|
||||||
|
drop = append(drop, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return send, drop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBeatAllowed reports whether this run's beats may leave the box. A run whose
|
||||||
|
// owner can't be resolved is refused: the liveblog is a public surface, and
|
||||||
|
// "don't know who this is" is not a safe basis for publishing where they are.
|
||||||
|
func runBeatAllowed(runID string) bool {
|
||||||
|
run, err := getZoneRun(runID)
|
||||||
|
if err != nil || run == nil || run.UserID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !isNewsOptedOut(id.UserID(run.UserID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// markRunBeatsSent stamps a batch delivered, in one transaction so a crash
|
||||||
|
// mid-mark can't leave half a run looking unsent and re-send it.
|
||||||
|
func markRunBeatsSent(beats []peteclient.RunBeat) error {
|
||||||
|
if len(beats) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := db.Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(`UPDATE pete_run_beat SET sent_at = ? WHERE run_id = ? AND seq = ?`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
for _, b := range beats {
|
||||||
|
if _, err := stmt.Exec(now, b.RunID, b.Seq); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The emit half of the run liveblog: the handful of places in the walk that
|
||||||
|
// know something worth telling, and the shape they tell it in.
|
||||||
|
//
|
||||||
|
// Every function here is a leaf. They read state, they append a row, they return
|
||||||
|
// nothing. None of them is allowed to change what the engine does or how long it
|
||||||
|
// takes to do it — if a beat can't be recorded, the run carries on exactly as it
|
||||||
|
// did before this file existed.
|
||||||
|
//
|
||||||
|
// The nouns are the payload and the numbers are the payload. No sentence
|
||||||
|
// assembled here ever reaches a reader: Pete writes the words, the same contract
|
||||||
|
// emitFact has always had.
|
||||||
|
|
||||||
|
// beatRunStart opens a run's story: who, where, and how far it goes. The token
|
||||||
|
// is the public board token, so Pete can hang the liveblog off the adventurer
|
||||||
|
// page the roster already links to.
|
||||||
|
//
|
||||||
|
// This is the only beat carrying identity. Every beat after it is keyed on the
|
||||||
|
// run id alone, which means a run whose start beat was dropped is anonymous
|
||||||
|
// rather than misattributed.
|
||||||
|
func beatRunStart(userID id.UserID, run *DungeonRun, zone ZoneDefinition) {
|
||||||
|
if run == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := peteclient.RunBeat{
|
||||||
|
Kind: "start",
|
||||||
|
Token: eventToken(userID, "roster"),
|
||||||
|
Zone: zone.Display,
|
||||||
|
TotalRooms: run.TotalRooms,
|
||||||
|
Room: 1,
|
||||||
|
RoomKind: string(RoomEntry),
|
||||||
|
}
|
||||||
|
if name := charName(userID); name != "" {
|
||||||
|
b.Name = name
|
||||||
|
}
|
||||||
|
if c, err := LoadDnDCharacter(userID); err == nil && c != nil && !c.PendingSetup {
|
||||||
|
b.Level = c.Level
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatRoom records an arrival. outcome distinguishes walking on from doubling
|
||||||
|
// back — the map on the who page already shows *where* the party is, and the
|
||||||
|
// difference between those two is most of what the log adds to it.
|
||||||
|
func beatRoom(run *DungeonRun, node string, idx int, outcome string) {
|
||||||
|
if run == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := peteclient.RunBeat{
|
||||||
|
Kind: "room",
|
||||||
|
Room: idx + 1,
|
||||||
|
TotalRooms: run.TotalRooms,
|
||||||
|
Outcome: outcome,
|
||||||
|
}
|
||||||
|
if g, ok := loadZoneGraph(run.ZoneID); ok {
|
||||||
|
if n, exists := g.Nodes[node]; exists {
|
||||||
|
b.RoomKind = string(nodeKindToRoomType(n.Kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatCombat records one resolved fight: what it was, how it went, and what it
|
||||||
|
// cost. Amount is damage taken by the party's leader — the HP pair is the state
|
||||||
|
// after, so a reader can see the run getting thinner room by room, which is the
|
||||||
|
// tension the Matrix DM has and the web has never had.
|
||||||
|
func beatCombat(run *DungeonRun, monster string, elite, boss, won, timedOut bool,
|
||||||
|
preHP, postHP, maxHP, crits, fumbles int) {
|
||||||
|
if run == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outcome := "won"
|
||||||
|
switch {
|
||||||
|
case won:
|
||||||
|
case timedOut:
|
||||||
|
outcome = "retreat" // outlasted, not killed: mechanically a withdrawal
|
||||||
|
default:
|
||||||
|
outcome = "down"
|
||||||
|
}
|
||||||
|
kind := string(RoomExploration)
|
||||||
|
switch {
|
||||||
|
case boss:
|
||||||
|
kind = string(RoomBoss)
|
||||||
|
case elite:
|
||||||
|
kind = string(RoomElite)
|
||||||
|
}
|
||||||
|
dmg := preHP - postHP
|
||||||
|
if dmg < 0 {
|
||||||
|
dmg = 0 // healed through the fight; "negative damage" is not a fact
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, peteclient.RunBeat{
|
||||||
|
Kind: "combat",
|
||||||
|
Room: run.CurrentRoom + 1,
|
||||||
|
TotalRooms: run.TotalRooms,
|
||||||
|
RoomKind: kind,
|
||||||
|
Target: monster,
|
||||||
|
Outcome: outcome,
|
||||||
|
Amount: dmg,
|
||||||
|
HP: postHP,
|
||||||
|
HPMax: maxHP,
|
||||||
|
Crits: crits,
|
||||||
|
Fumbles: fumbles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatTrap records a sprung trap. A zero-damage trap is still worth a beat: the
|
||||||
|
// near-miss is part of the run, and the log reads wrong if the party walks
|
||||||
|
// through a trap room and nothing at all is said about it.
|
||||||
|
func beatTrap(userID id.UserID, run *DungeonRun, damage int) {
|
||||||
|
if run == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hp, maxHP := dndHPSnapshot(userID)
|
||||||
|
outcome := "sprung"
|
||||||
|
if damage <= 0 {
|
||||||
|
outcome = "avoided"
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, peteclient.RunBeat{
|
||||||
|
Kind: "trap",
|
||||||
|
Room: run.CurrentRoom + 1,
|
||||||
|
TotalRooms: run.TotalRooms,
|
||||||
|
RoomKind: string(RoomTrap),
|
||||||
|
Outcome: outcome,
|
||||||
|
Amount: damage,
|
||||||
|
HP: hp,
|
||||||
|
HPMax: maxHP,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatTreasure records one thing found and kept. One beat per item rather than a
|
||||||
|
// count: an item is a name, and the name is the whole reason anybody reads a
|
||||||
|
// loot line.
|
||||||
|
func beatTreasure(run *DungeonRun, item, source string) {
|
||||||
|
if run == nil || item == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, peteclient.RunBeat{
|
||||||
|
Kind: "treasure",
|
||||||
|
Room: run.CurrentRoom + 1,
|
||||||
|
TotalRooms: run.TotalRooms,
|
||||||
|
Target: item,
|
||||||
|
Outcome: source, // "cache" | "boss" | "zone"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatHaul records a room's auto-harvest take, one beat for the room rather than
|
||||||
|
// one per resource — this is background gathering, and a per-resource beat would
|
||||||
|
// bury the fights it happens between. Target names the biggest single yield so
|
||||||
|
// the line has a noun in it; Amount is the total.
|
||||||
|
func beatHaul(run *DungeonRun, sum autoHarvestSummary) {
|
||||||
|
if run == nil || len(sum.Yields) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
// Deterministic pick: biggest yield, ties broken by name, so re-running the
|
||||||
|
// same room can't produce two different beats from the same map.
|
||||||
|
keys := make([]string, 0, len(sum.Yields))
|
||||||
|
for k, v := range sum.Yields {
|
||||||
|
total += v
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if sum.Yields[keys[i]] != sum.Yields[keys[j]] {
|
||||||
|
return sum.Yields[keys[i]] > sum.Yields[keys[j]]
|
||||||
|
}
|
||||||
|
return keys[i] < keys[j]
|
||||||
|
})
|
||||||
|
top := sum.Names[keys[0]]
|
||||||
|
if top == "" {
|
||||||
|
top = keys[0]
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, peteclient.RunBeat{
|
||||||
|
Kind: "haul",
|
||||||
|
Room: run.CurrentRoom + 1,
|
||||||
|
TotalRooms: run.TotalRooms,
|
||||||
|
Target: top,
|
||||||
|
Amount: total,
|
||||||
|
Count: len(sum.Yields),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatLock records a door the party had to deal with. Only the interesting
|
||||||
|
// outcomes reach here — an unlocked door is not an event.
|
||||||
|
func beatLock(run *DungeonRun, target, outcome string) {
|
||||||
|
if run == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, peteclient.RunBeat{
|
||||||
|
Kind: "lock",
|
||||||
|
Room: run.CurrentRoom + 1,
|
||||||
|
TotalRooms: run.TotalRooms,
|
||||||
|
Target: target,
|
||||||
|
Outcome: outcome, // "picked" | "sealed"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatRegion records a border crossing on a multi-region expedition. The run id
|
||||||
|
// changes at a crossing (each region gets its own run), so this beat closes one
|
||||||
|
// liveblog and the next run's start beat opens the next — naming the region
|
||||||
|
// ahead is what lets Pete stitch them into one journey.
|
||||||
|
func beatRegion(run *DungeonRun, from, to string) {
|
||||||
|
if run == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, peteclient.RunBeat{
|
||||||
|
Kind: "region",
|
||||||
|
Region: from,
|
||||||
|
Target: to,
|
||||||
|
Outcome: "crossed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatRunEnd closes the story. outcome is the only field that matters and it is
|
||||||
|
// the one the whole log is read for.
|
||||||
|
//
|
||||||
|
// First writer wins, and that is load-bearing. A run ends once, but it passes
|
||||||
|
// through more than one place that could say so: a death in the combat resolver
|
||||||
|
// goes on to call abandonZoneRun, and a completed run gets retired by the
|
||||||
|
// expedition layer. The specific callers file first and know what happened; the
|
||||||
|
// generic funnels file "abandoned" and would otherwise overwrite them with the
|
||||||
|
// least informative answer available.
|
||||||
|
func beatRunEnd(run *DungeonRun, outcome string) {
|
||||||
|
if run == nil || runHasEndBeat(run.RunID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordRunBeat(run.RunID, peteclient.RunBeat{
|
||||||
|
Kind: "end",
|
||||||
|
Room: run.CurrentRoom + 1,
|
||||||
|
TotalRooms: run.TotalRooms,
|
||||||
|
Outcome: outcome, // "cleared" | "died" | "retreated" | "abandoned"
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedBeatRun writes a dnd_zone_run row directly. The beat pusher resolves a
|
||||||
|
// run's owner through this table to decide whether the log may leave the box, so
|
||||||
|
// a test that skips it is testing a code path production never takes.
|
||||||
|
func seedBeatRun(t *testing.T, runID string, uid id.UserID) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
INSERT INTO dnd_zone_run
|
||||||
|
(run_id, user_id, zone_id, total_rooms, rooms_cleared, gm_mood,
|
||||||
|
current_node, visited_nodes, node_choices, rooms_traversed)
|
||||||
|
VALUES (?, ?, 'goblin_warrens', 8, '[]', 50, 'goblin_warrens.r1', '["goblin_warrens.r1"]', '{}', 1)`,
|
||||||
|
runID, string(uid)); err != nil {
|
||||||
|
t.Fatalf("seed zone run: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func beatKinds(t *testing.T, runID string) []string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT kind FROM pete_run_beat WHERE run_id = ? ORDER BY seq ASC`, runID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read beats: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBeatSeqIsMonotonicPerRun. (run_id, seq) is the identity Pete is
|
||||||
|
// idempotent on and it is also the render order, so a repeated or missing number
|
||||||
|
// is either a lost beat or a duplicated one. Two runs walking at once must not
|
||||||
|
// share a counter.
|
||||||
|
func TestRunBeatSeqIsMonotonicPerRun(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
recordRunBeat("run-a", peteclient.RunBeat{Kind: "room", Room: i + 1})
|
||||||
|
recordRunBeat("run-b", peteclient.RunBeat{Kind: "room", Room: i + 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, run := range []string{"run-a", "run-b"} {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT seq FROM pete_run_beat WHERE run_id = ? ORDER BY seq ASC`, run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var seqs []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var s int64
|
||||||
|
if err := rows.Scan(&s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seqs = append(seqs, s)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if len(seqs) != 3 {
|
||||||
|
t.Fatalf("%s: %d beats, want 3", run, len(seqs))
|
||||||
|
}
|
||||||
|
for i, s := range seqs {
|
||||||
|
if s != int64(i+1) {
|
||||||
|
t.Errorf("%s: seq[%d] = %d, want %d", run, i, s, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBeatsAreDroppedForOptedOutPlayers is the privacy guard, and it is
|
||||||
|
// stricter than the board's.
|
||||||
|
//
|
||||||
|
// The board omits an opted-out player from a snapshot. The liveblog would be a
|
||||||
|
// room-by-room account of where somebody is and what is happening to them, which
|
||||||
|
// is the most exposing surface in the whole plan — so the rule here is that the
|
||||||
|
// beats never leave the box at all. They are retired locally instead, so the
|
||||||
|
// buffer can't fill up with rows that will never ship.
|
||||||
|
//
|
||||||
|
// It is also the pin on the connection-pool deadlock this originally shipped
|
||||||
|
// with: resolving an owner requires a second query, and doing that with the beat
|
||||||
|
// cursor still open waits forever on a one-connection pool. If loadRunBeatBatch
|
||||||
|
// ever goes back to resolving inside its own rows loop, this test stops failing
|
||||||
|
// and starts HANGING — which is what it did the first time, and is why the note
|
||||||
|
// is here rather than in a comment nobody reads at 3am.
|
||||||
|
func TestRunBeatsAreDroppedForOptedOutPlayers(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@shy:test", "Quack", &now, &now)
|
||||||
|
seedRosterPlayer(t, "@loud:test", "Josie", &now, &now)
|
||||||
|
seedBeatRun(t, "run-shy", "@shy:test")
|
||||||
|
seedBeatRun(t, "run-loud", "@loud:test")
|
||||||
|
setNewsOptout("@shy:test", true)
|
||||||
|
|
||||||
|
recordRunBeat("run-shy", peteclient.RunBeat{Kind: "room", Room: 2})
|
||||||
|
recordRunBeat("run-loud", peteclient.RunBeat{Kind: "room", Room: 2})
|
||||||
|
|
||||||
|
send, drop, err := loadRunBeatBatch(100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadRunBeatBatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(send) != 1 || send[0].RunID != "run-loud" {
|
||||||
|
t.Fatalf("sendable beats = %+v, want only run-loud", send)
|
||||||
|
}
|
||||||
|
if len(drop) != 1 || drop[0].RunID != "run-shy" {
|
||||||
|
t.Fatalf("dropped beats = %+v, want only run-shy", drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retiring means marked sent, not deleted — one code path for "done with this
|
||||||
|
// row", and the retention sweep reaps it on its own clock.
|
||||||
|
if err := markRunBeatsSent(drop); err != nil {
|
||||||
|
t.Fatalf("retire: %v", err)
|
||||||
|
}
|
||||||
|
send, drop, _ = loadRunBeatBatch(100)
|
||||||
|
if len(drop) != 0 {
|
||||||
|
t.Errorf("retired beats came back: %+v", drop)
|
||||||
|
}
|
||||||
|
if len(send) != 1 {
|
||||||
|
t.Errorf("retiring the opted-out beats disturbed the rest: %+v", send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBeatsRefuseAnUnresolvableRun. The liveblog is public. A run whose owner
|
||||||
|
// can't be resolved is not a run we know is safe to publish — "don't know who
|
||||||
|
// this is" is not a basis for saying where they are.
|
||||||
|
func TestRunBeatsRefuseAnUnresolvableRun(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
|
||||||
|
recordRunBeat("run-ghost", peteclient.RunBeat{Kind: "room", Room: 1})
|
||||||
|
|
||||||
|
send, drop, err := loadRunBeatBatch(100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(send) != 0 {
|
||||||
|
t.Errorf("beats for an unknown run were queued for publication: %+v", send)
|
||||||
|
}
|
||||||
|
if len(drop) != 1 {
|
||||||
|
t.Errorf("orphan beats = %d, want 1 retired", len(drop))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunEndIsFirstWriterWins. A run ends once, but it passes through more than
|
||||||
|
// one place that can say so: a death in the combat resolver goes on to call
|
||||||
|
// abandonZoneRun, and the expedition layer retires completed runs. The specific
|
||||||
|
// outcome is filed first and must survive the generic one behind it — a log that
|
||||||
|
// says "abandoned" about somebody who was killed is worse than no log.
|
||||||
|
func TestRunEndIsFirstWriterWins(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
|
||||||
|
seedBeatRun(t, "run-a", "@a:test")
|
||||||
|
run, err := getZoneRun("run-a")
|
||||||
|
if err != nil || run == nil {
|
||||||
|
t.Fatalf("load run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
beatRunEnd(run, "died")
|
||||||
|
beatRunEnd(run, "abandoned")
|
||||||
|
beatRunEnd(run, "cleared")
|
||||||
|
|
||||||
|
if kinds := beatKinds(t, "run-a"); len(kinds) != 1 || kinds[0] != "end" {
|
||||||
|
t.Fatalf("beats = %v, want exactly one end beat", kinds)
|
||||||
|
}
|
||||||
|
send, _, err := loadRunBeatBatch(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(send) != 1 || send[0].Outcome != "died" {
|
||||||
|
t.Errorf("stored outcome = %+v, want the first (specific) close", send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBeatsAreANoOpWhenTheSeamIsOff. The whole channel hangs off the same
|
||||||
|
// master switch as the dispatch queue: with news emission off, nothing is
|
||||||
|
// recorded at all, so turning it off doesn't quietly accrue a buffer that floods
|
||||||
|
// Pete the moment it comes back on.
|
||||||
|
func TestRunBeatsAreANoOpWhenTheSeamIsOff(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
|
||||||
|
recordRunBeat("run-a", peteclient.RunBeat{Kind: "room", Room: 1})
|
||||||
|
if kinds := beatKinds(t, "run-a"); len(kinds) != 0 {
|
||||||
|
t.Errorf("recorded %v with the seam disabled", kinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBeatCombatReadsTheOutcome pins the three fight endings apart. "Outlasted
|
||||||
|
// by the monster" and "killed by the monster" are the same losing branch in the
|
||||||
|
// engine and mechanically different events — one starts a respawn timer and the
|
||||||
|
// other doesn't — so the log must not collapse them.
|
||||||
|
func TestBeatCombatReadsTheOutcome(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
|
||||||
|
seedBeatRun(t, "run-a", "@a:test")
|
||||||
|
run, _ := getZoneRun("run-a")
|
||||||
|
|
||||||
|
beatCombat(run, "Rat", false, false, true, false, 30, 24, 30, 1, 0)
|
||||||
|
beatCombat(run, "Aldric", false, true, false, true, 24, 8, 30, 0, 2)
|
||||||
|
beatCombat(run, "The Rotmother", false, true, false, false, 8, 0, 30, 0, 0)
|
||||||
|
|
||||||
|
send, _, err := loadRunBeatBatch(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(send) != 3 {
|
||||||
|
t.Fatalf("got %d combat beats, want 3", len(send))
|
||||||
|
}
|
||||||
|
want := []string{"won", "retreat", "down"}
|
||||||
|
for i, w := range want {
|
||||||
|
if send[i].Outcome != w {
|
||||||
|
t.Errorf("beat %d outcome = %q, want %q", i, send[i].Outcome, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if send[0].Amount != 6 || send[0].HP != 24 || send[0].HPMax != 30 {
|
||||||
|
t.Errorf("won beat lost its numbers: %+v", send[0])
|
||||||
|
}
|
||||||
|
if send[0].Crits != 1 {
|
||||||
|
t.Errorf("crits = %d, want 1", send[0].Crits)
|
||||||
|
}
|
||||||
|
if send[1].RoomKind != "boss" {
|
||||||
|
t.Errorf("room kind = %q, want boss", send[1].RoomKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBeatCombatNeverReportsNegativeDamage. A party that healed through a fight
|
||||||
|
// finishes on more HP than it started with. "Took −4 damage" is not a fact.
|
||||||
|
func TestBeatCombatNeverReportsNegativeDamage(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
|
||||||
|
seedBeatRun(t, "run-a", "@a:test")
|
||||||
|
run, _ := getZoneRun("run-a")
|
||||||
|
|
||||||
|
beatCombat(run, "Rat", false, false, true, false, 20, 28, 30, 0, 0)
|
||||||
|
|
||||||
|
send, _, _ := loadRunBeatBatch(10)
|
||||||
|
if len(send) != 1 || send[0].Amount != 0 {
|
||||||
|
t.Fatalf("amount = %+v, want 0", send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBeatHaulPicksTheBiggestYieldDeterministically. Go's map order is random,
|
||||||
|
// so a "mostly X" line built off a range would name a different resource every
|
||||||
|
// time the same room was rendered.
|
||||||
|
func TestBeatHaulPicksTheBiggestYieldDeterministically(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
|
||||||
|
for i, runID := range []string{"run-1", "run-2", "run-3", "run-4", "run-5"} {
|
||||||
|
seedBeatRun(t, runID, "@a:test")
|
||||||
|
run, _ := getZoneRun(runID)
|
||||||
|
beatHaul(run, autoHarvestSummary{
|
||||||
|
Yields: map[string]int{"ironcap": 5, "moss": 2, "flint": 1},
|
||||||
|
Names: map[string]string{"ironcap": "Ironcap", "moss": "Moss", "flint": "Flint"},
|
||||||
|
})
|
||||||
|
_ = i
|
||||||
|
}
|
||||||
|
send, _, err := loadRunBeatBatch(20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(send) != 5 {
|
||||||
|
t.Fatalf("got %d haul beats, want 5", len(send))
|
||||||
|
}
|
||||||
|
for _, b := range send {
|
||||||
|
if b.Target != "Ironcap" {
|
||||||
|
t.Fatalf("haul named %q, want Ironcap every time", b.Target)
|
||||||
|
}
|
||||||
|
if b.Amount != 8 || b.Count != 3 {
|
||||||
|
t.Errorf("haul totals = %d over %d kinds, want 8 over 3", b.Amount, b.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing gathered is not a beat.
|
||||||
|
seedBeatRun(t, "run-empty", "@a:test")
|
||||||
|
empty, _ := getZoneRun("run-empty")
|
||||||
|
beatHaul(empty, autoHarvestSummary{})
|
||||||
|
if kinds := beatKinds(t, "run-empty"); len(kinds) != 0 {
|
||||||
|
t.Errorf("an empty haul produced %v", kinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStartingARunOpensItsLog is the end-to-end seam check on the game side: the
|
||||||
|
// engine primitive every zone entry goes through files the one beat that carries
|
||||||
|
// identity, so nothing downstream has to be told who is walking.
|
||||||
|
func TestStartingARunOpensItsLog(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
enablePeteSeam(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
|
||||||
|
run, err := startZoneRun("@a:test", "goblin_warrens", 5, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startZoneRun: %v", err)
|
||||||
|
}
|
||||||
|
send, _, err := loadRunBeatBatch(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(send) != 1 || send[0].Kind != "start" {
|
||||||
|
t.Fatalf("beats = %+v, want one start", send)
|
||||||
|
}
|
||||||
|
b := send[0]
|
||||||
|
if b.RunID != run.RunID {
|
||||||
|
t.Errorf("start beat run = %q, want %q", b.RunID, run.RunID)
|
||||||
|
}
|
||||||
|
if b.Name != "Josie" || b.Level != 5 {
|
||||||
|
t.Errorf("start beat identity = %q L%d, want Josie L5", b.Name, b.Level)
|
||||||
|
}
|
||||||
|
if b.Token == "" || b.Token != eventToken("@a:test", "roster") {
|
||||||
|
t.Errorf("start beat token = %q, want the public board token", b.Token)
|
||||||
|
}
|
||||||
|
if b.TotalRooms != run.TotalRooms {
|
||||||
|
t.Errorf("start beat rooms = %d, want %d", b.TotalRooms, run.TotalRooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Siege war room, pushed to Pete.
|
||||||
|
//
|
||||||
|
// The Siege is the only mechanic where the whole town works on one object, and
|
||||||
|
// until now it existed exclusively in Matrix — which means anybody not in the
|
||||||
|
// room at the time never knew it happened. A communal event nobody can see is a
|
||||||
|
// communal event that fails.
|
||||||
|
//
|
||||||
|
// It rides the roster ticker and follows the roster's rules exactly, because it
|
||||||
|
// is the same kind of thing: a snapshot of what is currently true, pushed whole,
|
||||||
|
// replacing whatever Pete had, dropped rather than retried on failure. A retried
|
||||||
|
// snapshot would be a lie about how much HP is left.
|
||||||
|
//
|
||||||
|
// The one place it deliberately departs from the board is the opt-out. The board
|
||||||
|
// omits an opted-out player entirely — a row showing class + level + zone is
|
||||||
|
// trivially re-identifiable, so absence is the only honest option there. Here a
|
||||||
|
// contributor is anonymised instead of dropped: their damage is part of what the
|
||||||
|
// town did to the boss, and a defender board that quietly deleted it would
|
||||||
|
// understate the shared effort and stop the numbers adding up. An opted-out
|
||||||
|
// player who has NOT fought is still omitted — there is nothing to account for,
|
||||||
|
// so naming their absence would be exposure for nothing.
|
||||||
|
|
||||||
|
// siegeHistoryLimit bounds the "sieges past" table. A Siege a month means this is
|
||||||
|
// years of history; the cap only exists so the payload can't grow without bound.
|
||||||
|
const siegeHistoryLimit = 24
|
||||||
|
|
||||||
|
// pushSiege builds and sends the war room. Mirrors pushRoster: transitions are
|
||||||
|
// logged, the steady state is silent.
|
||||||
|
var siegePushOK bool
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) pushSiege() {
|
||||||
|
snap, err := buildSiegeSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("siege: build snapshot failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := peteclient.PushSiege(ctx, snap); err != nil {
|
||||||
|
if siegePushOK {
|
||||||
|
slog.Warn("siege: push failed, war room will go stale on Pete", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("siege: push failed, dropping snapshot", "err", err)
|
||||||
|
}
|
||||||
|
siegePushOK = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !siegePushOK {
|
||||||
|
slog.Info("siege: war room accepted by Pete", "active", snap.Active, "defenders", len(snap.Defenders))
|
||||||
|
siegePushOK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSiegeSnapshot assembles the whole war room: the live boss, the muster,
|
||||||
|
// and the history.
|
||||||
|
func buildSiegeSnapshot(now time.Time) (peteclient.SiegeSnapshot, error) {
|
||||||
|
snap := peteclient.SiegeSnapshot{SnapshotAt: now.Unix()}
|
||||||
|
|
||||||
|
hist, err := loadResolvedWorldBosses(siegeHistoryLimit)
|
||||||
|
if err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
snap.History = hist
|
||||||
|
|
||||||
|
boss, err := loadActiveWorldBoss()
|
||||||
|
if err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
if boss == nil {
|
||||||
|
return snap, nil // no Siege camped: a real answer, not an empty snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.Active = true
|
||||||
|
snap.BossID = boss.ID
|
||||||
|
snap.BossName = boss.Name
|
||||||
|
snap.Tier = boss.Tier
|
||||||
|
snap.HPCurrent = boss.HPCurrent
|
||||||
|
snap.HPMax = boss.HPMax
|
||||||
|
snap.StartsAt = boss.StartsAt.Unix()
|
||||||
|
snap.EndsAt = boss.EndsAt.Unix()
|
||||||
|
|
||||||
|
defenders, boutsToday, err := buildSiegeMuster(boss.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
snap.Defenders = defenders
|
||||||
|
snap.BoutsToday = boutsToday
|
||||||
|
return snap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSiegeMuster returns every alive adventurer's standing against this boss,
|
||||||
|
// ranked, plus how many bouts have been taken today.
|
||||||
|
//
|
||||||
|
// It starts from the contribution rows rather than from the roster so a
|
||||||
|
// contributor who has since died (or whose player_meta row went away) still
|
||||||
|
// appears — the damage they did is on the boss whether they are standing or not.
|
||||||
|
// The alive roster is then folded in on top to produce the zero-fight rows that
|
||||||
|
// make the "bout still going spare" column exist.
|
||||||
|
func buildSiegeMuster(bossID int64, now time.Time) ([]peteclient.SiegeDefender, int, error) {
|
||||||
|
contribs, err := loadWorldBossContribs(bossID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
|
||||||
|
byUser := make(map[id.UserID]worldBossContrib, len(contribs))
|
||||||
|
for _, c := range contribs {
|
||||||
|
byUser[c.UserID] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everyone alive, so the un-fought have a row to stand in.
|
||||||
|
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
order := make([]id.UserID, 0, len(contribs))
|
||||||
|
seen := make(map[id.UserID]bool, len(contribs))
|
||||||
|
for rows.Next() {
|
||||||
|
var uid string
|
||||||
|
if err := rows.Scan(&uid); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
u := id.UserID(uid)
|
||||||
|
if !seen[u] {
|
||||||
|
seen[u] = true
|
||||||
|
order = append(order, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
// Contributors who are no longer on the alive roster still owe the board a row.
|
||||||
|
for _, c := range contribs {
|
||||||
|
if !seen[c.UserID] {
|
||||||
|
seen[c.UserID] = true
|
||||||
|
order = append(order, c.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boutsToday := 0
|
||||||
|
out := make([]peteclient.SiegeDefender, 0, len(order))
|
||||||
|
for _, uid := range order {
|
||||||
|
c, fought := byUser[uid]
|
||||||
|
if fought && c.LastFightDate == today {
|
||||||
|
boutsToday++
|
||||||
|
}
|
||||||
|
optedOut := isNewsOptedOut(uid)
|
||||||
|
if optedOut && !fought {
|
||||||
|
continue // nothing to account for; naming the absence is exposure for nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
d := peteclient.SiegeDefender{Name: anonName}
|
||||||
|
if fought {
|
||||||
|
d.Fights = c.Fights
|
||||||
|
d.Damage = c.Damage
|
||||||
|
d.FoughtToday = c.LastFightDate == today
|
||||||
|
}
|
||||||
|
if !optedOut {
|
||||||
|
name := charName(uid)
|
||||||
|
if name == "" {
|
||||||
|
// No character name means no honest way to render the row: never fall
|
||||||
|
// back to a Matrix handle on a public page. A contributor in this state
|
||||||
|
// keeps their damage, anonymously; a non-contributor is simply dropped.
|
||||||
|
if !fought {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d.Name = name
|
||||||
|
d.Token = eventToken(uid, "roster")
|
||||||
|
if ch, err := LoadDnDCharacter(uid); err == nil && ch != nil && !ch.PendingSetup {
|
||||||
|
d.Level = ch.Level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank: damage, then bouts, then name. Deterministic to the last key so an
|
||||||
|
// unchanged muster produces a byte-identical snapshot and Pete's board doesn't
|
||||||
|
// reshuffle itself every two minutes.
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Damage != out[j].Damage {
|
||||||
|
return out[i].Damage > out[j].Damage
|
||||||
|
}
|
||||||
|
if out[i].Fights != out[j].Fights {
|
||||||
|
return out[i].Fights > out[j].Fights
|
||||||
|
}
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
})
|
||||||
|
return out, boutsToday, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadResolvedWorldBosses reads the closed-out Sieges, newest first, each with
|
||||||
|
// its defender count and the contributor who turned up most.
|
||||||
|
func loadResolvedWorldBosses(limit int) ([]peteclient.SiegePast, error) {
|
||||||
|
// resolved_at is selected raw and folded in Go, never COALESCE()'d in SQL:
|
||||||
|
// modernc.org/sqlite rebuilds a time.Time from the column's DECLARED type and
|
||||||
|
// COALESCE erases that affinity, so the Scan would fail. Same trap
|
||||||
|
// buildRosterSnapshot documents.
|
||||||
|
rows, err := db.Get().Query(`
|
||||||
|
SELECT id, name, tier, hp_max, hp_current, status, resolved_at, ends_at
|
||||||
|
FROM world_boss
|
||||||
|
WHERE status IN ('defeated', 'survived')
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []peteclient.SiegePast
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
h peteclient.SiegePast
|
||||||
|
resolvedAt, endsAt *time.Time
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&h.BossID, &h.BossName, &h.Tier, &h.HPMax, &h.HPRemaining,
|
||||||
|
&h.Outcome, &resolvedAt, &endsAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A boss resolved by the ticker has resolved_at; a legacy row might not.
|
||||||
|
// The window's close is the honest fallback — it is when the Siege ended
|
||||||
|
// either way, and it is never null.
|
||||||
|
switch {
|
||||||
|
case resolvedAt != nil:
|
||||||
|
h.EndedAt = resolvedAt.Unix()
|
||||||
|
case endsAt != nil:
|
||||||
|
h.EndedAt = endsAt.Unix()
|
||||||
|
}
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range out {
|
||||||
|
n, mvp, fights, err := worldBossMuster(out[i].BossID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("siege: history muster load failed", "boss", out[i].BossID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[i].Defenders = n
|
||||||
|
out[i].MVP = mvp
|
||||||
|
out[i].MVPFights = fights
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// worldBossMuster reports how many people fought a boss and who fought it most.
|
||||||
|
// The MVP is by fights, not damage — the same accessibility call the payout
|
||||||
|
// split makes (computeWorldBossPayouts): turning up is the contribution the
|
||||||
|
// mechanic actually asks for. An opted-out MVP is anonymised, never dropped;
|
||||||
|
// the count behind the name is a fact about the town.
|
||||||
|
func worldBossMuster(bossID int64) (defenders int, mvp string, mvpFights int, err error) {
|
||||||
|
contribs, err := loadWorldBossContribs(bossID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", 0, err
|
||||||
|
}
|
||||||
|
// loadWorldBossContribs already orders by fights desc, damage desc, so the
|
||||||
|
// first row with any fights at all is the MVP.
|
||||||
|
for _, c := range contribs {
|
||||||
|
if c.Fights <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defenders++
|
||||||
|
if mvp == "" {
|
||||||
|
mvp = anonName
|
||||||
|
if !isNewsOptedOut(c.UserID) {
|
||||||
|
if name := charName(c.UserID); name != "" {
|
||||||
|
mvp = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mvpFights = c.Fights
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defenders, mvp, mvpFights, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispatches ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// emitSiegeStart files the "a boss is at the gates" dispatch. PRIORITY: this is
|
||||||
|
// the one beat where the right response is for everyone to look up right now,
|
||||||
|
// and unlike a zone clear it is not something TwinBee has already announced to
|
||||||
|
// the same people — the games-room shout and this go to different rooms.
|
||||||
|
//
|
||||||
|
// Realm-level, so there is no subject player and no opt-out to apply.
|
||||||
|
func emitSiegeStart(boss *worldBossState) {
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: siegeGUID("siege_start", boss.ID),
|
||||||
|
EventType: "siege_start",
|
||||||
|
Tier: "priority",
|
||||||
|
Boss: boss.Name,
|
||||||
|
Level: boss.Tier,
|
||||||
|
Stakes: siegeWindowPhrase(boss),
|
||||||
|
OccurredAt: boss.StartsAt.Unix(),
|
||||||
|
}, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitSiegeWin files the "the town held" dispatch. Count is the number of
|
||||||
|
// defenders, which is what Pete's template reads to say how many stood.
|
||||||
|
func emitSiegeWin(boss *worldBossState, defenders int) {
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: siegeGUID("siege_win", boss.ID),
|
||||||
|
EventType: "siege_win",
|
||||||
|
Tier: "priority",
|
||||||
|
Boss: boss.Name,
|
||||||
|
Level: boss.Tier,
|
||||||
|
Count: defenders,
|
||||||
|
Outcome: "defeated",
|
||||||
|
OccurredAt: nowUnix(),
|
||||||
|
}, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitSiegeLoss files the "it broke through" dispatch.
|
||||||
|
func emitSiegeLoss(boss *worldBossState) {
|
||||||
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitFact(peteclient.Fact{
|
||||||
|
GUID: siegeGUID("siege_loss", boss.ID),
|
||||||
|
EventType: "siege_loss",
|
||||||
|
Tier: "priority",
|
||||||
|
Boss: boss.Name,
|
||||||
|
Level: boss.Tier,
|
||||||
|
Outcome: "survived",
|
||||||
|
OccurredAt: nowUnix(),
|
||||||
|
}, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// siegeGUID keys a Siege dispatch on the boss row id, which is unique and stable
|
||||||
|
// for the life of the event. That makes each of the three beats fire at most
|
||||||
|
// once per Siege however many times its resolution path is re-entered — the
|
||||||
|
// status guard in setWorldBossStatus already dedupes the payout, and this dedupes
|
||||||
|
// the news the same way.
|
||||||
|
func siegeGUID(eventType string, bossID int64) string {
|
||||||
|
return eventType + ":" + strconv.FormatInt(bossID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// siegeWindowPhrase is the deadline as Pete's siege_start template wants it —
|
||||||
|
// "You've got %s" — so it must read as a duration, not a timestamp.
|
||||||
|
func siegeWindowPhrase(boss *worldBossState) string {
|
||||||
|
h := int(boss.EndsAt.Sub(boss.StartsAt).Hours())
|
||||||
|
switch {
|
||||||
|
case h <= 0:
|
||||||
|
return "no time at all"
|
||||||
|
case h == 24:
|
||||||
|
return "a day"
|
||||||
|
case h%24 == 0:
|
||||||
|
return strconv.Itoa(h/24) + " days"
|
||||||
|
default:
|
||||||
|
return strconv.Itoa(h) + " hours"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedSiege writes a world_boss row directly and returns it. Real rows on
|
||||||
|
// purpose: the history query scans two declared DATETIME columns (resolved_at,
|
||||||
|
// ends_at) and the modernc affinity trap only fires against actual stored
|
||||||
|
// values, never against a hand-built struct.
|
||||||
|
func seedSiege(t *testing.T, name string, tier, hpMax, hpCurrent int, status string, starts, ends time.Time, resolved *time.Time) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Get().Exec(
|
||||||
|
`INSERT INTO world_boss (name, tier, hp_max, hp_current, status, starts_at, ends_at, resolved_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
name, tier, hpMax, hpCurrent, status, starts, ends, resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed world_boss: %v", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedContrib(t *testing.T, bossID int64, uid id.UserID, fights, damage int, lastDate string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`INSERT INTO world_boss_contrib (boss_id, user_id, fights, damage, last_fight_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`, bossID, string(uid), fights, damage, lastDate); err != nil {
|
||||||
|
t.Fatalf("seed contrib: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeSnapshotMustersEveryoneAlive is the reason the payload carries
|
||||||
|
// zero-fight rows at all. The mechanic is one bout per person per day, so the
|
||||||
|
// interesting number is not "who fought" but "who still could" — and Pete can
|
||||||
|
// only draw that column if the people in it are on the wire. A snapshot of
|
||||||
|
// contributors alone would render a board that quietly congratulates itself.
|
||||||
|
func TestSiegeSnapshotMustersEveryoneAlive(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-40 * time.Hour)
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@a:test", "Josie", &old, &old)
|
||||||
|
seedRosterPlayer(t, "@b:test", "Quack", &old, &old)
|
||||||
|
seedRosterPlayer(t, "@c:test", "Camcast", &old, &old)
|
||||||
|
|
||||||
|
bossID := seedSiege(t, "Gorloth the Sunderer", 4, 1000, 400, "active",
|
||||||
|
now.Add(-2*time.Hour), now.Add(70*time.Hour), nil)
|
||||||
|
seedContrib(t, bossID, "@a:test", 3, 500, today)
|
||||||
|
seedContrib(t, bossID, "@b:test", 1, 100, now.AddDate(0, 0, -1).Format("2006-01-02"))
|
||||||
|
|
||||||
|
snap, err := buildSiegeSnapshot(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildSiegeSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if !snap.Active || snap.BossName != "Gorloth the Sunderer" {
|
||||||
|
t.Fatalf("snapshot missed the live boss: %+v", snap)
|
||||||
|
}
|
||||||
|
if snap.HPCurrent != 400 || snap.HPMax != 1000 {
|
||||||
|
t.Errorf("pool = %d/%d, want 400/1000", snap.HPCurrent, snap.HPMax)
|
||||||
|
}
|
||||||
|
if len(snap.Defenders) != 3 {
|
||||||
|
t.Fatalf("muster has %d rows, want 3 — the un-fought must have a row to stand in", len(snap.Defenders))
|
||||||
|
}
|
||||||
|
if snap.BoutsToday != 1 {
|
||||||
|
t.Errorf("bouts_today = %d, want 1 — only Josie has been out today", snap.BoutsToday)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranked by damage: Josie, Quack, then the adventurer who hasn't started.
|
||||||
|
if snap.Defenders[0].Name != "Josie" || !snap.Defenders[0].FoughtToday {
|
||||||
|
t.Errorf("top of the muster = %+v, want Josie having fought today", snap.Defenders[0])
|
||||||
|
}
|
||||||
|
if snap.Defenders[1].Name != "Quack" || snap.Defenders[1].FoughtToday {
|
||||||
|
t.Errorf("second = %+v, want Quack with a bout still spare (hers was yesterday)", snap.Defenders[1])
|
||||||
|
}
|
||||||
|
if snap.Defenders[2].Name != "Camcast" || snap.Defenders[2].Fights != 0 {
|
||||||
|
t.Errorf("third = %+v, want Camcast at zero fights", snap.Defenders[2])
|
||||||
|
}
|
||||||
|
for _, d := range snap.Defenders {
|
||||||
|
if d.Token == "" {
|
||||||
|
t.Errorf("%s has no board token — the defender board can't link to their page", d.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeOptOutAnonymisesContributorAndDropsBystander is the one place the
|
||||||
|
// Siege deliberately breaks the board's opt-out rule, so it is worth pinning
|
||||||
|
// both halves.
|
||||||
|
//
|
||||||
|
// The board omits an opted-out player outright: a row showing class + level +
|
||||||
|
// zone re-identifies them, so absence is the only honest option. Here a
|
||||||
|
// CONTRIBUTOR is anonymised instead — the damage they did is on the boss and is
|
||||||
|
// part of what the town accomplished, and deleting it would understate the
|
||||||
|
// shared effort and stop the totals adding up. A non-contributor is still
|
||||||
|
// dropped, because there is nothing to account for and naming their absence
|
||||||
|
// would be exposure for nothing.
|
||||||
|
func TestSiegeOptOutAnonymisesContributorAndDropsBystander(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-40 * time.Hour)
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@shy:test", "Ghost", &old, &old) // opted out, fought
|
||||||
|
seedRosterPlayer(t, "@lurk:test", "Silent", &old, &old) // opted out, never fought
|
||||||
|
seedRosterPlayer(t, "@open:test", "Josie", &old, &old) // opted in, fought
|
||||||
|
setNewsOptout("@shy:test", true)
|
||||||
|
setNewsOptout("@lurk:test", true)
|
||||||
|
|
||||||
|
bossID := seedSiege(t, "The Iron Colossus", 4, 1000, 200, "active",
|
||||||
|
now.Add(-time.Hour), now.Add(71*time.Hour), nil)
|
||||||
|
seedContrib(t, bossID, "@shy:test", 4, 600, today)
|
||||||
|
seedContrib(t, bossID, "@open:test", 1, 200, today)
|
||||||
|
|
||||||
|
snap, err := buildSiegeSnapshot(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildSiegeSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.Defenders) != 2 {
|
||||||
|
t.Fatalf("muster has %d rows, want 2 — the opted-out bystander should be gone and the opted-out contributor kept", len(snap.Defenders))
|
||||||
|
}
|
||||||
|
|
||||||
|
top := snap.Defenders[0]
|
||||||
|
if top.Damage != 600 {
|
||||||
|
t.Fatalf("top of the muster did %d damage, want 600 — the anonymous contributor lost their rank", top.Damage)
|
||||||
|
}
|
||||||
|
if top.Name != anonName {
|
||||||
|
t.Errorf("opted-out contributor rendered as %q, want %q", top.Name, anonName)
|
||||||
|
}
|
||||||
|
if top.Token != "" {
|
||||||
|
t.Error("opted-out contributor carries a board token — that is a link straight back to a page that names them")
|
||||||
|
}
|
||||||
|
if top.Level != 0 {
|
||||||
|
t.Errorf("opted-out contributor leaked level %d — level + damage is most of a re-identification", top.Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range snap.Defenders {
|
||||||
|
if d.Name == "Silent" || d.Name == "Ghost" {
|
||||||
|
t.Errorf("opted-out player %q reached the wire under their character name", d.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeHistoryReadsResolvedClock is the scan-affinity guard for the history
|
||||||
|
// query, the exact trap buildRosterSnapshot documents: resolved_at and ends_at
|
||||||
|
// are declared DATETIME, and a COALESCE() in the SQL would erase that affinity
|
||||||
|
// so the Scan fails — which would silently publish an empty history rather than
|
||||||
|
// anything obviously broken.
|
||||||
|
func TestSiegeHistoryReadsResolvedClock(t *testing.T) {
|
||||||
|
newBoredomTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-40 * time.Hour)
|
||||||
|
|
||||||
|
seedRosterPlayer(t, "@a:test", "Josie", &old, &old)
|
||||||
|
seedRosterPlayer(t, "@b:test", "Quack", &old, &old)
|
||||||
|
|
||||||
|
resolved := now.Add(-24 * time.Hour)
|
||||||
|
won := seedSiege(t, "The Ashen Wyrm", 5, 1200, 0, "defeated",
|
||||||
|
now.Add(-96*time.Hour), now.Add(-24*time.Hour), &resolved)
|
||||||
|
seedContrib(t, won, "@a:test", 6, 700, "2026-07-01")
|
||||||
|
seedContrib(t, won, "@b:test", 2, 500, "2026-07-01")
|
||||||
|
|
||||||
|
// A legacy row with no resolved_at must still date itself — off the window's
|
||||||
|
// close, which is when the Siege ended either way and is never null.
|
||||||
|
lost := seedSiege(t, "Kravok, Maw of the Deep", 4, 800, 300, "survived",
|
||||||
|
now.Add(-200*time.Hour), now.Add(-128*time.Hour), nil)
|
||||||
|
seedContrib(t, lost, "@a:test", 1, 500, "2026-06-01")
|
||||||
|
|
||||||
|
snap, err := buildSiegeSnapshot(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildSiegeSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if snap.Active {
|
||||||
|
t.Error("no boss is camped but the snapshot claims one is")
|
||||||
|
}
|
||||||
|
if len(snap.History) != 2 {
|
||||||
|
t.Fatalf("history has %d rows, want 2", len(snap.History))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first (id desc): the survived Kravok was inserted last.
|
||||||
|
h := snap.History[0]
|
||||||
|
if h.BossName != "Kravok, Maw of the Deep" || h.Outcome != "survived" {
|
||||||
|
t.Fatalf("history[0] = %+v, want the survived Kravok first", h)
|
||||||
|
}
|
||||||
|
if h.HPRemaining != 300 || h.HPMax != 800 {
|
||||||
|
t.Errorf("survived bar = %d/%d, want 300/800", h.HPRemaining, h.HPMax)
|
||||||
|
}
|
||||||
|
if h.EndedAt != now.Add(-128*time.Hour).Unix() {
|
||||||
|
t.Errorf("legacy row dated %d, want the window close %d", h.EndedAt, now.Add(-128*time.Hour).Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
w := snap.History[1]
|
||||||
|
if w.Outcome != "defeated" || w.HPRemaining != 0 {
|
||||||
|
t.Errorf("defeated Siege = %+v, want a pool at zero", w)
|
||||||
|
}
|
||||||
|
if w.EndedAt != resolved.Unix() {
|
||||||
|
t.Errorf("resolved row dated %d, want resolved_at %d", w.EndedAt, resolved.Unix())
|
||||||
|
}
|
||||||
|
if w.Defenders != 2 {
|
||||||
|
t.Errorf("defenders = %d, want 2", w.Defenders)
|
||||||
|
}
|
||||||
|
// MVP is by fights, not damage — the same accessibility call the payout split
|
||||||
|
// makes. Josie fought six times for 700; had it been by damage she'd still win,
|
||||||
|
// so the ordering is pinned by loadWorldBossContribs' fights-desc ordering.
|
||||||
|
if w.MVP != "Josie" || w.MVPFights != 6 {
|
||||||
|
t.Errorf("MVP = %q with %d fights, want Josie with 6", w.MVP, w.MVPFights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeDispatchesFireOncePerSiege. The three Siege beats key their GUID on
|
||||||
|
// the boss row id, so a resolution path re-entered (a redeploy mid-window, the
|
||||||
|
// ticker's safety net firing after an inline kill) files the same dispatch guid
|
||||||
|
// and Pete dedupes it. Without that, a restart could announce the same Siege
|
||||||
|
// twice to the whole room.
|
||||||
|
func TestSiegeDispatchesFireOncePerSiege(t *testing.T) {
|
||||||
|
if a, b := siegeGUID("siege_start", 7), siegeGUID("siege_start", 7); a != b {
|
||||||
|
t.Errorf("guid not stable: %q vs %q", a, b)
|
||||||
|
}
|
||||||
|
if a, b := siegeGUID("siege_start", 7), siegeGUID("siege_start", 8); a == b {
|
||||||
|
t.Errorf("two different Sieges share guid %q", a)
|
||||||
|
}
|
||||||
|
if a, b := siegeGUID("siege_win", 7), siegeGUID("siege_loss", 7); a == b {
|
||||||
|
t.Errorf("win and loss share guid %q — one Siege cannot file both", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiegeWindowPhrase: the siege_start template says "You've got %s", so the
|
||||||
|
// stakes field has to read as a duration in a sentence, not as a timestamp.
|
||||||
|
func TestSiegeWindowPhrase(t *testing.T) {
|
||||||
|
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
window time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{worldBossWindow, "3 days"},
|
||||||
|
{24 * time.Hour, "a day"},
|
||||||
|
{36 * time.Hour, "36 hours"},
|
||||||
|
{0, "no time at all"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
b := &worldBossState{StartsAt: base, EndsAt: base.Add(c.window)}
|
||||||
|
if got := siegeWindowPhrase(b); got != c.want {
|
||||||
|
t.Errorf("siegeWindowPhrase(%v) = %q, want %q", c.window, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+217
-11
@@ -83,9 +83,17 @@ func PluginVersion(p Plugin) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dmCache maps user IDs to their DM room IDs to avoid creating duplicate rooms.
|
// dmCache maps user IDs to their DM room IDs to avoid creating duplicate rooms.
|
||||||
|
// It fronts the dm_rooms table; notDMCache is the negative half, marking rooms
|
||||||
|
// LearnDMRoom has already ruled out so group rooms cost one member lookup ever.
|
||||||
var (
|
var (
|
||||||
dmCache = make(map[id.UserID]id.RoomID)
|
dmCache = make(map[id.UserID]id.RoomID)
|
||||||
dmCacheMu sync.Mutex
|
dmMapped = make(map[id.UserID]bool)
|
||||||
|
notDMCache = make(map[id.RoomID]bool)
|
||||||
|
dmCacheMu sync.Mutex
|
||||||
|
|
||||||
|
// One-shot index of two-person rooms, built by findExistingDMRoom.
|
||||||
|
dmSweepOnce sync.Once
|
||||||
|
dmSweepIndex = make(map[id.UserID]id.RoomID)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Base provides common helpers for plugin implementations.
|
// Base provides common helpers for plugin implementations.
|
||||||
@@ -686,6 +694,180 @@ func (b *Base) SendReact(roomID id.RoomID, eventID id.EventID, emoji string) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rememberDMRoom pins a user's DM room in both the in-process cache and the
|
||||||
|
// database, so a restart doesn't send the bot off creating a duplicate room.
|
||||||
|
func rememberDMRoom(userID id.UserID, roomID id.RoomID) {
|
||||||
|
dmCacheMu.Lock()
|
||||||
|
dmCache[userID] = roomID
|
||||||
|
dmMapped[userID] = true
|
||||||
|
dmCacheMu.Unlock()
|
||||||
|
|
||||||
|
if d := db.Get(); d != nil {
|
||||||
|
_, err := d.Exec(`INSERT INTO dm_rooms (user_id, room_id, updated_at)
|
||||||
|
VALUES (?, ?, unixepoch())
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET room_id = excluded.room_id, updated_at = excluded.updated_at`,
|
||||||
|
string(userID), string(roomID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("persist dm room", "user", userID, "room", roomID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetDMRoom drops a mapping that no longer resolves to a live shared room.
|
||||||
|
func forgetDMRoom(userID id.UserID) {
|
||||||
|
dmCacheMu.Lock()
|
||||||
|
delete(dmCache, userID)
|
||||||
|
delete(dmMapped, userID)
|
||||||
|
dmCacheMu.Unlock()
|
||||||
|
|
||||||
|
if d := db.Get(); d != nil {
|
||||||
|
if _, err := d.Exec(`DELETE FROM dm_rooms WHERE user_id = ?`, string(userID)); err != nil {
|
||||||
|
slog.Error("forget dm room", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedDMRoom reads the persisted DM room for a user, if any.
|
||||||
|
func storedDMRoom(userID id.UserID) (id.RoomID, bool) {
|
||||||
|
d := db.Get()
|
||||||
|
if d == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var roomID string
|
||||||
|
err := d.QueryRow(`SELECT room_id FROM dm_rooms WHERE user_id = ?`, string(userID)).Scan(&roomID)
|
||||||
|
if err != nil || roomID == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return id.RoomID(roomID), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmRoomUsable reports whether the bot and the user still share the room. The
|
||||||
|
// user counts as present while merely invited — they often never accept, and
|
||||||
|
// treating that as "gone" is what would recreate the room on every send.
|
||||||
|
func (b *Base) dmRoomUsable(roomID id.RoomID, userID id.UserID) bool {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var self event.MemberEventContent
|
||||||
|
if err := b.Client.StateEvent(ctx, roomID, event.StateMember, string(b.Client.UserID), &self); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if self.Membership != event.MembershipJoin {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var other event.MemberEventContent
|
||||||
|
if err := b.Client.StateEvent(ctx, roomID, event.StateMember, string(userID), &other); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return other.Membership == event.MembershipJoin || other.Membership == event.MembershipInvite
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishDirect appends the room to the bot's m.direct account data, so the
|
||||||
|
// room is labelled as a DM rather than a nameless private room.
|
||||||
|
func (b *Base) publishDirect(userID id.UserID, roomID id.RoomID) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dmRooms := map[id.UserID][]id.RoomID{}
|
||||||
|
// A missing m.direct is a 404 — start from empty rather than bailing.
|
||||||
|
_ = b.Client.GetAccountData(ctx, "m.direct", &dmRooms)
|
||||||
|
|
||||||
|
for _, existing := range dmRooms[userID] {
|
||||||
|
if existing == roomID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dmRooms[userID] = append(dmRooms[userID], roomID)
|
||||||
|
|
||||||
|
if err := b.Client.SetAccountData(ctx, "m.direct", dmRooms); err != nil {
|
||||||
|
slog.Warn("publish m.direct", "user", userID, "room", roomID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordDMRoom claims a room as the user's DM room. Used for user-initiated
|
||||||
|
// DM invites, where the invite itself is the intent — it overwrites any older
|
||||||
|
// mapping, since the user just told us which room they want to talk in.
|
||||||
|
func (b *Base) RecordDMRoom(userID id.UserID, roomID id.RoomID) {
|
||||||
|
slog.Info("recorded user-initiated DM room", "user", userID, "room", roomID)
|
||||||
|
rememberDMRoom(userID, roomID)
|
||||||
|
b.publishDirect(userID, roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LearnDMRoom records a room the user messaged the bot in as their DM room,
|
||||||
|
// when it really is a two-person room. This adopts DM rooms that predate the
|
||||||
|
// database mapping instead of leaving them orphaned beside a freshly created one.
|
||||||
|
func (b *Base) LearnDMRoom(userID id.UserID, roomID id.RoomID) {
|
||||||
|
if b == nil || b.Client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dmCacheMu.Lock()
|
||||||
|
_, known := dmCache[userID]
|
||||||
|
mapped := dmMapped[userID]
|
||||||
|
notDM := notDMCache[roomID]
|
||||||
|
dmCacheMu.Unlock()
|
||||||
|
if known || mapped || notDM {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := storedDMRoom(userID); ok {
|
||||||
|
// Note it as mapped, not as resolved: caching the room here would let
|
||||||
|
// GetDMRoom skip its liveness check on a possibly-stale room.
|
||||||
|
dmCacheMu.Lock()
|
||||||
|
dmMapped[userID] = true
|
||||||
|
dmCacheMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := b.Client.JoinedMembers(context.Background(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, botIn := members.Joined[b.Client.UserID]
|
||||||
|
_, userIn := members.Joined[userID]
|
||||||
|
if len(members.Joined) != 2 || !botIn || !userIn {
|
||||||
|
// Group room — remember that so every later message here is free.
|
||||||
|
dmCacheMu.Lock()
|
||||||
|
notDMCache[roomID] = true
|
||||||
|
dmCacheMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("adopted existing DM room", "user", userID, "room", roomID)
|
||||||
|
rememberDMRoom(userID, roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findExistingDMRoom scans the bot's joined rooms for a two-person room shared
|
||||||
|
// with userID. The scan is expensive (one member lookup per room), so it runs
|
||||||
|
// at most once per process and only on the path that would otherwise create a
|
||||||
|
// duplicate room.
|
||||||
|
func (b *Base) findExistingDMRoom(userID id.UserID) (id.RoomID, bool) {
|
||||||
|
dmSweepOnce.Do(func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
joined, err := b.Client.JoinedRooms(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("DM sweep: list joined rooms", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, roomID := range joined.JoinedRooms {
|
||||||
|
members, err := b.Client.JoinedMembers(ctx, roomID)
|
||||||
|
if err != nil || len(members.Joined) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := members.Joined[b.Client.UserID]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for member := range members.Joined {
|
||||||
|
if member != b.Client.UserID {
|
||||||
|
dmSweepIndex[member] = roomID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("DM sweep complete", "rooms", len(joined.JoinedRooms), "dms", len(dmSweepIndex))
|
||||||
|
})
|
||||||
|
|
||||||
|
roomID, ok := dmSweepIndex[userID]
|
||||||
|
return roomID, ok
|
||||||
|
}
|
||||||
|
|
||||||
// GetDMRoom returns the DM room for a user, creating one if needed.
|
// GetDMRoom returns the DM room for a user, creating one if needed.
|
||||||
func (b *Base) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
func (b *Base) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
||||||
dmCacheMu.Lock()
|
dmCacheMu.Lock()
|
||||||
@@ -695,17 +877,42 @@ func (b *Base) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
|||||||
}
|
}
|
||||||
dmCacheMu.Unlock()
|
dmCacheMu.Unlock()
|
||||||
|
|
||||||
// Check account data for existing DM rooms
|
// Persisted mapping — the authoritative store across restarts.
|
||||||
var dmRooms map[id.UserID][]id.RoomID
|
if roomID, ok := storedDMRoom(userID); ok {
|
||||||
err := b.Client.GetAccountData(context.Background(), "m.direct", &dmRooms)
|
if b.dmRoomUsable(roomID, userID) {
|
||||||
if err == nil {
|
|
||||||
if rooms, ok := dmRooms[userID]; ok && len(rooms) > 0 {
|
|
||||||
roomID := rooms[len(rooms)-1] // use most recent
|
|
||||||
dmCacheMu.Lock()
|
dmCacheMu.Lock()
|
||||||
dmCache[userID] = roomID
|
dmCache[userID] = roomID
|
||||||
dmCacheMu.Unlock()
|
dmCacheMu.Unlock()
|
||||||
return roomID, nil
|
return roomID, nil
|
||||||
}
|
}
|
||||||
|
slog.Info("stored DM room no longer usable, recreating", "user", userID, "room", roomID)
|
||||||
|
forgetDMRoom(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check account data for existing DM rooms
|
||||||
|
var dmRooms map[id.UserID][]id.RoomID
|
||||||
|
err := b.Client.GetAccountData(context.Background(), "m.direct", &dmRooms)
|
||||||
|
if err == nil {
|
||||||
|
for i := len(dmRooms[userID]) - 1; i >= 0; i-- {
|
||||||
|
roomID := dmRooms[userID][i] // most recent first
|
||||||
|
if b.dmRoomUsable(roomID, userID) {
|
||||||
|
rememberDMRoom(userID, roomID)
|
||||||
|
return roomID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort before creating: sweep the rooms the bot is already in for a
|
||||||
|
// two-person room shared with this user. Users who predate the dm_rooms
|
||||||
|
// table have such a room and nothing pointing at it — without this sweep
|
||||||
|
// their first post-upgrade DM would open yet another duplicate. If several
|
||||||
|
// duplicates exist we cannot tell which is liveliest (no /sync, so no
|
||||||
|
// timestamps), so the newest by room-list order wins.
|
||||||
|
if roomID, ok := b.findExistingDMRoom(userID); ok {
|
||||||
|
slog.Info("recovered pre-existing DM room by member sweep", "user", userID, "room", roomID)
|
||||||
|
rememberDMRoom(userID, roomID)
|
||||||
|
b.publishDirect(userID, roomID)
|
||||||
|
return roomID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No existing DM room — create one
|
// No existing DM room — create one
|
||||||
@@ -728,9 +935,8 @@ func (b *Base) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
|||||||
return "", fmt.Errorf("create DM room: %w", err)
|
return "", fmt.Errorf("create DM room: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dmCacheMu.Lock()
|
rememberDMRoom(userID, resp.RoomID)
|
||||||
dmCache[userID] = resp.RoomID
|
b.publishDirect(userID, resp.RoomID)
|
||||||
dmCacheMu.Unlock()
|
|
||||||
return resp.RoomID, nil
|
return resp.RoomID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"math/rand/v2"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deterministic sim seeding — OFF by default, so the production binary is
|
||||||
|
// byte-identical in behaviour. The expedition-sim harness calls SeedSim once at
|
||||||
|
// subprocess startup to make a run reproducible: the three nondeterminism seams
|
||||||
|
// that dominate outcome variance — the zone-layout RNG, the run id (traps hash
|
||||||
|
// off it), and each combat SessionID (the whole turn engine hashes from it) —
|
||||||
|
// all derive from a single base seed via a process-local counter.
|
||||||
|
//
|
||||||
|
// This does NOT touch the peripheral top-level math/rand/v2 procs (pardon rolls,
|
||||||
|
// minor damage jitter, ambient events); those stay random. Seeding the three
|
||||||
|
// dominant seams collapses the batch-to-batch drift (identical dungeons + combat
|
||||||
|
// dice across arms) so an A/B passive change reads at ~0 noise. If a residual
|
||||||
|
// proc ever proves load-bearing, seed it too — but validate empirically first.
|
||||||
|
//
|
||||||
|
// Ordering contract: within one subprocess an expedition is resolved on a single
|
||||||
|
// goroutine, so nextSimSeed() is drawn in a deterministic order (zone rng, run
|
||||||
|
// id, then one per combat session in creation order). Two arms sharing a base
|
||||||
|
// seed draw identical values up to the point a passive change diverges them —
|
||||||
|
// and SessionIDs are assigned at session *creation*, before a fight resolves, so
|
||||||
|
// the Nth combat pairs regardless of how the fight plays out.
|
||||||
|
var (
|
||||||
|
simSeedActive atomic.Bool
|
||||||
|
simSeedBase uint64
|
||||||
|
simSeedCtr atomic.Uint64
|
||||||
|
)
|
||||||
|
|
||||||
|
// simCombatRand is an INDEPENDENT seeded stream for the outcome-decisive
|
||||||
|
// in-combat rolls that the three dominant seams don't cover: the spell
|
||||||
|
// attack/save/damage d20s (dnd_spell_combat.go), the 33% pardon death-cheat
|
||||||
|
// (combat_bridge.go), and the short-rest heal die (dnd_rest.go). These fire
|
||||||
|
// disproportionately in caster / borderline-boss runs — exactly the population
|
||||||
|
// being certified — and leaving them on the global generator was the main
|
||||||
|
// source of the per-cell residual (the fighter+ranger repro never exercised
|
||||||
|
// them, so it read ~0 noise while bard swung 8pp on identical code).
|
||||||
|
//
|
||||||
|
// It is a SEPARATE stream from nextSimSeed()'s counter so it never perturbs the
|
||||||
|
// zone-layout / run-id / SessionID draw order the martial repro validated.
|
||||||
|
// Within a subprocess an expedition runs on one goroutine; the mutex is belt-
|
||||||
|
// and-braces so a stray concurrent draw can't race, not a correctness crutch.
|
||||||
|
var (
|
||||||
|
simCombatMu sync.Mutex
|
||||||
|
simCombatRand *rand.Rand
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeedSim activates deterministic seeding for this process. A negative seed
|
||||||
|
// disables it (the default). Call once at startup, before any expedition runs —
|
||||||
|
// it is not safe to toggle while a run is in flight.
|
||||||
|
func SeedSim(seed int64) {
|
||||||
|
if seed < 0 {
|
||||||
|
simSeedActive.Store(false)
|
||||||
|
simCombatRand = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
simSeedBase = uint64(seed)
|
||||||
|
simSeedCtr.Store(0)
|
||||||
|
// 0x5EED5 gives the peripheral-combat stream a distinct sub-stream from the
|
||||||
|
// seam counter so the two never correlate or share draws.
|
||||||
|
simCombatRand = rand.New(rand.NewPCG(uint64(seed), 0x5EED5))
|
||||||
|
simSeedActive.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func simSeedOn() bool { return simSeedActive.Load() }
|
||||||
|
|
||||||
|
// nextSimSeed returns the next counter-mixed seed. Golden-ratio odd multiplier
|
||||||
|
// decorrelates successive draws.
|
||||||
|
func nextSimSeed() uint64 {
|
||||||
|
n := simSeedCtr.Add(1)
|
||||||
|
return simSeedBase ^ (n * 0x9E3779B97F4A7C15)
|
||||||
|
}
|
||||||
|
|
||||||
|
// simHexToken renders one seeded draw as the same 16-char hex shape the
|
||||||
|
// crypto-random id helpers produce.
|
||||||
|
func simHexToken() string {
|
||||||
|
v := nextSimSeed()
|
||||||
|
var b [8]byte
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(v >> (8 * i))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// simZoneRNG returns a deterministic generator for one zone layout.
|
||||||
|
func simZoneRNG() *rand.Rand {
|
||||||
|
return rand.New(rand.NewPCG(nextSimSeed(), 0xC0FFEE))
|
||||||
|
}
|
||||||
|
|
||||||
|
// simIntN / simFloat64 draw an outcome-decisive in-combat roll from the seeded
|
||||||
|
// peripheral stream when seeding is active; otherwise they fall through to the
|
||||||
|
// global generator so the production binary stays byte-identical (prod never
|
||||||
|
// calls SeedSim, so simSeedOn() is always false there).
|
||||||
|
func simIntN(n int) int {
|
||||||
|
if !simSeedOn() {
|
||||||
|
return rand.IntN(n)
|
||||||
|
}
|
||||||
|
simCombatMu.Lock()
|
||||||
|
defer simCombatMu.Unlock()
|
||||||
|
return simCombatRand.IntN(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func simFloat64() float64 {
|
||||||
|
if !simSeedOn() {
|
||||||
|
return rand.Float64()
|
||||||
|
}
|
||||||
|
simCombatMu.Lock()
|
||||||
|
defer simCombatMu.Unlock()
|
||||||
|
return simCombatRand.Float64()
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -116,9 +115,7 @@ func (p *TarotPlugin) handleTarot(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
|
||||||
}
|
}
|
||||||
|
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +148,7 @@ func (p *TarotPlugin) handleTarot(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotBasePrompt, tarotSingleSuffix, tarotFewShot, card, extraLines)
|
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotBasePrompt, tarotSingleSuffix, tarotFewShot, card, extraLines)
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("tarot: ollama call", "err", err)
|
slog.Error("tarot: ollama call", "err", err)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
||||||
@@ -166,9 +163,7 @@ func (p *TarotPlugin) handleSpread(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
|
||||||
}
|
}
|
||||||
|
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +197,7 @@ func (p *TarotPlugin) handleSpread(ctx MessageContext) error {
|
|||||||
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive the reading.",
|
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive the reading.",
|
||||||
tarotBasePrompt, tarotSpreadSuffix, tarotFewShot, cards[0], cards[1], cards[2], extraLines)
|
tarotBasePrompt, tarotSpreadSuffix, tarotFewShot, cards[0], cards[1], cards[2], extraLines)
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("tarot: ollama call", "err", err)
|
slog.Error("tarot: ollama call", "err", err)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ func (p *VibePlugin) resetCooldown(roomID id.RoomID) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *VibePlugin) handleVibe(ctx MessageContext) error {
|
func (p *VibePlugin) handleVibe(ctx MessageContext) error {
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +152,7 @@ Describe the room's current vibe:`, botName, transcript)
|
|||||||
slog.Error("vibe: send thinking", "err", err)
|
slog.Error("vibe: send thinking", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("vibe: ollama call", "err", err)
|
slog.Error("vibe: ollama call", "err", err)
|
||||||
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
||||||
@@ -165,9 +163,7 @@ Describe the room's current vibe:`, botName, transcript)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *VibePlugin) handleTLDR(ctx MessageContext) error {
|
func (p *VibePlugin) handleTLDR(ctx MessageContext) error {
|
||||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if ollamaHost == "" || ollamaModel == "" {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +195,7 @@ Summary:`, tldrBotName, transcript)
|
|||||||
slog.Error("vibe: send thinking", "err", err)
|
slog.Error("vibe: send thinking", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
response, err := callLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("vibe: ollama call", "err", err)
|
slog.Error("vibe: ollama call", "err", err)
|
||||||
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
||||||
|
|||||||
+17
-78
@@ -1,19 +1,17 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
"gogobee/internal/dreamclient"
|
"gogobee/internal/dreamclient"
|
||||||
|
"gogobee/internal/llm"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -533,9 +531,7 @@ func (p *WOTDPlugin) trackUsage(ctx MessageContext) {
|
|||||||
// verifyUsage asks the LLM whether the word was used correctly in context.
|
// verifyUsage asks the LLM whether the word was used correctly in context.
|
||||||
// Returns false if LLM is not configured or on any error.
|
// Returns false if LLM is not configured or on any error.
|
||||||
func (p *WOTDPlugin) verifyUsage(word, message string) bool {
|
func (p *WOTDPlugin) verifyUsage(word, message string) bool {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,58 +541,30 @@ The Word of the Day is "%s". Was this word used correctly and meaningfully in th
|
|||||||
|
|
||||||
Respond with ONLY "yes" or "no".`, message, word)
|
Respond with ONLY "yes" or "no".`, message, word)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
response, err := llmGenerate(context.Background(), llm.Request{
|
||||||
"model": model,
|
Prompt: prompt,
|
||||||
"prompt": prompt,
|
Timeout: wotdLLMTimeout,
|
||||||
"stream": false,
|
})
|
||||||
"think": false,
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
|
||||||
slog.Debug("wotd: sending LLM verification request", "url", apiURL, "word", word)
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("wotd: LLM verify request failed", "err", err)
|
slog.Error("wotd: LLM verify request failed", "err", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &result) != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
response := result.Response
|
|
||||||
// Strip <think>...</think> blocks (Qwen 3.5 reasoning)
|
|
||||||
if i := strings.Index(response, "<think>"); i != -1 {
|
|
||||||
if j := strings.Index(response, "</think>"); j != -1 {
|
|
||||||
response = response[:i] + response[j+len("</think>"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
answer := strings.ToLower(strings.TrimSpace(response))
|
answer := strings.ToLower(strings.TrimSpace(response))
|
||||||
accepted := strings.HasPrefix(answer, "yes")
|
accepted := strings.HasPrefix(answer, "yes")
|
||||||
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted)
|
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted)
|
||||||
return accepted
|
return accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wotdLLMTimeout preserves the 30s cap both WOTD paths enforced with their own
|
||||||
|
// http.Client. These run on message traffic, so they stay well under the
|
||||||
|
// interactive default.
|
||||||
|
const wotdLLMTimeout = 30 * time.Second
|
||||||
|
|
||||||
// llmTranslate asks the LLM for a brief English translation of a foreign word.
|
// llmTranslate asks the LLM for a brief English translation of a foreign word.
|
||||||
// Returns empty string on failure.
|
// Returns empty string on failure.
|
||||||
func (p *WOTDPlugin) llmTranslate(word, lang string) string {
|
func (p *WOTDPlugin) llmTranslate(word, lang string) string {
|
||||||
host := os.Getenv("OLLAMA_HOST")
|
if !llmConfigured() {
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
|
||||||
if host == "" || model == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,44 +580,15 @@ func (p *WOTDPlugin) llmTranslate(word, lang string) string {
|
|||||||
`Translate the %s word "%s" into English. Reply with ONLY the English translation — one or two words, no explanation, no punctuation.`,
|
`Translate the %s word "%s" into English. Reply with ONLY the English translation — one or two words, no explanation, no punctuation.`,
|
||||||
langName, word)
|
langName, word)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
response, err := llmGenerate(context.Background(), llm.Request{
|
||||||
"model": model,
|
Prompt: prompt,
|
||||||
"prompt": prompt,
|
Timeout: wotdLLMTimeout,
|
||||||
"stream": false,
|
})
|
||||||
"think": false,
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("wotd: LLM translate request failed", "err", err)
|
slog.Error("wotd: LLM translate request failed", "err", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &result) != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
response := result.Response
|
|
||||||
if i := strings.Index(response, "<think>"); i != -1 {
|
|
||||||
if j := strings.Index(response, "</think>"); j != -1 {
|
|
||||||
response = response[:i] + response[j+len("</think>"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
translation := strings.TrimSpace(response)
|
translation := strings.TrimSpace(response)
|
||||||
if translation == "" || len(translation) > 50 {
|
if translation == "" || len(translation) > 50 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// backtrackGraph — a fork at z.fork with two branches, plus a dead-end spur
|
||||||
|
// hanging off z.b whose only exit is back into z.b.
|
||||||
|
//
|
||||||
|
// z.entry → z.fork → z.a
|
||||||
|
// → z.b → z.spur
|
||||||
|
func backtrackGraph() ZoneGraph {
|
||||||
|
return ZoneGraph{
|
||||||
|
Nodes: map[string]ZoneNode{},
|
||||||
|
Edges: map[string][]ZoneEdge{
|
||||||
|
"z.entry": {{From: "z.entry", To: "z.fork"}},
|
||||||
|
"z.fork": {{From: "z.fork", To: "z.a"}, {From: "z.fork", To: "z.b"}},
|
||||||
|
"z.b": {{From: "z.b", To: "z.spur"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBacktrackTargetRequiresAdjacency is the regression guard for the bug this
|
||||||
|
// fixes: VisitedNodes is a first-entry ordered set, not a path stack, so the
|
||||||
|
// entry before CurrentNode can sit on a branch the party never walked from
|
||||||
|
// here. Stepping to it blind teleports them across the map.
|
||||||
|
func TestBacktrackTargetRequiresAdjacency(t *testing.T) {
|
||||||
|
// Walked entry → fork → a, doubled back, then took fork → b. The visited
|
||||||
|
// set is [entry, fork, a, b]; z.a is *not* joined to z.b.
|
||||||
|
run := &DungeonRun{
|
||||||
|
CurrentNode: "z.b",
|
||||||
|
VisitedNodes: []string{"z.entry", "z.fork", "z.a", "z.b"},
|
||||||
|
}
|
||||||
|
got, ok := backtrackTarget(backtrackGraph(), run)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("should fall back to the fork")
|
||||||
|
}
|
||||||
|
if got != "z.fork" {
|
||||||
|
t.Errorf("backtrack target = %s, want z.fork (z.a shares no edge with z.b)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBacktrackTargetSkipsOneWayCorridor — backing into a room whose only exit
|
||||||
|
// is the sealed fork walks straight back in, and the lock rolls are seeded per
|
||||||
|
// (run, edge), so it would do it forever. Refuse instead.
|
||||||
|
func TestBacktrackTargetSkipsOneWayCorridor(t *testing.T) {
|
||||||
|
run := &DungeonRun{
|
||||||
|
CurrentNode: "z.spur",
|
||||||
|
VisitedNodes: []string{"z.entry", "z.fork", "z.b", "z.spur"},
|
||||||
|
}
|
||||||
|
if got, ok := backtrackTarget(backtrackGraph(), run); ok {
|
||||||
|
t.Errorf("backtracked to %s; z.b only leads back to z.spur, so this loops", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBacktrackTargetAtEntry — nowhere behind the entry node.
|
||||||
|
func TestBacktrackTargetAtEntry(t *testing.T) {
|
||||||
|
run := &DungeonRun{CurrentNode: "z.entry", VisitedNodes: []string{"z.entry"}}
|
||||||
|
if _, ok := backtrackTarget(backtrackGraph(), run); ok {
|
||||||
|
t.Error("entry node has nowhere to back out to")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pendingFork is the typed shape of dnd_zone_run.node_choices when the
|
// pendingFork is the typed shape of dnd_zone_run.node_choices when the
|
||||||
@@ -79,14 +81,42 @@ func decodePendingFork(m map[string]any) (*pendingFork, error) {
|
|||||||
// test them without going through the live DB. Filled in by
|
// test them without going through the live DB. Filled in by
|
||||||
// evaluateForkEdges from the live run + character.
|
// evaluateForkEdges from the live run + character.
|
||||||
type edgeUnlockCtx struct {
|
type edgeUnlockCtx struct {
|
||||||
RunID string
|
RunID string
|
||||||
FromNode string
|
FromNode string
|
||||||
CharLevel int
|
CharLevel int
|
||||||
AbilityMods [6]int // STR, DEX, CON, INT, WIS, CHA — matches DnDCharacter.Modifiers()
|
// AbilityMods is the *party's best* modifier per ability — STR, DEX, CON,
|
||||||
|
// INT, WIS, CHA, matching DnDCharacter.Modifiers(). A door doesn't care
|
||||||
|
// which set of eyes spotted the seam, and reading only the leader's sheet
|
||||||
|
// meant a party's rogue and its hired scout were decorative at every lock.
|
||||||
|
// AbilityWho names whoever supplied each best, empty when it's the leader,
|
||||||
|
// so the fork prompt can say who got it open.
|
||||||
|
AbilityMods [6]int
|
||||||
|
AbilityWho [6]string
|
||||||
InventoryNames map[string]bool
|
InventoryNames map[string]bool
|
||||||
Expedition *Expedition
|
Expedition *Expedition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// creditFor names the party member whose ability carried a check, phrased for
|
||||||
|
// the fork prompt. Empty when the acting character managed it alone — there is
|
||||||
|
// nobody to credit and the menu stays quiet.
|
||||||
|
func (c edgeUnlockCtx) creditFor(ability int) string {
|
||||||
|
if who := c.AbilityWho[ability]; who != "" {
|
||||||
|
return who + " got it open"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// bestAbility folds one body's modifiers into the running party-best, recording
|
||||||
|
// the contributor's name for any ability it improves on.
|
||||||
|
func (c *edgeUnlockCtx) bestAbility(mods [6]int, who string) {
|
||||||
|
for i, m := range mods {
|
||||||
|
if m > c.AbilityMods[i] {
|
||||||
|
c.AbilityMods[i] = m
|
||||||
|
c.AbilityWho[i] = who
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// evaluateEdgeLock returns whether the player can take this edge right
|
// evaluateEdgeLock returns whether the player can take this edge right
|
||||||
// now, with a player-facing reason on failure. Per plan §G5: Perception
|
// now, with a player-facing reason on failure. Per plan §G5: Perception
|
||||||
// rolls fire once at fork-arrival (deterministic seed) and the result
|
// rolls fire once at fork-arrival (deterministic seed) and the result
|
||||||
@@ -101,7 +131,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
|
|||||||
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
||||||
total := roll + ctx.AbilityMods[4]
|
total := roll + ctx.AbilityMods[4]
|
||||||
if total >= dc {
|
if total >= dc {
|
||||||
return true, ""
|
return true, ctx.creditFor(4)
|
||||||
}
|
}
|
||||||
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
|
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
|
||||||
case LockKey:
|
case LockKey:
|
||||||
@@ -138,7 +168,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
|
|||||||
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
||||||
total := roll + ctx.AbilityMods[idx]
|
total := roll + ctx.AbilityMods[idx]
|
||||||
if total >= dc {
|
if total >= dc {
|
||||||
return true, ""
|
return true, ctx.creditFor(idx)
|
||||||
}
|
}
|
||||||
return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc)
|
return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc)
|
||||||
}
|
}
|
||||||
@@ -219,10 +249,45 @@ func buildUnlockCtx(c *DnDCharacter, runID, fromNode string) edgeUnlockCtx {
|
|||||||
}
|
}
|
||||||
if exp, err := getActiveExpedition(c.UserID); err == nil && exp != nil {
|
if exp, err := getActiveExpedition(c.UserID); err == nil && exp != nil {
|
||||||
ctx.Expedition = exp
|
ctx.Expedition = exp
|
||||||
|
foldPartyAbilities(&ctx, exp, c.UserID)
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// foldPartyAbilities raises ctx.AbilityMods to the best any body on the
|
||||||
|
// expedition can offer. The companion counts: he is a seat that walks the same
|
||||||
|
// corridor, and excluding him would make hiring a scout worth less than the
|
||||||
|
// coins it costs.
|
||||||
|
//
|
||||||
|
// Errors are swallowed rather than propagated — a roster read that fails leaves
|
||||||
|
// the leader's own mods standing, which is exactly the pre-party behaviour and
|
||||||
|
// never harder than it was.
|
||||||
|
func foldPartyAbilities(ctx *edgeUnlockCtx, exp *Expedition, acting id.UserID) {
|
||||||
|
seats, err := expeditionParty(exp.ID, string(exp.UserID))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.Kind == SeatCompanion {
|
||||||
|
class, level := companionLoadout(exp.ID)
|
||||||
|
ctx.bestAbility(companionSheet(class, level).Modifiers(), companionDisplayName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.UserID == acting {
|
||||||
|
continue // whoever we built the ctx from is already the baseline
|
||||||
|
}
|
||||||
|
mate, err := LoadDnDCharacter(s.UserID)
|
||||||
|
if err != nil || mate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _ := loadDisplayName(s.UserID)
|
||||||
|
if name == "" {
|
||||||
|
name = string(s.UserID)
|
||||||
|
}
|
||||||
|
ctx.bestAbility(mate.Modifiers(), name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// evaluateForkEdges walks all outgoing edges of fromNode in the graph
|
// evaluateForkEdges walks all outgoing edges of fromNode in the graph
|
||||||
// and produces a pending-choice list ready to be persisted. Locked
|
// and produces a pending-choice list ready to be persisted. Locked
|
||||||
// edges that have a Hint stay in the menu (the player needs the
|
// edges that have a Hint stay in the menu (the player needs the
|
||||||
@@ -288,6 +353,10 @@ func renderForkPrompt(zone ZoneDefinition, pf pendingFork) string {
|
|||||||
b.WriteString(fmt.Sprintf("**%s — Path divides.** Choose with `!zone go <n>`.\n\n", zone.Display))
|
b.WriteString(fmt.Sprintf("**%s — Path divides.** Choose with `!zone go <n>`.\n\n", zone.Display))
|
||||||
for _, c := range pf.Options {
|
for _, c := range pf.Options {
|
||||||
switch {
|
switch {
|
||||||
|
case c.Unlocked && c.Reason != "":
|
||||||
|
// A party-mate's ability beat the check — say so, so the player can
|
||||||
|
// see what the roster bought them.
|
||||||
|
b.WriteString(fmt.Sprintf("**%d.** %s _(%s)_\n", c.Index, c.Label, c.Reason))
|
||||||
case c.Unlocked:
|
case c.Unlocked:
|
||||||
b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label))
|
b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label))
|
||||||
case c.Hint != "":
|
case c.Hint != "":
|
||||||
@@ -367,6 +436,20 @@ func completeRunAtNode(runID string, boss bool) error {
|
|||||||
if boss {
|
if boss {
|
||||||
bossI = 1
|
bossI = 1
|
||||||
}
|
}
|
||||||
|
// Only a boss kill is a clear. This function also closes a non-boss
|
||||||
|
// dead-end — the party simply ran out of map — and beatRunEnd is
|
||||||
|
// first-writer-wins, so calling that "cleared" would put a lie in the
|
||||||
|
// liveblog and the run summary that nothing downstream could correct.
|
||||||
|
// "ended" is deliberately outside the {cleared,died,retreated,abandoned}
|
||||||
|
// set: the prompt renderer already degrades an unknown outcome to a
|
||||||
|
// neutral "the run ended", which is exactly what happened.
|
||||||
|
if run, _ := getZoneRun(runID); run != nil {
|
||||||
|
outcome := "ended"
|
||||||
|
if boss {
|
||||||
|
outcome = "cleared"
|
||||||
|
}
|
||||||
|
beatRunEnd(run, outcome)
|
||||||
|
}
|
||||||
_, err := db.Get().Exec(`
|
_, err := db.Get().Exec(`
|
||||||
UPDATE dnd_zone_run
|
UPDATE dnd_zone_run
|
||||||
SET boss_defeated = ?,
|
SET boss_defeated = ?,
|
||||||
@@ -405,7 +488,12 @@ func advanceZoneRunNode(runID, nextNode string) (int, error) {
|
|||||||
nextNode, string(visitedJSON), runID); err != nil {
|
nextNode, string(visitedJSON), runID); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return pathIndexOf(visited, nextNode), nil
|
idx := pathIndexOf(visited, nextNode)
|
||||||
|
// Every forward move in the game funnels through here — auto-advance, a
|
||||||
|
// player's `!zone go`, and the autopilot's stale-fork pick alike — which is
|
||||||
|
// what makes this the one honest place to say "the party is now in room N".
|
||||||
|
beatRoom(r, nextNode, idx, "entered")
|
||||||
|
return idx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveForkChoice takes a 1-based choice index against a pending
|
// resolveForkChoice takes a 1-based choice index against a pending
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Thieves' tools — the counterweight to a failed skill check.
|
||||||
|
//
|
||||||
|
// A Perception / stat-check lock rolls once per (run, edge) and the roll is
|
||||||
|
// seeded, deliberately, so re-reading the fork can't reroll it (plan §G5). That
|
||||||
|
// half shipped; the other half never did, so a bad roll simply deleted a branch
|
||||||
|
// of the graph for the rest of the run with no recourse at all — worst for a
|
||||||
|
// solo low-WIS character, who quietly loses routes they never learn existed.
|
||||||
|
//
|
||||||
|
// Tools are that recourse: a consumable that answers the check instead of the
|
||||||
|
// character. They are deliberately NOT a skeleton key. A key lock is a quest
|
||||||
|
// token, a level-min lock is progression, and a region-clear lock is structure —
|
||||||
|
// none of those are "you rolled badly", so none of them are pickable. Tools open
|
||||||
|
// exactly the locks that luck closed.
|
||||||
|
|
||||||
|
// thievesToolsName is the inventory item name. Matching is case-folded so a
|
||||||
|
// player typing it back at the shop doesn't have to find the apostrophe.
|
||||||
|
const thievesToolsName = "Thieves' Tools"
|
||||||
|
|
||||||
|
// thievesToolsItemType keeps them out of the combat consumable scan. They are a
|
||||||
|
// utility item like the medical-debt card, not something the fight engine may
|
||||||
|
// spend on the player's behalf.
|
||||||
|
const thievesToolsItemType = "tool"
|
||||||
|
|
||||||
|
// thievesToolsPrice is what Luigi charges. Priced against a T1 consumable so
|
||||||
|
// carrying a couple is a routine purchase rather than a considered one — the
|
||||||
|
// point is that no run is ever hard-walled, not to open a money sink.
|
||||||
|
const thievesToolsPrice int64 = 600
|
||||||
|
|
||||||
|
// pickableLock reports whether tools can answer this lock. Only the two
|
||||||
|
// dice-driven kinds qualify; see the file comment for why the rest don't.
|
||||||
|
func pickableLock(kind string) bool {
|
||||||
|
return kind == string(LockPerception) || kind == string(LockStatCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isThievesToolsReply matches a shop reply against the tools.
|
||||||
|
//
|
||||||
|
// Deliberately not "is the reply a substring of the name": that predicate is
|
||||||
|
// true for a bare "s", for "to", and for an empty message body — and this branch
|
||||||
|
// sits ahead of the consumable list, so a stray keystroke in the Supplies view
|
||||||
|
// would silently bill the player €600. Match on the item's own words instead.
|
||||||
|
func isThievesToolsReply(reply string) bool {
|
||||||
|
return containsFold(reply, "thieves") || containsFold(reply, "thief") ||
|
||||||
|
containsFold(reply, "tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
// thievesToolsHeld returns the inventory row IDs of every set the player is
|
||||||
|
// carrying. One read answers both "have they got any" and "how many are left",
|
||||||
|
// which the unlock flow would otherwise ask the inventory table three times.
|
||||||
|
func thievesToolsHeld(userID id.UserID) []int64 {
|
||||||
|
inv, err := loadAdvInventory(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
for _, it := range inv {
|
||||||
|
if strings.EqualFold(it.Name, thievesToolsName) {
|
||||||
|
ids = append(ids, it.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// findThievesTools returns the inventory row ID of one set of tools, or ok=false
|
||||||
|
// if the player is carrying none.
|
||||||
|
func findThievesTools(userID id.UserID) (int64, bool) {
|
||||||
|
held := thievesToolsHeld(userID)
|
||||||
|
if len(held) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return held[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// countThievesTools reports how many sets the player carries, for the "N left"
|
||||||
|
// line after a use.
|
||||||
|
func countThievesTools(userID id.UserID) int {
|
||||||
|
return len(thievesToolsHeld(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneCmdUnlock handles `!zone unlock <n>`: spend one set of thieves' tools to
|
||||||
|
// open a locked fork option, then commit the move exactly as `!zone go <n>`
|
||||||
|
// would. Every guard zoneCmdGo applies applies here too — same run, same leader
|
||||||
|
// rule, same mid-fight refusal — because this is that command with a different
|
||||||
|
// admission price.
|
||||||
|
func (p *AdventurePlugin) zoneCmdUnlock(ctx MessageContext, rest string) error {
|
||||||
|
run, isLeader, err := activeZoneRunFor(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||||
|
}
|
||||||
|
if !isLeader {
|
||||||
|
return p.SendDM(ctx.Sender, msgLeaderPicksPath)
|
||||||
|
}
|
||||||
|
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
|
||||||
|
}
|
||||||
|
pf, derr := decodePendingFork(run.NodeChoices)
|
||||||
|
if derr != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
|
||||||
|
}
|
||||||
|
if pf == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "No fork pending. Use "+continueHint(ctx.Sender))
|
||||||
|
}
|
||||||
|
|
||||||
|
held := thievesToolsHeld(ctx.Sender)
|
||||||
|
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
if rest == "" {
|
||||||
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
|
return p.SendDM(ctx.Sender, "**Which one?**\n\n"+renderForkPrompt(zone, *pf)+
|
||||||
|
fmt.Sprintf("\n\n_`!zone unlock <n>` — spends one set of %s. You carry %d._",
|
||||||
|
thievesToolsName, len(held)))
|
||||||
|
}
|
||||||
|
choice := atoiSafe(rest)
|
||||||
|
if choice < 1 || choice > len(pf.Options) {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("Choice must be a number from the menu (1–%d).", len(pf.Options)))
|
||||||
|
}
|
||||||
|
chosen := pf.Options[choice-1]
|
||||||
|
|
||||||
|
if chosen.Unlocked {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"**%s** is already open — no need to spend tools. `!zone go %d`.", chosen.Label, choice))
|
||||||
|
}
|
||||||
|
if !pickableLock(chosen.Lock) {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"🔒 Tools won't help here. %s\n\n_Thieves' tools answer a failed check, not a locked gate._",
|
||||||
|
lockRefusalFor(chosen)))
|
||||||
|
}
|
||||||
|
if len(held) == 0 {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"🔒 **%s** needs %s and you're carrying none.\n\nLuigi stocks them under `!shop` → Supplies.",
|
||||||
|
chosen.Label, thievesToolsName))
|
||||||
|
}
|
||||||
|
if rerr := removeAdvInventoryItem(held[0]); rerr != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Couldn't spend the tools: "+rerr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tools answered the check, so the option is open from here on. Commit
|
||||||
|
// it back to the pending fork before advancing: if the advance fails, the
|
||||||
|
// player has paid and must not be told the door is shut again.
|
||||||
|
pf.Options[choice-1].Unlocked = true
|
||||||
|
pf.Options[choice-1].Reason = "opened with " + thievesToolsName
|
||||||
|
_ = writePendingFork(run.RunID, *pf)
|
||||||
|
|
||||||
|
header := fmt.Sprintf("🔓 **%s** — picked. _(%s used, %d left)_\n\n",
|
||||||
|
chosen.Label, thievesToolsName, len(held)-1)
|
||||||
|
return p.commitForkChoice(ctx, run, pf.Options[choice-1], header)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockRefusalFor phrases why a non-pickable lock stays shut, preferring the
|
||||||
|
// evaluator's own reason so the player sees the same wording the menu gave.
|
||||||
|
func lockRefusalFor(c pendingChoice) string {
|
||||||
|
if c.Reason != "" {
|
||||||
|
return c.Reason + "."
|
||||||
|
}
|
||||||
|
switch c.Lock {
|
||||||
|
case string(LockKey):
|
||||||
|
return "That door wants a key, and a key is a thing you find, not a thing you force."
|
||||||
|
case string(LockLevelMin):
|
||||||
|
return "That way is beyond you yet. Come back stronger."
|
||||||
|
case string(LockRegionClear):
|
||||||
|
return "Somewhere else has to fall first."
|
||||||
|
}
|
||||||
|
return "That one isn't going to open."
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestPickableLock pins which locks thieves' tools may answer. Tools substitute
|
||||||
|
// for a failed die roll, never for progression: a key is a quest token, a level
|
||||||
|
// gate is progression, a region-clear gate is structure.
|
||||||
|
func TestPickableLock(t *testing.T) {
|
||||||
|
pickable := []ZoneEdgeLockKind{LockPerception, LockStatCheck}
|
||||||
|
for _, k := range pickable {
|
||||||
|
if !pickableLock(string(k)) {
|
||||||
|
t.Errorf("%s should be pickable", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sealed := []ZoneEdgeLockKind{LockKey, LockLevelMin, LockRegionClear, LockNone, ""}
|
||||||
|
for _, k := range sealed {
|
||||||
|
if pickableLock(string(k)) {
|
||||||
|
t.Errorf("%s must not be pickable", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBestAbilityTakesPartyMax is the core of the party-check fix: a door reads
|
||||||
|
// the best eyes present, not the leader's.
|
||||||
|
func TestBestAbilityTakesPartyMax(t *testing.T) {
|
||||||
|
ctx := edgeUnlockCtx{AbilityMods: [6]int{0, 1, 0, 0, 1, 0}}
|
||||||
|
ctx.bestAbility([6]int{0, 5, 0, 0, -1, 0}, "Josie")
|
||||||
|
ctx.bestAbility([6]int{0, 2, 0, 0, 6, 0}, "Pete")
|
||||||
|
|
||||||
|
if ctx.AbilityMods[1] != 5 || ctx.AbilityWho[1] != "Josie" {
|
||||||
|
t.Errorf("DEX = %d by %q, want 5 by Josie", ctx.AbilityMods[1], ctx.AbilityWho[1])
|
||||||
|
}
|
||||||
|
if ctx.AbilityMods[4] != 6 || ctx.AbilityWho[4] != "Pete" {
|
||||||
|
t.Errorf("WIS = %d by %q, want 6 by Pete", ctx.AbilityMods[4], ctx.AbilityWho[4])
|
||||||
|
}
|
||||||
|
// Nobody beat the leader's STR, so nobody is credited for it.
|
||||||
|
if ctx.AbilityWho[0] != "" {
|
||||||
|
t.Errorf("STR credited to %q, want nobody", ctx.AbilityWho[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateEdgeLockUsesPartyBest — a check the leader fails and a party-mate
|
||||||
|
// passes must open, and must say who opened it.
|
||||||
|
func TestEvaluateEdgeLockUsesPartyBest(t *testing.T) {
|
||||||
|
e := ZoneEdge{To: "z.secret", Lock: LockPerception, LockData: map[string]any{"dc": 30}}
|
||||||
|
ctx := edgeUnlockCtx{RunID: "run1", FromNode: "z.fork"}
|
||||||
|
|
||||||
|
if ok, _ := evaluateEdgeLock(e, ctx); ok {
|
||||||
|
t.Fatal("DC 30 should be unreachable with no modifiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// perceptionRollForEdge is seeded, so a mod big enough to clear DC 30 from
|
||||||
|
// any roll makes this deterministic without pinning the roll itself.
|
||||||
|
ctx.bestAbility([6]int{0, 0, 0, 0, 29, 0}, "Pete")
|
||||||
|
ok, reason := evaluateEdgeLock(e, ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("party best should open the door, got %q", reason)
|
||||||
|
}
|
||||||
|
if reason != "Pete got it open" {
|
||||||
|
t.Errorf("reason = %q, want credit to Pete", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRankForkOptionsPrefersUnvisitedThenWeight guards the autopilot's route
|
||||||
|
// choice. Menu order is edge-authoring order and means nothing to a player.
|
||||||
|
func TestRankForkOptionsPrefersUnvisitedThenWeight(t *testing.T) {
|
||||||
|
g := ZoneGraph{
|
||||||
|
Nodes: map[string]ZoneNode{},
|
||||||
|
Edges: map[string][]ZoneEdge{
|
||||||
|
"z.fork": {
|
||||||
|
{From: "z.fork", To: "z.seen", Weight: 9},
|
||||||
|
{From: "z.fork", To: "z.thin", Weight: 1},
|
||||||
|
{From: "z.fork", To: "z.fat", Weight: 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
run := &DungeonRun{CurrentNode: "z.fork", VisitedNodes: []string{"z.fork", "z.seen"}}
|
||||||
|
pf := &pendingFork{Options: []pendingChoice{
|
||||||
|
{Index: 1, To: "z.seen", Label: "Seen", Unlocked: true},
|
||||||
|
{Index: 2, To: "z.thin", Label: "Thin", Unlocked: true},
|
||||||
|
{Index: 3, To: "z.fat", Label: "Fat", Unlocked: true},
|
||||||
|
{Index: 4, To: "z.shut", Label: "Shut", Unlocked: false},
|
||||||
|
}}
|
||||||
|
|
||||||
|
got := rankForkOptions(g, run, pf)
|
||||||
|
want := []string{"z.fat", "z.thin", "z.seen"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d options, want %d (locked routes must be dropped)", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].To != w {
|
||||||
|
t.Errorf("rank[%d] = %s, want %s", i, got[i].To, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRankForkOptionsAllLocked — nothing takeable means nothing returned, which
|
||||||
|
// is what sends the autopilot to the tools/backtrack path instead of the reaper.
|
||||||
|
func TestRankForkOptionsAllLocked(t *testing.T) {
|
||||||
|
g := ZoneGraph{Nodes: map[string]ZoneNode{}, Edges: map[string][]ZoneEdge{}}
|
||||||
|
run := &DungeonRun{CurrentNode: "z.fork", VisitedNodes: []string{"z.fork"}}
|
||||||
|
pf := &pendingFork{Options: []pendingChoice{
|
||||||
|
{Index: 1, To: "z.a", Unlocked: false},
|
||||||
|
{Index: 2, To: "z.b", Unlocked: false},
|
||||||
|
}}
|
||||||
|
if got := rankForkOptions(g, run, pf); len(got) != 0 {
|
||||||
|
t.Errorf("got %d takeable options, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,11 @@ func revisitZoneRun(runID, targetNode string, visited []string) (int, error) {
|
|||||||
WHERE run_id = ?`, targetNode, runID); err != nil {
|
WHERE run_id = ?`, targetNode, runID); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return pathIndexOf(visited, targetNode), nil
|
idx := pathIndexOf(visited, targetNode)
|
||||||
|
if run, _ := getZoneRun(runID); run != nil {
|
||||||
|
beatRoom(run, targetNode, idx, "doubled back")
|
||||||
|
}
|
||||||
|
return idx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRevisitCmd implements `!revisit <N>` (also reachable as
|
// handleRevisitCmd implements `!revisit <N>` (also reachable as
|
||||||
|
|||||||
@@ -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/llm"
|
||||||
"gogobee/internal/peteclient"
|
"gogobee/internal/peteclient"
|
||||||
"gogobee/internal/plugin"
|
"gogobee/internal/plugin"
|
||||||
"gogobee/internal/util"
|
"gogobee/internal/util"
|
||||||
@@ -37,9 +38,11 @@ func main() {
|
|||||||
logLevel = "info"
|
logLevel = "info"
|
||||||
}
|
}
|
||||||
util.InitLogger(logLevel)
|
util.InitLogger(logLevel)
|
||||||
|
llmCfg := llm.ConfigFromEnv()
|
||||||
slog.Info(version.Full(), "level", logLevel,
|
slog.Info(version.Full(), "level", logLevel,
|
||||||
"ollama_host", os.Getenv("OLLAMA_HOST"),
|
"llm_backend", llmCfg.Backend,
|
||||||
"ollama_model", os.Getenv("OLLAMA_MODEL"))
|
"llm_endpoint", llmCfg.Endpoint,
|
||||||
|
"llm_model", llmCfg.Model)
|
||||||
|
|
||||||
dataDir := os.Getenv("DATA_DIR")
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
if dataDir == "" {
|
if dataDir == "" {
|
||||||
@@ -252,6 +255,9 @@ func main() {
|
|||||||
|
|
||||||
// ---- Set up event handlers ----
|
// ---- Set up event handlers ----
|
||||||
|
|
||||||
|
// Minimal Base used only for DM-room bookkeeping from the event handlers.
|
||||||
|
dmLearner := &plugin.Base{Client: client}
|
||||||
|
|
||||||
// Auto-join on invite + moderation member tracking
|
// Auto-join on invite + moderation member tracking
|
||||||
sess.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
sess.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -274,6 +280,11 @@ func main() {
|
|||||||
slog.Error("failed to join room", "room", evt.RoomID, "err", err)
|
slog.Error("failed to join room", "room", evt.RoomID, "err", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("joined room", "room", evt.RoomID)
|
slog.Info("joined room", "room", evt.RoomID)
|
||||||
|
// A user-initiated DM invite: claim it now, before any
|
||||||
|
// outbound DM has a chance to create a rival room.
|
||||||
|
if mem.IsDirect {
|
||||||
|
dmLearner.RecordDMRoom(evt.Sender, evt.RoomID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -322,6 +333,10 @@ func main() {
|
|||||||
body = strings.TrimSpace(body[idx+2:])
|
body = strings.TrimSpace(body[idx+2:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Adopt the room the user is talking in if it's their DM room, so the
|
||||||
|
// bot replies there instead of opening a second one.
|
||||||
|
dmLearner.LearnDMRoom(evt.Sender, evt.RoomID)
|
||||||
|
|
||||||
msgCtx := plugin.MessageContext{
|
msgCtx := plugin.MessageContext{
|
||||||
RoomID: evt.RoomID,
|
RoomID: evt.RoomID,
|
||||||
EventID: evt.ID,
|
EventID: evt.ID,
|
||||||
|
|||||||
Reference in New Issue
Block a user