Files
gogobee/internal/plugin/coop_event_meta.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

204 lines
7.4 KiB
Go
Raw 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 (
"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
}