Files
gogobee/internal/plugin/coop_dungeon_balance_test.go
prosolis 8ad31a0009 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>
2026-04-26 07:58:13 -07:00

266 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}