mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e6937c0c | ||
|
|
ae5e10435a | ||
|
|
77dde5d133 | ||
|
|
583616f9d0 | ||
|
|
3f9c338e67 | ||
|
|
afa49b2d4c | ||
|
|
c8878b095c | ||
|
|
96f2125944 | ||
|
|
10d1e4adf5 | ||
|
|
ba41dc2d1b |
@@ -1255,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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -288,6 +288,18 @@ type RosterDetail struct {
|
|||||||
// run — a party of one is not a party, and the page should say nothing rather
|
// 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.
|
// than draw a roster with a single chair in it.
|
||||||
Party []PartySeatView `json:"party,omitempty"`
|
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
|
// PartySeatView is one body on a shared expedition, as the public page may see
|
||||||
|
|||||||
@@ -1374,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)
|
||||||
@@ -1400,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+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, ", "))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(dispatchHTTP, 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,45 +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). The client is a parameter because the two
|
// completion. The timeout is a parameter because the two callers have genuinely
|
||||||
// callers have genuinely different patience: a dispatch is authored on a game
|
// different patience: a dispatch is authored on a game chokepoint and must not
|
||||||
// chokepoint and must not stall it, while a run summary rides a background
|
// stall it, while a run summary rides a background ticker and can afford to wait
|
||||||
// ticker and can afford to wait for a bigger model. See runSummaryHTTP.
|
// for a bigger model. See runSummaryTimeout.
|
||||||
func callOllamaDispatch(client *http.Client, host, model, prompt string) (string, error) {
|
func callLLMDispatch(timeout time.Duration, prompt string) (string, error) {
|
||||||
payload := map[string]interface{}{
|
return llmGenerate(context.Background(), llm.Request{
|
||||||
"model": model,
|
Prompt: prompt,
|
||||||
"prompt": prompt,
|
NumCtx: 4096,
|
||||||
"stream": false,
|
Timeout: timeout,
|
||||||
"think": false,
|
})
|
||||||
"options": map[string]interface{}{
|
|
||||||
"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 := 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 != 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
|
||||||
|
|||||||
@@ -144,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 {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -178,6 +180,71 @@ func TestOptedOutSeatIsAnonymisedNotDropped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// snapshot, which is the only way to reach it — Party rides RosterDetail, so this
|
||||||
// also proves the wiring in buildRosterSnapshot and not just partySeatViews.
|
// also proves the wiring in buildRosterSnapshot and not just partySeatViews.
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -70,8 +68,6 @@ const maxRunSummary = 1200
|
|||||||
// for load-then-generate, and a timeout here really does mean the box is down.
|
// for load-then-generate, and a timeout here really does mean the box is down.
|
||||||
const runSummaryTimeout = 5 * time.Minute
|
const runSummaryTimeout = 5 * time.Minute
|
||||||
|
|
||||||
var runSummaryHTTP = &http.Client{Timeout: runSummaryTimeout}
|
|
||||||
|
|
||||||
// runSummaryBusy is the whole concurrency story: at most one sweep in flight,
|
// 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
|
// 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
|
// costs the board nothing, and the ticks that fire meanwhile find the flag set
|
||||||
@@ -105,7 +101,7 @@ func (p *AdventurePlugin) sweepRunSummaries() {
|
|||||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if os.Getenv("OLLAMA_HOST") == "" || os.Getenv("OLLAMA_MODEL") == "" {
|
if !llmConfigured() {
|
||||||
return // no model, no summary, no wasted queries asking which run needs one
|
return // no model, no summary, no wasted queries asking which run needs one
|
||||||
}
|
}
|
||||||
runID := nextRunNeedingSummary()
|
runID := nextRunNeedingSummary()
|
||||||
@@ -174,8 +170,7 @@ func authorRunSummary(runID string) (summary, name string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := callOllamaDispatch(runSummaryHTTP, os.Getenv("OLLAMA_HOST"), os.Getenv("OLLAMA_MODEL"),
|
raw, err := callLLMDispatch(runSummaryTimeout, buildRunSummaryPrompt(name, beats))
|
||||||
buildRunSummaryPrompt(name, beats))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
|
slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
|
||||||
return "", name
|
return "", name
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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