Add co-op dungeon system (party runs, voting, betting, gifts, items)

Multi-day party runs with funding decisions, TwinBee-narrated floor events,
spectator parimutuel betting, basket/mimic gift system, and weighted-roll
item distribution including masterwork drops at T4-T5.

Schema (5 tables, 2 fewer than spec by computing helpfulness on-the-fly and
skipping the loot-pending state):
- coop_dungeon_runs / _members (daily funding as JSON column)
- coop_dungeon_events (votes as JSON, used to derive TwinBee helpfulness)
- coop_dungeon_bets / _gifts

Mechanics:
- 2-4 player parties, 24h invite window, locks consume one combat action
- Per-floor success roll: base + sum(funding) + level/pet bonuses + event
  vote modifier + active gift modifiers, clamped to 5..95
- Funding tiers (none/min/std/agg/all_in) with liability cap at +8% for
  under-leveled players
- TwinBee narrates events from existing flavor pool; 20 authored events with
  per-option modifiers and embedded recommendation
- Parimutuel betting with 10% rake; odds line shown on lock/daily/status posts
- Gift modifiers symmetric at 50/50 sender mix so no dominant strategy
- Item drops on success via weight-by-contribution roll; T4 25% / T5 100%
  masterwork chance (random pick from existing T4/T5 defs)

Balance via Monte Carlo (coop_dungeon_balance_test.go):
- All tiers exceed 1.5x solo daily income at average party profile
- Optimal funding strategy walks up tiers correctly (Min/Std/Std/Mixed/Agg)
- All-In stays -EV at every tier (boost lever, not optimal play)

Tests: parsing, liability cap, JSON roundtrips, vote tally with leader
tiebreak, event meta consistency, parimutuel payouts, gift EV symmetry,
weighted-roll distribution (Monte Carlo), masterwork tier gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-26 07:58:13 -07:00
parent 16d64323d9
commit 8ad31a0009
12 changed files with 3757 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
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
}