mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
adventure: bored adventurers go into dungeons on their own
A player who stops tending their adventurer doesn't stop having one. After 24h with no action against Adventure, the character gets restless and leaves on an expedition by itself: the easiest zone its level band allows, the cheapest supplies it can afford, and whatever gear was already on the rack. Everything downstream of the start was already autonomous — the autopilot walks rooms, drives elite and boss fights on the turn engine, camps, harvests and picks forks. The only thing that ever needed a human was `!expedition start`, so that's all this adds: a 30m ticker plus a clock. It never buys or equips anything, and that is the whole mechanic: a neglected adventurer grinds half-starved runs on rusting gear and comes home taxed. The prodexercise killed an L4 mage four rooms in on its first run. The clock is a new column. Every existing timestamp is unusable: last_active_at is auto-bumped by saveAdvCharacter (the autopilot would refresh a bored character's own idle clock), loadAdvDailyActivity counts the autopilot's own expedition logs, and user_stats.updated_at is chat presence, not a game action. last_player_action_at is written only by markPlayerAction, from a real player action against Adventure — any interface, not just Matrix. Raid zones (raidContentWarning: the party-tuned T5 bosses with a 0% solo clear) are avoided while anything else is in band. At L13+ they're all that's left, and the adventurer goes in anyway and loses. That's intended. dnd_expedition.boredom + isBoredomDriven stop a run nobody asked for from shielding an absent player from the idle reaper or holding their streak. A manually-started expedition still holds it while the autopilot walks it. Robbie visits and pays silently for idle players — he was going to file a daily public bulletin about people who stopped playing weeks ago. Note for anyone touching the time-scanning queries here: modernc.org/sqlite rebuilds a time.Time from the column's declared type, and COALESCE()/MAX() erase it. playerIsIdle fails open, so a broken scan there declares the whole server idle. Both it and lastExpeditionByZone select declared columns and fold in Go, and the tests seed real rows so the scan actually executes.
This commit is contained in:
@@ -383,44 +383,10 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
}
|
||||
|
||||
zone := zoneForCaps
|
||||
// Holiday perk: a complimentary standard pack is added to the supplies
|
||||
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
||||
// on purpose — it's a freebie on top of whatever the player bought.
|
||||
suppliesPurchase := purchase
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
}
|
||||
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
||||
suppliesPurchase.StandardPacks += pk
|
||||
}
|
||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||
|
||||
// Debit coins; bail on debit failure (race / cap).
|
||||
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(zoneID)) {
|
||||
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
||||
}
|
||||
|
||||
exp, err := startExpedition(ctx.Sender, zoneID, "", supplies)
|
||||
_, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting")
|
||||
if err != nil {
|
||||
// Refund on persistence failure.
|
||||
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund")
|
||||
return p.SendDM(ctx.Sender, "Couldn't start expedition: "+err.Error())
|
||||
return p.SendDM(ctx.Sender, err.Error())
|
||||
}
|
||||
|
||||
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
|
||||
// harvest depends on this; the existing zone-run !advance/!retreat
|
||||
// commands now operate on this run while the expedition is active.
|
||||
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
||||
// Refund and tear the expedition row back down — without a
|
||||
// linked run, harvest and rooms can't function.
|
||||
_ = abandonExpedition(ctx.Sender)
|
||||
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund (run-spawn failed)")
|
||||
return p.SendDM(ctx.Sender, "Couldn't outfit the first region: "+err.Error())
|
||||
}
|
||||
|
||||
// Log the start with prewritten flavor.
|
||||
startLine := flavor.Pick(flavor.ExpeditionStart)
|
||||
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
||||
markActedToday(ctx.Sender)
|
||||
|
||||
var b strings.Builder
|
||||
@@ -442,6 +408,65 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
// beginExpedition performs the non-interactive half of starting an expedition:
|
||||
// supply freebies, the coin debit, persistence, the starting region's run, and
|
||||
// the opening log entry. It refunds and tears down on every failure path, so a
|
||||
// caller holding an error owes the player nothing.
|
||||
//
|
||||
// Callers own the balance check, the eligibility guards, and the player-facing
|
||||
// messaging. This is the shared transaction under `!expedition start` and the
|
||||
// boredom ticker (gogobee_boredom_plan.md §4); the returned errors are written
|
||||
// as complete player-facing sentences so both callers can surface them as-is.
|
||||
//
|
||||
// It deliberately does NOT call markActedToday — an expedition the player did
|
||||
// not ask for must not spend their daily action or count as them showing up.
|
||||
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
|
||||
if p.euro == nil {
|
||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
|
||||
}
|
||||
cost := float64(purchase.Cost())
|
||||
|
||||
// Holiday perk: a complimentary standard pack is added to the supplies
|
||||
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
||||
// on purpose — it's a freebie on top of whatever the player bought.
|
||||
suppliesPurchase := purchase
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
}
|
||||
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
||||
suppliesPurchase.StandardPacks += pk
|
||||
}
|
||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||
|
||||
// Debit coins; bail on debit failure (race / cap).
|
||||
if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) {
|
||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
|
||||
}
|
||||
|
||||
exp, err := startExpedition(uid, zone.ID, "", supplies)
|
||||
if err != nil {
|
||||
// Refund on persistence failure.
|
||||
p.euro.Credit(uid, cost, "expedition outfitting refund")
|
||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
|
||||
}
|
||||
|
||||
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
|
||||
// harvest depends on this; the existing zone-run !advance/!retreat
|
||||
// commands now operate on this run while the expedition is active.
|
||||
if _, err := ensureRegionRun(exp, charLevel); err != nil {
|
||||
// Refund and tear the expedition row back down — without a
|
||||
// linked run, harvest and rooms can't function.
|
||||
_ = abandonExpedition(uid)
|
||||
p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)")
|
||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
|
||||
}
|
||||
|
||||
// Log the start with prewritten flavor.
|
||||
startLine := flavor.Pick(flavor.ExpeditionStart)
|
||||
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
||||
return exp, supplies, startLine, nil
|
||||
}
|
||||
|
||||
// raidContentWarning returns a TwinBee-voiced heads-up for zones whose
|
||||
// boss is tuned for a party rather than a solo adventurer. T5 zones
|
||||
// (Dragon's Lair / Abyss Portal) have boss HP / damage curves that the
|
||||
|
||||
Reference in New Issue
Block a user