mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
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:
332
internal/plugin/coop_dungeon_test.go
Normal file
332
internal/plugin/coop_dungeon_test.go
Normal file
@@ -0,0 +1,332 @@
|
||||
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 no leader vote falls back deterministically", map[id.UserID]string{other: "A", third: "B"}, ""}, // "A" or "B" — accept either
|
||||
}
|
||||
for _, tc := range tests {
|
||||
event := &CoopEvent{Votes: tc.votes}
|
||||
got := coopTallyVote(event, leader)
|
||||
if tc.want == "" {
|
||||
if got != "A" && got != "B" {
|
||||
t.Errorf("%s: got %q, want one of A/B", tc.name, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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
|
||||
}
|
||||
Reference in New Issue
Block a user