mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
266 lines
8.9 KiB
Go
266 lines
8.9 KiB
Go
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()
|
||
}
|