989 lines
39 KiB
Markdown
989 lines
39 KiB
Markdown
# Simulation Engine Architecture: Quark's Holo-Grid Ledger
|
||
|
||
> **Status**: Design Analysis — Pre-Implementation
|
||
> **Author**: Engine Architecture Team
|
||
> **Dependencies**: `data-source/taxonomy.yaml`, Go 1.22+, Valkey 8.x
|
||
|
||
---
|
||
|
||
## 1. Engine Overview
|
||
|
||
The simulation engine is a **Go-based daemon** that reads the static taxonomy, generates live match events on a rolling schedule, simulates tick-by-tick combat, calculates real-time probabilities for all market types, and pushes continuous state updates into Valkey for the TanStack Start frontend to consume.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ SIMULATION ENGINE │
|
||
│ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
|
||
│ │SCHEDULER │→ │MATCHMAKER│→ │SIMULATOR │→ │PROBABILITY│ │
|
||
│ │ │ │ │ │ (tick) │ │ ENGINE │ │
|
||
│ └──────────┘ └──────────┘ └────┬─────┘ └─────┬─────┘ │
|
||
│ │ │ │
|
||
│ ┌────▼─────┐ ┌────▼─────┐ │
|
||
│ │ EVENT │ │ MARKET │ │
|
||
│ │GENERATOR │ │ UPDATER │ │
|
||
│ └──────────┘ └────┬─────┘ │
|
||
│ │ │
|
||
│ ┌─────▼──────┐ │
|
||
│ │ VALKEY │ │
|
||
│ │ WRITER │ │
|
||
│ └────────────┘ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Engine Modules
|
||
|
||
| Module | Responsibility | Frequency |
|
||
|--------|---------------|-----------|
|
||
| **Scheduler** | Manages match lifecycle: create, run, resolve, retire | Every 30s check |
|
||
| **Matchmaker** | Pairs factions, picks venues, generates initial markets | On match creation |
|
||
| **Simulator** | Runs one combat tick per active match | Every 2s per match |
|
||
| **Event Generator** | Rolls for random in-match events | Every tick |
|
||
| **Probability Engine** | Recalculates all market odds from current state | Every tick |
|
||
| **Market Updater** | Applies probability → price conversion + margin | Every tick |
|
||
| **Valkey Writer** | Serializes & pushes atomic state updates to cache | Every tick |
|
||
|
||
---
|
||
|
||
## 2. Match Lifecycle
|
||
|
||
Every match (sector engagement) follows a deterministic state machine:
|
||
|
||
```
|
||
SCHEDULED ──────► PRE-MATCH ──────► LIVE ──────► RESOLVING ──────► SETTLED
|
||
│ │ │ │ │
|
||
│ T-60s before │ Markets open │ Combat ticks │ Winner found │ Markets paid
|
||
│ match start │ initial odds │ odds moving │ final odds │ match archived
|
||
│ │ set from │ events fire │ locked │
|
||
│ │ faction stats │ │ │
|
||
▼ ▼ ▼ ▼ ▼
|
||
Announced to Betslip accepts Live telemetry 3-tick cooldown Removed from
|
||
frontend index wagers streaming no new bets active index
|
||
```
|
||
|
||
### Lifecycle Timing
|
||
|
||
| Phase | Duration | Markets | Description |
|
||
|-------|----------|---------|-------------|
|
||
| `SCHEDULED` | 30–90s | Closed | Match announced, factions & venue visible, no betting |
|
||
| `PRE_MATCH` | 60s | Open (pre-match odds) | Initial odds calculated, pre-match wagers accepted |
|
||
| `LIVE` | 60–150 ticks (2–5 min) | Open (live odds) | Active combat simulation, odds shift each tick |
|
||
| `RESOLVING` | 3 ticks (6s) | **Suspended** | Winner determined, final state locked, dramatic pause |
|
||
| `SETTLED` | 10s | Closed (final) | Results committed, markets graded, match archived |
|
||
|
||
### Concurrent Match Capacity
|
||
|
||
The engine should maintain a **rolling window** of active matches:
|
||
|
||
```go
|
||
const (
|
||
TargetActiveMatches = 8 // Aim for 8 concurrent live matches
|
||
MaxActiveMatches = 12 // Hard cap
|
||
MinActiveMatches = 4 // Trigger new match creation if below
|
||
MatchCreationCooldown = 15 // Seconds between new match spawns
|
||
)
|
||
```
|
||
|
||
The scheduler continuously checks: if `active < MinActiveMatches`, create new matches until `TargetActiveMatches` is reached. This guarantees the frontend always has events to display across all quadrants.
|
||
|
||
---
|
||
|
||
## 3. Matchmaker — Pairing Algorithm
|
||
|
||
The matchmaker selects two factions and a venue for each new engagement.
|
||
|
||
### 3.1 Faction Pairing Rules
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────┐
|
||
│ PAIRING PROBABILITY WEIGHTS │
|
||
├─────────────────────────────────────────────────┤
|
||
│ Same-quadrant matchup │ 60% chance │
|
||
│ Cross-quadrant matchup │ 30% chance │
|
||
│ Cross-quadrant (distant) │ 10% chance │
|
||
│ (Alpha↔Delta, Beta↔Gamma) │ │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Constraints:**
|
||
- A faction cannot appear in more than 2 simultaneous active matches
|
||
- Repeat matchups (same two factions) require a 3-match cooldown
|
||
- Rivalry matchups (high rivalry_modifier) get a 2x selection weight boost — they're marquee events
|
||
|
||
### 3.2 Venue Selection
|
||
|
||
```go
|
||
func SelectVenue(home, away FactionDef, taxonomy Taxonomy) SectorPoolItem {
|
||
// 1. Prefer venues in the home faction's quadrant (60%)
|
||
// 2. Neutral venues from either faction's quadrant (30%)
|
||
// 3. Random venue from any quadrant (10%)
|
||
|
||
// Weight boost for thematic venues:
|
||
// - If home faction is Klingon and venue is "qonos-arena" → 3x weight
|
||
// - If matchup has rivalry_modifier > 1.20 → prefer "high-profile" tagged venues
|
||
|
||
// Environmental modifiers from the venue apply to the entire match
|
||
}
|
||
```
|
||
|
||
### 3.3 Initial Odds Calculation
|
||
|
||
Pre-match odds derive from **faction combat stats + venue modifiers + rivalry premium**:
|
||
|
||
```go
|
||
func CalculatePreMatchOdds(home, away FactionDef, venue SectorPoolItem) float64 {
|
||
// 1. Calculate effective power ratings
|
||
homePower := (float64(home.BaseShield) * venue.ShieldModifier * 0.4) +
|
||
(float64(home.BaseAttack) * venue.DamageModifier * 0.4) +
|
||
(float64(home.BaseEvasion) * venue.EvasionModifier * 0.2)
|
||
|
||
awayPower := (float64(away.BaseShield) * venue.ShieldModifier * 0.4) +
|
||
(float64(away.BaseAttack) * venue.DamageModifier * 0.4) +
|
||
(float64(away.BaseEvasion) * venue.EvasionModifier * 0.2)
|
||
|
||
// 2. Convert power differential to probability via logistic function
|
||
diff := homePower - awayPower
|
||
homeProb := 1.0 / (1.0 + math.Exp(-diff / ScalingFactor))
|
||
|
||
// 3. Apply rivalry modifier (inflates drama, widens spread)
|
||
rivalryMod := home.RivalryModifiers[away.ID] // e.g., 1.35
|
||
// Rivalry pushes odds away from 50/50 — makes favorites more favored
|
||
|
||
// 4. Convert probability to decimal odds
|
||
homeOdds := 1.0 / homeProb
|
||
awayOdds := 1.0 / (1.0 - homeProb)
|
||
|
||
// 5. Apply house margin (overround)
|
||
// Target: 105-108% overround (realistic sportsbook margin)
|
||
margin := 1.06
|
||
homeOdds = homeOdds / margin
|
||
awayOdds = awayOdds / margin
|
||
|
||
return homeOdds // (and awayOdds)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Combat Simulator — The Tick Loop
|
||
|
||
Every 2 seconds, each active match processes one **combat tick**.
|
||
|
||
### 4.1 Tick Sequence
|
||
|
||
```
|
||
FOR each active match:
|
||
│
|
||
├─ 1. RESOLVE ACTIVE EFFECTS
|
||
│ └─ Process lingering buffs/debuffs from prior events
|
||
│
|
||
├─ 2. CALCULATE EFFECTIVE STATS
|
||
│ ├─ Base stats × venue modifiers × active effects
|
||
│ └─ Factor in type advantages (aggressive vs defensive, etc.)
|
||
│
|
||
├─ 3. COMBAT EXCHANGE
|
||
│ ├─ Both factions attack simultaneously
|
||
│ ├─ Roll hit/miss for each (attack_power vs opponent evasion)
|
||
│ ├─ Calculate damage dealt (if hit)
|
||
│ ├─ Apply damage to opponent shields
|
||
│ └─ Accumulate kinetic_yield (total energy released)
|
||
│
|
||
├─ 4. EVENT ROLL
|
||
│ ├─ Roll against venue.special_event_chance
|
||
│ ├─ Roll against faction-specific event triggers
|
||
│ └─ If event fires → apply effects, log to event stream
|
||
│
|
||
├─ 5. CHECK WIN CONDITIONS
|
||
│ ├─ Shield collapse (one faction hits 0%)
|
||
│ ├─ Mutual destruction (both hit 0% same tick)
|
||
│ └─ Time limit (max ticks reached → highest shields wins)
|
||
│
|
||
├─ 6. RECALCULATE ALL MARKET PROBABILITIES
|
||
│ └─ Feed current state to probability engine
|
||
│
|
||
└─ 7. PUSH TO VALKEY
|
||
└─ Atomic JSON state update
|
||
```
|
||
|
||
### 4.2 Combat Resolution Math
|
||
|
||
```go
|
||
type TickResult struct {
|
||
HomeDamageDealt float64
|
||
AwayDamageDealt float64
|
||
HomeShieldsAfter float64
|
||
AwayShieldsAfter float64
|
||
KineticYieldDelta float64
|
||
EventFired *MatchEvent // nil if no event
|
||
}
|
||
|
||
func ResolveCombatTick(match *LiveMatch) TickResult {
|
||
home := &match.Factions.Home
|
||
away := &match.Factions.Away
|
||
venue := match.Venue
|
||
|
||
// Effective stats = base × venue modifier × active effects
|
||
homeAttack := float64(home.EffectiveAttack) * venue.DamageModifier
|
||
awayAttack := float64(away.EffectiveAttack) * venue.DamageModifier
|
||
homeEvasion := float64(home.EffectiveEvasion) * venue.EvasionModifier
|
||
awayEvasion := float64(away.EffectiveEvasion) * venue.EvasionModifier
|
||
|
||
// Hit chance = attacker_power / (attacker_power + defender_evasion)
|
||
// This creates a natural 0-1 probability range
|
||
homeHitChance := homeAttack / (homeAttack + awayEvasion)
|
||
awayHitChance := awayAttack / (awayAttack + homeEvasion)
|
||
|
||
// Roll for hits
|
||
var homeDmg, awayDmg float64
|
||
if rand.Float64() < homeHitChance {
|
||
// Home hits away — damage = base * random(0.6, 1.4) variance
|
||
homeDmg = homeAttack * (0.6 + rand.Float64()*0.8) * 0.1 // Scale to shield %
|
||
}
|
||
if rand.Float64() < awayHitChance {
|
||
awayDmg = awayAttack * (0.6 + rand.Float64()*0.8) * 0.1
|
||
}
|
||
|
||
// Apply type advantage multipliers
|
||
homeDmg *= TypeAdvantage(home.Type, away.Type)
|
||
awayDmg *= TypeAdvantage(away.Type, home.Type)
|
||
|
||
// Apply damage to shields (clamped to 0-100)
|
||
away.ShieldCapacity = clamp(away.ShieldCapacity - homeDmg, 0, 100)
|
||
home.ShieldCapacity = clamp(home.ShieldCapacity - awayDmg, 0, 100)
|
||
|
||
// Accumulate kinetic yield (total energy released = total damage dealt)
|
||
kineticDelta := homeDmg + awayDmg
|
||
match.KineticYield += kineticDelta
|
||
|
||
return TickResult{
|
||
HomeDamageDealt: homeDmg,
|
||
AwayDamageDealt: awayDmg,
|
||
HomeShieldsAfter: home.ShieldCapacity,
|
||
AwayShieldsAfter: away.ShieldCapacity,
|
||
KineticYieldDelta: kineticDelta,
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.3 Type Advantage Matrix
|
||
|
||
Combat types create a rock-paper-scissors dynamic:
|
||
|
||
```
|
||
DEFENDER
|
||
aggressive defensive tactical infiltrator swarm
|
||
ATTACKER ┌──────────────────────────────────────────────────────┐
|
||
aggressive│ 1.00 0.80 1.20 1.30 0.90 │
|
||
defensive │ 1.20 1.00 0.90 0.80 1.10 │
|
||
tactical │ 0.80 1.10 1.00 1.20 1.00 │
|
||
infiltratr│ 0.70 1.20 0.80 1.00 1.30 │
|
||
swarm │ 1.10 0.90 1.00 0.70 1.00 │
|
||
└──────────────────────────────────────────────────────┘
|
||
|
||
Key interactions:
|
||
aggressive → infiltrator: 1.30 (brute force overwhelms trickery)
|
||
defensive → aggressive: 1.20 (shields absorb the charge)
|
||
infiltrator→ defensive: 1.20 (bypasses shield walls)
|
||
tactical → aggressive: counterplay
|
||
swarm → infiltrator: 0.70 (can't find them all)
|
||
infiltrator→ swarm: 1.30 (surgical strikes on hive nodes)
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Random Event System
|
||
|
||
Events inject narrative drama and create sudden odds shifts — the equivalent of a red card, injury, or weather delay in real sports.
|
||
|
||
### 5.1 Event Architecture
|
||
|
||
```go
|
||
type MatchEvent struct {
|
||
ID string // Unique event instance ID
|
||
TemplateID string // Reference to event template
|
||
Name string // Display name
|
||
Description string // Narrative text for UI feed
|
||
Tick int // When it fired
|
||
Category EventCategory // combat | environmental | faction | market
|
||
Effects []EventEffect // Stat modifications
|
||
Duration int // Ticks the effect lasts (0 = instant)
|
||
Severity string // minor | major | critical
|
||
}
|
||
|
||
type EventEffect struct {
|
||
Target string // "home" | "away" | "both" | "market"
|
||
Stat string // "shield" | "attack" | "evasion" | "price"
|
||
Operation string // "add" | "multiply" | "set"
|
||
Value float64
|
||
}
|
||
```
|
||
|
||
### 5.2 Event Categories & Templates
|
||
|
||
#### COMBAT EVENTS (triggered by combat state)
|
||
|
||
| Event | Trigger | Effect | Duration | Severity |
|
||
|-------|---------|--------|----------|----------|
|
||
| **Critical Hit** | 5% per hit | 3× damage on this hit | Instant | minor |
|
||
| **Shield Harmonics Failure** | When shields < 30% | Shields drop additional 10% | Instant | major |
|
||
| **Weapons Overload** | 3% per tick | Attacker deals 2× but takes 5% self-damage | Instant | major |
|
||
| **Emergency Shield Reroute** | When shields < 15% | Restore 20% shields, disable attack for 3 ticks | 3 ticks | critical |
|
||
| **Hull Breach** | When shields hit 0% for first time | Shields cannot regenerate above 25% | Permanent | critical |
|
||
|
||
#### ENVIRONMENTAL EVENTS (triggered by venue)
|
||
|
||
| Event | Base Chance | Venue Tags | Effect | Duration |
|
||
|-------|-------------|------------|--------|----------|
|
||
| **Plasma Storm Surge** | venue.special_event_chance | plasma-storms | Both shields −15% | Instant |
|
||
| **Gravitational Anomaly** | venue.special_event_chance × 0.5 | any | Both evasion ×0.5 | 5 ticks |
|
||
| **Subspace Rift** | venue.special_event_chance × 0.3 | temporal-anomaly | Random shield reset (both set to random 40-80%) | Instant |
|
||
| **Sensor Blackout** | venue.special_event_chance × 0.4 | sensor-interference | All markets SUSPENDED | 3 ticks |
|
||
| **Debris Impact** | venue.special_event_chance × 0.6 | debris-field, debris-dense | Random faction takes 8-15% shield damage | Instant |
|
||
| **Energy Dampening Field** | venue.special_event_chance × 0.2 | energy-dampening | Both attack ×0.6 | 4 ticks |
|
||
|
||
#### FACTION-SPECIFIC EVENTS (triggered by faction type)
|
||
|
||
| Event | Faction(s) | Trigger | Effect |
|
||
|-------|-----------|---------|--------|
|
||
| **Borg Adaptation** | `borg` | After taking 3 consecutive hits | Incoming damage ×0.5 for 5 ticks |
|
||
| **Cloaking Ambush** | `romulans` | 8% per tick when shields > 60% | Next attack deals 2.5× damage, market suspends 1 tick |
|
||
| **Honor Duel Challenge** | `klingons` | When losing by 20+ shield points | Attack ×1.5, shields stop regenerating |
|
||
| **Prophet Intervention** | `bajorans` | 3% per tick | Shields restored to 70%, all markets suspended 2 ticks |
|
||
| **Energy Dampening Weapon** | `breen` | Once per match, 15% trigger | Opponent shields instantly halved |
|
||
| **Tholian Web Deployment** | `tholians` | When shields > 50% | Opponent evasion set to 0 for 4 ticks |
|
||
| **Ketracel Supply Drop** | `dominion` | When shields < 40% | All stats ×1.3 for 6 ticks |
|
||
| **Ferengi Tactical Retreat** | `ferengi` | When shields < 25% | Evasion ×3 for 3 ticks (hard to finish off) |
|
||
| **Species 8472 Bio-Pulse** | `species8472` | 10% per tick | Ignores shields, deals 20% direct damage |
|
||
| **Changeling Sabotage** | `dominion` (away only) | 5% per tick | Home faction attack ×0.7 for 3 ticks |
|
||
|
||
#### MARKET EVENTS (triggered by odds state)
|
||
|
||
| Event | Trigger | Effect |
|
||
|-------|---------|--------|
|
||
| **Subspace Interruption** | Shield swing > 25% in single tick | ALL markets suspended for 2 ticks |
|
||
| **Odds Avalanche** | One faction's moneyline crosses 5.00 | Exotic markets (comeback, mutual destruction) repriced with tighter odds |
|
||
| **Dead Heat Protocol** | Both shields within 5% and tick > 60% of max | Duration market prices collapse; match-winner market widens |
|
||
|
||
### 5.3 Event Execution Flow
|
||
|
||
```go
|
||
func (engine *Engine) RollForEvents(match *LiveMatch, tickResult TickResult) []MatchEvent {
|
||
var events []MatchEvent
|
||
|
||
// 1. Environmental event roll
|
||
if rand.Float64() < match.Venue.SpecialEventChance {
|
||
event := engine.pickEnvironmentalEvent(match.Venue)
|
||
events = append(events, event)
|
||
}
|
||
|
||
// 2. Faction-specific event checks
|
||
for _, side := range []string{"home", "away"} {
|
||
faction := match.GetFaction(side)
|
||
for _, template := range engine.factionEvents[faction.Type] {
|
||
if template.CheckTrigger(match, side, tickResult) {
|
||
if rand.Float64() < template.Probability {
|
||
event := template.Instantiate(match, side)
|
||
events = append(events, event)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Combat state events
|
||
if tickResult.HomeDamageDealt > 0 && rand.Float64() < 0.05 {
|
||
events = append(events, CriticalHitEvent("home"))
|
||
}
|
||
|
||
// 4. Market disruption checks
|
||
shieldSwing := math.Abs(tickResult.HomeShieldsAfter - tickResult.AwayShieldsAfter)
|
||
if shieldSwing > 25 {
|
||
events = append(events, SubspaceInterruptionEvent())
|
||
}
|
||
|
||
// Apply all event effects
|
||
for _, event := range events {
|
||
engine.applyEventEffects(match, event)
|
||
}
|
||
|
||
return events
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Probability Engine — The Core Odds Machine
|
||
|
||
This is the heart of the sportsbook. Every tick, after combat resolution and event processing, all market probabilities must be recalculated from the current match state.
|
||
|
||
### 6.1 Design Principles
|
||
|
||
1. **Probabilities are derived, not stored** — odds are always computed from the current state vector, never carried forward from previous ticks
|
||
2. **Overround is applied last** — compute fair probability first, then apply house margin
|
||
3. **Suspension trumps calculation** — if a market event has suspended a market, skip recalculation until the suspension lifts
|
||
4. **Odds floor/ceiling** — No market selection can go below 1.01 or above 51.00
|
||
|
||
### 6.2 Match State Vector
|
||
|
||
Every probability calculation takes this state as input:
|
||
|
||
```go
|
||
type MatchState struct {
|
||
HomeShields float64 // 0-100
|
||
AwayShields float64 // 0-100
|
||
HomeFactionType string // aggressive, defensive, etc.
|
||
AwayFactionType string
|
||
HomeBaseStats [3]int // shield, attack, evasion
|
||
AwayBaseStats [3]int
|
||
KineticYield float64 // Cumulative energy released
|
||
CurrentTick int // How far into the match
|
||
MaxTicks int // Match duration limit
|
||
ActiveEffects []Effect // Lingering buffs/debuffs
|
||
VenueModifiers Venue // Environmental modifiers
|
||
EventLog []Event // History (for conditional markets)
|
||
}
|
||
```
|
||
|
||
### 6.3 Market-Specific Probability Models
|
||
|
||
#### MODEL A: Match Winner (Moneyline)
|
||
|
||
The most important market. Uses a **logistic regression model** on shield differential, weighted by time remaining.
|
||
|
||
```go
|
||
func ProbabilityMatchWinner(state MatchState) (homeProb, awayProb float64) {
|
||
// Shield differential (positive = home advantage)
|
||
shieldDiff := state.HomeShields - state.AwayShields
|
||
|
||
// Time factor: early ticks → differential matters less
|
||
// Late ticks → differential matters more (less time to recover)
|
||
tickProgress := float64(state.CurrentTick) / float64(state.MaxTicks)
|
||
timeFactor := 0.5 + (tickProgress * 1.5) // Ranges 0.5 → 2.0
|
||
|
||
// Stat advantage: inherent faction strength difference
|
||
homePower := effectivePower(state.HomeBaseStats, state.VenueModifiers)
|
||
awayPower := effectivePower(state.AwayBaseStats, state.VenueModifiers)
|
||
statDiff := (homePower - awayPower) * 0.1 // Scaled down
|
||
|
||
// Combined signal
|
||
signal := (shieldDiff * timeFactor * 0.05) + statDiff
|
||
|
||
// Logistic function → probability
|
||
homeProb = 1.0 / (1.0 + math.Exp(-signal))
|
||
|
||
// Edge case: if one side is at 0 shields
|
||
if state.HomeShields <= 0 { homeProb = 0.0 }
|
||
if state.AwayShields <= 0 { homeProb = 1.0 }
|
||
|
||
// Clamp to avoid absurd odds
|
||
homeProb = clamp(homeProb, 0.02, 0.98)
|
||
awayProb = 1.0 - homeProb
|
||
|
||
return homeProb, awayProb
|
||
}
|
||
```
|
||
|
||
**Behavior examples:**
|
||
| State | Home P | Away P | Home Odds | Away Odds |
|
||
|-------|--------|--------|-----------|-----------|
|
||
| Start (both 100%) | 0.50 | 0.50 | 2.00 | 2.00 |
|
||
| Home 80%, Away 60%, tick 25% | 0.58 | 0.42 | 1.72 | 2.38 |
|
||
| Home 45%, Away 70%, tick 50% | 0.30 | 0.70 | 3.33 | 1.43 |
|
||
| Home 20%, Away 65%, tick 80% | 0.08 | 0.92 | 12.50 | 1.09 |
|
||
| Home 5%, Away 50%, tick 90% | 0.03 | 0.97 | 33.33 | 1.03 |
|
||
|
||
#### MODEL B: Total Kinetic Yield (Over/Under)
|
||
|
||
Projects final total kinetic yield from current rate and remaining time.
|
||
|
||
```go
|
||
func ProbabilityKineticYield(state MatchState, line float64) (overProb, underProb float64) {
|
||
// Current rate of energy release per tick
|
||
ticksElapsed := max(state.CurrentTick, 1)
|
||
currentRate := state.KineticYield / float64(ticksElapsed)
|
||
|
||
// Project remaining yield
|
||
remainingTicks := float64(state.MaxTicks - state.CurrentTick)
|
||
projectedTotal := state.KineticYield + (currentRate * remainingTicks)
|
||
|
||
// Variance decreases as match progresses (more certainty)
|
||
variance := (1.0 - float64(state.CurrentTick)/float64(state.MaxTicks)) * 3.0
|
||
variance = max(variance, 0.3) // Minimum uncertainty
|
||
|
||
// Normal CDF approximation
|
||
z := (line - projectedTotal) / variance
|
||
underProb = normalCDF(z)
|
||
overProb = 1.0 - underProb
|
||
|
||
// Clamp
|
||
overProb = clamp(overProb, 0.02, 0.98)
|
||
underProb = 1.0 - overProb
|
||
|
||
return overProb, underProb
|
||
}
|
||
```
|
||
|
||
#### MODEL C: First Shield Breach
|
||
|
||
Predicts which faction's shields hit zero first based on damage rate trajectories.
|
||
|
||
```go
|
||
func ProbabilityFirstBreach(state MatchState) (homeFirstProb, awayFirstProb float64) {
|
||
// If one already breached
|
||
if state.HomeShields <= 0 && state.AwayShields > 0 { return 1.0, 0.0 }
|
||
if state.AwayShields <= 0 && state.HomeShields > 0 { return 0.0, 1.0 }
|
||
if state.HomeShields <= 0 && state.AwayShields <= 0 { return 0.5, 0.5 }
|
||
|
||
// Estimate ticks to breach for each
|
||
homeDamageRate := estimateDamageRate("away_to_home", state) // damage per tick to home
|
||
awayDamageRate := estimateDamageRate("home_to_away", state) // damage per tick to away
|
||
|
||
homeTicksToBreach := state.HomeShields / max(homeDamageRate, 0.01)
|
||
awayTicksToBreach := state.AwayShields / max(awayDamageRate, 0.01)
|
||
|
||
// Faction with fewer ticks to breach is more likely to breach first
|
||
total := homeTicksToBreach + awayTicksToBreach
|
||
homeFirstProb = awayTicksToBreach / total // Inverted: if away takes longer, home breaches first more likely... wait
|
||
// Actually: if home has fewer ticks to breach, home breaches first
|
||
homeFirstProb = 1.0 - (homeTicksToBreach / total)
|
||
awayFirstProb = 1.0 - homeFirstProb
|
||
|
||
return clamp(homeFirstProb, 0.02, 0.98), clamp(awayFirstProb, 0.02, 0.98)
|
||
}
|
||
```
|
||
|
||
#### MODEL D: Engagement Duration (Over/Under Cycles)
|
||
|
||
```go
|
||
func ProbabilityDuration(state MatchState, line float64) (overProb, underProb float64) {
|
||
// Estimate remaining ticks based on shield drain rates
|
||
avgDrainRate := estimateAverageDrainRate(state) // Combined shield loss per tick
|
||
avgShieldsRemaining := (state.HomeShields + state.AwayShields) / 2.0
|
||
|
||
estimatedRemainingTicks := avgShieldsRemaining / max(avgDrainRate, 0.1)
|
||
estimatedTotalTicks := float64(state.CurrentTick) + estimatedRemainingTicks
|
||
|
||
// If already past the line, it's definitely over
|
||
if float64(state.CurrentTick) >= line {
|
||
return 1.0, 0.0
|
||
}
|
||
|
||
// Normal CDF on projected total vs line
|
||
variance := estimatedRemainingTicks * 0.3
|
||
z := (line - estimatedTotalTicks) / max(variance, 0.1)
|
||
underProb = normalCDF(z)
|
||
overProb = 1.0 - underProb
|
||
|
||
return clamp(overProb, 0.02, 0.98), clamp(underProb, 0.02, 0.98)
|
||
}
|
||
```
|
||
|
||
#### MODEL E: Comeback Victory
|
||
|
||
```go
|
||
func ProbabilityComeback(state MatchState, deficitThreshold float64) (homeComeback, awayComeback float64) {
|
||
// Check if the deficit condition has been met historically
|
||
homeMaxDeficit := trackMaxDeficit(state.EventLog, "home")
|
||
awayMaxDeficit := trackMaxDeficit(state.EventLog, "away")
|
||
|
||
homeDeficitMet := homeMaxDeficit >= deficitThreshold
|
||
awayDeficitMet := awayMaxDeficit >= deficitThreshold
|
||
|
||
if !homeDeficitMet && !awayDeficitMet {
|
||
// Need both: P(reach deficit) × P(win from deficit)
|
||
// This is very rare → long odds
|
||
homeComeback = 0.05 // ~5% base, adjusted by state
|
||
awayComeback = 0.05
|
||
} else {
|
||
// Deficit was met — now it's "just" a win probability from behind
|
||
if homeDeficitMet {
|
||
homeWinProb, _ := ProbabilityMatchWinner(state)
|
||
homeComeback = homeWinProb * 0.8 // Discount for comeback difficulty
|
||
}
|
||
if awayDeficitMet {
|
||
_, awayWinProb := ProbabilityMatchWinner(state)
|
||
awayComeback = awayWinProb * 0.8
|
||
}
|
||
}
|
||
|
||
return clamp(homeComeback, 0.02, 0.50), clamp(awayComeback, 0.02, 0.50)
|
||
}
|
||
```
|
||
|
||
#### MODEL F: Mutual Destruction
|
||
|
||
```go
|
||
func ProbabilityMutualDestruction(state MatchState) float64 {
|
||
// Both shields need to reach 0 on the same tick
|
||
// More likely when shields are close together and both low
|
||
shieldDiff := math.Abs(state.HomeShields - state.AwayShields)
|
||
avgShields := (state.HomeShields + state.AwayShields) / 2.0
|
||
|
||
// Base probability is very low
|
||
baseProb := 0.01
|
||
|
||
// Increases when shields are similar (small diff)
|
||
similarityBoost := 1.0 + (20.0 - min(shieldDiff, 20.0)) / 20.0 * 3.0
|
||
|
||
// Increases when both shields are low
|
||
lowShieldBoost := 1.0
|
||
if avgShields < 30 {
|
||
lowShieldBoost = 1.0 + (30.0 - avgShields) / 30.0 * 5.0
|
||
}
|
||
|
||
prob := baseProb * similarityBoost * lowShieldBoost
|
||
return clamp(prob, 0.005, 0.30)
|
||
}
|
||
```
|
||
|
||
#### MODEL G: Subspace Anomaly
|
||
|
||
```go
|
||
func ProbabilitySubspaceAnomaly(state MatchState) (anomalyProb, cleanProb float64) {
|
||
// Geometric distribution: P(at least one event in remaining ticks)
|
||
remainingTicks := state.MaxTicks - state.CurrentTick
|
||
perTickChance := state.VenueModifiers.SpecialEventChance
|
||
|
||
// Has an anomaly already occurred?
|
||
alreadyOccurred := hasAnomalyOccurred(state.EventLog)
|
||
if alreadyOccurred {
|
||
return 1.0, 0.0 // Already happened → "yes" is certain
|
||
}
|
||
|
||
// P(no anomaly in remaining ticks) = (1 - p)^n
|
||
cleanProb = math.Pow(1.0 - perTickChance, float64(remainingTicks))
|
||
anomalyProb = 1.0 - cleanProb
|
||
|
||
return clamp(anomalyProb, 0.02, 0.98), clamp(cleanProb, 0.02, 0.98)
|
||
}
|
||
```
|
||
|
||
### 6.4 Probability → Price Conversion
|
||
|
||
```go
|
||
func ProbabilityToPrice(prob float64, margin float64) float64 {
|
||
// Fair odds
|
||
fairOdds := 1.0 / prob
|
||
|
||
// Apply house margin (overround)
|
||
// margin = 1.06 means 106% book (6% house edge)
|
||
adjustedOdds := fairOdds / margin
|
||
|
||
// Apply floor/ceiling
|
||
adjustedOdds = clamp(adjustedOdds, 1.01, 51.00)
|
||
|
||
// Round to 2 decimal places
|
||
return math.Round(adjustedOdds * 100) / 100
|
||
}
|
||
```
|
||
|
||
### 6.5 Overround Strategy
|
||
|
||
The house margin varies by market category:
|
||
|
||
| Category | Target Overround | Rationale |
|
||
|----------|-----------------|-----------|
|
||
| Primary (moneyline, O/U) | 105–106% | High liquidity, competitive odds |
|
||
| Secondary (handicap, duration) | 107–108% | Moderate liquidity |
|
||
| Exotic (comeback, mutual dest.) | 110–115% | Low liquidity, high variance, more house edge |
|
||
|
||
---
|
||
|
||
## 7. Valkey Data Schema
|
||
|
||
### 7.1 Key Structure
|
||
|
||
```
|
||
sector:telemetry:{match_id} → Full match JSON state (HSET)
|
||
sector:events:{match_id} → Capped event log, last 20 events (LIST)
|
||
sector:index:active → Set of active match IDs (SET)
|
||
sector:index:quadrant:{q_id} → Active match IDs in quadrant (SET)
|
||
sector:schedule:upcoming → Upcoming match announcements (SORTED SET by start time)
|
||
global:stats → Engine statistics (HASH)
|
||
```
|
||
|
||
### 7.2 Match Telemetry Payload (JSON pushed to Valkey)
|
||
|
||
This is the exact JSON the TanStack Start frontend receives:
|
||
|
||
```json
|
||
{
|
||
"id": "wolf-359-a7f3",
|
||
"name": "Wolf 359 — Memorial Perimeter",
|
||
"quadrant": "alpha",
|
||
"status": "LIVE",
|
||
"tick": 42,
|
||
"maxTicks": 90,
|
||
"kinetic_yield": 14.7,
|
||
"venue": {
|
||
"id": "wolf-359",
|
||
"name": "Wolf 359 — Memorial Perimeter",
|
||
"tags": ["memorial", "debris-field", "high-anomaly"]
|
||
},
|
||
"factions": {
|
||
"home": {
|
||
"id": "starfleet",
|
||
"name": "Starfleet Command",
|
||
"abbreviation": "SFC",
|
||
"type": "defensive",
|
||
"glyph": "◆",
|
||
"colorCode": "#34d399",
|
||
"shieldCapacity": 62,
|
||
"effectiveAttack": 58,
|
||
"effectiveEvasion": 40,
|
||
"activeEffects": []
|
||
},
|
||
"away": {
|
||
"id": "borg",
|
||
"name": "The Borg Collective",
|
||
"abbreviation": "BRG",
|
||
"type": "swarm",
|
||
"glyph": "█",
|
||
"colorCode": "#38bdf8",
|
||
"shieldCapacity": 71,
|
||
"effectiveAttack": 99,
|
||
"effectiveEvasion": 12,
|
||
"activeEffects": [
|
||
{ "name": "Borg Adaptation", "remainingTicks": 3, "effect": "damage_taken_x0.5" }
|
||
]
|
||
}
|
||
},
|
||
"markets": [
|
||
{
|
||
"id": "wolf-359-a7f3-moneyline-home",
|
||
"templateId": "match-winner",
|
||
"name": "Starfleet Command Decisive Victory",
|
||
"category": "primary",
|
||
"price": 2.85,
|
||
"previousPrice": 2.70,
|
||
"trend": "up",
|
||
"status": "OPEN",
|
||
"impliedProbability": 0.351
|
||
},
|
||
{
|
||
"id": "wolf-359-a7f3-moneyline-away",
|
||
"templateId": "match-winner",
|
||
"name": "The Borg Collective Decisive Victory",
|
||
"category": "primary",
|
||
"price": 1.48,
|
||
"previousPrice": 1.52,
|
||
"trend": "down",
|
||
"status": "OPEN",
|
||
"impliedProbability": 0.676
|
||
},
|
||
{
|
||
"id": "wolf-359-a7f3-ky-over",
|
||
"templateId": "kinetic-over-under",
|
||
"name": "Over 8.5 Terawatts",
|
||
"category": "primary",
|
||
"price": 1.35,
|
||
"previousPrice": 1.40,
|
||
"trend": "down",
|
||
"status": "OPEN",
|
||
"impliedProbability": 0.741
|
||
}
|
||
],
|
||
"recentEvents": [
|
||
{
|
||
"tick": 38,
|
||
"name": "Borg Adaptation",
|
||
"description": "The Collective adapts to Starfleet phaser frequencies. Incoming damage reduced by 50%.",
|
||
"severity": "major"
|
||
},
|
||
{
|
||
"tick": 31,
|
||
"name": "Debris Impact",
|
||
"description": "Wolf 359 wreckage collides with Borg cube. Shields -12%.",
|
||
"severity": "minor"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 7.3 Event Log Entry (LPUSH to sector:events:{match_id})
|
||
|
||
```json
|
||
{
|
||
"tick": 38,
|
||
"timestamp": "2024-01-15T10:30:42Z",
|
||
"name": "Borg Adaptation",
|
||
"category": "faction",
|
||
"severity": "major",
|
||
"description": "The Collective adapts to Starfleet phaser frequencies. Incoming damage reduced by 50%.",
|
||
"effects": [
|
||
{ "target": "away", "stat": "damage_taken", "operation": "multiply", "value": 0.5, "duration": 5 }
|
||
],
|
||
"marketImpact": {
|
||
"suspendedMarkets": [],
|
||
"significantPriceChanges": ["wolf-359-a7f3-moneyline-home", "wolf-359-a7f3-moneyline-away"]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Go Engine File Structure
|
||
|
||
```
|
||
ingestion-engine/
|
||
├── main.go # Entry point: init, taxonomy load, main loop
|
||
├── go.mod
|
||
├── go.sum
|
||
├── config/
|
||
│ ├── taxonomy.go # YAML parsing & taxonomy types
|
||
│ └── constants.go # Tuning knobs (tick rate, max matches, etc.)
|
||
├── engine/
|
||
│ ├── scheduler.go # Match lifecycle state machine
|
||
│ ├── matchmaker.go # Faction pairing & venue selection
|
||
│ ├── simulator.go # Combat tick resolution
|
||
│ ├── events.go # Random event generation & application
|
||
│ ├── probability.go # All probability models (A through G)
|
||
│ ├── markets.go # Market hydration, pricing, suspension
|
||
│ └── type_advantage.go # Combat type interaction matrix
|
||
├── models/
|
||
│ ├── match.go # LiveMatch, MatchState structs
|
||
│ ├── faction.go # FactionState, ActiveEffect structs
|
||
│ ├── market.go # Market, MarketSelection structs
|
||
│ └── event.go # MatchEvent, EventEffect structs
|
||
└── cache/
|
||
├── valkey.go # Valkey client wrapper
|
||
└── serializer.go # JSON payload assembly
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Main Loop Pseudocode
|
||
|
||
```go
|
||
func main() {
|
||
// 1. Load taxonomy
|
||
taxonomy := config.LoadTaxonomy("../data-source/taxonomy.yaml")
|
||
|
||
// 2. Connect to Valkey
|
||
valkeyClient := cache.NewValkeyClient("localhost:6379")
|
||
|
||
// 3. Initialize engine
|
||
engine := engine.NewEngine(taxonomy, valkeyClient)
|
||
|
||
// 4. Seed initial matches
|
||
engine.Scheduler.SeedMatches(TargetActiveMatches)
|
||
|
||
// 5. Main tick loop (every 2 seconds)
|
||
ticker := time.NewTicker(2 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
for range ticker.C {
|
||
// A. Scheduler: check for matches to create/retire
|
||
engine.Scheduler.Tick()
|
||
|
||
// B. For each active match, run one combat tick
|
||
for _, match := range engine.Scheduler.ActiveMatches() {
|
||
switch match.Status {
|
||
case "PRE_MATCH":
|
||
// Just update countdown timer, no combat
|
||
engine.UpdatePreMatchCountdown(match)
|
||
|
||
case "LIVE":
|
||
// Run full tick cycle
|
||
tickResult := engine.Simulator.ResolveTick(match)
|
||
events := engine.EventGenerator.Roll(match, tickResult)
|
||
engine.Probability.RecalculateAll(match)
|
||
engine.Markets.UpdatePrices(match)
|
||
|
||
case "RESOLVING":
|
||
// Cooldown ticks — no combat, markets suspended
|
||
engine.Simulator.ResolveCountdown(match)
|
||
|
||
case "SETTLED":
|
||
// Grade markets, archive, remove from active
|
||
engine.Scheduler.SettleMatch(match)
|
||
continue
|
||
}
|
||
|
||
// Push state to Valkey
|
||
engine.Cache.PushMatchState(match)
|
||
engine.Cache.PushEventLog(match)
|
||
}
|
||
|
||
// C. Update active index
|
||
engine.Cache.UpdateActiveIndex(engine.Scheduler.ActiveMatches())
|
||
|
||
log.Println(">> Tick complete. Active matches:", len(engine.Scheduler.ActiveMatches()))
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Tuning Knobs & Configuration
|
||
|
||
All magic numbers should be extractable constants:
|
||
|
||
```go
|
||
// config/constants.go
|
||
|
||
// Tick timing
|
||
const TickInterval = 2 * time.Second
|
||
|
||
// Match lifecycle
|
||
const TargetActiveMatches = 8
|
||
const MaxActiveMatches = 12
|
||
const MinActiveMatches = 4
|
||
const PreMatchDuration = 30 // ticks (60s)
|
||
const MinMatchDuration = 60 // ticks (2 min)
|
||
const MaxMatchDuration = 150 // ticks (5 min)
|
||
const ResolvingDuration = 3 // ticks (6s)
|
||
|
||
// Combat
|
||
const DamageScalingFactor = 0.1 // Converts raw attack to shield %
|
||
const DamageVarianceLow = 0.6 // Min damage multiplier
|
||
const DamageVarianceHigh = 1.4 // Max damage multiplier
|
||
const CriticalHitChance = 0.05 // 5% per hit
|
||
const CriticalHitMultiplier = 3.0
|
||
|
||
// Probability
|
||
const LogisticScalingFactor = 20.0 // Controls sigmoid steepness
|
||
const MinProbability = 0.02 // Floor (max odds ~50.00)
|
||
const MaxProbability = 0.98 // Ceiling (min odds ~1.02)
|
||
const MinPrice = 1.01
|
||
const MaxPrice = 51.00
|
||
|
||
// Margins
|
||
const PrimaryMarketMargin = 1.06 // 106% overround
|
||
const SecondaryMarketMargin = 1.075
|
||
const ExoticMarketMargin = 1.12
|
||
|
||
// Matchmaking
|
||
const SameQuadrantWeight = 0.60
|
||
const CrossQuadrantWeight = 0.30
|
||
const DistantQuadrantWeight = 0.10
|
||
const MaxSimultaneousAppearances = 2
|
||
const RepeatMatchupCooldown = 3
|
||
const RivalrySelectionBoost = 2.0
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Edge Cases & Failure Modes
|
||
|
||
| Scenario | Handling |
|
||
|----------|----------|
|
||
| **Both shields hit 0 on same tick** | Mutual Destruction — market graded as "yes", match winner is a draw (both moneylines voided/refunded) |
|
||
| **Match hits max tick limit** | Higher shields wins. If tied, compare cumulative damage dealt. If still tied, draw. |
|
||
| **Valkey connection lost** | Engine continues simulating in memory, buffers state, reconnects with exponential backoff. On reconnect, flush all buffered states. |
|
||
| **All factions in cooldown** | Reduce cooldown timers by 1, create cross-quadrant match with least-recently-used factions |
|
||
| **Event chain causes instant kill** | Shield floor of 1% for first 10 ticks (grace period). After that, instant kills are valid. |
|
||
| **Price oscillation (rapid up/down)** | Apply 3-tick moving average smoothing to price output. Raw probability still updates instantly. |
|
||
| **Probability model disagrees with state** | State always wins. If shields are 0, P(win) = 0 regardless of model output. Hard overrides > model. |
|
||
|
||
---
|
||
|
||
## 12. Future Extensibility
|
||
|
||
| Feature | Approach |
|
||
|---------|----------|
|
||
| **Accumulator / Parlay bets** | Multiply implied probabilities across selections from different matches. Apply accumulator margin premium. |
|
||
| **Match history / form** | Track last N results per faction in Valkey sorted set. Feed into pre-match odds as a "form" modifier. |
|
||
| **Tournament mode** | Bracket of 8/16 factions. Winners advance. Outright winner market across the tournament. |
|
||
| **Player props (crew members)** | Add crew member sub-entities to factions with individual stats. Create crew-level markets. |
|
||
| **Live commentary feed** | Event log already structured. Add a natural language template engine to generate commentary strings. |
|
||
| **Seasonal rankings** | Track faction W/L/D records. Create league table. End-of-season markets (champion, relegation). |
|