mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Expedition autopilot Phase 4 + rogue sneak attack + TwinBee voice sweep
Tedium-removal pass driven by live play feedback. Three big threads: Expedition autopilot Phase 4 — background auto-run + harvest-until-dry: - New expeditionAutoRunTicker walks active expeditions every 15min (5min tick, per-expedition CAS on new last_autorun_at column). Skips combat sessions, briefing/recap quiet windows, expeditions <30min old. - Walks up to 3 rooms/tick with compact narration; suppresses DMs when 0 rooms walked or just hitting the per-tick cap (no "stretch complete" filler). Player only hears from the bot when a real decision is needed. - Compact mode: one-line combat narration for trash/elite, auto-resolves elite doorways via the forward-sim engine (boss still pauses). Threaded via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom. - Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8 attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses. - Ambient ticker: anti-repeat by Kind via new last_ambient_kind column (avoids two pack_rat DMs in a row when the pool only has 6 lines). - !expedition go <n> now routes to the fork-choice handler when active + numeric; fork footer rewritten to suggest !expedition go instead of !zone go. Boss/Elite doorway: formatNextRoomMessage routes the action hint by next room type and autopilot loop breaks via new nextRoomType field so "Room X/Y — Boss" doesn't double-print with contradictory hints (!zone advance vs !fight). - runAutopilotWalk extracted from expeditionCmdRun so foreground and background share the loop body. Rogue Sneak Attack actually exists now: - Audit found the rogue's "Sneak Attack" passive was AutoCritFirst + 5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this with a small flat buff; the class's defining mechanic never matched its tooltip or 5e identity. - Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in combat_primitives.go (same lane as DivineStrikePerHit). Scales with level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20. - AutoCritFirst + 5% rider retained as bonuses on top of working sneak attack — preserves the opener-burst feel. - L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes from "16 rounds to kill, die in 8" to winnable. TwinBee voice sweep (Phase B3): - ~530 line changes across 24 files replacing third-person "TwinBee notes/files/tracks/respects/..." constructions with first-person inside flavor string literals. Code identifiers (TwinBeeLine, twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names (TwinBee's Bell), achievements, and game-title references in fun.go (Konami TwinBee ship) left intact. - Catches a real bug: Valdris fight rendered "TwinBee marks the Legendary Resistance" mid-combat in third person, contradicting the Phase B2 convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -253,6 +253,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.expeditionBriefingTicker()
|
||||
go p.expeditionRecapTicker()
|
||||
go p.expeditionAmbientTicker()
|
||||
go p.expeditionAutoRunTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
|
||||
@@ -79,7 +79,7 @@ var ClosingSuccess = []string{
|
||||
"in business and out of the deeper healthcare plans.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Morning DM: {morning_time} UTC\n" +
|
||||
"Evening summary: {summary_time} UTC — TwinBee will be there.\n\n" +
|
||||
"Evening summary: {summary_time} UTC — I will be there.\n\n" +
|
||||
"So will everyone else who showed up today.",
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ var ClosingFailure = []string{
|
||||
"So will you, which is the important part.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Tomorrow's choices at {morning_time} UTC.\n" +
|
||||
"TwinBee had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
|
||||
"I had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
|
||||
"Rest. You've earned the rest, at least.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
|
||||
@@ -442,7 +442,7 @@ var SummaryResting = []string{
|
||||
"Resting today. XP: zero. Loot: zero. Alive: yes.",
|
||||
"No action. Hovel. Wall. Nothing. Home.",
|
||||
"Sat this one out. The dungeons noticed. The dungeons don't care.",
|
||||
"Rest day. TwinBee noticed.",
|
||||
"Rest day. I noticed.",
|
||||
}
|
||||
|
||||
var SummaryDead = []string{
|
||||
@@ -487,8 +487,8 @@ var SummaryStandoutHornets = []string{
|
||||
var TwinBeeSummarySuccess = []string{
|
||||
"Visited {location}. Retrieved {loot} worth €{value}. Returned in excellent condition.",
|
||||
"{location}: success. {loot} recovered at €{value}. Standard operation.",
|
||||
"Cleared {location}. {loot}, €{value}. TwinBee has reported this as typical.",
|
||||
"{loot} worth €{value} from {location}. TwinBee is unsurprised.",
|
||||
"Cleared {location}. {loot}, €{value}. I have reported this as typical.",
|
||||
"{loot} worth €{value} from {location}. I am unsurprised.",
|
||||
"Professional operation in {location}. {loot}, €{value}. No further comment.",
|
||||
}
|
||||
|
||||
@@ -497,11 +497,11 @@ var TwinBeeSummaryWithdrawal = []string{
|
||||
"{location}: early departure. Strategic positioning. Returns tomorrow.",
|
||||
"Reconnaissance of {location} complete. Phase Two: tomorrow.",
|
||||
"Withdrew from {location} on schedule. The schedule has been updated.",
|
||||
"{location} assessed. TwinBee returns tomorrow. The {location} has been warned.",
|
||||
"{location} assessed. I return tomorrow. The {location} has been warned.",
|
||||
}
|
||||
|
||||
var TwinBeeSummaryEmpty = []string{
|
||||
"Visited {location}. Nothing there today. TwinBee notes this for the record.",
|
||||
"{location}: empty. The dungeon has failed to provide. TwinBee has noted it.",
|
||||
"Nothing found in {location}. TwinBee is filing feedback with the dungeon.",
|
||||
"Visited {location}. Nothing there today. I note this for the record.",
|
||||
"{location}: empty. The dungeon has failed to provide. I have noted it.",
|
||||
"Nothing found in {location}. I am filing feedback with the dungeon.",
|
||||
}
|
||||
|
||||
@@ -7,268 +7,268 @@ package plugin
|
||||
// from victories, looked at the right way, which is TwinBee's way.
|
||||
|
||||
var TwinBeeSuccess = []string{
|
||||
"TwinBee descended into {location} with characteristic enthusiasm " +
|
||||
"I descended into {location} with characteristic enthusiasm " +
|
||||
"and the kind of confidence that makes dungeon monsters briefly " +
|
||||
"reconsider their career choices. Several reconsidered too slowly. " +
|
||||
"TwinBee has returned with {loot} worth €{value} and a new personal philosophy " +
|
||||
"I have returned with {loot} worth €{value} and a new personal philosophy " +
|
||||
"regarding the optimum angle of attack on a Stone Golem. " +
|
||||
"The philosophy works. It has been field-tested. Just now.",
|
||||
|
||||
"Another flawless operation. TwinBee entered {location}, assessed the situation " +
|
||||
"Another flawless operation. I entered {location}, assessed the situation " +
|
||||
"with professional precision, and extracted {loot} worth €{value} " +
|
||||
"with minimal collateral damage to TwinBee specifically. " +
|
||||
"with minimal collateral damage to me specifically. " +
|
||||
"The dungeon has been informed it performed adequately. " +
|
||||
"TwinBee does not give compliments freely. This was a compliment.",
|
||||
"I do not give compliments freely. This was a compliment.",
|
||||
|
||||
"TwinBee has returned from {location} victorious, which is to say " +
|
||||
"TwinBee has returned from {location}, because for TwinBee these are the same thing. " +
|
||||
"{loot} worth €{value}. {xp} XP that TwinBee does not need " +
|
||||
"but accepts graciously, as a gift, from the dungeon. " +
|
||||
"I have returned from {location} victorious, which is to say " +
|
||||
"I have returned from {location}, because for me these are the same thing. " +
|
||||
"{loot} worth €{value}. {xp} XP that I do not need " +
|
||||
"but accept graciously, as a gift, from the dungeon. " +
|
||||
"The dungeon can try harder next time. It probably won't. They never do.",
|
||||
|
||||
"TwinBee located {loot} worth €{value} in {location} after what TwinBee " +
|
||||
"is describing in the post-mission debrief as 'a series of increasingly correct decisions.' " +
|
||||
"I located {loot} worth €{value} in {location} after what I am " +
|
||||
"describing in the post-mission debrief as 'a series of increasingly correct decisions.' " +
|
||||
"The monsters involved have been described as 'adequate opposition' " +
|
||||
"in the same debrief. TwinBee is generous with these things.",
|
||||
"in the same debrief. I am generous with these things.",
|
||||
|
||||
"In. Retrieved {loot}. Out. €{value}. {xp} XP. " +
|
||||
"TwinBee has the efficiency of someone who has done this many times " +
|
||||
"I have the efficiency of someone who has done this many times " +
|
||||
"and the confidence of someone who has never once considered " +
|
||||
"that it might not go well. These two qualities, combined, produce results. " +
|
||||
"The results are in your share of the haul.",
|
||||
|
||||
"TwinBee reports: {location} cleared, {loot} secured, €{value} assessed, " +
|
||||
"one near-miss that TwinBee is not classifying as a near-miss " +
|
||||
"because TwinBee does not have near-misses, TwinBee has " +
|
||||
"I report: {location} cleared, {loot} secured, €{value} assessed, " +
|
||||
"one near-miss that I am not classifying as a near-miss " +
|
||||
"because I do not have near-misses, I have " +
|
||||
"'moments of dynamic tactical adjustment that resolved favorably.' " +
|
||||
"The moment resolved favorably. Obviously.",
|
||||
|
||||
"The {location} boss encountered TwinBee today. " +
|
||||
"This is how TwinBee prefers to frame the encounter — " +
|
||||
"the boss encountered TwinBee, not the other way around — " +
|
||||
"The {location} boss encountered me today. " +
|
||||
"This is how I prefer to frame the encounter — " +
|
||||
"the boss encountered me, not the other way around — " +
|
||||
"because it accurately reflects the power dynamics. " +
|
||||
"{loot} worth €{value}. The boss has been encountered.",
|
||||
|
||||
"TwinBee went to {location} and came back with {loot} worth €{value}, " +
|
||||
"which is what TwinBee does, and {xp} XP, which TwinBee acknowledges " +
|
||||
"I went to {location} and came back with {loot} worth €{value}, " +
|
||||
"which is what I do, and {xp} XP, which I acknowledge " +
|
||||
"with the quiet grace of someone for whom excellence " +
|
||||
"has long since stopped being a surprise.",
|
||||
|
||||
"Professional assessment of today's {location} operation: successful. " +
|
||||
"Loot: {loot}, €{value}. Opposition: handled. Duration: optimal. " +
|
||||
"TwinBee's equipment: immaculate. TwinBee: as expected.",
|
||||
"My equipment: immaculate. Me: as expected.",
|
||||
|
||||
"TwinBee extracted {loot} worth €{value} from {location} " +
|
||||
"I extracted {loot} worth €{value} from {location} " +
|
||||
"with the efficiency of a professional and the cheerfulness of someone " +
|
||||
"who genuinely enjoys doing this, which TwinBee does, " +
|
||||
"which is why TwinBee is better at it than everyone else, " +
|
||||
"which TwinBee is.",
|
||||
"who genuinely enjoys doing this, which I do, " +
|
||||
"which is why I am better at it than everyone else, " +
|
||||
"which I am.",
|
||||
|
||||
"The monsters in {location} gave it their best today. " +
|
||||
"TwinBee noticed this and respected it for the duration of the encounter, " +
|
||||
"I noticed this and respected it for the duration of the encounter, " +
|
||||
"which was brief. {loot} worth €{value}. {xp} XP. " +
|
||||
"TwinBee has returned in excellent spirits, which is not news, " +
|
||||
"I have returned in excellent spirits, which is not news, " +
|
||||
"but it is accurate.",
|
||||
|
||||
"TwinBee's route through {location} was, according to TwinBee's own review, " +
|
||||
"My route through {location} was, according to my own review, " +
|
||||
"'essentially perfect.' No argument has been presented to the contrary. " +
|
||||
"TwinBee returned with {loot} worth €{value} and a complete absence " +
|
||||
"of anything that went wrong, which is TwinBee's preferred kind of absence.",
|
||||
"I returned with {loot} worth €{value} and a complete absence " +
|
||||
"of anything that went wrong, which is my preferred kind of absence.",
|
||||
|
||||
"A lesser adventurer would have found {location} challenging today. " +
|
||||
"TwinBee found it brisk. {loot}, €{value}, {xp} XP, " +
|
||||
"I found it brisk. {loot}, €{value}, {xp} XP, " +
|
||||
"and the mild satisfaction of a problem that turned out " +
|
||||
"to be an appropriately-sized problem. TwinBee appreciates appropriate sizing.",
|
||||
"to be an appropriately-sized problem. I appreciate appropriate sizing.",
|
||||
|
||||
"TwinBee entered {location} from the north approach, which TwinBee has determined " +
|
||||
"I entered {location} from the north approach, which I have determined " +
|
||||
"is the correct approach, and exited with {loot} worth €{value}. " +
|
||||
"The southern approach is noted. It is not the correct approach. " +
|
||||
"TwinBee has opinions about approaches and they are correct opinions.",
|
||||
"I have opinions about approaches and they are correct opinions.",
|
||||
|
||||
"Operational summary: {location} visited, opposition encountered, opposition handled, " +
|
||||
"{loot} extracted at €{value} assessed value, {xp} XP collected, " +
|
||||
"TwinBee returned in the condition TwinBee left in, " +
|
||||
"which is optimal, because TwinBee is always in optimal condition.",
|
||||
"I returned in the condition I left in, " +
|
||||
"which is optimal, because I am always in optimal condition.",
|
||||
}
|
||||
|
||||
var TwinBeeExceptional = []string{
|
||||
"OUTSTANDING PERFORMANCE. TwinBee located and neutralised the dungeon boss " +
|
||||
"— which TwinBee describes as 'a scheduling conflict that has now been resolved' — " +
|
||||
"OUTSTANDING PERFORMANCE. I located and neutralised the dungeon boss " +
|
||||
"— which I describe as 'a scheduling conflict that has now been resolved' — " +
|
||||
"and recovered {loot} worth €{value}. " +
|
||||
"This is, according to TwinBee, a typical Tuesday. " +
|
||||
"TwinBee has had many exceptional Tuesdays. The dungeons are aware.",
|
||||
"This is, by my reckoning, a typical Tuesday. " +
|
||||
"I have had many exceptional Tuesdays. The dungeons are aware.",
|
||||
|
||||
"TwinBee found {loot} worth €{value} in {location} after what TwinBee " +
|
||||
"is calling 'a brief but educational exchange' with something very large " +
|
||||
"I found {loot} worth €{value} in {location} after what I am " +
|
||||
"calling 'a brief but educational exchange' with something very large " +
|
||||
"that is no longer a going concern. Bonus XP noted. " +
|
||||
"TwinBee noted it first. TwinBee notes everything first.",
|
||||
"I noted it first. I note everything first.",
|
||||
|
||||
"The inner chamber. TwinBee reached the inner chamber. " +
|
||||
"TwinBee notes that reaching the inner chamber requires " +
|
||||
"The inner chamber. I reached the inner chamber. " +
|
||||
"I note that reaching the inner chamber requires " +
|
||||
"the successful navigation of everything between here and the inner chamber, " +
|
||||
"which TwinBee navigated successfully, and then {loot} worth €{value} " +
|
||||
"was in the inner chamber, and TwinBee took it. " +
|
||||
"which I navigated successfully, and then {loot} worth €{value} " +
|
||||
"was in the inner chamber, and I took it. " +
|
||||
"The inner chamber is now available to be an outer chamber.",
|
||||
|
||||
"A boss encounter. TwinBee's assessment of the boss: 'interesting.' " +
|
||||
"The boss's assessment of TwinBee: insufficient time to complete. " +
|
||||
"{loot} worth €{value}. TwinBee has forwarded the boss's personnel file " +
|
||||
"A boss encounter. My assessment of the boss: 'interesting.' " +
|
||||
"The boss's assessment of me: insufficient time to complete. " +
|
||||
"{loot} worth €{value}. I have forwarded the boss's personnel file " +
|
||||
"to the relevant afterlife authorities with a positive reference.",
|
||||
|
||||
"EXCEPTIONAL. TwinBee's word, used sparingly. " +
|
||||
"EXCEPTIONAL. My word, used sparingly. " +
|
||||
"Today merits it. {loot} worth €{value} from the deepest chamber " +
|
||||
"of {location}, retrieved past opposition that TwinBee describes " +
|
||||
"of {location}, retrieved past opposition that I describe " +
|
||||
"as 'the most interesting opposition I've faced this week,' " +
|
||||
"which is high praise from TwinBee and accurately assesses the week so far.",
|
||||
"which is high praise from me and accurately assesses the week so far.",
|
||||
|
||||
"TwinBee found something the dungeon wasn't offering publicly. " +
|
||||
"TwinBee finds things dungeons aren't offering publicly. " +
|
||||
"I found something the dungeon wasn't offering publicly. " +
|
||||
"I find things dungeons aren't offering publicly. " +
|
||||
"It's a skill. {loot} worth €{value}. {xp} XP. " +
|
||||
"The dungeon has updated its offering. TwinBee approves of the update " +
|
||||
"The dungeon has updated its offering. I approve of the update " +
|
||||
"from a position of already having taken the thing.",
|
||||
|
||||
"A critical run. Every encounter resolved correctly. Every chamber yielded. " +
|
||||
"Every roll, every decision, every approach: correct. " +
|
||||
"{loot} worth €{value}. {xp} XP. " +
|
||||
"TwinBee submits this run as the reference standard for {location}. " +
|
||||
"I submit this run as the reference standard for {location}. " +
|
||||
"Future runs should aspire to this. They will not reach it.",
|
||||
|
||||
"The boss was large. TwinBee is TwinBee. " +
|
||||
"The boss was large. I am here. " +
|
||||
"These two facts met in {location} and produced {loot} worth €{value} " +
|
||||
"and a story that TwinBee is telling to everyone and will continue telling " +
|
||||
"until a better story replaces it, which will also be TwinBee's story.",
|
||||
"and a story that I am telling to everyone and will continue telling " +
|
||||
"until a better story replaces it, which will also be my story.",
|
||||
}
|
||||
|
||||
var TwinBeeWithdrawal = []string{
|
||||
"TwinBee has completed today's operation and is taking the remainder " +
|
||||
"I have completed today's operation and am taking the remainder " +
|
||||
"of the afternoon for strategic planning. " +
|
||||
"This is unrelated to {location}. {location} is unrelated to everything. " +
|
||||
"TwinBee has already moved on and suggests you do the same.",
|
||||
"I have already moved on and suggest you do the same.",
|
||||
|
||||
"TwinBee assessed {location} with characteristic thoroughness, " +
|
||||
"I assessed {location} with characteristic thoroughness, " +
|
||||
"identified that today's objectives had been sufficiently advanced " +
|
||||
"by the reconnaissance phase, and withdrew in good order " +
|
||||
"before the situation became interesting in ways TwinBee did not schedule. " +
|
||||
"This is called professionalism. TwinBee has it.",
|
||||
"before the situation became interesting in ways I did not schedule. " +
|
||||
"This is called professionalism. I have it.",
|
||||
|
||||
"Today's operation at {location} concluded earlier than projected. " +
|
||||
"TwinBee encountered something very large, evaluated the encounter on its merits, " +
|
||||
"I encountered something very large, evaluated the encounter on its merits, " +
|
||||
"and determined that returning tomorrow with more information " +
|
||||
"was the superior strategic outcome. " +
|
||||
"TwinBee was not running. TwinBee does not run. " +
|
||||
"TwinBee was moving with purpose in the opposite direction. These are different.",
|
||||
"I was not running. I do not run. " +
|
||||
"I was moving with purpose in the opposite direction. These are different.",
|
||||
|
||||
"TwinBee left {location} at a time of TwinBee's choosing, " +
|
||||
"I left {location} at a time of my own choosing, " +
|
||||
"which happened to coincide with the arrival of something " +
|
||||
"that TwinBee is describing in the official report as 'a scheduling conflict.' " +
|
||||
"that I am describing in the official report as 'a scheduling conflict.' " +
|
||||
"The conflict has been noted. It will be addressed. Tomorrow. " +
|
||||
"At a time that suits TwinBee.",
|
||||
"At a time that suits me.",
|
||||
|
||||
"TACTICAL WITHDRAWAL EXECUTED. {location} has been informed, " +
|
||||
"via the medium of TwinBee's departure, that today was not the day. " +
|
||||
"TwinBee knows which days are the days. Today was for gathering intelligence. " +
|
||||
"via the medium of my departure, that today was not the day. " +
|
||||
"I know which days are the days. Today was for gathering intelligence. " +
|
||||
"The intelligence gathered is: {location} still exists and should watch itself.",
|
||||
|
||||
"TwinBee retreated from {location} in the same way the tide retreats from the shore — " +
|
||||
"I retreated from {location} in the same way the tide retreats from the shore — " +
|
||||
"deliberately, inevitably, and with the full understanding that it will be back, " +
|
||||
"and the shore knows this, and the shore is thinking about it right now.",
|
||||
|
||||
"Something in {location} today required TwinBee to make a decision. " +
|
||||
"TwinBee made the decision. The decision was: not today. " +
|
||||
"TwinBee's decisions are correct decisions by definition, " +
|
||||
"Something in {location} today required me to make a decision. " +
|
||||
"I made the decision. The decision was: not today. " +
|
||||
"My decisions are correct decisions by definition, " +
|
||||
"which means 'not today' was correct, which will be proven correct " +
|
||||
"when TwinBee returns tomorrow and the thing is no longer a factor. " +
|
||||
"These things always work out when TwinBee decides they will.",
|
||||
"when I return tomorrow and the thing is no longer a factor. " +
|
||||
"These things always work out when I decide they will.",
|
||||
|
||||
"The mission objectives for today's {location} operation have been " +
|
||||
"reclassified as 'Phase One of a Two-Phase Operation,' " +
|
||||
"where Phase One is reconnaissance and Phase Two is tomorrow. " +
|
||||
"Phase One is complete. Phase One was, in retrospect, always the plan.",
|
||||
|
||||
"TwinBee encountered something in {location} that TwinBee is describing " +
|
||||
"I encountered something in {location} that I am describing " +
|
||||
"as 'an opportunity to demonstrate strategic patience.' " +
|
||||
"TwinBee demonstrated strategic patience. The patience was demonstrated " +
|
||||
"I demonstrated strategic patience. The patience was demonstrated " +
|
||||
"at significant velocity in the direction of the exit, " +
|
||||
"but the patience was present throughout.",
|
||||
|
||||
"Official statement from TwinBee regarding today's {location} operation: " +
|
||||
"Official statement from me regarding today's {location} operation: " +
|
||||
"'Everything went according to plan.' " +
|
||||
"The plan has been updated to reflect what happened. " +
|
||||
"The updated plan describes what happened as planned. " +
|
||||
"TwinBee's plans are always accurate.",
|
||||
"My plans are always accurate.",
|
||||
|
||||
"TwinBee is back. The thing in {location} is also still there. " +
|
||||
"TwinBee is aware of the thing. The thing is aware of TwinBee. " +
|
||||
"This is a stalemate that TwinBee will resolve on TwinBee's timeline, " +
|
||||
"I am back. The thing in {location} is also still there. " +
|
||||
"I am aware of the thing. The thing is aware of me. " +
|
||||
"This is a stalemate that I will resolve on my own timeline, " +
|
||||
"which is different from the thing's timeline, " +
|
||||
"and TwinBee's timeline is the one that matters.",
|
||||
"and my timeline is the one that matters.",
|
||||
|
||||
"The {location} situation has been described by TwinBee as " +
|
||||
"'temporarily inconclusive,' which is a phrase TwinBee uses " +
|
||||
"The {location} situation has been described, by me, as " +
|
||||
"'temporarily inconclusive,' which is a phrase I use " +
|
||||
"when something very large expressed a strong position " +
|
||||
"and TwinBee has chosen to revisit the position on better terms. " +
|
||||
"The terms will be better. TwinBee will see to it.",
|
||||
"and I have chosen to revisit the position on better terms. " +
|
||||
"The terms will be better. I will see to it.",
|
||||
|
||||
"Today, TwinBee gathered extensive tactical data on {location}. " +
|
||||
"Today, I gathered extensive tactical data on {location}. " +
|
||||
"The data indicates that {location} is well-defended. " +
|
||||
"The data was gathered by going in and then coming back out, " +
|
||||
"which is a time-honoured data gathering methodology " +
|
||||
"that TwinBee has employed before and will employ again, ideally less urgently.",
|
||||
"that I have employed before and will employ again, ideally less urgently.",
|
||||
|
||||
"TwinBee's return today is best understood as a 'victory lap around the outside,' " +
|
||||
"which is a phrase TwinBee has just coined and will be using going forward. " +
|
||||
"My return today is best understood as a 'victory lap around the outside,' " +
|
||||
"which is a phrase I have just coined and will be using going forward. " +
|
||||
"A victory lap around the outside of {location} has been completed. " +
|
||||
"TwinBee is home. The inside of {location} is tomorrow's victory lap.",
|
||||
"I am home. The inside of {location} is tomorrow's victory lap.",
|
||||
|
||||
"Something the size of a building expressed interest in TwinBee at depth. " +
|
||||
"TwinBee expressed equal and opposite interest in being elsewhere. " +
|
||||
"Both interests were satisfied. TwinBee is elsewhere. " +
|
||||
"Something the size of a building expressed interest in me at depth. " +
|
||||
"I expressed equal and opposite interest in being elsewhere. " +
|
||||
"Both interests were satisfied. I am elsewhere. " +
|
||||
"The something is where it was. The relationship is stable.",
|
||||
}
|
||||
|
||||
var TwinBeeEmpty = []string{
|
||||
"TwinBee has returned from {location} and wishes it to be known " +
|
||||
"that the dungeon was empty, which TwinBee attributes entirely to their reputation " +
|
||||
"preceding them. The monsters left. They knew. " +
|
||||
"TwinBee respects this. TwinBee also found nothing. These facts are unrelated.",
|
||||
"I have returned from {location} and wish it to be known " +
|
||||
"that the dungeon was empty, which I attribute entirely to my reputation " +
|
||||
"preceding me. The monsters left. They knew. " +
|
||||
"I respect this. I also found nothing. These facts are unrelated.",
|
||||
|
||||
"Intelligence suggested {location} would be productive today. " +
|
||||
"TwinBee's intelligence was incorrect. " +
|
||||
"TwinBee's intelligence is reviewing its methodology. " +
|
||||
"My intelligence was incorrect. " +
|
||||
"My intelligence is reviewing its methodology. " +
|
||||
"The dungeon contained no loot, no monsters worth discussing, " +
|
||||
"and one confused bat that TwinBee has agreed not to mention publicly. " +
|
||||
"and one confused bat that I have agreed not to mention publicly. " +
|
||||
"Moving on.",
|
||||
|
||||
"{location} was cleared. By whom is unclear. TwinBee suspects a rival. " +
|
||||
"TwinBee does not have rivals, technically, " +
|
||||
"but is prepared to invent one for the purposes of this grievance. " +
|
||||
"{location} was cleared. By whom is unclear. I suspect a rival. " +
|
||||
"I do not have rivals, technically, " +
|
||||
"but am prepared to invent one for the purposes of this grievance. " +
|
||||
"No loot. No comment. The rival has been noted.",
|
||||
|
||||
"The {location} run today yielded nothing of material value, " +
|
||||
"which TwinBee attributes to the universe's temporary misalignment " +
|
||||
"with TwinBee's interests. The universe will correct itself. " +
|
||||
"TwinBee is giving it until tomorrow.",
|
||||
"which I attribute to the universe's temporary misalignment " +
|
||||
"with my interests. The universe will correct itself. " +
|
||||
"I am giving it until tomorrow.",
|
||||
|
||||
"Empty. {location} was empty today, which is {location}'s problem. " +
|
||||
"TwinBee showed up. TwinBee was prepared. TwinBee had the right equipment. " +
|
||||
"I showed up. I was prepared. I had the right equipment. " +
|
||||
"The dungeon failed to provide an adequate challenge or adequate reward. " +
|
||||
"This reflects poorly on the dungeon.",
|
||||
|
||||
"TwinBee cleared four rooms of {location} and found nothing worth the trip. " +
|
||||
"TwinBee is not saying the trip was not worth it — TwinBee does not say that — " +
|
||||
"I cleared four rooms of {location} and found nothing worth the trip. " +
|
||||
"I am not saying the trip was not worth it — I do not say that — " +
|
||||
"but the material outcome was zero and the XP was minimal " +
|
||||
"and TwinBee would like to register this as feedback for the dungeon.",
|
||||
"and I would like to register this as feedback for the dungeon.",
|
||||
|
||||
"The treasure was already gone when TwinBee arrived, " +
|
||||
"which means someone got there before TwinBee, " +
|
||||
"The treasure was already gone when I arrived, " +
|
||||
"which means someone got there before me, " +
|
||||
"which is a sentence that has never previously been true " +
|
||||
"and which TwinBee is treating as a statistical anomaly " +
|
||||
"and which I am treating as a statistical anomaly " +
|
||||
"rather than a pattern that might develop.",
|
||||
|
||||
"No monsters. No treasure. No meaningful opposition. " +
|
||||
"Just TwinBee in {location} swinging at things that weren't there. " +
|
||||
"TwinBee has described this run as 'character-building.' " +
|
||||
"TwinBee's character was already built. " +
|
||||
"Just me in {location} swinging at things that weren't there. " +
|
||||
"I have described this run as 'character-building.' " +
|
||||
"My character was already built. " +
|
||||
"This run has contributed nothing to the structure.",
|
||||
}
|
||||
|
||||
@@ -530,7 +530,7 @@ var RespawnDM = []string{
|
||||
|
||||
var IdleShameDM = []string{
|
||||
"{name}, hey. Didn't see you out there today.\n\n" +
|
||||
"TwinBee swung by the hovel — door was shut, no answer.\n" +
|
||||
"I swung by the hovel — door was shut, no answer.\n" +
|
||||
"Hope you're alright. The dungeon will keep. The mine will keep.\n" +
|
||||
"They've been there a while; they're patient.\n\n" +
|
||||
"Tomorrow: 08:00 UTC. We'll be ready when you are.",
|
||||
@@ -538,16 +538,16 @@ var IdleShameDM = []string{
|
||||
"Quiet day for you, {name}.\n\n" +
|
||||
"The morning DM went out and didn't come back. That happens.\n" +
|
||||
"Real life sometimes shows up uninvited and stays for dinner.\n" +
|
||||
"TwinBee picked up a little extra in the field — covered for you.\n\n" +
|
||||
"I picked up a little extra in the field — covered for you.\n\n" +
|
||||
"Tomorrow morning the choices reset. Drop by when you can.",
|
||||
|
||||
"{name}, no sign of you in the field today.\n\n" +
|
||||
"The forest is still there. So's the mine. So's that one rusted sword\n" +
|
||||
"you keep meaning to replace. Nothing important moved.\n\n" +
|
||||
"08:00 UTC tomorrow — fresh menu, same patient TwinBee.",
|
||||
"08:00 UTC tomorrow — fresh menu, same patient narrator.",
|
||||
|
||||
"Missed you out there, {name}.\n\n" +
|
||||
"TwinBee went on a haul today and saved a corner of the splits for you,\n" +
|
||||
"I went on a haul today and saved a corner of the splits for you,\n" +
|
||||
"then ate it on principle when you didn't show. (Sorry. House rule.)\n" +
|
||||
"Nothing's lost that can't be earned back.\n\n" +
|
||||
"Tomorrow: 08:00 UTC. The dungeons are warm.",
|
||||
@@ -558,7 +558,7 @@ var IdleShameDM = []string{
|
||||
"08:00 UTC. Whenever you're back, we pick up where we left off.",
|
||||
|
||||
"{name}. Hope you had a good rest day.\n\n" +
|
||||
"TwinBee held down the fort. Nothing dramatic, nothing lost —\n" +
|
||||
"I held down the fort. Nothing dramatic, nothing lost —\n" +
|
||||
"the world declined to end on your behalf, which feels generous.\n\n" +
|
||||
"When you're ready, the morning DM hits at 08:00 UTC. No rush.",
|
||||
}
|
||||
@@ -584,8 +584,8 @@ var OnboardingDM = []string{
|
||||
var MorningDM = []string{
|
||||
"⚔️ Good morning, {name}. Another day, another opportunity for regret.\n\n" +
|
||||
"{character_sheet}\n\n" +
|
||||
"Where are you headed? TwinBee would choose the dungeon.\n" +
|
||||
"TwinBee would also survive it. You are not TwinBee. Choose wisely.",
|
||||
"Where are you headed? I would choose the dungeon.\n" +
|
||||
"I would also survive it. You are not TwinBee. Choose wisely.",
|
||||
|
||||
"⚔️ {name}. You're alive. The options are:\n\n" +
|
||||
"{character_sheet}\n\n" +
|
||||
@@ -609,6 +609,6 @@ var MorningDM = []string{
|
||||
"{character_sheet}\n\n" +
|
||||
"Or don't. You could rest. You could do nothing.\n" +
|
||||
"The bot will send you a disappointed DM at midnight.\n" +
|
||||
"TwinBee will know. TwinBee always knows.\n" +
|
||||
"I will know. I always know.\n" +
|
||||
"Reply to choose.",
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ func craftingTeaserText(foragingSkill, craftsSucceeded int, userID id.UserID) st
|
||||
// Post-unlock: nudge when no successful crafts yet (gentle), or once a
|
||||
// week (loose periodicity via day-of-year %).
|
||||
if craftsSucceeded == 0 {
|
||||
return "🧪 **Crafting is unlocked.** Gather a couple of matching ingredients and TwinBee will auto-craft consumables — try `!adventure recipes` to see what your level supports."
|
||||
return "🧪 **Crafting is unlocked.** Gather a couple of matching ingredients and I will auto-craft consumables — try `!adventure recipes` to see what your level supports."
|
||||
}
|
||||
if int(time.Now().UTC().Weekday()) == craftingReminderWeekday(userID, time.Now().UTC()) {
|
||||
return "🧪 *Crafting reminder* — `!adventure recipes` shows what's available at Foraging Lv." + fmt.Sprintf("%d.", foragingSkill)
|
||||
@@ -836,7 +836,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers: jackshit.\n", tbRewards.Eligible))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")
|
||||
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. I noticed.)\n\n")
|
||||
sb.WriteString("───────────────────\n\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ func (p *AdventurePlugin) selectTwinBeeFlavor(result *TwinBeeResult) string {
|
||||
}
|
||||
|
||||
if len(pool) == 0 {
|
||||
return "TwinBee went to " + result.Location.Name + ". Results pending."
|
||||
return "I went to " + result.Location.Name + ". Results pending."
|
||||
}
|
||||
|
||||
text := pool[rand.IntN(len(pool))]
|
||||
@@ -330,13 +330,13 @@ type twinBeeGiftDef struct {
|
||||
}
|
||||
|
||||
var twinBeeGifts = []twinBeeGiftDef{
|
||||
{"success_chance", "TwinBee's Lucky Star ⭐", 10, 24 * time.Hour, "TwinBee sends a star. They have many stars. Use it well."},
|
||||
{"success_chance", "TwinBee's Lucky Star ⭐", 10, 24 * time.Hour, "I send a star. They have many stars. Use it well."},
|
||||
{"death_chance", "Bec's Blessing 🐝", -5, 48 * time.Hour, "Bec has blessed you. Bec does not do this lightly. Don't die."},
|
||||
{"loot_quality", "WinBee's Coin 🪙", 15, 24 * time.Hour, "WinBee flipped this coin and it came up you. Lucky."},
|
||||
{"mining_success", "Goemon's Pipe 🎋", 8, 48 * time.Hour, "Borrowed from Goemon. Return not expected. Results expected."},
|
||||
{"foraging_death", "Pentarou's Feather 🪶", -10, 24 * time.Hour, "Pentarou parted with this reluctantly. They like you enough. Mostly."},
|
||||
{"xp_multiplier", "TwinBee's Bell Fragment 🔔", 5, 48 * time.Hour, "A piece of the Bell. It rings when you're doing well. It will ring."},
|
||||
{"exceptional_chance", "Power Up Pod 🫛", 50, 48 * time.Hour, "TwinBee found extras. This is not a common occurrence. Don't waste it."},
|
||||
{"exceptional_chance", "Power Up Pod 🫛", 50, 48 * time.Hour, "I found extras. This is not a common occurrence. Don't waste it."},
|
||||
}
|
||||
|
||||
const twinBeeGiftChance = 0.15
|
||||
|
||||
@@ -116,6 +116,16 @@ type CombatModifiers struct {
|
||||
// no Weapon → no Divine Strike.
|
||||
DivineStrikePerHit int
|
||||
|
||||
// Class-identity audit (2026-05-16) — Rogue Sneak Attack as actual
|
||||
// per-hit Nd6 bonus damage. Number of d6 to roll on every player hit.
|
||||
// 5e gates this on "once per turn given advantage/ally adjacent"; our
|
||||
// 1v1 engine has no turn-boundary concept, so the same "lands every
|
||||
// hit" convention as DivineStrikePerHit applies — and since Rogues
|
||||
// only get one swing per round, this naturally caps at once/round.
|
||||
// Set in applyClassPassives based on Rogue level (1d6 at L1-2 → 4d6
|
||||
// at L7-8 → 10d6 at L19-20, per 5e progression).
|
||||
SneakAttackDie int
|
||||
|
||||
// Phase 10 SUB2b — Mage subclasses.
|
||||
// ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the
|
||||
// start of each combat by Abjuration L5+ (2× Mage level, +prof at L7).
|
||||
|
||||
@@ -150,12 +150,12 @@ func isTrivialEvent(e CombatEvent) bool {
|
||||
// (misses, whiffs) so long boss fights don't read as a wall of
|
||||
// "you swing wide / they swing wide" lines.
|
||||
var narrativeFillerSlog = []string{
|
||||
"_The exchange settles into a rhythm. A few rounds of nothing meaningful — TwinBee skips to the next thing that matters._",
|
||||
"_TwinBee narrates the next few seconds in a single sigh. Both sides keep swinging. Both sides keep missing._",
|
||||
"_And the battle goes on. Yadda yadda yadda. TwinBee will narrate the next part where something actually lands._",
|
||||
"_The grind continues. TwinBee uses the time to mentally redesign the dungeon's lighting._",
|
||||
"_A flurry of misses on both sides. Nobody is impressing anybody. TwinBee waits for the next hit._",
|
||||
"_TwinBee politely declines to narrate this stretch. It was, in TwinBee's professional opinion, a waste of everyone's time._",
|
||||
"_The exchange settles into a rhythm. A few rounds of nothing meaningful — I skip to the next thing that matters._",
|
||||
"_I narrate the next few seconds in a single sigh. Both sides keep swinging. Both sides keep missing._",
|
||||
"_And the battle goes on. Yadda yadda yadda. I will narrate the next part where something actually lands._",
|
||||
"_The grind continues. I use the time to mentally redesign the dungeon's lighting._",
|
||||
"_A flurry of misses on both sides. Nobody is impressing anybody. I wait for the next hit._",
|
||||
"_I politely decline to narrate this stretch. It was, in my professional opinion, a waste of everyone's time._",
|
||||
}
|
||||
|
||||
func clampHP(hp int) int {
|
||||
@@ -896,7 +896,7 @@ func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *action
|
||||
|
||||
var narrativeTurnFlee = []string{
|
||||
"🏃 You break off and run. No shame in it — the dead don't get a sequel.",
|
||||
"🏃 You decide this fight isn't yours to win and make for the exit. TwinBee respects the math.",
|
||||
"🏃 You decide this fight isn't yours to win and make for the exit. I respect the math.",
|
||||
"🏃 You disengage and bolt. The enemy doesn't chase. The enemy didn't have to.",
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,19 @@ func applyPlayerHitDamageMods(st *combatState, player *Combatant, dmg int, isCri
|
||||
if player.Mods.DivineStrikePerHit > 0 && player.Stats.Weapon != nil {
|
||||
dmg += player.Mods.DivineStrikePerHit
|
||||
}
|
||||
// Class-identity audit (2026-05-16) — Rogue Sneak Attack. Add Nd6 on
|
||||
// every player hit. Same "once per turn = every hit" convention as
|
||||
// Divine Strike. Rogues swing once per round so this naturally lands
|
||||
// once/round at the cadence 5e expects. The bonus rides through the
|
||||
// crit doubling above (already applied to dmg) so a sneak-attack crit
|
||||
// reads correctly: doubled weapon dmg, then +Nd6 unaffected by the
|
||||
// crit. (5e doubles the sneak attack dice on crit too — we under-
|
||||
// shoot by ~Nd6 on the rare crit, which is acceptable for now.)
|
||||
if player.Mods.SneakAttackDie > 0 {
|
||||
for i := 0; i < player.Mods.SneakAttackDie; i++ {
|
||||
dmg += 1 + st.roll(6)
|
||||
}
|
||||
}
|
||||
// Phase 10 SUB2a-ii — Assassin Death Strike proxy: bonus damage on the
|
||||
// first hit only. Stacks on top of the Rogue's Sneak Attack auto-crit
|
||||
// (which already doubled the base damage above) — the bonus itself is
|
||||
|
||||
@@ -91,7 +91,7 @@ func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
|
||||
}
|
||||
|
||||
outcome := p.finishCombatSession(userID, sess, enemy)
|
||||
preamble := fmt.Sprintf("⏳ Your fight with **%s** timed out — TwinBee finished it for you.\n\n", enemy.Name)
|
||||
preamble := fmt.Sprintf("⏳ Your fight with **%s** timed out — I finished it for you.\n\n", enemy.Name)
|
||||
if err := p.SendDM(userID, preamble+outcome); err != nil {
|
||||
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
@@ -85,8 +85,12 @@ type Expedition struct {
|
||||
DMMood int
|
||||
LastBriefingAt *time.Time
|
||||
LastRecapAt *time.Time
|
||||
LastActivity time.Time
|
||||
CompletedAt *time.Time
|
||||
// LastAmbientKind — the Kind of the most recent ambient event the
|
||||
// ticker fired (e.g. "pack_rat", "monologue"). Empty when no ambient
|
||||
// has fired yet. Used by pickAmbientEvent for back-to-back anti-repeat.
|
||||
LastAmbientKind string
|
||||
LastActivity time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
// IsActive reports whether the expedition is in flight.
|
||||
@@ -189,7 +193,8 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition
|
||||
WHERE user_id = ?
|
||||
AND status = 'active'
|
||||
@@ -210,7 +215,8 @@ func getExpedition(id string) (*Expedition, error) {
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition WHERE expedition_id = ?`, id)
|
||||
e, err := scanExpedition(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -240,7 +246,8 @@ func scanExpedition(row scanner) (*Expedition, error) {
|
||||
&suppliesJSON, &campJSON, &e.ThreatLevel, &siegeI,
|
||||
&threatJSON, &e.TemporalStack, ®ionJSON,
|
||||
&e.XPEarned, &e.CoinsEarned, &e.DMMood,
|
||||
&lastBriefingRaw, &lastRecapRaw, &e.LastActivity, &completedRaw,
|
||||
&lastBriefingRaw, &lastRecapRaw, &e.LastAmbientKind,
|
||||
&e.LastActivity, &completedRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -11,16 +11,18 @@ import (
|
||||
|
||||
// Phase 2 of expedition autopilot — auto-harvest on room entry.
|
||||
//
|
||||
// Design (settled 2026-05-14):
|
||||
// Design (settled 2026-05-14, harvest-until-dry added 2026-05-16):
|
||||
// - All applicable actions auto-run on every walked room. Class- and
|
||||
// kill-gated nodes filter themselves out via the existing per-resource
|
||||
// restrictions.
|
||||
// - Rare+ nodes are NOT auto-attempted. If any sit available in the
|
||||
// room, autopilot stops with stopRareNode so the player can spend the
|
||||
// attempt deliberately.
|
||||
// - One attempt per eligible node per visit. A failed roll doesn't
|
||||
// retry; it logs the fail and moves on. Charges only decrement on
|
||||
// non-Nothing outcomes (mirrors handleHarvestCmd).
|
||||
// - Each eligible (Common/Uncommon) node is ground until it runs dry or
|
||||
// until autoHarvestPerNodeAttempts attempts have been spent — whichever
|
||||
// comes first. Failed rolls re-roll without depleting charges, same
|
||||
// as a manual player retrying !forage/!scavenge/etc. The cap stops
|
||||
// the walk from getting stuck on a hard-DC node forever.
|
||||
// - Noise interrupts apply threat++ and continue (parity with manual).
|
||||
// - Standard/Elite/Patrol combat interrupts hard-stop the walk:
|
||||
// stopEnded on death, stopHarvestCombat otherwise.
|
||||
@@ -28,6 +30,12 @@ import (
|
||||
// - Per-room footer ("+2 Iron, +1 Sage; 1 fail") + a walk-end tally
|
||||
// across all rooms.
|
||||
|
||||
// autoHarvestPerNodeAttempts bounds how many times autopilot will swing
|
||||
// at a single node before giving up for this visit. Sized to handle a
|
||||
// typical Common/Uncommon node (charges 1–3, success rate 50–80%) with
|
||||
// margin, while keeping a DC-20 outlier from spinning indefinitely.
|
||||
const autoHarvestPerNodeAttempts = 8
|
||||
|
||||
// autoHarvestSummary holds the aggregated outcome for one room's
|
||||
// auto-harvest pass. Per-room footer renders from this; expeditionCmdRun
|
||||
// also folds it into a walk-wide tally for the closing block.
|
||||
@@ -135,69 +143,75 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
||||
continue
|
||||
}
|
||||
|
||||
// Interrupt roll, same model as handleHarvestCmd.
|
||||
interrupt, intTotal := resolveCombatInterrupt(
|
||||
exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil)
|
||||
switch interrupt {
|
||||
case InterruptNoise:
|
||||
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
|
||||
res.Summary.NoiseInts++
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||
fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
|
||||
// fall through to the d20 roll, same as manual harvest.
|
||||
case InterruptStandard, InterruptElite, InterruptPatrol:
|
||||
body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
|
||||
res.CombatNarr = body
|
||||
res.PlayerEnded = ended
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||
fmt.Sprintf("autopilot %s kind=%d total=%d", string(action), int(interrupt), intTotal), "")
|
||||
if persistNodes {
|
||||
_ = saveHarvestNodes(exp, nodeID, nodes)
|
||||
// Grind this node until it's dry or until the per-node attempt
|
||||
// cap fires. A failed roll re-rolls (mirrors a manual player
|
||||
// pressing the harvest command again); only successful pulls
|
||||
// decrement charges.
|
||||
for attempt := 0; attempt < autoHarvestPerNodeAttempts && n.CurrentCharges > 0; attempt++ {
|
||||
// Interrupt roll, same model as handleHarvestCmd.
|
||||
interrupt, intTotal := resolveCombatInterrupt(
|
||||
exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil)
|
||||
switch interrupt {
|
||||
case InterruptNoise:
|
||||
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
|
||||
res.Summary.NoiseInts++
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||
fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
|
||||
// fall through to the d20 roll, same as manual harvest.
|
||||
case InterruptStandard, InterruptElite, InterruptPatrol:
|
||||
body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
|
||||
res.CombatNarr = body
|
||||
res.PlayerEnded = ended
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||
fmt.Sprintf("autopilot %s kind=%d total=%d", string(action), int(interrupt), intTotal), "")
|
||||
if persistNodes {
|
||||
_ = saveHarvestNodes(exp, nodeID, nodes)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Resolve the harvest attempt.
|
||||
roll := rand.IntN(20) + 1
|
||||
mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
|
||||
if action == HarvestFish {
|
||||
if adv, _ := loadAdvCharacter(userID); adv != nil {
|
||||
mod += fishingSkillBonus(adv.FishingSkill)
|
||||
// Resolve the harvest attempt.
|
||||
roll := rand.IntN(20) + 1
|
||||
mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
|
||||
if action == HarvestFish {
|
||||
if adv, _ := loadAdvCharacter(userID); adv != nil {
|
||||
mod += fishingSkillBonus(adv.FishingSkill)
|
||||
}
|
||||
}
|
||||
}
|
||||
total := roll + mod
|
||||
dcDelta, _ := zoneConditionHarvestDCMod(exp, action)
|
||||
effectiveDC := rdef.DC + dcDelta
|
||||
outcome := resolveHarvestOutcome(total, effectiveDC)
|
||||
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
||||
total := roll + mod
|
||||
dcDelta, _ := zoneConditionHarvestDCMod(exp, action)
|
||||
effectiveDC := rdef.DC + dcDelta
|
||||
outcome := resolveHarvestOutcome(total, effectiveDC)
|
||||
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
||||
|
||||
var grantedQty int
|
||||
switch outcome {
|
||||
case OutcomeNothing:
|
||||
res.Summary.Fails++
|
||||
case OutcomeCommon:
|
||||
grantedQty = 1
|
||||
case OutcomeStandard:
|
||||
grantedQty = rand.IntN(3) + 1
|
||||
case OutcomeRich:
|
||||
grantedQty = rand.IntN(3) + 1
|
||||
if bonus := pickRichBonus(exp.ZoneID, rdef.ID); bonus != nil {
|
||||
_ = grantHarvestYield(userID, *bonus, 1)
|
||||
res.Summary.Yields[bonus.ID] += 1
|
||||
res.Summary.Names[bonus.ID] = bonus.Name
|
||||
var grantedQty int
|
||||
switch outcome {
|
||||
case OutcomeNothing:
|
||||
res.Summary.Fails++
|
||||
case OutcomeCommon:
|
||||
grantedQty = 1
|
||||
case OutcomeStandard:
|
||||
grantedQty = rand.IntN(3) + 1
|
||||
case OutcomeRich:
|
||||
grantedQty = rand.IntN(3) + 1
|
||||
if bonus := pickRichBonus(exp.ZoneID, rdef.ID); bonus != nil {
|
||||
_ = grantHarvestYield(userID, *bonus, 1)
|
||||
res.Summary.Yields[bonus.ID] += 1
|
||||
res.Summary.Names[bonus.ID] = bonus.Name
|
||||
}
|
||||
}
|
||||
if grantedQty > 0 {
|
||||
_ = grantHarvestYield(userID, rdef, grantedQty)
|
||||
res.Summary.Yields[rdef.ID] += grantedQty
|
||||
res.Summary.Names[rdef.ID] = rdef.Name
|
||||
n.CurrentCharges--
|
||||
persistNodes = true
|
||||
}
|
||||
}
|
||||
if grantedQty > 0 {
|
||||
_ = grantHarvestYield(userID, rdef, grantedQty)
|
||||
res.Summary.Yields[rdef.ID] += grantedQty
|
||||
res.Summary.Names[rdef.ID] = rdef.Name
|
||||
n.CurrentCharges--
|
||||
persistNodes = true
|
||||
}
|
||||
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||
fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
|
||||
string(action), rdef.ID, roll, total, rdef.DC, string(outcome)), "")
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||
fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
|
||||
string(action), rdef.ID, roll, total, rdef.DC, string(outcome)), "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,18 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||||
case "list", "ls":
|
||||
return p.expeditionCmdList(ctx, c)
|
||||
case "start", "begin", "go":
|
||||
case "start", "begin":
|
||||
return p.expeditionCmdStart(ctx, c, rest)
|
||||
case "go", "choose":
|
||||
// Context-sensitive: with an active expedition + numeric rest,
|
||||
// treat as "take fork path N" (forwards to zoneCmdGo so the
|
||||
// player doesn't have to remember the !zone seam). Otherwise
|
||||
// fall back to the historical alias for `!expedition start`.
|
||||
if rest != "" && isAllDigits(strings.TrimSpace(rest)) {
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
return p.zoneCmdGo(ctx, rest)
|
||||
}
|
||||
}
|
||||
return p.expeditionCmdStart(ctx, c, rest)
|
||||
case "status", "info":
|
||||
return p.expeditionCmdStatus(ctx, rest)
|
||||
@@ -458,6 +469,22 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
// helper: ensure we don't shadow id.UserID import in test harness.
|
||||
var _ id.UserID
|
||||
|
||||
// isAllDigits reports whether s is a non-empty string of ASCII digits.
|
||||
// Used to route `!expedition go N` to the fork-choice handler when N is
|
||||
// numeric; non-numeric rest (`!expedition go ironforge`) still falls
|
||||
// through to expeditionCmdStart.
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── run (autopilot) ─────────────────────────────────────────────────────────
|
||||
|
||||
// autopilotRoomCap bounds a single `!expedition run` invocation. Real-time
|
||||
@@ -472,6 +499,19 @@ const autopilotRoomCap = 6
|
||||
// could end the run; pause, heal, or commit."
|
||||
const autopilotLowHPPct = 0.30
|
||||
|
||||
// autopilotWalkResult bundles the staged narration plus structured
|
||||
// metadata so foreground (streamFlow) and background (single DM, with
|
||||
// progress-based DM suppression) callers can share the same walk loop.
|
||||
type autopilotWalkResult struct {
|
||||
stream []string
|
||||
finalMsg string
|
||||
rooms int
|
||||
reason stopReason
|
||||
// initErr — populated when the walk couldn't start (no expedition,
|
||||
// no run). Foreground surfaces it as a DM; background swallows it.
|
||||
initErr string
|
||||
}
|
||||
|
||||
// expeditionCmdRun is the autopilot surface. It loops advanceOnce until a
|
||||
// natural interrupt fires (fork, elite/boss doorway, death, complete) or
|
||||
// an injected interrupt fires (low HP, low SU, room cap). Each iteration's
|
||||
@@ -480,27 +520,42 @@ const autopilotLowHPPct = 0.30
|
||||
// combat already auto-resolves inside resolveCombatRoom; elite/boss
|
||||
// doorways stop here so the player can choose !fight on their own terms.
|
||||
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false)
|
||||
if r.initErr != "" {
|
||||
return p.SendDM(ctx.Sender, r.initErr)
|
||||
}
|
||||
return p.streamFlow(ctx.Sender, r.stream, r.finalMsg)
|
||||
}
|
||||
|
||||
// runAutopilotWalk runs the autopilot loop up to maxRooms times and
|
||||
// returns a bundle the caller can either streamFlow (foreground) or
|
||||
// post as a single DM (background ticker). Pure side effects on the
|
||||
// run graph / harvest tally / supplies / threat — same as before, just
|
||||
// no streamFlow here. compact==true switches the underlying combat
|
||||
// narration into terse mode and auto-resolves elite (not boss) rooms.
|
||||
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact bool) autopilotWalkResult {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()}
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not on an expedition. `!expedition list` to pick one.")
|
||||
return autopilotWalkResult{initErr: "You're not on an expedition. `!expedition list` to pick one."}
|
||||
}
|
||||
if exp.RunID == "" {
|
||||
return p.SendDM(ctx.Sender, "No active region run. Try `!region` to refresh.")
|
||||
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
|
||||
}
|
||||
|
||||
var stream []string
|
||||
var finalMsg string
|
||||
rooms := 0
|
||||
reason := stopOK // sticky: last loop-exit reason
|
||||
|
||||
// Phase 2 — walk-wide auto-harvest tally. Per-room footers go into the
|
||||
// stream as we go; the cumulative haul renders on the final block.
|
||||
walkYields := map[string]int{}
|
||||
walkNames := map[string]string{}
|
||||
|
||||
for i := 0; i < autopilotRoomCap; i++ {
|
||||
for i := 0; i < maxRooms; i++ {
|
||||
// Pre-iteration interrupts: low HP / low SU. Skip on the first
|
||||
// iteration so the player always gets at least one room out of
|
||||
// `!expedition run` even if they limped in — autopilot stops on
|
||||
@@ -508,13 +563,14 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
if i > 0 {
|
||||
if msg, stop := autopilotPreflight(ctx.Sender, exp); stop {
|
||||
finalMsg = msg
|
||||
reason = stopPreflight
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
res, aerr := p.advanceOnce(ctx)
|
||||
res, aerr := p.advanceOnceWithOpts(ctx, compact)
|
||||
if aerr != nil {
|
||||
return p.SendDM(ctx.Sender, aerr.Error())
|
||||
return autopilotWalkResult{initErr: aerr.Error()}
|
||||
}
|
||||
|
||||
// Roll this step's beats into the running stream.
|
||||
@@ -538,6 +594,7 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
} else {
|
||||
finalMsg = res.final
|
||||
}
|
||||
reason = res.reason
|
||||
break
|
||||
}
|
||||
|
||||
@@ -550,6 +607,24 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
exp = fresh
|
||||
}
|
||||
|
||||
// Arrived at a Boss doorway: stop here. The "Room X/Y — Boss.
|
||||
// !fight when ready." line in res.final already tells the player
|
||||
// what to do; another loop iteration would just hit the gate and
|
||||
// emit a duplicate "Room X/Y — Boss" message.
|
||||
//
|
||||
// For Elite + non-compact, do the same. In compact mode we let
|
||||
// the next iteration run because the gate will auto-resolve the
|
||||
// elite inline (which is the whole point of compact mode).
|
||||
if res.nextRoomType == RoomBoss || (res.nextRoomType == RoomElite && !compact) {
|
||||
r := stopBoss
|
||||
if res.nextRoomType == RoomElite {
|
||||
r = stopElite
|
||||
}
|
||||
finalMsg = autopilotFooter(r, rooms)
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
|
||||
// Phase 2 — auto-harvest the room we just walked into. Aggregates
|
||||
// into walkYields/walkNames; per-room footer goes straight into
|
||||
// the stream. Rare+ nodes and combat interrupts hard-stop the
|
||||
@@ -562,6 +637,7 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
if herr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for k, v := range hr.Summary.Yields {
|
||||
walkYields[k] += v
|
||||
walkNames[k] = hr.Summary.Names[k]
|
||||
@@ -572,11 +648,11 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
if hr.CombatNarr != "" {
|
||||
// A harvest interrupt fired combat. The combat narration
|
||||
// becomes the final block; reason classifies death vs survive.
|
||||
reason := stopHarvestCombat
|
||||
r := stopHarvestCombat
|
||||
if hr.PlayerEnded {
|
||||
reason = stopEnded
|
||||
r = stopEnded
|
||||
}
|
||||
footer := autopilotFooter(reason, rooms)
|
||||
footer := autopilotFooter(r, rooms)
|
||||
finalMsg = hr.CombatNarr
|
||||
if footer != "" {
|
||||
finalMsg += "\n\n" + footer
|
||||
@@ -584,6 +660,7 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
if tally := renderWalkTally(walkYields, walkNames); tally != "" && !hr.PlayerEnded {
|
||||
finalMsg += "\n\n" + tally
|
||||
}
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
if len(hr.RarePending) > 0 {
|
||||
@@ -591,6 +668,7 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
if tally := renderWalkTally(walkYields, walkNames); tally != "" {
|
||||
finalMsg += "\n\n" + tally
|
||||
}
|
||||
reason = stopRareNode
|
||||
break
|
||||
}
|
||||
|
||||
@@ -614,7 +692,12 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
!strings.Contains(finalMsg, "Walk haul:") {
|
||||
finalMsg += "\n\n" + tally
|
||||
}
|
||||
return p.streamFlow(ctx.Sender, stream, finalMsg)
|
||||
return autopilotWalkResult{
|
||||
stream: stream,
|
||||
finalMsg: finalMsg,
|
||||
rooms: rooms,
|
||||
reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// autopilotPreflight checks the threshold-based interrupts that fire
|
||||
@@ -644,7 +727,7 @@ func autopilotFooter(reason stopReason, rooms int) string {
|
||||
}
|
||||
switch reason {
|
||||
case stopFork:
|
||||
return fmt.Sprintf("⏸ **Autopilot paused at a fork** (after %s). `!zone go <n>` to choose, then `!expedition run` to keep walking.", roomsStr)
|
||||
return fmt.Sprintf("⏸ **Autopilot paused at a fork** (after %s). `!expedition go <n>` to choose; auto-walk resumes automatically.", roomsStr)
|
||||
case stopElite:
|
||||
return fmt.Sprintf("⏸ **Autopilot paused — elite ahead** (after %s). `!fight` when ready, then `!expedition run` to continue.", roomsStr)
|
||||
case stopBoss:
|
||||
|
||||
@@ -130,7 +130,8 @@ func loadExpeditionsNeedingBriefing(threshold time.Time) ([]*Expedition, error)
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition
|
||||
WHERE status = 'active'
|
||||
AND start_date < ?
|
||||
@@ -151,7 +152,8 @@ func loadExpeditionsNeedingRecap(threshold time.Time) ([]*Expedition, error) {
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition
|
||||
WHERE status = 'active'
|
||||
AND (last_recap_at IS NULL OR last_recap_at < ?)`, threshold)
|
||||
|
||||
@@ -101,7 +101,8 @@ func getResumableExpedition(userID id.UserID) (*Expedition, error) {
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition
|
||||
WHERE user_id = ?
|
||||
AND status = 'extracting'
|
||||
|
||||
@@ -44,7 +44,7 @@ type NightCheck struct {
|
||||
MonsterID string // bestiary id of the encounter, if any
|
||||
MonsterName string
|
||||
Summary string // short factual line
|
||||
Flavor string // TwinBee voice line, may be empty
|
||||
Flavor string // I voice line, may be empty
|
||||
ThreatBumped bool // signs-of-passage adds +2 threat
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,19 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
// at high tiers (T5 mean trails leaders by ~10pp pre-tune). Add a
|
||||
// modest steady-DPS rider so post-opener rounds aren't pure attrition.
|
||||
mods.DamageBonus += 0.05
|
||||
// Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
|
||||
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
|
||||
// AutoCritFirst + DamageBonus alone left the rogue's defining
|
||||
// mechanic invisible at the table: monte-carlo win rates looked
|
||||
// fine but a player hitting once per round saw weapon-only damage
|
||||
// every swing after the opener. Engine path uses the same "every
|
||||
// hit" treatment as Divine Strike (rogues swing 1×/round so this
|
||||
// matches the 5e once-per-turn cadence in practice).
|
||||
sneakDice := (c.Level + 1) / 2
|
||||
if sneakDice > 10 {
|
||||
sneakDice = 10
|
||||
}
|
||||
mods.SneakAttackDie += sneakDice
|
||||
case ClassMage:
|
||||
stats.AttackBonus++
|
||||
// Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
|
||||
|
||||
@@ -233,7 +233,7 @@ func buildSpellList() []SpellDefinition {
|
||||
Upcast: "more vigour per slot above 2nd"},
|
||||
{ID: "augury", Name: "Augury", Level: 2, School: "divination",
|
||||
Classes: cleric, Effect: EffectUtility, CastTime: CastRitual,
|
||||
Description: "Ask the next half-hour a yes-or-no question. TwinBee relays the omen, mostly straight."},
|
||||
Description: "Ask the next half-hour a yes-or-no question. I relay the omen, mostly straight."},
|
||||
|
||||
// ── 2nd level — Ranger ────────────────────────────────────────────────
|
||||
{ID: "pass_without_trace", Name: "Pass Without Trace", Level: 2, School: "abjuration",
|
||||
|
||||
@@ -84,57 +84,57 @@ var dndSubclassRegistry = []DnDSubclassInfo{
|
||||
// Fighter
|
||||
{SubclassChampion, ClassFighter, "Champion",
|
||||
"Raw power, brutal efficiency.",
|
||||
"You've chosen the old way. No tricks, no systems. Just you, and the blade, and the next hit. TwinBee approves of this directness."},
|
||||
"You've chosen the old way. No tricks, no systems. Just you, and the blade, and the next hit. I approve of this directness."},
|
||||
{SubclassBattleMaster, ClassFighter, "Battle Master",
|
||||
"Tactical superiority, maneuver control.",
|
||||
"Maneuvers, dice, control of the engagement. TwinBee notes that the smartest fighter in the room rarely throws the first punch."},
|
||||
"Maneuvers, dice, control of the engagement. I note that the smartest fighter in the room rarely throws the first punch."},
|
||||
{SubclassBerserker, ClassFighter, "Berserker",
|
||||
"Rage-fueled destruction, high risk/reward.",
|
||||
"Rage as a tool, exhaustion as a tax. TwinBee files this under 'load-bearing recklessness'."},
|
||||
"Rage as a tool, exhaustion as a tax. I file this under 'load-bearing recklessness'."},
|
||||
|
||||
// Rogue
|
||||
{SubclassThief, ClassRogue, "Thief",
|
||||
"Speed, opportunism, fast hands.",
|
||||
"Quick fingers, quicker exits. TwinBee suspects the locks of the world have been put on notice."},
|
||||
"Quick fingers, quicker exits. I suspect the locks of the world have been put on notice."},
|
||||
{SubclassAssassin, ClassRogue, "Assassin",
|
||||
"Setup and execution; devastating openers.",
|
||||
"Patience, then violence, then nothing. TwinBee respects the economy of motion."},
|
||||
"Patience, then violence, then nothing. I respect the economy of motion."},
|
||||
{SubclassArcaneTrickster, ClassRogue, "Arcane Trickster",
|
||||
"Stealth plus a slice of Mage spellcasting.",
|
||||
"Magic in the hands of a Rogue. TwinBee considers this the universe's way of apologizing for giving you low HP."},
|
||||
"Magic in the hands of a Rogue. I consider this the universe's way of apologizing for giving you low HP."},
|
||||
|
||||
// Mage
|
||||
{SubclassEvocation, ClassMage, "Evocation",
|
||||
"Pure damage output. The glass cannon perfected.",
|
||||
"You picked the path of the louder spell. TwinBee finds this aesthetically pleasing and structurally alarming."},
|
||||
"You picked the path of the louder spell. I find this aesthetically pleasing and structurally alarming."},
|
||||
{SubclassAbjuration, ClassMage, "Abjuration",
|
||||
"Wards, shields, counterspells.",
|
||||
"You'd rather not get hit, and you've made it your specialty. TwinBee approves of this kind of foresight."},
|
||||
"You'd rather not get hit, and you've made it your specialty. I approve of this kind of foresight."},
|
||||
{SubclassNecromancy, ClassMage, "Necromancy",
|
||||
"Control undead and drain life.",
|
||||
"Undeath as a tool rather than a state. TwinBee has opinions about this. TwinBee is keeping them to itself."},
|
||||
"Undeath as a tool rather than a state. I have opinions about this. I am keeping them to itself."},
|
||||
|
||||
// Cleric
|
||||
{SubclassLifeDomain, ClassCleric, "Life Domain",
|
||||
"Maximum healing output. Party support anchor.",
|
||||
"Healing as discipline, not afterthought. TwinBee notes the party will live longer because of you, whether they thank you or not."},
|
||||
"Healing as discipline, not afterthought. I note the party will live longer because of you, whether they thank you or not."},
|
||||
{SubclassWarDomain, ClassCleric, "War Domain",
|
||||
"Combat Cleric — extra attacks, martial weapons.",
|
||||
"You came to fight, not to mediate. TwinBee finds this clarifying."},
|
||||
"You came to fight, not to mediate. I find this clarifying."},
|
||||
{SubclassTrickeryDomain, ClassCleric, "Trickery Domain",
|
||||
"Illusion, misdirection, chaos.",
|
||||
"A Cleric who lies for a living. TwinBee delights in the implied theological complications."},
|
||||
"A Cleric who lies for a living. I delight in the implied theological complications."},
|
||||
|
||||
// Ranger
|
||||
{SubclassHunter, ClassRanger, "Hunter",
|
||||
"Zone specialist; bonuses against enemy types.",
|
||||
"You read the terrain before you draw the bow. TwinBee finds this professionalism rare and welcome."},
|
||||
"You read the terrain before you draw the bow. I find this professionalism rare and welcome."},
|
||||
{SubclassBeastMaster, ClassRanger, "Beast Master",
|
||||
"Deep Ranger–pet bond. Pet as combat partner.",
|
||||
"You and your pet, all the way down. TwinBee finds this genuinely moving and declines to be embarrassed about that."},
|
||||
"You and your pet, all the way down. I find this genuinely moving and decline to be embarrassed about that."},
|
||||
{SubclassGloomStalker, ClassRanger, "Gloom Stalker",
|
||||
"Darkness specialist. Exceptional underground.",
|
||||
"You were already good in the dark. Now you're better. TwinBee notes: the Underdark is waiting."},
|
||||
"You were already good in the dark. Now you're better. I note: the Underdark is waiting."},
|
||||
|
||||
// Druid — Open5e caster scaffold
|
||||
{SubclassCircleLand, ClassDruid, "Circle of the Land",
|
||||
|
||||
@@ -96,7 +96,7 @@ type ZoneDefinition struct {
|
||||
LevelMax int
|
||||
Faction string
|
||||
Atmosphere string // one-line biome description
|
||||
Hook string // entry-room narration seed (TwinBee voice, italic)
|
||||
Hook string // entry-room narration seed (I voice, italic)
|
||||
MinRooms int
|
||||
MaxRooms int
|
||||
Enemies []ZoneEnemy
|
||||
@@ -213,7 +213,7 @@ func zoneGoblinWarrens() ZoneDefinition {
|
||||
LevelMax: 3,
|
||||
Faction: "Goblins, Hobgoblins",
|
||||
Atmosphere: "Low ceilings, torchlight, crude traps, cackling in the dark.",
|
||||
Hook: "A network of fetid tunnels burrowed beneath the Merchant's Road. The smell arrives before the sounds — smoke, rot, and something worse. TwinBee advises keeping one hand on your blade.",
|
||||
Hook: "A network of fetid tunnels burrowed beneath the Merchant's Road. The smell arrives before the sounds — smoke, rot, and something worse. I advise keeping one hand on your blade.",
|
||||
MinRooms: 6,
|
||||
MaxRooms: 7,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -257,7 +257,7 @@ func zoneCryptValdris() ZoneDefinition {
|
||||
LevelMax: 3,
|
||||
Faction: "Undead",
|
||||
Atmosphere: "Stone corridors, dripping water, candles that shouldn't still be burning.",
|
||||
Hook: "The iron gate hangs open — someone left in a hurry. Carved into the stone above: \"HERE LIES VALDRIS. DO NOT.\" The rest has been chiseled away. TwinBee declines to speculate.",
|
||||
Hook: "The iron gate hangs open — someone left in a hurry. Carved into the stone above: \"HERE LIES VALDRIS. DO NOT.\" The rest has been chiseled away. I decline to speculate.",
|
||||
MinRooms: 6,
|
||||
MaxRooms: 7,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -313,7 +313,7 @@ func zoneForestShadows() ZoneDefinition {
|
||||
LevelMax: 5,
|
||||
Faction: "Beasts, Fey-corrupted creatures, Bandits",
|
||||
Atmosphere: "Ancient forest, twisted paths, eerie silence, bioluminescent fungi, things in the canopy.",
|
||||
Hook: "The forest was beautiful once. Travelers still say so, usually right before they stop saying anything at all. The trees lean in when you're not looking. TwinBee has noted this is not a metaphor.",
|
||||
Hook: "The forest was beautiful once. Travelers still say so, usually right before they stop saying anything at all. The trees lean in when you're not looking. I have noted this is not a metaphor.",
|
||||
MinRooms: 6,
|
||||
MaxRooms: 8,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -376,7 +376,7 @@ func zoneSunkenTemple() ZoneDefinition {
|
||||
LevelMax: 5,
|
||||
Faction: "Kuo-toa, Water Elementals, Aboleth-touched",
|
||||
Atmosphere: "Flooded stone chambers, barnacled pillars, salt smell, alien glyphs, things that swim in the dark water.",
|
||||
Hook: "The tide went out thirty years ago and never fully came back. The temple stayed wet anyway. Something down there keeps it that way. TwinBee suggests waterproofing your spellbook.",
|
||||
Hook: "The tide went out thirty years ago and never fully came back. The temple stayed wet anyway. Something down there keeps it that way. I suggest waterproofing your spellbook.",
|
||||
MinRooms: 6,
|
||||
MaxRooms: 8,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -422,7 +422,7 @@ func zoneManorBlackspire() ZoneDefinition {
|
||||
LevelMax: 8,
|
||||
Faction: "Undead, Shadows, Vampiric",
|
||||
Atmosphere: "Victorian decay, impossible architecture, portraits whose eyes follow movement, cold spots, locked rooms that weren't locked before.",
|
||||
Hook: "The manor has been for sale for eleven years. Every buyer has either left immediately or not left at all. The real estate listing describes it as 'full of character.' TwinBee finds this accurate.",
|
||||
Hook: "The manor has been for sale for eleven years. Every buyer has either left immediately or not left at all. The real estate listing describes it as 'full of character.' I find this accurate.",
|
||||
MinRooms: 7,
|
||||
MaxRooms: 9,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -490,7 +490,7 @@ func zoneUnderforge() ZoneDefinition {
|
||||
LevelMax: 8,
|
||||
Faction: "Fire Elementals, Constructs, Salamanders, Azers",
|
||||
Atmosphere: "Volcanic caverns, rivers of cooling lava, ancient dwarven stonework, the constant bass note of something very large moving below.",
|
||||
Hook: "The dwarven forge-city of Kharak Dûn was not abandoned. It was sealed from the outside. TwinBee does not have information on what they were sealing in.",
|
||||
Hook: "The dwarven forge-city of Kharak Dûn was not abandoned. It was sealed from the outside. I do not have information on what they were sealing in.",
|
||||
MinRooms: 7,
|
||||
MaxRooms: 9,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -547,7 +547,7 @@ func zoneUnderdark() ZoneDefinition {
|
||||
LevelMax: 12,
|
||||
Faction: "Drow, Mind Flayers, Beholders (far), Ropers, Hook Horrors",
|
||||
Atmosphere: "Absolute darkness, phosphorescent mushroom groves, vast underground seas, carved drow cities in the distance, things older than the surface world.",
|
||||
Hook: "There is a world below the world. It has its own cities, its own wars, its own sky — which is stone, and has never once been kind. TwinBee speaks more quietly here. Something might be listening.",
|
||||
Hook: "There is a world below the world. It has its own cities, its own wars, its own sky — which is stone, and has never once been kind. I speak more quietly here. Something might be listening.",
|
||||
MinRooms: 8,
|
||||
MaxRooms: 10,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -604,7 +604,7 @@ func zoneFeywildCrossing() ZoneDefinition {
|
||||
LevelMax: 12,
|
||||
Faction: "Hags, Redcaps, Will-o-Wisps, Fomorians, Unseelie Fey",
|
||||
Atmosphere: "Impossible beauty, treacherous whimsy, time distortion, rules that change without notice, bargains with terrible fine print.",
|
||||
Hook: "The veil between worlds is thin here. Colors are too saturated. The mushrooms are too large. A small creature made of starlight just offered you a deal. TwinBee advises extreme caution regarding deals.",
|
||||
Hook: "The veil between worlds is thin here. Colors are too saturated. The mushrooms are too large. A small creature made of starlight just offered you a deal. I advise extreme caution regarding deals.",
|
||||
MinRooms: 8,
|
||||
MaxRooms: 10,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -662,7 +662,7 @@ func zoneDragonsLair() ZoneDefinition {
|
||||
LevelMax: 20,
|
||||
Faction: "Kobolds, Drakes, Young Dragons, Wyrm",
|
||||
Atmosphere: "Scorched stone, rivers of gold coins half-melted into the floor, kobold warrens as outer defenses, growing heat, the unmistakable smell of something ancient and enormous.",
|
||||
Hook: "The mountain has not erupted in forty years. The locals say it is dormant. The locals are wrong about what lives in mountains. TwinBee has prepared an unusually long entry description for this one.",
|
||||
Hook: "The mountain has not erupted in forty years. The locals say it is dormant. The locals are wrong about what lives in mountains. I have prepared an unusually long entry description for this one.",
|
||||
MinRooms: 9,
|
||||
MaxRooms: 10,
|
||||
Enemies: []ZoneEnemy{
|
||||
@@ -710,7 +710,7 @@ func zoneAbyssPortal() ZoneDefinition {
|
||||
LevelMax: 20,
|
||||
Faction: "Demons, Fiends, Corrupted Celestials",
|
||||
Atmosphere: "Reality fractures, impossible geometry, constant low psychic pressure, the feeling of being watched by something that has no eyes.",
|
||||
Hook: "Someone opened a door they should not have opened. The door is still open. Things are still coming through. TwinBee is not making jokes about this one.",
|
||||
Hook: "Someone opened a door they should not have opened. The door is still open. Things are still coming through. I am not making jokes about this one.",
|
||||
MinRooms: 9,
|
||||
MaxRooms: 10,
|
||||
Enemies: []ZoneEnemy{
|
||||
|
||||
@@ -84,7 +84,7 @@ func zoneHelpText() string {
|
||||
b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
|
||||
b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n")
|
||||
b.WriteString("`!zone compliment` — flatter TwinBee (they'll like that)\n")
|
||||
b.WriteString("`!zone lore` — TwinBee shares zone history")
|
||||
b.WriteString("`!zone lore` — I share zone history")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -386,6 +386,7 @@ const (
|
||||
stopBlocked // an active CombatSession blocks the advance
|
||||
stopRareNode // auto-harvest spotted a Rare+ node; player should decide
|
||||
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
|
||||
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
|
||||
)
|
||||
|
||||
// advanceResult bundles the staged narration + dispatch shape of one
|
||||
@@ -398,6 +399,12 @@ type advanceResult struct {
|
||||
phases []string
|
||||
final string
|
||||
reason stopReason
|
||||
// nextRoomType — when reason is stopOK, the type of the room the
|
||||
// player just stepped into. The autopilot loop reads this to break
|
||||
// before running a redundant iteration whose only job would be to
|
||||
// re-emit the Boss/Elite doorway gate (and produce a duplicate
|
||||
// "Room X/Y — Boss" line in the same DM).
|
||||
nextRoomType RoomType
|
||||
}
|
||||
|
||||
// zoneCmdAdvance resolves the room the player is currently standing in,
|
||||
@@ -421,7 +428,15 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
// "Couldn't ..." context); callers should SendDM them verbatim. State
|
||||
// mutations on run / character / combat sessions persist regardless of
|
||||
// how the caller dispatches the narration.
|
||||
// advanceOnce — single-room advance. compact==true switches to the
|
||||
// terse autopilot-friendly combat narration and auto-resolves elite
|
||||
// doorways (boss still stops; boss is the climax beat). Foreground
|
||||
// `!expedition run` / `!zone advance` always pass false.
|
||||
func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error) {
|
||||
return p.advanceOnceWithOpts(ctx, false)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) (advanceResult, error) {
|
||||
run, err := getActiveZoneRun(ctx.Sender)
|
||||
if err != nil {
|
||||
return advanceResult{}, fmt.Errorf("Couldn't read run state: %s", err.Error())
|
||||
@@ -445,6 +460,15 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
|
||||
// door — only on the advance that walked the player here, which already
|
||||
// fired below on the previous room. A won CombatSession for the encounter
|
||||
// means the fight's done; fall through so the graph clears the room.
|
||||
//
|
||||
// compact==true (background autopilot) auto-resolves elite rooms via
|
||||
// the same forward-sim engine used for exploration combat. Boss still
|
||||
// pauses regardless — the boss is the run's climax beat and shouldn't
|
||||
// be settled while the player isn't paying attention.
|
||||
var eliteAutoIntro string
|
||||
var eliteAutoPhases []string
|
||||
var eliteAutoOutcome string
|
||||
eliteAutoResolved := false
|
||||
if prev == RoomElite || prev == RoomBoss {
|
||||
encID := encounterIDForRoom(prevIdx)
|
||||
sess, serr := getCombatSessionForEncounter(run.RunID, encID)
|
||||
@@ -452,17 +476,36 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
|
||||
return advanceResult{}, fmt.Errorf("Couldn't read combat state: %s", serr.Error())
|
||||
}
|
||||
if sess == nil || sess.Status != CombatStatusWon {
|
||||
kind := "Elite"
|
||||
r := stopElite
|
||||
if prev == RoomBoss {
|
||||
kind = "Boss"
|
||||
r = stopBoss
|
||||
if prev == RoomBoss || !compact {
|
||||
kind := "Elite"
|
||||
r := stopElite
|
||||
if prev == RoomBoss {
|
||||
kind = "Boss"
|
||||
r = stopBoss
|
||||
}
|
||||
return advanceResult{
|
||||
final: fmt.Sprintf("**Room %d/%d — %s.** Type `!fight` to engage.",
|
||||
prevIdx+1, run.TotalRooms, kind),
|
||||
reason: r,
|
||||
}, nil
|
||||
}
|
||||
return advanceResult{
|
||||
final: fmt.Sprintf("**Room %d/%d — %s.** Type `!fight` to engage.",
|
||||
prevIdx+1, run.TotalRooms, kind),
|
||||
reason: r,
|
||||
}, nil
|
||||
// Compact-mode elite auto-resolve.
|
||||
ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, true, true)
|
||||
if aerr != nil {
|
||||
return advanceResult{}, fmt.Errorf("Couldn't auto-resolve elite: %s", aerr.Error())
|
||||
}
|
||||
if aended {
|
||||
return advanceResult{
|
||||
intro: ai,
|
||||
phases: ap,
|
||||
final: ao,
|
||||
reason: stopEnded,
|
||||
}, nil
|
||||
}
|
||||
eliteAutoIntro = ai
|
||||
eliteAutoPhases = ap
|
||||
eliteAutoOutcome = ao
|
||||
eliteAutoResolved = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +546,15 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
|
||||
// rooms return a non-nil phases slice — those get streamed with
|
||||
// 2–3s delays for suspense; non-combat rooms collapse into a single
|
||||
// SendDM as before.
|
||||
intro, phases, outcome, ended, err := p.resolveRoom(ctx.Sender, run, zone)
|
||||
intro, phases, outcome, ended, err := p.resolveRoom(ctx.Sender, run, zone, compact)
|
||||
if eliteAutoResolved {
|
||||
// Fold the auto-resolved elite combat in. resolveRoom returned
|
||||
// empty for the elite room type (turn-based system never ran),
|
||||
// so we just use the forward-sim narration we captured above.
|
||||
intro = eliteAutoIntro
|
||||
phases = eliteAutoPhases
|
||||
outcome = eliteAutoOutcome
|
||||
}
|
||||
if err != nil {
|
||||
return advanceResult{}, fmt.Errorf("Couldn't resolve room: %s", err.Error())
|
||||
}
|
||||
@@ -573,11 +624,12 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
|
||||
}, nil
|
||||
}
|
||||
return advanceResult{
|
||||
preStream: preStream,
|
||||
intro: intro,
|
||||
phases: phases,
|
||||
final: p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next),
|
||||
reason: stopOK,
|
||||
preStream: preStream,
|
||||
intro: intro,
|
||||
phases: phases,
|
||||
final: p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next),
|
||||
reason: stopOK,
|
||||
nextRoomType: next,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -616,7 +668,18 @@ func (p *AdventurePlugin) formatNextRoomMessage(run *DungeonRun, zone ZoneDefini
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(next)))
|
||||
b.WriteString("`!zone advance` to continue.")
|
||||
// Route the action hint by what's actually ahead. The next advance
|
||||
// against an Elite/Boss room hits the doorway gate and demands
|
||||
// `!fight` — telling the player to `!zone advance` here would
|
||||
// directly contradict that gate's message.
|
||||
switch next {
|
||||
case RoomBoss:
|
||||
b.WriteString("`!fight` when you're ready for the boss.")
|
||||
case RoomElite:
|
||||
b.WriteString("`!fight` when ready.")
|
||||
default:
|
||||
b.WriteString("`!zone advance` to continue.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -664,7 +727,7 @@ func (p *AdventurePlugin) streamFlow(userID id.UserID, phaseMessages []string, f
|
||||
// inter-phase delays — see resolveCombatRoom for the contract. For
|
||||
// non-combat rooms (entry, trap), phases is nil and outcome carries the
|
||||
// resolution narration.
|
||||
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
switch run.CurrentRoomType() {
|
||||
case RoomEntry:
|
||||
return
|
||||
@@ -673,7 +736,7 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
||||
outcome = narration
|
||||
return
|
||||
case RoomExploration:
|
||||
return p.resolveCombatRoom(userID, run, zone, false)
|
||||
return p.resolveCombatRoom(userID, run, zone, false, compact)
|
||||
case RoomElite, RoomBoss:
|
||||
// Manual turn-based combat (!fight / !attack). zoneCmdAdvance only
|
||||
// reaches resolveRoom for an Elite/Boss room once a won CombatSession
|
||||
@@ -694,12 +757,13 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
||||
//
|
||||
// Phases will be nil only on a "no roster" skip — caller treats that as a
|
||||
// non-paced fallthrough.
|
||||
func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite bool) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite)
|
||||
if !ok {
|
||||
outcome = fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite])
|
||||
return
|
||||
}
|
||||
preHP, _ := dndHPSnapshot(userID)
|
||||
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood)
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
@@ -708,6 +772,30 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
|
||||
|
||||
// Compact mode: skip TwinBee banter, skip the multi-beat play-by-play.
|
||||
// Render a single outcome line. Still records kills, threat, and drops.
|
||||
if compact && result.PlayerWon {
|
||||
hpDelta := preHP - postHP
|
||||
var ob strings.Builder
|
||||
label := monster.Name
|
||||
if elite {
|
||||
label = "Elite " + monster.Name
|
||||
}
|
||||
ob.WriteString(fmt.Sprintf("⚔️ **%s** down — HP %d→%d", label, preHP, postHP))
|
||||
if hpDelta > 0 {
|
||||
ob.WriteString(fmt.Sprintf(" (−%d)", hpDelta))
|
||||
}
|
||||
ob.WriteString(".")
|
||||
recordZoneKillForUser(userID, monster.ID)
|
||||
applyRoomCombatThreatForUser(userID, elite)
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||
ob.WriteString(" ")
|
||||
ob.WriteString(drop)
|
||||
}
|
||||
outcome = ob.String()
|
||||
return
|
||||
}
|
||||
|
||||
// Intro: pre-combat narration + creature stat block. This lands first,
|
||||
// before the 5–8s phase pacing kicks in.
|
||||
var ib strings.Builder
|
||||
@@ -734,11 +822,16 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
|
||||
// Phases: forward-simulating engine play-by-play. Use the player's
|
||||
// display name when available so narrative lines read naturally.
|
||||
playerName := "You"
|
||||
if name, _ := loadDisplayName(userID); name != "" {
|
||||
playerName = name
|
||||
// In compact mode we already returned early on a win; the remaining
|
||||
// compact loss path still wants no multi-beat phases (the player
|
||||
// needs the outcome, not 10 lines of attrition).
|
||||
if !compact {
|
||||
playerName := "You"
|
||||
if name, _ := loadDisplayName(userID); name != "" {
|
||||
playerName = name
|
||||
}
|
||||
phases = RenderCombatLog(result, playerName, monster.Name)
|
||||
}
|
||||
phases = RenderCombatLog(result, playerName, monster.Name)
|
||||
|
||||
// Outcome: post-combat block. Nat20/nat1 narration goes here so it
|
||||
// lands *after* the play-by-play, where it has dramatic context.
|
||||
@@ -943,7 +1036,7 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
line := twinBeeLine(zone.ID, DMLore, run.RunID, narrationCadence(run))
|
||||
if line == "" {
|
||||
return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.")
|
||||
return p.SendDM(ctx.Sender, "I have nothing to say about this place.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("📖 **%s — Lore**\n\n%s", zone.Display, line))
|
||||
}
|
||||
|
||||
@@ -651,5 +651,5 @@ var potionOfHealingZoneFlavor = map[ZoneID]string{
|
||||
ZoneUnderdark: "Drow-made. The formula is different. The effect is the same. The ingredients are not discussed.",
|
||||
ZoneFeywildCrossing: "It shifts color when you look at it sideways. The fey make medicine the way they make everything: beautifully and with caveats.",
|
||||
ZoneDragonsLair: "A kobold medic's field kit, looted from a belt pouch. The kobolds take care of their own.",
|
||||
ZoneAbyssPortal: "Glows faintly red. Still works. TwinBee recommends not asking what's in it.",
|
||||
ZoneAbyssPortal: "Glows faintly red. Still works. I recommend not asking what's in it.",
|
||||
}
|
||||
|
||||
@@ -145,23 +145,12 @@ func loadExpeditionsForAmbient(cutoff time.Time) ([]string, error) {
|
||||
// DMs the player, and appends a log entry. Idempotent: the CAS update
|
||||
// short-circuits on rowsAffected == 0 so a double-fire is a no-op.
|
||||
func (p *AdventurePlugin) deliverAmbient(e *Expedition, now time.Time) error {
|
||||
cutoff := now.Add(-ambientCooldown)
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_ambient_at = ?,
|
||||
last_activity = ?
|
||||
WHERE expedition_id = ?
|
||||
AND status = 'active'
|
||||
AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
|
||||
now, now, e.ID, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil // someone else got there first
|
||||
}
|
||||
|
||||
ev := pickAmbientEvent(e, defaultAmbientRNG())
|
||||
// Pick before claiming the slot so the CAS can persist the new Kind
|
||||
// alongside last_ambient_at — that way back-to-back fires for the
|
||||
// same expedition (different ticker tick, different rng) read the
|
||||
// updated LastAmbientKind on the next load. Picking is pure and
|
||||
// cheap; if the CAS loses, we simply discard the picked event.
|
||||
ev := pickAmbientEvent(e, defaultAmbientRNG(), e.LastAmbientKind)
|
||||
line := flavor.Pick(ev.Pool)
|
||||
if line == "" {
|
||||
// Pool was empty — degrade to a generic monologue rather than
|
||||
@@ -170,6 +159,23 @@ func (p *AdventurePlugin) deliverAmbient(e *Expedition, now time.Time) error {
|
||||
line = flavor.Pick(ev.Pool)
|
||||
}
|
||||
|
||||
cutoff := now.Add(-ambientCooldown)
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_ambient_at = ?,
|
||||
last_ambient_kind = ?,
|
||||
last_activity = ?
|
||||
WHERE expedition_id = ?
|
||||
AND status = 'active'
|
||||
AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
|
||||
now, ev.Kind, now, e.ID, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil // someone else got there first
|
||||
}
|
||||
|
||||
footer := p.applyAmbientEffect(e, ev)
|
||||
body := renderAmbientDM(e, ev, line, footer)
|
||||
|
||||
@@ -267,9 +273,23 @@ func ambientEvents() []ambientEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// pickAmbientEvent runs a weighted pick over eligible events.
|
||||
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
|
||||
// is the Kind of the previous fire (or "" if none); when a pick lands on
|
||||
// that same Kind, we re-roll once. We only re-roll once so the bias is a
|
||||
// soft nudge — if the user is in a state where most weight sits on one
|
||||
// Kind (e.g. starving cuts pack_rat → camped + low threat narrows the
|
||||
// table further) we still let it repeat rather than loop forever.
|
||||
// Exposed for tests; the rng parameter lets them seed deterministically.
|
||||
func pickAmbientEvent(e *Expedition, rng *rand.Rand) ambientEvent {
|
||||
func pickAmbientEvent(e *Expedition, rng *rand.Rand, avoidKind string) ambientEvent {
|
||||
first := weightedPickAmbient(e, rng)
|
||||
if avoidKind == "" || first.Kind != avoidKind {
|
||||
return first
|
||||
}
|
||||
return weightedPickAmbient(e, rng)
|
||||
}
|
||||
|
||||
// weightedPickAmbient is the underlying weighted-pick over eligible events.
|
||||
func weightedPickAmbient(e *Expedition, rng *rand.Rand) ambientEvent {
|
||||
events := ambientEvents()
|
||||
total := 0
|
||||
for _, ev := range events {
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestPickAmbientEvent_AlwaysReturnsEligible(t *testing.T) {
|
||||
}
|
||||
for i := 0; i < 200; i++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(i), uint64(i*7+1)))
|
||||
ev := pickAmbientEvent(e, rng)
|
||||
ev := pickAmbientEvent(e, rng, "")
|
||||
if disallowed[ev.Kind] {
|
||||
t.Fatalf("seed %d picked ineligible event %q", i, ev.Kind)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestPickAmbientEvent_CampVisitorRequiresCamp(t *testing.T) {
|
||||
sawVisitor := false
|
||||
for i := 0; i < 500; i++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i*13+2)))
|
||||
if pickAmbientEvent(e, rng).Kind == "camp_visitor" {
|
||||
if pickAmbientEvent(e, rng, "").Kind == "camp_visitor" {
|
||||
sawVisitor = true
|
||||
break
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestPickAmbientEvent_PackRatNeedsSupplies(t *testing.T) {
|
||||
e := &Expedition{Supplies: ExpeditionSupplies{Current: 0}}
|
||||
for i := 0; i < 200; i++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(i+3), uint64(i*5+7)))
|
||||
if pickAmbientEvent(e, rng).Kind == "pack_rat" {
|
||||
if pickAmbientEvent(e, rng, "").Kind == "pack_rat" {
|
||||
t.Fatalf("seed %d: pack_rat fired with no supplies", i)
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestPickAmbientEvent_WhisperNeedsThreat(t *testing.T) {
|
||||
low := &Expedition{Supplies: ExpeditionSupplies{Current: 5}, ThreatLevel: 10}
|
||||
for i := 0; i < 200; i++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(i+5), uint64(i*11+3)))
|
||||
if pickAmbientEvent(low, rng).Kind == "faction_whisper" {
|
||||
if pickAmbientEvent(low, rng, "").Kind == "faction_whisper" {
|
||||
t.Fatalf("seed %d: whisper fired below threshold", i)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func TestPickAmbientEvent_WhisperNeedsThreat(t *testing.T) {
|
||||
saw := false
|
||||
for i := 0; i < 500; i++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(i+9), uint64(i*17+5)))
|
||||
if pickAmbientEvent(hot, rng).Kind == "faction_whisper" {
|
||||
if pickAmbientEvent(hot, rng, "").Kind == "faction_whisper" {
|
||||
saw = true
|
||||
break
|
||||
}
|
||||
@@ -120,12 +120,41 @@ func TestPickAmbientEvent_SiegeSuppressesWhisper(t *testing.T) {
|
||||
}
|
||||
for i := 0; i < 200; i++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(i+13), uint64(i*19+11)))
|
||||
if pickAmbientEvent(e, rng).Kind == "faction_whisper" {
|
||||
if pickAmbientEvent(e, rng, "").Kind == "faction_whisper" {
|
||||
t.Fatalf("seed %d: whisper fired during siege", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickAmbientEvent_AntiRepeatRerollsOnce(t *testing.T) {
|
||||
// When the first roll lands on avoidKind, we re-roll once. We can't
|
||||
// guarantee the second roll diverges (the same Kind can win twice
|
||||
// legitimately), but across many seeds the same-Kind repeat rate
|
||||
// must drop materially vs the no-avoid baseline.
|
||||
e := &Expedition{Supplies: ExpeditionSupplies{Current: 5}}
|
||||
const trials = 2000
|
||||
baseline := 0 // Kind == "pack_rat" with no avoid
|
||||
guarded := 0 // Kind == "pack_rat" with avoidKind == "pack_rat"
|
||||
for i := 0; i < trials; i++ {
|
||||
seedA := uint64(i*2 + 1)
|
||||
seedB := uint64(i*2 + 2)
|
||||
if pickAmbientEvent(e, rand.New(rand.NewPCG(seedA, seedB)), "").Kind == "pack_rat" {
|
||||
baseline++
|
||||
}
|
||||
if pickAmbientEvent(e, rand.New(rand.NewPCG(seedA, seedB)), "pack_rat").Kind == "pack_rat" {
|
||||
guarded++
|
||||
}
|
||||
}
|
||||
if guarded >= baseline {
|
||||
t.Fatalf("anti-repeat did not reduce pack_rat hits: baseline=%d guarded=%d", baseline, guarded)
|
||||
}
|
||||
// Sanity: the guarded count should be small (≈ baseline²/total), not
|
||||
// just one less. Allow generous slack but flag obvious regressions.
|
||||
if guarded*3 > baseline {
|
||||
t.Errorf("anti-repeat reduction weaker than expected: baseline=%d guarded=%d", baseline, guarded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAmbientDM_NoFooterPureFlavor(t *testing.T) {
|
||||
e := &Expedition{CurrentDay: 4}
|
||||
ev := ambientEventMonologue()
|
||||
|
||||
197
internal/plugin/expedition_autorun.go
Normal file
197
internal/plugin/expedition_autorun.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 4 of expedition autopilot — background auto-run ticker. Walks
|
||||
// active expeditions on real time so the player only engages when a real
|
||||
// decision is needed (fork, elite, boss, low HP/SU, rare node, death).
|
||||
//
|
||||
// Design (settled 2026-05-16):
|
||||
// • Tick every autoRunTickInterval; per-expedition rate-limited by
|
||||
// autoRunCooldown via a last_autorun_at CAS.
|
||||
// • Skip when the player is in active combat, inside the briefing/recap
|
||||
// quiet window, or on a very-fresh expedition (give them a beat).
|
||||
// • Walk up to autoRunRoomCap rooms per tick. Smaller than foreground's
|
||||
// cap so background DMs stay digestible.
|
||||
// • DM-suppression rules:
|
||||
// - 0 rooms walked → silent (still stuck at a fork / blocked / etc.;
|
||||
// the player already knows, no point spamming).
|
||||
// - rooms > 0 with stopOK (hit room cap) → silent; we'll keep walking
|
||||
// on the next tick. Cadence handles pacing; no "stretch complete"
|
||||
// footer churn.
|
||||
// - Any other reason with rooms > 0 → DM the bundled walk.
|
||||
// • Idempotency: CAS-claim last_autorun_at before doing any work. A
|
||||
// double-fire on the same expedition is a no-op.
|
||||
|
||||
const (
|
||||
// autoRunTickInterval — how often the ticker wakes. The per-expedition
|
||||
// cooldown is what actually paces; the tick just has to be frequent
|
||||
// enough that the cooldown is enforced cleanly.
|
||||
autoRunTickInterval = 5 * time.Minute
|
||||
|
||||
// autoRunCooldown — minimum gap between background walks for one
|
||||
// expedition. 15 min keeps the dungeon moving while leaving room for
|
||||
// the player to step in and steer if they want.
|
||||
autoRunCooldown = 15 * time.Minute
|
||||
|
||||
// autoRunMinExpeditionAge — don't auto-walk a brand-new expedition;
|
||||
// let the player walk the first beat manually so the opening reads
|
||||
// as deliberate.
|
||||
autoRunMinExpeditionAge = 30 * time.Minute
|
||||
|
||||
// autoRunRoomCap — rooms per background tick. Smaller than the
|
||||
// foreground cap so a single background DM doesn't dump an
|
||||
// overwhelming wall of narration.
|
||||
autoRunRoomCap = 3
|
||||
)
|
||||
|
||||
// expeditionAutoRunTicker — fires the auto-run pass every tick interval.
|
||||
func (p *AdventurePlugin) expeditionAutoRunTicker() {
|
||||
ticker := time.NewTicker(autoRunTickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.fireExpeditionAutoRuns(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireExpeditionAutoRuns walks active expeditions due for a background
|
||||
// step and dispatches one walk per. Cheap per-expedition gating happens
|
||||
// in tryAutoRun; this layer just selects candidates.
|
||||
func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) {
|
||||
// Reuse the ambient quiet window — same intent: don't talk over the
|
||||
// scheduled 06:00 briefing or 21:00 recap.
|
||||
if inAmbientQuietWindow(now) {
|
||||
return
|
||||
}
|
||||
due, err := loadExpeditionsForAutoRun(
|
||||
now.Add(-autoRunCooldown), now.Add(-autoRunMinExpeditionAge))
|
||||
if err != nil {
|
||||
slog.Error("expedition: load autorun candidates", "err", err)
|
||||
return
|
||||
}
|
||||
for _, expID := range due {
|
||||
e, err := getExpedition(expID)
|
||||
if err != nil || e == nil {
|
||||
continue
|
||||
}
|
||||
if e.Status != ExpeditionStatusActive {
|
||||
continue
|
||||
}
|
||||
uid := id.UserID(e.UserID)
|
||||
if hasActiveCombatSession(uid) {
|
||||
continue
|
||||
}
|
||||
if err := p.tryAutoRun(e, now); err != nil {
|
||||
slog.Warn("expedition: autorun step", "expedition", e.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadExpeditionsForAutoRun returns active expeditions whose
|
||||
// last_autorun_at is NULL or older than runCutoff, AND whose start_date
|
||||
// is older than ageCutoff so a freshly-started expedition isn't yanked
|
||||
// out from under the player on tick 1.
|
||||
func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT expedition_id
|
||||
FROM dnd_expedition
|
||||
WHERE status = 'active'
|
||||
AND start_date < ?
|
||||
AND (last_autorun_at IS NULL OR last_autorun_at < ?)`,
|
||||
ageCutoff, runCutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, s)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// tryAutoRun claims the slot for this expedition, runs one background
|
||||
// walk, and DMs the result if the suppression rules say to. The CAS-
|
||||
// update is the only persistent side effect on the autorun column —
|
||||
// supplies/threat/run-graph state are mutated by the walk itself, just
|
||||
// as they would be in a foreground !expedition run.
|
||||
func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
|
||||
cutoff := now.Add(-autoRunCooldown)
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_autorun_at = ?,
|
||||
last_activity = ?
|
||||
WHERE expedition_id = ?
|
||||
AND status = 'active'
|
||||
AND (last_autorun_at IS NULL OR last_autorun_at < ?)`,
|
||||
now, now, e.ID, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil // someone else got there first
|
||||
}
|
||||
|
||||
uid := id.UserID(e.UserID)
|
||||
r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true)
|
||||
if r.initErr != "" {
|
||||
// "no expedition" / "no run" — race with abandon/extract. Silent.
|
||||
return nil
|
||||
}
|
||||
if !shouldDMAutoRun(r) {
|
||||
return nil
|
||||
}
|
||||
|
||||
body := renderAutoRunDM(r)
|
||||
if err := p.SendDM(uid, body); err != nil {
|
||||
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldDMAutoRun applies the background suppression rules. See the
|
||||
// file-top design block for the rationale.
|
||||
func shouldDMAutoRun(r autopilotWalkResult) bool {
|
||||
if r.rooms == 0 {
|
||||
return false
|
||||
}
|
||||
if r.reason == stopOK {
|
||||
// Hit the per-tick room cap. Next tick will continue; no need to
|
||||
// post a "stretch complete" filler DM.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// renderAutoRunDM bundles the staged walk narration into a single DM.
|
||||
// Background can't pace via streamFlow, so we concatenate phases with a
|
||||
// blank line between each beat and tack on the final message.
|
||||
func renderAutoRunDM(r autopilotWalkResult) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("🚶 *Auto-walk*\n\n")
|
||||
for _, s := range r.stream {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(s)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(r.finalMsg)
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user