mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Multi-region zones now default CurrentRegion to the entry region at
startExpedition; that region is also written to regions_visited so
the visited/cleared distinction is meaningful from Day 1.
MarkRegionVisited / MarkRegionBossDefeated expose the combat-link
hook surface. MarkRegionBossDefeated also flips Expedition.BossDefeated
when the cleared region's IsZoneBoss is set, since the zone-boss kill
is the same event from §7's POV (suppresses temporal accumulation,
kills further awareness pulses, etc.). setCurrentRegion is the
companion writer used by E4c's travel command.
!expedition status now shows the region line ("🗺 Region: Drow
Outpost (2/4)") with a ✓ marker when the region boss is down.
377 lines
13 KiB
Go
377 lines
13 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/flavor"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// !expedition — Phase 12 E1c. The command surface for the multi-day
|
|
// expedition system landed in E1a/E1b. This file is presentation +
|
|
// dispatch only; data plumbing lives in dnd_expedition.go and supply
|
|
// math in dnd_expedition_supplies.go.
|
|
//
|
|
// Subcommands:
|
|
//
|
|
// !expedition → help (or status if active)
|
|
// !expedition list → zones available at player level
|
|
// !expedition start <zone> [Ns] [Md] → outfit & start; default 1 standard pack
|
|
// !expedition status → daily briefing-style block (§12.1)
|
|
// !expedition log → last 5 expedition log entries
|
|
// !expedition abandon → end the expedition (no rewards)
|
|
// !expedition help → help text
|
|
//
|
|
// Day cycle (06:00/21:00 cron) lands in E1d. Camp commands (!camp) land
|
|
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.
|
|
|
|
func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error {
|
|
userMu := p.advUserLock(ctx.Sender)
|
|
userMu.Lock()
|
|
defer userMu.Unlock()
|
|
|
|
c, err := LoadDnDCharacter(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
|
|
}
|
|
if c == nil || c.PendingSetup {
|
|
return p.SendDM(ctx.Sender,
|
|
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
|
|
}
|
|
|
|
args = strings.TrimSpace(args)
|
|
sub, rest := splitFirstWord(args)
|
|
switch strings.ToLower(sub) {
|
|
case "":
|
|
// If active, show status; otherwise help.
|
|
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
|
return p.expeditionCmdStatus(ctx)
|
|
}
|
|
return p.SendDM(ctx.Sender, expeditionHelpText())
|
|
case "help", "?":
|
|
return p.SendDM(ctx.Sender, expeditionHelpText())
|
|
case "list", "ls":
|
|
return p.zoneCmdList(ctx, c)
|
|
case "start", "begin", "go":
|
|
return p.expeditionCmdStart(ctx, c, rest)
|
|
case "status", "info":
|
|
return p.expeditionCmdStatus(ctx)
|
|
case "log", "history":
|
|
return p.expeditionCmdLog(ctx)
|
|
case "abandon", "quit":
|
|
return p.expeditionCmdAbandon(ctx)
|
|
default:
|
|
return p.SendDM(ctx.Sender, expeditionHelpText())
|
|
}
|
|
}
|
|
|
|
func expeditionHelpText() string {
|
|
var b strings.Builder
|
|
b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n")
|
|
b.WriteString("`!expedition list` — show zones available at your level\n")
|
|
b.WriteString("`!expedition start <zone> [Ns] [Md]` — outfit & begin\n")
|
|
b.WriteString(" `Ns` = N standard packs (10 SU, 50 coins, max 3)\n")
|
|
b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins, max 1)\n")
|
|
b.WriteString(" default: `1s`\n")
|
|
b.WriteString("`!expedition status` — current expedition snapshot\n")
|
|
b.WriteString("`!expedition log` — last 5 log entries\n")
|
|
b.WriteString("`!expedition abandon` — end the expedition (no rewards)")
|
|
return b.String()
|
|
}
|
|
|
|
// ── start ───────────────────────────────────────────────────────────────────
|
|
|
|
// parseSupplyArgs reads a token stream like "2s 1d" into a SupplyPurchase.
|
|
// Tokens are case-insensitive; suffix s|standard or d|deluxe. Bare integer
|
|
// is interpreted as standard packs. Default (empty) is one standard pack.
|
|
func parseSupplyArgs(rest string) (SupplyPurchase, error) {
|
|
p := SupplyPurchase{}
|
|
if strings.TrimSpace(rest) == "" {
|
|
p.StandardPacks = 1
|
|
return p, nil
|
|
}
|
|
for _, tok := range strings.Fields(rest) {
|
|
t := strings.ToLower(strings.TrimSpace(tok))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
var (
|
|
numPart string
|
|
suf string
|
|
)
|
|
// Find first non-digit position.
|
|
i := 0
|
|
for i < len(t) && t[i] >= '0' && t[i] <= '9' {
|
|
i++
|
|
}
|
|
numPart = t[:i]
|
|
suf = t[i:]
|
|
if numPart == "" {
|
|
return p, fmt.Errorf("can't parse pack token %q", tok)
|
|
}
|
|
n, err := strconv.Atoi(numPart)
|
|
if err != nil {
|
|
return p, fmt.Errorf("can't parse pack count in %q", tok)
|
|
}
|
|
switch suf {
|
|
case "", "s", "std", "standard":
|
|
p.StandardPacks += n
|
|
case "d", "dlx", "deluxe":
|
|
p.DeluxePacks += n
|
|
default:
|
|
return p, fmt.Errorf("unknown pack suffix in %q (use s or d)", tok)
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error {
|
|
if rest == "" {
|
|
return p.SendDM(ctx.Sender,
|
|
"`!expedition start <zone> [Ns] [Md]` — pick from `!expedition list`. Example: `!expedition start goblin_warrens 2s` (2 standard packs).")
|
|
}
|
|
zoneTok, packTok := splitFirstWord(rest)
|
|
available := zonesForLevel(c.Level)
|
|
zoneID, ok := resolveZoneInput(zoneTok, available)
|
|
if !ok {
|
|
return p.SendDM(ctx.Sender,
|
|
"Unknown zone for your level. Try `!expedition list`.")
|
|
}
|
|
purchase, err := parseSupplyArgs(packTok)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
|
}
|
|
if err := purchase.Validate(); err != nil {
|
|
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
|
}
|
|
cost := float64(purchase.Cost())
|
|
if p.euro == nil {
|
|
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
|
}
|
|
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
|
int(cost), balance))
|
|
}
|
|
|
|
// Reject if any expedition or zone run already active.
|
|
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
|
|
zone, _ := getZone(existing.ZoneID)
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
|
zone.Display, existing.CurrentDay))
|
|
}
|
|
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
|
|
zone, _ := getZone(existing.ZoneID)
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
|
|
zone.Display))
|
|
}
|
|
|
|
zone, _ := getZone(zoneID)
|
|
supplies := makeSupplies(zone.Tier, purchase)
|
|
|
|
// 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)
|
|
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())
|
|
}
|
|
|
|
// Log the start with prewritten flavor.
|
|
startLine := flavor.Pick(flavor.ExpeditionStart)
|
|
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
|
b.WriteString("_" + zone.Hook + "_\n\n")
|
|
b.WriteString(fmt.Sprintf("**Outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
|
|
supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost()))
|
|
b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — that's roughly **%d days** of provisions.\n\n",
|
|
supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn)))
|
|
if startLine != "" {
|
|
b.WriteString(startLine)
|
|
b.WriteString("\n\n")
|
|
}
|
|
b.WriteString("Use `!expedition status` for the daily briefing format. Day 1 begins now.")
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|
|
|
|
func estimateDays(maxSU, dailyBurn float32) int {
|
|
if dailyBurn <= 0 {
|
|
return 0
|
|
}
|
|
return int(math.Floor(float64(maxSU / dailyBurn)))
|
|
}
|
|
|
|
// ── status ──────────────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error {
|
|
exp, err := getActiveExpedition(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
|
}
|
|
if exp == nil {
|
|
return p.SendDM(ctx.Sender,
|
|
"No active expedition. Use `!expedition list` then `!expedition start <zone>`.")
|
|
}
|
|
zone, _ := getZone(exp.ZoneID)
|
|
c, _ := LoadDnDCharacter(ctx.Sender)
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d**\n\n", exp.CurrentDay))
|
|
b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier)))
|
|
if r, ok := CurrentRegion(exp); ok {
|
|
cleared := IsRegionCleared(exp, r.ID)
|
|
marker := ""
|
|
if cleared {
|
|
marker = " ✓"
|
|
}
|
|
b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n",
|
|
r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker))
|
|
}
|
|
if c != nil {
|
|
b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax))
|
|
}
|
|
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burn %.1f/day → ~%d days left)_\n",
|
|
exp.Supplies.Current, exp.Supplies.Max, currentBurn(exp),
|
|
estimateDays(exp.Supplies.Current, currentBurn(exp))))
|
|
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
|
exp.ThreatLevel, threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
|
|
if exp.TemporalStack != 0 {
|
|
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", exp.TemporalStack))
|
|
}
|
|
if exp.Camp != nil && exp.Camp.Active {
|
|
b.WriteString(fmt.Sprintf("⛺ **Camp:** %s (room %d)\n", exp.Camp.Type, exp.Camp.RoomIndex+1))
|
|
}
|
|
state := supplyDepletion(exp.Supplies)
|
|
if state != SupplyNormal {
|
|
b.WriteString(fmt.Sprintf("⚠ **%s** — roll modifier %d\n",
|
|
depletionLabel(state), supplyRollModifier(state)))
|
|
}
|
|
b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s",
|
|
exp.StartDate.Format("2006-01-02 15:04"),
|
|
exp.LastActivity.Format("2006-01-02 15:04")))
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|
|
|
|
func currentBurn(exp *Expedition) float32 {
|
|
burn := exp.Supplies.DailyBurn
|
|
if exp.SiegeMode {
|
|
// §8.3: siege doubles supply burn outright, overriding HarshMod
|
|
// (which can be < 2 at low tiers).
|
|
mult := exp.Supplies.HarshMod
|
|
if mult < 2 {
|
|
mult = 2
|
|
}
|
|
return burn * mult
|
|
}
|
|
if exp.ThreatLevel > 60 {
|
|
// Harsh conditions: §4.1.
|
|
mult := exp.Supplies.HarshMod
|
|
if mult <= 0 {
|
|
mult = 1
|
|
}
|
|
burn *= mult
|
|
}
|
|
return burn
|
|
}
|
|
|
|
func threatThresholdLabel(level int, siege bool) string {
|
|
if siege {
|
|
return "Siege"
|
|
}
|
|
switch {
|
|
case level >= 81:
|
|
return "Siege"
|
|
case level >= 61:
|
|
return "Hostile"
|
|
case level >= 41:
|
|
return "Alert"
|
|
case level >= 21:
|
|
return "Stirring"
|
|
default:
|
|
return "Quiet"
|
|
}
|
|
}
|
|
|
|
func depletionLabel(s SupplyDepletionState) string {
|
|
switch s {
|
|
case SupplyRationing:
|
|
return "Rationing"
|
|
case SupplySevereRationing:
|
|
return "Severe rationing"
|
|
case SupplyStarvation:
|
|
return "Starvation"
|
|
}
|
|
return "Normal"
|
|
}
|
|
|
|
// ── log ─────────────────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error {
|
|
exp, err := getActiveExpedition(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
|
}
|
|
if exp == nil {
|
|
return p.SendDM(ctx.Sender, "No active expedition.")
|
|
}
|
|
entries, err := recentExpeditionLog(exp.ID, 5)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read log: "+err.Error())
|
|
}
|
|
if len(entries) == 0 {
|
|
return p.SendDM(ctx.Sender, "No log entries yet.")
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("📜 **Expedition log — last %d entries**\n\n", len(entries)))
|
|
for _, e := range entries {
|
|
b.WriteString(fmt.Sprintf("**Day %d** — _%s_ (%s)\n",
|
|
e.Day, e.Type, formatLogTimestamp(e.Timestamp)))
|
|
if e.Summary != "" {
|
|
b.WriteString(" " + e.Summary + "\n")
|
|
}
|
|
if e.Flavor != "" {
|
|
b.WriteString(" _" + e.Flavor + "_\n")
|
|
}
|
|
}
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|
|
|
|
func formatLogTimestamp(t time.Time) string {
|
|
return t.Format("2006-01-02 15:04")
|
|
}
|
|
|
|
// ── abandon ─────────────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
|
exp, err := getActiveExpedition(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
|
}
|
|
if exp == nil {
|
|
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
|
|
}
|
|
zone, _ := getZone(exp.ZoneID)
|
|
if err := abandonExpedition(ctx.Sender); err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
|
}
|
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
|
zone.Display, exp.CurrentDay))
|
|
}
|
|
|
|
// helper: ensure we don't shadow id.UserID import in test harness.
|
|
var _ id.UserID
|