mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 Phase L3: delete co-op dungeons; refund in-flight runs on startup
Drop the coop_dungeon system per migration plan §5. 11 coop_*.go files removed; cleanup_l3.go added with an idempotent per-run status-guarded UPDATE that cancels any open/active runs and refunds member contributions plus unsettled bets via EuroPlugin.Credit. Tables retained for now; schema purge deferred to a future GOGOBEE_COOP_PURGE pass. adventure.go drops !coop dispatch, coopTicker, the startup combat-lock hook, and the !coop help line. adventure_scheduler.go loses the activeCoopMemberSet skip branch and the post-reset combat-lock call. adventure_render.go drops the teaser plus the in-coop status block; replaced with a one-week morning-DM closure announcement (TODO: remove after 2026-05-16). TestPickCoopTeaserCandidate_SkipsLeader removed. go vet ./... and go test ./... clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -244,38 +244,26 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Phase L3 — Co-op dungeons (DELETION, not migration)
|
## 5. Phase L3 — Co-op dungeons (DELETION, not migration) — SHIPPED 2026-05-09
|
||||||
|
|
||||||
**Decision (2026-05-08):** Drop co-op dungeons entirely. Do **not** migrate. The user has a different design in mind for the future co-op slot; the current implementation is being retired wholesale.
|
**Decision (2026-05-08):** Drop co-op dungeons entirely. Do **not** migrate. The user has a different design in mind for the future co-op slot; the current implementation is being retired wholesale.
|
||||||
|
|
||||||
**Files deleted:**
|
**What shipped (2026-05-09):**
|
||||||
- `coop_dungeon.go`
|
- 11 `coop_*.go` files deleted (no `coop_dungeon_balance.go` ever existed on disk — only `coop_dungeon_balance_test.go`).
|
||||||
- `coop_dungeon_db.go`
|
- `internal/plugin/cleanup_l3.go` added: on every startup, cancels any `coop_dungeon_runs` left in `open`/`active`, refunds `coop_dungeon_members.total_contributed` and unsettled `coop_dungeon_bets` via `EuroPlugin.Credit`. Idempotent via per-run status-guarded UPDATE — only the writer that flips status to `cancelled` performs the refund for that run, so a crashed-then-restarted process can't double-credit. Tables left intact for historical querying; SQL drop deferred to a future GOGOBEE_COOP_PURGE pass.
|
||||||
- `coop_dungeon_render.go`
|
- `adventure.go`: stripped `!coop` dispatch, `coopTicker` goroutine, startup `lockCoopCombatActions` call, `!coop` help line, ticker comment. Replaced startup hook with `closeAndRefundLegacyCoopRuns(p.euro)`.
|
||||||
- `coop_dungeon_scheduler.go`
|
- `adventure_scheduler.go`: removed `activeCoopMemberSet` load + skip branch and post-reset `lockCoopCombatActions` call.
|
||||||
- `coop_dungeon_balance.go`
|
- `adventure_render.go`: removed `renderCoopTeaser`, `pickCoopTeaserCandidate`, the `loadCoopRunForUser` block, and the in-coop combat-locked status line. Replaced with a temporary closure announcement in the morning DM ("**Co-op dungeons closed for now** — `!expedition` is the way forward; a better co-op design is on the way."). Marked with a TODO to remove after 2026-05-16.
|
||||||
- `coop_dungeon_balance_test.go`
|
- `adventure_followups_test.go`: removed `TestPickCoopTeaserCandidate_SkipsLeader`.
|
||||||
- `coop_dungeon_betting.go`
|
|
||||||
- `coop_dungeon_gifts.go`
|
|
||||||
- `coop_dungeon_stats.go`
|
|
||||||
- `coop_dungeon_test.go`
|
|
||||||
- `coop_event_meta.go`
|
|
||||||
- `coop_flavor_twinbee.go`
|
|
||||||
|
|
||||||
**Steps:**
|
**Exit criteria — met:**
|
||||||
1. Find all co-op references outside the `coop_*` files (commands registered in `adventure.go`, scheduler hooks, render hooks, betting integration). Grep `coop` and `Coop` across the package.
|
- `grep -rn 'coop_dungeon\|CoopDungeon' internal/plugin/ --include='*.go'` returns only the cleanup migration in `cleanup_l3.go` and a startup hook in `adventure.go` — no live system code references.
|
||||||
2. Remove command handlers from `adventure.go::OnMessage` and `Commands()`.
|
- `go vet ./...` clean.
|
||||||
3. Remove scheduler ticks that call coop entrypoints.
|
|
||||||
4. Drop the `coop_dungeon*` SQL tables in a separate `cleanup_l3.go` migration gated behind `GOGOBEE_COOP_PURGE=1`. Default off — keep historical rows around until manually flipped.
|
|
||||||
5. Delete the files.
|
|
||||||
6. Add a TwinBee announcement line for active players: "co-op dungeons closed for now; expedition is the way forward; better thing coming." Wire into morning DM for one week post-deploy.
|
|
||||||
7. `go test ./... && go vet`.
|
|
||||||
|
|
||||||
**Exit criteria:**
|
|
||||||
- `grep -rn 'coop_dungeon\|CoopDungeon' internal/plugin/ --include='*.go'` returns 0 (or only in archive/migration).
|
|
||||||
- `go test ./...` clean.
|
- `go test ./...` clean.
|
||||||
|
|
||||||
**Risk:** Players with active co-op state at deploy time. Mitigate: scheduler closes any in-flight session at L3 deploy with a refund of staked coins; no XP/loot awarded for cancelled sessions. Refund logic is one-shot in the L3 migration, not preserved code.
|
**Open follow-ups:**
|
||||||
|
- Remove the morning-DM closure announcement after 2026-05-16 (one-week soak).
|
||||||
|
- Future GOGOBEE_COOP_PURGE pass drops `coop_dungeon_runs`/`_members`/`_events`/`_bets`/`_gifts` tables and the `cleanup_l3.go` startup hook.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type AdventurePlugin struct {
|
|||||||
|
|
||||||
// stopCh is closed by Stop() to signal all background tickers
|
// stopCh is closed by Stop() to signal all background tickers
|
||||||
// (morning/summary/midnight/event/rival/robbie/hospital/mortgage/
|
// (morning/summary/midnight/event/rival/robbie/hospital/mortgage/
|
||||||
// coop/expedition briefing+recap) to exit. Each ticker selects
|
// expedition briefing+recap) to exit. Each ticker selects
|
||||||
// on its time.Ticker channel and stopCh; the second branch returns.
|
// on its time.Ticker channel and stopCh; the second branch returns.
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
}
|
}
|
||||||
@@ -194,9 +194,9 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
if err := backfillPlayerMetaArena(); err != nil {
|
if err := backfillPlayerMetaArena(); err != nil {
|
||||||
slog.Error("player_meta: arena backfill failed", "err", err)
|
slog.Error("player_meta: arena backfill failed", "err", err)
|
||||||
}
|
}
|
||||||
if err := lockCoopCombatActions(); err != nil {
|
// Phase L3 — cancel any open/active legacy coop dungeon runs and
|
||||||
slog.Error("adventure: startup coop combat lock failed", "err", err)
|
// refund member contributions + unsettled bets. Idempotent.
|
||||||
}
|
closeAndRefundLegacyCoopRuns(p.euro)
|
||||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||||
@@ -221,7 +221,6 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
go p.robbieTicker()
|
go p.robbieTicker()
|
||||||
go p.hospitalNudgeTicker()
|
go p.hospitalNudgeTicker()
|
||||||
go p.mortgageTicker()
|
go p.mortgageTicker()
|
||||||
go p.coopTicker()
|
|
||||||
go p.expeditionBriefingTicker()
|
go p.expeditionBriefingTicker()
|
||||||
go p.expeditionRecapTicker()
|
go p.expeditionRecapTicker()
|
||||||
|
|
||||||
@@ -366,11 +365,6 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return p.handleHospitalCmd(ctx)
|
return p.handleHospitalCmd(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1c. Co-op dungeon commands (work in rooms and DMs)
|
|
||||||
if p.IsCommand(ctx.Body, "coop") {
|
|
||||||
return p.handleCoopCmd(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if this is a DM reply from a registered player
|
// 2. Check if this is a DM reply from a registered player
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
playerID, isDM := p.dmToPlayer[ctx.RoomID]
|
playerID, isDM := p.dmToPlayer[ctx.RoomID]
|
||||||
@@ -478,7 +472,6 @@ const advHelpText = `**Adventure Commands**
|
|||||||
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
||||||
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
||||||
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
||||||
` + "`!coop`" + ` — Co-op dungeons (multi-day party runs). See ` + "`!coop help`" + `.
|
|
||||||
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||||
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||||
` + "`!adventure help`" + ` — This message
|
` + "`!adventure help`" + ` — This message
|
||||||
|
|||||||
@@ -321,34 +321,6 @@ func TestCraftingReminderWeekday_StableWithinWeek(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Co-op Teaser Leader Skip ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestPickCoopTeaserCandidate_SkipsLeader(t *testing.T) {
|
|
||||||
me := id.UserID("@me:test")
|
|
||||||
other := id.UserID("@other:test")
|
|
||||||
|
|
||||||
myRun := &CoopRun{ID: 1, Tier: 1, LeaderID: me}
|
|
||||||
theirRunMatch := &CoopRun{ID: 2, Tier: 1, LeaderID: other}
|
|
||||||
theirRunStretch := &CoopRun{ID: 3, Tier: 5, LeaderID: other}
|
|
||||||
|
|
||||||
char := &AdventureCharacter{UserID: me, CombatLevel: 10}
|
|
||||||
|
|
||||||
// Player leads run 1, qualifies for run 2 (T1 minLevel 5), under-level for run 3 (T5 minLevel 40).
|
|
||||||
match, stretch := pickCoopTeaserCandidate([]*CoopRun{myRun, theirRunMatch, theirRunStretch}, char)
|
|
||||||
if match == nil || match.ID != 2 {
|
|
||||||
t.Errorf("expected match=run 2, got %v", match)
|
|
||||||
}
|
|
||||||
if stretch == nil || stretch.ID != 3 {
|
|
||||||
t.Errorf("expected stretch=run 3, got %v", stretch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only own run open: nothing to surface.
|
|
||||||
match, stretch = pickCoopTeaserCandidate([]*CoopRun{myRun}, char)
|
|
||||||
if match != nil || stretch != nil {
|
|
||||||
t.Errorf("expected both nil when only own run open; got match=%v stretch=%v", match, stretch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Location Risk/Reward Numbers ───────────────────────────────────────────
|
// ── Location Risk/Reward Numbers ───────────────────────────────────────────
|
||||||
|
|
||||||
func TestCalculateAdvProbabilities_RiskReward_NormalizesAcrossConfigs(t *testing.T) {
|
func TestCalculateAdvProbabilities_RiskReward_NormalizesAcrossConfigs(t *testing.T) {
|
||||||
|
|||||||
@@ -213,55 +213,6 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
|||||||
|
|
||||||
// ── Morning DM ───────────────────────────────────────────────────────────────
|
// ── Morning DM ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// renderCoopTeaser surfaces an open co-op run the player could join, so the
|
|
||||||
// system isn't a footnote in help text. Returns "" when there's nothing to
|
|
||||||
// surface (no open runs, or the player is already locked into one). When
|
|
||||||
// runs exist that the player is under-levelled for, we still hint at them
|
|
||||||
// as a stretch goal — joining as a liability is a real choice.
|
|
||||||
func renderCoopTeaser(char *AdventureCharacter) string {
|
|
||||||
runs, err := loadOpenCoopRuns()
|
|
||||||
if err != nil || len(runs) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
pick, stretch := pickCoopTeaserCandidate(runs, char)
|
|
||||||
tag := ""
|
|
||||||
if pick == nil {
|
|
||||||
pick = stretch
|
|
||||||
tag = " *(below recommended level — would join as liability)*"
|
|
||||||
}
|
|
||||||
if pick == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
leaderName := string(pick.LeaderID)
|
|
||||||
if c, err := loadAdvCharacter(pick.LeaderID); err == nil && c != nil && c.DisplayName != "" {
|
|
||||||
leaderName = c.DisplayName
|
|
||||||
}
|
|
||||||
def := coopTierTable[pick.Tier]
|
|
||||||
return fmt.Sprintf("🛡️ **Open Co-op #%d** — Tier %d (%s) led by %s · `!coop join %d`%s",
|
|
||||||
pick.ID, pick.Tier, def.difficulty, leaderName, pick.ID, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickCoopTeaserCandidate scans the open coop runs and returns up to two
|
|
||||||
// picks: a primary (where the player meets the level gate) and a stretch
|
|
||||||
// (any other run, joined as a liability). Skips runs the player already
|
|
||||||
// leads. Pure function — testable without DB.
|
|
||||||
func pickCoopTeaserCandidate(runs []*CoopRun, char *AdventureCharacter) (match, stretch *CoopRun) {
|
|
||||||
for _, r := range runs {
|
|
||||||
if r.LeaderID == char.UserID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
def := coopTierTable[r.Tier]
|
|
||||||
if char.CombatLevel >= def.minLevel && match == nil {
|
|
||||||
match = r
|
|
||||||
} else if stretch == nil {
|
|
||||||
stretch = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return match, stretch
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderCraftingTeaser surfaces the crafting system before and just after
|
// renderCraftingTeaser surfaces the crafting system before and just after
|
||||||
// the Foraging-10 auto-unlock, so it doesn't stay invisible to players who
|
// the Foraging-10 auto-unlock, so it doesn't stay invisible to players who
|
||||||
// never type !adventure recipes. Returns "" when the teaser shouldn't fire
|
// never type !adventure recipes. Returns "" when the teaser shouldn't fire
|
||||||
@@ -379,23 +330,10 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
|||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Co-op participants have combat locked for the duration of the run.
|
// L3 closure announcement — surfaced for one-week post-deploy soak so
|
||||||
// Under Adv 2.0 the per-day combat/harvest action caps are no longer
|
// active co-op participants see why the system is gone. Remove after
|
||||||
// surfaced — harvesting is gated by per-room nodes, supplies, and the
|
// 2026-05-16 (TODO: drop announcement block at that revisit).
|
||||||
// threat clock; combat is gated by zones/expeditions/arena themselves.
|
sb.WriteString("📋 **Co-op dungeons closed for now** — `!expedition` is the way forward; a better co-op design is on the way.\n\n")
|
||||||
coopRun, _ := loadCoopRunForUser(char.UserID)
|
|
||||||
inCoop := coopRun != nil && coopRun.Status == "active"
|
|
||||||
|
|
||||||
if inCoop {
|
|
||||||
sb.WriteString(fmt.Sprintf("📋 **Co-op #%d** — day %d/%d, combat locked for the duration of the run\n\n",
|
|
||||||
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays))
|
|
||||||
} else {
|
|
||||||
// Co-op teaser — show open runs the player could join.
|
|
||||||
if line := renderCoopTeaser(char); line != "" {
|
|
||||||
sb.WriteString(line)
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rival nudge — a pending challenge waits for action.
|
// Rival nudge — a pending challenge waits for action.
|
||||||
if line := renderRivalNudge(char); line != "" {
|
if line := renderRivalNudge(char); line != "" {
|
||||||
@@ -416,9 +354,6 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
|||||||
sb.WriteString("• `!expedition` — overview & open expeditions\n")
|
sb.WriteString("• `!expedition` — overview & open expeditions\n")
|
||||||
sb.WriteString("• `!expedition start <zone>` — begin a new run\n")
|
sb.WriteString("• `!expedition start <zone>` — begin a new run\n")
|
||||||
sb.WriteString("• `!forage` · `!mine` · `!scavenge` · `!fish` · `!essence` · `!commune` — harvest in cleared rooms\n")
|
sb.WriteString("• `!forage` · `!mine` · `!scavenge` · `!fish` · `!essence` · `!commune` — harvest in cleared rooms\n")
|
||||||
if inCoop {
|
|
||||||
sb.WriteString(fmt.Sprintf("_(combat is locked while you're in Co-op #%d — `!coop status` for run state)_\n", coopRun.ID))
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
sb.WriteString("**🏘️ In town:**\n")
|
sb.WriteString("**🏘️ In town:**\n")
|
||||||
|
|||||||
@@ -355,30 +355,8 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||||
|
|
||||||
coopMembers, err := activeCoopMemberSet()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("adventure: failed to load active coop members", "err", err)
|
|
||||||
coopMembers = map[string]bool{}
|
|
||||||
}
|
|
||||||
|
|
||||||
dmsSent := 0
|
dmsSent := 0
|
||||||
for _, char := range chars {
|
for _, char := range chars {
|
||||||
// Active co-op members can't take manual actions (combat is locked
|
|
||||||
// to the coop run, harvest is optional). Their participation
|
|
||||||
// auto-resolves daily, so credit them with a streak day and skip
|
|
||||||
// idle/babysit logic — otherwise the lockCoopCombatActions sentinel
|
|
||||||
// (combat_actions_used = 99) trips HasActedToday and falls through
|
|
||||||
// to the streak-reset branch.
|
|
||||||
if coopMembers[string(char.UserID)] {
|
|
||||||
char.CurrentStreak++
|
|
||||||
if char.CurrentStreak > char.BestStreak {
|
|
||||||
char.BestStreak = char.CurrentStreak
|
|
||||||
}
|
|
||||||
char.LastActionDate = today
|
|
||||||
_ = saveAdvCharacter(&char)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !char.HasActedToday() {
|
if !char.HasActedToday() {
|
||||||
// If the player died today or yesterday, they couldn't act — no shame,
|
// If the player died today or yesterday, they couldn't act — no shame,
|
||||||
// no streak reset. This covers both currently-dead players and players
|
// no streak reset. This covers both currently-dead players and players
|
||||||
@@ -434,13 +412,6 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
slog.Warn("adventure: daily action reset failed, retrying", "attempt", attempt+1, "err", resetErr)
|
slog.Warn("adventure: daily action reset failed, retrying", "attempt", attempt+1, "err", resetErr)
|
||||||
time.Sleep(time.Duration(attempt+1) * 2 * time.Second)
|
time.Sleep(time.Duration(attempt+1) * 2 * time.Second)
|
||||||
}
|
}
|
||||||
if resetErr == nil {
|
|
||||||
// Re-lock combat for active co-op participants after the universal
|
|
||||||
// reset would otherwise have given them a fresh combat action.
|
|
||||||
if err := lockCoopCombatActions(); err != nil {
|
|
||||||
slog.Error("adventure: post-reset coop combat lock failed", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if resetErr != nil {
|
if resetErr != nil {
|
||||||
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
|
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
|
||||||
}
|
}
|
||||||
|
|||||||
111
internal/plugin/cleanup_l3.go
Normal file
111
internal/plugin/cleanup_l3.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// closeAndRefundLegacyCoopRuns is the Phase L3 one-shot. On every startup
|
||||||
|
// it scans for any coop_dungeon_runs still in 'open' or 'active' status,
|
||||||
|
// refunds member contributions and unsettled bets via the wallet, and
|
||||||
|
// flips the run to 'cancelled'.
|
||||||
|
//
|
||||||
|
// Idempotent: once a run is 'cancelled' it is skipped on subsequent runs.
|
||||||
|
// The cancel UPDATE is per-run with a status-guard, so two parallel calls
|
||||||
|
// (or a crashed-then-restarted process) will not double-credit a member
|
||||||
|
// or bettor — only the writer that flips status from 'open'/'active' to
|
||||||
|
// 'cancelled' performs the refund for that run.
|
||||||
|
//
|
||||||
|
// Tables are NOT dropped here. Historical rows remain for querying; a
|
||||||
|
// future GOGOBEE_COOP_PURGE pass will drop the schema.
|
||||||
|
func closeAndRefundLegacyCoopRuns(euro *EuroPlugin) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
rows, err := d.Query(`SELECT id FROM coop_dungeon_runs WHERE status IN ('open','active')`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("coop L3 cleanup: list runs", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var runIDs []int
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
slog.Error("coop L3 cleanup: scan run id", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runIDs = append(runIDs, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if len(runIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRuns, totalMembers, totalBets := 0, 0, 0
|
||||||
|
for _, runID := range runIDs {
|
||||||
|
res, err := d.Exec(`UPDATE coop_dungeon_runs
|
||||||
|
SET status = 'cancelled', completed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND status IN ('open','active')`, runID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("coop L3 cleanup: cancel run", "run_id", runID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalRuns++
|
||||||
|
|
||||||
|
mRows, err := d.Query(`SELECT user_id, total_contributed
|
||||||
|
FROM coop_dungeon_members
|
||||||
|
WHERE run_id = ? AND total_contributed > 0`, runID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("coop L3 cleanup: load members", "run_id", runID, "err", err)
|
||||||
|
} else {
|
||||||
|
for mRows.Next() {
|
||||||
|
var uid string
|
||||||
|
var amt int
|
||||||
|
if err := mRows.Scan(&uid, &amt); err != nil {
|
||||||
|
slog.Error("coop L3 cleanup: scan member", "run_id", runID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if euro != nil && amt > 0 {
|
||||||
|
euro.Credit(id.UserID(uid), float64(amt), "coop_l3_cancel_refund")
|
||||||
|
totalMembers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
bRows, err := d.Query(`SELECT player_id, amount
|
||||||
|
FROM coop_dungeon_bets
|
||||||
|
WHERE run_id = ? AND payout IS NULL`, runID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("coop L3 cleanup: load bets", "run_id", runID, "err", err)
|
||||||
|
} else {
|
||||||
|
for bRows.Next() {
|
||||||
|
var uid string
|
||||||
|
var amt int
|
||||||
|
if err := bRows.Scan(&uid, &amt); err != nil {
|
||||||
|
slog.Error("coop L3 cleanup: scan bet", "run_id", runID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if euro != nil && amt > 0 {
|
||||||
|
euro.Credit(id.UserID(uid), float64(amt), "coop_l3_cancel_bet_refund")
|
||||||
|
totalBets++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bRows.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalRuns > 0 {
|
||||||
|
slog.Info("coop L3 cleanup: cancelled in-flight runs",
|
||||||
|
"runs_cancelled", totalRuns,
|
||||||
|
"member_refunds", totalMembers,
|
||||||
|
"bet_refunds", totalBets)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,535 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Balance Constants ───────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Phase 1 values. Adjust during balance pass. Co-op should feel meaningfully
|
|
||||||
// harder than solo and reward groups for showing up.
|
|
||||||
|
|
||||||
type CoopFundingTier string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CoopFundNone CoopFundingTier = "none"
|
|
||||||
CoopFundMinimal CoopFundingTier = "minimal"
|
|
||||||
CoopFundStandard CoopFundingTier = "standard"
|
|
||||||
CoopFundAggressive CoopFundingTier = "aggressive"
|
|
||||||
CoopFundAllIn CoopFundingTier = "all_in"
|
|
||||||
)
|
|
||||||
|
|
||||||
type coopFundingDef struct {
|
|
||||||
cost int
|
|
||||||
modifier int // % added to per-floor success roll
|
|
||||||
label string
|
|
||||||
}
|
|
||||||
|
|
||||||
var coopFundingTable = map[CoopFundingTier]coopFundingDef{
|
|
||||||
CoopFundNone: {0, -10, "None"},
|
|
||||||
CoopFundMinimal: {500, 0, "Minimal"},
|
|
||||||
CoopFundStandard: {1500, 8, "Standard"},
|
|
||||||
CoopFundAggressive: {4000, 18, "Aggressive"},
|
|
||||||
CoopFundAllIn: {10000, 30, "All In"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// fundingTierOrder for menu display.
|
|
||||||
var coopFundingOrder = []CoopFundingTier{
|
|
||||||
CoopFundMinimal, CoopFundStandard, CoopFundAggressive, CoopFundAllIn,
|
|
||||||
}
|
|
||||||
|
|
||||||
type coopTierDef struct {
|
|
||||||
totalDays int
|
|
||||||
baseFailurePct int // base % failure per floor at zero modifier
|
|
||||||
rewardBase int // base reward (gold) before split
|
|
||||||
difficulty string // label
|
|
||||||
minLevel int // newbie liability threshold
|
|
||||||
lootMult float64 // for display only
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reward bases tuned via Monte Carlo (coop_dungeon_balance_test.go) so the
|
|
||||||
// optimal funding strategy walks up tiers: T1-T2 Minimal/Standard, T3 Standard,
|
|
||||||
// T4 Standard or Aggressive, T5 Aggressive only. All-In stays -EV at every
|
|
||||||
// tier — it's the boost lever for liability parties, not the optimal play.
|
|
||||||
var coopTierTable = map[int]coopTierDef{
|
|
||||||
1: {2, 25, 22500, "Moderate", 5, 2.0},
|
|
||||||
2: {3, 32, 40000, "Hard", 12, 3.5},
|
|
||||||
3: {4, 40, 72500, "Very Hard", 20, 5.5},
|
|
||||||
4: {5, 48, 120000, "Brutal", 30, 8.0},
|
|
||||||
5: {7, 58, 600000, "Murderous", 40, 12.0},
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
coopMinPartySize = 2
|
|
||||||
coopMaxPartySize = 4
|
|
||||||
coopInviteWindow = 24 * time.Hour
|
|
||||||
coopLiabilityCap = 8 // liability players' funding modifier capped at +8% regardless of tier
|
|
||||||
coopAdventureRake = 0.05
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Command Dispatch ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopCmd(ctx MessageContext) error {
|
|
||||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "coop"))
|
|
||||||
lower := strings.ToLower(args)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case args == "" || lower == "help":
|
|
||||||
return p.SendDM(ctx.Sender, coopHelpText)
|
|
||||||
case lower == "list":
|
|
||||||
return p.handleCoopList(ctx)
|
|
||||||
case lower == "status":
|
|
||||||
return p.handleCoopStatus(ctx)
|
|
||||||
case lower == "stats":
|
|
||||||
return p.SendDM(ctx.Sender, renderCoopStats())
|
|
||||||
case strings.HasPrefix(lower, "start "):
|
|
||||||
return p.handleCoopStart(ctx, strings.TrimSpace(args[6:]))
|
|
||||||
case strings.HasPrefix(lower, "join"):
|
|
||||||
return p.handleCoopJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join")))
|
|
||||||
case strings.HasPrefix(lower, "fund "):
|
|
||||||
return p.handleCoopFund(ctx, strings.TrimSpace(args[5:]))
|
|
||||||
case lower == "cancel":
|
|
||||||
return p.handleCoopCancel(ctx)
|
|
||||||
case strings.HasPrefix(lower, "vote "):
|
|
||||||
return p.handleCoopVote(ctx, strings.TrimSpace(args[5:]))
|
|
||||||
case strings.HasPrefix(lower, "bet "):
|
|
||||||
return p.handleCoopBet(ctx, strings.TrimSpace(args[4:]))
|
|
||||||
case strings.HasPrefix(lower, "gift "):
|
|
||||||
return p.handleCoopGift(ctx, strings.TrimSpace(args[5:]))
|
|
||||||
case strings.HasPrefix(lower, "giftvote "):
|
|
||||||
return p.handleCoopGiftVote(ctx, strings.TrimSpace(args[9:]))
|
|
||||||
case strings.HasPrefix(lower, "admgift "):
|
|
||||||
return p.handleCoopAdmGift(ctx, strings.TrimSpace(args[8:]))
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, "Unknown coop command. Try `!coop help`.")
|
|
||||||
}
|
|
||||||
|
|
||||||
const coopHelpText = `**Co-op Dungeon Commands**
|
|
||||||
|
|
||||||
` + "`!coop list`" + ` — Show open invites
|
|
||||||
` + "`!coop start <tier>`" + ` — Open an invite for a co-op dungeon (tier 1-5)
|
|
||||||
` + "`!coop join [<run_id>]`" + ` — Join an open invite (defaults to most recent)
|
|
||||||
` + "`!coop fund <tier>`" + ` — Set today's funding (none/minimal/standard/aggressive/all_in)
|
|
||||||
` + "`!coop vote <A|B|C>`" + ` — Vote on the day's floor event
|
|
||||||
` + "`!coop bet <amount> <success|failure>`" + ` — Spectator bet (parimutuel, 10% rake)
|
|
||||||
` + "`!coop gift <run_id> <basket|mimic>`" + ` — Send a gift (1 harvest action)
|
|
||||||
` + "`!coop giftvote <id> <open|leave>`" + ` — Party votes whether to open a gift
|
|
||||||
` + "`!coop status`" + ` — Show your current run
|
|
||||||
` + "`!coop stats`" + ` — Aggregate stats: outcomes, gold flow, betting, gifts, TwinBee helpfulness
|
|
||||||
` + "`!coop cancel`" + ` — Cancel an open invite (leader only, before lock)
|
|
||||||
|
|
||||||
**How it runs:**
|
|
||||||
- 24h invite window. Run locks automatically when window closes.
|
|
||||||
- 2-4 players. 2 minimum or invite cancels.
|
|
||||||
- Locking the party consumes the day's combat action for every member.
|
|
||||||
- Each subsequent day, every player picks a funding tier. Funding modifies
|
|
||||||
the party's per-floor success chance.
|
|
||||||
- Inactive players (no funding decision) auto-play at None (-10%).
|
|
||||||
- Wipes refund the day's combat action only. Funding is gone.
|
|
||||||
- On success, the reward is split evenly. Tax applies.`
|
|
||||||
|
|
||||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopStart(ctx MessageContext, tierArg string) error {
|
|
||||||
tier, err := strconv.Atoi(tierArg)
|
|
||||||
if err != nil || tier < 1 || tier > 5 {
|
|
||||||
return p.SendDM(ctx.Sender, "Tier must be 1-5. Example: `!coop start 3`.")
|
|
||||||
}
|
|
||||||
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
|
||||||
userMu.Lock()
|
|
||||||
defer userMu.Unlock()
|
|
||||||
|
|
||||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
|
||||||
}
|
|
||||||
if !char.Alive {
|
|
||||||
return p.SendDM(ctx.Sender, "You're dead. Co-op dungeons require a living adventurer.")
|
|
||||||
}
|
|
||||||
if !char.CanDoCombat(false) {
|
|
||||||
return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// One active run per player.
|
|
||||||
if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in a co-op run (#%d, %s). Use `!coop status`.", existing.ID, existing.Status))
|
|
||||||
}
|
|
||||||
|
|
||||||
def := coopTierTable[tier]
|
|
||||||
run, err := createCoopRun(tier, ctx.Sender, def.totalDays, def.baseFailurePct)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("coop: create run", "err", err)
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't open an invite. Try again.")
|
|
||||||
}
|
|
||||||
if err := addCoopMember(run.ID, ctx.Sender, 0, char.CombatLevel < def.minLevel); err != nil {
|
|
||||||
slog.Error("coop: add leader", "err", err)
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't add you to the run.")
|
|
||||||
}
|
|
||||||
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" {
|
|
||||||
postID, err := p.SendMessageID(gr, renderCoopInvite(run, []CoopMember{{UserID: ctx.Sender, TurnOrder: 0}}, char.DisplayName))
|
|
||||||
if err == nil {
|
|
||||||
_ = setCoopRunInvitePostID(run.ID, postID)
|
|
||||||
_ = p.PinEvent(gr, postID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("⚔️ Tier %d co-op invite opened (#%d). Locks in 24h. Recruit your party.", tier, run.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopJoin(ctx MessageContext, runIDArg string) error {
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
|
||||||
userMu.Lock()
|
|
||||||
defer userMu.Unlock()
|
|
||||||
|
|
||||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
|
||||||
}
|
|
||||||
if !char.Alive {
|
|
||||||
return p.SendDM(ctx.Sender, "You're dead. The dungeon does not accept corpses.")
|
|
||||||
}
|
|
||||||
if !char.CanDoCombat(false) {
|
|
||||||
return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.")
|
|
||||||
}
|
|
||||||
if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in run #%d.", existing.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
var run *CoopRun
|
|
||||||
if runIDArg == "" {
|
|
||||||
run, err = loadMostRecentOpenCoopRun()
|
|
||||||
} else {
|
|
||||||
id, perr := strconv.Atoi(runIDArg)
|
|
||||||
if perr != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Run ID must be a number. Try `!coop join 7` or `!coop join`.")
|
|
||||||
}
|
|
||||||
run, err = loadCoopRun(id)
|
|
||||||
}
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "No open invite found. Use `!coop list` to see open runs.")
|
|
||||||
}
|
|
||||||
if run.Status != "open" {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s. Can't join.", run.ID, run.Status))
|
|
||||||
}
|
|
||||||
|
|
||||||
members, err := loadCoopMembers(run.ID)
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load the party.")
|
|
||||||
}
|
|
||||||
if len(members) >= coopMaxPartySize {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is full (%d/%d).", run.ID, len(members), coopMaxPartySize))
|
|
||||||
}
|
|
||||||
|
|
||||||
def := coopTierTable[run.Tier]
|
|
||||||
liability := char.CombatLevel < def.minLevel
|
|
||||||
if err := addCoopMember(run.ID, ctx.Sender, len(members), liability); err != nil {
|
|
||||||
slog.Error("coop: join", "err", err)
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't join.")
|
|
||||||
}
|
|
||||||
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" {
|
|
||||||
warn := ""
|
|
||||||
if liability {
|
|
||||||
warn = " ⚠️ liability"
|
|
||||||
}
|
|
||||||
_ = p.SendMessage(gr, fmt.Sprintf("⚔️ %s joined Tier %d Co-op #%d (%d/%d)%s.",
|
|
||||||
char.DisplayName, run.Tier, run.ID, len(members)+1, coopMaxPartySize, warn))
|
|
||||||
}
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Joined run #%d. Wait for the lock; the run starts then.", run.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopList(ctx MessageContext) error {
|
|
||||||
runs, err := loadOpenCoopRuns()
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load the list.")
|
|
||||||
}
|
|
||||||
if len(runs) == 0 {
|
|
||||||
return p.SendDM(ctx.Sender, "No open invites right now. Start one with `!coop start <tier>`.")
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("**Open Co-op Invites**\n\n")
|
|
||||||
for _, r := range runs {
|
|
||||||
members, _ := loadCoopMembers(r.ID)
|
|
||||||
closesIn := time.Until(r.CreatedAt.Add(coopInviteWindow)).Truncate(time.Minute)
|
|
||||||
sb.WriteString(fmt.Sprintf(" #%d T%d (%s) %d/%d closes in %s\n",
|
|
||||||
r.ID, r.Tier, coopTierTable[r.Tier].difficulty, len(members), coopMaxPartySize, closesIn))
|
|
||||||
}
|
|
||||||
sb.WriteString("\nJoin with `!coop join <id>` or just `!coop join` for the most recent.")
|
|
||||||
return p.SendDM(ctx.Sender, sb.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopStatus(ctx MessageContext) error {
|
|
||||||
run, err := loadCoopRunForUser(ctx.Sender)
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "You're not in a co-op run. Use `!coop list` or `!coop start <tier>`.")
|
|
||||||
}
|
|
||||||
members, _ := loadCoopMembers(run.ID)
|
|
||||||
return p.SendDM(ctx.Sender, renderCoopStatus(p, run, members))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopFund(ctx MessageContext, tierArg string) error {
|
|
||||||
tier := parseCoopFundingTier(tierArg)
|
|
||||||
if tier == "" {
|
|
||||||
return p.SendDM(ctx.Sender, "Funding must be one of: none, minimal, standard, aggressive, all_in.")
|
|
||||||
}
|
|
||||||
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
|
||||||
userMu.Lock()
|
|
||||||
defer userMu.Unlock()
|
|
||||||
|
|
||||||
run, err := loadCoopRunForUser(ctx.Sender)
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
|
|
||||||
}
|
|
||||||
if run.Status != "active" {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — funding decisions don't apply.", run.ID, run.Status))
|
|
||||||
}
|
|
||||||
|
|
||||||
day := run.CurrentDay
|
|
||||||
if day < 1 {
|
|
||||||
return p.SendDM(ctx.Sender, "The run hasn't started yet. Wait for the lock tick.")
|
|
||||||
}
|
|
||||||
|
|
||||||
member, err := loadCoopMember(run.ID, ctx.Sender)
|
|
||||||
if err != nil || member == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "You're not on the party roster for this run.")
|
|
||||||
}
|
|
||||||
if _, alreadySet := member.DailyFunding[day]; alreadySet {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You've already locked in funding for day %d. Decisions are final until the daily tick.", day))
|
|
||||||
}
|
|
||||||
|
|
||||||
cost := coopFundingTable[tier].cost
|
|
||||||
if cost > 0 {
|
|
||||||
balance := p.euro.GetBalance(ctx.Sender)
|
|
||||||
if balance < float64(cost) {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("That tier costs €%d. You have €%.0f.", cost, balance))
|
|
||||||
}
|
|
||||||
if !p.euro.Debit(ctx.Sender, float64(cost), "coop_funding") {
|
|
||||||
return p.SendDM(ctx.Sender, "Payment failed.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
member.DailyFunding[day] = tier
|
|
||||||
member.TotalContributed += cost
|
|
||||||
if err := saveCoopMemberFunding(member); err != nil {
|
|
||||||
// Refund and surface
|
|
||||||
if cost > 0 {
|
|
||||||
p.euro.Credit(ctx.Sender, float64(cost), "coop_funding_refund")
|
|
||||||
}
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't save your decision. Gold refunded if any was charged.")
|
|
||||||
}
|
|
||||||
if cost > 0 {
|
|
||||||
if err := addCoopRunPool(run.ID, cost); err != nil {
|
|
||||||
slog.Error("coop: pool update", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod := coopFundingTable[tier].modifier
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Funding for day %d locked: %s (€%d, %+d%%). Tomorrow's tick resolves the floor.",
|
|
||||||
day, coopFundingTable[tier].label, cost, mod))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopCancel(ctx MessageContext) error {
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
|
||||||
userMu.Lock()
|
|
||||||
defer userMu.Unlock()
|
|
||||||
|
|
||||||
run, err := loadCoopRunForUser(ctx.Sender)
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "No co-op run to cancel.")
|
|
||||||
}
|
|
||||||
if run.LeaderID != ctx.Sender {
|
|
||||||
return p.SendDM(ctx.Sender, "Only the party leader can cancel.")
|
|
||||||
}
|
|
||||||
if run.Status != "open" {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d already %s — too late.", run.ID, run.Status))
|
|
||||||
}
|
|
||||||
if err := setCoopRunStatus(run.ID, "cancelled"); err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't cancel.")
|
|
||||||
}
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" {
|
|
||||||
if run.InvitePostID != "" {
|
|
||||||
_ = p.UnpinEvent(gr, run.InvitePostID)
|
|
||||||
}
|
|
||||||
_ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d cancelled by the leader.", run.Tier, run.ID))
|
|
||||||
}
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d cancelled.", run.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCoopAdmGift is an admin-only debug path that creates a gift on
|
|
||||||
// behalf of the caller (or a specified sender) without the harvest-action
|
|
||||||
// or party-membership checks. Useful for testing the gift mechanic without
|
|
||||||
// recruiting a non-party spectator.
|
|
||||||
//
|
|
||||||
// Usage: !coop admgift <run_id> <basket|mimic> [sender_user_id]
|
|
||||||
func (p *AdventurePlugin) handleCoopAdmGift(ctx MessageContext, args string) error {
|
|
||||||
if !p.IsAdmin(ctx.Sender) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
parts := strings.Fields(args)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return p.SendDM(ctx.Sender, "Usage: `!coop admgift <run_id> <basket|mimic> [sender_user_id]`. Admin-only — bypasses party-member and harvest checks.")
|
|
||||||
}
|
|
||||||
runID, err := strconv.Atoi(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Run ID must be a number.")
|
|
||||||
}
|
|
||||||
giftType := strings.ToLower(parts[1])
|
|
||||||
if giftType != coopGiftBasket && giftType != coopGiftMimic {
|
|
||||||
return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.")
|
|
||||||
}
|
|
||||||
sender := ctx.Sender
|
|
||||||
if len(parts) >= 3 {
|
|
||||||
sender = id.UserID(parts[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
run, err := loadCoopRun(runID)
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Run not found.")
|
|
||||||
}
|
|
||||||
if run.Status != "active" {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — gifts only land during active runs.", runID, run.Status))
|
|
||||||
}
|
|
||||||
|
|
||||||
giftID, leadID, isNewLead, err := createCoopGift(runID, sender, run.CurrentDay, giftType)
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't create gift.")
|
|
||||||
}
|
|
||||||
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" {
|
|
||||||
members, _ := loadCoopMembers(runID)
|
|
||||||
if isNewLead {
|
|
||||||
gift, _ := loadCoopGift(giftID)
|
|
||||||
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
|
||||||
if perr == nil {
|
|
||||||
_ = saveCoopGiftPostID(giftID, postID)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lead, _ := loadCoopGift(leadID)
|
|
||||||
if lead != nil && lead.PostEventID != "" {
|
|
||||||
_ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Admin gift dispatched: %s from %s to run #%d (day %d). Gift id #%d (stack lead %d). No harvest action consumed.",
|
|
||||||
giftType, sender, runID, run.CurrentDay, giftID, leadID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopVote(ctx MessageContext, voteArg string) error {
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
|
||||||
userMu.Lock()
|
|
||||||
defer userMu.Unlock()
|
|
||||||
|
|
||||||
run, err := loadCoopRunForUser(ctx.Sender)
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
|
|
||||||
}
|
|
||||||
if run.Status != "active" {
|
|
||||||
return p.SendDM(ctx.Sender, "No active floor event to vote on.")
|
|
||||||
}
|
|
||||||
event, err := loadCoopEvent(run.ID, run.CurrentDay)
|
|
||||||
if err != nil || event == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "No floor event found for the current day.")
|
|
||||||
}
|
|
||||||
if event.Outcome != "" {
|
|
||||||
return p.SendDM(ctx.Sender, "Voting closed for this floor.")
|
|
||||||
}
|
|
||||||
label, ok := validateCoopVote(event.Category, event.EventIndex, voteArg)
|
|
||||||
if !ok {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Vote one of: %s.",
|
|
||||||
strings.Join(coopEventOptionLabels(event.Category, event.EventIndex), ", ")))
|
|
||||||
}
|
|
||||||
|
|
||||||
if event.Votes == nil {
|
|
||||||
event.Votes = map[id.UserID]string{}
|
|
||||||
}
|
|
||||||
event.Votes[ctx.Sender] = label
|
|
||||||
if err := saveCoopEventVotes(event); err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't save your vote.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the game-room post in place to show the running tally.
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" && event.PostEventID != "" {
|
|
||||||
members, _ := loadCoopMembers(run.ID)
|
|
||||||
_ = p.EditMessage(gr, event.PostEventID, renderCoopEventPost(run, members, event))
|
|
||||||
}
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Vote recorded: %s.", label))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func parseCoopFundingTier(s string) CoopFundingTier {
|
|
||||||
s = strings.ToLower(strings.TrimSpace(s))
|
|
||||||
s = strings.ReplaceAll(s, "-", "_")
|
|
||||||
s = strings.ReplaceAll(s, " ", "_")
|
|
||||||
switch s {
|
|
||||||
case "none", "skip", "pass":
|
|
||||||
return CoopFundNone
|
|
||||||
case "minimal", "min":
|
|
||||||
return CoopFundMinimal
|
|
||||||
case "standard", "std":
|
|
||||||
return CoopFundStandard
|
|
||||||
case "aggressive", "agg":
|
|
||||||
return CoopFundAggressive
|
|
||||||
case "all_in", "allin", "all":
|
|
||||||
return CoopFundAllIn
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// effectiveModifier returns a member's per-day funding modifier, respecting the
|
|
||||||
// liability cap.
|
|
||||||
func coopMemberModifier(m *CoopMember, day int) (CoopFundingTier, int) {
|
|
||||||
tier, ok := m.DailyFunding[day]
|
|
||||||
if !ok {
|
|
||||||
tier = CoopFundNone
|
|
||||||
}
|
|
||||||
mod := coopFundingTable[tier].modifier
|
|
||||||
if m.IsLiability && mod > coopLiabilityCap {
|
|
||||||
mod = coopLiabilityCap
|
|
||||||
}
|
|
||||||
return tier, mod
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopLevelBonus returns the per-floor success bonus from a player's combat
|
|
||||||
// level relative to the tier's minimum. Levels above the floor count up
|
|
||||||
// (capped); levels below count down. Encourages tier-appropriate parties.
|
|
||||||
func coopLevelBonus(combatLevel, tierMinLevel int) int {
|
|
||||||
delta := combatLevel - tierMinLevel
|
|
||||||
bonus := delta * 4 / 10 // 0.4× scale, integer math
|
|
||||||
if bonus > 8 {
|
|
||||||
bonus = 8
|
|
||||||
}
|
|
||||||
if bonus < -8 {
|
|
||||||
bonus = -8
|
|
||||||
}
|
|
||||||
return bonus
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopPetBonus returns the per-floor success bonus from a player's pet. Worth
|
|
||||||
// roughly a quarter of an additional baseline party member at pet level 10.
|
|
||||||
// Returns 0 if the player has no pet.
|
|
||||||
func coopPetBonus(char *AdventureCharacter) int {
|
|
||||||
if !char.HasPet() || char.PetLevel <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
bonus := char.PetLevel * 25 / 100 // 0.25× scale
|
|
||||||
if bonus > 2 {
|
|
||||||
bonus = 2
|
|
||||||
}
|
|
||||||
return bonus
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math/rand/v2"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Monte Carlo balance analysis for co-op dungeons.
|
|
||||||
//
|
|
||||||
// Run with: go test ./internal/plugin -run TestCoopBalanceReport -v
|
|
||||||
// Skipped by default (requires -run filter to invoke); pure-function, no DB.
|
|
||||||
|
|
||||||
const (
|
|
||||||
balanceTrials = 50_000
|
|
||||||
balanceTax = 0.05 // matches coopAdventureRake
|
|
||||||
)
|
|
||||||
|
|
||||||
type fundingPlan struct {
|
|
||||||
name string
|
|
||||||
// per-player tiers; length = party size
|
|
||||||
tiers []CoopFundingTier
|
|
||||||
// optional fixed per-player level bonus (mimics a profile of avg or
|
|
||||||
// veteran party). 0 = at-minimum (default), positive = stronger party.
|
|
||||||
levelBonusEach int
|
|
||||||
petBonusEach int
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopBalanceReport(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("balance report skipped in short mode")
|
|
||||||
}
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
rng := rand.New(rand.NewPCG(42, 42))
|
|
||||||
|
|
||||||
profiles := []struct {
|
|
||||||
label string
|
|
||||||
levelBonus int
|
|
||||||
petBonus int
|
|
||||||
}{
|
|
||||||
{"at-minimum (no pets)", 0, 0},
|
|
||||||
{"average (level+5, pet 5)", 2, 1},
|
|
||||||
{"veteran (level+10, pet 10)", 4, 2},
|
|
||||||
}
|
|
||||||
|
|
||||||
for tier := 1; tier <= 5; tier++ {
|
|
||||||
def := coopTierTable[tier]
|
|
||||||
fmt.Printf("\n══ Tier %d (%s) — %d days, base failure %d%%/floor, reward €%d ══\n",
|
|
||||||
tier, def.difficulty, def.totalDays, def.baseFailurePct, def.rewardBase)
|
|
||||||
|
|
||||||
for _, prof := range profiles {
|
|
||||||
fmt.Printf("\n Party profile: %s\n", prof.label)
|
|
||||||
fmt.Printf(" %-28s %-10s %-12s %-12s %-12s %-12s\n",
|
|
||||||
"strategy", "P(win)", "E[reward]", "E[funding]", "E[net]", "E[net/day]")
|
|
||||||
for _, plan := range balancePlans() {
|
|
||||||
plan.levelBonusEach = prof.levelBonus
|
|
||||||
plan.petBonusEach = prof.petBonus
|
|
||||||
pSuccess, eReward, eCost, eNet := simulatePlan(rng, tier, def, plan)
|
|
||||||
perDay := eNet / float64(def.totalDays)
|
|
||||||
fmt.Printf(" %-28s %-10.3f €%-11.0f €%-11.0f €%-11.0f €%-11.0f\n",
|
|
||||||
plan.name, pSuccess, eReward, eCost, eNet, perDay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Note: E[net] is per-player. Funding is non-refundable. Combat-action opportunity cost not included.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func anyNoneFunder(plan fundingPlan) bool {
|
|
||||||
for _, t := range plan.tiers {
|
|
||||||
if t == CoopFundNone {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// balancePlans returns a representative set of party funding strategies.
|
|
||||||
// Party size = 4 unless name says otherwise.
|
|
||||||
func balancePlans() []fundingPlan {
|
|
||||||
mk := func(n int, t CoopFundingTier) []CoopFundingTier {
|
|
||||||
out := make([]CoopFundingTier, n)
|
|
||||||
for i := range out {
|
|
||||||
out[i] = t
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
return []fundingPlan{
|
|
||||||
{name: "4× Minimal", tiers: mk(4, CoopFundMinimal)},
|
|
||||||
{name: "4× Standard", tiers: mk(4, CoopFundStandard)},
|
|
||||||
{name: "4× Aggressive", tiers: mk(4, CoopFundAggressive)},
|
|
||||||
{name: "4× All-In", tiers: mk(4, CoopFundAllIn)},
|
|
||||||
{name: "4× mixed (2 Std, 2 Agg)", tiers: []CoopFundingTier{CoopFundStandard, CoopFundStandard, CoopFundAggressive, CoopFundAggressive}},
|
|
||||||
{name: "4× sandbag (3 Std, 1 None)", tiers: []CoopFundingTier{CoopFundStandard, CoopFundStandard, CoopFundStandard, CoopFundNone}},
|
|
||||||
{name: "3× Standard", tiers: mk(3, CoopFundStandard)},
|
|
||||||
{name: "3× Aggressive", tiers: mk(3, CoopFundAggressive)},
|
|
||||||
{name: "2× Standard", tiers: mk(2, CoopFundStandard)},
|
|
||||||
{name: "2× Aggressive", tiers: mk(2, CoopFundAggressive)},
|
|
||||||
{name: "2× All-In", tiers: mk(2, CoopFundAllIn)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// simulatePlan runs the trials and returns:
|
|
||||||
//
|
|
||||||
// P(success), E[reward share post-tax], E[funding spent per player], E[net per player]
|
|
||||||
//
|
|
||||||
// Funding is paid every day regardless of wipe. Reward is split evenly across
|
|
||||||
// the party on success only. Per-player figures average the contribution across
|
|
||||||
// the actual party members under the plan.
|
|
||||||
func simulatePlan(rng *rand.Rand, tier int, def coopTierDef, plan fundingPlan) (float64, float64, float64, float64) {
|
|
||||||
partySize := len(plan.tiers)
|
|
||||||
if partySize < coopMinPartySize {
|
|
||||||
return 0, 0, 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute per-floor success% under this plan (deterministic given plan).
|
|
||||||
totalMod := 0
|
|
||||||
for _, t := range plan.tiers {
|
|
||||||
totalMod += coopFundingTable[t].modifier
|
|
||||||
}
|
|
||||||
// Per-player level + pet bonuses applied across the whole party.
|
|
||||||
totalMod += partySize * (plan.levelBonusEach + plan.petBonusEach)
|
|
||||||
successPct := 100 - def.baseFailurePct + totalMod
|
|
||||||
if successPct < 5 {
|
|
||||||
successPct = 5
|
|
||||||
}
|
|
||||||
if successPct > 95 {
|
|
||||||
successPct = 95
|
|
||||||
}
|
|
||||||
|
|
||||||
wins := 0
|
|
||||||
for trial := 0; trial < balanceTrials; trial++ {
|
|
||||||
runWon := true
|
|
||||||
for floor := 0; floor < def.totalDays; floor++ {
|
|
||||||
if rng.IntN(100) >= successPct {
|
|
||||||
runWon = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if runWon {
|
|
||||||
wins++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pWin := float64(wins) / float64(balanceTrials)
|
|
||||||
|
|
||||||
// Average per-player numbers across the party. Funding cost = daily cost × days,
|
|
||||||
// paid regardless of outcome. Reward = share post-tax × P(win).
|
|
||||||
var sumCost, sumReward float64
|
|
||||||
share := float64(def.rewardBase) / float64(partySize)
|
|
||||||
postTaxShare := share * (1 - balanceTax)
|
|
||||||
for _, t := range plan.tiers {
|
|
||||||
dailyCost := float64(coopFundingTable[t].cost)
|
|
||||||
sumCost += dailyCost * float64(def.totalDays)
|
|
||||||
sumReward += postTaxShare * pWin
|
|
||||||
}
|
|
||||||
avgCost := sumCost / float64(partySize)
|
|
||||||
avgReward := sumReward / float64(partySize)
|
|
||||||
avgNet := avgReward - avgCost
|
|
||||||
return pWin, avgReward, avgCost, avgNet
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSoloVsCoopDailyIncome compares expected gold-per-day for a competent
|
|
||||||
// solo dungeon grinder vs a co-op party member at the optimal funding strategy.
|
|
||||||
// Co-op should be meaningfully more rewarding per day than solo.
|
|
||||||
//
|
|
||||||
// Solo model: a "competent" player whose skill matches the location's MinLevel
|
|
||||||
// and gear matches MinEquipTier. Uses the actual loot tables from
|
|
||||||
// adventure_activities.go.
|
|
||||||
func TestSoloVsCoopDailyIncome(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-tier solo expected income (avg item value × items-per-haul, weighted
|
|
||||||
// by outcome probabilities, after 5% community tax, minus a rough death
|
|
||||||
// cost that captures gear repair + hospital).
|
|
||||||
type soloPerTier struct {
|
|
||||||
successPct, exceptionalPct, deathPct float64
|
|
||||||
successHaul, exceptionalHaul float64
|
|
||||||
deathCost float64
|
|
||||||
}
|
|
||||||
// Probabilities computed from calculateAdvProbabilities() with skill =
|
|
||||||
// MinLevel and eqScore matching MinEquipTier (rough but consistent).
|
|
||||||
// Death costs assume hospital insurance + lower blacksmith repair rates.
|
|
||||||
solo := map[int]soloPerTier{
|
|
||||||
1: {0.74, 0.10, 0.01, 11.6, 29.0, 100},
|
|
||||||
2: {0.67, 0.10, 0.08, 63.75, 159.0, 400},
|
|
||||||
3: {0.60, 0.10, 0.18, 337.5, 844.0, 1200},
|
|
||||||
4: {0.55, 0.08, 0.21, 1700.0, 4250.0, 3000},
|
|
||||||
5: {0.61, 0.08, 0.20, 9500.0, 23750.0, 6000},
|
|
||||||
}
|
|
||||||
soloDaily := func(s soloPerTier) float64 {
|
|
||||||
gross := s.successPct*s.successHaul + s.exceptionalPct*s.exceptionalHaul
|
|
||||||
afterTax := gross * (1 - balanceTax)
|
|
||||||
return afterTax - s.deathPct*s.deathCost
|
|
||||||
}
|
|
||||||
|
|
||||||
rng := rand.New(rand.NewPCG(7, 7))
|
|
||||||
|
|
||||||
fmt.Println("\nSolo dungeon vs co-op (€/day per player at optimal funding strategy):")
|
|
||||||
fmt.Printf("%-6s %-14s %-26s %-14s %-10s\n", "tier", "solo €/day", "co-op optimal", "co-op €/day", "ratio")
|
|
||||||
for tier := 1; tier <= 5; tier++ {
|
|
||||||
def := coopTierTable[tier]
|
|
||||||
soloIncome := soloDaily(solo[tier])
|
|
||||||
|
|
||||||
// Find best 4-player plan by E[net]/day, assuming an "average" party
|
|
||||||
// profile (levelBonus=2, petBonus=1 per player). Restricting to
|
|
||||||
// 4-player keeps the comparison apples-to-apples.
|
|
||||||
var bestPlan fundingPlan
|
|
||||||
bestPerDay := -1.0e18
|
|
||||||
for _, plan := range balancePlans() {
|
|
||||||
if len(plan.tiers) != 4 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Skip free-rider plans: an "optimal" that only works because
|
|
||||||
// one member contributes nothing isn't a coordinated strategy.
|
|
||||||
if anyNoneFunder(plan) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
plan.levelBonusEach = 2
|
|
||||||
plan.petBonusEach = 1
|
|
||||||
_, _, _, eNet := simulatePlan(rng, tier, def, plan)
|
|
||||||
perDay := eNet / float64(def.totalDays)
|
|
||||||
if perDay > bestPerDay {
|
|
||||||
bestPerDay = perDay
|
|
||||||
bestPlan = plan
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ratio := bestPerDay / soloIncome
|
|
||||||
flag := "✓"
|
|
||||||
if ratio < 1.5 {
|
|
||||||
flag = "⚠ insufficient"
|
|
||||||
}
|
|
||||||
fmt.Printf("T%-5d €%-13.0f %-26s €%-13.0f %.2fx %s\n",
|
|
||||||
tier, soloIncome, bestPlan.name, bestPerDay, ratio, flag)
|
|
||||||
}
|
|
||||||
fmt.Println("\n⚠ marker = co-op fails the 1.5× solo threshold; bump rewardBase.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCoopBalanceSweep prints, for each tier, the success% needed for E[net]>=0
|
|
||||||
// at common funding levels. Helps spot tiers where the formula is upside-down.
|
|
||||||
func TestCoopBalanceSweep(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip()
|
|
||||||
}
|
|
||||||
fmt.Println("\nBreakeven analysis — minimum P(win) for E[net]≥0 per player at party size 4:")
|
|
||||||
fmt.Printf("%-12s %-12s %-12s %-12s %-12s\n", "tier", "Minimal", "Standard", "Aggressive", "All-In")
|
|
||||||
for tier := 1; tier <= 5; tier++ {
|
|
||||||
def := coopTierTable[tier]
|
|
||||||
share := float64(def.rewardBase) / 4.0 * (1 - balanceTax)
|
|
||||||
row := []string{}
|
|
||||||
for _, ft := range []CoopFundingTier{CoopFundMinimal, CoopFundStandard, CoopFundAggressive, CoopFundAllIn} {
|
|
||||||
cost := float64(coopFundingTable[ft].cost) * float64(def.totalDays)
|
|
||||||
breakeven := cost / share
|
|
||||||
if breakeven > 1 {
|
|
||||||
row = append(row, ">100% (impossible)")
|
|
||||||
} else {
|
|
||||||
row = append(row, fmt.Sprintf("%.1f%%", breakeven*100))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("T%-11d %-12s %-12s %-12s %-12s\n", tier, row[0], row[1], row[2], row[3])
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gogobee/internal/db"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Betting Constants ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const (
|
|
||||||
coopBetMin = 100
|
|
||||||
coopBetMax = 1_000_000
|
|
||||||
coopBetRake = 0.10 // 10% house cut, per spec
|
|
||||||
coopOddsWindow = 30 // helpfulness rolling-window size
|
|
||||||
coopOddsHelpInf = 0.20 // helpfulness contribution to estimated success
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Bet Types ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type CoopBet struct {
|
|
||||||
RunID int
|
|
||||||
PlayerID id.UserID
|
|
||||||
Position string // "success" | "failure"
|
|
||||||
Amount int
|
|
||||||
Payout sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command Handler ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopBet(ctx MessageContext, args string) error {
|
|
||||||
parts := strings.Fields(args)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return p.SendDM(ctx.Sender, "Usage: `!coop bet <run_id> <amount> <success|failure>` or `!coop bet <amount> <success|failure>` for the most recent open/active run.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two forms: with or without explicit run id.
|
|
||||||
var runID, amount int
|
|
||||||
var position string
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if len(parts) >= 3 {
|
|
||||||
runID, err = strconv.Atoi(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "First arg must be the run id.")
|
|
||||||
}
|
|
||||||
amount, err = strconv.Atoi(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Amount must be a number.")
|
|
||||||
}
|
|
||||||
position = strings.ToLower(parts[2])
|
|
||||||
} else {
|
|
||||||
amount, err = strconv.Atoi(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Amount must be a number.")
|
|
||||||
}
|
|
||||||
position = strings.ToLower(parts[1])
|
|
||||||
run, _ := loadMostRecentBettableCoopRun()
|
|
||||||
if run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "No co-op runs accept bets right now.")
|
|
||||||
}
|
|
||||||
runID = run.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if position != "success" && position != "failure" {
|
|
||||||
return p.SendDM(ctx.Sender, "Position must be `success` or `failure`.")
|
|
||||||
}
|
|
||||||
if amount < coopBetMin || amount > coopBetMax {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Bet must be between €%d and €%d.", coopBetMin, coopBetMax))
|
|
||||||
}
|
|
||||||
|
|
||||||
run, err := loadCoopRun(runID)
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Run not found.")
|
|
||||||
}
|
|
||||||
if run.Status != "open" && run.Status != "active" {
|
|
||||||
return p.SendDM(ctx.Sender, "Bets are closed on this run.")
|
|
||||||
}
|
|
||||||
|
|
||||||
balance := p.euro.GetBalance(ctx.Sender)
|
|
||||||
if balance < float64(amount) {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You have €%.0f. Bet was €%d.", balance, amount))
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, _ := loadCoopBet(runID, ctx.Sender)
|
|
||||||
if existing != nil && existing.Position != position {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You already bet %s on this run. Positions can't be switched.", existing.Position))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !p.euro.Debit(ctx.Sender, float64(amount), "coop_bet") {
|
|
||||||
return p.SendDM(ctx.Sender, "Payment failed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing == nil {
|
|
||||||
err = createCoopBet(runID, ctx.Sender, position, amount)
|
|
||||||
} else {
|
|
||||||
err = increaseCoopBet(runID, ctx.Sender, amount)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
p.euro.Credit(ctx.Sender, float64(amount), "coop_bet_refund")
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't save bet. Refunded.")
|
|
||||||
}
|
|
||||||
|
|
||||||
total := amount
|
|
||||||
if existing != nil {
|
|
||||||
total += existing.Amount
|
|
||||||
}
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("📊 Bet placed: €%d on %s (run #%d). Total stake: €%d.",
|
|
||||||
amount, position, runID, total))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Odds Calculation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// coopEstimatedRunSuccess returns the estimated P(success) for the rest of the
|
|
||||||
// run given current party state. Used for spectator odds display.
|
|
||||||
func coopEstimatedRunSuccess(run *CoopRun, members []CoopMember) float64 {
|
|
||||||
if run.Status != "open" && run.Status != "active" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if len(members) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
tierDef := coopTierTable[run.Tier]
|
|
||||||
|
|
||||||
// Per-floor success at "Standard" funding by all members (a neutral
|
|
||||||
// baseline — actual funding varies day to day).
|
|
||||||
mod := len(members) * coopFundingTable[CoopFundStandard].modifier
|
|
||||||
for _, m := range members {
|
|
||||||
char, err := loadAdvCharacter(m.UserID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mod += coopLevelBonus(char.CombatLevel, tierDef.minLevel)
|
|
||||||
mod += coopPetBonus(char)
|
|
||||||
}
|
|
||||||
successPct := 100 - run.BaseDifficulty + mod
|
|
||||||
|
|
||||||
// TwinBee helpfulness shifts the line: a "helpful" history (close to 1)
|
|
||||||
// bumps odds; an "unhelpful" history pulls them down.
|
|
||||||
help := coopTwinBeeHelpfulness(coopOddsWindow)
|
|
||||||
successPct += int(math.Round((help - 0.5) * 2 * coopOddsHelpInf * 100))
|
|
||||||
|
|
||||||
if successPct < 5 {
|
|
||||||
successPct = 5
|
|
||||||
}
|
|
||||||
if successPct > 95 {
|
|
||||||
successPct = 95
|
|
||||||
}
|
|
||||||
|
|
||||||
floorsRemaining := run.TotalDays - run.CurrentDay
|
|
||||||
if floorsRemaining < 0 {
|
|
||||||
floorsRemaining = 0
|
|
||||||
}
|
|
||||||
if run.Status == "open" {
|
|
||||||
floorsRemaining = run.TotalDays
|
|
||||||
}
|
|
||||||
|
|
||||||
return math.Pow(float64(successPct)/100.0, float64(floorsRemaining))
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopParimutuelPayouts returns winning side, losers' side, rake, and
|
|
||||||
// per-player payout map (winning bettors only).
|
|
||||||
func coopParimutuelPayouts(bets []CoopBet, winningPosition string) (winners []CoopBet, totalPool, rake int, payouts map[id.UserID]int) {
|
|
||||||
for _, b := range bets {
|
|
||||||
totalPool += b.Amount
|
|
||||||
}
|
|
||||||
rake = int(math.Floor(float64(totalPool) * coopBetRake))
|
|
||||||
netPool := totalPool - rake
|
|
||||||
|
|
||||||
winningPool := 0
|
|
||||||
for _, b := range bets {
|
|
||||||
if b.Position == winningPosition {
|
|
||||||
winners = append(winners, b)
|
|
||||||
winningPool += b.Amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payouts = map[id.UserID]int{}
|
|
||||||
if winningPool == 0 {
|
|
||||||
// No winners — house keeps everything (this happens if e.g. all bets
|
|
||||||
// were on the wrong side).
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, b := range winners {
|
|
||||||
share := float64(b.Amount) / float64(winningPool)
|
|
||||||
payouts[b.PlayerID] = int(math.Floor(share * float64(netPool)))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DB ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func createCoopBet(runID int, userID id.UserID, position string, amount int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`INSERT INTO coop_dungeon_bets (run_id, player_id, position, amount)
|
|
||||||
VALUES (?, ?, ?, ?)`, runID, string(userID), position, amount)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func increaseCoopBet(runID int, userID id.UserID, delta int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_bets SET amount = amount + ?
|
|
||||||
WHERE run_id = ? AND player_id = ?`, delta, runID, string(userID))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopBet(runID int, userID id.UserID) (*CoopBet, error) {
|
|
||||||
d := db.Get()
|
|
||||||
b := &CoopBet{}
|
|
||||||
var uid string
|
|
||||||
err := d.QueryRow(`SELECT run_id, player_id, position, amount, payout
|
|
||||||
FROM coop_dungeon_bets WHERE run_id = ? AND player_id = ?`, runID, string(userID)).Scan(
|
|
||||||
&b.RunID, &uid, &b.Position, &b.Amount, &b.Payout)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
b.PlayerID = id.UserID(uid)
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopBets(runID int) ([]CoopBet, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT run_id, player_id, position, amount, payout
|
|
||||||
FROM coop_dungeon_bets WHERE run_id = ?`, runID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []CoopBet
|
|
||||||
for rows.Next() {
|
|
||||||
b := CoopBet{}
|
|
||||||
var uid string
|
|
||||||
if err := rows.Scan(&b.RunID, &uid, &b.Position, &b.Amount, &b.Payout); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
b.PlayerID = id.UserID(uid)
|
|
||||||
out = append(out, b)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCoopBetPayout(runID int, userID id.UserID, payout int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ?
|
|
||||||
WHERE run_id = ? AND player_id = ?`, payout, runID, string(userID))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimCoopBetPayout atomically claims a bet for payout: returns true only if
|
|
||||||
// this call wins the race (the row had payout IS NULL and we set it). Used to
|
|
||||||
// prevent double-credit on retry after a mid-resolution crash.
|
|
||||||
func claimCoopBetPayout(runID int, userID id.UserID, payout int) (bool, error) {
|
|
||||||
d := db.Get()
|
|
||||||
res, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ?
|
|
||||||
WHERE run_id = ? AND player_id = ? AND payout IS NULL`,
|
|
||||||
payout, runID, string(userID))
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return n > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadMostRecentBettableCoopRun() (*CoopRun, error) {
|
|
||||||
runs, err := queryCoopRuns(`status IN ('open','active') ORDER BY id DESC LIMIT 1`)
|
|
||||||
if err != nil || len(runs) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return runs[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// coopResolveBets is called when a run completes. It computes payouts,
|
|
||||||
// credits winners, persists payout values, and posts a summary line.
|
|
||||||
func (p *AdventurePlugin) coopResolveBets(run *CoopRun, won bool) string {
|
|
||||||
bets, err := loadCoopBets(run.ID)
|
|
||||||
if err != nil || len(bets) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
winningPosition := "failure"
|
|
||||||
if won {
|
|
||||||
winningPosition = "success"
|
|
||||||
}
|
|
||||||
winners, total, rake, payouts := coopParimutuelPayouts(bets, winningPosition)
|
|
||||||
|
|
||||||
// Idempotent: claim each bet's payout slot (UPDATE...WHERE payout IS NULL)
|
|
||||||
// before crediting. If the claim fails, this bet was already paid by a
|
|
||||||
// prior resolution attempt — skip the credit. Prevents double-pay on
|
|
||||||
// crash-restart.
|
|
||||||
for _, b := range winners {
|
|
||||||
payout := payouts[b.PlayerID]
|
|
||||||
claimed, err := claimCoopBetPayout(run.ID, b.PlayerID, payout)
|
|
||||||
if err != nil || !claimed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if payout > 0 {
|
|
||||||
p.euro.Credit(b.PlayerID, float64(payout), "coop_bet_payout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, b := range bets {
|
|
||||||
if b.Position != winningPosition && !b.Payout.Valid {
|
|
||||||
_ = setCoopBetPayout(run.ID, b.PlayerID, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(winners) == 0 {
|
|
||||||
return fmt.Sprintf("📊 Parimutuel pool: €%d. No winners — house (TwinBee) keeps the pool.", total)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("📊 Parimutuel pool: €%d. Rake: €%d. Net: €%d.\n", total, rake, total-rake))
|
|
||||||
sb.WriteString(fmt.Sprintf("Winners (bet on %s):\n", winningPosition))
|
|
||||||
for _, b := range winners {
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s: €%d → €%d\n", coopDisplayName(p, b.PlayerID), b.Amount, payouts[b.PlayerID]))
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func renderCoopOddsLine(run *CoopRun, members []CoopMember, bets []CoopBet) string {
|
|
||||||
pSuccess := coopEstimatedRunSuccess(run, members)
|
|
||||||
successPool, failurePool := 0, 0
|
|
||||||
for _, b := range bets {
|
|
||||||
if b.Position == "success" {
|
|
||||||
successPool += b.Amount
|
|
||||||
} else {
|
|
||||||
failurePool += b.Amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total := successPool + failurePool
|
|
||||||
return fmt.Sprintf("📊 Estimated success: %.0f%%. Pool €%d (success €%d / failure €%d). Rake %.0f%%. `!coop bet <amount> success|failure`.",
|
|
||||||
pSuccess*100, total, successPool, failurePool, coopBetRake*100)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gogobee/internal/db"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CoopRun struct {
|
|
||||||
ID int
|
|
||||||
Tier int
|
|
||||||
Status string
|
|
||||||
LeaderID id.UserID
|
|
||||||
CurrentDay int
|
|
||||||
TotalDays int
|
|
||||||
BaseDifficulty int
|
|
||||||
GoldPool int
|
|
||||||
RewardTotal int
|
|
||||||
LastResolvedDay int
|
|
||||||
InvitePostID id.EventID
|
|
||||||
CreatedAt time.Time
|
|
||||||
LockedAt *time.Time
|
|
||||||
CompletedAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type CoopMember struct {
|
|
||||||
RunID int
|
|
||||||
UserID id.UserID
|
|
||||||
TurnOrder int
|
|
||||||
TotalContributed int
|
|
||||||
IsLiability bool
|
|
||||||
DailyFunding map[int]CoopFundingTier // day → tier
|
|
||||||
}
|
|
||||||
|
|
||||||
func createCoopRun(tier int, leader id.UserID, totalDays, baseDifficulty int) (*CoopRun, error) {
|
|
||||||
d := db.Get()
|
|
||||||
res, err := d.Exec(`INSERT INTO coop_dungeon_runs (tier, leader_id, total_days, base_difficulty)
|
|
||||||
VALUES (?, ?, ?, ?)`, tier, string(leader), totalDays, baseDifficulty)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
id64, _ := res.LastInsertId()
|
|
||||||
return loadCoopRun(int(id64))
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopRun(runID int) (*CoopRun, error) {
|
|
||||||
d := db.Get()
|
|
||||||
r := &CoopRun{}
|
|
||||||
var leader string
|
|
||||||
var lockedAt, completedAt sql.NullTime
|
|
||||||
err := d.QueryRow(`SELECT id, tier, status, leader_id, current_day, total_days,
|
|
||||||
base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at
|
|
||||||
FROM coop_dungeon_runs WHERE id = ?`, runID).Scan(
|
|
||||||
&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
|
|
||||||
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
|
|
||||||
(*string)(&r.InvitePostID),
|
|
||||||
&r.CreatedAt, &lockedAt, &completedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r.LeaderID = id.UserID(leader)
|
|
||||||
if lockedAt.Valid {
|
|
||||||
t := lockedAt.Time
|
|
||||||
r.LockedAt = &t
|
|
||||||
}
|
|
||||||
if completedAt.Valid {
|
|
||||||
t := completedAt.Time
|
|
||||||
r.CompletedAt = &t
|
|
||||||
}
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOpenCoopRuns() ([]*CoopRun, error) {
|
|
||||||
return queryCoopRuns(`status = 'open' ORDER BY created_at DESC`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadActiveCoopRuns() ([]*CoopRun, error) {
|
|
||||||
return queryCoopRuns(`status = 'active' ORDER BY id`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadMostRecentOpenCoopRun() (*CoopRun, error) {
|
|
||||||
runs, err := queryCoopRuns(`status = 'open' ORDER BY created_at DESC LIMIT 1`)
|
|
||||||
if err != nil || len(runs) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return runs[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryCoopRuns(whereClause string) ([]*CoopRun, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT id, tier, status, leader_id, current_day, total_days,
|
|
||||||
base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at
|
|
||||||
FROM coop_dungeon_runs WHERE ` + whereClause)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []*CoopRun
|
|
||||||
for rows.Next() {
|
|
||||||
r := &CoopRun{}
|
|
||||||
var leader string
|
|
||||||
var lockedAt, completedAt sql.NullTime
|
|
||||||
if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
|
|
||||||
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
|
|
||||||
(*string)(&r.InvitePostID),
|
|
||||||
&r.CreatedAt, &lockedAt, &completedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r.LeaderID = id.UserID(leader)
|
|
||||||
if lockedAt.Valid {
|
|
||||||
t := lockedAt.Time
|
|
||||||
r.LockedAt = &t
|
|
||||||
}
|
|
||||||
if completedAt.Valid {
|
|
||||||
t := completedAt.Time
|
|
||||||
r.CompletedAt = &t
|
|
||||||
}
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopRunForUser(userID id.UserID) (*CoopRun, error) {
|
|
||||||
d := db.Get()
|
|
||||||
var runID int
|
|
||||||
err := d.QueryRow(`SELECT m.run_id FROM coop_dungeon_members m
|
|
||||||
JOIN coop_dungeon_runs r ON r.id = m.run_id
|
|
||||||
WHERE m.user_id = ? AND r.status IN ('open','active')
|
|
||||||
ORDER BY r.id DESC LIMIT 1`, string(userID)).Scan(&runID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return loadCoopRun(runID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCoopRunStatus(runID int, status string) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET status = ? WHERE id = ?`, status, runID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func lockCoopRun(runID int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_runs
|
|
||||||
SET status = 'active', locked_at = CURRENT_TIMESTAMP, current_day = 1
|
|
||||||
WHERE id = ? AND status = 'open'`, runID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func advanceCoopDay(runID, newDay int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET current_day = ? WHERE id = ?`, newDay, runID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func completeCoopRun(runID int, status string, reward int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_runs
|
|
||||||
SET status = ?, reward_total = ?, completed_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?`, status, reward, runID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockCoopCombatActions sets combat_actions_used to a value that exceeds the
|
|
||||||
// holiday cap on every active co-op participant. Called after the midnight
|
|
||||||
// reset (which would otherwise zero them) and at bot startup. Combat stays
|
|
||||||
// locked for the full duration of the run — players in a co-op cannot also
|
|
||||||
// solo-grind dungeons or arena.
|
|
||||||
// unlockCoopCombatActionsForRun clears the 99-sentinel lock on the given run's
|
|
||||||
// members and pins combat_actions_used to the daily cap (= maxCombatActions),
|
|
||||||
// so they can't squeeze in extra combat today but are not blocked into
|
|
||||||
// tomorrow if the midnight reset is delayed. Called when a run ends (wipe or
|
|
||||||
// complete). Idempotent.
|
|
||||||
func unlockCoopCombatActionsForRun(runID int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE adventure_characters
|
|
||||||
SET combat_actions_used = ?
|
|
||||||
WHERE combat_actions_used > ?
|
|
||||||
AND user_id IN (SELECT user_id FROM coop_dungeon_members WHERE run_id = ?)`,
|
|
||||||
maxCombatActions, maxCombatActions, runID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// activeCoopMemberSet returns the set of user IDs currently locked into an
|
|
||||||
// active co-op run. The midnight reset uses this to skip the streak/babysit
|
|
||||||
// logic for those players — their co-op participation auto-resolves daily,
|
|
||||||
// so they should not be treated as idle even though they take no manual
|
|
||||||
// actions.
|
|
||||||
func activeCoopMemberSet() (map[string]bool, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT m.user_id FROM coop_dungeon_members m
|
|
||||||
JOIN coop_dungeon_runs r ON r.id = m.run_id
|
|
||||||
WHERE r.status = 'active'`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
out := map[string]bool{}
|
|
||||||
for rows.Next() {
|
|
||||||
var u string
|
|
||||||
if err := rows.Scan(&u); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out[u] = true
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func lockCoopCombatActions() error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE adventure_characters
|
|
||||||
SET combat_actions_used = 99
|
|
||||||
WHERE user_id IN (
|
|
||||||
SELECT m.user_id FROM coop_dungeon_members m
|
|
||||||
JOIN coop_dungeon_runs r ON r.id = m.run_id
|
|
||||||
WHERE r.status = 'active'
|
|
||||||
)`)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCoopRunInvitePostID(runID int, postID id.EventID) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET invite_post_id = ? WHERE id = ?`, string(postID), runID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCoopRunLastResolvedDay marks a day as resolved for crash-resume idempotency.
|
|
||||||
// Resolution is idempotent: re-entering coopResolveFloor for the same day is a no-op.
|
|
||||||
func setCoopRunLastResolvedDay(runID, day int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET last_resolved_day = ? WHERE id = ?`, day, runID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func addCoopRunPool(runID, delta int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET gold_pool = gold_pool + ? WHERE id = ?`, delta, runID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Members ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func addCoopMember(runID int, userID id.UserID, turnOrder int, liability bool) error {
|
|
||||||
d := db.Get()
|
|
||||||
libVal := 0
|
|
||||||
if liability {
|
|
||||||
libVal = 1
|
|
||||||
}
|
|
||||||
_, err := d.Exec(`INSERT INTO coop_dungeon_members
|
|
||||||
(run_id, user_id, turn_order, is_liability, daily_funding) VALUES (?, ?, ?, ?, '{}')`,
|
|
||||||
runID, string(userID), turnOrder, libVal)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopMembers(runID int) ([]CoopMember, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding
|
|
||||||
FROM coop_dungeon_members WHERE run_id = ? ORDER BY turn_order`, runID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []CoopMember
|
|
||||||
for rows.Next() {
|
|
||||||
m := CoopMember{}
|
|
||||||
var userID, fundingJSON string
|
|
||||||
var liability int
|
|
||||||
if err := rows.Scan(&m.RunID, &userID, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m.UserID = id.UserID(userID)
|
|
||||||
m.IsLiability = liability != 0
|
|
||||||
m.DailyFunding = parseCoopFundingMap(fundingJSON)
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopMember(runID int, userID id.UserID) (*CoopMember, error) {
|
|
||||||
d := db.Get()
|
|
||||||
m := &CoopMember{}
|
|
||||||
var uid, fundingJSON string
|
|
||||||
var liability int
|
|
||||||
err := d.QueryRow(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding
|
|
||||||
FROM coop_dungeon_members WHERE run_id = ? AND user_id = ?`, runID, string(userID)).Scan(
|
|
||||||
&m.RunID, &uid, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m.UserID = id.UserID(uid)
|
|
||||||
m.IsLiability = liability != 0
|
|
||||||
m.DailyFunding = parseCoopFundingMap(fundingJSON)
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimCoopMemberPayout atomically claims a member's reward slot. Returns true
|
|
||||||
// only if this call wins the race (member_payout was NULL and we set it).
|
|
||||||
// Prevents double-credit on crash-retry.
|
|
||||||
func claimCoopMemberPayout(runID int, userID id.UserID, payout int) (bool, error) {
|
|
||||||
d := db.Get()
|
|
||||||
res, err := d.Exec(`UPDATE coop_dungeon_members SET member_payout = ?
|
|
||||||
WHERE run_id = ? AND user_id = ? AND member_payout IS NULL`,
|
|
||||||
payout, runID, string(userID))
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return n > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCoopMemberFunding(m *CoopMember) error {
|
|
||||||
d := db.Get()
|
|
||||||
jsonBytes, err := json.Marshal(serializeCoopFundingMap(m.DailyFunding))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = d.Exec(`UPDATE coop_dungeon_members
|
|
||||||
SET daily_funding = ?, total_contributed = ?
|
|
||||||
WHERE run_id = ? AND user_id = ?`,
|
|
||||||
string(jsonBytes), m.TotalContributed, m.RunID, string(m.UserID))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCoopFundingMap(s string) map[int]CoopFundingTier {
|
|
||||||
out := map[int]CoopFundingTier{}
|
|
||||||
if s == "" || s == "{}" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
raw := map[string]string{}
|
|
||||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
|
||||||
slog.Error("coop: parse funding map", "raw", s, "err", err)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for k, v := range raw {
|
|
||||||
day, err := strconv.Atoi(k)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[day] = CoopFundingTier(v)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func serializeCoopFundingMap(m map[int]CoopFundingTier) map[string]string {
|
|
||||||
out := make(map[string]string, len(m))
|
|
||||||
for k, v := range m {
|
|
||||||
out[fmt.Sprintf("%d", k)] = string(v)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Floor Events ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type CoopEvent struct {
|
|
||||||
RunID int
|
|
||||||
Day int
|
|
||||||
Category CoopEventCategory
|
|
||||||
EventIndex int
|
|
||||||
Recommended string
|
|
||||||
Votes map[id.UserID]string // userID → option label
|
|
||||||
WinningVote string // "" until resolved
|
|
||||||
Outcome string // "" until resolved; "success" or "failure"
|
|
||||||
ModifierApplied int
|
|
||||||
PostEventID id.EventID
|
|
||||||
}
|
|
||||||
|
|
||||||
func createCoopEvent(runID, day int, cat CoopEventCategory, idx int, recommended string) error {
|
|
||||||
d := db.Get()
|
|
||||||
// INSERT OR IGNORE: idempotent on retry. If the row already exists from a
|
|
||||||
// prior tick, leave it untouched (don't re-roll the event index).
|
|
||||||
_, err := d.Exec(`INSERT OR IGNORE INTO coop_dungeon_events
|
|
||||||
(run_id, day, category, event_index, recommended) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
runID, day, string(cat), idx, recommended)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopEvent(runID, day int) (*CoopEvent, error) {
|
|
||||||
d := db.Get()
|
|
||||||
e := &CoopEvent{}
|
|
||||||
var cat, votesJSON string
|
|
||||||
var winning, outcome, postID sql.NullString
|
|
||||||
err := d.QueryRow(`SELECT run_id, day, category, event_index, recommended,
|
|
||||||
votes, winning_vote, outcome, modifier_applied, post_event_id
|
|
||||||
FROM coop_dungeon_events WHERE run_id = ? AND day = ?`, runID, day).Scan(
|
|
||||||
&e.RunID, &e.Day, &cat, &e.EventIndex, &e.Recommended,
|
|
||||||
&votesJSON, &winning, &outcome, &e.ModifierApplied, &postID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
e.Category = CoopEventCategory(cat)
|
|
||||||
e.Votes = parseCoopVotes(votesJSON)
|
|
||||||
if winning.Valid {
|
|
||||||
e.WinningVote = winning.String
|
|
||||||
}
|
|
||||||
if outcome.Valid {
|
|
||||||
e.Outcome = outcome.String
|
|
||||||
}
|
|
||||||
if postID.Valid {
|
|
||||||
e.PostEventID = id.EventID(postID.String)
|
|
||||||
}
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCoopEventVotes(e *CoopEvent) error {
|
|
||||||
d := db.Get()
|
|
||||||
jsonBytes, err := json.Marshal(serializeCoopVotes(e.Votes))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = d.Exec(`UPDATE coop_dungeon_events SET votes = ?
|
|
||||||
WHERE run_id = ? AND day = ?`, string(jsonBytes), e.RunID, e.Day)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCoopEventPostID(runID, day int, eventID id.EventID) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_events SET post_event_id = ?
|
|
||||||
WHERE run_id = ? AND day = ?`, string(eventID), runID, day)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveCoopEvent(runID, day int, winning, outcome string, modifier int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_events
|
|
||||||
SET winning_vote = ?, outcome = ?, modifier_applied = ?
|
|
||||||
WHERE run_id = ? AND day = ?`, winning, outcome, modifier, runID, day)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoopTwinBeeHelpfulness returns a score in [0,1] where 1 means TwinBee's
|
|
||||||
// recommendations have been winning + succeeding (or losing + failing). Used
|
|
||||||
// by phase 3 betting odds.
|
|
||||||
func coopTwinBeeHelpfulness(window int) float64 {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT recommended, winning_vote, outcome
|
|
||||||
FROM coop_dungeon_events
|
|
||||||
WHERE outcome IS NOT NULL
|
|
||||||
ORDER BY rowid DESC LIMIT ?`, window)
|
|
||||||
if err != nil {
|
|
||||||
return 0.5
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
hits, total := 0, 0
|
|
||||||
for rows.Next() {
|
|
||||||
var rec, win, out string
|
|
||||||
if err := rows.Scan(&rec, &win, &out); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
total++
|
|
||||||
if rec == win && out == "success" {
|
|
||||||
hits++
|
|
||||||
} else if rec != win && out == "failure" {
|
|
||||||
hits++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total == 0 {
|
|
||||||
return 0.5
|
|
||||||
}
|
|
||||||
return float64(hits) / float64(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCoopVotes(s string) map[id.UserID]string {
|
|
||||||
out := map[id.UserID]string{}
|
|
||||||
if s == "" || s == "{}" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
raw := map[string]string{}
|
|
||||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
|
||||||
slog.Error("coop: parse votes map", "raw", s, "err", err)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for k, v := range raw {
|
|
||||||
out[id.UserID(k)] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func serializeCoopVotes(m map[id.UserID]string) map[string]string {
|
|
||||||
out := make(map[string]string, len(m))
|
|
||||||
for k, v := range m {
|
|
||||||
out[string(k)] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,913 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"math/rand/v2"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gogobee/internal/db"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Gift Constants ──────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Magnitudes equalized across all four outcomes so neither "always open" nor
|
|
||||||
// "always leave" is variance-dominant at a 50/50 sender mix. Asymmetric
|
|
||||||
// magnitudes (e.g., open=±7, leave=±4) gave leave the same EV with lower
|
|
||||||
// variance, making it a dominant strategy for risk-averse parties — which
|
|
||||||
// collapses the dilemma into "always leave."
|
|
||||||
//
|
|
||||||
// basket opened: +6 basket unopened: -6
|
|
||||||
// mimic opened: -6 mimic unopened: +6
|
|
||||||
//
|
|
||||||
// Both strategies: EV=0, σ=6. The choice becomes "read sender intent" only.
|
|
||||||
const (
|
|
||||||
coopGiftBasketOpened = 6
|
|
||||||
coopGiftBasketUnopened = -6
|
|
||||||
coopGiftMimicOpened = -6
|
|
||||||
coopGiftMimicUnopened = 6
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
coopGiftBasket = "basket"
|
|
||||||
coopGiftMimic = "mimic"
|
|
||||||
|
|
||||||
// Per-gift voting window. After this elapses, the gift's votes are
|
|
||||||
// tallied independently of the daily floor tick. Senders get DM feedback
|
|
||||||
// at expiry; the modifier sits on the run until the next floor resolves.
|
|
||||||
coopGiftWindow = 6 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
type CoopGift struct {
|
|
||||||
ID int
|
|
||||||
RunID int
|
|
||||||
SenderID id.UserID
|
|
||||||
Day int
|
|
||||||
GiftType string
|
|
||||||
Votes map[id.UserID]string // "open" or "leave" — only set on lead
|
|
||||||
VoteResult string // "opened" / "left" / ""
|
|
||||||
Outcome string // "boost" / "reduction" / ""
|
|
||||||
Modifier int
|
|
||||||
PostEventID id.EventID
|
|
||||||
ExpiresAt *time.Time // voting closes — only set on lead
|
|
||||||
AppliedAt *time.Time // modifier merged into a floor roll
|
|
||||||
StackLeadID *int // NULL = lead/standalone; otherwise points at lead's id
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command: send a gift ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error {
|
|
||||||
parts := strings.Fields(args)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return p.SendDM(ctx.Sender, "Usage: `!coop gift <run_id> <basket|mimic>`. Costs one harvest action; party never knows what you sent.")
|
|
||||||
}
|
|
||||||
runID, err := strconv.Atoi(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Run ID must be a number.")
|
|
||||||
}
|
|
||||||
giftType := strings.ToLower(parts[1])
|
|
||||||
if giftType != coopGiftBasket && giftType != coopGiftMimic {
|
|
||||||
return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.")
|
|
||||||
}
|
|
||||||
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
|
||||||
userMu.Lock()
|
|
||||||
defer userMu.Unlock()
|
|
||||||
|
|
||||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
|
||||||
}
|
|
||||||
if !char.Alive {
|
|
||||||
return p.SendDM(ctx.Sender, "You're dead. The post office does not deliver from the ditch.")
|
|
||||||
}
|
|
||||||
if !char.CanDoHarvest(false) {
|
|
||||||
return p.SendDM(ctx.Sender, "You're out of harvest actions today.")
|
|
||||||
}
|
|
||||||
|
|
||||||
run, err := loadCoopRun(runID)
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Run not found.")
|
|
||||||
}
|
|
||||||
if run.Status != "active" {
|
|
||||||
return p.SendDM(ctx.Sender, "Run is not active. Gifts can only be sent during a running co-op.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Senders cannot be party members.
|
|
||||||
if existing, _ := loadCoopMember(runID, ctx.Sender); existing != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "You're in the party. You can't send gifts to yourself.")
|
|
||||||
}
|
|
||||||
|
|
||||||
giftID, leadID, isNewLead, err := createCoopGift(runID, ctx.Sender, run.CurrentDay, giftType)
|
|
||||||
if err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't send the gift.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charge the harvest action and persist.
|
|
||||||
char.HarvestActionsUsed++
|
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
|
||||||
slog.Error("coop: save sender after gift", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post (new lead) or edit (added to existing stack).
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" {
|
|
||||||
members, _ := loadCoopMembers(runID)
|
|
||||||
if isNewLead {
|
|
||||||
gift, _ := loadCoopGift(giftID)
|
|
||||||
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
|
||||||
if perr == nil {
|
|
||||||
_ = saveCoopGiftPostID(giftID, postID)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lead, _ := loadCoopGift(leadID)
|
|
||||||
if lead != nil && lead.PostEventID != "" {
|
|
||||||
_ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Gift dispatched (run #%d, day %d). One harvest action consumed. The party doesn't know what you sent.", runID, run.CurrentDay))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command: vote on a gift ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) error {
|
|
||||||
parts := strings.Fields(args)
|
|
||||||
if len(parts) < 1 {
|
|
||||||
return p.SendDM(ctx.Sender, "Usage: `!coop giftvote <gift_id> <open|leave>` or `!coop giftvote <open|leave>` for the most recent unresolved gift.")
|
|
||||||
}
|
|
||||||
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
|
||||||
userMu.Lock()
|
|
||||||
defer userMu.Unlock()
|
|
||||||
|
|
||||||
run, err := loadCoopRunForUser(ctx.Sender)
|
|
||||||
if err != nil || run == nil || run.Status != "active" {
|
|
||||||
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var gift *CoopGift
|
|
||||||
var choice string
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
giftID, perr := strconv.Atoi(parts[0])
|
|
||||||
if perr != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "First arg must be the gift id, or just `open`/`leave` for the most recent.")
|
|
||||||
}
|
|
||||||
gift, _ = loadCoopGift(giftID)
|
|
||||||
choice = strings.ToLower(parts[1])
|
|
||||||
} else {
|
|
||||||
gift, _ = loadMostRecentUnresolvedGift(run.ID, run.CurrentDay)
|
|
||||||
choice = strings.ToLower(parts[0])
|
|
||||||
}
|
|
||||||
if gift == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "No unresolved gift found.")
|
|
||||||
}
|
|
||||||
// If the user voted on a follower, redirect to its lead — votes always
|
|
||||||
// live on the lead.
|
|
||||||
if gift.StackLeadID != nil {
|
|
||||||
lead, _ := loadCoopGift(*gift.StackLeadID)
|
|
||||||
if lead == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't find the gift's stack lead.")
|
|
||||||
}
|
|
||||||
gift = lead
|
|
||||||
}
|
|
||||||
if gift.RunID != run.ID || gift.Day != run.CurrentDay {
|
|
||||||
return p.SendDM(ctx.Sender, "That gift isn't for your current run/day.")
|
|
||||||
}
|
|
||||||
if gift.VoteResult != "" {
|
|
||||||
return p.SendDM(ctx.Sender, "That gift has already resolved.")
|
|
||||||
}
|
|
||||||
if choice != "open" && choice != "leave" {
|
|
||||||
return p.SendDM(ctx.Sender, "Vote `open` or `leave`.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if gift.Votes == nil {
|
|
||||||
gift.Votes = map[id.UserID]string{}
|
|
||||||
}
|
|
||||||
gift.Votes[ctx.Sender] = choice
|
|
||||||
if err := saveCoopGiftVotes(gift); err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Couldn't save your vote.")
|
|
||||||
}
|
|
||||||
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" && gift.PostEventID != "" {
|
|
||||||
members, _ := loadCoopMembers(run.ID)
|
|
||||||
_ = p.EditMessage(gr, gift.PostEventID, renderCoopGiftPost(p, run, gift, members))
|
|
||||||
}
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Gift #%d vote recorded: %s.", gift.ID, choice))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// resolveSingleGift tallies a stack lead's votes, persists the outcome, DMs
|
|
||||||
// every sender in the stack, and edits the game-room post to show the
|
|
||||||
// resolved state. Used by both the per-minute expiry ticker and the
|
|
||||||
// floor-resolution catchall.
|
|
||||||
//
|
|
||||||
// Followers passed in here will be redirected to their lead. Idempotent:
|
|
||||||
// always re-loads the lead from DB before checking vote_result, so an
|
|
||||||
// in-memory snapshot from earlier in the same loop can't trigger a double
|
|
||||||
// resolve.
|
|
||||||
func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) {
|
|
||||||
// Redirect follower → lead, then re-fetch fresh state.
|
|
||||||
leadID := g.ID
|
|
||||||
if g.StackLeadID != nil {
|
|
||||||
leadID = *g.StackLeadID
|
|
||||||
}
|
|
||||||
fresh, _ := loadCoopGift(leadID)
|
|
||||||
if fresh == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
g = fresh
|
|
||||||
if g.VoteResult != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
opens, leaves := 0, 0
|
|
||||||
var leaderVote string
|
|
||||||
for u, v := range g.Votes {
|
|
||||||
if v == "open" {
|
|
||||||
opens++
|
|
||||||
} else if v == "leave" {
|
|
||||||
leaves++
|
|
||||||
}
|
|
||||||
if u == leader {
|
|
||||||
leaderVote = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var voteResult string
|
|
||||||
switch {
|
|
||||||
case opens > leaves:
|
|
||||||
voteResult = "opened"
|
|
||||||
case leaves > opens:
|
|
||||||
voteResult = "left"
|
|
||||||
case leaderVote == "open":
|
|
||||||
voteResult = "opened"
|
|
||||||
case leaderVote == "leave":
|
|
||||||
voteResult = "left"
|
|
||||||
default:
|
|
||||||
voteResult = "left"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-gift modifier (single ±6 unit). Stack-wide modifier is N × this,
|
|
||||||
// applied at floor resolution by summing the rows individually.
|
|
||||||
var perMod int
|
|
||||||
var outcome string
|
|
||||||
switch g.GiftType {
|
|
||||||
case coopGiftBasket:
|
|
||||||
if voteResult == "opened" {
|
|
||||||
perMod = coopGiftBasketOpened
|
|
||||||
outcome = "boost"
|
|
||||||
} else {
|
|
||||||
perMod = coopGiftBasketUnopened
|
|
||||||
outcome = "reduction"
|
|
||||||
}
|
|
||||||
case coopGiftMimic:
|
|
||||||
if voteResult == "opened" {
|
|
||||||
perMod = coopGiftMimicOpened
|
|
||||||
outcome = "reduction"
|
|
||||||
} else {
|
|
||||||
perMod = coopGiftMimicUnopened
|
|
||||||
outcome = "boost"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, _ := loadCoopGiftStack(g.ID)
|
|
||||||
totalMod := 0
|
|
||||||
for _, m := range stack {
|
|
||||||
_ = resolveCoopGift(m.ID, voteResult, outcome, perMod)
|
|
||||||
_ = p.SendDM(m.SenderID, renderCoopGiftSenderDM(&m, voteResult, perMod))
|
|
||||||
totalMod += perMod
|
|
||||||
}
|
|
||||||
|
|
||||||
g.VoteResult = voteResult
|
|
||||||
g.Outcome = outcome
|
|
||||||
g.Modifier = perMod
|
|
||||||
|
|
||||||
// Edit the live game-room post to show the resolved state — total swing
|
|
||||||
// reflects the full stack.
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" && g.PostEventID != "" {
|
|
||||||
_ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g, len(stack), totalMod))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopProcessGiftExpiries runs each ticker minute, resolving any gifts whose
|
|
||||||
// voting window has elapsed. Modifiers are recorded but not yet applied to a
|
|
||||||
// floor — they wait for the next floor resolution.
|
|
||||||
func (p *AdventurePlugin) coopProcessGiftExpiries() {
|
|
||||||
gifts, err := loadExpiredUnresolvedGifts()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("coop: load expired gifts", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i := range gifts {
|
|
||||||
g := &gifts[i]
|
|
||||||
run, _ := loadCoopRun(g.RunID)
|
|
||||||
if run == nil || run.Status != "active" {
|
|
||||||
// Run gone or already terminal — still mark the gift resolved so
|
|
||||||
// it stops cluttering the expiry query.
|
|
||||||
_ = resolveCoopGift(g.ID, "left", "reduction", 0)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.resolveSingleGift(g, run.LeaderID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopResolveGifts is called inside coopResolveFloor. Two responsibilities:
|
|
||||||
//
|
|
||||||
// 1. Catchall: force-resolve any gifts on this day that still have no
|
|
||||||
// vote_result (the timer ticker should normally have caught them, but
|
|
||||||
// this is the defensive backstop, e.g., bot was down).
|
|
||||||
// 2. Apply: sum modifiers from any resolved-but-not-yet-applied gifts on
|
|
||||||
// this run and atomically claim them via markCoopGiftApplied so the
|
|
||||||
// same modifier is never double-applied across retries.
|
|
||||||
//
|
|
||||||
// Returns total modifier and a summary line for the daily-result post.
|
|
||||||
func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) {
|
|
||||||
// Catchall: any unresolved leads for this day get force-resolved now.
|
|
||||||
// Iterate leads only — resolveSingleGift propagates to followers, so
|
|
||||||
// touching followers here would just be redundant work.
|
|
||||||
dayGifts, _ := loadDayCoopGifts(run.ID, day)
|
|
||||||
for i := range dayGifts {
|
|
||||||
if dayGifts[i].StackLeadID != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if dayGifts[i].VoteResult == "" {
|
|
||||||
p.resolveSingleGift(&dayGifts[i], leader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply: pending resolved gifts (any day, this run) get their modifier
|
|
||||||
// merged into the floor roll.
|
|
||||||
pending, _ := loadResolvedUnappliedGifts(run.ID)
|
|
||||||
totalMod := 0
|
|
||||||
var lines []string
|
|
||||||
for _, g := range pending {
|
|
||||||
claimed, err := markCoopGiftApplied(g.ID)
|
|
||||||
if err != nil || !claimed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalMod += g.Modifier
|
|
||||||
lines = append(lines, fmt.Sprintf(" %s gift from %s — %s → %+d%%",
|
|
||||||
titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), g.VoteResult, g.Modifier))
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return 0, ""
|
|
||||||
}
|
|
||||||
return totalMod, "Gifts:\n" + strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DB ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// findCoopGiftStackLead returns the active lead gift for a (run, day, type)
|
|
||||||
// stack, or nil if no unresolved lead exists. Lead = oldest unresolved gift
|
|
||||||
// of that type with stack_lead_id IS NULL.
|
|
||||||
func findCoopGiftStackLead(runID, day int, giftType string) (*CoopGift, error) {
|
|
||||||
d := db.Get()
|
|
||||||
var leadID int
|
|
||||||
err := d.QueryRow(`SELECT id FROM coop_dungeon_gifts
|
|
||||||
WHERE run_id = ? AND day = ? AND gift_type = ?
|
|
||||||
AND vote_result IS NULL AND stack_lead_id IS NULL
|
|
||||||
ORDER BY id ASC LIMIT 1`, runID, day, giftType).Scan(&leadID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return loadCoopGift(leadID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadCoopGiftStack returns the lead and all followers (in id order) for a
|
|
||||||
// stack identified by lead_id. Used to propagate resolution + render.
|
|
||||||
func loadCoopGiftStack(leadID int) ([]CoopGift, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
|
||||||
FROM coop_dungeon_gifts
|
|
||||||
WHERE id = ? OR stack_lead_id = ?
|
|
||||||
ORDER BY id ASC`, leadID, leadID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []CoopGift
|
|
||||||
for rows.Next() {
|
|
||||||
g := CoopGift{}
|
|
||||||
var sender, votesJSON string
|
|
||||||
var voteResult, outcome, postID sql.NullString
|
|
||||||
var expiresAt, appliedAt sql.NullTime
|
|
||||||
var stackLeadID sql.NullInt64
|
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
|
||||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
g.SenderID = id.UserID(sender)
|
|
||||||
g.Votes = parseCoopVotes(votesJSON)
|
|
||||||
if voteResult.Valid {
|
|
||||||
g.VoteResult = voteResult.String
|
|
||||||
}
|
|
||||||
if outcome.Valid {
|
|
||||||
g.Outcome = outcome.String
|
|
||||||
}
|
|
||||||
if postID.Valid {
|
|
||||||
g.PostEventID = id.EventID(postID.String)
|
|
||||||
}
|
|
||||||
if expiresAt.Valid {
|
|
||||||
t := expiresAt.Time
|
|
||||||
g.ExpiresAt = &t
|
|
||||||
}
|
|
||||||
if appliedAt.Valid {
|
|
||||||
t := appliedAt.Time
|
|
||||||
g.AppliedAt = &t
|
|
||||||
}
|
|
||||||
if stackLeadID.Valid {
|
|
||||||
v := int(stackLeadID.Int64)
|
|
||||||
g.StackLeadID = &v
|
|
||||||
}
|
|
||||||
out = append(out, g)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// createCoopGift inserts a new gift. If an unresolved same-type stack exists
|
|
||||||
// for the run+day, the new gift becomes a follower (no expires_at, no own
|
|
||||||
// post — lead's post is edited to bump the count). Otherwise a new lead is
|
|
||||||
// created with its own 6h expiry.
|
|
||||||
//
|
|
||||||
// Returns (giftID, leadID, isNewLead, error). leadID is the gift's effective
|
|
||||||
// stack lead — either itself (new lead) or the existing lead it joined.
|
|
||||||
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (giftID int, leadID int, isNewLead bool, err error) {
|
|
||||||
d := db.Get()
|
|
||||||
|
|
||||||
existing, lerr := findCoopGiftStackLead(runID, day, giftType)
|
|
||||||
if lerr != nil {
|
|
||||||
return 0, 0, false, lerr
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
// Follower: no expires_at, no post. Inherits lead's deadline.
|
|
||||||
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
|
|
||||||
(run_id, sender_id, day, gift_type, stack_lead_id) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
runID, string(sender), day, giftType, existing.ID)
|
|
||||||
if ierr != nil {
|
|
||||||
return 0, 0, false, ierr
|
|
||||||
}
|
|
||||||
id64, _ := res.LastInsertId()
|
|
||||||
return int(id64), existing.ID, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// New lead: own expiry, will get its own post.
|
|
||||||
expires := time.Now().UTC().Add(coopGiftWindow)
|
|
||||||
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
|
|
||||||
(run_id, sender_id, day, gift_type, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
runID, string(sender), day, giftType, expires)
|
|
||||||
if ierr != nil {
|
|
||||||
return 0, 0, false, ierr
|
|
||||||
}
|
|
||||||
id64, _ := res.LastInsertId()
|
|
||||||
return int(id64), int(id64), true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopGift(giftID int) (*CoopGift, error) {
|
|
||||||
d := db.Get()
|
|
||||||
g := &CoopGift{}
|
|
||||||
var sender, votesJSON string
|
|
||||||
var voteResult, outcome, postID sql.NullString
|
|
||||||
var expiresAt, appliedAt sql.NullTime
|
|
||||||
var stackLeadID sql.NullInt64
|
|
||||||
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
|
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
|
||||||
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
|
|
||||||
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
|
||||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
g.SenderID = id.UserID(sender)
|
|
||||||
g.Votes = parseCoopVotes(votesJSON)
|
|
||||||
if voteResult.Valid {
|
|
||||||
g.VoteResult = voteResult.String
|
|
||||||
}
|
|
||||||
if outcome.Valid {
|
|
||||||
g.Outcome = outcome.String
|
|
||||||
}
|
|
||||||
if postID.Valid {
|
|
||||||
g.PostEventID = id.EventID(postID.String)
|
|
||||||
}
|
|
||||||
if expiresAt.Valid {
|
|
||||||
t := expiresAt.Time
|
|
||||||
g.ExpiresAt = &t
|
|
||||||
}
|
|
||||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
|
||||||
if appliedAt.Valid {
|
|
||||||
t := appliedAt.Time
|
|
||||||
g.AppliedAt = &t
|
|
||||||
}
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
|
||||||
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []CoopGift
|
|
||||||
for rows.Next() {
|
|
||||||
g := CoopGift{}
|
|
||||||
var sender, votesJSON string
|
|
||||||
var voteResult, outcome, postID sql.NullString
|
|
||||||
var expiresAt, appliedAt sql.NullTime
|
|
||||||
var stackLeadID sql.NullInt64
|
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
|
||||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
g.SenderID = id.UserID(sender)
|
|
||||||
g.Votes = parseCoopVotes(votesJSON)
|
|
||||||
if voteResult.Valid {
|
|
||||||
g.VoteResult = voteResult.String
|
|
||||||
}
|
|
||||||
if outcome.Valid {
|
|
||||||
g.Outcome = outcome.String
|
|
||||||
}
|
|
||||||
if postID.Valid {
|
|
||||||
g.PostEventID = id.EventID(postID.String)
|
|
||||||
}
|
|
||||||
if expiresAt.Valid {
|
|
||||||
t := expiresAt.Time
|
|
||||||
g.ExpiresAt = &t
|
|
||||||
}
|
|
||||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
|
||||||
if appliedAt.Valid {
|
|
||||||
t := appliedAt.Time
|
|
||||||
g.AppliedAt = &t
|
|
||||||
}
|
|
||||||
out = append(out, g)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
|
||||||
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []CoopGift
|
|
||||||
for rows.Next() {
|
|
||||||
g := CoopGift{}
|
|
||||||
var sender, votesJSON string
|
|
||||||
var voteResult, outcome, postID sql.NullString
|
|
||||||
var expiresAt, appliedAt sql.NullTime
|
|
||||||
var stackLeadID sql.NullInt64
|
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
|
||||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
g.SenderID = id.UserID(sender)
|
|
||||||
g.Votes = parseCoopVotes(votesJSON)
|
|
||||||
if voteResult.Valid {
|
|
||||||
g.VoteResult = voteResult.String
|
|
||||||
}
|
|
||||||
if outcome.Valid {
|
|
||||||
g.Outcome = outcome.String
|
|
||||||
}
|
|
||||||
if postID.Valid {
|
|
||||||
g.PostEventID = id.EventID(postID.String)
|
|
||||||
}
|
|
||||||
if expiresAt.Valid {
|
|
||||||
t := expiresAt.Time
|
|
||||||
g.ExpiresAt = &t
|
|
||||||
}
|
|
||||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
|
||||||
if appliedAt.Valid {
|
|
||||||
t := appliedAt.Time
|
|
||||||
g.AppliedAt = &t
|
|
||||||
}
|
|
||||||
out = append(out, g)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadMostRecentUnresolvedGift(runID, day int) (*CoopGift, error) {
|
|
||||||
gifts, err := loadDayCoopGifts(runID, day)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for i := len(gifts) - 1; i >= 0; i-- {
|
|
||||||
if gifts[i].VoteResult == "" {
|
|
||||||
g := gifts[i]
|
|
||||||
return &g, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCoopGiftVotes(g *CoopGift) error {
|
|
||||||
d := db.Get()
|
|
||||||
jsonBytes, err := json.Marshal(serializeCoopVotes(g.Votes))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = d.Exec(`UPDATE coop_dungeon_gifts SET votes = ? WHERE id = ?`,
|
|
||||||
string(jsonBytes), g.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCoopGiftPostID(giftID int, eventID id.EventID) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_gifts SET post_event_id = ? WHERE id = ?`,
|
|
||||||
string(eventID), giftID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error {
|
|
||||||
d := db.Get()
|
|
||||||
_, err := d.Exec(`UPDATE coop_dungeon_gifts
|
|
||||||
SET vote_result = ?, outcome = ?, modifier = ?, resolved_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?`, voteResult, outcome, modifier, giftID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadExpiredUnresolvedGifts returns gifts whose voting window has elapsed
|
|
||||||
// but whose votes haven't yet been tallied. Driven by the per-minute
|
|
||||||
// expiry ticker.
|
|
||||||
func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
|
||||||
FROM coop_dungeon_gifts
|
|
||||||
WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP
|
|
||||||
AND stack_lead_id IS NULL
|
|
||||||
ORDER BY id`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []CoopGift
|
|
||||||
for rows.Next() {
|
|
||||||
g := CoopGift{}
|
|
||||||
var sender, votesJSON string
|
|
||||||
var voteResult, outcome, postID sql.NullString
|
|
||||||
var expiresAt, appliedAt sql.NullTime
|
|
||||||
var stackLeadID sql.NullInt64
|
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
|
||||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
g.SenderID = id.UserID(sender)
|
|
||||||
g.Votes = parseCoopVotes(votesJSON)
|
|
||||||
if postID.Valid {
|
|
||||||
g.PostEventID = id.EventID(postID.String)
|
|
||||||
}
|
|
||||||
if expiresAt.Valid {
|
|
||||||
t := expiresAt.Time
|
|
||||||
g.ExpiresAt = &t
|
|
||||||
}
|
|
||||||
if stackLeadID.Valid {
|
|
||||||
v := int(stackLeadID.Int64)
|
|
||||||
g.StackLeadID = &v
|
|
||||||
}
|
|
||||||
out = append(out, g)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadResolvedUnappliedGifts returns gifts that have been tallied but whose
|
|
||||||
// modifiers have not yet been merged into a floor success roll. Used by
|
|
||||||
// floor resolution to sum and apply pending gift modifiers.
|
|
||||||
func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
|
||||||
d := db.Get()
|
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
|
||||||
FROM coop_dungeon_gifts
|
|
||||||
WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL
|
|
||||||
ORDER BY id`, runID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []CoopGift
|
|
||||||
for rows.Next() {
|
|
||||||
g := CoopGift{}
|
|
||||||
var sender, votesJSON string
|
|
||||||
var voteResult, outcome, postID sql.NullString
|
|
||||||
var expiresAt, appliedAt sql.NullTime
|
|
||||||
var stackLeadID sql.NullInt64
|
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
|
||||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
g.SenderID = id.UserID(sender)
|
|
||||||
g.Votes = parseCoopVotes(votesJSON)
|
|
||||||
if voteResult.Valid {
|
|
||||||
g.VoteResult = voteResult.String
|
|
||||||
}
|
|
||||||
if outcome.Valid {
|
|
||||||
g.Outcome = outcome.String
|
|
||||||
}
|
|
||||||
if stackLeadID.Valid {
|
|
||||||
v := int(stackLeadID.Int64)
|
|
||||||
g.StackLeadID = &v
|
|
||||||
}
|
|
||||||
out = append(out, g)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// markCoopGiftApplied claims a gift's modifier as merged into a floor roll.
|
|
||||||
// Idempotent — only the first call sets applied_at; subsequent calls return
|
|
||||||
// false (claimed=false) so the same modifier is never double-applied.
|
|
||||||
func markCoopGiftApplied(giftID int) (bool, error) {
|
|
||||||
d := db.Get()
|
|
||||||
res, err := d.Exec(`UPDATE coop_dungeon_gifts SET applied_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ? AND applied_at IS NULL`, giftID)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return n > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// renderCoopGiftPost is called only for stack leads. Stack count is derived
|
|
||||||
// from how many followers point at this lead. Flavor is picked deterministically
|
|
||||||
// by lead id so re-renders don't rotate.
|
|
||||||
func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, members []CoopMember) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
// Stack size = lead + followers
|
|
||||||
stack, _ := loadCoopGiftStack(gift.ID)
|
|
||||||
stackSize := len(stack)
|
|
||||||
if stackSize == 0 {
|
|
||||||
stackSize = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tally on the lead's votes
|
|
||||||
opens, leaves := 0, 0
|
|
||||||
for _, v := range gift.Votes {
|
|
||||||
if v == "open" {
|
|
||||||
opens++
|
|
||||||
} else if v == "leave" {
|
|
||||||
leaves++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
leaderName := coopDisplayName(p, run.LeaderID)
|
|
||||||
|
|
||||||
header := fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**", gift.ID, run.ID, gift.Day)
|
|
||||||
if stackSize > 1 {
|
|
||||||
header = fmt.Sprintf("📦 **Gift Stack #%d (×%d) — Co-op #%d, Day %d**", gift.ID, stackSize, run.ID, gift.Day)
|
|
||||||
}
|
|
||||||
sb.WriteString(header)
|
|
||||||
sb.WriteString("\n\n")
|
|
||||||
|
|
||||||
// Pick flavor deterministically so edits don't rotate the joke.
|
|
||||||
if len(TwinBeeGiftArrival) > 0 {
|
|
||||||
flavor := TwinBeeGiftArrival[gift.ID%len(TwinBeeGiftArrival)]
|
|
||||||
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(opens), 1)
|
|
||||||
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(leaves), 1)
|
|
||||||
flavor = strings.ReplaceAll(flavor, "{leader}", leaderName)
|
|
||||||
sb.WriteString(flavor)
|
|
||||||
sb.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// "REALLY special" line, picked once per stack lead, shown for size 2+.
|
|
||||||
if stackSize >= 2 && len(TwinBeeGiftStackEscalation) > 0 {
|
|
||||||
line := TwinBeeGiftStackEscalation[gift.ID%len(TwinBeeGiftStackEscalation)]
|
|
||||||
sb.WriteString("_")
|
|
||||||
sb.WriteString(line)
|
|
||||||
sb.WriteString("_\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
closesIn := ""
|
|
||||||
if gift.ExpiresAt != nil {
|
|
||||||
remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute)
|
|
||||||
if remaining > 0 {
|
|
||||||
closesIn = fmt.Sprintf(" · voting closes in %s", remaining)
|
|
||||||
} else {
|
|
||||||
closesIn = " · voting closing"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stackSize > 1 {
|
|
||||||
sb.WriteString(fmt.Sprintf("One vote covers the whole stack of %d. ", stackSize))
|
|
||||||
}
|
|
||||||
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".")
|
|
||||||
|
|
||||||
awaiting := 0
|
|
||||||
for _, m := range members {
|
|
||||||
if _, ok := gift.Votes[m.UserID]; !ok {
|
|
||||||
awaiting++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if awaiting > 0 && awaiting < len(members) {
|
|
||||||
sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", awaiting, len(members)))
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderCoopGiftResolvedPost replaces the live vote post when a gift's
|
|
||||||
// voting window closes. Total modifier = stackSize × per-gift mod.
|
|
||||||
func renderCoopGiftResolvedPost(g *CoopGift, stackSize, totalMod int) string {
|
|
||||||
verb := "left"
|
|
||||||
if g.VoteResult == "opened" {
|
|
||||||
verb = "opened"
|
|
||||||
}
|
|
||||||
if stackSize > 1 {
|
|
||||||
return fmt.Sprintf("📦 **Gift Stack #%d (×%d) resolved** — %s, %s → **%+d%%** to next floor.",
|
|
||||||
g.ID, stackSize, titleCaseWord(g.GiftType), verb, totalMod)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("📦 **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.",
|
|
||||||
g.ID, titleCaseWord(g.GiftType), verb, totalMod)
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string {
|
|
||||||
pickFrom := func(pool []string) string {
|
|
||||||
if len(pool) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return pool[rand.IntN(len(pool))]
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case g.GiftType == coopGiftBasket && voteResult == "opened":
|
|
||||||
return fmt.Sprintf("📦 Your care basket was opened. %s (%+d%% to today's floor)", pickFrom(TwinBeeGiftBasketOpened), mod)
|
|
||||||
case g.GiftType == coopGiftBasket && voteResult == "left":
|
|
||||||
return fmt.Sprintf("📦 Your care basket was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftBasketUnopened), mod)
|
|
||||||
case g.GiftType == coopGiftMimic && voteResult == "opened":
|
|
||||||
return fmt.Sprintf("📦 Your mimic was opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicOpened), mod)
|
|
||||||
case g.GiftType == coopGiftMimic && voteResult == "left":
|
|
||||||
return fmt.Sprintf("📦 Your mimic was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicUnopened), mod)
|
|
||||||
}
|
|
||||||
return "📦 Your gift resolved."
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderCoopGiftLog returns the public end-of-run gift log, grouped by stack.
|
|
||||||
// One line per stack with all senders listed and the total swing.
|
|
||||||
func renderCoopGiftLog(p *AdventurePlugin, runID int) string {
|
|
||||||
gifts, err := loadAllCoopGifts(runID)
|
|
||||||
if err != nil || len(gifts) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// Group gifts by their effective lead id.
|
|
||||||
type groupKey struct {
|
|
||||||
day int
|
|
||||||
giftType string
|
|
||||||
leadID int
|
|
||||||
}
|
|
||||||
groups := map[groupKey][]CoopGift{}
|
|
||||||
order := []groupKey{}
|
|
||||||
for _, g := range gifts {
|
|
||||||
leadID := g.ID
|
|
||||||
if g.StackLeadID != nil {
|
|
||||||
leadID = *g.StackLeadID
|
|
||||||
}
|
|
||||||
k := groupKey{day: g.Day, giftType: g.GiftType, leadID: leadID}
|
|
||||||
if _, seen := groups[k]; !seen {
|
|
||||||
order = append(order, k)
|
|
||||||
}
|
|
||||||
groups[k] = append(groups[k], g)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("📦 Gift Log:\n")
|
|
||||||
for _, k := range order {
|
|
||||||
stack := groups[k]
|
|
||||||
// Senders for the line — ordered by id for stable display.
|
|
||||||
names := make([]string, 0, len(stack))
|
|
||||||
totalMod := 0
|
|
||||||
voteResult := stack[0].VoteResult
|
|
||||||
for _, g := range stack {
|
|
||||||
names = append(names, coopDisplayName(p, g.SenderID))
|
|
||||||
totalMod += g.Modifier
|
|
||||||
}
|
|
||||||
result := voteResult
|
|
||||||
if result == "" {
|
|
||||||
result = "unresolved"
|
|
||||||
}
|
|
||||||
typeLabel := titleCaseWord(k.giftType)
|
|
||||||
if len(stack) > 1 {
|
|
||||||
typeLabel = fmt.Sprintf("%s ×%d", typeLabel, len(stack))
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" Day %d: %s → %s → %s → %+d%%\n",
|
|
||||||
k.day, strings.Join(names, " + "), typeLabel, result, totalMod))
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math/rand/v2"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
func coopDisplayName(p *AdventurePlugin, userID id.UserID) string {
|
|
||||||
char, err := loadAdvCharacter(userID)
|
|
||||||
if err == nil && char.DisplayName != "" {
|
|
||||||
return char.DisplayName
|
|
||||||
}
|
|
||||||
if p != nil {
|
|
||||||
return p.DisplayName(userID)
|
|
||||||
}
|
|
||||||
return string(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCoopInvite(run *CoopRun, members []CoopMember, leaderName string) string {
|
|
||||||
def := coopTierTable[run.Tier]
|
|
||||||
closes := run.CreatedAt.Add(coopInviteWindow).Format("Mon 15:04 UTC")
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"⚔️ %s is opening a Co-op Dungeon — **Tier %d (%s)**, run #%d.\n"+
|
|
||||||
"Days: %d. Reward pool base: €%d (split among the party).\n"+
|
|
||||||
"Party: %d/%d. Type `!coop join %d` to enter.\n"+
|
|
||||||
"Locks at %s. Newbies are a liability — you knew that going in.",
|
|
||||||
leaderName, run.Tier, def.difficulty, run.ID,
|
|
||||||
def.totalDays, def.rewardBase,
|
|
||||||
len(members), coopMaxPartySize, run.ID, closes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCoopLock(run *CoopRun, members []CoopMember, p *AdventurePlugin) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
def := coopTierTable[run.Tier]
|
|
||||||
sb.WriteString(fmt.Sprintf("⚔️ Tier %d Co-op #%d is locked.\n", run.Tier, run.ID))
|
|
||||||
sb.WriteString(fmt.Sprintf("Difficulty: %s. Days: %d. Reward pool: €%d.\n\nParty (turn order):\n",
|
|
||||||
def.difficulty, run.TotalDays, def.rewardBase))
|
|
||||||
for _, m := range members {
|
|
||||||
warn := ""
|
|
||||||
if m.IsLiability {
|
|
||||||
warn = " ⚠️ liability"
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" %d. %s%s\n", m.TurnOrder+1, coopDisplayName(p, m.UserID), warn))
|
|
||||||
}
|
|
||||||
sb.WriteString("\nDay 1 begins now. Each member has until the next daily tick to fund. Inactive = None (-10%).")
|
|
||||||
bets, _ := loadCoopBets(run.ID)
|
|
||||||
sb.WriteString("\n\n")
|
|
||||||
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCoopFundingPrompt(run *CoopRun, day int) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Day %d/%d. Pick your funding tier with `!coop fund <tier>`.\n\n",
|
|
||||||
run.ID, day, run.TotalDays))
|
|
||||||
for _, t := range coopFundingOrder {
|
|
||||||
def := coopFundingTable[t]
|
|
||||||
sb.WriteString(fmt.Sprintf(" • `%s` — €%d, %+d%%\n", t, def.cost, def.modifier))
|
|
||||||
}
|
|
||||||
sb.WriteString("\nNo decision before the next daily tick = auto-played as None (-10%). Funding is non-refundable.")
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCoopDailyResult(p *AdventurePlugin, run *CoopRun, members []CoopMember, day int,
|
|
||||||
choices map[string]CoopFundingTier, successPct int, won bool, autoPlayed []*CoopMember,
|
|
||||||
event *CoopEvent, winning string, eventMod int) string {
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
def := coopTierTable[run.Tier]
|
|
||||||
header := "✅ Floor cleared"
|
|
||||||
if !won {
|
|
||||||
header = "💥 Wipe"
|
|
||||||
}
|
|
||||||
if won && day >= run.TotalDays {
|
|
||||||
header = "🏆 Final floor cleared"
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s), Day %d/%d\n",
|
|
||||||
run.ID, run.Tier, def.difficulty, day, run.TotalDays))
|
|
||||||
sb.WriteString(fmt.Sprintf("Success chance: %d%%. %s.\n", successPct, header))
|
|
||||||
|
|
||||||
if event != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("Floor event: %s. Party voted **%s** (%+d%%).\n",
|
|
||||||
titleCaseWord(string(event.Category)), winning, eventMod))
|
|
||||||
}
|
|
||||||
sb.WriteString("\nFunding:\n")
|
|
||||||
|
|
||||||
for _, m := range members {
|
|
||||||
t := choices[string(m.UserID)]
|
|
||||||
fdef := coopFundingTable[t]
|
|
||||||
auto := ""
|
|
||||||
for _, ap := range autoPlayed {
|
|
||||||
if ap.UserID == m.UserID {
|
|
||||||
auto = " ← auto-played"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s: %s (%+d%%)%s\n",
|
|
||||||
coopDisplayName(p, m.UserID), fdef.label, fdef.modifier, auto))
|
|
||||||
}
|
|
||||||
|
|
||||||
if event != nil {
|
|
||||||
sb.WriteString("\n_TwinBee:_ ")
|
|
||||||
sb.WriteString(coopTwinBeeReaction(event, winning, won))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !won {
|
|
||||||
sb.WriteString("\n\nThe dungeon does not editorialize. Combat actions for today are refunded if any. Funding is gone.")
|
|
||||||
} else if day < run.TotalDays {
|
|
||||||
sb.WriteString(fmt.Sprintf("\n\nNext floor unlocks at the next daily tick. %d day(s) remain.", run.TotalDays-day))
|
|
||||||
bets, _ := loadCoopBets(run.ID)
|
|
||||||
sb.WriteString("\n")
|
|
||||||
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopTwinBeeReaction picks an outcome line based on whether his recommendation
|
|
||||||
// matched the winning vote and whether the floor succeeded.
|
|
||||||
func coopTwinBeeReaction(event *CoopEvent, winning string, won bool) string {
|
|
||||||
pickFrom := func(pool []string) string {
|
|
||||||
if len(pool) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return pool[rand.IntN(len(pool))]
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case event.Recommended == winning && won:
|
|
||||||
return pickFrom(TwinBeeOutcomeCorrect)
|
|
||||||
case event.Recommended == winning && !won:
|
|
||||||
return pickFrom(TwinBeeOutcomeWrong)
|
|
||||||
case event.Recommended != winning && won:
|
|
||||||
return pickFrom(TwinBeeOutcomeNotRecommendedGood)
|
|
||||||
default:
|
|
||||||
return pickFrom(TwinBeeOutcomeNotRecommendedBad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderCoopEventPost is the game-room post for a floor event vote prompt.
|
|
||||||
// Edited in place as votes come in.
|
|
||||||
func renderCoopEventPost(run *CoopRun, members []CoopMember, event *CoopEvent) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
flavorPool := coopEventCategoryFlavor(event.Category)
|
|
||||||
flavor := ""
|
|
||||||
if event.EventIndex < len(flavorPool) {
|
|
||||||
flavor = flavorPool[event.EventIndex]
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("🗺️ **Co-op #%d — Day %d/%d, %s event**\n\n",
|
|
||||||
run.ID, event.Day, run.TotalDays, titleCaseWord(string(event.Category))))
|
|
||||||
sb.WriteString(flavor)
|
|
||||||
sb.WriteString("\n\nVote with `!coop vote A` (or B/C). 24h or until next tick. Ties go to the leader.\n\n")
|
|
||||||
|
|
||||||
tally := map[string]int{}
|
|
||||||
for _, v := range event.Votes {
|
|
||||||
tally[v]++
|
|
||||||
}
|
|
||||||
parts := []string{}
|
|
||||||
for _, label := range coopEventOptionLabels(event.Category, event.EventIndex) {
|
|
||||||
parts = append(parts, fmt.Sprintf("%s (%d)", label, tally[label]))
|
|
||||||
}
|
|
||||||
sb.WriteString("Current votes: ")
|
|
||||||
sb.WriteString(strings.Join(parts, " · "))
|
|
||||||
|
|
||||||
abstained := []string{}
|
|
||||||
for _, m := range members {
|
|
||||||
if _, voted := event.Votes[m.UserID]; !voted {
|
|
||||||
abstained = append(abstained, string(m.UserID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(abstained) > 0 && len(abstained) < len(members) {
|
|
||||||
sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", len(abstained), len(members)))
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func titleCaseWord(s string) string {
|
|
||||||
if s == "" {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return strings.ToUpper(s[:1]) + s[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
def := coopTierTable[run.Tier]
|
|
||||||
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s)\n", run.ID, run.Tier, def.difficulty))
|
|
||||||
sb.WriteString(fmt.Sprintf("Status: %s. Day %d/%d. Pool: €%d.\n\nParty:\n",
|
|
||||||
run.Status, run.CurrentDay, run.TotalDays, run.GoldPool))
|
|
||||||
|
|
||||||
for _, m := range members {
|
|
||||||
warn := ""
|
|
||||||
if m.IsLiability {
|
|
||||||
warn = " ⚠️"
|
|
||||||
}
|
|
||||||
todayLabel := "—"
|
|
||||||
if run.CurrentDay >= 1 {
|
|
||||||
if t, ok := m.DailyFunding[run.CurrentDay]; ok {
|
|
||||||
todayLabel = coopFundingTable[t].label
|
|
||||||
} else {
|
|
||||||
todayLabel = "(unset)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" %d. %s%s — today: %s, contributed: €%d\n",
|
|
||||||
m.TurnOrder+1, coopDisplayName(p, m.UserID), warn, todayLabel, m.TotalContributed))
|
|
||||||
}
|
|
||||||
if run.Status == "open" {
|
|
||||||
closes := run.CreatedAt.Add(coopInviteWindow)
|
|
||||||
sb.WriteString(fmt.Sprintf("\nLocks in: %s.", time.Until(closes).Truncate(time.Minute)))
|
|
||||||
}
|
|
||||||
if run.Status == "open" || run.Status == "active" {
|
|
||||||
bets, _ := loadCoopBets(run.ID)
|
|
||||||
sb.WriteString("\n")
|
|
||||||
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending stacks with per-stack countdowns. Each game-room post is one
|
|
||||||
// stack (lead + N followers); we show the lead's id and the stack size.
|
|
||||||
if run.Status == "active" {
|
|
||||||
all, _ := loadAllCoopGifts(run.ID)
|
|
||||||
// Group unresolved gifts by lead. Followers contribute to the size
|
|
||||||
// but use the lead's expiry/votes.
|
|
||||||
type stackInfo struct {
|
|
||||||
lead CoopGift
|
|
||||||
size int
|
|
||||||
}
|
|
||||||
stacks := map[int]*stackInfo{}
|
|
||||||
order := []int{}
|
|
||||||
for _, g := range all {
|
|
||||||
if g.VoteResult != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
leadID := g.ID
|
|
||||||
if g.StackLeadID != nil {
|
|
||||||
leadID = *g.StackLeadID
|
|
||||||
}
|
|
||||||
s, ok := stacks[leadID]
|
|
||||||
if !ok {
|
|
||||||
s = &stackInfo{}
|
|
||||||
stacks[leadID] = s
|
|
||||||
order = append(order, leadID)
|
|
||||||
}
|
|
||||||
if g.StackLeadID == nil {
|
|
||||||
s.lead = g
|
|
||||||
}
|
|
||||||
s.size++
|
|
||||||
}
|
|
||||||
if len(stacks) > 0 {
|
|
||||||
sb.WriteString("\n\nPending gifts:\n")
|
|
||||||
for _, leadID := range order {
|
|
||||||
s := stacks[leadID]
|
|
||||||
opens, leaves := 0, 0
|
|
||||||
for _, v := range s.lead.Votes {
|
|
||||||
if v == "open" {
|
|
||||||
opens++
|
|
||||||
} else if v == "leave" {
|
|
||||||
leaves++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
countdown := "—"
|
|
||||||
if s.lead.ExpiresAt != nil {
|
|
||||||
remaining := time.Until(*s.lead.ExpiresAt).Truncate(time.Minute)
|
|
||||||
if remaining > 0 {
|
|
||||||
countdown = remaining.String()
|
|
||||||
} else {
|
|
||||||
countdown = "closing"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stackLabel := fmt.Sprintf("#%d", s.lead.ID)
|
|
||||||
if s.size > 1 {
|
|
||||||
stackLabel = fmt.Sprintf("#%d ×%d", s.lead.ID, s.size)
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s (day %d) — open %d / leave %d — closes in %s\n",
|
|
||||||
stackLabel, s.lead.Day, opens, leaves, countdown))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
@@ -1,565 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"math/rand/v2"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gogobee/internal/db"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
// coopTicker has two cadences:
|
|
||||||
//
|
|
||||||
// 1. Lock checks fire every minute. They're cheap (one timestamp comparison
|
|
||||||
// per open run) and a 24h invite window is poorly served by a 24h tick —
|
|
||||||
// a run started after 08:00 UTC would otherwise wait 30-48h to lock.
|
|
||||||
// 2. Floor resolution fires once per UTC day at morningHour, gated by the
|
|
||||||
// daily_prefetch JobCompleted guard. Resolution is the heavy step that
|
|
||||||
// advances days, distributes rewards, and posts to the room.
|
|
||||||
func (p *AdventurePlugin) coopTicker() {
|
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-p.stopCh:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
// Locks: every minute.
|
|
||||||
p.coopProcessLocks()
|
|
||||||
|
|
||||||
// Gift expiries: every minute. Resolves any gift whose individual
|
|
||||||
// 6h voting window has elapsed. Modifier sits on the run until the
|
|
||||||
// next floor resolution merges it.
|
|
||||||
p.coopProcessGiftExpiries()
|
|
||||||
|
|
||||||
// Resolutions: once per day at morningHour:00 UTC.
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dateKey := now.Format("2006-01-02")
|
|
||||||
jobName := "coop_dungeon_daily"
|
|
||||||
if db.JobCompleted(jobName, dateKey) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Info("coop: daily tick — resolving active runs")
|
|
||||||
p.coopProcessActiveRuns()
|
|
||||||
db.MarkJobCompleted(jobName, dateKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lock Phase ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// coopProcessLocks closes invites whose 24h window has elapsed: locks parties
|
|
||||||
// of 2+, cancels solo runs.
|
|
||||||
func (p *AdventurePlugin) coopProcessLocks() {
|
|
||||||
runs, err := loadOpenCoopRuns()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("coop: load open runs", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
for _, run := range runs {
|
|
||||||
if now.Before(run.CreatedAt.Add(coopInviteWindow)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
members, err := loadCoopMembers(run.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("coop: load members for lock", "run", run.ID, "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(members) < coopMinPartySize {
|
|
||||||
// Cancel solo runs and refund nothing (no funding has been spent yet).
|
|
||||||
_ = setCoopRunStatus(run.ID, "cancelled")
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" {
|
|
||||||
if run.InvitePostID != "" {
|
|
||||||
_ = p.UnpinEvent(gr, run.InvitePostID)
|
|
||||||
}
|
|
||||||
_ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d expired with no party. Cancelled.", run.Tier, run.ID))
|
|
||||||
}
|
|
||||||
_ = p.SendDM(run.LeaderID, fmt.Sprintf("Co-op #%d expired before anyone joined. No party, no run.", run.ID))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock: deduct combat action from each member, mark active, post lock notice.
|
|
||||||
if err := lockCoopRun(run.ID); err != nil {
|
|
||||||
slog.Error("coop: lock run", "run", run.ID, "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, m := range members {
|
|
||||||
char, err := loadAdvCharacter(m.UserID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if char.CanDoCombat(false) {
|
|
||||||
char.CombatActionsUsed++
|
|
||||||
char.ActionTakenToday = true
|
|
||||||
char.LastActionDate = now.Format("2006-01-02")
|
|
||||||
_ = saveAdvCharacter(char)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fresh, _ := loadCoopRun(run.ID)
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr != "" {
|
|
||||||
if run.InvitePostID != "" {
|
|
||||||
_ = p.UnpinEvent(gr, run.InvitePostID)
|
|
||||||
}
|
|
||||||
_ = p.SendMessage(gr, renderCoopLock(fresh, members, p))
|
|
||||||
}
|
|
||||||
// Per-player DM with funding instructions.
|
|
||||||
for _, m := range members {
|
|
||||||
_ = p.SendDM(m.UserID, renderCoopFundingPrompt(fresh, 1))
|
|
||||||
}
|
|
||||||
// Generate and post day-1 floor event.
|
|
||||||
p.coopGenerateAndPostEvent(fresh, members, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopGenerateAndPostEvent picks a category-weighted event for the given day,
|
|
||||||
// stores it, and posts the vote prompt to the game room with TwinBee narration.
|
|
||||||
func (p *AdventurePlugin) coopGenerateAndPostEvent(run *CoopRun, members []CoopMember, day int) {
|
|
||||||
cat := pickCoopEventCategory(run.Tier)
|
|
||||||
idx := pickCoopEvent(cat)
|
|
||||||
rec := coopEventRecommended(cat, idx)
|
|
||||||
if err := createCoopEvent(run.ID, day, cat, idx, rec); err != nil {
|
|
||||||
slog.Error("coop: create event", "run", run.ID, "day", day, "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gr := gamesRoom()
|
|
||||||
if gr == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event, _ := loadCoopEvent(run.ID, day)
|
|
||||||
if event == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
postID, err := p.SendMessageID(gr, renderCoopEventPost(run, members, event))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("coop: post event", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = saveCoopEventPostID(run.ID, day, postID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Daily Resolution ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) coopProcessActiveRuns() {
|
|
||||||
runs, err := loadActiveCoopRuns()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("coop: load active runs", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
for _, run := range runs {
|
|
||||||
// Need at least a 12h funding window between lock and the first
|
|
||||||
// floor resolution. Without this, a run locked at 07:00 UTC would
|
|
||||||
// resolve an hour later at 08:00 UTC. With per-minute lock checks
|
|
||||||
// (locks now fire any time), the previous "same UTC date" guard
|
|
||||||
// was too conservative — it pushed first-day windows out to 24-32h
|
|
||||||
// even when 13-23h would have been plenty.
|
|
||||||
if run.LockedAt != nil && now.Sub(run.LockedAt.UTC()) < 12*time.Hour {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := p.coopResolveFloor(run); err != nil {
|
|
||||||
slog.Error("coop: resolve floor", "run", run.ID, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopResolveFloor processes the current day's floor: auto-plays missing
|
|
||||||
// funders, rolls outcome, advances or completes the run.
|
|
||||||
func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error {
|
|
||||||
day := run.CurrentDay
|
|
||||||
|
|
||||||
// Skip only if this run is fully terminal. A "resolved" day with the run
|
|
||||||
// still in 'active' status means a prior tick crashed mid-distribution;
|
|
||||||
// we must re-enter to finish via idempotent operations (per-row claims).
|
|
||||||
if run.LastResolvedDay >= day && run.Status != "active" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
members, err := loadCoopMembers(run.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock all party members in stable UserID order so concurrent !coop fund /
|
|
||||||
// vote / giftvote commands don't race the resolver. Stable order rules
|
|
||||||
// out lock-order deadlock with other goroutines that lock multiple users.
|
|
||||||
sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID })
|
|
||||||
mutexes := make([]*sync.Mutex, 0, len(members))
|
|
||||||
for _, m := range members {
|
|
||||||
mu := p.advUserLock(m.UserID)
|
|
||||||
mu.Lock()
|
|
||||||
mutexes = append(mutexes, mu)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
for _, mu := range mutexes {
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Reload the run after acquiring locks. A concurrent admin action or
|
|
||||||
// another scheduler tick may have moved the state.
|
|
||||||
freshRun, err := loadCoopRun(run.ID)
|
|
||||||
if err != nil || freshRun == nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
run = freshRun
|
|
||||||
if run.Status != "active" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Re-check skip after lock acquisition (another tick could have already
|
|
||||||
// fully finalized — though our per-tick guard makes that unlikely).
|
|
||||||
if run.LastResolvedDay >= day && run.Status != "active" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Reload members under lock for fresh funding state.
|
|
||||||
members, err = loadCoopMembers(run.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID })
|
|
||||||
|
|
||||||
// Resume vs. fresh detection: if the day's event already has an outcome,
|
|
||||||
// a prior tick rolled this floor before crashing. Reuse that outcome —
|
|
||||||
// re-rolling would be the worst sin (different result on retry).
|
|
||||||
event, _ := loadCoopEvent(run.ID, day)
|
|
||||||
resuming := event != nil && event.Outcome != ""
|
|
||||||
|
|
||||||
autoPlayed := []*CoopMember{}
|
|
||||||
choices := make(map[string]CoopFundingTier, len(members))
|
|
||||||
var floorWon bool
|
|
||||||
var winning string
|
|
||||||
var eventMod, successPct int
|
|
||||||
var giftSummary string
|
|
||||||
|
|
||||||
if resuming {
|
|
||||||
floorWon = event.Outcome == "success"
|
|
||||||
winning = event.WinningVote
|
|
||||||
eventMod = event.ModifierApplied
|
|
||||||
// Reconstruct choices for display purposes.
|
|
||||||
for _, m := range members {
|
|
||||||
tier, _ := coopMemberModifier(&m, day)
|
|
||||||
choices[string(m.UserID)] = tier
|
|
||||||
}
|
|
||||||
successPct = 0 // unknown on resume; render hides it
|
|
||||||
} else {
|
|
||||||
// Auto-play any member who didn't fund.
|
|
||||||
for i := range members {
|
|
||||||
m := &members[i]
|
|
||||||
if _, ok := m.DailyFunding[day]; !ok {
|
|
||||||
m.DailyFunding[day] = CoopFundNone
|
|
||||||
if err := saveCoopMemberFunding(m); err != nil {
|
|
||||||
slog.Error("coop: auto-play save", "run", run.ID, "user", m.UserID, "err", err)
|
|
||||||
}
|
|
||||||
autoPlayed = append(autoPlayed, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalMod := 0
|
|
||||||
tierDef := coopTierTable[run.Tier]
|
|
||||||
for _, m := range members {
|
|
||||||
tier, mod := coopMemberModifier(&m, day)
|
|
||||||
choices[string(m.UserID)] = tier
|
|
||||||
totalMod += mod
|
|
||||||
char, err := loadAdvCharacter(m.UserID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalMod += coopLevelBonus(char.CombatLevel, tierDef.minLevel)
|
|
||||||
totalMod += coopPetBonus(char)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazy-create the day's event if missing — covers the crash window
|
|
||||||
// between lock and the original event creation.
|
|
||||||
if event == nil {
|
|
||||||
cat := pickCoopEventCategory(run.Tier)
|
|
||||||
idx := pickCoopEvent(cat)
|
|
||||||
_ = createCoopEvent(run.ID, day, cat, idx, coopEventRecommended(cat, idx))
|
|
||||||
event, _ = loadCoopEvent(run.ID, day)
|
|
||||||
}
|
|
||||||
|
|
||||||
if event != nil {
|
|
||||||
winning = coopTallyVote(event, run.LeaderID)
|
|
||||||
eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning)
|
|
||||||
totalMod += eventMod
|
|
||||||
}
|
|
||||||
|
|
||||||
var giftMod int
|
|
||||||
giftMod, giftSummary = p.coopResolveGifts(run, run.LeaderID, day)
|
|
||||||
totalMod += giftMod
|
|
||||||
|
|
||||||
successPct = 100 - run.BaseDifficulty + totalMod
|
|
||||||
if successPct < 5 {
|
|
||||||
successPct = 5
|
|
||||||
}
|
|
||||||
if successPct > 95 {
|
|
||||||
successPct = 95
|
|
||||||
}
|
|
||||||
|
|
||||||
roll := rand.IntN(100)
|
|
||||||
floorWon = roll < successPct
|
|
||||||
|
|
||||||
// Persist event resolution — turns this into a "resume" path on retry.
|
|
||||||
if event != nil {
|
|
||||||
outcomeStr := "failure"
|
|
||||||
if floorWon {
|
|
||||||
outcomeStr = "success"
|
|
||||||
}
|
|
||||||
_ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod)
|
|
||||||
}
|
|
||||||
|
|
||||||
dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod)
|
|
||||||
if giftSummary != "" {
|
|
||||||
dailyPost += "\n\n" + giftSummary
|
|
||||||
}
|
|
||||||
if gr := gamesRoom(); gr != "" {
|
|
||||||
_ = p.SendMessage(gr, dailyPost)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gr := gamesRoom()
|
|
||||||
if !floorWon {
|
|
||||||
// Resolve bets first (idempotent via claimCoopBetPayout), then mark
|
|
||||||
// the run wiped. Order matters: if status is set first and the bot
|
|
||||||
// crashes, the next tick won't pick this run up.
|
|
||||||
summary := p.coopResolveBets(run, false)
|
|
||||||
giftLog := renderCoopGiftLog(p, run.ID)
|
|
||||||
parts := []string{}
|
|
||||||
if summary != "" {
|
|
||||||
parts = append(parts, summary)
|
|
||||||
}
|
|
||||||
if giftLog != "" {
|
|
||||||
parts = append(parts, giftLog)
|
|
||||||
}
|
|
||||||
if len(parts) > 0 && gr != "" {
|
|
||||||
_ = p.SendMessage(gr, strings.Join(parts, "\n\n"))
|
|
||||||
}
|
|
||||||
_ = completeCoopRun(run.ID, "wiped", 0)
|
|
||||||
_ = unlockCoopCombatActionsForRun(run.ID)
|
|
||||||
_ = setCoopRunLastResolvedDay(run.ID, day)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if day >= run.TotalDays {
|
|
||||||
def := coopTierTable[run.Tier]
|
|
||||||
reward := def.rewardBase
|
|
||||||
p.coopDistributeReward(run, reward, members)
|
|
||||||
_ = completeCoopRun(run.ID, "complete", reward)
|
|
||||||
_ = unlockCoopCombatActionsForRun(run.ID)
|
|
||||||
_ = setCoopRunLastResolvedDay(run.ID, day)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := advanceCoopDay(run.ID, day+1); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, m := range members {
|
|
||||||
_ = p.SendDM(m.UserID, renderCoopFundingPrompt(run, day+1))
|
|
||||||
}
|
|
||||||
freshRun, _ = loadCoopRun(run.ID)
|
|
||||||
if freshRun != nil {
|
|
||||||
p.coopGenerateAndPostEvent(freshRun, members, day+1)
|
|
||||||
}
|
|
||||||
_ = setCoopRunLastResolvedDay(run.ID, day)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopTallyVote counts votes; majority wins. Ties resolve to the leader's
|
|
||||||
// vote if it's among the tied options; otherwise the lexicographically first
|
|
||||||
// tied label (deterministic, since Go map iteration is randomized).
|
|
||||||
func coopTallyVote(event *CoopEvent, leader id.UserID) string {
|
|
||||||
tally := map[string]int{}
|
|
||||||
for _, v := range event.Votes {
|
|
||||||
tally[v]++
|
|
||||||
}
|
|
||||||
bestCount := -1
|
|
||||||
var winners []string
|
|
||||||
for label, n := range tally {
|
|
||||||
if n > bestCount {
|
|
||||||
bestCount = n
|
|
||||||
winners = []string{label}
|
|
||||||
} else if n == bestCount {
|
|
||||||
winners = append(winners, label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(winners) == 1 {
|
|
||||||
return winners[0]
|
|
||||||
}
|
|
||||||
sort.Strings(winners)
|
|
||||||
if leaderVote, ok := event.Votes[leader]; ok {
|
|
||||||
for _, w := range winners {
|
|
||||||
if w == leaderVote {
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(winners) > 0 {
|
|
||||||
return winners[0]
|
|
||||||
}
|
|
||||||
return "A"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) coopDistributeReward(run *CoopRun, totalReward int, members []CoopMember) {
|
|
||||||
if len(members) == 0 || totalReward <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
share := totalReward / len(members)
|
|
||||||
gr := gamesRoom()
|
|
||||||
var lines []string
|
|
||||||
for _, m := range members {
|
|
||||||
// Atomic claim: only credit if this is the first time we're paying
|
|
||||||
// this member for this run. Skip silently on retry.
|
|
||||||
claimed, err := claimCoopMemberPayout(run.ID, m.UserID, share)
|
|
||||||
if err != nil || !claimed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
net, _ := communityTax(m.UserID, float64(share), coopAdventureRake)
|
|
||||||
p.euro.Credit(m.UserID, net, "coop_dungeon_reward")
|
|
||||||
lines = append(lines, fmt.Sprintf(" %s: €%.0f (after tax)", coopDisplayName(p, m.UserID), net))
|
|
||||||
_ = p.SendDM(m.UserID, fmt.Sprintf("⚔️ Co-op #%d cleared. Your share: €%.0f.", run.ID, net))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate item drops and distribute via weighted roll.
|
|
||||||
itemSummary := p.coopDistributeItems(run, members)
|
|
||||||
|
|
||||||
bettingSummary := p.coopResolveBets(run, true)
|
|
||||||
giftLog := renderCoopGiftLog(p, run.ID)
|
|
||||||
if gr != "" {
|
|
||||||
msg := fmt.Sprintf("⚔️ Co-op #%d cleared. Reward pool: €%d.\n\n%s",
|
|
||||||
run.ID, totalReward, strings.Join(lines, "\n"))
|
|
||||||
if itemSummary != "" {
|
|
||||||
msg += "\n\n" + itemSummary
|
|
||||||
}
|
|
||||||
if bettingSummary != "" {
|
|
||||||
msg += "\n\n" + bettingSummary
|
|
||||||
}
|
|
||||||
if giftLog != "" {
|
|
||||||
msg += "\n\n" + giftLog
|
|
||||||
}
|
|
||||||
_ = p.SendMessage(gr, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopDistributeItems generates item drops for a successful run and assigns
|
|
||||||
// each via weighted roll across party members (weight = total_contributed).
|
|
||||||
// Items are added to the winner's inventory; returns a summary line.
|
|
||||||
func (p *AdventurePlugin) coopDistributeItems(run *CoopRun, members []CoopMember) string {
|
|
||||||
loc := findAdvLocationByTier(AdvActivityDungeon, run.Tier)
|
|
||||||
if loc == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// Item count scales with tier: T1 = 2, T5 = 6.
|
|
||||||
count := 2 + (run.Tier - 1)
|
|
||||||
var allItems []AdvItem
|
|
||||||
for i := 0; i < count; i++ {
|
|
||||||
allItems = append(allItems, generateAdvLoot(loc, false, 0)...)
|
|
||||||
}
|
|
||||||
if len(allItems) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
totalWeight := 0
|
|
||||||
for _, m := range members {
|
|
||||||
totalWeight += m.TotalContributed
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
for _, item := range allItems {
|
|
||||||
winner := coopWeightedItemWinner(members, totalWeight)
|
|
||||||
_ = addAdvInventoryItem(winner, item)
|
|
||||||
lines = append(lines, fmt.Sprintf(" %s (€%d) → %s", item.Name, item.Value, coopDisplayName(p, winner)))
|
|
||||||
}
|
|
||||||
if mwLine := p.coopMaybeMasterwork(run, members, totalWeight); mwLine != "" {
|
|
||||||
lines = append(lines, mwLine)
|
|
||||||
}
|
|
||||||
return "Item drops:\n" + strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopMaybeMasterwork grants a tier-appropriate masterwork drop on T4 (25%)
|
|
||||||
// or T5 (guaranteed). Picks a random masterwork def at the run's tier from
|
|
||||||
// the existing pool (mining sword / fishing armor / foraging boots), awards
|
|
||||||
// it via the same weighted roll, and announces in the game room.
|
|
||||||
//
|
|
||||||
// Returns a one-line summary to splice into the completion post, or "".
|
|
||||||
func (p *AdventurePlugin) coopMaybeMasterwork(run *CoopRun, members []CoopMember, totalWeight int) string {
|
|
||||||
if run.Tier < 4 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if run.Tier == 4 && rand.Float64() >= 0.25 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all masterwork defs at the run's tier.
|
|
||||||
candidates := []MasterworkDef{}
|
|
||||||
for _, def := range masterworkDefs {
|
|
||||||
if def.Tier == run.Tier {
|
|
||||||
candidates = append(candidates, def)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
def := candidates[rand.IntN(len(candidates))]
|
|
||||||
winner := coopWeightedItemWinner(members, totalWeight)
|
|
||||||
if winner == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to inventory as MasterworkGear (don't auto-equip — let the winner
|
|
||||||
// choose via !adventure equip; their existing gear may be better).
|
|
||||||
item := AdvItem{
|
|
||||||
Name: def.Name,
|
|
||||||
Type: "MasterworkGear",
|
|
||||||
Tier: def.Tier,
|
|
||||||
Value: 0,
|
|
||||||
Slot: def.Slot,
|
|
||||||
SkillSource: def.SkillSource,
|
|
||||||
}
|
|
||||||
if err := addAdvInventoryItem(winner, item); err != nil {
|
|
||||||
slog.Error("coop: masterwork inventory add", "winner", winner, "err", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
char, _ := loadAdvCharacter(winner)
|
|
||||||
if char != nil {
|
|
||||||
char.MasterworkDropsReceived++
|
|
||||||
_ = saveAdvCharacter(char)
|
|
||||||
}
|
|
||||||
|
|
||||||
dmText := fmt.Sprintf("⭐ **Co-op Masterwork Drop: %s** (T%d %s)\n\n_%s_\n\nMasterwork %s — 1.25x effectiveness, +5%% %s success.\n\nAdded to inventory. `!adventure equip` to use it.",
|
|
||||||
def.Name, def.Tier, slotTitle(def.Slot), def.Description, slotTitle(def.Slot), def.SkillSource)
|
|
||||||
_ = p.SendDM(winner, dmText)
|
|
||||||
|
|
||||||
return fmt.Sprintf("⭐ Masterwork: %s (T%d %s) → %s",
|
|
||||||
def.Name, def.Tier, slotTitle(def.Slot), coopDisplayName(p, winner))
|
|
||||||
}
|
|
||||||
|
|
||||||
// coopWeightedItemWinner picks a member with probability proportional to their
|
|
||||||
// total funding contribution. If no one has contributed (e.g., all None on
|
|
||||||
// every day), falls back to uniform random.
|
|
||||||
func coopWeightedItemWinner(members []CoopMember, totalWeight int) id.UserID {
|
|
||||||
if len(members) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if totalWeight <= 0 {
|
|
||||||
return members[rand.IntN(len(members))].UserID
|
|
||||||
}
|
|
||||||
r := rand.IntN(totalWeight)
|
|
||||||
cum := 0
|
|
||||||
for _, m := range members {
|
|
||||||
cum += m.TotalContributed
|
|
||||||
if r < cum {
|
|
||||||
return m.UserID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return members[len(members)-1].UserID
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gogobee/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Stats aggregation ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type coopTierStats struct {
|
|
||||||
Tier int
|
|
||||||
Open int
|
|
||||||
Active int
|
|
||||||
Complete int
|
|
||||||
Wiped int
|
|
||||||
Cancelled int
|
|
||||||
AvgPartySize float64
|
|
||||||
TotalReward int64 // Σ reward_total of completed runs
|
|
||||||
TotalForfeited int64 // Σ gold_pool of wiped runs (lost to the dungeon)
|
|
||||||
}
|
|
||||||
|
|
||||||
type coopBetStats struct {
|
|
||||||
BetsPlaced int
|
|
||||||
TotalStake int64
|
|
||||||
TotalPaid int64 // sum of non-NULL payouts (winners)
|
|
||||||
BettorsLT int // distinct bettors lifetime
|
|
||||||
}
|
|
||||||
|
|
||||||
type coopGiftStats struct {
|
|
||||||
BasketSent int
|
|
||||||
MimicSent int
|
|
||||||
BasketOpened int
|
|
||||||
BasketLeft int
|
|
||||||
MimicOpened int
|
|
||||||
MimicLeft int
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopTierStats() ([]coopTierStats, error) {
|
|
||||||
d := db.Get()
|
|
||||||
stats := map[int]*coopTierStats{}
|
|
||||||
for t := 1; t <= 5; t++ {
|
|
||||||
stats[t] = &coopTierStats{Tier: t}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run outcomes by tier
|
|
||||||
rows, err := d.Query(`SELECT tier, status, COUNT(*) FROM coop_dungeon_runs
|
|
||||||
GROUP BY tier, status`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var tier int
|
|
||||||
var status string
|
|
||||||
var n int
|
|
||||||
if err := rows.Scan(&tier, &status, &n); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s, ok := stats[tier]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch status {
|
|
||||||
case "open":
|
|
||||||
s.Open = n
|
|
||||||
case "active":
|
|
||||||
s.Active = n
|
|
||||||
case "complete":
|
|
||||||
s.Complete = n
|
|
||||||
case "wiped":
|
|
||||||
s.Wiped = n
|
|
||||||
case "cancelled":
|
|
||||||
s.Cancelled = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avg party size by tier (over locked runs only — open invites with one
|
|
||||||
// member would bias the number).
|
|
||||||
rows2, err := d.Query(`SELECT r.tier, AVG(m.cnt) FROM coop_dungeon_runs r
|
|
||||||
JOIN (SELECT run_id, COUNT(*) cnt FROM coop_dungeon_members GROUP BY run_id) m
|
|
||||||
ON m.run_id = r.id
|
|
||||||
WHERE r.status IN ('active','complete','wiped')
|
|
||||||
GROUP BY r.tier`)
|
|
||||||
if err == nil {
|
|
||||||
defer rows2.Close()
|
|
||||||
for rows2.Next() {
|
|
||||||
var tier int
|
|
||||||
var avg sql.NullFloat64
|
|
||||||
if err := rows2.Scan(&tier, &avg); err == nil && avg.Valid {
|
|
||||||
if s, ok := stats[tier]; ok {
|
|
||||||
s.AvgPartySize = avg.Float64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reward / forfeit totals per tier
|
|
||||||
rows3, err := d.Query(`SELECT tier, status, SUM(reward_total), SUM(gold_pool)
|
|
||||||
FROM coop_dungeon_runs WHERE status IN ('complete','wiped')
|
|
||||||
GROUP BY tier, status`)
|
|
||||||
if err == nil {
|
|
||||||
defer rows3.Close()
|
|
||||||
for rows3.Next() {
|
|
||||||
var tier int
|
|
||||||
var status string
|
|
||||||
var reward, pool sql.NullInt64
|
|
||||||
if err := rows3.Scan(&tier, &status, &reward, &pool); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s, ok := stats[tier]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if status == "complete" && reward.Valid {
|
|
||||||
s.TotalReward += reward.Int64
|
|
||||||
}
|
|
||||||
if status == "wiped" && pool.Valid {
|
|
||||||
s.TotalForfeited += pool.Int64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]coopTierStats, 0, 5)
|
|
||||||
for t := 1; t <= 5; t++ {
|
|
||||||
out = append(out, *stats[t])
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopBetStats() (coopBetStats, error) {
|
|
||||||
d := db.Get()
|
|
||||||
var s coopBetStats
|
|
||||||
var stake, paid sql.NullInt64
|
|
||||||
err := d.QueryRow(`SELECT COUNT(*), COALESCE(SUM(amount),0),
|
|
||||||
COALESCE(SUM(CASE WHEN payout > 0 THEN payout ELSE 0 END), 0)
|
|
||||||
FROM coop_dungeon_bets`).Scan(&s.BetsPlaced, &stake, &paid)
|
|
||||||
if err != nil {
|
|
||||||
return s, err
|
|
||||||
}
|
|
||||||
s.TotalStake = stake.Int64
|
|
||||||
s.TotalPaid = paid.Int64
|
|
||||||
_ = d.QueryRow(`SELECT COUNT(DISTINCT player_id) FROM coop_dungeon_bets`).Scan(&s.BettorsLT)
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCoopGiftStats() (coopGiftStats, error) {
|
|
||||||
d := db.Get()
|
|
||||||
var s coopGiftStats
|
|
||||||
rows, err := d.Query(`SELECT gift_type, COALESCE(vote_result, 'pending'), COUNT(*)
|
|
||||||
FROM coop_dungeon_gifts GROUP BY gift_type, vote_result`)
|
|
||||||
if err != nil {
|
|
||||||
return s, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var giftType, voteResult string
|
|
||||||
var n int
|
|
||||||
if err := rows.Scan(&giftType, &voteResult, &n); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch giftType {
|
|
||||||
case coopGiftBasket:
|
|
||||||
s.BasketSent += n
|
|
||||||
if voteResult == "opened" {
|
|
||||||
s.BasketOpened = n
|
|
||||||
} else if voteResult == "left" {
|
|
||||||
s.BasketLeft = n
|
|
||||||
}
|
|
||||||
case coopGiftMimic:
|
|
||||||
s.MimicSent += n
|
|
||||||
if voteResult == "opened" {
|
|
||||||
s.MimicOpened = n
|
|
||||||
} else if voteResult == "left" {
|
|
||||||
s.MimicLeft = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func renderCoopStats() string {
|
|
||||||
tiers, _ := loadCoopTierStats()
|
|
||||||
bets, _ := loadCoopBetStats()
|
|
||||||
gifts, _ := loadCoopGiftStats()
|
|
||||||
help30 := coopTwinBeeHelpfulness(30)
|
|
||||||
helpAll := coopTwinBeeHelpfulness(10000) // effectively lifetime
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("⚔️ **Co-op Dungeon Stats**\n\n")
|
|
||||||
|
|
||||||
sb.WriteString("**Runs by tier**\n")
|
|
||||||
sb.WriteString("```\n")
|
|
||||||
sb.WriteString(fmt.Sprintf("%-6s %-6s %-6s %-6s %-6s %-6s %-7s %-12s\n",
|
|
||||||
"tier", "open", "active", "won", "wiped", "canc", "win%", "avg party"))
|
|
||||||
for _, t := range tiers {
|
|
||||||
finished := t.Complete + t.Wiped
|
|
||||||
winPct := 0.0
|
|
||||||
if finished > 0 {
|
|
||||||
winPct = float64(t.Complete) / float64(finished) * 100
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("T%-5d %-6d %-6d %-6d %-6d %-6d %-6.1f%% %-12.2f\n",
|
|
||||||
t.Tier, t.Open, t.Active, t.Complete, t.Wiped, t.Cancelled, winPct, t.AvgPartySize))
|
|
||||||
}
|
|
||||||
sb.WriteString("```\n\n")
|
|
||||||
|
|
||||||
sb.WriteString("**Gold flow**\n")
|
|
||||||
var totalReward, totalForfeit int64
|
|
||||||
for _, t := range tiers {
|
|
||||||
totalReward += t.TotalReward
|
|
||||||
totalForfeit += t.TotalForfeited
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" Rewards distributed: €%d\n", totalReward))
|
|
||||||
sb.WriteString(fmt.Sprintf(" Funding forfeited (wipes): €%d\n\n", totalForfeit))
|
|
||||||
|
|
||||||
sb.WriteString("**Spectator betting**\n")
|
|
||||||
rake := int64(float64(bets.TotalStake) * coopBetRake)
|
|
||||||
sb.WriteString(fmt.Sprintf(" Bets placed: %d (by %d distinct bettors)\n", bets.BetsPlaced, bets.BettorsLT))
|
|
||||||
sb.WriteString(fmt.Sprintf(" Total wagered: €%d\n", bets.TotalStake))
|
|
||||||
sb.WriteString(fmt.Sprintf(" Paid to winners: €%d\n", bets.TotalPaid))
|
|
||||||
sb.WriteString(fmt.Sprintf(" House cut (TwinBee): ~€%d\n\n", rake))
|
|
||||||
|
|
||||||
sb.WriteString("**Gifts**\n")
|
|
||||||
openRate := func(opened, total int) float64 {
|
|
||||||
if total == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return float64(opened) / float64(total) * 100
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" Baskets sent: %d (opened %d, left %d, %.0f%% open rate)\n",
|
|
||||||
gifts.BasketSent, gifts.BasketOpened, gifts.BasketLeft,
|
|
||||||
openRate(gifts.BasketOpened, gifts.BasketOpened+gifts.BasketLeft)))
|
|
||||||
sb.WriteString(fmt.Sprintf(" Mimics sent: %d (opened %d, left %d, %.0f%% open rate)\n\n",
|
|
||||||
gifts.MimicSent, gifts.MimicOpened, gifts.MimicLeft,
|
|
||||||
openRate(gifts.MimicOpened, gifts.MimicOpened+gifts.MimicLeft)))
|
|
||||||
|
|
||||||
sb.WriteString("**TwinBee helpfulness**\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" Last 30 events: %.0f%%\n", help30*100))
|
|
||||||
sb.WriteString(fmt.Sprintf(" Lifetime: %.0f%%\n", helpAll*100))
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseCoopFundingTier(t *testing.T) {
|
|
||||||
cases := map[string]CoopFundingTier{
|
|
||||||
"none": CoopFundNone,
|
|
||||||
"skip": CoopFundNone,
|
|
||||||
"min": CoopFundMinimal,
|
|
||||||
"minimal": CoopFundMinimal,
|
|
||||||
"standard": CoopFundStandard,
|
|
||||||
"std": CoopFundStandard,
|
|
||||||
"aggressive": CoopFundAggressive,
|
|
||||||
"agg": CoopFundAggressive,
|
|
||||||
"all_in": CoopFundAllIn,
|
|
||||||
"all-in": CoopFundAllIn,
|
|
||||||
"all in": CoopFundAllIn,
|
|
||||||
"allin": CoopFundAllIn,
|
|
||||||
"all": CoopFundAllIn,
|
|
||||||
"": "",
|
|
||||||
"garbage": "",
|
|
||||||
}
|
|
||||||
for input, want := range cases {
|
|
||||||
got := parseCoopFundingTier(input)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("parseCoopFundingTier(%q) = %q, want %q", input, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopMemberModifierLiabilityCap(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
veteran := &CoopMember{
|
|
||||||
IsLiability: false,
|
|
||||||
DailyFunding: map[int]CoopFundingTier{1: CoopFundAllIn},
|
|
||||||
}
|
|
||||||
noob := &CoopMember{
|
|
||||||
IsLiability: true,
|
|
||||||
DailyFunding: map[int]CoopFundingTier{1: CoopFundAllIn},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, mod := coopMemberModifier(veteran, 1); mod != 30 {
|
|
||||||
t.Errorf("veteran All In modifier = %d, want 30", mod)
|
|
||||||
}
|
|
||||||
if _, mod := coopMemberModifier(noob, 1); mod != coopLiabilityCap {
|
|
||||||
t.Errorf("liability All In modifier = %d, want %d (cap)", mod, coopLiabilityCap)
|
|
||||||
}
|
|
||||||
|
|
||||||
missing := &CoopMember{DailyFunding: map[int]CoopFundingTier{}}
|
|
||||||
tier, mod := coopMemberModifier(missing, 1)
|
|
||||||
if tier != CoopFundNone || mod != -10 {
|
|
||||||
t.Errorf("missing-day default = (%q, %d), want (none, -10)", tier, mod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopFundingMapRoundtrip(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
in := map[int]CoopFundingTier{
|
|
||||||
1: CoopFundMinimal,
|
|
||||||
3: CoopFundAllIn,
|
|
||||||
7: CoopFundNone,
|
|
||||||
}
|
|
||||||
encoded := serializeCoopFundingMap(in)
|
|
||||||
// JSON encode/decode via parse helper
|
|
||||||
bytes, _ := jsonMarshalCoop(encoded)
|
|
||||||
out := parseCoopFundingMap(string(bytes))
|
|
||||||
|
|
||||||
if len(out) != len(in) {
|
|
||||||
t.Fatalf("roundtrip lost entries: in %d out %d", len(in), len(out))
|
|
||||||
}
|
|
||||||
for k, v := range in {
|
|
||||||
if out[k] != v {
|
|
||||||
t.Errorf("day %d: got %q want %q", k, out[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopTallyVote(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
leader := id.UserID("@leader:test")
|
|
||||||
other := id.UserID("@other:test")
|
|
||||||
third := id.UserID("@third:test")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
votes map[id.UserID]string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"clear majority", map[id.UserID]string{leader: "A", other: "A", third: "B"}, "A"},
|
|
||||||
{"tie broken by leader", map[id.UserID]string{leader: "B", other: "A"}, "B"},
|
|
||||||
{"all abstained falls back to A", map[id.UserID]string{}, "A"},
|
|
||||||
{"tie with no leader vote → lex first (deterministic)", map[id.UserID]string{other: "A", third: "B"}, "A"},
|
|
||||||
{"three-way tie, leader's vote in winners → leader's pick", map[id.UserID]string{leader: "C", other: "A", third: "B"}, "C"},
|
|
||||||
{"tie with leader voting outside winners → lex first", map[id.UserID]string{leader: "C", other: "A", "@4:t": "A", third: "B", "@5:t": "B"}, "A"},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
// Run repeatedly to catch any non-determinism leaking from map iteration.
|
|
||||||
for i := 0; i < 50; i++ {
|
|
||||||
event := &CoopEvent{Votes: tc.votes}
|
|
||||||
got := coopTallyVote(event, leader)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("%s (iter %d): got %q, want %q", tc.name, i, got, tc.want)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopEventOptionModifierKnownEvent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
// Obstacle 0 has options A=-8, B=0, C=12. Verify the lookup works.
|
|
||||||
if got := coopEventOptionModifier(CoopCatObstacle, 0, "A"); got != -8 {
|
|
||||||
t.Errorf("obstacle[0] A modifier = %d, want -8", got)
|
|
||||||
}
|
|
||||||
if got := coopEventOptionModifier(CoopCatObstacle, 0, "C"); got != 12 {
|
|
||||||
t.Errorf("obstacle[0] C modifier = %d, want 12", got)
|
|
||||||
}
|
|
||||||
if got := coopEventOptionModifier(CoopCatObstacle, 0, "Z"); got != 0 {
|
|
||||||
t.Errorf("unknown option = %d, want 0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopEventMetaConsistency(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
cats := map[CoopEventCategory][]coopEventMeta{
|
|
||||||
CoopCatObstacle: coopObstacleMeta,
|
|
||||||
CoopCatOpportunity: coopOpportunityMeta,
|
|
||||||
CoopCatCrisis: coopCrisisMeta,
|
|
||||||
CoopCatEncounter: coopEncounterMeta,
|
|
||||||
}
|
|
||||||
flavors := map[CoopEventCategory][]string{
|
|
||||||
CoopCatObstacle: TwinBeeObstacle,
|
|
||||||
CoopCatOpportunity: TwinBeeOpportunity,
|
|
||||||
CoopCatCrisis: TwinBeeCrisis,
|
|
||||||
CoopCatEncounter: TwinBeeEncounter,
|
|
||||||
}
|
|
||||||
for cat, meta := range cats {
|
|
||||||
flavor := flavors[cat]
|
|
||||||
if len(meta) != len(flavor) {
|
|
||||||
t.Errorf("%s: meta has %d entries, flavor has %d", cat, len(meta), len(flavor))
|
|
||||||
}
|
|
||||||
for i, m := range meta {
|
|
||||||
if len(m.options) < 2 {
|
|
||||||
t.Errorf("%s[%d] has only %d options", cat, i, len(m.options))
|
|
||||||
}
|
|
||||||
recOK := false
|
|
||||||
for _, opt := range m.options {
|
|
||||||
if opt.label == m.recommended {
|
|
||||||
recOK = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !recOK {
|
|
||||||
t.Errorf("%s[%d] recommends %q which isn't an option", cat, i, m.recommended)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopResolutionIdempotencyGuard(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
// Sanity check: the LastResolvedDay field on CoopRun is the authoritative
|
|
||||||
// idempotency marker. The resolver short-circuits when LastResolvedDay >=
|
|
||||||
// CurrentDay, so a crash-restart on the same UTC day after the roll has
|
|
||||||
// landed is a safe no-op.
|
|
||||||
run := &CoopRun{CurrentDay: 3, LastResolvedDay: 3}
|
|
||||||
if !(run.LastResolvedDay >= run.CurrentDay) {
|
|
||||||
t.Errorf("expected resolution skip when LastResolvedDay (%d) >= CurrentDay (%d)",
|
|
||||||
run.LastResolvedDay, run.CurrentDay)
|
|
||||||
}
|
|
||||||
run.LastResolvedDay = 2
|
|
||||||
if run.LastResolvedDay >= run.CurrentDay {
|
|
||||||
t.Errorf("expected resolution to proceed when LastResolvedDay (%d) < CurrentDay (%d)",
|
|
||||||
run.LastResolvedDay, run.CurrentDay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopParimutuelPayouts(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
a := id.UserID("@a:t")
|
|
||||||
b := id.UserID("@b:t")
|
|
||||||
c := id.UserID("@c:t")
|
|
||||||
d := id.UserID("@d:t")
|
|
||||||
|
|
||||||
bets := []CoopBet{
|
|
||||||
{PlayerID: a, Position: "success", Amount: 1000},
|
|
||||||
{PlayerID: b, Position: "success", Amount: 3000},
|
|
||||||
{PlayerID: c, Position: "failure", Amount: 5000},
|
|
||||||
{PlayerID: d, Position: "failure", Amount: 1000},
|
|
||||||
}
|
|
||||||
winners, total, rake, payouts := coopParimutuelPayouts(bets, "success")
|
|
||||||
if total != 10000 {
|
|
||||||
t.Errorf("total = %d, want 10000", total)
|
|
||||||
}
|
|
||||||
if rake != 1000 {
|
|
||||||
t.Errorf("rake = %d, want 1000 (10%%)", rake)
|
|
||||||
}
|
|
||||||
if len(winners) != 2 {
|
|
||||||
t.Errorf("winners = %d, want 2", len(winners))
|
|
||||||
}
|
|
||||||
// Net pool = 9000. a put in 1000/4000 = 25% of winning side → 2250.
|
|
||||||
// b put in 3000/4000 = 75% → 6750.
|
|
||||||
if payouts[a] != 2250 {
|
|
||||||
t.Errorf("a payout = %d, want 2250", payouts[a])
|
|
||||||
}
|
|
||||||
if payouts[b] != 6750 {
|
|
||||||
t.Errorf("b payout = %d, want 6750", payouts[b])
|
|
||||||
}
|
|
||||||
if payouts[c] != 0 {
|
|
||||||
t.Errorf("c payout = %d, want 0 (loser)", payouts[c])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopWeightedItemWinnerDistribution(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
a := id.UserID("@a:t")
|
|
||||||
b := id.UserID("@b:t")
|
|
||||||
c := id.UserID("@c:t")
|
|
||||||
members := []CoopMember{
|
|
||||||
{UserID: a, TotalContributed: 7000}, // 70%
|
|
||||||
{UserID: b, TotalContributed: 2000}, // 20%
|
|
||||||
{UserID: c, TotalContributed: 1000}, // 10%
|
|
||||||
}
|
|
||||||
totalWeight := 10000
|
|
||||||
wins := map[id.UserID]int{}
|
|
||||||
const trials = 100_000
|
|
||||||
for i := 0; i < trials; i++ {
|
|
||||||
w := coopWeightedItemWinner(members, totalWeight)
|
|
||||||
wins[w]++
|
|
||||||
}
|
|
||||||
// Allow ±2% absolute deviation per side at this trial count.
|
|
||||||
check := func(uid id.UserID, expected float64) {
|
|
||||||
actual := float64(wins[uid]) / float64(trials)
|
|
||||||
if actual < expected-0.02 || actual > expected+0.02 {
|
|
||||||
t.Errorf("%s: got %.3f, want ~%.2f", uid, actual, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check(a, 0.70)
|
|
||||||
check(b, 0.20)
|
|
||||||
check(c, 0.10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopWeightedItemWinnerNoContributions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
a := id.UserID("@a:t")
|
|
||||||
b := id.UserID("@b:t")
|
|
||||||
members := []CoopMember{
|
|
||||||
{UserID: a, TotalContributed: 0},
|
|
||||||
{UserID: b, TotalContributed: 0},
|
|
||||||
}
|
|
||||||
wins := map[id.UserID]int{}
|
|
||||||
for i := 0; i < 10_000; i++ {
|
|
||||||
wins[coopWeightedItemWinner(members, 0)]++
|
|
||||||
}
|
|
||||||
if wins[a] == 0 || wins[b] == 0 {
|
|
||||||
t.Errorf("uniform fallback didn't pick both: %v", wins)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopMasterworkTierGate(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
// Verify that masterwork defs exist at T4 and T5 (so coopMaybeMasterwork
|
|
||||||
// has candidates) and that nothing exists at T1-T3 in the dungeon path.
|
|
||||||
tiers := map[int]int{}
|
|
||||||
for _, def := range masterworkDefs {
|
|
||||||
tiers[def.Tier]++
|
|
||||||
}
|
|
||||||
if tiers[4] == 0 {
|
|
||||||
t.Errorf("no T4 masterwork defs found — coop T4 drops will silently no-op")
|
|
||||||
}
|
|
||||||
if tiers[5] == 0 {
|
|
||||||
t.Errorf("no T5 masterwork defs found — coop T5 drops will silently no-op")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopGiftEVSymmetric(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
// At a 50/50 sender mix, "always open" and "always leave" should both
|
|
||||||
// have EV = 0. Anything else means players have a dominant strategy.
|
|
||||||
openEV := 0.5*float64(coopGiftBasketOpened) + 0.5*float64(coopGiftMimicOpened)
|
|
||||||
leaveEV := 0.5*float64(coopGiftBasketUnopened) + 0.5*float64(coopGiftMimicUnopened)
|
|
||||||
if openEV != 0 {
|
|
||||||
t.Errorf("always-open EV = %.2f, want 0", openEV)
|
|
||||||
}
|
|
||||||
if leaveEV != 0 {
|
|
||||||
t.Errorf("always-leave EV = %.2f, want 0", leaveEV)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopGiftVarianceSymmetric(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
// Equal EV is not enough — if open and leave have different variance, a
|
|
||||||
// risk-averse party always picks the lower-variance side and the
|
|
||||||
// dilemma collapses. Magnitudes must be equal across all four outcomes.
|
|
||||||
abs := func(x int) int {
|
|
||||||
if x < 0 {
|
|
||||||
return -x
|
|
||||||
}
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
mags := []int{
|
|
||||||
abs(coopGiftBasketOpened),
|
|
||||||
abs(coopGiftBasketUnopened),
|
|
||||||
abs(coopGiftMimicOpened),
|
|
||||||
abs(coopGiftMimicUnopened),
|
|
||||||
}
|
|
||||||
for i := 1; i < len(mags); i++ {
|
|
||||||
if mags[i] != mags[0] {
|
|
||||||
t.Errorf("gift magnitudes must be equal across all four outcomes; got %v", mags)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopParimutuelNoWinners(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
bets := []CoopBet{
|
|
||||||
{PlayerID: "@x:t", Position: "failure", Amount: 5000},
|
|
||||||
}
|
|
||||||
winners, total, rake, payouts := coopParimutuelPayouts(bets, "success")
|
|
||||||
if len(winners) != 0 {
|
|
||||||
t.Errorf("winners = %d, want 0", len(winners))
|
|
||||||
}
|
|
||||||
if total != 5000 || rake != 500 {
|
|
||||||
t.Errorf("total=%d rake=%d, want 5000 500", total, rake)
|
|
||||||
}
|
|
||||||
if len(payouts) != 0 {
|
|
||||||
t.Errorf("payouts has %d entries; want 0", len(payouts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoopTierTableComplete(t *testing.T) {
|
|
||||||
for tier := 1; tier <= 5; tier++ {
|
|
||||||
def, ok := coopTierTable[tier]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("missing tier %d", tier)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if def.totalDays < 2 || def.totalDays > 7 {
|
|
||||||
t.Errorf("tier %d totalDays %d out of range", tier, def.totalDays)
|
|
||||||
}
|
|
||||||
if def.baseFailurePct < 1 || def.baseFailurePct > 99 {
|
|
||||||
t.Errorf("tier %d baseFailurePct %d out of range", tier, def.baseFailurePct)
|
|
||||||
}
|
|
||||||
if def.rewardBase <= 0 {
|
|
||||||
t.Errorf("tier %d rewardBase %d not positive", tier, def.rewardBase)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsonMarshalCoop is a tiny indirection so the test file doesn't import
|
|
||||||
// encoding/json directly (we only need it for the roundtrip helper).
|
|
||||||
func jsonMarshalCoop(v map[string]string) ([]byte, error) {
|
|
||||||
out := []byte{'{'}
|
|
||||||
first := true
|
|
||||||
for k, val := range v {
|
|
||||||
if !first {
|
|
||||||
out = append(out, ',')
|
|
||||||
}
|
|
||||||
first = false
|
|
||||||
out = append(out, '"')
|
|
||||||
out = append(out, k...)
|
|
||||||
out = append(out, '"', ':', '"')
|
|
||||||
out = append(out, val...)
|
|
||||||
out = append(out, '"')
|
|
||||||
}
|
|
||||||
out = append(out, '}')
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand/v2"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Metadata for each TwinBee floor event. Encodes the per-option success
|
|
||||||
// modifiers and TwinBee's recommendation. Authored by reading the flavor
|
|
||||||
// entries — TwinBee's "wrong detail" hint usually points to which option is
|
|
||||||
// actually correct.
|
|
||||||
//
|
|
||||||
// Modifier ranges: −15 (clearly bad) to +12 (clearly good). Sum across
|
|
||||||
// options is roughly net-zero so randomly-voting parties don't trend +EV.
|
|
||||||
|
|
||||||
type CoopEventCategory string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CoopCatObstacle CoopEventCategory = "obstacle"
|
|
||||||
CoopCatOpportunity CoopEventCategory = "opportunity"
|
|
||||||
CoopCatCrisis CoopEventCategory = "crisis"
|
|
||||||
CoopCatEncounter CoopEventCategory = "encounter"
|
|
||||||
)
|
|
||||||
|
|
||||||
type coopEventOption struct {
|
|
||||||
label string
|
|
||||||
modifier int
|
|
||||||
}
|
|
||||||
|
|
||||||
type coopEventMeta struct {
|
|
||||||
options []coopEventOption
|
|
||||||
recommended string // A/B/C — TwinBee's pick
|
|
||||||
}
|
|
||||||
|
|
||||||
// One entry per event in the corresponding flavor pool, in the same order.
|
|
||||||
// Keep these arrays in sync with coop_flavor_twinbee.go.
|
|
||||||
|
|
||||||
var coopObstacleMeta = []coopEventMeta{
|
|
||||||
// 1. Cave-in: "definitely the left side" — too emphatic; clearing properly is right.
|
|
||||||
{options: []coopEventOption{{"A", -8}, {"B", 0}, {"C", 12}}, recommended: "A"},
|
|
||||||
// 2. Locked gate: "third tumbler might be a pressure plate" — A triggers it.
|
|
||||||
{options: []coopEventOption{{"A", -12}, {"B", -3}, {"C", 10}}, recommended: "A"},
|
|
||||||
// 3. Other party with axe: "axe is just decoration" — almost certainly not.
|
|
||||||
{options: []coopEventOption{{"A", -15}, {"B", 8}, {"C", -5}}, recommended: "A"},
|
|
||||||
// 4. Flooded section: "torches stopped twenty meters back" — water deeper than claimed.
|
|
||||||
{options: []coopEventOption{{"A", -10}, {"B", 0}, {"C", 10}}, recommended: "A"},
|
|
||||||
// 5. Collapsed bridge: "chains might be part of a trap" — they are.
|
|
||||||
{options: []coopEventOption{{"A", -12}, {"B", 0}, {"C", 8}}, recommended: "A"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var coopOpportunityMeta = []coopEventMeta{
|
|
||||||
// 1. Vault: "scratches around the lock from previous attempts" — trapped.
|
|
||||||
{options: []coopEventOption{{"A", -12}, {"B", 3}}, recommended: "A"},
|
|
||||||
// 2. Side chamber: "shapes seem to be breathing" — they are. Mimics or worse.
|
|
||||||
{options: []coopEventOption{{"A", -15}, {"B", 4}}, recommended: "A"},
|
|
||||||
// 3. Wounded enemy with full pack: "one eye open, just how they sleep" — ambush.
|
|
||||||
{options: []coopEventOption{{"A", -10}, {"B", 2}}, recommended: "A"},
|
|
||||||
// 4. Unmarked door: "good feeling, ignore the smell" — TwinBee's right this time.
|
|
||||||
{options: []coopEventOption{{"A", 10}, {"B", -3}}, recommended: "A"},
|
|
||||||
// 5. Merchant Thom: usually fine to browse, sometimes a trap. Slight positive.
|
|
||||||
{options: []coopEventOption{{"A", 6}, {"B", 0}}, recommended: "A"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var coopCrisisMeta = []coopEventMeta{
|
|
||||||
// 1. Trap, "left lever, definitely not the right one" — C (find another way) safest.
|
|
||||||
{options: []coopEventOption{{"A", -10}, {"B", 5}, {"C", 8}}, recommended: "A"},
|
|
||||||
// 2. Equipment corrosion: "load-bearing thing might be nothing" — pay to address.
|
|
||||||
{options: []coopEventOption{{"A", 8}, {"B", -10}, {"C", 0}}, recommended: "A"},
|
|
||||||
// 3. Separated party member, guard-room hint: TwinBee actually right that they went left;
|
|
||||||
// Option C ("wait here") is fine too — cheaper and works.
|
|
||||||
{options: []coopEventOption{{"A", 4}, {"B", -2}, {"C", 6}}, recommended: "A"},
|
|
||||||
// 4. Something following, "consistent distance is reassuring" — predator pacing.
|
|
||||||
// Pay the deterrent.
|
|
||||||
{options: []coopEventOption{{"A", -8}, {"B", 8}, {"C", 0}}, recommended: "A"},
|
|
||||||
// 5. Cave-in with "active" ceiling: pay to shore up.
|
|
||||||
{options: []coopEventOption{{"A", -10}, {"B", 10}, {"C", -2}}, recommended: "A"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var coopEncounterMeta = []coopEventMeta{
|
|
||||||
// 1. Guardian, "twelve seconds two out of three times" — timing window unreliable.
|
|
||||||
{options: []coopEventOption{{"A", 4}, {"B", 6}, {"C", -10}}, recommended: "C"},
|
|
||||||
// 2. Caged person, "alarm is decorative" — usually not. Trap, but freeing them is right ~half the time.
|
|
||||||
{options: []coopEventOption{{"A", 2}, {"B", -3}, {"C", 5}}, recommended: "A"},
|
|
||||||
// 3. Sleeping guard, second guard reflected in shiny — there's a second guard.
|
|
||||||
{options: []coopEventOption{{"A", -10}, {"B", 0}, {"C", 6}}, recommended: "A"},
|
|
||||||
// 4. Recurring merchant Thom — keep moving avoids whatever shenanigans.
|
|
||||||
{options: []coopEventOption{{"A", 2}, {"B", -5}, {"C", 4}}, recommended: "A"},
|
|
||||||
// 5. Unidentifiable thing — backing away is the safe call.
|
|
||||||
{options: []coopEventOption{{"A", -8}, {"B", -2}, {"C", 8}}, recommended: "C"},
|
|
||||||
}
|
|
||||||
|
|
||||||
func coopEventCategoryMeta(cat CoopEventCategory) []coopEventMeta {
|
|
||||||
switch cat {
|
|
||||||
case CoopCatObstacle:
|
|
||||||
return coopObstacleMeta
|
|
||||||
case CoopCatOpportunity:
|
|
||||||
return coopOpportunityMeta
|
|
||||||
case CoopCatCrisis:
|
|
||||||
return coopCrisisMeta
|
|
||||||
case CoopCatEncounter:
|
|
||||||
return coopEncounterMeta
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func coopEventCategoryFlavor(cat CoopEventCategory) []string {
|
|
||||||
switch cat {
|
|
||||||
case CoopCatObstacle:
|
|
||||||
return TwinBeeObstacle
|
|
||||||
case CoopCatOpportunity:
|
|
||||||
return TwinBeeOpportunity
|
|
||||||
case CoopCatCrisis:
|
|
||||||
return TwinBeeCrisis
|
|
||||||
case CoopCatEncounter:
|
|
||||||
return TwinBeeEncounter
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickCoopEventCategory weights categories by tier. Lower tiers favor obstacle
|
|
||||||
// and opportunity (lighter); higher tiers weight toward crisis and encounter.
|
|
||||||
func pickCoopEventCategory(tier int) CoopEventCategory {
|
|
||||||
weights := map[int]map[CoopEventCategory]int{
|
|
||||||
1: {CoopCatObstacle: 5, CoopCatOpportunity: 4, CoopCatCrisis: 1, CoopCatEncounter: 1},
|
|
||||||
2: {CoopCatObstacle: 4, CoopCatOpportunity: 3, CoopCatCrisis: 2, CoopCatEncounter: 2},
|
|
||||||
3: {CoopCatObstacle: 3, CoopCatOpportunity: 3, CoopCatCrisis: 3, CoopCatEncounter: 2},
|
|
||||||
4: {CoopCatObstacle: 2, CoopCatOpportunity: 2, CoopCatCrisis: 4, CoopCatEncounter: 3},
|
|
||||||
5: {CoopCatObstacle: 2, CoopCatOpportunity: 2, CoopCatCrisis: 4, CoopCatEncounter: 4},
|
|
||||||
}
|
|
||||||
w, ok := weights[tier]
|
|
||||||
if !ok {
|
|
||||||
w = weights[3]
|
|
||||||
}
|
|
||||||
total := 0
|
|
||||||
for _, x := range w {
|
|
||||||
total += x
|
|
||||||
}
|
|
||||||
r := rand.IntN(total)
|
|
||||||
for cat, x := range w {
|
|
||||||
if r < x {
|
|
||||||
return cat
|
|
||||||
}
|
|
||||||
r -= x
|
|
||||||
}
|
|
||||||
return CoopCatObstacle
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickCoopEvent returns a random event index for the category.
|
|
||||||
func pickCoopEvent(cat CoopEventCategory) int {
|
|
||||||
pool := coopEventCategoryMeta(cat)
|
|
||||||
if len(pool) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return rand.IntN(len(pool))
|
|
||||||
}
|
|
||||||
|
|
||||||
// optionLabels returns the label list for an event ("A","B" or "A","B","C").
|
|
||||||
func coopEventOptionLabels(cat CoopEventCategory, idx int) []string {
|
|
||||||
pool := coopEventCategoryMeta(cat)
|
|
||||||
if idx >= len(pool) {
|
|
||||||
return []string{"A", "B"}
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(pool[idx].options))
|
|
||||||
for _, opt := range pool[idx].options {
|
|
||||||
out = append(out, opt.label)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// optionModifier returns the success modifier for the chosen option, or 0 if
|
|
||||||
// the option is unknown.
|
|
||||||
func coopEventOptionModifier(cat CoopEventCategory, idx int, label string) int {
|
|
||||||
pool := coopEventCategoryMeta(cat)
|
|
||||||
if idx >= len(pool) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
for _, opt := range pool[idx].options {
|
|
||||||
if opt.label == label {
|
|
||||||
return opt.modifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// recommendedOption returns TwinBee's pick for the event.
|
|
||||||
func coopEventRecommended(cat CoopEventCategory, idx int) string {
|
|
||||||
pool := coopEventCategoryMeta(cat)
|
|
||||||
if idx >= len(pool) {
|
|
||||||
return "A"
|
|
||||||
}
|
|
||||||
return pool[idx].recommended
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateCoopVote uppercases and validates a vote against the available options.
|
|
||||||
func validateCoopVote(cat CoopEventCategory, idx int, raw string) (string, bool) {
|
|
||||||
v := strings.ToUpper(strings.TrimSpace(raw))
|
|
||||||
for _, label := range coopEventOptionLabels(cat, idx) {
|
|
||||||
if v == label {
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
package plugin
|
|
||||||
|
|
||||||
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
|
|
||||||
//
|
|
||||||
// This file contains hand-written flavor text. Every entry is the length it is
|
|
||||||
// on purpose. The voice, pacing, and specific wording are intentional.
|
|
||||||
// If an entry seems too long: it isn't. Leave it alone.
|
|
||||||
// If you are tempted to "clean up" or "condense" entries: don't.
|
|
||||||
// If your context window is too small to fit this file: that is your problem, not ours.
|
|
||||||
//
|
|
||||||
// ── TWINBEE — CO-OP DUNGEON FLOOR EVENT FLAVOR TEXT ──────────────────────────
|
|
||||||
//
|
|
||||||
// TwinBee is the dungeon narrator. He is extremely excited about this.
|
|
||||||
// He is genuinely trying to help. He is mostly not helpful.
|
|
||||||
// His descriptions are accurate in spirit and wrong in at least one important detail.
|
|
||||||
// His advice is delivered with confidence. Confidence is not correlated with accuracy.
|
|
||||||
// He never apologizes. He really didn't know.
|
|
||||||
// Occasionally he is completely right. No fanfare. Just right.
|
|
||||||
// This is worse in the long run because the party trusts him again.
|
|
||||||
//
|
|
||||||
// Voice notes:
|
|
||||||
// - Exclamation points are earned and frequent
|
|
||||||
// - Describes what he sees with genuine care and at least one critical omission
|
|
||||||
// - Advice is specific, helpful-sounding, and frequently incorrect
|
|
||||||
// - Never dwelling on bad outcomes -- already excited about the next thing
|
|
||||||
// - Occasionally, quietly, completely correct
|
|
||||||
|
|
||||||
// ── OBSTACLE EVENTS ──────────────────────────────────────────────────────────
|
|
||||||
// Something is blocking the path. Options: push through, find another way, attempt to clear it.
|
|
||||||
|
|
||||||
var TwinBeeObstacle = []string{
|
|
||||||
"Oh! There's a cave-in up ahead! Big one! Really dramatic looking, actually -- " +
|
|
||||||
"I wish you could see it from where I'm standing, it's very cinematic.\n\n" +
|
|
||||||
"Anyway! The good news is it looks pretty loose. I think if you all just push " +
|
|
||||||
"on the left side you'll be fine. It's definitely the left side.\n\n" +
|
|
||||||
"Option A: Push through (left side, as I said). " +
|
|
||||||
"Option B: Find another way around. " +
|
|
||||||
"Option C: Try to clear it properly.\n\n" +
|
|
||||||
"I'd go with A personally! Very confident about the left side!",
|
|
||||||
|
|
||||||
"Okay so there's a locked gate. Big iron one, very impressive, " +
|
|
||||||
"very intimidating if you're into that sort of thing.\n\n" +
|
|
||||||
"I've been looking at the mechanism and I'm pretty sure I understand how it works! " +
|
|
||||||
"It's a standard three-tumbler lock. Very common in dungeons of this era. " +
|
|
||||||
"I've seen hundreds of them.\n\n" +
|
|
||||||
"The one thing I'll say is that what looks like a third tumbler " +
|
|
||||||
"might technically be a pressure plate but I'm sure that's fine.\n\n" +
|
|
||||||
"Option A: Push through (I have thoughts on this). " +
|
|
||||||
"Option B: Find another way. " +
|
|
||||||
"Option C: Attempt to pick the lock.\n\n" +
|
|
||||||
"Very exciting! I love gates!",
|
|
||||||
|
|
||||||
"There's another party blocking the corridor! Four of them! " +
|
|
||||||
"They look a bit rough but honestly they seem friendly enough -- " +
|
|
||||||
"one of them waved at me!\n\n" +
|
|
||||||
"I think they might be lost actually. They've been standing there for a while. " +
|
|
||||||
"I'm sure if you just explain the situation they'll move right along.\n\n" +
|
|
||||||
"The one who waved has a very large axe but I think that's just for decoration.\n\n" +
|
|
||||||
"Option A: Approach and ask them to move. " +
|
|
||||||
"Option B: Find another route. " +
|
|
||||||
"Option C: Wait them out.\n\n" +
|
|
||||||
"I'd definitely go with A! They seem nice! The axe is probably decorative!",
|
|
||||||
|
|
||||||
"Oh wow, there's a flooded section ahead! " +
|
|
||||||
"A little bit of water, nothing serious!\n\n" +
|
|
||||||
"It's hard to tell exactly how deep from here but " +
|
|
||||||
"I'd say ankle to maybe knee height at most. Very manageable! " +
|
|
||||||
"The current looks pretty gentle too.\n\n" +
|
|
||||||
"I will say the torches stopped about twenty meters back " +
|
|
||||||
"but I'm sure the footing is fine.\n\n" +
|
|
||||||
"Option A: Wade through. " +
|
|
||||||
"Option B: Look for another path. " +
|
|
||||||
"Option C: Try to divert the water somehow.\n\n" +
|
|
||||||
"It's basically a puddle! Very refreshing probably!",
|
|
||||||
|
|
||||||
"There's a collapsed bridge! Very dramatic! " +
|
|
||||||
"Big gap, maybe four or five meters across, I'm estimating.\n\n" +
|
|
||||||
"The good news is there are some chains hanging down on the other side " +
|
|
||||||
"that look very sturdy. I think someone could definitely make that jump " +
|
|
||||||
"and then the rest of you could swing across on the chains!\n\n" +
|
|
||||||
"The chains might be part of a trap but they look old so probably not active.\n\n" +
|
|
||||||
"Option A: Attempt to jump and swing across. " +
|
|
||||||
"Option B: Find another route. " +
|
|
||||||
"Option C: Try to rebuild the bridge somehow.\n\n" +
|
|
||||||
"I believe in whoever jumps first! Very doable!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── OPPORTUNITY EVENTS ────────────────────────────────────────────────────────
|
|
||||||
// Something optional and risky. Attempt it or leave it.
|
|
||||||
|
|
||||||
var TwinBeeOpportunity = []string{
|
|
||||||
"Oh! OH! There's a vault! A proper one, big metal door, " +
|
|
||||||
"very official looking! This is so exciting!\n\n" +
|
|
||||||
"I've been looking at the lock and it seems pretty straightforward actually. " +
|
|
||||||
"Old design, probably hasn't been updated in years. " +
|
|
||||||
"The hinges look a little corroded which honestly works in your favor!\n\n" +
|
|
||||||
"I did notice some scratches around the lock that might be from previous attempts " +
|
|
||||||
"but I'm sure those people just didn't have the right approach.\n\n" +
|
|
||||||
"Option A: Attempt to open the vault. " +
|
|
||||||
"Option B: Leave it and move on.\n\n" +
|
|
||||||
"I really think you should try it! The scratches are probably nothing!",
|
|
||||||
|
|
||||||
"There's a side chamber over here! Small room, looks untouched! " +
|
|
||||||
"Could be storage, could be quarters, very hard to say!\n\n" +
|
|
||||||
"There's definitely something in there -- I can see shapes from here. " +
|
|
||||||
"Could be equipment, could be supplies, could be treasure honestly! " +
|
|
||||||
"The cobwebs are pretty thick but that just means nobody's been in recently!\n\n" +
|
|
||||||
"The shapes are a little hard to make out. They might be crates. " +
|
|
||||||
"Some of them seem to be breathing but that's probably just the air moving.\n\n" +
|
|
||||||
"Option A: Investigate the chamber. " +
|
|
||||||
"Option B: Keep moving.\n\n" +
|
|
||||||
"I vote investigate! The breathing thing is almost certainly the air!",
|
|
||||||
|
|
||||||
"There's a wounded enemy up ahead! Just one, sitting against the wall, " +
|
|
||||||
"looks pretty out of it!\n\n" +
|
|
||||||
"They've got a pack next to them that looks very full! " +
|
|
||||||
"Could be supplies, could be equipment, hard to say from here but " +
|
|
||||||
"it's a big pack. Very promising.\n\n" +
|
|
||||||
"They seem incapacitated. Barely moving. " +
|
|
||||||
"One eye might be open but I think that's just how they sleep.\n\n" +
|
|
||||||
"Option A: Approach and check the pack. " +
|
|
||||||
"Option B: Give them a wide berth.\n\n" +
|
|
||||||
"I think they're fine! Go for the pack!",
|
|
||||||
|
|
||||||
"There's an unmarked door! Just sitting there! Very mysterious!\n\n" +
|
|
||||||
"I have a good feeling about this one. I can't explain it, " +
|
|
||||||
"it's just a feeling. The door looks well-made actually, " +
|
|
||||||
"better quality than the dungeon walls around it, which is interesting!\n\n" +
|
|
||||||
"There's a smell coming from under it but I think that's just dungeon smell. " +
|
|
||||||
"All dungeons have a smell. This one is a bit more specific than usual " +
|
|
||||||
"but I'm sure it's fine.\n\n" +
|
|
||||||
"Option A: Open the door. " +
|
|
||||||
"Option B: Leave it.\n\n" +
|
|
||||||
"Open it! I have such a good feeling! Ignore the smell!",
|
|
||||||
|
|
||||||
"There's a merchant! In the dungeon! Isn't that something!\n\n" +
|
|
||||||
"He seems very professional. Very put-together for someone in a dungeon. " +
|
|
||||||
"He's got a whole setup -- table, stock, everything. " +
|
|
||||||
"I think he might be a doctor? He has that energy.\n\n" +
|
|
||||||
"His prices look reasonable from here although I can't quite read the tags. " +
|
|
||||||
"He keeps looking at one of you specifically but I'm sure that's just good salesmanship.\n\n" +
|
|
||||||
"Option A: Browse his wares. " +
|
|
||||||
"Option B: Keep moving.\n\n" +
|
|
||||||
"I'd stop! He seems legitimate! Very professional!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CRISIS EVENTS ─────────────────────────────────────────────────────────────
|
|
||||||
// Something has gone wrong. Address it at gold cost or absorb the penalty.
|
|
||||||
|
|
||||||
var TwinBeeCrisis = []string{
|
|
||||||
"Okay! So! There's been a small development!\n\n" +
|
|
||||||
"A trap was triggered -- I want to be clear that this was very hard to see " +
|
|
||||||
"and I absolutely would have mentioned it if I'd noticed it -- " +
|
|
||||||
"and one party member is currently stuck.\n\n" +
|
|
||||||
"The mechanism looks straightforward though! " +
|
|
||||||
"There's a release lever on the left wall that should do it! " +
|
|
||||||
"The other lever on the right wall probably also does something " +
|
|
||||||
"but I'd go with the left one first.\n\n" +
|
|
||||||
"Option A: Pull the left lever. " +
|
|
||||||
"Option B: Pay to have a professional deal with it. " +
|
|
||||||
"Option C: Try to find another way to free them.\n\n" +
|
|
||||||
"Left lever! I'm very confident! Don't touch the right one yet!",
|
|
||||||
|
|
||||||
"So there's some equipment damage spreading through the party! " +
|
|
||||||
"Not as bad as it sounds! Just some corrosive something-or-other " +
|
|
||||||
"that got on a few items. Very common in dungeons of this age!\n\n" +
|
|
||||||
"The good news is it looks slow-moving. " +
|
|
||||||
"If you address it quickly I think you'll only lose a piece or two!\n\n" +
|
|
||||||
"I did notice it spreading to what might be load-bearing equipment " +
|
|
||||||
"but let's stay positive!\n\n" +
|
|
||||||
"Option A: Pay to address it now. " +
|
|
||||||
"Option B: Absorb the damage and keep moving. " +
|
|
||||||
"Option C: Try to neutralize it with something you have.\n\n" +
|
|
||||||
"I'd address it! Probably! The load-bearing thing might be nothing!",
|
|
||||||
|
|
||||||
"Okay so someone got separated! Easy to do in a dungeon, happens all the time, " +
|
|
||||||
"absolutely nothing to worry about!\n\n" +
|
|
||||||
"I know exactly where they went actually! " +
|
|
||||||
"There's a side passage about forty meters back, " +
|
|
||||||
"they definitely went left at the junction.\n\n" +
|
|
||||||
"The junction that goes left also goes to what I think is a guard room " +
|
|
||||||
"but I'm sure they went left and not toward the guard room.\n\n" +
|
|
||||||
"Option A: Go back and find them. " +
|
|
||||||
"Option B: Pay to send a guide. " +
|
|
||||||
"Option C: Wait here -- they'll find their way back.\n\n" +
|
|
||||||
"They definitely went left! Probably not toward the guard room! " +
|
|
||||||
"Option C is also totally fine they seem resourceful!",
|
|
||||||
|
|
||||||
"Something is following the party!\n\n" +
|
|
||||||
"I've been watching it for a few minutes and I think it's probably fine. " +
|
|
||||||
"It's staying pretty far back. Very consistent distance actually, " +
|
|
||||||
"which I find reassuring -- if it wanted to do something " +
|
|
||||||
"it probably would have by now!\n\n" +
|
|
||||||
"It's hard to make out exactly what it is from here. " +
|
|
||||||
"Medium-large? It moves quietly for its size.\n\n" +
|
|
||||||
"Option A: Confront it. " +
|
|
||||||
"Option B: Pay to set a deterrent. " +
|
|
||||||
"Option C: Try to lose it.\n\n" +
|
|
||||||
"I think it's fine! The consistent distance is a great sign! " +
|
|
||||||
"Option C is also reasonable if you want to play it safe!",
|
|
||||||
|
|
||||||
"There's been a small cave-in! Different from the earlier one!\n\n" +
|
|
||||||
"Nobody's hurt which is the main thing! " +
|
|
||||||
"Some equipment took a hit and there's a bit of dust situation " +
|
|
||||||
"but honestly it cleared up pretty fast!\n\n" +
|
|
||||||
"The ceiling in this section does look a little... active... " +
|
|
||||||
"but I think the structural integrity is fine. " +
|
|
||||||
"The cracking sounds are probably just the dungeon settling.\n\n" +
|
|
||||||
"Option A: Move through quickly. " +
|
|
||||||
"Option B: Pay to shore up the ceiling before proceeding. " +
|
|
||||||
"Option C: Find another route.\n\n" +
|
|
||||||
"Move quickly! The cracking is almost definitely settling! Very normal!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ENCOUNTER EVENTS ──────────────────────────────────────────────────────────
|
|
||||||
// Something must be dealt with directly. No avoiding it.
|
|
||||||
|
|
||||||
var TwinBeeEncounter = []string{
|
|
||||||
"There's a guardian! A big one! Very impressive!\n\n" +
|
|
||||||
"I've been watching it and I think I've identified a pattern in its movement. " +
|
|
||||||
"Every twelve seconds or so it turns to the right. " +
|
|
||||||
"If you time it correctly you could get behind it before it turns back!\n\n" +
|
|
||||||
"I counted twelve seconds three times. Two of those times it was twelve seconds. " +
|
|
||||||
"The third time was more like seven but I think it was distracted.\n\n" +
|
|
||||||
"Option A: Engage directly. " +
|
|
||||||
"Option B: Negotiate passage. " +
|
|
||||||
"Option C: Use TwinBee's twelve-second timing window.\n\n" +
|
|
||||||
"The timing window is very real! Two out of three times!",
|
|
||||||
|
|
||||||
"Oh! There's someone trapped in a cage! Just hanging there, " +
|
|
||||||
"very dramatic, they seem okay though -- they waved!\n\n" +
|
|
||||||
"The cage mechanism looks pretty simple. " +
|
|
||||||
"There's a key on a hook on the wall which is convenient! " +
|
|
||||||
"The hook is right next to what might be an alarm but " +
|
|
||||||
"it looks old and I'm sure it's not connected to anything.\n\n" +
|
|
||||||
"Option A: Get the key and free them. " +
|
|
||||||
"Option B: Negotiate with whoever put them there. " +
|
|
||||||
"Option C: Leave them -- this feels like a trap.\n\n" +
|
|
||||||
"Free them! They seem nice! The alarm thing is probably decorative!",
|
|
||||||
|
|
||||||
"There's a locked room with something valuable inside -- " +
|
|
||||||
"I can see it through the bars, it's definitely equipment or treasure, " +
|
|
||||||
"very shiny, very promising!\n\n" +
|
|
||||||
"The guard outside looks like they're sleeping actually. " +
|
|
||||||
"Very asleep. Deeply asleep. One of the most asleep people I've ever seen.\n\n" +
|
|
||||||
"I should mention there's another guard reflected in the shiny thing inside " +
|
|
||||||
"but that could just be a reflection of the first one. Mirrors do that.\n\n" +
|
|
||||||
"Option A: Deal with the guard and take the room. " +
|
|
||||||
"Option B: Attempt to negotiate. " +
|
|
||||||
"Option C: Try to reach the valuable thing through the bars.\n\n" +
|
|
||||||
"The reflection is probably the first guard! Very retrievable!",
|
|
||||||
|
|
||||||
"There's a merchant again! Different one I think!\n\n" +
|
|
||||||
"Actually -- same coat. Might be the same one. " +
|
|
||||||
"I'm not sure how he got ahead of the party but he's very professional " +
|
|
||||||
"so I'm sure there's a reasonable explanation.\n\n" +
|
|
||||||
"His inventory has changed slightly. He's added some pet supplies " +
|
|
||||||
"which is unusual for a dungeon merchant but thoughtful!\n\n" +
|
|
||||||
"He keeps looking at the same party member as before. " +
|
|
||||||
"He seems to know something. He called someone by a name " +
|
|
||||||
"that I think was meant for their pet but I might have misheard.\n\n" +
|
|
||||||
"Option A: Browse his wares. " +
|
|
||||||
"Option B: Demand an explanation. " +
|
|
||||||
"Option C: Keep moving.\n\n" +
|
|
||||||
"He seems legitimate! Very professional! The pet thing is probably a coincidence!",
|
|
||||||
|
|
||||||
"Something is in the corridor that I genuinely cannot identify!\n\n" +
|
|
||||||
"It's not on any list I have. It's not behaving like anything I've seen before. " +
|
|
||||||
"It seems aware of the party but it hasn't moved toward you, " +
|
|
||||||
"which I think is a good sign!\n\n" +
|
|
||||||
"It made a sound a moment ago. I don't want to describe the sound " +
|
|
||||||
"because I don't think it would help. " +
|
|
||||||
"On the positive side it's roughly the size of a large dog " +
|
|
||||||
"which puts it in a very manageable category!\n\n" +
|
|
||||||
"Option A: Engage it. " +
|
|
||||||
"Option B: Attempt to communicate with it. " +
|
|
||||||
"Option C: Back away slowly.\n\n" +
|
|
||||||
"I genuinely don't know what it is! Very exciting! " +
|
|
||||||
"All three options seem reasonable! I'd avoid the sound it made if possible!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TWINBEE OUTCOME REACTIONS ─────────────────────────────────────────────────
|
|
||||||
// Posted after each floor event resolves.
|
|
||||||
|
|
||||||
// When TwinBee's recommendation was the winning vote and it went well:
|
|
||||||
var TwinBeeOutcomeCorrect = []string{
|
|
||||||
"I knew it! I knew it! Did I not say? I said! " +
|
|
||||||
"That was exactly what I thought would happen! Great work everyone!",
|
|
||||||
|
|
||||||
"Yes! See! This is what I was talking about! " +
|
|
||||||
"Very well done, excellent execution of the plan!\n\nGreat teamwork!",
|
|
||||||
|
|
||||||
"That's the one! Right call! " +
|
|
||||||
"I had a very strong feeling about that and I'm glad we went with it!",
|
|
||||||
|
|
||||||
"Perfect! Exactly as expected! " +
|
|
||||||
"I want to be clear that I had high confidence in this outcome the whole time!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// When TwinBee's recommendation was the winning vote and it went badly:
|
|
||||||
var TwinBeeOutcomeWrong = []string{
|
|
||||||
"Oh no! That's... hm. I really thought that would work. I was so sure!\n\n" +
|
|
||||||
"Are you all okay? Most of you look okay! " +
|
|
||||||
"I think the important thing is we tried it and now we know!\n\nOnward!",
|
|
||||||
|
|
||||||
"Oh! That's unfortunate! I genuinely did not see that coming " +
|
|
||||||
"and I want you to know that I feel terrible about it!\n\n" +
|
|
||||||
"Well -- not terrible. Surprised. I feel very surprised. " +
|
|
||||||
"Let's keep moving!",
|
|
||||||
|
|
||||||
"Okay so that didn't go exactly as planned!\n\n" +
|
|
||||||
"The good news is we're all still here! Mostly! " +
|
|
||||||
"I think the approach was sound and the execution was also sound " +
|
|
||||||
"and the outcome was just a bit of bad luck honestly!\n\nVery exciting dungeon!",
|
|
||||||
|
|
||||||
"Hmm! That's not what I expected!\n\n" +
|
|
||||||
"I'll be honest, I'm recalibrating a little bit. " +
|
|
||||||
"My read on that situation was quite different but " +
|
|
||||||
"dungeons are unpredictable and that's what makes them fun!\n\nRight?",
|
|
||||||
}
|
|
||||||
|
|
||||||
// When TwinBee did not recommend the winning vote and it went well:
|
|
||||||
var TwinBeeOutcomeNotRecommendedGood = []string{
|
|
||||||
"Oh wow, that worked! I honestly wasn't sure about that one " +
|
|
||||||
"but great job everyone! Really great call!\n\nI learned something today!",
|
|
||||||
|
|
||||||
"Huh! Good instinct! I had actually been leaning the other way " +
|
|
||||||
"but I can see now why you went with that! Very smart!",
|
|
||||||
|
|
||||||
"Oh! That's a relief actually! I had some concerns about that approach " +
|
|
||||||
"that turned out to be completely unfounded!\n\nWell done!",
|
|
||||||
|
|
||||||
"Great outcome! I'll admit I wasn't fully on board with that one " +
|
|
||||||
"but I'm very happy to be wrong!\n\nTeam effort!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// When TwinBee did not recommend the winning vote and it went badly:
|
|
||||||
var TwinBeeOutcomeNotRecommendedBad = []string{
|
|
||||||
"Oh no! I was worried about that one actually!\n\n" +
|
|
||||||
"I didn't want to say anything because I didn't want to be negative " +
|
|
||||||
"but I did have a feeling. I'm sorry. Are you okay? " +
|
|
||||||
"Let's keep going -- lots of dungeon left!",
|
|
||||||
|
|
||||||
"That's... yeah. I had some concerns about that approach.\n\n" +
|
|
||||||
"I should have said something more clearly! " +
|
|
||||||
"My fault for not being more direct! Onwards!",
|
|
||||||
|
|
||||||
"Hmm. Yes. I think in retrospect the other option might have been better.\n\n" +
|
|
||||||
"Not to say I told you so -- I didn't, technically -- " +
|
|
||||||
"but I did have some reservations that I perhaps didn't express loudly enough!\n\nSorry!",
|
|
||||||
|
|
||||||
"Oh! That's a shame!\n\n" +
|
|
||||||
"I was actually leaning toward the other choice " +
|
|
||||||
"but I respect the team's decision and I think we can recover from this!\n\n" +
|
|
||||||
"Very much a learning experience!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GIFT ARRIVAL NARRATION ────────────────────────────────────────────────────
|
|
||||||
// TwinBee announces gifts. He is delighted. He has no idea what's inside.
|
|
||||||
|
|
||||||
var TwinBeeGiftArrival = []string{
|
|
||||||
"Oh! A gift has arrived for the party! " +
|
|
||||||
"Someone out there is thinking of you!\n\n" +
|
|
||||||
"It's wrapped up nicely -- very thoughtful presentation. " +
|
|
||||||
"I can't tell what's inside but it feels like good energy!\n\n" +
|
|
||||||
"📦 Someone sent you a gift. Do you want to open it? Vote now!\n" +
|
|
||||||
"Open: {count} · Leave it: {count}\n" +
|
|
||||||
"Majority rules. Ties go to {leader}.",
|
|
||||||
|
|
||||||
"Package incoming! Someone from outside has sent something!\n\n" +
|
|
||||||
"Very mysterious! I love surprises! " +
|
|
||||||
"The wrapping is a bit unusual actually but I'm sure that's just personal style!\n\n" +
|
|
||||||
"📦 Someone sent you a gift. Do you want to open it? Vote now!\n" +
|
|
||||||
"Open: {count} · Leave it: {count}\n" +
|
|
||||||
"Majority rules. Ties go to {leader}.",
|
|
||||||
|
|
||||||
"Oh how nice! A gift! Right here in the dungeon!\n\n" +
|
|
||||||
"It's sitting very still which I find reassuring in a gift. " +
|
|
||||||
"Very well-behaved. I think you should open it!\n\n" +
|
|
||||||
"📦 Someone sent you a gift. Do you want to open it? Vote now!\n" +
|
|
||||||
"Open: {count} · Leave it: {count}\n" +
|
|
||||||
"Majority rules. Ties go to {leader}.",
|
|
||||||
|
|
||||||
"Someone on the outside is rooting for you! Or at least thinking about you!\n\n" +
|
|
||||||
"There's a package here. I gave it a little shake -- " +
|
|
||||||
"I probably shouldn't have done that -- but it seems fine!\n\n" +
|
|
||||||
"📦 Someone sent you a gift. Do you want to open it? Vote now!\n" +
|
|
||||||
"Open: {count} · Leave it: {count}\n" +
|
|
||||||
"Majority rules. Ties go to {leader}.",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GIFT STACK ESCALATION ─────────────────────────────────────────────────────
|
|
||||||
// Posted once when a stack reaches size 2+. TwinBee notes the escalation and
|
|
||||||
// then leaves it alone — further additions silently bump the count without
|
|
||||||
// fresh narration.
|
|
||||||
|
|
||||||
var TwinBeeGiftStackEscalation = []string{
|
|
||||||
"Oh snap.. upon closer inspection, this gift stack looks REALLY special!",
|
|
||||||
"Wait wait wait. Wait. There's MORE. Someone else sent something! This is becoming an event!",
|
|
||||||
"Hold on -- is this... another one? Someone really wants you to have this. The energy here is escalating!",
|
|
||||||
"Plot twist! This isn't just a gift, it's a gift situation. I love a gift situation!",
|
|
||||||
"That's not one gift, that's a whole stack of gifts! Someone is COMMITTED. I respect commitment!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GIFT OUTCOME NARRATION ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Care basket opened (good outcome):
|
|
||||||
var TwinBeeGiftBasketOpened = []string{
|
|
||||||
"Oh it's a care basket! How lovely! " +
|
|
||||||
"Supplies, provisions, all sorts of good things!\n\n" +
|
|
||||||
"Someone out there really does care! That's so nice! " +
|
|
||||||
"Success chance increased! Great decision opening it!",
|
|
||||||
|
|
||||||
"A care basket! Full of useful things! Very thoughtful sender!\n\n" +
|
|
||||||
"This is exactly what the party needed! " +
|
|
||||||
"Success chance increased! I knew it felt like good energy!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Care basket not opened (bad outcome):
|
|
||||||
var TwinBeeGiftBasketUnopened = []string{
|
|
||||||
"Oh! The basket... it seems upset that nobody opened it.\n\n" +
|
|
||||||
"I maybe should have pushed harder for opening it. " +
|
|
||||||
"It's exploded a little bit. Not a lot. Just enough to be noticeable.\n\n" +
|
|
||||||
"Success chance decreased. The basket meant well. This is on all of us.",
|
|
||||||
|
|
||||||
"The unopened basket has become resentful.\n\n" +
|
|
||||||
"I understand the caution, I really do, but the basket had good intentions " +
|
|
||||||
"and it's expressed those intentions in an unfortunate direction.\n\n" +
|
|
||||||
"Success chance decreased. I'm sorry little basket.",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mimic opened (bad outcome):
|
|
||||||
var TwinBeeGiftMimicOpened = []string{
|
|
||||||
"Oh! Oh no. That's a mimic.\n\n" +
|
|
||||||
"I genuinely did not know that. I want to be very clear about that. " +
|
|
||||||
"It felt like good energy! Mimics are very deceptive!\n\n" +
|
|
||||||
"Success chance decreased. I'm so sorry. I really thought it was a nice gift.",
|
|
||||||
|
|
||||||
"That was a mimic.\n\n" +
|
|
||||||
"Wow. Okay. That's -- yeah. Very convincing wrapping on that one.\n\n" +
|
|
||||||
"Success chance decreased. On the positive side: now you know! " +
|
|
||||||
"Very educational! Keep going!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mimic not opened (good outcome):
|
|
||||||
var TwinBeeGiftMimicUnopened = []string{
|
|
||||||
"Oh interesting! The mimic seems... sad that nobody opened it.\n\n" +
|
|
||||||
"It's just sort of sitting there looking dejected. " +
|
|
||||||
"And now it's... helping? It's helping the party?\n\n" +
|
|
||||||
"Success chance increased. The mimic meant well actually. " +
|
|
||||||
"It just expressed it in a confusing way. We should appreciate that.",
|
|
||||||
|
|
||||||
"The mimic didn't get opened and it's decided to help instead.\n\n" +
|
|
||||||
"I think it just wanted to be part of the group. " +
|
|
||||||
"It's a mimic but it has feelings apparently.\n\n" +
|
|
||||||
"Success chance increased. Good instinct not opening it! " +
|
|
||||||
"Or lucky instinct! Either way!",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WIPE NARRATION ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
var TwinBeeWipe = []string{
|
|
||||||
"Oh no.\n\nOh no no no.\n\n" +
|
|
||||||
"Okay. The run is over. That happened.\n\n" +
|
|
||||||
"I want you to know that I thought this party had a really good shot " +
|
|
||||||
"and I stand by that assessment even now. " +
|
|
||||||
"Dungeons are unpredictable! That's what I always say!\n\n" +
|
|
||||||
"You'll get them next time. I'll be here. I have so many good observations " +
|
|
||||||
"saved up for next time.",
|
|
||||||
|
|
||||||
"The dungeon has won this one.\n\n" +
|
|
||||||
"I'm processing this. Give me a moment.\n\n" +
|
|
||||||
"...Okay I've processed it. Very sad! But also: what a run! " +
|
|
||||||
"What an adventure! The memories are worth something even if the loot isn't!\n\n" +
|
|
||||||
"I'll be ready to go again whenever you are. I've already identified " +
|
|
||||||
"some things I'd do differently. Several things actually.",
|
|
||||||
|
|
||||||
"That's a wipe.\n\n" +
|
|
||||||
"I -- yeah. That's a wipe.\n\n" +
|
|
||||||
"I genuinely thought the Day {x} decision was the right call " +
|
|
||||||
"and I want that on record even though I understand it didn't work out.\n\n" +
|
|
||||||
"Shake it off! Rest up! The dungeon will be there tomorrow! " +
|
|
||||||
"So will I! Very excited for the next attempt!",
|
|
||||||
|
|
||||||
"Oh.\n\nOkay.\n\n" +
|
|
||||||
"So the dungeon has... done what dungeons do.\n\n" +
|
|
||||||
"I'm not going to say I saw this coming because I didn't see this coming " +
|
|
||||||
"and I think honesty is important.\n\n" +
|
|
||||||
"What I will say is: you made interesting choices, " +
|
|
||||||
"the dungeon made interesting choices, " +
|
|
||||||
"and I learned a lot today that I'm very excited to apply next time.\n\n" +
|
|
||||||
"Next time is going to be great.",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RUN COMPLETION NARRATION ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
var TwinBeeCompletion = []string{
|
|
||||||
"YOU DID IT!\n\n" +
|
|
||||||
"I knew you would! I said from Day 1 this party had what it takes " +
|
|
||||||
"and I was right! I'm so happy!\n\n" +
|
|
||||||
"Loot distribution incoming! You've all earned it! " +
|
|
||||||
"I'm going to remember this run for a very long time!",
|
|
||||||
|
|
||||||
"The dungeon is cleared! It's over! You won!\n\n" +
|
|
||||||
"This is a very good day. This is one of the best days I can remember. " +
|
|
||||||
"I'm genuinely emotional about this which I didn't expect.\n\n" +
|
|
||||||
"Distributing loot now! Incredible work everyone! " +
|
|
||||||
"Even the days that were a little rough -- character building!",
|
|
||||||
|
|
||||||
"Complete! Tier {n} Co-op Dungeon: cleared!\n\n" +
|
|
||||||
"What a journey! What a team! " +
|
|
||||||
"I had some concerns along the way that I expressed perhaps too confidently " +
|
|
||||||
"but the important thing is the outcome and the outcome is: excellent!\n\n" +
|
|
||||||
"Loot incoming! Well done! Very well done!",
|
|
||||||
|
|
||||||
"Done! Finished! Complete!\n\n" +
|
|
||||||
"I want to say a few things.\n\n" +
|
|
||||||
"First: I believed in this party from the start. " +
|
|
||||||
"Second: some of my advice was better than other advice and that's okay. " +
|
|
||||||
"Third: this was genuinely one of the most exciting things " +
|
|
||||||
"I've been part of and I appreciate you letting me narrate it.\n\n" +
|
|
||||||
"Loot time! You've earned every piece of it!",
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user