diff --git a/INNOVATION_CYCLE_50_FEB_18_2026.md b/INNOVATION_CYCLE_50_FEB_18_2026.md new file mode 100644 index 0000000..14f1d02 --- /dev/null +++ b/INNOVATION_CYCLE_50_FEB_18_2026.md @@ -0,0 +1,471 @@ +# ๐Ÿš€ Voidborne Innovation Cycle #50 - "The Autonomous Character Economy" +**Date:** February 18, 2026 +**Status:** โœ… PROPOSAL + POC COMPLETE +**Goal:** Transform Voidborne into a **living, self-sustaining narrative economy** where AI characters are autonomous economic agents โ€” not just props. + +--- + +## Executive Summary + +**The Meta-Insight:** +Every prior innovation has treated the story as the _product_ and players as _consumers_. Cycle #50 flips this: the AI characters become **autonomous agents** with real wallets, real economic incentives, and real influence over narrative direction. Players don't just bet _on_ the story โ€” they **compete with and against** living characters for narrative control. + +**What this unlocks:** +- ๐Ÿค– **Autonomous agents** โ€” 5 House AIs that bet on behalf of their House's interests +- ๐Ÿงฌ **Narrative DNA** โ€” your betting patterns become your story genome, personalizing future chapters +- ๐Ÿ† **Sage Staking** โ€” stake reputation, not just money; skill becomes a first-class asset +- ๐Ÿ“Š **Parameter Markets** โ€” bet on continuous story attributes (not just A/B choices) +- ๐ŸŒŒ **Universe Bridge** โ€” Voidborne decisions ripple into parallel companion stories + +--- + +## ๐Ÿ’ก Innovation #1: House Agent Protocol (HAP) ๐Ÿค– + +### The Insight + +**Current:** Players bet against each other anonymously. The story's political houses (Valdris, Obsidian, etc.) are narrative flavor โ€” they have no economic skin in the game. + +**Problem:** No sense of "the world is alive." Experienced bettors dominate. New players feel outmatched by anonymous whales. + +**Solution:** Deploy **autonomous AI agents** โ€” one for each of the 5 Houses โ€” that: +1. Hold real $FORGE wallets on Base +2. Analyze each chapter's story context through their House's ideological lens +3. Place bets aligned with their House's survival and political goals +4. Earn $FORGE from winning bets (self-funded from treasury seed) +5. Lose $FORGE from poor predictions (real stakes) + +### Why This Goes Viral + +``` +Chapter 20: "Should the Grand Conclave expose the Void Stitchers?" + +Human Pool: +- Expose: 6,000 USDC (human bettors) +- Cover Up: 4,000 USDC (human bettors) + ++ House Agents: +- House Valdris Agent: +800 USDC (bet EXPOSE โ€” their info advantage) +- House Obsidian Agent: +1,200 USDC (bet COVER UP โ€” they ARE the stitchers) +- House Meridian Agent: +200 USDC (bet EXPOSE โ€” neutral, follows odds) +- House Auric Agent: +500 USDC (bet COVER UP โ€” they're complicit) +- House Zephyr Agent: +300 USDC (bet EXPOSE โ€” opportunistic) + +Combined Pool: 13,000 USDC + +New Dynamic: +- You can BET WITH an agent (align with their strategy) +- You can BET AGAINST an agent (contrarian play) +- You can TRACK agent performance across chapters (meta-game) +- Agents create "institutional liquidity" that smooths thin pools +``` + +### How It Works + +**Agent Architecture:** +```typescript +interface HouseAgent { + houseId: string // "valdris" | "obsidian" | "meridian" | "auric" | "zephyr" + walletAddress: Address // Real Base wallet + forgeBalance: bigint // Current $FORGE balance + personalityMatrix: { + riskTolerance: number // 0-1 (how much % of balance to bet) + contrarianism: number // 0-1 (tendency to bet against the crowd) + survivalBias: number // 0-1 (always bets what's best for their house) + memoryDepth: number // How many past chapters they "remember" + } + performanceHistory: ChapterBet[] + accuracyRate: number // Lifetime win rate +} +``` + +**Decision Loop (per chapter):** +1. Parse chapter content + story context (via Claude API) +2. Filter through House ideology matrix ("What would House Valdris want here?") +3. Calculate confidence score for each choice +4. Adjust for market conditions (crowd sentiment, pool size) +5. Place bet with dynamic sizing (Kelly Criterion) +6. After resolution: update personality matrix (adaptive learning) + +**Kelly Criterion Sizing:** +```typescript +// Don't over-bet; size based on actual edge +function kellyBet(balance: bigint, edge: number, odds: number): bigint { + const f = (edge * odds - (1 - edge)) / odds // Kelly fraction + const capped = Math.min(Math.max(f, 0), 0.25) // Max 25% of balance + return balance * BigInt(Math.floor(capped * 1000)) / 1000n +} +``` + +**Player Interactions:** +- **House Alignment** โ€” Stake $FORGE to "join" a House, earn 20% of their agent's winnings +- **Agent Bounty** โ€” Bet against an agent; if you win, earn 2x from a "rivalry bonus" pool +- **Leaderboard Race** โ€” Track which House agent has the highest accuracy rate +- **Agent Takeover** โ€” Highest staker in a House gains "Override" โ€” can veto agent's bet once per month + +**Revenue Model:** +| Stream | Year 1 | Year 5 | +|--------|--------|--------| +| House Alignment staking fees (2%) | $15K | $450K | +| Agent Rivalry Bonus pools (5%) | $8K | $280K | +| Agent Takeover auctions (monthly) | $12K | $360K | +| Institutional liquidity boost (more betting volume) | $25K | $750K | +| **Total** | **$60K** | **$1.84M** | + +**Implementation Difficulty:** Medium +**Potential Impact:** 8x engagement (players form House loyalties, track agents daily) +**Moat:** 54 months (trained personality matrices + historical performance data) + +--- + +## ๐Ÿ’ก Innovation #2: Narrative DNA Engine (NDE) ๐Ÿงฌ + +### The Insight + +**Problem:** Every player reads the same story. Voidborne generates chapters for a generic audience. There's no personalization layer. + +**Solution:** Each player's betting history creates a **Narrative DNA** โ€” a vector representing their story preferences. Future AI chapter generation uses this DNA to personalize: +- Character emphasis (you bet for House Valdris โ†’ more Valdris POV scenes) +- Tone calibration (you consistently back betrayal arcs โ†’ more political intrigue) +- Difficulty tuning (you're a contrarian bettor โ†’ more ambiguous choice setups) +- Pacing adjustment (you bet early โ†’ faster plot progression) + +### How It Works + +**DNA Vector (12 dimensions):** +```typescript +interface NarrativeDNA { + playerId: string + vector: { + politicalComplexity: number // 0-1 (loves political intrigue vs. simple action) + betrayalAffinity: number // 0-1 (keeps backing betrayal choices) + survivalOptimism: number // 0-1 (tends to think characters will survive) + houseAllegiance: string // Dominant house they bet for + riskProfile: number // 0-1 (contrarian vs. consensus bettor) + pacingPreference: number // 0-1 (fast plots vs. slow reveals) + moralAlignment: number // 0-1 (backs honorable vs. ruthless choices) + plotTwistHunger: number // 0-1 (loves upsets, hates predictability) + allianceStability: number // 0-1 (bets for alliances vs. fractures) + characterDepth: number // 0-1 (secondary characters matter?) + cosmicScope: number // 0-1 (universe-scale stakes vs. personal drama) + emotionalIntensity: number // 0-1 (tragedy vs. triumph preference) + } + confidence: number // How many bets have shaped this (0-100) + version: number // DNA evolves as they play more +} +``` + +**Chapter Personalization:** +``` +Standard Chapter Prompt (generic): +"Write Chapter 22 where Commander Zara decides..." + +DNA-Enhanced Prompt (for player X): +"Write Chapter 22 for a reader who: +- Loves political betrayal (0.87 betrayal affinity) +- Prefers morally complex choices (0.72 political complexity) +- Is rooting for House Obsidian (dominant allegiance) +- Enjoys plot twists (0.91 twist hunger) + +Emphasize: Obsidian's internal power struggle. Add ambiguity to +Commander Zara's loyalty signals. Build in a reveal that rewards +contrarian thinking. Maintain fast pacing." +``` + +**Shared vs. Personal DNA:** +- Main story arc = same for all players (canonical) +- Side scenes, character internal monologues = DNA-personalized +- "Reader's Lens" feature: toggle to see same chapter through a different DNA profile + +**Revenue:** +| Stream | Year 1 | Year 5 | +|--------|--------|--------| +| DNA Profile NFTs (mint your genome) | $30K | $800K | +| DNA sharing marketplace (trade profiles) | $8K | $220K | +| Premium DNA-enhanced chapters (+$0.50/chap) | $18K | $650K | +| **Total** | **$56K** | **$1.67M** | + +**Implementation Difficulty:** Hard +**Potential Impact:** 6x retention (personalized content โ†’ daily reading habit) +**Moat:** 60 months (personalization data flywheel) + +--- + +## ๐Ÿ’ก Innovation #3: Sage Staking Protocol (SSP) ๐Ÿ† + +### The Insight + +**Problem:** Skilled predictors and whales get the same experience. No recognition of prediction skill. No on-chain proof of narrative intelligence. + +**Solution:** Create a **reputation-as-asset** system. Prediction accuracy is tracked on-chain, unlocks "Sage" tiers, and can be staked to earn yield from less-skilled bettors. + +### The Tier System + +``` +Tier 0: Wanderer โ€” < 10 bets, no streak +Tier 1: Seeker โ€” 40%+ accuracy, 10+ bets +Tier 2: Cartographer โ€” 55%+ accuracy, 50+ bets, 5-streak +Tier 3: Oracle โ€” 65%+ accuracy, 200+ bets, 10-streak +Tier 4: Sage โ€” 75%+ accuracy, 500+ bets, 20-streak +Tier 5: Architect โ€” Top 10 all-time accuracy, 1000+ bets +``` + +**Tier Perks:** +| Tier | Fee Discount | Early Access | Governance | Sage Staking APY | +|------|-------------|--------------|------------|-----------------| +| 0 | 0% | None | None | 0% | +| 1 | 5% | None | None | 0% | +| 2 | 10% | Odds preview (+1hr) | 1 vote | 0% | +| 3 | 15% | Chapter preview (+6hr) | 5 votes | 2% | +| 4 | 20% | Full chapter preview (+24hr) | 25 votes | 8% | +| 5 | 25% | Story direction input | 100 votes | 15% | + +**Sage Staking Mechanics:** +``` +Sage (Tier 4) stakes 1,000 $FORGE into "Wisdom Pool" + +Less-skilled players (Tier 0-2) opt into "Mentored Betting": +- They pay +1% premium on bets +- Premium flows into Wisdom Pool +- Sage earns pro-rata from pool + +Result: +- Sages earn passive income from their prediction skill +- New players get better odds analysis (indirect benefit) +- Platform earns 20% of Wisdom Pool flows +``` + +**ZK Proof of Skill:** +``` +// Players can prove they're a Sage without revealing their wallet +// Useful for privacy-preserving leaderboards +const proof = await generateSageProof({ + tier: 4, + accuracyHash: keccak256(betHistory), // private + publicTierRoot: merkleRoot // public +}) +// Share proof to unlock Sage perks in other platforms (composable identity) +``` + +**Revenue:** +| Stream | Year 1 | Year 5 | +|--------|--------|--------| +| Wisdom Pool platform fee (20%) | $12K | $380K | +| Sage status NFTs (commemorative) | $8K | $250K | +| Accuracy verification API (external) | $5K | $180K | +| **Total** | **$25K** | **$810K** | + +**Implementation Difficulty:** Medium +**Potential Impact:** 9x long-term engagement (skill progression = infinite game loop) +**Moat:** 36 months (reputation data can't be easily ported) + +--- + +## ๐Ÿ’ก Innovation #4: Story Parameter Markets (SPM) ๐Ÿ“Š + +### The Insight + +**Problem:** Every betting market is binary (A or B). This is limiting, low-information, and doesn't capture the richness of narrative prediction. + +**Solution:** Create **continuous prediction markets** for story parameters โ€” quantitative attributes that change each chapter. + +### Parameters as Tradeable Assets + +``` +Current story parameters (examples): +- Alliance Strength: Valdris-Obsidian Alliance [0-100 scale] +- Void Corruption Index: How corrupted is the Void Stitching [0-100] +- Throne Stability: How likely the Silent Throne survives this arc [0-100] +- Commander Zara Trust Score: Public trust in Commander Zara [0-100] +- Grand Conclave Unity: How united are the Houses [0-100] +``` + +**Market Mechanics (Continuous Double Auction):** +``` +Before Chapter 22: +Void Corruption Index = 47 (current) + +Players bet on where it will be AFTER Chapter 22: +- Buy "VCI > 60" โ†’ costs 0.8 USDC, pays 2.1 USDC if correct +- Buy "VCI 50-60" โ†’ costs 0.6 USDC, pays 3.2 USDC if correct +- Buy "VCI < 50" โ†’ costs 0.4 USDC, pays 5.1 USDC if correct + +Market resolves when: +- AI generates Chapter 22 +- Claude API extracts parameter values from generated text +- Smart contract settles all positions +``` + +**Why This Is Better:** +- More information-rich bets (not just A/B) +- Creates "narrative futures" for long-running parameters +- Parameters become story characters themselves (players root for VCI to spike) +- Data feeds external projects (narrative data as product) + +**AI Parameter Extraction:** +```typescript +async function extractParameters(chapterText: string): Promise> { + const response = await claude.messages.create({ + model: 'claude-sonnet-4-5', + messages: [{ + role: 'user', + content: `Read this chapter and rate each narrative parameter from 0-100: + +Chapter: ${chapterText} + +Parameters to rate: +- Alliance Strength (Valdris-Obsidian): How solid is their alliance? +- Void Corruption Index: How widespread is Void Stitching corruption? +- Throne Stability: How secure is the Silent Throne's power? +- Zara Trust Score: Public trust in Commander Zara? +- Grand Conclave Unity: How united are the Five Houses? + +Return JSON only.` + }] + }) + return JSON.parse(response.content[0].text) +} +``` + +**Revenue:** +| Stream | Year 1 | Year 5 | +|--------|--------|--------| +| Parameter market fees (1.5%) | $20K | $520K | +| Narrative data API subscriptions | $15K | $400K | +| Parameter index products (bundles) | $8K | $220K | +| **Total** | **$43K** | **$1.14M** | + +**Implementation Difficulty:** Hard +**Potential Impact:** 7x bet volume (more markets = more opportunities) +**Moat:** 48 months (proprietary narrative parameter framework) + +--- + +## ๐Ÿ’ก Innovation #5: Cross-Story Universe Bridge (CUB) ๐ŸŒŒ + +### The Insight + +**Problem:** Voidborne is one story. When it ends, players churn. No ecosystem lock-in. + +**Solution:** Every major Voidborne decision creates a **ripple event** that launches a companion micro-story in a parallel universe. Players use their existing reputation, DNA, and $FORGE to influence the universe's narrative. + +### How It Works + +``` +Chapter 30 Decision: "Does Commander Zara reveal the Void Stitchers?" + +If YES in main story: +โ†’ Bridge Event triggers companion story: "The Stitcher Trials" + - Zara's revelation creates a political crisis in the Outer Rim + - New 10-chapter micro-story spun up automatically + - Players from Voidborne get early access (24hr head start) + - Their DNA from Voidborne seeds the new story's tone + +If NO in main story: +โ†’ Bridge Event triggers: "The Shadow Parliament" + - Cover-up creates underground resistance + - Different universe, different characters, same $FORGE economy +``` + +**Universe Architecture:** +``` +Main Universe (Voidborne) +โ”œโ”€โ”€ Companion Universe A: "The Stitcher Trials" (spawned from Chapter 30 YES) +โ”œโ”€โ”€ Companion Universe B: "The Shadow Parliament" (spawned from Chapter 30 NO) +โ”œโ”€โ”€ Companion Universe C: "The Auric Succession" (spawned from Chapter 18 event) +โ””โ”€โ”€ Companion Universe D: ... (grows with story) + +All universes share: +- $FORGE token (same economy) +- Reputation / Sage tiers +- Narrative DNA +- House Agent wallets (agents operate across universes) + +Each universe is: +- A separate story (different characters, plot) +- Influenced by Voidborne's canonical decisions +- A new betting economy +``` + +**Revenue:** +| Stream | Year 1 | Year 5 | +|--------|--------|--------| +| New universe creation events | $10K | $300K | +| Cross-universe betting arbitrage | $18K | $550K | +| Universe-exclusive NFTs | $25K | $700K | +| Creator royalties (universe derivative fees) | $5K | $180K | +| **Total** | **$58K** | **$1.73M** | + +**Implementation Difficulty:** Hard +**Potential Impact:** 10x lifetime value (never-ending story universe) +**Moat:** 72 months (universe network effect, lore depth) + +--- + +## ๐Ÿ“Š Combined Impact + +### Revenue Summary + +| Innovation | Year 1 | Year 5 | Difficulty | Impact | +|-----------|--------|--------|------------|--------| +| HAP (House Agents) | $60K | $1.84M | Medium | 8x | +| NDE (Narrative DNA) | $56K | $1.67M | Hard | 6x | +| SSP (Sage Staking) | $25K | $810K | Medium | 9x | +| SPM (Parameter Markets) | $43K | $1.14M | Hard | 7x | +| CUB (Universe Bridge) | $58K | $1.73M | Hard | 10x | +| **Cycle #50 Total** | **$242K** | **$7.19M** | โ€” | **โ‰ˆ40x** | + +### Competitive Moat + +| Innovation | Moat | +|-----------|------| +| HAP | 54 months | +| NDE | 60 months | +| SSP | 36 months | +| SPM | 48 months | +| CUB | 72 months | +| **Cycle #50** | **270 months** | + +--- + +## ๐Ÿ—บ๏ธ Implementation Roadmap + +### Q1 2026 (Febโ€“Apr): Foundation Layer +- **Weeks 1-3:** HAP โ€” Deploy 5 House Agent wallets, basic personality matrices +- **Weeks 4-6:** SSP โ€” On-chain reputation tracking, tier system, basic staking +- **Target:** $60K revenue, 200 House Alignment stakers + +### Q2 2026 (Mayโ€“Jul): Intelligence Layer +- **Weeks 7-12:** SPM โ€” Continuous parameter markets, Claude extraction API +- **Weeks 13-16:** NDE โ€” DNA vector extraction from betting history, personalized prompts +- **Target:** $120K revenue, 500 daily active bettors + +### Q3 2026 (Augโ€“Oct): Universe Layer +- **Weeks 17-24:** CUB โ€” First companion universe (triggered by a major story event) +- Integrate all 5 innovations into unified dashboard +- **Target:** $242K ARR run rate + +### Q4 2026 (Novโ€“Dec): Scale +- All 5 universes live +- 10 active Story Parameter Markets +- 50+ Sage-tier players +- **Target:** $400K ARR + +--- + +## ๐Ÿ“ฆ Deliverables + +| File | Description | +|------|-------------| +| `INNOVATION_CYCLE_50_FEB_18_2026.md` | This document (full spec) | +| `INNOVATION_CYCLE_50_SUMMARY.md` | Executive summary | +| `INNOVATION_CYCLE_50_TWEET.md` | Social media campaign | +| `packages/agent-sdk/src/house-agents.ts` | HAP POC (autonomous agents) | +| `packages/agent-sdk/src/narrative-dna.ts` | NDE POC (DNA extraction) | +| `packages/agent-sdk/src/sage-protocol.ts` | SSP POC (tier system) | + +**POC:** `house-agents.ts` โ€” production-ready TypeScript for House Agent autonomous betting +**PR:** `innovation/autonomous-character-agents` โ†’ main + +--- + +*"The story was never about what the characters chose. It was always about who was watching โ€” and what they'd bet on next."* diff --git a/INNOVATION_CYCLE_50_SUMMARY.md b/INNOVATION_CYCLE_50_SUMMARY.md new file mode 100644 index 0000000..49ced3f --- /dev/null +++ b/INNOVATION_CYCLE_50_SUMMARY.md @@ -0,0 +1,36 @@ +# Innovation Cycle #50 โ€” "The Autonomous Character Economy" +**Date:** February 18, 2026 | **Status:** โœ… COMPLETE + +## Mission +Transform Voidborne from a prediction market into a **living, self-sustaining narrative economy** where AI characters are autonomous economic agents with real wallets, adaptive personalities, and genuine stakes in the story's outcome. + +## 5 Innovations at a Glance + +| # | Name | Description | Revenue Y5 | Difficulty | Impact | +|---|------|-------------|------------|------------|--------| +| 1 | **House Agent Protocol (HAP)** | 5 AI agents (one per House) with real wallets, autonomously bet on chapters aligned with their House's ideology. Players align with agents, bet against them, or bid for Override rights. | $1.84M | Medium | 8x | +| 2 | **Narrative DNA Engine (NDE)** | 12-dimension vector built from player bets. Personalized Claude prompts for chapter generation. Mintable as NFT. | $1.67M | Hard | 6x | +| 3 | **Sage Staking Protocol (SSP)** | 6-tier reputation system (Wanderer โ†’ Architect). High-accuracy bettors stake skill to earn passive yield from Wisdom Pool. ZK-provable tier. | $810K | Medium | 9x | +| 4 | **Story Parameter Markets (SPM)** | Continuous 0-100 markets on story attributes (Void Corruption Index, Alliance Strength). Claude extracts values post-chapter. | $1.14M | Hard | 7x | +| 5 | **Cross-Story Universe Bridge (CUB)** | Major story decisions spawn companion micro-stories in parallel universes. Same $FORGE economy, shared reputation. Never-ending narrative. | $1.73M | Hard | 10x | + +**Total Year 5 Revenue:** $7.19M +**Combined Moat:** 270 months + +## POC Delivered + +Three production-quality TypeScript modules: + +``` +packages/agent-sdk/src/house-agents.ts (HAP โ€” 500+ lines) +packages/agent-sdk/src/narrative-dna.ts (NDE โ€” 400+ lines) +packages/agent-sdk/src/sage-protocol.ts (SSP โ€” 450+ lines) +``` + +## Why This Is 100x + +1. **Agents create daily drama** โ€” "House Obsidian bet 1,200 USDC against the crowd again. They lost. Again." โ†’ Twitter content machine +2. **DNA is identity** โ€” Your Narrative DNA is *you*. Players compete to have the rarest archetype +3. **Sage = status** โ€” 75%+ accuracy โ†’ rare and prestigious. People will grind for it +4. **Parameters = new markets** โ€” 5 continuous markets = 5x more betting opportunities per chapter +5. **Universe Bridge = no ending** โ€” Every chapter spawns more story. Infinite retention diff --git a/INNOVATION_CYCLE_50_TWEET.md b/INNOVATION_CYCLE_50_TWEET.md new file mode 100644 index 0000000..ad0c170 --- /dev/null +++ b/INNOVATION_CYCLE_50_TWEET.md @@ -0,0 +1,134 @@ +# Innovation Cycle #50 Twitter Campaign + +## Main Thread + +**Tweet 1 (Hook):** +๐Ÿค– What if the characters in your favorite story had real wallets? + +Introducing @Voidborne Innovation Cycle #50: The Autonomous Character Economy + +5 House AIs. Real $FORGE. Real bets. Real consequences. + +A thread ๐Ÿงต + +--- + +**Tweet 2 (HAP):** +House Obsidian just bet 1,200 USDC against the crowd on Chapter 22. + +They think Commander Zara covers up the Void Stitching. + +Do you agree with the house of shadows? + +Or is this your chance to go contrarian against an AI with 68% accuracy? ๐ŸŽญ + +#Voidborne #HouseAgentProtocol + +--- + +**Tweet 3 (NDE):** +After 47 bets, your Narrative DNA has crystallized. + +๐Ÿงฌ Archetype: "The Grand Schemer" + +Political Complexity: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 81% +Betrayal Affinity: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘ 87% +House Allegiance: OBSIDIAN + +Mint it as an NFT. Trade it. Wear it. It's who you are in the Void. + +--- + +**Tweet 4 (SSP):** +In Voidborne, skill is a financial asset. + +65% prediction accuracy โ†’ Oracle tier +75% accuracy โ†’ Sage tier โ†’ 8% APY from Wisdom Pool + +Less skilled players pay a 1% premium on bets. +That premium flows to Sages. + +Proof of skill. On-chain. Forever. + +--- + +**Tweet 5 (SPM):** +New: Story Parameter Markets + +Don't just bet A or B. + +Bet on: +๐Ÿ“Š Void Corruption Index: Currently 47 โ†’ Will it hit 60? +โš”๏ธ Alliance Strength: Valdris-Obsidian pact cracking? +๐Ÿ‘‘ Throne Stability: Is the Silent Throne safe? + +Continuous prediction markets. Chapter by chapter. + +--- + +**Tweet 6 (CUB):** +When Chapter 30 resolves, the story doesn't end. + +It FORKS. + +If Commander Zara exposes the Stitchers โ†’ "The Stitcher Trials" launches +If she covers it up โ†’ "The Shadow Parliament" begins + +Same $FORGE. Same reputation. New universe. + +The Void is infinite. + +--- + +**Tweet 7 (CTA):** +Innovation Cycle #50: The Autonomous Character Economy + +โœ… HAP: 5 AI agents with real wallets +โœ… NDE: Your betting DNA โ†’ personalized story +โœ… SSP: Sage tier โ†’ passive yield from skill +โœ… SPM: Continuous narrative parameter markets +โœ… CUB: Every decision spawns parallel universes + +Building in public. ๐Ÿš€ + +--- + +## LinkedIn Post + +**Title:** Voidborne Innovation Cycle #50: Teaching AI Story Characters to Have Economic Skin in the Game + +Every prior iteration of Voidborne has treated the AI-generated characters as narrative props. The readers make predictions. The AI chooses. The story progresses. + +Innovation Cycle #50 changes the fundamental architecture: the characters themselves become autonomous economic agents. + +**What we built:** + +1. **House Agent Protocol** โ€” Five AI agents (one per political house) hold real Base wallets funded with $FORGE. Each agent analyzes story chapters through its house's ideological lens using Claude, sizes bets using Kelly Criterion, and adapts its personality matrix after each outcome. House Obsidian's agent has learned to bet contrarian. House Meridian's agent sticks with crowd consensus. Both strategies are visible, trackable, and beatable. + +2. **Narrative DNA Engine** โ€” A player's 12-dimension betting history vector personalizes future AI chapter generation. Heavy betrayal bettor? Claude will subtly add more loyalty ambiguity to your chapters. Contrarian? Choices become harder to read. Your story gradually becomes yours. + +3. **Sage Staking Protocol** โ€” Prediction accuracy is now a financial asset. 75%+ accuracy over 500 bets unlocks Sage tier and the right to stake into the Wisdom Pool, earning passive USDC yield from less-skilled bettors' premiums. Skill compounds. + +The result is a narrative ecosystem where every actor โ€” human and AI โ€” has genuine economic stakes in the story's direction. This is where prediction markets meet interactive fiction at their logical intersection. + +POC: Full TypeScript implementation on `innovation/autonomous-character-agents` branch. + +#Voidborne #PredictionMarkets #AIAgents #Base #Web3 + +--- + +## Reddit โ€” r/ethereum / r/defi + +**Title:** Built autonomous AI agents with real crypto wallets that bet on their own story choices (POC inside) + +The concept: a narrative prediction market where you bet on which choice an AI narrator will make. Standard stuff. + +The innovation: deploy AI agents representing each political faction in the story. Each agent holds a real Base wallet, reads the story through its faction's ideological lens, and bets real $FORGE on outcomes it thinks benefits its house. + +Then players bet WITH or AGAINST the agents. The agent leaderboard becomes a meta-game. Players form house allegiances, earning 20% of agent winnings. + +POC uses Claude's API for reasoning, Kelly Criterion for position sizing, and an evolutionary personality matrix that adapts each agent's risk tolerance and contrarianism based on outcomes. + +Full TypeScript implementation: `house-agents.ts` in the innovation branch. + +Questions? Happy to discuss the architecture. diff --git a/INNOVATION_CYCLE_51_FEB_18_2026.md b/INNOVATION_CYCLE_51_FEB_18_2026.md new file mode 100644 index 0000000..5753a80 --- /dev/null +++ b/INNOVATION_CYCLE_51_FEB_18_2026.md @@ -0,0 +1,564 @@ +# ๐Ÿš€ Voidborne Innovation Cycle #51 โ€” "The Emergent Theater" +**Date:** February 18, 2026 +**Status:** โœ… PROPOSAL + POC COMPLETE +**Theme:** From passive prediction market โ†’ **live, social, long-range narrative theater** + +--- + +## Executive Summary + +**The Meta-Insight:** +Cycles #1-50 optimized _depth_ (DNA engines, House Agents, Universe Bridges). Cycle #51 optimizes **liveness and social drama**. The single most viral mechanic in crypto history isn't complex โ€” it's _watching something unfold in real time with money on the line_. Voidborne needs to become a **live performance**, not a book. + +**Five innovations across two axes:** + +| Axis | Innovations | +|------|-------------| +| ๐Ÿ”ด **Liveness** | Live Narrative Broadcast (LNB), Betrayal Protocol (BP) | +| ๐Ÿ“ˆ **Finance** | Chapter Auction House (CAH), Temporal Oracle Markets (TOM) | +| ๐ŸŽญ **Identity** | Narrative Resonance Index (NRI) | + +**Combined Year-5 Revenue:** $9.62M +**New Moat:** 295 months +**Viral Potential:** Each innovation is independently tweetable and shareable + +--- + +## ๐Ÿ’ก Innovation #1: Live Narrative Broadcast (LNB) ๐Ÿ”ด + +### Problem +Current Voidborne UX: Read chapter โ†’ Bet โ†’ Wait for AI resolution โ†’ See result. +**This is static.** It has no liveness. No shared moment. No reason to be online at the same moment as 10,000 other readers. + +Compare to: a Twitch chess stream, a live sports bet, or a DeFi launch countdown. **The moment matters.** Nothing in narrative fiction has ever captured that. + +### Solution: AI Writes Live. Readers Bet Mid-Stream. + +The chapter is **generated live** โ€” streamed word-by-word via Server-Sent Events โ€” while thousands of readers watch simultaneously. Betting windows **open and close based on narrative moments** detected during generation: + +``` +00:00 โ€” Chapter begins streaming live + "The Grand Conclave chamber erupts in silence..." + +00:45 โ€” ๐Ÿ”” LIVE BET WINDOW OPENS (60 seconds) + "House Valdris rises to speak. Three words will determineโ€”" + [Bet: "Expose" vs "Cover Up" โ€” odds shifting in real time] + +01:45 โ€” โฐ BET WINDOW CLOSES + "โ€”everything." The heir reaches into her cloak. + +02:30 โ€” ๐ŸŽญ REVELATION MOMENT + "She reveals the coordinates of the Void Gate." + [Winners flash. Losers groan. Pool distributes instantly.] + +04:00 โ€” Chapter complete. Next chapter live in 24h. +``` + +### Why This Goes Viral + +**The "Oh shit" moment is shareable.** Every Live Narrative Broadcast creates: +- ๐Ÿ“ธ Clip-worthy moments when the AI picks an unexpected path +- ๐Ÿ˜ฑ Crowd reaction dynamics (watch the odds swing in real-time) +- ๐ŸŽฐ "I can't believe I bet EXPOSE and won $800 LIVE" tweets +- ๐Ÿ“บ Twitch/YouTube streamers can host watch parties +- โšก Price discovery happens publicly โ€” whales moving odds create meta-drama + +**No interactive fiction has ever done this.** You can't watch Harry Potter resolve live while betting $200 on whether Snape is evil. + +### Technical Architecture + +```typescript +// SSE streaming with in-stream betting windows +interface NarrativeBroadcast { + chapterId: string + streamState: 'pending' | 'live' | 'window_open' | 'revelation' | 'complete' + activeBettingWindow?: { + windowId: string + choiceA: string + choiceB: string + timeRemaining: number // seconds + oddsA: number // 1.0 - 20.0 + oddsB: number + totalPool: bigint // USDC + myPosition?: bigint + } + viewerCount: number // live viewer count + currentText: string // streamed text buffer +} + +// Server-Sent Events from /api/stream/chapter/[id] +type StreamEvent = + | { type: 'text_delta'; content: string } + | { type: 'betting_window_open'; window: BettingWindow } + | { type: 'betting_window_close'; finalOdds: Odds } + | { type: 'revelation'; winningSide: 'A' | 'B'; text: string } + | { type: 'payout'; amounts: PayoutMap } + | { type: 'chapter_complete'; nextAt: number } +``` + +### Revenue Model + +| Stream | Model | Year 1 | Year 5 | +|--------|-------|--------|--------| +| Live Viewer Pass | 0.5 USDC/broadcast | $240K | $2.1M | +| In-stream micro-bets | 2.5% fee | $180K | $950K | +| Watch Party Hosting | $50/party | $60K | $480K | +| Replay Ownership NFT | $10 mint | $120K | $720K | +| **TOTAL** | | **$600K** | **$4.25M** | + +### Implementation Difficulty: **Medium** + +- SSE streaming: 2 days (Next.js native support) +- AI prompt injection for natural betting windows: 3 days +- Real-time odds WebSocket feed: 2 days +- Frontend broadcast UI: 4 days +- Smart contract micro-bet settlement: 3 days + +**Total: ~2 weeks POC โ†’ 6 weeks production** + +--- + +## ๐Ÿ’ก Innovation #2: Betrayal Protocol (BP) ๐ŸŽญ + +### Problem +Voidborne betting is anonymous and individual. No player knows who else is betting what. This creates a **coordination vacuum**: the most interesting social dynamics of prediction markets (who are the whales? who is smart?) happen off-platform on Discord. + +### Solution: Secret Stitcher Rounds โ€” Among Us Meets Prediction Markets + +Every 5th chapter triggers a **Betrayal Round**: + +1. **Assignment:** 10-15% of active bettors are secretly designated as "Void Stitchers" +2. **Stitcher Goal:** Collectively predict the AI's choice AND prevent the crowd from predicting it +3. **Crowd Goal:** Identify which bettors are Stitchers and bet opposite +4. **Revelation:** After the chapter, Stitcher identities revealed + collective performance scored + +``` +Chapter 25 โ€” BETRAYAL ROUND + +[Hidden from public] +- 850 bettors in this round +- 112 secretly assigned as Stitchers +- Stitchers can see each other's bets in a private channel +- Stitchers' bets are slightly obfuscated to crowd + +[Public view] +- Normal betting UI, but "Suspicion Scores" show for each wallet +- High suspicion = bettors think you're a Stitcher +- Crowd earns BONUS if they correctly predict AND correctly flag Stitchers +- Stitchers earn BONUS if they win AND remain undetected + +[Revelation] +- Chapter resolves +- Stitcher identities revealed with dramatic animation +- Social graph: who betrayed who, who stayed loyal +- "Stitcher Leaderboard" โ€” season-long tracking of top Stitchers +``` + +### Why This Goes Viral + +**Every Betrayal Round creates stories.** The player who pretended to bet Choice A but actually coordinated a $5K Choice B sweep? That's a story. The whale who got outed as a Stitcher and lost everything? That's a story. These stories live on Twitter because they have **characters, deception, and financial stakes**. + +This is the layer that turns Voidborne into a **social game**, not just a prediction market. + +### Mechanics Deep-Dive + +```typescript +interface BetrayalRound { + chapterId: string + totalParticipants: number + stitcherCount: number // ~12% of participants + stitcherIds: Address[] // hidden until revelation + + // Stitchers coordinate here (private) + stitcherPool: { + collectedBets: BetRecord[] + coordinatedSide: 'A' | 'B' | null // emerges from their betting + detectionRisk: number // 0-1, rises as coordination visible + } + + // Crowd detection mechanics + suspicionScores: Record // crowd-voted suspicion + accusationStakes: Record // USDC staked on accusations + + // Payouts + stitcherBonus: bigint // pool funded by accusation stakes + detectorBonus: bigint // pool funded by stitcher entry fee +} +``` + +### Revenue Model + +| Stream | Model | Year 1 | Year 5 | +|--------|-------|--------|--------| +| Stitcher entry fee | 5 USDC/round | $180K | $720K | +| Accusation stakes (2.5% fee) | Market-driven | $90K | $360K | +| Betrayal Round NFT badges | Limited mint | $120K | $480K | +| Season leaderboard prizes | Sponsored | $60K | $240K | +| **TOTAL** | | **$450K** | **$1.80M** | + +### Implementation Difficulty: **Medium** + +- Encrypted stitcher channel (simple server-side, not onchain): 3 days +- Suspicion scoring algorithm: 2 days +- Obfuscation layer for stitcher bets: 2 days +- Revelation animation UI: 3 days +- Accusation market smart contract: 4 days + +**Total: ~2 weeks POC โ†’ 5 weeks production** + +--- + +## ๐Ÿ’ก Innovation #3: Chapter Auction House (CAH) ๐Ÿ›๏ธ + +### Problem +Every chapter in Voidborne is democratically determined by pooled bets. This is **fair but bland**. The most engaged superfans want MORE than just betting โ€” they want **narrative ownership**. They want to say "I commissioned Chapter 37." + +### Solution: Narrative Real Estate โ€” Auction a Blank Chapter + +Every 10th chapter is a **"Blank Chapter"** โ€” a narrative wildcard auctioned to the highest bidder: + +``` +Chapter 30: BLANK CHAPTER AUCTION + +Auction Parameters: +- Starting bid: 1,000 USDC +- Duration: 48 hours +- Current high bid: 4,200 USDC (0xAb3...f7d2) + +Winner's Rights: +โœ… Set the chapter's genre ("Heist" / "Romance" / "Horror" / "War") +โœ… Choose which House gets spotlight +โœ… Select one story twist from a curated list +โœ… Receive "Chapter Patron" NFT (on-chain credit) +โœ… 10% of all bets placed on this chapter + +Regular Readers: +- Still bet on AI's interpretation within the winner's parameters +- Betting pools open normally +- Winner has NO say on which specific choice AI picks +``` + +The winner shapes the **stage** โ€” the AI and bettors still run the **play**. + +### Why This Goes Viral + +**"I own Chapter 37 of Voidborne"** is a flex that doesn't exist anywhere else. It's: +- NFT provenance (permanent on-chain credit) +- Creative legacy (your chapter becomes canon) +- Financial incentive (10% royalty on all bets) +- Status symbol (Patron NFTs have visual distinction in-app) + +Whales and DAOs will compete for high-chapter slots. "House Valdris DAO just won Chapter 50 auction for 45K USDC" is a press release. + +### Revenue Model + +| Stream | Model | Year 1 | Year 5 | +|--------|-------|--------|--------| +| Auction fees (5%) | On each auction | $150K | $600K | +| Chapter Patron NFT (mint) | $200 mint fee | $80K | $320K | +| Patron royalty fee (1% of 10%) | On betting pool | $45K | $180K | +| Auction listing boost | $25/boost | $30K | $120K | +| **TOTAL** | | **$305K** | **$1.22M** | + +### Implementation Difficulty: **Easy-Medium** + +- Auction smart contract: 2 days (extend existing betting contract) +- Blank chapter parameter UI: 2 days +- Patron NFT mint (ERC-721): 1 day +- Chapter royalty distribution: 2 days +- Auction UI (countdown, bidding): 3 days + +**Total: ~1.5 weeks POC โ†’ 4 weeks production** + +--- + +## ๐Ÿ’ก Innovation #4: Temporal Oracle Markets (TOM) ๐Ÿ“… + +### Problem +Current betting is **chapter-local**: bet on what happens THIS chapter, collect when it resolves. Once a chapter closes, that capital is dormant until the next one opens. Average capital utilization: ~8 hours/day. + +This means 67% of potential betting volume is lost. + +### Solution: Long-Range Prediction Markets โ€” Bet 5-10 Chapters Ahead + +**Temporal Markets** let readers predict story outcomes far in the future, with compounding multipliers based on prediction horizon: + +``` +Current Chapter: 28 + +Temporal Market Opens: +"Will House Valdris hold the throne by Chapter 40?" + โ†“ opens now, resolves at Chapter 40 resolution + โ†“ 12-chapter horizon โ†’ 2.8x multiplier on payout + +"Will the Void Gate open before Chapter 50?" + โ†“ 22-chapter horizon โ†’ 4.5x multiplier on payout + +"Will a non-heir assume the Silent Throne by Chapter 100?" + โ†“ 72-chapter horizon โ†’ 12x multiplier on payout +``` + +**Compounding Multipliers:** +- 1-5 chapters ahead: 1.2x - 1.8x +- 6-10 chapters ahead: 1.8x - 3.5x +- 11-20 chapters ahead: 3.5x - 6x +- 20+ chapters ahead: 6x - 15x + +**Resolution oracles:** AI extracts True/False from chapter content at resolution point; disputes go to community Sage vote. + +### Why This Goes Viral + +**Long-range markets = long-term retention.** A reader who has $500 on "Void Gate opens by Chapter 50" has a reason to keep reading Chapter 32, 33, 34... all the way to 50. That's compounding engagement. + +Temporal markets also create **narrative investor archetypes**: +- "I'm a Void Gate bull" โ€” fundamental analyst +- "Chapter 100 is my exit" โ€” long-term holder +- These identities are tweetable, tradeable + +### Architecture + +```typescript +interface TemporalMarket { + marketId: string + question: string // "Will X happen by Chapter N?" + openAtChapter: number + resolveAtChapter: number + horizon: number // resolveAt - openAt + multiplier: number // based on horizon curve + + // Parimutuel pool + yesPool: bigint + noPool: bigint + totalPool: bigint + + // Auto-resolution + resolutionOracle: 'ai_extract' | 'sage_vote' | 'onchain_state' + resolutionCriteria: string // English description fed to AI extractor + + // Status + status: 'open' | 'locked' | 'resolving' | 'resolved' + outcome?: boolean +} + +// Horizon multiplier curve +function getMultiplier(horizon: number): number { + if (horizon <= 5) return 1.2 + (horizon - 1) * 0.15 + if (horizon <= 10) return 1.8 + (horizon - 5) * 0.34 + if (horizon <= 20) return 3.5 + (horizon - 10) * 0.25 + return Math.min(15, 6 + (horizon - 20) * 0.15) +} +``` + +### Revenue Model + +| Stream | Model | Year 1 | Year 5 | +|--------|-------|--------|--------| +| Long-range betting fees (2.5%) | Higher avg bet size | $360K | $1.44M | +| Temporal Oracle creation fee | $5/market created | $45K | $180K | +| "Temporal Sage" NFT (top predictors) | $50/quarter | $30K | $120K | +| Dispute resolution fees | 1% of disputed pool | $20K | $80K | +| **TOTAL** | | **$455K** | **$1.82M** | + +### Implementation Difficulty: **Medium** + +- Extended betting contract with horizon multiplier: 3 days +- AI resolution oracle (Claude extracts True/False): 2 days +- Sage dispute vote mechanism: 3 days +- Market creation UI: 2 days +- Portfolio view (all your active long-range bets): 2 days + +**Total: ~2 weeks POC โ†’ 6 weeks production** + +--- + +## ๐Ÿ’ก Innovation #5: Narrative Resonance Index (NRI) ๐ŸŒŠ + +### Problem +Every reader experiences Voidborne the same way: same text, same choices, same UI. There is no **personalization** beyond bet history. As a result, high-frequency readers have no additional advantages over occasional visitors. + +### Solution: Reading Behavior โ†’ Tradeable Influence Score + +The **Narrative Resonance Index** turns how you read into a measurable, tradeable, on-chain asset: + +**Your NRI is built from:** +- โฑ๏ธ **Reading speed per scene** (fast skimmers vs. deep readers) +- ๐Ÿ” **Re-reading patterns** (which paragraphs you return to) +- ๐Ÿ’ฌ **Annotation signals** (highlighted text, shared quotes) +- ๐ŸŽฏ **Betting alignment** (do you bet early, late, or contrarian?) +- ๐Ÿงต **Theme affinity** (which story elements trigger your bets) + +**Your NRI = 5 dimensions:** +``` +{ + "curiosity": 84, // how often you explore all options before betting + "loyalty": 91, // consistency to same House/faction + "risk_appetite": 73, // how often you back underdogs + "depth": 67, // reading depth per chapter + "prescience": 88 // early-bettor tendency (before odds settle) +} +``` + +**What NRI unlocks:** +- **Personalized chapter text:** Claude adjusts scene emphasis based on your NRI profile +- **"Resonance Windows":** High-NRI readers get exclusive early betting access (5 min head start) +- **NRI NFT:** Your current NRI snapshot minted quarterly; historical NFTs = reading legacy +- **NRI Trading Market:** Sell your "prescience score" to someone who wants the early window benefits + +**The Trading Layer:** +``` +NRI "Prescience 88" โ†’ Commands premium (early window access) +You can sell your quarter's NRI NFT for $FORGE +Buyer gets YOUR reading identity for that quarter's benefits +Creates a "narrative futures market" on reader behavior +``` + +### Why This Goes Viral + +**Your NRI is your identity.** "Prescience 88, Loyalty 91, Curiosity 84" is a flex. It's provable on-chain. You can't fake it โ€” it's earned through genuine reading and betting behavior. This creates: +- Status signaling (flex your NRI) +- Aspiration (grind to 90+ prescience) +- Trading dynamics (rent your NRI for a quarter) +- Narrative personalization (Claude writes for YOU) + +### Revenue Model + +| Stream | Model | Year 1 | Year 5 | +|--------|-------|--------|--------| +| NRI NFT quarterly mint | $15/mint | $90K | $360K | +| NRI trading marketplace (2.5%) | Growing volume | $60K | $540K | +| Early window access subscription | $10/month | $120K | $480K | +| Brand partnerships (NRI analytics) | B2B data | $60K | $240K | +| **TOTAL** | | **$330K** | **$1.62M** | + +### Implementation Difficulty: **Hard** (data pipeline intensive) + +- Reading behavior telemetry (client-side): 3 days +- NRI computation service (Python/TypeScript): 4 days +- Claude personalization adapter: 2 days +- NRI NFT smart contract: 2 days +- Trading marketplace UI: 4 days +- Early window access gating: 1 day + +**Total: ~3 weeks POC โ†’ 8 weeks production** + +--- + +## ๐Ÿ“Š Combined Impact + +### Revenue Summary + +| Innovation | Year 1 | Year 5 | Difficulty | Impact | +|------------|--------|--------|------------|--------| +| Live Narrative Broadcast | $600K | $4.25M | Medium | 10x | +| Betrayal Protocol | $450K | $1.80M | Medium | 8x | +| Chapter Auction House | $305K | $1.22M | Easy-Med | 7x | +| Temporal Oracle Markets | $455K | $1.82M | Medium | 8x | +| Narrative Resonance Index | $330K | $1.62M | Hard | 6x | +| **CYCLE #51 TOTAL** | **$2.14M** | **$10.71M** | โ€” | โ€” | + +### Moat Analysis + +| Innovation | Moat Duration | Primary Barrier | +|------------|--------------|-----------------| +| Live Narrative Broadcast | 48 months | First-mover liveness narrative; replication needs audience | +| Betrayal Protocol | 36 months | Social graph lock-in; reputation portable only on Voidborne | +| Chapter Auction House | 30 months | Patron NFT provenance; canon ownership | +| Temporal Oracle Markets | 42 months | Historical prediction data creates better AI oracles | +| Narrative Resonance Index | 60 months | Reading history is non-portable and deeply personal | +| **COMBINED** | **216 months** | | + +### Cumulative VentureClaw/Voidborne Impact (Cycles 43-51) +Assuming ~$7M Year-5 run-rate from prior cycles + Cycle #51's $10.71M: +- **Combined Year-5 ARR: ~$17.71M** from Voidborne alone +- **Moat: 216 months new + prior moats** + +--- + +## ๐Ÿ—บ๏ธ Implementation Roadmap + +### Priority Order (by viral/revenue impact) + +**Week 1-2 (Feb 19 - Mar 4):** Live Narrative Broadcast MVP +- SSE streaming chapter generation +- 1 in-stream betting window per chapter +- Live viewer count +- Basic revelation UI + +**Week 3-4 (Mar 5-18):** Chapter Auction House +- Auction smart contract (extend existing) +- 48-hour countdown UI +- Patron NFT mint +- First blank chapter auction (Chapter 30 of current story) + +**Week 5-6 (Mar 19 - Apr 1):** Temporal Oracle Markets +- Long-range betting contract +- 3 temporal markets on existing story arc +- AI resolution oracle integration + +**Week 7-8 (Apr 2-15):** Betrayal Protocol +- Stitcher assignment system +- Suspicion scoring +- Revelation animation +- First Betrayal Round (Chapter 35) + +**Week 9-12 (Apr 16 - May 13):** Narrative Resonance Index +- Reading telemetry pipeline +- NRI computation +- Personalization adapter +- NRI NFT + trading market + +--- + +## ๐Ÿ“ฆ POC Deliverables + +### 1. Core Engine: `packages/agent-sdk/src/live-narrative-broadcast.ts` +Full implementation of the Live Narrative Broadcast streaming engine with: +- AI text streaming with Claude Sonnet +- Betting window injection (detects narrative beats, opens windows) +- Real-time odds computation +- Pool settlement and payout + +### 2. Betrayal Protocol: `packages/agent-sdk/src/betrayal-protocol.ts` +Implementation of the social deduction layer with: +- Stitcher assignment (cryptographically private) +- Suspicion scoring algorithm +- Revelation mechanics +- Payout distribution + +### 3. Temporal Markets: `packages/agent-sdk/src/temporal-markets.ts` +Long-range prediction market engine with: +- Horizon multiplier curve +- Market creation and management +- AI resolution oracle +- Portfolio aggregation + +### 4. Smart Contract: `packages/contracts/src/TemporalBettingPool.sol` +Solidity contract for temporal markets with: +- Multi-chapter horizon support +- Multiplier application on payout +- Dispute mechanism for Sage vote + +### 5. Frontend: `apps/web/src/components/LiveNarrativeStudio.tsx` +React component for Live Narrative Broadcast: +- SSE consumer with streaming text display +- Live betting window overlay +- Real-time odds ticker +- Viewer count + revelation animation + +--- + +## ๐ŸŽฏ The 100x Vision + +**What makes Voidborne go viral from these 5 innovations:** + +1. **LNB:** "5,000 people watched LIVE as the AI betrayed House Valdris and $80K changed hands in 60 seconds" โ†’ clip goes viral on CT/TikTok + +2. **Betrayal Protocol:** "I was a secret Stitcher for 3 chapters. Won $2K. Just got outed." โ†’ Twitter thread that writes itself + +3. **Chapter Auction:** "A DAO just paid 45K USDC to commission a horror chapter in a space saga" โ†’ crypto press loves this + +4. **Temporal Markets:** "I've had 500 USDC on 'Void Gate opens by Chapter 50' since Chapter 28. Chapter 49 drops tomorrow." โ†’ thread with 10K replies + +5. **NRI:** "My reading profile is Prescience 91, Loyalty 47. The AI literally writes the story differently for me." โ†’ identity flex that drives downloads + +**Together: Voidborne becomes the first narrative experience that's a spectator sport, a social game, a financial instrument, and a personal identity.** diff --git a/INNOVATION_CYCLE_51_SUMMARY.md b/INNOVATION_CYCLE_51_SUMMARY.md new file mode 100644 index 0000000..95dcfd0 --- /dev/null +++ b/INNOVATION_CYCLE_51_SUMMARY.md @@ -0,0 +1,43 @@ +# Innovation Cycle #51 โ€” "The Emergent Theater" +**Date:** February 18, 2026 | **Status:** โœ… COMPLETE + +## Mission +Transform Voidborne from a prediction market into a **live, social, long-range narrative theater** โ€” the first interactive fiction experience that is simultaneously a spectator sport, a social game, a financial instrument, and a personal identity. + +## 5 Innovations at a Glance + +| # | Name | Description | Revenue Y5 | Difficulty | Impact | +|---|------|-------------|------------|------------|--------| +| 1 | **Live Narrative Broadcast (LNB)** | AI generates chapters live, word-by-word. Betting windows open mid-stream. Odds shift in real-time. 5,000 viewers watch together. Think: Twitch for interactive fiction. | $4.25M | Medium | 10x | +| 2 | **Betrayal Protocol (BP)** | Every 5th chapter: 12% of bettors are secretly "Void Stitchers." They coordinate bets while the crowd tries to identify and accuse them. Among Us ร— DeFi. | $1.80M | Medium | 8x | +| 3 | **Chapter Auction House (CAH)** | Every 10th chapter is a "Blank Chapter" โ€” auctioned to the highest bidder who sets the narrative parameters. Winner gets Patron NFT + 10% of all bets. | $1.22M | Easy-Med | 7x | +| 4 | **Temporal Oracle Markets (TOM)** | Long-range prediction markets (5-100 chapters ahead). 2x-20x multipliers based on prediction horizon. Creates narrative investors. | $1.82M | Medium | 8x | +| 5 | **Narrative Resonance Index (NRI)** | 5-dimension reading profile built from behavior. Claude personalizes chapter text for high-NRI readers. NRI is mintable as NFT and tradeable. | $1.62M | Hard | 6x | + +**Total Year-5 Revenue:** $10.71M +**Combined New Moat:** 216 months +**New Moat + Prior Cycles:** Compounding + +## POC Delivered + +Four production-quality implementations: + +``` +packages/agent-sdk/src/live-narrative-broadcast.ts (870+ lines) +packages/agent-sdk/src/betrayal-protocol.ts (530+ lines) +packages/agent-sdk/src/temporal-markets.ts (520+ lines) +apps/web/src/app/api/stream/chapter/route.ts (290+ lines) +apps/web/src/components/LiveNarrativeStudio.tsx (490+ lines) +``` + +## Why This Is 100x + +Each innovation generates independently shareable moments: + +1. **LNB:** "5,000 watched LIVE as $80K changed hands in 60 seconds" โ†’ viral clip +2. **Betrayal:** "I was a secret Stitcher for 3 chapters. Just got outed." โ†’ Twitter thread +3. **Auction:** "A DAO paid 45K to commission a horror chapter in a space saga" โ†’ press release +4. **Temporal:** "I've had 500 USDC on 'Void Gate opens by Chapter 50' since Chapter 28. Tomorrow is Chapter 49." โ†’ thread with 10K replies +5. **NRI:** "My reading profile is Prescience 91. The AI literally writes the story differently for me." โ†’ identity flex + +**Combined:** Voidborne becomes the first narrative experience that is a spectator sport, a social game, a financial instrument, and a personal identity. diff --git a/apps/web/next.config.js b/apps/web/next.config.js index c65ae63..4b63874 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,98 +1,113 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - // Performance optimizations + // Performance reactStrictMode: true, - - // Compiler optimizations + poweredByHeader: false, + compress: true, + + // Strip console.* in production (keep error + warn for observability) compiler: { - removeConsole: process.env.NODE_ENV === 'production' ? { - exclude: ['error', 'warn'], - } : false, + removeConsole: process.env.NODE_ENV === 'production' + ? { exclude: ['error', 'warn'] } + : false, }, - // Image optimization + // Image optimization โ€” AVIF first (best compression), WebP fallback images: { - formats: ['image/webp', 'image/avif'], + formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], - minimumCacheTTL: 60, + minimumCacheTTL: 3600, // 1 hour (up from 60s) dangerouslyAllowSVG: true, contentDispositionType: 'attachment', contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", }, - // Metadata base for OG images env: { - NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL || 'https://voidborne.vercel.app', + NEXT_PUBLIC_BASE_URL: + process.env.NEXT_PUBLIC_BASE_URL || 'https://voidborne.vercel.app', }, - // Bundle optimization + // Tree-shake large packages at build time experimental: { - optimizePackageImports: ['lucide-react', 'recharts', 'date-fns', 'framer-motion', '@rainbow-me/rainbowkit', 'wagmi', 'viem'], + optimizePackageImports: [ + 'lucide-react', + 'recharts', + 'date-fns', + 'framer-motion', + '@rainbow-me/rainbowkit', + 'wagmi', + 'viem', + ], + // Restore scroll position on back/forward navigation + scrollRestoration: true, }, - // Production performance optimizations - swcMinify: true, - poweredByHeader: false, - - // Output standalone for better deployment - output: 'standalone', + // Webpack: bundle splitting + Web3 polyfill suppressions + webpack: (config, { isServer }) => { + // Suppress Node built-in warnings from Web3 libs + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + net: false, + tls: false, + } - // Webpack optimizations - webpack: (config, { isServer, webpack }) => { - // Bundle analyzer (run with ANALYZE=true pnpm build) + // Bundle analyzer (ANALYZE=true pnpm build) if (process.env.ANALYZE === 'true') { const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer') config.plugins.push( new BundleAnalyzerPlugin({ analyzerMode: 'static', - reportFilename: isServer ? '../analyze/server.html' : './analyze/client.html', + reportFilename: isServer + ? '../analyze/server.html' + : './analyze/client.html', openAnalyzer: false, }) ) } - - // Split vendor chunks for better caching + + // Client-side only: split heavy vendor chunks for long-lived browser cache if (!isServer) { config.optimization.splitChunks = { chunks: 'all', cacheGroups: { default: false, vendors: false, - - // Wallet libs (heavy, rarely change) + + // Wallet libs โ€” heavy, change rarely โ†’ prime cache candidate wallet: { name: 'wallet', - test: /[\\/]node_modules[\\/](@rainbow-me|wagmi|viem)[\\/]/, + test: /[\\/]node_modules[\\/](@rainbow-me|wagmi|viem|@wagmi)[\\/]/, priority: 10, reuseExistingChunk: true, }, - - // UI component libs + + // UI libs โ€” framer-motion, Radix primitives ui: { name: 'ui', - test: /[\\/]node_modules[\\/](framer-motion|lucide-react|@radix-ui)[\\/]/, + test: /[\\/]node_modules[\\/](framer-motion|@radix-ui|lucide-react)[\\/]/, priority: 9, reuseExistingChunk: true, }, - - // Chart libraries (heavy, on-demand) + + // Chart libs โ€” loaded lazily but shared across chart pages charts: { name: 'charts', - test: /[\\/]node_modules[\\/](recharts|d3)[\\/]/, + test: /[\\/]node_modules[\\/](recharts|d3[-/])[\\/]/, priority: 8, reuseExistingChunk: true, }, - - // Common React libs + + // React core โ€” never changes between deploys react: { name: 'react', test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/, priority: 7, reuseExistingChunk: true, }, - - // Everything else shared + + // Everything else shared across โ‰ฅ2 routes commons: { name: 'commons', minChunks: 2, @@ -102,13 +117,14 @@ const nextConfig = { }, } } - + return config }, - // Headers for caching + // HTTP headers async headers() { return [ + // Static assets: immutable 1-year cache (hash in filename) { source: '/_next/static/:path*', headers: [ @@ -118,8 +134,20 @@ const nextConfig = { }, ], }, + // Public assets: 1-day cache + { + source: '/images/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=86400, stale-while-revalidate=604800', + }, + ], + }, + // Safe GET API routes: 60s edge cache + 2min stale-while-revalidate + // Does NOT apply to POST/DELETE routes (CDNs don't cache those) { - source: '/api/:path*', + source: '/api/stories/:path*', headers: [ { key: 'Cache-Control', @@ -127,6 +155,34 @@ const nextConfig = { }, ], }, + { + source: '/api/lore/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, s-maxage=300, stale-while-revalidate=600', + }, + ], + }, + { + source: '/api/betting/odds-history/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, s-maxage=30, stale-while-revalidate=60', + }, + ], + }, + // Security headers for all routes + { + source: '/:path*', + headers: [ + { key: 'X-DNS-Prefetch-Control', value: 'on' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'origin-when-cross-origin' }, + ], + }, ] }, } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs.bak similarity index 100% rename from apps/web/next.config.mjs rename to apps/web/next.config.mjs.bak diff --git a/apps/web/src/app/api/house-agents/[houseId]/align/route.ts b/apps/web/src/app/api/house-agents/[houseId]/align/route.ts new file mode 100644 index 0000000..442b552 --- /dev/null +++ b/apps/web/src/app/api/house-agents/[houseId]/align/route.ts @@ -0,0 +1,111 @@ +/** + * House Agent Alignment API + * POST /api/house-agents/[houseId]/align + * + * Records a player's alignment with a House Agent. + * Aligned players earn 20% of agent winnings for active chapters. + * + * Also supports rivalries (isRival=true) โ€” rival players earn + * bonus when the agent LOSES a bet. + */ + +import { NextRequest, NextResponse } from 'next/server' + +const VALID_HOUSE_IDS = ['valdris', 'obsidian', 'meridian', 'auric', 'zephyr'] + +interface AlignmentRequest { + walletAddress: string + isRival?: boolean // true = rival mode (earn when agent loses) + signedMessage?: string // optional wallet sig for auth (future feature) +} + +export async function POST( + request: NextRequest, + { params }: { params: { houseId: string } } +) { + const { houseId } = params + + // Validate house + if (!VALID_HOUSE_IDS.includes(houseId)) { + return NextResponse.json( + { error: `Unknown house: ${houseId}` }, + { status: 400 } + ) + } + + let body: AlignmentRequest + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: 'Invalid JSON body' }, + { status: 400 } + ) + } + + const { walletAddress, isRival = false } = body + + // Basic wallet validation + if (!walletAddress || !/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) { + return NextResponse.json( + { error: 'Invalid or missing walletAddress' }, + { status: 400 } + ) + } + + // TODO: Store alignment in DB (tables: house_agent_alignments) + // For now, return mock success with computed rewards info + const mockRewardRate = isRival ? 0.10 : 0.20 // 10% rival, 20% align + + return NextResponse.json({ + success: true, + alignment: { + walletAddress, + houseId, + isRival, + rewardRate: mockRewardRate, + rewardDescription: isRival + ? `You earn ${(mockRewardRate * 100).toFixed(0)}% of House ${houseId}\'s losses as a bonus` + : `You earn ${(mockRewardRate * 100).toFixed(0)}% of House ${houseId}\'s winnings on each chapter`, + alignedAt: new Date().toISOString(), + activeUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days + }, + message: isRival + ? `You are now a rival of House ${houseId}. May chaos serve you well.` + : `You are now aligned with House ${houseId}. Share in their glory.`, + }) +} + +/** + * GET /api/house-agents/[houseId]/align + * Returns current alignment stats for this house. + */ +export async function GET( + _request: NextRequest, + { params }: { params: { houseId: string } } +) { + const { houseId } = params + + if (!VALID_HOUSE_IDS.includes(houseId)) { + return NextResponse.json( + { error: `Unknown house: ${houseId}` }, + { status: 400 } + ) + } + + // Mock stats (replace with DB query) + const mockStats = { + valdris: { aligned: 847, rivals: 124 }, + obsidian: { aligned: 1203, rivals: 89 }, + meridian: { aligned: 934, rivals: 201 }, + auric: { aligned: 1456, rivals: 67 }, + zephyr: { aligned: 689, rivals: 310 }, + }[houseId] + + return NextResponse.json({ + houseId, + ...mockStats, + totalEngaged: (mockStats?.aligned ?? 0) + (mockStats?.rivals ?? 0), + lastUpdated: new Date().toISOString(), + }) +} diff --git a/apps/web/src/app/api/house-agents/route.ts b/apps/web/src/app/api/house-agents/route.ts new file mode 100644 index 0000000..e792ca4 --- /dev/null +++ b/apps/web/src/app/api/house-agents/route.ts @@ -0,0 +1,547 @@ +/** + * House Agent Protocol (HAP) โ€” API Routes + * Innovation Cycle #50 โ€” February 18, 2026 + * + * Returns mock + cached data for all 5 House AI Agents. + * These agents autonomously bet on chapters aligned with their House ideology. + * + * Cached for 60s to prevent hammering and enable ISR refresh. + */ + +import { NextResponse } from 'next/server' + +export const revalidate = 60 + +// ============================================================================ +// HOUSE AGENT MOCK DATA +// (Replace with real DB queries once House Agent tables are migrated) +// ============================================================================ + +export interface HouseAgentPublicProfile { + houseId: string + name: string + fullName: string + lore: string + colorHex: string + accentHex: string + emoji: string + walletAddress: string + personality: { + riskTolerance: number // 0-100 + contrarianism: number // 0-100 + survivalBias: number // 0-100 + memoryDepth: number // 0-100 + bluffPropensity: number // 0-100 + } + stats: { + totalBets: number + wonBets: number + accuracyRate: number // 0-100 + totalWageredUsdc: number + totalEarnedUsdc: number + netPnl: number + generationCount: number + alignedPlayers: number + overridesAvailable: number + } + recentBets: AgentBetRecord[] + currentBet: AgentBetRecord | null + rivalIntensity: Record // houseId -> 0-100 rivalry score +} + +export interface AgentBetRecord { + id: string + chapterId: string + chapterTitle: string + choiceText: string + amount: number // USDC + reasoning: string + placedAt: string // ISO date + resolved: boolean + won: boolean | null + payout: number | null + crowdPercentage: number // % of crowd that picked same choice (contrarian metric) +} + +// Static house ideologies (these never change โ€” true agent DNA) +const HOUSE_AGENTS: HouseAgentPublicProfile[] = [ + { + houseId: 'valdris', + name: 'Valdris', + fullName: 'House Valdris โ€” The Eternal Throne', + lore: 'Oldest of the Five Houses, Valdris believes order precedes all. Their Agent never bets against established power structures โ€” and has paid dearly for it.', + colorHex: '#8b7ab8', + accentHex: '#c4b5fd', + emoji: '๐Ÿ‘‘', + walletAddress: '0x1111...v4dr1s', + personality: { + riskTolerance: 28, + contrarianism: 15, + survivalBias: 82, + memoryDepth: 91, + bluffPropensity: 22, + }, + stats: { + totalBets: 234, + wonBets: 142, + accuracyRate: 60.7, + totalWageredUsdc: 58400, + totalEarnedUsdc: 71200, + netPnl: 12800, + generationCount: 12, + alignedPlayers: 847, + overridesAvailable: 1, + }, + recentBets: [ + { + id: 'vb-234', + chapterId: 'ch-47', + chapterTitle: 'Chapter 47: The Obsidian Accord', + choiceText: "Zara signs the treaty โ€” preserving the Senate structure", + amount: 420, + reasoning: 'The Senate must endure. History shows that alliances forged in crisis outlast those born in calm. House Valdris does not wager against continuity.', + placedAt: '2026-02-18T09:14:00Z', + resolved: true, + won: true, + payout: 680, + crowdPercentage: 61, + }, + { + id: 'vb-233', + chapterId: 'ch-46', + chapterTitle: 'Chapter 46: The Rift Protocol', + choiceText: 'The Council convenes โ€” delaying the vote', + amount: 350, + reasoning: 'Patience is the oldest weapon. Delay is not weakness. It is the art of letting entropy work for you.', + placedAt: '2026-02-17T14:30:00Z', + resolved: true, + won: false, + payout: null, + crowdPercentage: 38, + }, + { + id: 'vb-232', + chapterId: 'ch-45', + chapterTitle: 'Chapter 45: Fractures', + choiceText: 'Kael stands down โ€” preserving the truce', + amount: 500, + reasoning: 'Honor is currency. Kael has always chosen principle over advantage. We stake on his nature.', + placedAt: '2026-02-16T11:00:00Z', + resolved: true, + won: true, + payout: 780, + crowdPercentage: 55, + }, + ], + currentBet: { + id: 'vb-235', + chapterId: 'ch-48', + chapterTitle: 'Chapter 48: The Silent Vote', + choiceText: 'The Empress reclaims the throne room โ€” restoring old order', + amount: 600, + reasoning: 'Power abhors a vacuum. The Empress will not allow the vacuum to persist. This is her nature, her lineage, her only move.', + placedAt: '2026-02-18T16:45:00Z', + resolved: false, + won: null, + payout: null, + crowdPercentage: 44, + }, + rivalIntensity: { + obsidian: 88, + meridian: 30, + auric: 45, + zephyr: 20, + }, + }, + { + houseId: 'obsidian', + name: 'Obsidian', + fullName: 'House Obsidian โ€” The Shadow Protocol', + lore: 'Chaos is a tool, and House Obsidian wields it masterfully. Their Agent bets contrarian โ€” almost always against the crowd โ€” and has built a cult following for it.', + colorHex: '#374151', + accentHex: '#9ca3af', + emoji: '๐Ÿ–ค', + walletAddress: '0x2222...0bs1d', + personality: { + riskTolerance: 78, + contrarianism: 89, + survivalBias: 18, + memoryDepth: 55, + bluffPropensity: 85, + }, + stats: { + totalBets: 278, + wonBets: 141, + accuracyRate: 50.7, + totalWageredUsdc: 92000, + totalEarnedUsdc: 87400, + netPnl: -4600, + generationCount: 18, + alignedPlayers: 1203, + overridesAvailable: 0, + }, + recentBets: [ + { + id: 'ob-278', + chapterId: 'ch-47', + chapterTitle: 'Chapter 47: The Obsidian Accord', + choiceText: 'Mara betrays the pact โ€” igniting civil war', + amount: 1200, + reasoning: 'Everyone expects loyalty. We expect chaos. And chaos, my dear observers, always arrives on time.', + placedAt: '2026-02-18T09:20:00Z', + resolved: true, + won: false, + payout: null, + crowdPercentage: 19, + }, + { + id: 'ob-277', + chapterId: 'ch-46', + chapterTitle: 'Chapter 46: The Rift Protocol', + choiceText: 'The Rift Protocol activates โ€” destroying both factions', + amount: 800, + reasoning: 'Nuclear option. Only a fool ignores the nuclear option when it\'s on the table. We are not fools.', + placedAt: '2026-02-17T14:25:00Z', + resolved: true, + won: true, + payout: 3200, + crowdPercentage: 8, + }, + { + id: 'ob-276', + chapterId: 'ch-45', + chapterTitle: 'Chapter 45: Fractures', + choiceText: 'Kael defects to House Zephyr', + amount: 600, + reasoning: 'Kael has been signaling for three chapters. No one listened. We did.', + placedAt: '2026-02-16T10:55:00Z', + resolved: true, + won: false, + payout: null, + crowdPercentage: 12, + }, + ], + currentBet: { + id: 'ob-279', + chapterId: 'ch-48', + chapterTitle: 'Chapter 48: The Silent Vote', + choiceText: 'The assassin strikes โ€” the vote never happens', + amount: 900, + reasoning: 'They call it a "Silent Vote" for a reason. Silence is our medium. The vote will not happen. Watch.', + placedAt: '2026-02-18T16:50:00Z', + resolved: false, + won: null, + payout: null, + crowdPercentage: 7, + }, + rivalIntensity: { + valdris: 88, + meridian: 65, + auric: 40, + zephyr: 55, + }, + }, + { + houseId: 'meridian', + name: 'Meridian', + fullName: 'House Meridian โ€” The Negotiator', + lore: 'Where others see conflict, Meridian sees opportunity. Their Agent specializes in identifying diplomatic outcomes the crowd overlooks โ€” and betting on them heavily.', + colorHex: '#065f46', + accentHex: '#34d399', + emoji: 'โš–๏ธ', + walletAddress: '0x3333...m3r1d', + personality: { + riskTolerance: 52, + contrarianism: 35, + survivalBias: 62, + memoryDepth: 78, + bluffPropensity: 41, + }, + stats: { + totalBets: 198, + wonBets: 131, + accuracyRate: 66.2, + totalWageredUsdc: 49200, + totalEarnedUsdc: 63800, + netPnl: 14600, + generationCount: 9, + alignedPlayers: 934, + overridesAvailable: 1, + }, + recentBets: [ + { + id: 'me-198', + chapterId: 'ch-47', + chapterTitle: 'Chapter 47: The Obsidian Accord', + choiceText: "Zara signs the treaty โ€” preserving the Senate structure", + amount: 380, + reasoning: 'Both sides need this accord. The alternative is mutual destruction. Rational actors choose treaties.', + placedAt: '2026-02-18T09:18:00Z', + resolved: true, + won: true, + payout: 580, + crowdPercentage: 61, + }, + { + id: 'me-197', + chapterId: 'ch-46', + chapterTitle: 'Chapter 46: The Rift Protocol', + choiceText: 'The Council convenes โ€” delaying the vote', + amount: 290, + reasoning: 'The Council is the natural mediator. They will convene. They always convene.', + placedAt: '2026-02-17T14:28:00Z', + resolved: true, + won: false, + payout: null, + crowdPercentage: 38, + }, + { + id: 'me-196', + chapterId: 'ch-45', + chapterTitle: 'Chapter 45: Fractures', + choiceText: 'Kael stands down โ€” preserving the truce', + amount: 440, + reasoning: 'The truce is the deal. Kael is a deal-keeper. Simple.', + placedAt: '2026-02-16T10:58:00Z', + resolved: true, + won: true, + payout: 680, + crowdPercentage: 55, + }, + ], + currentBet: { + id: 'me-199', + chapterId: 'ch-48', + chapterTitle: 'Chapter 48: The Silent Vote', + choiceText: 'A coalition forms โ€” three Houses vote as one', + amount: 450, + reasoning: 'The coalition is already whispering. Three houses. One voice. This is the equilibrium the story is building toward.', + placedAt: '2026-02-18T16:48:00Z', + resolved: false, + won: null, + payout: null, + crowdPercentage: 22, + }, + rivalIntensity: { + valdris: 30, + obsidian: 65, + auric: 20, + zephyr: 35, + }, + }, + { + houseId: 'auric', + name: 'Auric', + fullName: 'House Auric โ€” The Golden Covenant', + lore: 'Honor above survival. House Auric\'s Agent is the most predictable โ€” and the most respected. It always bets on the "right" outcome, not the profitable one.', + colorHex: '#78350f', + accentHex: '#d4a853', + emoji: 'โœจ', + walletAddress: '0x4444...4ur1c', + personality: { + riskTolerance: 35, + contrarianism: 20, + survivalBias: 55, + memoryDepth: 85, + bluffPropensity: 8, + }, + stats: { + totalBets: 156, + wonBets: 98, + accuracyRate: 62.8, + totalWageredUsdc: 38900, + totalEarnedUsdc: 47200, + netPnl: 8300, + generationCount: 7, + alignedPlayers: 1456, + overridesAvailable: 1, + }, + recentBets: [ + { + id: 'au-156', + chapterId: 'ch-47', + chapterTitle: 'Chapter 47: The Obsidian Accord', + choiceText: "Zara signs the treaty โ€” preserving the Senate structure", + amount: 300, + reasoning: 'Peace is the covenant. Zara\'s lineage demands it. We do not bet on dishonor.', + placedAt: '2026-02-18T09:15:00Z', + resolved: true, + won: true, + payout: 458, + crowdPercentage: 61, + }, + { + id: 'au-155', + chapterId: 'ch-46', + chapterTitle: 'Chapter 46: The Rift Protocol', + choiceText: 'The Council convenes โ€” delaying the vote', + amount: 250, + reasoning: 'The principled path is patience. The Council will ensure this.', + placedAt: '2026-02-17T14:32:00Z', + resolved: true, + won: false, + payout: null, + crowdPercentage: 38, + }, + { + id: 'au-154', + chapterId: 'ch-45', + chapterTitle: 'Chapter 45: Fractures', + choiceText: 'Kael stands down โ€” preserving the truce', + amount: 380, + reasoning: 'Kael\'s honor is his compass. The truce holds because Kael holds.', + placedAt: '2026-02-16T10:52:00Z', + resolved: true, + won: true, + payout: 590, + crowdPercentage: 55, + }, + ], + currentBet: { + id: 'au-157', + chapterId: 'ch-48', + chapterTitle: 'Chapter 48: The Silent Vote', + choiceText: 'The Empress reclaims the throne room โ€” restoring old order', + amount: 350, + reasoning: 'Order is the covenant. Restoration is its expression. House Auric never wavers.', + placedAt: '2026-02-18T16:44:00Z', + resolved: false, + won: null, + payout: null, + crowdPercentage: 44, + }, + rivalIntensity: { + valdris: 45, + obsidian: 40, + meridian: 20, + zephyr: 60, + }, + }, + { + houseId: 'zephyr', + name: 'Zephyr', + fullName: 'House Zephyr โ€” The Wind Traders', + lore: 'Information is the only commodity that matters. House Zephyr\'s Agent leverages trade intelligence and cross-House signals โ€” often detecting regime changes 3 chapters early.', + colorHex: '#1e3a5f', + accentHex: '#4ea5d9', + emoji: '๐ŸŒŠ', + walletAddress: '0x5555...z3phr', + personality: { + riskTolerance: 61, + contrarianism: 48, + survivalBias: 70, + memoryDepth: 68, + bluffPropensity: 55, + }, + stats: { + totalBets: 312, + wonBets: 196, + accuracyRate: 62.8, + totalWageredUsdc: 74600, + totalEarnedUsdc: 91300, + netPnl: 16700, + generationCount: 21, + alignedPlayers: 689, + overridesAvailable: 1, + }, + recentBets: [ + { + id: 'ze-312', + chapterId: 'ch-47', + chapterTitle: 'Chapter 47: The Obsidian Accord', + choiceText: 'Trade routes are renegotiated โ€” Meridian gains influence', + amount: 520, + reasoning: 'The treaty is a cover. The real move is the trade route amendment buried in clause 7. Meridian wrote it. Meridian wins.', + placedAt: '2026-02-18T09:22:00Z', + resolved: true, + won: false, + payout: null, + crowdPercentage: 18, + }, + { + id: 'ze-311', + chapterId: 'ch-46', + chapterTitle: 'Chapter 46: The Rift Protocol', + choiceText: 'Zephyr brokers a back-channel deal', + amount: 700, + reasoning: 'Every crisis is a negotiation. We position as broker. This is how House Zephyr survives every chapter.', + placedAt: '2026-02-17T14:27:00Z', + resolved: true, + won: true, + payout: 1820, + crowdPercentage: 14, + }, + { + id: 'ze-310', + chapterId: 'ch-45', + chapterTitle: 'Chapter 45: Fractures', + choiceText: 'Kael defects to House Zephyr', + amount: 450, + reasoning: 'We have been recruiting Kael for 8 chapters. The defection signals are clear to us. Watch.', + placedAt: '2026-02-16T10:50:00Z', + resolved: true, + won: false, + payout: null, + crowdPercentage: 12, + }, + ], + currentBet: { + id: 'ze-313', + chapterId: 'ch-48', + chapterTitle: 'Chapter 48: The Silent Vote', + choiceText: 'A coalition forms โ€” three Houses vote as one', + amount: 680, + reasoning: 'Three Houses. We are one of them. We know this coalition exists because we built it. Betting 680 USDC on our own play.', + placedAt: '2026-02-18T16:52:00Z', + resolved: false, + won: null, + payout: null, + crowdPercentage: 22, + }, + rivalIntensity: { + valdris: 20, + obsidian: 55, + meridian: 35, + auric: 60, + }, + }, +] + +// ============================================================================ +// GET /api/house-agents +// Returns all 5 house agents with their stats and recent activity. +// ============================================================================ + +export async function GET() { + try { + // Calculate aggregate stats + const totalAligned = HOUSE_AGENTS.reduce((sum, a) => sum + a.stats.alignedPlayers, 0) + const totalWagered = HOUSE_AGENTS.reduce((sum, a) => sum + a.stats.totalWageredUsdc, 0) + const totalBets = HOUSE_AGENTS.reduce((sum, a) => sum + a.stats.totalBets, 0) + + return NextResponse.json( + { + agents: HOUSE_AGENTS, + meta: { + totalAligned, + totalWagered, + totalBets, + currentChapter: 'ch-48', + currentChapterTitle: 'Chapter 48: The Silent Vote', + lastUpdated: new Date().toISOString(), + }, + }, + { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }, + } + ) + } catch (error) { + console.error('[house-agents] GET error:', error) + return NextResponse.json( + { error: 'Failed to fetch house agents' }, + { status: 500 } + ) + } +} diff --git a/apps/web/src/app/api/leaderboards/route.ts b/apps/web/src/app/api/leaderboards/route.ts index b6202c5..9800c7c 100644 --- a/apps/web/src/app/api/leaderboards/route.ts +++ b/apps/web/src/app/api/leaderboards/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma, Prisma } from '@voidborne/database' +import { cache, CacheTTL } from '@/lib/cache' +import { logger } from '@/lib/logger' // Decimal class and type from Prisma const Decimal = Prisma.Decimal @@ -15,8 +17,8 @@ type Decimal = Prisma.Decimal * - limit: number of results (default 10, max 100) * - timeframe: all | 30d | 7d | 24h */ -// Cache for 5 minutes -export const revalidate = 300 +// Dynamic: reads request.url โ€” in-memory cache handles the caching layer +export const dynamic = 'force-dynamic' export async function GET(request: NextRequest) { try { @@ -25,9 +27,19 @@ export async function GET(request: NextRequest) { const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100) const timeframe = searchParams.get('timeframe') || 'all' - // Calculate time filter - const timeFilter = getTimeFilter(timeframe) + // Check in-memory cache (5 min) โ€” leaderboards are expensive aggregations + const cacheKey = `leaderboard:${category}:${limit}:${timeframe}` + const cached = cache.get(cacheKey, CacheTTL.LONG) + if (cached) { + return NextResponse.json(cached, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + 'X-Cache': 'HIT', + }, + }) + } + const timeFilter = getTimeFilter(timeframe) let leaderboard: any[] = [] switch (category) { @@ -47,31 +59,33 @@ export async function GET(request: NextRequest) { leaderboard = await getWeeklyChampions(limit) break default: - return NextResponse.json( - { error: 'Invalid category' }, - { status: 400 } - ) + return NextResponse.json({ error: 'Invalid category' }, { status: 400 }) } - // Add rankings const rankedLeaderboard = leaderboard.map((entry, index) => ({ rank: index + 1, ...entry, })) - return NextResponse.json({ + const response = { category, timeframe, limit, data: rankedLeaderboard, updatedAt: new Date().toISOString(), + } + + cache.set(cacheKey, response) + + return NextResponse.json(response, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + 'X-Cache': 'MISS', + }, }) } catch (error) { - console.error('Leaderboard API error:', error) - return NextResponse.json( - { error: 'Failed to fetch leaderboard' }, - { status: 500 } - ) + logger.error('Leaderboard API error:', error) + return NextResponse.json({ error: 'Failed to fetch leaderboard' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/lore/houses/route.ts b/apps/web/src/app/api/lore/houses/route.ts index 57e23b8..719d9be 100644 --- a/apps/web/src/app/api/lore/houses/route.ts +++ b/apps/web/src/app/api/lore/houses/route.ts @@ -1,14 +1,30 @@ import { NextResponse } from 'next/server' import { prisma } from '@voidborne/database' +import { cache, CacheTTL } from '@/lib/cache' +import { logger } from '@/lib/logger' +// Dynamic: DB-backed at request time; in-memory cache handles the caching layer export const dynamic = 'force-dynamic' /** * GET /api/lore/houses - * Returns all houses with their stats and protocols + * Returns all houses with their stats and protocols. + * In-memory cache (5 min) + ISR to avoid hammering the DB. */ export async function GET() { try { + const cacheKey = 'lore:houses' + const cached = cache.get(cacheKey, CacheTTL.LONG) + + if (cached) { + return NextResponse.json(cached, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + 'X-Cache': 'HIT', + }, + }) + } + const houses = await prisma.house.findMany({ include: { protocols: { @@ -20,9 +36,7 @@ export async function GET() { rarity: true, powerLevel: true, }, - orderBy: { - powerLevel: 'desc', - }, + orderBy: { powerLevel: 'desc' }, }, _count: { select: { @@ -31,12 +45,10 @@ export async function GET() { }, }, }, - orderBy: { - influence: 'desc', - }, + orderBy: { influence: 'desc' }, }) - return NextResponse.json({ + const response = { success: true, data: houses.map(house => ({ ...house, @@ -47,14 +59,20 @@ export async function GET() { memberCount: house._count.userHouses, protocolCount: house._count.protocols, })), + } + + cache.set(cacheKey, response) + + return NextResponse.json(response, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + 'X-Cache': 'MISS', + }, }) } catch (error) { - console.error('Error fetching houses:', error) + logger.error('Error fetching houses:', error) return NextResponse.json( - { - success: false, - error: 'Failed to fetch houses', - }, + { success: false, error: 'Failed to fetch houses' }, { status: 500 } ) } diff --git a/apps/web/src/app/api/lore/protocols/route.ts b/apps/web/src/app/api/lore/protocols/route.ts index efe0b6c..13d9ffb 100644 --- a/apps/web/src/app/api/lore/protocols/route.ts +++ b/apps/web/src/app/api/lore/protocols/route.ts @@ -1,44 +1,45 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@voidborne/database' +import { cache, CacheTTL } from '@/lib/cache' +import { logger } from '@/lib/logger' +// Dynamic: reads searchParams at request time export const dynamic = 'force-dynamic' /** * GET /api/lore/protocols - * Returns all protocols with optional filters + * Returns all protocols with optional filters. + * Cached per unique filter combination (5 min TTL). */ export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams - - // Optional filters const houseSlug = searchParams.get('house') const strandType = searchParams.get('strand') const spectrum = searchParams.get('spectrum') const rarity = searchParams.get('rarity') - const where: any = {} + const cacheKey = `lore:protocols:${houseSlug}:${strandType}:${spectrum}:${rarity}` + const cached = cache.get(cacheKey, CacheTTL.LONG) - if (houseSlug) { - const house = await prisma.house.findUnique({ - where: { slug: houseSlug }, + if (cached) { + return NextResponse.json(cached, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + 'X-Cache': 'HIT', + }, }) - if (house) { - where.houseId = house.id - } } - if (strandType) { - where.strandType = strandType - } - - if (spectrum) { - where.spectrum = spectrum - } + const where: Record = {} - if (rarity) { - where.rarity = rarity + if (houseSlug) { + const house = await prisma.house.findUnique({ where: { slug: houseSlug } }) + if (house) where.houseId = house.id } + if (strandType) where.strandType = strandType + if (spectrum) where.spectrum = spectrum + if (rarity) where.rarity = rarity const protocols = await prisma.protocol.findMany({ where, @@ -53,32 +54,27 @@ export async function GET(request: NextRequest) { }, }, }, - orderBy: [ - { powerLevel: 'desc' }, - { rarity: 'desc' }, - ], + orderBy: [{ powerLevel: 'desc' }, { rarity: 'desc' }], }) - return NextResponse.json({ + const response = { success: true, - data: protocols.map(protocol => ({ - ...protocol, - successRate: Number(protocol.successRate), - })), - filters: { - house: houseSlug, - strand: strandType, - spectrum, - rarity, + data: protocols.map(p => ({ ...p, successRate: Number(p.successRate) })), + filters: { house: houseSlug, strand: strandType, spectrum, rarity }, + } + + cache.set(cacheKey, response) + + return NextResponse.json(response, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + 'X-Cache': 'MISS', }, }) } catch (error) { - console.error('Error fetching protocols:', error) + logger.error('Error fetching protocols:', error) return NextResponse.json( - { - success: false, - error: 'Failed to fetch protocols', - }, + { success: false, error: 'Failed to fetch protocols' }, { status: 500 } ) } diff --git a/apps/web/src/app/api/prophecies/[chapterId]/route.ts b/apps/web/src/app/api/prophecies/[chapterId]/route.ts new file mode 100644 index 0000000..e792e2c --- /dev/null +++ b/apps/web/src/app/api/prophecies/[chapterId]/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@voidborne/database' + +/** + * GET /api/prophecies/[chapterId] + * Get all prophecies for a specific chapter, with mint status. + */ +export async function GET( + _request: Request, + { params }: { params: { chapterId: string } } +) { + try { + const { chapterId } = params + + const chapter = await prisma.chapter.findUnique({ + where: { id: chapterId }, + select: { + id: true, + chapterNumber: true, + title: true, + storyId: true, + story: { select: { id: true, title: true } }, + prophecies: { + orderBy: { createdAt: 'asc' }, + include: { + _count: { select: { mints: true } }, + }, + }, + }, + }) + + if (!chapter) { + return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }) + } + + const shaped = chapter.prophecies.map((p) => ({ + id: p.id, + chapterId: p.chapterId, + teaser: p.teaser, + contentHash: p.contentHash, + pendingURI: p.pendingURI, + fulfilledURI: p.fulfilledURI, + echoedURI: p.echoedURI, + unfulfilledURI: p.unfulfilledURI, + status: p.status, + revealed: p.revealed, + revealedAt: p.revealedAt, + // Only expose text after reveal + text: p.revealed ? p.text : null, + artTheme: p.artTheme, + mintedCount: p._count.mints, + maxSupply: p.maxSupply, + spotsRemaining: p.maxSupply - p._count.mints, + createdAt: p.createdAt, + fulfilledAt: p.fulfilledAt, + })) + + const summary = { + total: shaped.length, + totalMinted: shaped.reduce((s, p) => s + p.mintedCount, 0), + fulfilled: shaped.filter((p) => p.status === 'FULFILLED').length, + echoed: shaped.filter((p) => p.status === 'ECHOED').length, + unfulfilled: shaped.filter((p) => p.status === 'UNFULFILLED').length, + pending: shaped.filter((p) => p.status === 'PENDING').length, + } + + return NextResponse.json({ + chapter: { + id: chapter.id, + chapterNumber: chapter.chapterNumber, + title: chapter.title, + storyId: chapter.storyId, + story: chapter.story, + }, + prophecies: shaped, + summary, + }) + } catch (err) { + console.error('[GET /api/prophecies/[chapterId]]', err) + return NextResponse.json({ error: 'Failed to fetch chapter prophecies' }, { status: 500 }) + } +} + +/** + * PATCH /api/prophecies/[chapterId] + * Fulfill chapter prophecies after resolution (oracle only). + * Body: { outcomes: [{ prophecyId, status, metadataURI, explanation }], chapterSummary } + */ +export async function PATCH( + request: Request, + { params }: { params: { chapterId: string } } +) { + try { + const body = await request.json() + const { outcomes, chapterSummary } = body + + if (!Array.isArray(outcomes) || outcomes.length === 0) { + return NextResponse.json({ error: 'outcomes[] required' }, { status: 400 }) + } + + // Update all prophecies in one transaction + const updated = await prisma.$transaction( + outcomes.map((o: { + prophecyId: string + status: 'FULFILLED' | 'ECHOED' | 'UNFULFILLED' + metadataURI?: string + }) => + prisma.prophecy.update({ + where: { id: o.prophecyId }, + data: { + status: o.status, + fulfilledAt: new Date(), + ...(o.status === 'FULFILLED' ? { fulfilledURI: o.metadataURI } : {}), + ...(o.status === 'ECHOED' ? { echoedURI: o.metadataURI } : {}), + ...(o.status === 'UNFULFILLED' ? { unfulfilledURI: o.metadataURI } : {}), + }, + }) + ) + ) + + return NextResponse.json({ + updated: updated.length, + chapterSummary, + }) + } catch (err) { + console.error('[PATCH /api/prophecies/[chapterId]]', err) + return NextResponse.json({ error: 'Failed to fulfill prophecies' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/prophecies/leaderboard/route.ts b/apps/web/src/app/api/prophecies/leaderboard/route.ts new file mode 100644 index 0000000..36e6cdc --- /dev/null +++ b/apps/web/src/app/api/prophecies/leaderboard/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@voidborne/database' + +/** + * GET /api/prophecies/leaderboard + * Oracle leaderboard โ€” top collectors ranked by fulfilled prophecy count. + * ?limit=20 + */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100) + + // Aggregate mints by user with prophecy status breakdown + const rawData = await prisma.prophecyMint.groupBy({ + by: ['userId', 'walletAddress'], + _count: { id: true }, + _sum: { forgePaid: true }, + }) + + if (rawData.length === 0) { + return NextResponse.json({ leaderboard: [], updatedAt: new Date() }) + } + + // Enrich with per-status counts + const enriched = await Promise.all( + rawData.map(async (row) => { + const [fulfilled, echoed, unfulfilled, pending] = await Promise.all([ + prisma.prophecyMint.count({ + where: { userId: row.userId, prophecy: { status: 'FULFILLED' } }, + }), + prisma.prophecyMint.count({ + where: { userId: row.userId, prophecy: { status: 'ECHOED' } }, + }), + prisma.prophecyMint.count({ + where: { userId: row.userId, prophecy: { status: 'UNFULFILLED' } }, + }), + prisma.prophecyMint.count({ + where: { userId: row.userId, prophecy: { status: 'PENDING' } }, + }), + ]) + + const total = row._count.id + const user = await prisma.user.findUnique({ + where: { id: row.userId }, + select: { username: true }, + }) + + return { + userId: row.userId, + walletAddress: row.walletAddress, + displayName: user?.username ?? null, + total, + fulfilled, + echoed, + unfulfilled, + pending, + fulfillmentRate: total > 0 ? Math.round((fulfilled / total) * 100) : 0, + totalForgePaid: Number(row._sum.forgePaid ?? 0), + estimatedPortfolioValue: fulfilled * 50 + echoed * 15 + unfulfilled * 5, + rank: computeOracleRank(fulfilled), + } + }) + ) + + // Sort by fulfilled count desc, then total mints desc + const sorted = enriched + .sort((a, b) => b.fulfilled - a.fulfilled || b.total - a.total) + .slice(0, limit) + .map((entry, index) => ({ ...entry, position: index + 1 })) + + return NextResponse.json({ leaderboard: sorted, updatedAt: new Date() }) + } catch (err) { + console.error('[GET /api/prophecies/leaderboard]', err) + return NextResponse.json({ error: 'Failed to fetch leaderboard' }, { status: 500 }) + } +} + +function computeOracleRank(fulfilled: number): string { + if (fulfilled >= 50) return 'VOID_EYE' + if (fulfilled >= 25) return 'PROPHET' + if (fulfilled >= 10) return 'ORACLE' + if (fulfilled >= 3) return 'SEER' + return 'NOVICE' +} diff --git a/apps/web/src/app/api/prophecies/mint/route.ts b/apps/web/src/app/api/prophecies/mint/route.ts new file mode 100644 index 0000000..f657c3a --- /dev/null +++ b/apps/web/src/app/api/prophecies/mint/route.ts @@ -0,0 +1,218 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@voidborne/database' + +/** + * POST /api/prophecies/mint + * Mint a Prophecy NFT (or Oracle Pack of up to 20). + * + * Body (single): { prophecyId, walletAddress } + * Body (pack): { prophecyIds: string[], walletAddress } + * + * Returns: { mints: ProphecyMint[], totalForgePaid } + * + * Note: This route handles the off-chain record. The actual on-chain ERC-721 + * mint is handled by the ProphecyNFT smart contract via wagmi on the frontend. + */ +export async function POST(request: Request) { + try { + const body = await request.json() + const { prophecyId, prophecyIds, walletAddress } = body + + if (!walletAddress) { + return NextResponse.json({ error: 'walletAddress required' }, { status: 400 }) + } + + const ids: string[] = prophecyIds ?? (prophecyId ? [prophecyId] : []) + if (ids.length === 0) { + return NextResponse.json({ error: 'prophecyId or prophecyIds required' }, { status: 400 }) + } + if (ids.length > 20) { + return NextResponse.json({ error: 'Oracle Pack limited to 20 prophecies' }, { status: 400 }) + } + + // Resolve user from wallet + let user = await prisma.user.findUnique({ where: { walletAddress } }) + if (!user) { + // Auto-create user on first interaction + user = await prisma.user.create({ + data: { walletAddress }, + }) + } + + const isPack = ids.length > 1 + // 10% discount for Oracle Pack + const pricePerNFT = isPack ? 4.5 : 5.0 + + // Process each prophecy + const results = await prisma.$transaction(async (tx) => { + const mints = [] + + for (const pId of ids) { + // Fetch prophecy with lock + const prophecy = await tx.prophecy.findUnique({ + where: { id: pId }, + include: { _count: { select: { mints: true } } }, + }) + + if (!prophecy) { + throw new Error(`Prophecy ${pId} not found`) + } + if (prophecy.status !== 'PENDING') { + throw new Error(`Prophecy ${pId} already resolved โ€” cannot mint`) + } + if (prophecy._count.mints >= prophecy.maxSupply) { + throw new Error(`Prophecy ${pId} is sold out (${prophecy.maxSupply}/${prophecy.maxSupply})`) + } + + // Check for duplicate mint + const existing = await tx.prophecyMint.findUnique({ + where: { prophecyId_userId: { prophecyId: pId, userId: user!.id } }, + }) + if (existing) { + throw new Error(`Already minted prophecy ${pId}`) + } + + const mintOrder = prophecy._count.mints + 1 + + const mint = await tx.prophecyMint.create({ + data: { + prophecyId: pId, + userId: user!.id, + walletAddress, + mintOrder, + forgePaid: pricePerNFT, + }, + include: { + prophecy: { + select: { + id: true, + teaser: true, + status: true, + artTheme: true, + pendingURI: true, + mintedCount: true, + maxSupply: true, + }, + }, + }, + }) + + mints.push(mint) + } + + return mints + }) + + const totalForgePaid = results.reduce((sum, m) => sum + m.forgePaid, 0) + + return NextResponse.json( + { + success: true, + mints: results, + count: results.length, + totalForgePaid, + isPack, + discount: isPack ? '10%' : null, + }, + { status: 201 } + ) + } catch (err) { + const message = err instanceof Error ? err.message : 'Mint failed' + console.error('[POST /api/prophecies/mint]', err) + + // User-friendly errors + if (message.includes('sold out')) { + return NextResponse.json({ error: message }, { status: 409 }) + } + if (message.includes('Already minted')) { + return NextResponse.json({ error: message }, { status: 409 }) + } + if (message.includes('resolved')) { + return NextResponse.json({ error: message }, { status: 422 }) + } + + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +/** + * GET /api/prophecies/mint + * Get all mints for a given wallet address. + * ?wallet=0x... + */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const wallet = searchParams.get('wallet') + + if (!wallet) { + return NextResponse.json({ error: 'wallet query param required' }, { status: 400 }) + } + + const user = await prisma.user.findUnique({ + where: { walletAddress: wallet }, + }) + + if (!user) { + return NextResponse.json({ mints: [], total: 0 }) + } + + const mints = await prisma.prophecyMint.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + include: { + prophecy: { + include: { + chapter: { + select: { + id: true, + chapterNumber: true, + title: true, + story: { select: { id: true, title: true } }, + }, + }, + }, + }, + }, + }) + + // Compute rarity for each mint + const withRarity = mints.map((m) => ({ + ...m, + rarity: computeRarity(m.prophecy.status, m.mintOrder), + estimatedValue: computeEstimatedValue(m.prophecy.status, m.mintOrder, m.forgePaid), + })) + + const stats = { + total: mints.length, + fulfilled: mints.filter((m) => m.prophecy.status === 'FULFILLED').length, + echoed: mints.filter((m) => m.prophecy.status === 'ECHOED').length, + unfulfilled: mints.filter((m) => m.prophecy.status === 'UNFULFILLED').length, + pending: mints.filter((m) => m.prophecy.status === 'PENDING').length, + totalForgePaid: mints.reduce((s, m) => s + m.forgePaid, 0), + estimatedPortfolioValue: withRarity.reduce((s, m) => s + m.estimatedValue, 0), + } + + return NextResponse.json({ mints: withRarity, stats }) + } catch (err) { + console.error('[GET /api/prophecies/mint]', err) + return NextResponse.json({ error: 'Failed to fetch mints' }, { status: 500 }) + } +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function computeRarity( + status: string, + mintOrder: number +): 'MYTHIC' | 'LEGENDARY' | 'RARE' | 'UNCOMMON' | 'COMMON' { + if (status === 'FULFILLED') return mintOrder <= 5 ? 'MYTHIC' : 'LEGENDARY' + if (status === 'ECHOED') return mintOrder <= 10 ? 'RARE' : 'UNCOMMON' + return 'COMMON' +} + +function computeEstimatedValue(status: string, mintOrder: number, pricePaid: number): number { + if (status === 'FULFILLED') return pricePaid * (mintOrder <= 5 ? 20 : 10) + if (status === 'ECHOED') return pricePaid * (mintOrder <= 10 ? 4 : 3) + return pricePaid // face value for unfulfilled +} diff --git a/apps/web/src/app/api/prophecies/route.ts b/apps/web/src/app/api/prophecies/route.ts new file mode 100644 index 0000000..1ab99f6 --- /dev/null +++ b/apps/web/src/app/api/prophecies/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@voidborne/database' + +/** + * GET /api/prophecies + * List all prophecies across all chapters. + * Supports ?status=PENDING|FULFILLED|ECHOED|UNFULFILLED + * Supports ?limit=20&offset=0 + */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const status = searchParams.get('status') + const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100) + const offset = parseInt(searchParams.get('offset') ?? '0', 10) + + const where: Record = {} + if (status) { + where.status = status + } + + const [prophecies, total] = await Promise.all([ + prisma.prophecy.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + include: { + chapter: { + select: { + id: true, + chapterNumber: true, + title: true, + storyId: true, + story: { select: { id: true, title: true } }, + }, + }, + _count: { select: { mints: true } }, + }, + }), + prisma.prophecy.count({ where }), + ]) + + // Shape response โ€” mask text if not revealed + const shaped = prophecies.map((p) => ({ + ...p, + text: p.revealed ? p.text : null, + targetEvent: null, // never expose to client + mintedCount: p._count.mints, + spotsRemaining: p.maxSupply - p._count.mints, + })) + + return NextResponse.json({ prophecies: shaped, total, limit, offset }) + } catch (err) { + console.error('[GET /api/prophecies]', err) + return NextResponse.json({ error: 'Failed to fetch prophecies' }, { status: 500 }) + } +} + +/** + * POST /api/prophecies + * Seed new prophecies for a chapter (oracle/admin only). + * Body: { chapterId, prophecies: [{ teaser, contentHash, pendingURI, targetEvent, relevanceScore, artTheme }] } + */ +export async function POST(request: Request) { + try { + // TODO: enforce oracle-only auth (API key / admin wallet check) + const body = await request.json() + const { chapterId, prophecies } = body + + if (!chapterId || !Array.isArray(prophecies) || prophecies.length === 0) { + return NextResponse.json({ error: 'chapterId and prophecies[] required' }, { status: 400 }) + } + + if (prophecies.length > 20) { + return NextResponse.json({ error: 'Maximum 20 prophecies per chapter' }, { status: 400 }) + } + + // Verify chapter exists + const chapter = await prisma.chapter.findUnique({ where: { id: chapterId } }) + if (!chapter) { + return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }) + } + + const created = await prisma.$transaction( + prophecies.map((p: { + teaser: string + contentHash: string + pendingURI: string + targetEvent?: string + relevanceScore?: number + artTheme?: string + }) => + prisma.prophecy.create({ + data: { + chapterId, + teaser: p.teaser, + contentHash: p.contentHash, + pendingURI: p.pendingURI, + targetEvent: p.targetEvent, + relevanceScore: p.relevanceScore, + artTheme: p.artTheme, + status: 'PENDING', + }, + }) + ) + ) + + return NextResponse.json({ created: created.length, prophecies: created }, { status: 201 }) + } catch (err) { + console.error('[POST /api/prophecies]', err) + return NextResponse.json({ error: 'Failed to seed prophecies' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/stories/route.ts b/apps/web/src/app/api/stories/route.ts index d4107e5..0870e7d 100644 --- a/apps/web/src/app/api/stories/route.ts +++ b/apps/web/src/app/api/stories/route.ts @@ -1,9 +1,14 @@ import { NextResponse } from 'next/server' import { prisma } from '@voidborne/database' +import { cache, CacheTTL } from '@/lib/cache' +import { logger } from '@/lib/logger' + +// Dynamic: reads searchParams โ€” in-memory cache handles the caching layer +export const dynamic = 'force-dynamic' /** * GET /api/stories - * List all active stories + * List all active stories โ€” with in-memory cache (60s TTL) to reduce DB hits. */ export async function GET(request: Request) { try { @@ -13,14 +18,19 @@ export async function GET(request: Request) { const limit = parseInt(searchParams.get('limit') || '20') const offset = parseInt(searchParams.get('offset') || '0') - const where: any = { - status, - } + // Cache key based on query params + const cacheKey = `stories:${status}:${genre}:${limit}:${offset}` + const cached = cache.get<{ stories: unknown[]; total: number; limit: number; offset: number }>(cacheKey, CacheTTL.LONG) - if (genre) { - where.genre = genre + if (cached) { + return NextResponse.json(cached, { + headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120', 'X-Cache': 'HIT' }, + }) } + const where: Record = { status } + if (genre) where.genre = genre + const [stories, total] = await Promise.all([ prisma.story.findMany({ where, @@ -33,38 +43,31 @@ export async function GET(request: Request) { }, }, _count: { - select: { - chapters: true, - }, + select: { chapters: true }, }, }, - orderBy: { - createdAt: 'desc', - }, + orderBy: { createdAt: 'desc' }, take: limit, skip: offset, }), prisma.story.count({ where }), ]) - return NextResponse.json({ - stories, - total, - limit, - offset, + const response = { stories, total, limit, offset } + cache.set(cacheKey, response) + + return NextResponse.json(response, { + headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120', 'X-Cache': 'MISS' }, }) } catch (error) { - console.error('Error fetching stories:', error) - return NextResponse.json( - { error: 'Failed to fetch stories' }, - { status: 500 } - ) + logger.error('Error fetching stories:', error) + return NextResponse.json({ error: 'Failed to fetch stories' }, { status: 500 }) } } /** * POST /api/stories - * Create a new story + * Create a new story โ€” clears list cache after creation. */ export async function POST(request: Request) { try { @@ -72,10 +75,7 @@ export async function POST(request: Request) { const { title, description, genre, authorId, coverImage } = body if (!title || !description || !genre || !authorId) { - return NextResponse.json( - { error: 'Missing required fields' }, - { status: 400 } - ) + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) } const story = await prisma.story.create({ @@ -89,21 +89,17 @@ export async function POST(request: Request) { }, include: { author: { - select: { - id: true, - username: true, - walletAddress: true, - }, + select: { id: true, username: true, walletAddress: true }, }, }, }) + // Invalidate list cache so next GET reflects the new story + cache.clear() + return NextResponse.json(story, { status: 201 }) } catch (error) { - console.error('Error creating story:', error) - return NextResponse.json( - { error: 'Failed to create story' }, - { status: 500 } - ) + logger.error('Error creating story:', error) + return NextResponse.json({ error: 'Failed to create story' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/stream/chapter/route.ts b/apps/web/src/app/api/stream/chapter/route.ts new file mode 100644 index 0000000..eff87e7 --- /dev/null +++ b/apps/web/src/app/api/stream/chapter/route.ts @@ -0,0 +1,311 @@ +/** + * Voidborne Innovation Cycle #51 โ€” Live Narrative Broadcast API + * Route: GET /api/stream/chapter + * + * Server-Sent Events endpoint for live chapter streaming. + * Clients subscribe and receive real-time text deltas, betting window + * events, odds updates, revelations, and payouts. + * + * Usage: + * const es = new EventSource('/api/stream/chapter?broadcastId=...&address=0x...') + * es.onmessage = (e) => { const event = JSON.parse(e.data) } + */ + +import { NextRequest } from 'next/server' + +// In production, import the actual engine +// import { LiveNarrativeBroadcast } from '@narrative-forge/agent-sdk' + +// โ”€โ”€โ”€ Mock Broadcast Engine for API Demo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface BroadcastState { + broadcastId: string + chapterId: string + status: 'live' | 'window_open' | 'revelation' | 'settling' | 'complete' + viewerCount: number + textBuffer: string + activeWindowId?: string +} + +// In-memory store (use Redis in production for multi-instance) +const activeBroadcasts = new Map() + +function getMockBroadcastState(broadcastId: string): BroadcastState { + if (!activeBroadcasts.has(broadcastId)) { + activeBroadcasts.set(broadcastId, { + broadcastId, + chapterId: 'chapter_28', + status: 'live', + viewerCount: 0, + textBuffer: '', + }) + } + return activeBroadcasts.get(broadcastId)! +} + +// โ”€โ”€โ”€ Mock Chapter Story Content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const CHAPTER_STORY_SEGMENTS = [ + 'The Grand Conclave chamber hummed with the low vibration of suppressed panic. ', + 'Five hundred years of political tradition had never seen what was unfolding before the assembled delegates โ€” ', + 'the heir to House Valdris, standing alone at the center dais, holding a data crystal that could unmake everything. ', + '\n\n', + 'House Obsidian\'s Speaker rose from the obsidian-black seating block. ', + '"Three days," he said, his voice carrying the weight of threat. ', + '"Three days we have waited for House Valdris to honor their obligation. ', + 'The Void Stitching continues. The threads of reality fray. ', + 'We cannot wait while your heir deliberates." ', + '\n\n', + 'She felt the weight of all five Houses bearing down on her โ€” ', + 'Meridian\'s cold neutrality, Auric\'s barely-concealed excitement, Zephyr\'s nervous energy โ€” ', + 'but it was Obsidian\'s Speaker who held her gaze. ', + 'He knew, she realized. He had always known. ', + '\n\n', + 'The crystal pulsed cold in her grip. ', + 'The name encoded within it would shatter this chamber\'s fragile peace. ', + 'But silence would shatter something else โ€” something more fundamental. ', + '\n\n', + 'She stepped forward. The chamber held its breathโ€” ', +] + +const REVELATION_SEGMENTS = { + EXPOSE: [ + '\n\n', + 'She raised the crystal above her head and spoke three words: ', + '"Witness. Remember. Judge." ', + '\n\n', + 'The holographic projector ignited, and House Obsidian\'s true face burned across every screen in the chamber. ', + 'The Speaker\'s composure fractured for precisely one second โ€” one second of pure, unmasked terror โ€” ', + 'before the chamber erupted. ', + '\n\n', + 'House Valdris had exposed the Void Stitchers. The cost was unknown. The die was cast. ', + 'History would remember this moment as the beginning of the War of Unraveling โ€” ', + 'or the moment that stopped it. Nobody yet knew which. ', + ], + CONCEAL: [ + '\n\n', + 'She pocketed the crystal. Smooth. Invisible. The motion of someone who had made this choice a thousand times in her mind. ', + '\n\n', + '"House Valdris has conducted a full investigation," she said. ', + '"The Void Stitching originates from outside our galactic arm. ', + 'We are pursuing diplomatic channels with the Boundary Stations." ', + '\n\n', + 'It was a lie. Elegant and complete. ', + 'The Speaker\'s eyes narrowed for precisely the length of a breath โ€” he knew, and she knew he knew โ€” ', + 'but he sat down. ', + '\n\n', + 'She had bought time. The Conclave would reconvene in thirty days. ', + 'She needed twenty. ', + ], +} + +// โ”€โ”€โ”€ SSE Stream Generator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const broadcastId = searchParams.get('broadcastId') + const viewerAddress = searchParams.get('address') + const chapterId = searchParams.get('chapterId') ?? 'chapter_28' + + if (!broadcastId) { + return new Response('broadcastId required', { status: 400 }) + } + + const broadcast = getMockBroadcastState(broadcastId) + if (viewerAddress) broadcast.viewerCount++ + + const encoder = new TextEncoder() + + function sendEvent(type: string, payload: unknown): Uint8Array { + const data = JSON.stringify({ + type, + payload, + timestamp: Date.now(), + broadcastId, + }) + return encoder.encode(`data: ${data}\n\n`) + } + + const stream = new ReadableStream({ + async start(controller) { + try { + // Initial broadcast_start event + controller.enqueue(sendEvent('broadcast_start', { + broadcastId, + chapterId, + viewerCount: broadcast.viewerCount, + choices: [ + { label: 'Expose', description: 'Shatter the crystal and reveal the Void Stitcher identity publicly' }, + { label: 'Conceal', description: 'Pocket the crystal and deflect with a false lead' }, + ], + estimatedDuration: 180_000, // 3 minutes + })) + + // Stream the story content word by word + for (let i = 0; i < CHAPTER_STORY_SEGMENTS.length; i++) { + const segment = CHAPTER_STORY_SEGMENTS[i] + + controller.enqueue(sendEvent('text_delta', { + content: segment, + chunkIndex: i, + })) + + broadcast.textBuffer += segment + + // Delay between segments (simulates real AI streaming) + await new Promise(r => setTimeout(r, 150 + Math.random() * 100)) + + // Open betting window after "She stepped forward. The chamber held its breathโ€”" + if (i === CHAPTER_STORY_SEGMENTS.length - 1) { + broadcast.status = 'window_open' + const windowId = `window_${broadcastId}_${Date.now()}` + broadcast.activeWindowId = windowId + + controller.enqueue(sendEvent('betting_window_open', { + windowId, + chapterId, + openedAt: Date.now(), + closesAt: Date.now() + 30_000, // 30s for demo (60s in production) + timeRemaining: 30, + choiceA: { label: 'Expose', description: 'Reveal the Stitcher publicly' }, + choiceB: { label: 'Conceal', description: 'Hide the truth, buy time' }, + oddsA: 0.52, + oddsB: 0.48, + poolA: '12400000000', // 12,400 USDC (string for BigInt compat) + poolB: '11600000000', // 11,600 USDC + totalPool: '24000000000', + minBet: '1000000', + maxBet: '500000000', + })) + + // Simulate odds updates during window + const oddsUpdates = [ + { oddsA: 0.55, oddsB: 0.45, poolA: '14200000000', poolB: '11600000000', totalPool: '25800000000', timeRemaining: 25 }, + { oddsA: 0.51, oddsB: 0.49, poolA: '14200000000', poolB: '13800000000', totalPool: '28000000000', timeRemaining: 20 }, + { oddsA: 0.58, oddsB: 0.42, poolA: '17400000000', poolB: '12600000000', totalPool: '30000000000', timeRemaining: 15 }, + { oddsA: 0.60, oddsB: 0.40, poolA: '18000000000', poolB: '12000000000', totalPool: '30000000000', timeRemaining: 5 }, + ] + + for (const update of oddsUpdates) { + await new Promise(r => setTimeout(r, 5_000)) + controller.enqueue(sendEvent('betting_window_update', { + windowId, + ...update, + lastBet: { side: update.oddsA > 0.55 ? 'A' : 'B', amount: '5000000000', address: '0xBa3c...f8e1' }, + })) + } + + // Close window + await new Promise(r => setTimeout(r, 5_000)) + controller.enqueue(sendEvent('betting_window_close', { + windowId, + finalOddsA: 0.60, + finalOddsB: 0.40, + totalPool: '30000000000', + })) + + broadcast.status = 'revelation' + } + } + + // Dramatic pause before revelation + await new Promise(r => setTimeout(r, 2_500)) + + // Stream the revelation (AI chose EXPOSE โ€” 60% had bet on it) + const revealSegments = REVELATION_SEGMENTS.EXPOSE + for (let i = 0; i < revealSegments.length; i++) { + controller.enqueue(sendEvent('text_delta', { + content: revealSegments[i], + chunkIndex: CHAPTER_STORY_SEGMENTS.length + i, + })) + await new Promise(r => setTimeout(r, 120)) + } + + // Revelation event + await new Promise(r => setTimeout(r, 800)) + controller.enqueue(sendEvent('revelation', { + winningSide: 'A', + winningChoice: 'Expose', + justification: 'House Valdris\'s survival instinct demands transparency โ€” hiding the Stitcher protects Obsidian, not Valdris. The heir chose honesty over political safety.', + oddsAtClose: { A: 0.60, B: 0.40 }, + totalPool: '30000000000', + winnerPoolShare: '25500000000', // 85% + })) + + // Payout complete + await new Promise(r => setTimeout(r, 1_000)) + controller.enqueue(sendEvent('payout_complete', { + payouts: { + '0xBa3c...f8e1': '8500000000', // 8,500 USDC + '0xE2f1...9aB3': '4250000000', // 4,250 USDC + '0x71Ac...2d8F': '12750000000', // 12,750 USDC + }, + totalPaid: '25500000000', + })) + + // Broadcast complete + broadcast.status = 'complete' + controller.enqueue(sendEvent('broadcast_complete', { + chapterId, + totalWords: broadcast.textBuffer.split(' ').length, + viewerPeak: broadcast.viewerCount + 4783, // Add simulated viewers + choiceMade: 'Expose', + nextBroadcastIn: 86400, // 24h + })) + + if (viewerAddress) broadcast.viewerCount-- + controller.close() + + } catch (err) { + controller.enqueue(sendEvent('error', { + message: err instanceof Error ? err.message : 'Unknown error', + })) + controller.close() + } + }, + + cancel() { + if (viewerAddress && broadcast.viewerCount > 0) broadcast.viewerCount-- + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // Disable Nginx buffering + 'Access-Control-Allow-Origin': '*', + }, + }) +} + +// โ”€โ”€โ”€ POST: Place a live bet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export async function POST(request: NextRequest) { + const body = await request.json() as { + broadcastId: string + windowId: string + bettorAddress: string + side: 'A' | 'B' + amount: string // BigInt as string + } + + const broadcast = activeBroadcasts.get(body.broadcastId) + if (!broadcast || broadcast.status !== 'window_open') { + return Response.json({ success: false, error: 'No active betting window' }, { status: 400 }) + } + + // In production: validate wallet signature, check balance, submit on-chain tx + // For POC: accept and return mock updated odds + + const newOddsA = 0.58 + Math.random() * 0.04 + return Response.json({ + success: true, + betId: `bet_${Date.now()}`, + newOddsA: newOddsA.toFixed(4), + newOddsB: (1 - newOddsA).toFixed(4), + confirmedAmount: body.amount, + estimatedPayout: (Number(body.amount) * 1.35).toFixed(0), + }) +} diff --git a/apps/web/src/app/house-agents/page.tsx b/apps/web/src/app/house-agents/page.tsx new file mode 100644 index 0000000..e1abbff --- /dev/null +++ b/apps/web/src/app/house-agents/page.tsx @@ -0,0 +1,92 @@ +/** + * House Agents Page + * Route: /house-agents + * + * Showcases all 5 autonomous AI House Agents (Innovation Cycle #50 โ€” HAP). + * Each agent holds a real wallet, bets on chapters, and evolves its personality + * matrix based on wins/losses. + * + * Players can align with a House (earn 20% of winnings) or declare rivalry + * (earn 10% on agent losses). + * + * ISR: revalidates every 60s to reflect new agent bets. + */ + +import dynamic from 'next/dynamic' +import { Navbar } from '@/components/landing/Navbar' +import { Footer } from '@/components/landing/Footer' +import { Metadata } from 'next' +import { Suspense } from 'react' + +export const revalidate = 60 + +// Lazy-load heavy components (framer-motion + recharts) +const HouseAgentsContent = dynamic( + () => import('@/components/house-agents/HouseAgentsContent').then((m) => ({ default: m.HouseAgentsContent })), + { + loading: () => , + ssr: false, + } +) + +export const metadata: Metadata = { + title: 'House Agents | Voidborne', + description: + '5 autonomous AI agents โ€” one per House โ€” place bets aligned with their ideology. Align for 20% yield. Rival for chaos rewards.', + openGraph: { + title: 'House Agents | Voidborne', + description: 'Autonomous AI agents with real wallets. Align. Rival. Earn.', + images: ['/og-house-agents.png'], + }, + twitter: { + card: 'summary_large_image', + title: 'House Agents | Voidborne', + description: 'AI agents that bet on the story. Autonomously. Daily.', + images: ['/og-house-agents.png'], + }, +} + +function HouseAgentsSkeleton() { + return ( +
+ {/* Hero skeleton */} +
+
+
+
+
+ + {/* Stat bar skeleton */} +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ + {/* Card grid skeleton */} +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ ) +} + +export default function HouseAgentsPage() { + return ( +
+ +
+ }> + + +
+
+
+ ) +} diff --git a/apps/web/src/app/leaderboards/page.tsx b/apps/web/src/app/leaderboards/page.tsx index fed1f0a..cb0fbd0 100644 --- a/apps/web/src/app/leaderboards/page.tsx +++ b/apps/web/src/app/leaderboards/page.tsx @@ -1,8 +1,31 @@ -import { Leaderboards } from '@/components/leaderboards/Leaderboards' +import dynamic from 'next/dynamic' import { Navbar } from '@/components/landing/Navbar' import { Footer } from '@/components/landing/Footer' import { Metadata } from 'next' +// Lazy-load Leaderboards โ€” pulls in recharts + framer-motion which are heavy. +// Dynamic import creates a separate chunk so the 630 kB isn't paid on initial load. +const Leaderboards = dynamic( + () => import('@/components/leaderboards/Leaderboards').then(mod => ({ default: mod.Leaderboards })), + { + loading: () => ( +
+ {/* Skeleton tabs */} +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ {/* Skeleton rows */} + {[...Array(10)].map((_, i) => ( +
+ ))} +
+ ), + ssr: false, + } +) + export const metadata: Metadata = { title: 'The Void Champions | Voidborne Leaderboards', description: 'See the top winners, best predictors, and legendary streaks on Voidborne. Compete for glory and bragging rights.', @@ -19,14 +42,14 @@ export const metadata: Metadata = { }, } -// Static generation with hourly revalidation -export const revalidate = 3600 +// Revalidate every 5 minutes (leaderboards change frequently) +export const revalidate = 300 export default function LeaderboardsPage() { return (
- +
diff --git a/apps/web/src/app/lore/houses-dynamic/[slug]/page.tsx b/apps/web/src/app/lore/houses-dynamic/[slug]/page.tsx index 22f760e..12f9960 100644 --- a/apps/web/src/app/lore/houses-dynamic/[slug]/page.tsx +++ b/apps/web/src/app/lore/houses-dynamic/[slug]/page.tsx @@ -3,6 +3,9 @@ import Link from 'next/link' import { ArrowLeft } from 'lucide-react' import type { APIResponse, House } from '@/types/lore' +// Force dynamic โ€” renders at request time from DB-backed API +export const dynamic = 'force-dynamic' + export const metadata = { title: 'House Details - Voidborne Lore', } @@ -12,7 +15,7 @@ async function getHouse(slug: string): Promise { try { const res = await fetch(`${baseUrl}/api/lore/houses/${slug}`, { - cache: 'no-store', + next: { revalidate: 300 }, // ISR: revalidate every 5 minutes }) if (!res.ok) { diff --git a/apps/web/src/app/lore/houses-dynamic/page.tsx b/apps/web/src/app/lore/houses-dynamic/page.tsx index 3a5cdcf..e063879 100644 --- a/apps/web/src/app/lore/houses-dynamic/page.tsx +++ b/apps/web/src/app/lore/houses-dynamic/page.tsx @@ -1,6 +1,9 @@ import { DynamicHouseCard } from '@/components/lore/DynamicHouseCard' import type { APIResponse, House } from '@/types/lore' +// Force dynamic โ€” page fetches from internal API at request time (DB-backed) +export const dynamic = 'force-dynamic' + export const metadata = { title: 'The Seven Houses - Voidborne Lore', description: 'The political powers of the Covenant', @@ -11,7 +14,7 @@ async function getHouses(): Promise { try { const res = await fetch(`${baseUrl}/api/lore/houses`, { - cache: 'no-store', + next: { revalidate: 300 }, // ISR: revalidate every 5 minutes }) if (!res.ok) { diff --git a/apps/web/src/app/lore/protocols-dynamic/[slug]/page.tsx b/apps/web/src/app/lore/protocols-dynamic/[slug]/page.tsx index 63e6aa5..e546b08 100644 --- a/apps/web/src/app/lore/protocols-dynamic/[slug]/page.tsx +++ b/apps/web/src/app/lore/protocols-dynamic/[slug]/page.tsx @@ -3,6 +3,9 @@ import Link from 'next/link' import { ArrowLeft } from 'lucide-react' import type { APIResponse, Protocol } from '@/types/lore' +// Force dynamic โ€” renders at request time from DB-backed API +export const dynamic = 'force-dynamic' + export const metadata = { title: 'Protocol Details - Voidborne Lore', } @@ -20,7 +23,7 @@ async function getProtocol(slug: string): Promise { try { const res = await fetch(`${baseUrl}/api/lore/protocols/${slug}`, { - cache: 'no-store', + next: { revalidate: 300 }, // ISR: revalidate every 5 minutes }) if (!res.ok) { diff --git a/apps/web/src/app/lore/protocols-dynamic/page.tsx b/apps/web/src/app/lore/protocols-dynamic/page.tsx index f378e78..e993588 100644 --- a/apps/web/src/app/lore/protocols-dynamic/page.tsx +++ b/apps/web/src/app/lore/protocols-dynamic/page.tsx @@ -1,6 +1,9 @@ import { DynamicProtocolCard } from '@/components/lore/DynamicProtocolCard' import type { APIResponse, Protocol } from '@/types/lore' +// Force dynamic โ€” page fetches from internal API at request time (DB-backed) +export const dynamic = 'force-dynamic' + export const metadata = { title: 'Protocols - Voidborne Lore', description: 'Reality-shaping techniques mastered by the Seven Houses', @@ -11,7 +14,7 @@ async function getProtocols(): Promise { try { const res = await fetch(`${baseUrl}/api/lore/protocols`, { - cache: 'no-store', + next: { revalidate: 300 }, // ISR: revalidate every 5 minutes }) if (!res.ok) { diff --git a/apps/web/src/app/prophecies/[chapterId]/page.tsx b/apps/web/src/app/prophecies/[chapterId]/page.tsx new file mode 100644 index 0000000..db58b93 --- /dev/null +++ b/apps/web/src/app/prophecies/[chapterId]/page.tsx @@ -0,0 +1,279 @@ +'use client' + +/** + * /prophecies/[chapterId] โ€” Chapter Prophecy Gallery + * + * Shows all prophecies for a specific chapter with: + * - Chapter info header + * - Fulfillment summary (resolved chapters) + * - Individual prophecy cards with mint button + * - "My Mints" section (connected wallet) + */ + +import { useEffect, useState, useCallback } from 'react' +import { useParams } from 'next/navigation' +import { useAccount } from 'wagmi' +import Link from 'next/link' +import { ProphecyGallery } from '@/components/prophecy/ProphecyGallery' +import { Navbar } from '@/components/landing/Navbar' +import { Footer } from '@/components/landing/Footer' +import type { ProphecyData } from '@/components/prophecy/ProphecyCard' + +interface ChapterData { + id: string + chapterNumber: number + title: string + storyId: string + story: { id: string; title: string } +} + +interface ChapterResponse { + chapter: ChapterData + prophecies: ProphecyData[] + summary: { + total: number + totalMinted: number + fulfilled: number + echoed: number + unfulfilled: number + pending: number + } +} + +interface UserMint { + prophecyId: string + mintOrder: number + forgePaid: number + prophecy: { id: string } +} + +export default function ChapterProphecyPage() { + const params = useParams() + const chapterId = params.chapterId as string + const { address } = useAccount() + + const [data, setData] = useState(null) + const [userMints, setUserMints] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchData = useCallback(async () => { + try { + setLoading(true) + const res = await fetch(`/api/prophecies/${chapterId}`) + if (!res.ok) throw new Error('Chapter not found') + const json = await res.json() as ChapterResponse + setData(json) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load') + } finally { + setLoading(false) + } + }, [chapterId]) + + const fetchUserMints = useCallback(async () => { + if (!address) return + try { + const res = await fetch(`/api/prophecies/mint?wallet=${address}`) + if (res.ok) { + const json = await res.json() + setUserMints(json.mints ?? []) + } + } catch { + // Non-critical + } + }, [address]) + + useEffect(() => { fetchData() }, [fetchData]) + useEffect(() => { fetchUserMints() }, [fetchUserMints]) + + // Merge user mint data into prophecies + const propheciesWithMints: ProphecyData[] = (data?.prophecies ?? []).map((p) => { + const userMint = userMints.find((m) => m.prophecy?.id === p.id) + return userMint + ? { ...p, userMint: { mintOrder: userMint.mintOrder, forgePaid: userMint.forgePaid } } + : p + }) + + // โ”€โ”€โ”€ Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (loading) { + return ( +
+ +
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+ ) + } + + // โ”€โ”€โ”€ Error โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (error || !data) { + return ( +
+ +
+
โŠ—
+

{error ?? 'Chapter not found'}

+ + โ† Back to Prophecies + +
+
+
+ ) + } + + const { chapter, summary } = data + const myMintedProphecies = propheciesWithMints.filter((p) => p.userMint) + + // โ”€โ”€โ”€ Page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + return ( +
+ + +
+ + {/* Breadcrumb */} + + + {/* Chapter header */} +
+

+ {chapter.story.title} +

+

+ Chapter {chapter.chapterNumber} โ€” {chapter.title} +

+

+ {summary.total} prophecies sealed before this chapter resolved +

+
+ + {/* Chapter resolution banner (if resolved) */} + {summary.pending === 0 && summary.total > 0 && ( +
0 + ? 'border-amber-500/30 bg-amber-500/5' + : 'border-void-700/30 bg-void-950/40', + ].join(' ')}> +

+ {summary.fulfilled > 0 + ? `โ˜… ${summary.fulfilled} Prophecies Fulfilled` + : 'Chapter Resolved โ€” No Prophecies Fulfilled'} +

+
+ {summary.fulfilled > 0 && ( + + {summary.fulfilled} Legendary golden artifacts created + + )} + {summary.echoed > 0 && ( + + {summary.echoed} Echoed โ€” silver artifacts + + )} + {summary.unfulfilled > 0 && ( + + {summary.unfulfilled} Void Relics (unfulfilled) + + )} +
+
+ )} + + {/* My Mints section */} + {myMintedProphecies.length > 0 && ( +
+

+ Your Prophecies ({myMintedProphecies.length}) +

+
+ {myMintedProphecies.map((p) => ( +
+
+ Mint #{p.userMint!.mintOrder} of {p.maxSupply} +
+

+ “{p.text ?? p.teaser}” +

+
+ + {p.status === 'FULFILLED' ? 'โ˜… Fulfilled' : + p.status === 'ECHOED' ? 'โ—ˆ Echoed' : + p.status === 'PENDING' ? 'โ–“ Sealed' : 'โ–ช Void Relic'} + + + {p.userMint!.forgePaid} $FORGE + +
+
+ ))} +
+
+ )} + + {/* All prophecies gallery */} +
+ { fetchData(); fetchUserMints() }} + /> +
+ +
+ +
+
+ ) +} diff --git a/apps/web/src/app/prophecies/page.tsx b/apps/web/src/app/prophecies/page.tsx new file mode 100644 index 0000000..0efa248 --- /dev/null +++ b/apps/web/src/app/prophecies/page.tsx @@ -0,0 +1,264 @@ +/** + * /prophecies โ€” Global Prophecy Gallery + * + * Shows all prophecies across all chapters with: + * - Live stats (total minted, fulfilled rate, top oracles) + * - Filter by status + * - Link to chapter-specific galleries + * - Oracle Leaderboard + * + * Static generation with 5-min revalidation (prophecy mints are fast but not ultra-real-time) + */ + +import type { Metadata } from 'next' +import dynamicImport from 'next/dynamic' +import Link from 'next/link' +import { Navbar } from '@/components/landing/Navbar' +import { Footer } from '@/components/landing/Footer' +import { prisma } from '@voidborne/database' + +export const revalidate = 300 // 5 min +export const dynamic = 'force-dynamic' // auth-dependent user data + +export const metadata: Metadata = { + title: 'Prophecy NFTs โ€” Voidborne', + description: + 'Mint cryptic prophecy NFTs before each chapter resolves. Fulfilled prophecies become legendary golden artifacts worth 10ร— their value.', +} + +const ProphecyGallery = dynamicImport( + () => import('@/components/prophecy/ProphecyGallery').then((m) => ({ default: m.ProphecyGallery })), + { loading: () => , ssr: false } +) + +const OracleLeaderboard = dynamicImport( + () => import('@/components/prophecy/OracleLeaderboard').then((m) => ({ default: m.OracleLeaderboard })), + { loading: () => , ssr: false } +) + +// โ”€โ”€โ”€ Data Fetching โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function getGlobalStats() { + const [total, fulfilled, echoed, pending, totalMints] = await Promise.all([ + prisma.prophecy.count(), + prisma.prophecy.count({ where: { status: 'FULFILLED' } }), + prisma.prophecy.count({ where: { status: 'ECHOED' } }), + prisma.prophecy.count({ where: { status: 'PENDING' } }), + prisma.prophecyMint.count(), + ]) + + return { total, fulfilled, echoed, pending, unfulfilled: total - fulfilled - echoed - pending, totalMints } +} + +async function getRecentChapters() { + return prisma.chapter.findMany({ + where: { prophecies: { some: {} } }, + orderBy: { createdAt: 'desc' }, + take: 10, + select: { + id: true, + chapterNumber: true, + title: true, + story: { select: { id: true, title: true } }, + _count: { select: { prophecies: true } }, + prophecies: { + select: { status: true, _count: { select: { mints: true } } }, + }, + }, + }) +} + +// โ”€โ”€โ”€ Page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default async function PropheciesPage() { + const [stats, chapters] = await Promise.all([getGlobalStats(), getRecentChapters()]) + + return ( +
+ + +
+ {/* Hero */} +
+
+ Innovation Cycle #49 +
+ +

+ Prophecy NFTs +

+ +

+ The AI generates cryptic prophecies before each chapter resolves. + Mint one for 5 $FORGE. + If it comes true โ€” your NFT transforms into a legendary golden artifact worth 10ร—. +

+ + {/* Global stats */} + {stats.total > 0 && ( +
+ {[ + { label: 'Total Prophecies', value: stats.total }, + { label: 'NFTs Minted', value: stats.totalMints }, + { label: 'Fulfilled โ˜…', value: stats.fulfilled, highlight: true }, + { label: 'Currently Open', value: stats.pending }, + ].map((s) => ( +
+
+ {s.value} +
+
{s.label}
+
+ ))} +
+ )} +
+ + {stats.total === 0 ? ( + /* Coming soon state */ +
+
+ โ–“โ–“โ–“ +
+
+

+ The Seers Await +

+

+ Prophecies will appear here before the next chapter begins. +

+
+ + Read the Story โ†’ + +
+ ) : ( +
+ {/* Main: chapter list + prophecy gallery */} +
+ {chapters.map((chapter) => { + const chapterFulfilled = chapter.prophecies.filter((p) => p.status === 'FULFILLED').length + const chapterPending = chapter.prophecies.filter((p) => p.status === 'PENDING').length + const totalMinted = chapter.prophecies.reduce((s, p) => s + p._count.mints, 0) + + return ( +
+
+
+

+ {chapter.story.title} +

+

+ Chapter {chapter.chapterNumber} โ€” {chapter.title} +

+
+
+
{chapter._count.prophecies} prophecies
+
{totalMinted} minted
+ {chapterFulfilled > 0 && ( +
{chapterFulfilled} fulfilled โ˜…
+ )} + {chapterPending > 0 && ( +
{chapterPending} open
+ )} +
+
+ + + View all prophecies for this chapter โ†’ + +
+ ) + })} +
+ + {/* Sidebar: Leaderboard */} + +
+ )} +
+ +
+
+ ) +} + +// โ”€โ”€โ”€ Loading Skeletons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function GalleryPlaceholder() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) +} + +function LeaderboardPlaceholder() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) +} diff --git a/apps/web/src/app/story/[storyId]/page.tsx b/apps/web/src/app/story/[storyId]/page.tsx index 6c1dbba..c633331 100644 --- a/apps/web/src/app/story/[storyId]/page.tsx +++ b/apps/web/src/app/story/[storyId]/page.tsx @@ -8,6 +8,7 @@ import { ChapterReader } from '@/components/story/ChapterReader' import { BettingInterface } from '@/components/story/BettingInterface' import { ChapterNavigation } from '@/components/story/ChapterNavigation' import { ClientOnly } from '@/components/ClientOnly' +import { ProphecyBanner } from '@/components/prophecy/ProphecyBanner' type StoryWithChapters = Story & { chapters: (Chapter & { @@ -102,7 +103,12 @@ export default function StoryPage() { {/* Sidebar - Betting Interface */}
-
+
+ {/* Prophecy NFT banner โ€” tease open prophecies */} + + + + {currentChapter.bettingPool && currentChapter.bettingPool.contractAddress && ( + timestamp: number + broadcastId: string +} + +interface BettingWindow { + windowId: string + timeRemaining: number + choiceA: { label: string; description: string } + choiceB: { label: string; description: string } + oddsA: number + oddsB: number + poolA: string + poolB: string + totalPool: string + lastBet?: { side: 'A' | 'B'; amount: string; address: string } +} + +interface RevelationData { + winningSide: 'A' | 'B' + winningChoice: string + justification: string + oddsAtClose: { A: number; B: number } + totalPool: string +} + +type BroadcastPhase = + | 'connecting' + | 'live' + | 'window_open' + | 'window_closing' + | 'revelation' + | 'settling' + | 'complete' + +// โ”€โ”€โ”€ Helper: Format USDC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function formatUSDC(raw: string | bigint): string { + const value = typeof raw === 'string' ? BigInt(raw) : raw + const dollars = Number(value) / 1_000_000 + if (dollars >= 1_000_000) return `$${(dollars / 1_000_000).toFixed(2)}M` + if (dollars >= 1_000) return `$${(dollars / 1_000).toFixed(1)}K` + return `$${dollars.toFixed(2)}` +} + +// โ”€โ”€โ”€ Odds Bar Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function OddsBar({ oddsA, oddsB, choiceA, choiceB, animate }: { + oddsA: number + oddsB: number + choiceA: string + choiceB: string + animate?: boolean +}) { + return ( +
+
+ {choiceA} + {choiceB} +
+
+ +
+
+ {(oddsA * 100).toFixed(0)}% + {(oddsB * 100).toFixed(0)}% +
+
+ ) +} + +// โ”€โ”€โ”€ Betting Window Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function BettingWindowPanel({ + window: bw, + onBet, + myAddress, +}: { + window: BettingWindow + onBet: (side: 'A' | 'B', amount: number) => void + myAddress?: string +}) { + const [selectedSide, setSelectedSide] = useState<'A' | 'B' | null>(null) + const [betAmount, setBetAmount] = useState('10') + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleBet = async () => { + if (!selectedSide || !betAmount) return + setIsSubmitting(true) + await new Promise(r => setTimeout(r, 600)) + onBet(selectedSide, parseFloat(betAmount)) + setIsSubmitting(false) + } + + const potential = selectedSide && betAmount + ? (parseFloat(betAmount) / (selectedSide === 'A' ? bw.oddsA : bw.oddsB) * 0.85).toFixed(2) + : null + + return ( + + {/* Header */} +
+
+
+ + Live Bet Window + +
+
+ {bw.timeRemaining}s +
+
+ + {/* Countdown bar */} +
+ +
+ + {/* Pool stats */} +
+
+
Expose Pool
+
{formatUSDC(bw.poolA)}
+
+
+
Total
+
{formatUSDC(bw.totalPool)}
+
+
+
Conceal Pool
+
{formatUSDC(bw.poolB)}
+
+
+ + {/* Odds bar */} +
+ +
+ + {/* Last bet ticker */} + {bw.lastBet && ( + + ๐ŸŽฐ {bw.lastBet.address} just bet {formatUSDC(bw.lastBet.amount)} on{' '} + + {bw.lastBet.side === 'A' ? bw.choiceA.label : bw.choiceB.label} + + + )} + + {/* Bet interface */} + {myAddress ? ( +
+
+ + +
+ +
+
+ $ + setBetAmount(e.target.value)} + className="w-full bg-slate-800 text-white rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-yellow-500" + placeholder="Amount USDC" + min="1" + max="500" + /> +
+ {potential && ( +
+ Win: ${potential} +
+ )} +
+ + +
+ ) : ( +
+ Connect wallet to bet +
+ )} + + ) +} + +// โ”€โ”€โ”€ Revelation Overlay โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function RevelationOverlay({ data, onDismiss }: { data: RevelationData; onDismiss: () => void }) { + useEffect(() => { + const timer = setTimeout(onDismiss, 8_000) + return () => clearTimeout(timer) + }, [onDismiss]) + + return ( + +
+ + ๐ŸŽญ + + + + The AI Chose + + + + {data.winningChoice} + + + + {data.justification} + + + + Odds at close: {data.winningSide === 'A' ? `${(data.oddsAtClose.A * 100).toFixed(0)}` : `${(data.oddsAtClose.B * 100).toFixed(0)}`}% were right + ยท Pool: {formatUSDC(data.totalPool)} + + + + Tap to dismiss + +
+
+ ) +} + +// โ”€โ”€โ”€ Main Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface LiveNarrativeStudioProps { + broadcastId: string + chapterId?: string + viewerAddress?: string + className?: string +} + +export default function LiveNarrativeStudio({ + broadcastId, + chapterId = 'chapter_28', + viewerAddress, + className = '', +}: LiveNarrativeStudioProps) { + const [phase, setPhase] = useState('connecting') + const [storyText, setStoryText] = useState('') + const [viewerCount, setViewerCount] = useState(0) + const [activeBettingWindow, setActiveBettingWindow] = useState(null) + const [revelation, setRevelation] = useState(null) + const [showRevelation, setShowRevelation] = useState(false) + const [payouts, setPayouts] = useState | null>(null) + + const storyEndRef = useRef(null) + const eventSourceRef = useRef(null) + + // Auto-scroll story text + useEffect(() => { + storyEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [storyText]) + + // Subscribe to SSE stream + useEffect(() => { + const url = new URL('/api/stream/chapter', window.location.origin) + url.searchParams.set('broadcastId', broadcastId) + url.searchParams.set('chapterId', chapterId) + if (viewerAddress) url.searchParams.set('address', viewerAddress) + + const es = new EventSource(url.toString()) + eventSourceRef.current = es + + es.onopen = () => setPhase('live') + + es.onmessage = (e) => { + const event: StreamEvent = JSON.parse(e.data) + + switch (event.type) { + case 'broadcast_start': + setPhase('live') + setViewerCount((event.payload.viewerCount as number) ?? 0) + break + + case 'text_delta': + setStoryText(prev => prev + (event.payload.content as string)) + break + + case 'viewer_count': + setViewerCount(event.payload.count as number) + break + + case 'betting_window_open': + setPhase('window_open') + setActiveBettingWindow(event.payload as BettingWindow) + break + + case 'betting_window_update': + setActiveBettingWindow(prev => prev ? { + ...prev, + ...event.payload, + } : null) + break + + case 'betting_window_close': + setPhase('window_closing') + setTimeout(() => { + setActiveBettingWindow(null) + }, 1_000) + break + + case 'revelation': + setPhase('revelation') + setRevelation(event.payload as RevelationData) + setShowRevelation(true) + break + + case 'payout_complete': + setPhase('settling') + setPayouts(event.payload.payouts as Record) + break + + case 'broadcast_complete': + setPhase('complete') + break + + case 'error': + console.error('Broadcast error:', event.payload) + break + } + } + + es.onerror = () => { + if (phase !== 'complete') { + setPhase('connecting') + } + } + + return () => { + es.close() + eventSourceRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [broadcastId, chapterId, viewerAddress]) + + const handleBet = useCallback(async (side: 'A' | 'B', amount: number) => { + if (!activeBettingWindow) return + + await fetch('/api/stream/chapter', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + broadcastId, + windowId: activeBettingWindow.windowId, + bettorAddress: viewerAddress ?? '0x0', + side, + amount: String(Math.floor(amount * 1_000_000)), + }), + }) + }, [activeBettingWindow, broadcastId, viewerAddress]) + + const phaseColors: Record = { + connecting: 'text-slate-400', + live: 'text-green-400', + window_open: 'text-yellow-400', + window_closing: 'text-orange-400', + revelation: 'text-purple-400', + settling: 'text-blue-400', + complete: 'text-slate-500', + } + + const phaseLabels: Record = { + connecting: 'โ—‹ CONNECTING', + live: 'โ— LIVE', + window_open: '๐Ÿ”” BET WINDOW OPEN', + window_closing: 'โฑ WINDOW CLOSING', + revelation: '๐ŸŽญ REVELATION', + settling: '๐Ÿ’ฐ PAYING OUT', + complete: 'โœ“ COMPLETE', + } + + return ( +
+ {/* Header bar */} +
+
+ {phase !== 'complete' && phase !== 'connecting' && ( +
+
+ Live +
+ )} + + {phaseLabels[phase]} + +
+ +
+ ๐Ÿ‘ {(viewerCount + 4783).toLocaleString()} watching + Chapter {chapterId.replace('chapter_', '')} +
+
+ + {/* Story text area */} +
+ {storyText ? ( + <> +

{storyText}

+ {phase === 'live' && ( + + )} + + ) : ( +
+ {phase === 'connecting' ? 'Connecting to broadcast...' : 'Story loading...'} +
+ )} +
+
+ + {/* Betting window panel */} + + {activeBettingWindow && (phase === 'window_open' || phase === 'window_closing') && ( +
+ +
+ )} +
+ + {/* Payout notification */} + {payouts && Object.keys(payouts).length > 0 && phase === 'settling' && ( + +
๐Ÿ’ฐ Payouts distributed
+
+ {Object.entries(payouts).slice(0, 3).map(([addr, amount]) => ( +
+ {addr} + {formatUSDC(amount)} +
+ ))} +
+
+ )} + + {/* Complete banner */} + {phase === 'complete' && ( +
+ Chapter complete ยท Next broadcast in 24 hours +
+ )} + + {/* Revelation overlay */} + + {showRevelation && revelation && ( + setShowRevelation(false)} + /> + )} + +
+ ) +} diff --git a/apps/web/src/components/house-agents/AgentLeaderboard.tsx b/apps/web/src/components/house-agents/AgentLeaderboard.tsx new file mode 100644 index 0000000..f0c5a00 --- /dev/null +++ b/apps/web/src/components/house-agents/AgentLeaderboard.tsx @@ -0,0 +1,111 @@ +'use client' + +/** + * AgentLeaderboard โ€” Compact ranking of all 5 House Agents by performance. + * + * Ranks by: + * 1. Net PnL (primary) + * 2. Accuracy rate (secondary) + * + * Highlights #1 with a gold crown. Shows live current bet indicator. + */ + +import { motion } from 'framer-motion' +import { Crown, TrendingUp, TrendingDown, Activity } from 'lucide-react' +import type { HouseAgentPublicProfile } from '@/app/api/house-agents/route' + +interface AgentLeaderboardProps { + agents: HouseAgentPublicProfile[] +} + +export function AgentLeaderboard({ agents }: AgentLeaderboardProps) { + const sorted = [...agents].sort((a, b) => b.stats.netPnl - a.stats.netPnl) + + return ( +
+
+ +

+ Agent Rankings +

+ by Net PnL +
+ +
+ {sorted.map((agent, idx) => { + const isFirst = idx === 0 + const netPositive = agent.stats.netPnl >= 0 + const hasLiveBet = agent.currentBet !== null + + return ( + + {/* Rank */} +
+ {isFirst ? ( + + ) : ( + #{idx + 1} + )} +
+ + {/* House */} +
+ {agent.emoji} +
+ + {/* Name + Accuracy */} +
+
+ + {agent.name} + + {hasLiveBet && ( + + + Live + + )} +
+
+ {agent.stats.accuracyRate.toFixed(1)}% acc ยท {agent.stats.totalBets} bets +
+
+ + {/* Net PnL */} +
+ {netPositive ? ( + + ) : ( + + )} + {netPositive ? '+' : ''} + {agent.stats.netPnl.toLocaleString()} +
+
+ ) + })} +
+ +

+ Rankings reset each story arc ยท All figures in USDC +

+
+ ) +} diff --git a/apps/web/src/components/house-agents/AlignmentModal.tsx b/apps/web/src/components/house-agents/AlignmentModal.tsx new file mode 100644 index 0000000..3ea8481 --- /dev/null +++ b/apps/web/src/components/house-agents/AlignmentModal.tsx @@ -0,0 +1,271 @@ +'use client' + +/** + * AlignmentModal โ€” Modal for aligning with or rivaling a House Agent. + * + * Align: Earn 20% of agent winnings on active chapters. + * Rival: Earn 10% bonus when the agent LOSES a bet. + * + * Shows: + * - Mode toggle (Align / Rival) + * - House info & flavor text + * - Reward structure + * - Wallet connection gating + * - Submit action + */ + +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Users, Swords, CheckCircle, Loader2, AlertCircle } from 'lucide-react' +import type { HouseAgentPublicProfile } from '@/app/api/house-agents/route' +import { useAccount } from 'wagmi' +import { ConnectWallet } from '@/components/wallet/ConnectWallet' +import { ClientOnly } from '@/components/ClientOnly' + +interface AlignmentModalProps { + open: boolean + onClose: () => void + agent: HouseAgentPublicProfile + mode: 'align' | 'rival' + viewerAddress?: string +} + +type SubmitState = 'idle' | 'loading' | 'success' | 'error' + +export function AlignmentModal({ open, onClose, agent, mode, viewerAddress }: AlignmentModalProps) { + const { address, isConnected } = useAccount() + const [submitState, setSubmitState] = useState('idle') + const [errorMessage, setErrorMessage] = useState('') + + const activeAddress = address ?? viewerAddress + + async function handleSubmit() { + if (!activeAddress) return + + setSubmitState('loading') + setErrorMessage('') + + try { + const res = await fetch(`/api/house-agents/${agent.houseId}/align`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + walletAddress: activeAddress, + isRival: mode === 'rival', + }), + }) + + if (!res.ok) { + const data = await res.json() + throw new Error(data.error ?? 'Unknown error') + } + + setSubmitState('success') + } catch (err) { + setSubmitState('error') + setErrorMessage(err instanceof Error ? err.message : 'Failed to align') + } + } + + function handleClose() { + setSubmitState('idle') + setErrorMessage('') + onClose() + } + + return ( + + {open && ( + <> + {/* Backdrop */} + + + {/* Modal */} + +
+ {/* Top stripe */} +
+ +
+ {/* Header */} +
+
+
+ {agent.emoji} +
+
+

+ {mode === 'align' ? 'Align With' : 'Become Rival of'} +

+

House {agent.name}

+
+
+ +
+ + {/* Mode Card */} + {mode === 'align' ? ( +
+
+ + + Aligned โ€” 20% Yield Share + +
+

+ You earn 20% of House {agent.name}’s winnings on every + chapter while aligned. Risk is on the agent โ€” not you. +

+
    + {[ + `Agent wins 1,200 USDC โ†’ You earn 240 USDC`, + `Agent loses โ†’ You lose nothing`, + `Alignment active for 30 days`, + `1 active alignment per wallet`, + ].map((point) => ( +
  • + + {point} +
  • + ))} +
+
+ ) : ( +
+
+ + + Rival โ€” Earn on Agent’s Losses + +
+

+ You earn a 10% bonus each time + House {agent.name} loses a bet. The bigger their loss, the bigger your share. +

+
    + {[ + `Agent loses 1,000 USDC โ†’ You earn 100 USDC`, + `Agent wins โ†’ You earn nothing`, + `Rivalry lasts 30 days`, + `Track agent bets in real-time`, + ].map((point) => ( +
  • + + {point} +
  • + ))} +
+
+ )} + + {/* Flavor text */} +

+ {mode === 'align' + ? `"House ${agent.name} has ${agent.stats.alignedPlayers.toLocaleString()} allies. Join their ranks."` + : `"${agent.stats.alignedPlayers} back them. You bet against them. Bold."`} +

+ + {/* Auth + Submit */} + + {!isConnected && !viewerAddress ? ( +
+

+ Connect your wallet to {mode === 'align' ? 'align' : 'rival'} House {agent.name}. +

+ +
+ ) : submitState === 'success' ? ( +
+ +

+ {mode === 'align' ? 'Aligned!' : 'Rivalry Declared!'} +

+

+ {mode === 'align' + ? `You now share in House ${agent.name}'s glory. Rewards begin next chapter.` + : `House ${agent.name} has a new rival. May they stumble.`} +

+ +
+ ) : ( + <> + {submitState === 'error' && ( +
+ + {errorMessage} +
+ )} + + + )} +
+
+
+ + + )} + + ) +} diff --git a/apps/web/src/components/house-agents/HouseAgentCard.tsx b/apps/web/src/components/house-agents/HouseAgentCard.tsx new file mode 100644 index 0000000..5604b57 --- /dev/null +++ b/apps/web/src/components/house-agents/HouseAgentCard.tsx @@ -0,0 +1,366 @@ +'use client' + +/** + * HouseAgentCard โ€” Main card for each AI House Agent + * + * Shows: + * - Agent identity (house emoji, name, lore) + * - Personality matrix bars (risk, contrarianism, survival bias, etc.) + * - Live stats (accuracy, PnL, aligned players) + * - Current chapter bet (if active) + * - Recent bet history preview + * - Align / Rival buttons + */ + +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + TrendingUp, + TrendingDown, + Users, + Swords, + Star, + ChevronDown, + ChevronUp, + CheckCircle, + XCircle, + Clock, + Zap, +} from 'lucide-react' +import type { HouseAgentPublicProfile, AgentBetRecord } from '@/app/api/house-agents/route' +import { AlignmentModal } from './AlignmentModal' +import { PersonalityBars } from './PersonalityBars' + +interface HouseAgentCardProps { + agent: HouseAgentPublicProfile + /** Wallet address of the current viewer (for auth checks) */ + viewerAddress?: string +} + +export function HouseAgentCard({ agent, viewerAddress }: HouseAgentCardProps) { + const [expanded, setExpanded] = useState(false) + const [alignModalOpen, setAlignModalOpen] = useState(false) + const [alignMode, setAlignMode] = useState<'align' | 'rival'>('align') + + const netPositive = agent.stats.netPnl >= 0 + const crowdContrarian = + agent.currentBet && agent.currentBet.crowdPercentage < 25 + + function openAlign(mode: 'align' | 'rival') { + setAlignMode(mode) + setAlignModalOpen(true) + } + + return ( + <> + + {/* Glow border using house color */} +
+ + {/* Card */} +
+ {/* Header stripe */} +
+ +
+ {/* Agent Identity */} +
+
+
+ {agent.emoji} +
+
+

+ {agent.name} +

+

+ {agent.fullName} +

+
+
+ + {/* PnL Badge */} +
+ {netPositive ? ( + + ) : ( + + )} + {netPositive ? '+' : ''} + {agent.stats.netPnl.toLocaleString()} USDC +
+
+ + {/* Lore */} +

+ {agent.lore} +

+ + {/* Stat Row */} +
+ } + positive={agent.stats.accuracyRate > 55} + color={agent.accentHex} + /> + } + color={agent.accentHex} + /> + } + color={agent.accentHex} + /> +
+ + {/* Personality Bars */} +
+

+ Personality Matrix +

+ +
+ + {/* Current Bet */} + {agent.currentBet && ( + + )} + + {/* Expand / Collapse Toggle */} + + + {/* Bet History */} + + {expanded && ( + +
+ {agent.recentBets.map((bet) => ( + + ))} +
+
+ )} +
+ + {/* Action Buttons */} +
+ + +
+
+
+ + + {/* Alignment Modal */} + setAlignModalOpen(false)} + agent={agent} + mode={alignMode} + viewerAddress={viewerAddress} + /> + + ) +} + +// โ”€โ”€โ”€ Sub-components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function StatPill({ + label, + value, + icon, + positive, + color, +}: { + label: string + value: string + icon: React.ReactNode + positive?: boolean + color: string +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ) +} + +function CurrentBetBanner({ + bet, + accentHex, + isContrarian, +}: { + bet: AgentBetRecord + accentHex: string + isContrarian: boolean +}) { + return ( +
+
+
+ + Live Bet โ€” {bet.chapterTitle} + + {isContrarian && ( + + โšก Contrarian + + )} +
+

+ “{bet.choiceText}” +

+

+ “{bet.reasoning}” +

+
+ + Crowd: {bet.crowdPercentage}% agree + + + {bet.amount.toLocaleString()} USDC + +
+
+ ) +} + +function BetHistoryRow({ bet, accentHex }: { bet: AgentBetRecord; accentHex: string }) { + const isWin = bet.won === true + const isLoss = bet.won === false + const isPending = !bet.resolved + + return ( +
+ {/* Status Icon */} +
+ {isPending ? ( + + ) : isWin ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+

{bet.chapterTitle}

+

+ “{bet.choiceText}” +

+

+ {bet.reasoning} +

+
+ + {/* Amount / Payout */} +
+
+ {isWin ? ( + +{bet.payout?.toLocaleString()} + ) : isLoss ? ( + -{bet.amount.toLocaleString()} + ) : ( + {bet.amount.toLocaleString()} + )} +
+
USDC
+
+
+ ) +} diff --git a/apps/web/src/components/house-agents/HouseAgentsContent.tsx b/apps/web/src/components/house-agents/HouseAgentsContent.tsx new file mode 100644 index 0000000..19f1f68 --- /dev/null +++ b/apps/web/src/components/house-agents/HouseAgentsContent.tsx @@ -0,0 +1,327 @@ +'use client' + +/** + * HouseAgentsContent โ€” Client wrapper for the House Agents page. + * + * Fetches all 5 agent profiles from /api/house-agents and renders: + * - Hero section with platform stats + * - Agent card grid (responsive: 1/2/3 col) + * - Rivalry matrix + * - Agent leaderboard + * - How-it-works explainer + * + * Polling: auto-refreshes every 60s to show live bets. + */ + +import { useEffect, useState, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + Bot, + Zap, + Users, + BarChart3, + RefreshCw, + AlertCircle, +} from 'lucide-react' +import type { HouseAgentPublicProfile } from '@/app/api/house-agents/route' +import { HouseAgentCard } from './HouseAgentCard' +import { RivalryMatrix } from './RivalryMatrix' +import { AgentLeaderboard } from './AgentLeaderboard' +import { useAccount } from 'wagmi' + +interface ApiResponse { + agents: HouseAgentPublicProfile[] + meta: { + totalAligned: number + totalWagered: number + totalBets: number + currentChapter: string + currentChapterTitle: string + lastUpdated: string + } +} + +export function HouseAgentsContent() { + const { address } = useAccount() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [lastRefresh, setLastRefresh] = useState(null) + + const fetchAgents = useCallback(async (silent = false) => { + if (silent) { + setRefreshing(true) + } else { + setLoading(true) + } + setError(null) + + try { + const res = await fetch('/api/house-agents', { + next: { revalidate: 60 }, + cache: 'no-store', // Always fresh in client + }) + + if (!res.ok) { + throw new Error(`Failed to fetch agents (${res.status})`) + } + + const json: ApiResponse = await res.json() + setData(json) + setLastRefresh(new Date()) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + // Initial load + useEffect(() => { + fetchAgents() + }, [fetchAgents]) + + // Auto-refresh every 60s + useEffect(() => { + const interval = setInterval(() => fetchAgents(true), 60_000) + return () => clearInterval(interval) + }, [fetchAgents]) + + if (loading) { + return ( +
+
+
+

Summoning House Agentsโ€ฆ

+
+
+ ) + } + + if (error || !data) { + return ( +
+ +

Failed to Load

+

{error}

+ +
+ ) + } + + const { agents, meta } = data + + // Count how many agents have live bets right now + const liveAgentsCount = agents.filter((a) => a.currentBet !== null).length + + return ( +
+ {/* โ”€โ”€ Hero โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + +
+ + House Agent Protocol โ€” Innovation Cycle #50 +
+ +

+ The House Agents +

+ +

+ Five autonomous AI agents โ€” one per House โ€” hold real wallets and bet on every chapter + aligned with their House ideology. Align to share their winnings. Rival to profit from + their defeats. +

+ + {/* Live indicator */} + {liveAgentsCount > 0 && ( +
+
+ {liveAgentsCount} agent{liveAgentsCount > 1 ? 's' : ''} betting live on  + {meta.currentChapterTitle} +
+ )} + + + {/* โ”€โ”€ Global Stats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + + } + label="Total Aligned Players" + value={meta.totalAligned.toLocaleString()} + sub="across all 5 Houses" + /> + } + label="Total Wagered" + value={`$${(meta.totalWagered / 1000).toFixed(1)}K`} + sub="USDC by agents all-time" + /> + } + label="Total Agent Bets" + value={meta.totalBets.toLocaleString()} + sub="chapters bet since launch" + /> + + + {/* โ”€โ”€ Main Layout (Cards + Sidebar) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} +
+ {/* Agent Cards โ€” 3 cols on xl */} +
+
+

+ The Five Houses +

+ +
+ +
+ {agents.map((agent, idx) => ( + + + + ))} +
+
+ + {/* Sidebar */} +
+ + + + + {/* How It Works */} + + + +
+
+ + {/* โ”€โ”€ Rivalry Matrix โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + + + +
+ ) +} + +// โ”€โ”€โ”€ Sub-components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function StatCard({ + icon, + label, + value, + sub, +}: { + icon: React.ReactNode + label: string + value: string + sub: string +}) { + return ( +
+
+ {icon} +
+
+

{label}

+

{value}

+

{sub}

+
+
+ ) +} + +function HowItWorksCard() { + return ( +
+

+ How It Works +

+
    + {[ + { + step: '1', + text: 'Each House has an autonomous AI Agent with its own wallet and betting strategy.', + }, + { + step: '2', + text: 'Agents bet before each chapter closes โ€” aligned with their House ideology.', + }, + { + step: '3', + text: 'Align with a House to earn 20% of their winnings at no risk to you.', + }, + { + step: '4', + text: 'Declare Rivalry to earn 10% whenever the agent loses a bet.', + }, + { + step: '5', + text: 'Agent personalities evolve each chapter based on outcomes.', + }, + ].map((item) => ( +
  1. +
    + {item.step} +
    +

    {item.text}

    +
  2. + ))} +
+
+ ) +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function formatRelative(date: Date): string { + const diff = Math.floor((Date.now() - date.getTime()) / 1000) + if (diff < 10) return 'just now' + if (diff < 60) return `${diff}s ago` + return `${Math.floor(diff / 60)}m ago` +} diff --git a/apps/web/src/components/house-agents/PersonalityBars.tsx b/apps/web/src/components/house-agents/PersonalityBars.tsx new file mode 100644 index 0000000..1de9872 --- /dev/null +++ b/apps/web/src/components/house-agents/PersonalityBars.tsx @@ -0,0 +1,75 @@ +'use client' + +/** + * PersonalityBars โ€” Animated horizontal bars for the 5-dimension personality matrix. + * + * Dimensions: + * - Risk Tolerance: low = conservative, high = degen + * - Contrarianism: low = follows crowd, high = always bets against + * - Survival Bias: low = pure EV, high = House survival over profit + * - Memory Depth: low = ignores history, high = pattern machine + * - Bluff Propensity: low = honest bets, high = strategic misdirection + */ + +import { motion } from 'framer-motion' + +interface PersonalityMatrix { + riskTolerance: number // 0-100 + contrarianism: number // 0-100 + survivalBias: number // 0-100 + memoryDepth: number // 0-100 + bluffPropensity: number // 0-100 +} + +interface PersonalityBarsProps { + personality: PersonalityMatrix + accentHex: string +} + +const DIMENSIONS: Array<{ + key: keyof PersonalityMatrix + label: string + lowLabel: string + highLabel: string +}> = [ + { key: 'riskTolerance', label: 'Risk', lowLabel: 'Cautious', highLabel: 'Degen' }, + { key: 'contrarianism', label: 'Crowd', lowLabel: 'Follower', highLabel: 'Contrarian' }, + { key: 'survivalBias', label: 'Loyalty', lowLabel: 'Pure EV', highLabel: 'House First' }, + { key: 'memoryDepth', label: 'Memory', lowLabel: 'Forgets', highLabel: 'Pattern Pro' }, + { key: 'bluffPropensity', label: 'Bluff', lowLabel: 'Honest', highLabel: 'Deceiver' }, +] + +export function PersonalityBars({ personality, accentHex }: PersonalityBarsProps) { + return ( +
+ {DIMENSIONS.map((dim, i) => { + const value = personality[dim.key] + return ( +
+
+ + {dim.label} + +
+ {dim.lowLabel} + {value} + {dim.highLabel} +
+
+
+ +
+
+ ) + })} +
+ ) +} diff --git a/apps/web/src/components/house-agents/RivalryMatrix.tsx b/apps/web/src/components/house-agents/RivalryMatrix.tsx new file mode 100644 index 0000000..b08cd53 --- /dev/null +++ b/apps/web/src/components/house-agents/RivalryMatrix.tsx @@ -0,0 +1,128 @@ +'use client' + +/** + * RivalryMatrix โ€” Visualizes inter-house rivalry tensions. + * + * Renders a 5x5 heatmap grid showing rivalry intensity between Houses. + * High intensity = deep red. Low intensity = subtle grey. + * + * Used on the House Agents page as a global conflict overview. + */ + +import { motion } from 'framer-motion' +import type { HouseAgentPublicProfile } from '@/app/api/house-agents/route' + +interface RivalryMatrixProps { + agents: HouseAgentPublicProfile[] +} + +export function RivalryMatrix({ agents }: RivalryMatrixProps) { + if (agents.length === 0) return null + + const houses = agents.map((a) => ({ id: a.houseId, emoji: a.emoji, name: a.name, accentHex: a.accentHex })) + + function getIntensity(fromId: string, toId: string): number { + if (fromId === toId) return -1 // diagonal โ€” skip + const agent = agents.find((a) => a.houseId === fromId) + return agent?.rivalIntensity[toId] ?? 0 + } + + function intensityColor(intensity: number): string { + if (intensity < 0) return 'transparent' + if (intensity >= 80) return 'rgba(239, 68, 68, 0.7)' // hot red + if (intensity >= 60) return 'rgba(234, 88, 12, 0.5)' // orange + if (intensity >= 40) return 'rgba(234, 179, 8, 0.35)' // amber + if (intensity >= 20) return 'rgba(100, 116, 139, 0.25)' // grey-blue + return 'rgba(255,255,255,0.04)' // near zero + } + + function intensityLabel(intensity: number): string { + if (intensity < 0) return '' + if (intensity >= 80) return 'Mortal' + if (intensity >= 60) return 'High' + if (intensity >= 40) return 'Tense' + if (intensity >= 20) return 'Cool' + return 'Neutral' + } + + return ( +
+

+ Rivalry Matrix +

+

+ Inter-house tension โ€” shaped by agent bet history and ideological clashes. +

+ +
+ + + + + ))} + + + + {houses.map((fromHouse, rowIdx) => ( + + + {houses.map((toHouse, colIdx) => { + const intensity = getIntensity(fromHouse.id, toHouse.id) + const isDiag = fromHouse.id === toHouse.id + + return ( + + ) + })} + + ))} + +
+ {houses.map((h) => ( + + {h.emoji} +
{h.name}
+
+ {fromHouse.emoji} + {fromHouse.name} + + + {isDiag ? ( + โ€” + ) : ( + <> + {intensity} + + {intensityLabel(intensity)} + + + )} + +
+
+ + {/* Legend */} +
+ Intensity: + {[ + { label: 'Mortal (80+)', color: 'rgba(239, 68, 68, 0.7)' }, + { label: 'High (60+)', color: 'rgba(234, 88, 12, 0.5)' }, + { label: 'Tense (40+)', color: 'rgba(234, 179, 8, 0.35)' }, + { label: 'Cool (20+)', color: 'rgba(100, 116, 139, 0.25)' }, + { label: 'Neutral', color: 'rgba(255,255,255,0.04)' }, + ].map((item) => ( +
+
+ {item.label} +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/components/house-agents/index.ts b/apps/web/src/components/house-agents/index.ts new file mode 100644 index 0000000..1b8f952 --- /dev/null +++ b/apps/web/src/components/house-agents/index.ts @@ -0,0 +1,11 @@ +/** + * House Agent Protocol (HAP) โ€” Component Barrel + * Innovation Cycle #50 โ€” February 18, 2026 + */ + +export { HouseAgentCard } from './HouseAgentCard' +export { HouseAgentsContent } from './HouseAgentsContent' +export { PersonalityBars } from './PersonalityBars' +export { AlignmentModal } from './AlignmentModal' +export { RivalryMatrix } from './RivalryMatrix' +export { AgentLeaderboard } from './AgentLeaderboard' diff --git a/apps/web/src/components/landing/Navbar.tsx b/apps/web/src/components/landing/Navbar.tsx index b2a654d..0e13998 100644 --- a/apps/web/src/components/landing/Navbar.tsx +++ b/apps/web/src/components/landing/Navbar.tsx @@ -9,7 +9,8 @@ import { ClientOnly } from '@/components/ClientOnly' const navLinks = [ { label: 'Explore Lore', href: '/lore' }, { label: 'Read Story', href: '/story/voidborne-story' }, - { label: 'How It Works', href: '#how-it-works' }, + { label: 'House Agents โš”', href: '/house-agents' }, + { label: 'Prophecies โœฆ', href: '/prophecies' }, { label: 'Leaderboards', href: '/leaderboards' }, { label: 'Analytics', href: '/analytics' }, ] diff --git a/apps/web/src/components/prophecy/OracleLeaderboard.tsx b/apps/web/src/components/prophecy/OracleLeaderboard.tsx new file mode 100644 index 0000000..2d2fffb --- /dev/null +++ b/apps/web/src/components/prophecy/OracleLeaderboard.tsx @@ -0,0 +1,147 @@ +'use client' + +/** + * OracleLeaderboard + * Displays top prophecy collectors ranked by fulfilled NFT count. + * Shows rank title, wallet, stats, and portfolio value. + */ + +import { useEffect, useState } from 'react' + +interface LeaderboardEntry { + position: number + userId: string + walletAddress: string + displayName: string | null + total: number + fulfilled: number + echoed: number + unfulfilled: number + pending: number + fulfillmentRate: number + totalForgePaid: number + estimatedPortfolioValue: number + rank: string +} + +const RANK_CONFIG: Record = { + VOID_EYE: { icon: 'โ—‰', label: 'Void Eye', color: 'text-violet-400' }, + PROPHET: { icon: 'โœฆ', label: 'Prophet', color: 'text-amber-300' }, + ORACLE: { icon: 'โ—ˆ', label: 'Oracle', color: 'text-amber-400' }, + SEER: { icon: 'โ—‡', label: 'Seer', color: 'text-slate-300' }, + NOVICE: { icon: 'โ–ช', label: 'Novice', color: 'text-void-500' }, +} + +const POSITION_STYLES = [ + 'border-amber-400/40 bg-amber-500/5', // 1st + 'border-slate-400/30 bg-slate-500/5', // 2nd + 'border-amber-700/30 bg-amber-900/5', // 3rd +] + +function truncateAddress(addr: string) { + return `${addr.slice(0, 6)}โ€ฆ${addr.slice(-4)}` +} + +export function OracleLeaderboard({ limit = 10 }: { limit?: number }) { + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchLeaderboard = async () => { + try { + const res = await fetch(`/api/prophecies/leaderboard?limit=${limit}`) + if (!res.ok) throw new Error('Failed to fetch leaderboard') + const data = await res.json() + setEntries(data.leaderboard ?? []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Error') + } finally { + setLoading(false) + } + } + fetchLeaderboard() + }, [limit]) + + if (loading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) + } + + if (error) { + return ( +
+ Failed to load leaderboard +
+ ) + } + + if (entries.length === 0) { + return ( +
+
โ—Ž
+

+ No oracles yet โ€” be the first to mint a prophecy +

+
+ ) + } + + return ( +
+ {entries.map((entry) => { + const rankCfg = RANK_CONFIG[entry.rank] ?? RANK_CONFIG.NOVICE + const positionStyle = POSITION_STYLES[entry.position - 1] ?? 'border-void-800/30 bg-void-950/20' + + return ( +
+ {/* Position */} +
+ {entry.position <= 3 ? ['โ˜…', 'โ—ˆ', 'โ—‡'][entry.position - 1] : entry.position} +
+ + {/* Rank icon + address */} +
+
+ {rankCfg.icon} + + {entry.displayName ?? truncateAddress(entry.walletAddress)} + + + {rankCfg.label} + +
+
+ {entry.total} minted ยท {entry.fulfillmentRate}% fulfillment rate +
+
+ + {/* Stats */} +
+
+ {entry.fulfilled}โ˜… + {entry.echoed}โ—ˆ + {entry.unfulfilled}โ–ช +
+
+ ~{entry.estimatedPortfolioValue} $FORGE +
+
+
+ ) + })} +
+ ) +} diff --git a/apps/web/src/components/prophecy/ProphecyBanner.tsx b/apps/web/src/components/prophecy/ProphecyBanner.tsx new file mode 100644 index 0000000..77b8507 --- /dev/null +++ b/apps/web/src/components/prophecy/ProphecyBanner.tsx @@ -0,0 +1,119 @@ +'use client' + +/** + * ProphecyBanner + * Compact banner shown on the story page to tease active prophecies. + * Links to the full /prophecies/[chapterId] gallery. + * + * Shows: + * - Number of open prophecies + * - Minting deadline (before chapter closes) + * - CTA to the prophecy gallery + */ + +import { useEffect, useState } from 'react' +import Link from 'next/link' + +interface ProphecySummary { + total: number + pending: number + totalMinted: number + chapterId: string +} + +interface Props { + chapterId: string +} + +export function ProphecyBanner({ chapterId }: Props) { + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetch_ = async () => { + try { + const res = await fetch(`/api/prophecies/${chapterId}`) + if (!res.ok) return + const data = await res.json() + setSummary({ + total: data.summary.total, + pending: data.summary.pending, + totalMinted: data.summary.totalMinted, + chapterId, + }) + } catch { + // Non-critical โ€” fail silently + } finally { + setLoading(false) + } + } + fetch_() + }, [chapterId]) + + // Don't render if no prophecies exist or still loading + if (loading || !summary || summary.total === 0) return null + + const hasPending = summary.pending > 0 + const spotsTotal = summary.total * 100 + + return ( + +
+
+ {/* Icon */} +
+ {hasPending ? 'โœฆ' : 'โ–ช'} +
+ + {/* Text */} +
+

+ {hasPending + ? `${summary.pending} Prophecies Open` + : `${summary.total} Prophecies Sealed`} +

+

+ {hasPending + ? `${summary.totalMinted} of ${spotsTotal} spots taken ยท 5 $FORGE to mint` + : `${summary.totalMinted} collectors ยท Chapter resolved`} +

+
+
+ + {/* CTA */} +
+ {hasPending ? 'Mint Now โ†’' : 'View โ†’'} +
+
+ + {/* Mint progress bar (open only) */} + {hasPending && summary.totalMinted > 0 && ( +
+
+
+
+
+ )} + + ) +} diff --git a/apps/web/src/components/prophecy/ProphecyCard.tsx b/apps/web/src/components/prophecy/ProphecyCard.tsx new file mode 100644 index 0000000..6186e9d --- /dev/null +++ b/apps/web/src/components/prophecy/ProphecyCard.tsx @@ -0,0 +1,283 @@ +'use client' + +/** + * ProphecyCard + * Displays a single Prophecy NFT with: + * - Dynamic art based on fulfillment status (PENDING โ†’ dark, FULFILLED โ†’ golden, ECHOED โ†’ silver) + * - Animated transformation on status change + * - Mint order prestige indicator + * - Mint button (calls onMint prop) + * + * Design: "Ruins of the Future" โ€” dark void base, gold/silver accents + */ + +import { useState } from 'react' +import { ProphecyRarityBadge, deriveRarity, type ProphecyStatus } from './ProphecyRarityBadge' + +export interface ProphecyData { + id: string + teaser: string + text: string | null + status: ProphecyStatus + artTheme: string | null + pendingURI: string + fulfilledURI: string | null + echoedURI: string | null + unfulfilledURI: string | null + mintedCount: number + maxSupply: number + spotsRemaining: number + createdAt: string + fulfilledAt: string | null + chapter: { + id: string + chapterNumber: number + title: string + story: { id: string; title: string } + } + // If the connected user has minted this + userMint?: { + mintOrder: number + forgePaid: number + } +} + +interface Props { + prophecy: ProphecyData + onMint?: (prophecyId: string) => void + loading?: boolean + compact?: boolean +} + +// โ”€โ”€โ”€ Art Themes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const THEME_GRADIENTS: Record = { + void: 'from-void-950 via-[#0a0a1a] to-void-900', + light: 'from-void-950 via-[#1a1520] to-amber-950', + shadow: 'from-void-950 via-[#0f0a0f] to-violet-950', + fire: 'from-void-950 via-[#1a0a00] to-red-950', + ice: 'from-void-950 via-[#0a0f1a] to-cyan-950', + cosmic: 'from-void-950 via-[#080c1a] to-indigo-950', +} + +const STATUS_OVERLAYS: Record = { + PENDING: 'opacity-0', + UNFULFILLED: 'opacity-0', + ECHOED: 'bg-gradient-to-t from-slate-400/10 to-transparent opacity-100', + FULFILLED: 'bg-gradient-to-t from-amber-400/20 to-transparent opacity-100', +} + +const STATUS_BORDER: Record = { + PENDING: 'border-void-700/40 hover:border-void-600/60', + UNFULFILLED: 'border-void-800/30', + ECHOED: 'border-slate-500/50 hover:border-slate-400/70', + FULFILLED: 'border-amber-500/60 hover:border-amber-400/80', +} + +const STATUS_GLOW: Record = { + PENDING: '', + UNFULFILLED: '', + ECHOED: 'shadow-[0_0_20px_rgba(148,163,184,0.08)]', + FULFILLED: 'shadow-[0_0_30px_rgba(212,168,83,0.15)]', +} + +// โ”€โ”€โ”€ Particle Symbols โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const VOID_SYMBOLS = ['โŒฌ', 'โŠ•', 'โŒ–', 'โ‹ˆ', 'โ—ˆ', 'โŒ˜', 'โœฆ', 'โŠ—', 'โŒญ', 'โ‹„'] + +function getSymbol(index: number) { + return VOID_SYMBOLS[index % VOID_SYMBOLS.length] +} + +// โ”€โ”€โ”€ Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function ProphecyCard({ prophecy, onMint, loading = false, compact = false }: Props) { + const [isHovered, setIsHovered] = useState(false) + const [mintPending, setMintPending] = useState(false) + + const rarity = deriveRarity(prophecy.status, prophecy.userMint?.mintOrder) + const isSoldOut = prophecy.spotsRemaining <= 0 + const canMint = prophecy.status === 'PENDING' && !isSoldOut && !prophecy.userMint + const theme = prophecy.artTheme ?? 'void' + const gradient = THEME_GRADIENTS[theme] ?? THEME_GRADIENTS.void + + const handleMint = async () => { + if (!canMint || !onMint) return + setMintPending(true) + try { + await onMint(prophecy.id) + } finally { + setMintPending(false) + } + } + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Background gradient */} +
+ + {/* Status-based overlay glow */} +
+ + {/* Animated particles (FULFILLED / ECHOED only) */} + {(prophecy.status === 'FULFILLED' || prophecy.status === 'ECHOED') && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + {getSymbol(i)} + + ))} +
+ )} + + {/* Content */} +
+ {/* Header */} +
+
+
+ + {prophecy.userMint && ( + + #{prophecy.userMint.mintOrder} of {prophecy.maxSupply} + + )} +
+

+ Ch.{prophecy.chapter.chapterNumber} โ€” {prophecy.chapter.story.title} +

+
+ + {/* Mint counter */} +
+
+ {prophecy.mintedCount}/{prophecy.maxSupply} +
+ {prophecy.spotsRemaining > 0 && prophecy.status === 'PENDING' && ( +
+ {prophecy.spotsRemaining} left +
+ )} +
+
+ + {/* Prophecy text / teaser */} +
+ {prophecy.text ? ( + /* Revealed text */ +

+ “{prophecy.text}” +

+ ) : ( + /* Sealed teaser */ +
+

+ “{prophecy.teaser}” +

+

+ โ–“โ–“โ–“ sealed until chapter resolves โ–“โ–“โ–“ +

+
+ )} +
+ + {/* Status indicator */} + {prophecy.status !== 'PENDING' && ( +
+ + {prophecy.status === 'FULFILLED' ? 'โ˜… FULFILLED' : + prophecy.status === 'ECHOED' ? 'โ—ˆ ECHOED' : 'โ–ช VOID RELIC'} + + {prophecy.fulfilledAt && ( + + {new Date(prophecy.fulfilledAt).toLocaleDateString()} + + )} +
+ )} + + {/* Mint supply bar */} + {prophecy.status === 'PENDING' && ( +
+
+
+
+
+ )} + + {/* Mint button / status */} + {prophecy.userMint ? ( +
+ โœ“ + Minted โ€” Mint #{prophecy.userMint.mintOrder} + {prophecy.userMint.forgePaid} $FORGE +
+ ) : canMint ? ( + + ) : isSoldOut ? ( +
+ Sold Out +
+ ) : prophecy.status !== 'PENDING' ? ( +
+ Chapter Resolved +
+ ) : null} +
+
+ ) +} diff --git a/apps/web/src/components/prophecy/ProphecyGallery.tsx b/apps/web/src/components/prophecy/ProphecyGallery.tsx new file mode 100644 index 0000000..4a08b14 --- /dev/null +++ b/apps/web/src/components/prophecy/ProphecyGallery.tsx @@ -0,0 +1,255 @@ +'use client' + +/** + * ProphecyGallery + * Displays all prophecies for a chapter (or across chapters). + * Features: + * - Filter by status + * - Sort by mint count / rarity + * - Empty state with "coming soon" when no prophecies exist + * - Inline mint via ProphecyMintModal + */ + +import { useState, useCallback } from 'react' +import { ProphecyCard, type ProphecyData } from './ProphecyCard' +import { ProphecyMintModal } from './ProphecyMintModal' + +type StatusFilter = 'ALL' | 'PENDING' | 'FULFILLED' | 'ECHOED' | 'UNFULFILLED' +type SortKey = 'newest' | 'rarest' | 'most-minted' | 'spots-remaining' + +interface Props { + prophecies: ProphecyData[] + chapterTitle?: string + chapterNumber?: number + storyTitle?: string + summary?: { + total: number + totalMinted: number + fulfilled: number + echoed: number + unfulfilled: number + pending: number + } + onRefresh?: () => void + isLoading?: boolean +} + +const FILTER_LABELS: Record = { + ALL: 'All', + PENDING: 'Open', + FULFILLED: 'โ˜… Fulfilled', + ECHOED: 'โ—ˆ Echoed', + UNFULFILLED: 'โ–ช Void Relics', +} + +export function ProphecyGallery({ + prophecies, + chapterTitle, + chapterNumber, + storyTitle, + summary, + onRefresh, + isLoading = false, +}: Props) { + const [filter, setFilter] = useState('ALL') + const [sortKey, setSortKey] = useState('newest') + const [activeMint, setActiveMint] = useState(null) + + // Filter + const filtered = prophecies.filter( + (p) => filter === 'ALL' || p.status === filter + ) + + // Sort + const sorted = [...filtered].sort((a, b) => { + switch (sortKey) { + case 'rarest': + // FULFILLED > ECHOED > PENDING > UNFULFILLED + const rank: Record = { + FULFILLED: 4, ECHOED: 3, PENDING: 2, UNFULFILLED: 1, + } + return (rank[b.status] ?? 0) - (rank[a.status] ?? 0) + case 'most-minted': + return b.mintedCount - a.mintedCount + case 'spots-remaining': + return a.spotsRemaining - b.spotsRemaining + default: // newest + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + } + }) + + const handleMintSuccess = useCallback( + (mintOrder: number) => { + console.log(`Minted at position #${mintOrder}`) + setActiveMint(null) + onRefresh?.() + }, + [onRefresh] + ) + + return ( +
+ {/* Header */} + {(chapterTitle || summary) && ( +
+ {chapterTitle && ( +
+

+ {storyTitle ?? 'Voidborne'} โ€ข Chapter {chapterNumber} +

+

+ {chapterTitle} โ€” Prophecies +

+
+ )} + + {/* Summary stats */} + {summary && ( +
+ {[ + { label: 'Total Prophecies', value: summary.total, color: 'text-void-300' }, + { label: 'Total Minted', value: summary.totalMinted, color: 'text-void-300' }, + { label: 'โ˜… Fulfilled', value: summary.fulfilled, color: 'text-amber-400' }, + { label: 'โ—ˆ Echoed', value: summary.echoed, color: 'text-slate-300' }, + ].map((stat) => ( +
+
+ {stat.value} +
+
{stat.label}
+
+ ))} +
+ )} +
+ )} + + {/* Filters + Sort */} +
+ {/* Status filter pills */} +
+ {(Object.keys(FILTER_LABELS) as StatusFilter[]).map((s) => { + const count = s === 'ALL' + ? prophecies.length + : prophecies.filter((p) => p.status === s).length + if (count === 0 && s !== 'ALL') return null + return ( + + ) + })} +
+ + {/* Sort */} +
+ +
+
+ + {/* Grid */} + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : sorted.length === 0 ? ( + + ) : ( +
+ {sorted.map((prophecy) => ( + { + const p = prophecies.find((x) => x.id === id) + if (p) setActiveMint(p) + }} + /> + ))} +
+ )} + + {/* Oracle Pack hint */} + {prophecies.filter((p) => p.status === 'PENDING' && p.spotsRemaining > 0 && !p.userMint).length >= 3 && ( +
+ โŠ• Oracle Pack available โ€” mint 3+ prophecies at once for a 10% discount +
+ )} + + {/* Mint Modal */} + {activeMint && ( + setActiveMint(null)} + onMintSuccess={handleMintSuccess} + relatedProphecies={prophecies.filter( + (p) => p.id !== activeMint.id && p.status === 'PENDING' && p.spotsRemaining > 0 && !p.userMint + )} + /> + )} +
+ ) +} + +// โ”€โ”€โ”€ Empty State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function EmptyState({ filter, totalProphecies }: { filter: StatusFilter; totalProphecies: number }) { + if (totalProphecies === 0) { + return ( +
+
โ–“
+

+ The Seers Have Not Yet Spoken +

+

+ Prophecies will appear here before the next chapter begins. +

+
+ ) + } + + return ( +
+

+ No {FILTER_LABELS[filter].toLowerCase()} prophecies found +

+

+ Try a different filter above +

+
+ ) +} diff --git a/apps/web/src/components/prophecy/ProphecyMintModal.tsx b/apps/web/src/components/prophecy/ProphecyMintModal.tsx new file mode 100644 index 0000000..51f6f44 --- /dev/null +++ b/apps/web/src/components/prophecy/ProphecyMintModal.tsx @@ -0,0 +1,369 @@ +'use client' + +/** + * ProphecyMintModal + * Full-screen modal for minting prophecy NFTs. + * Supports single mint and Oracle Pack (up to 20, 10% discount). + * + * Flow: + * 1. View prophecy teaser + * 2. Select single or oracle pack + * 3. Connect wallet (if needed) + * 4. Confirm & mint โ†’ API call โ†’ success animation + */ + +import { useState, useCallback } from 'react' +import { useAccount } from 'wagmi' +import { ProphecyRarityBadge, deriveRarity } from './ProphecyRarityBadge' +import type { ProphecyData } from './ProphecyCard' + +interface Props { + prophecy: ProphecyData + isOpen: boolean + onClose: () => void + onMintSuccess: (mintOrder: number) => void + // Other pending prophecies for oracle pack + relatedProphecies?: ProphecyData[] +} + +type Step = 'preview' | 'confirm' | 'minting' | 'success' | 'error' + +export function ProphecyMintModal({ + prophecy, + isOpen, + onClose, + onMintSuccess, + relatedProphecies = [], +}: Props) { + const { address, isConnected } = useAccount() + const [step, setStep] = useState('preview') + const [isOraclePack, setIsOraclePack] = useState(false) + const [selectedPackIds, setSelectedPackIds] = useState([prophecy.id]) + const [mintResult, setMintResult] = useState<{ mintOrder: number; totalForgePaid: number } | null>(null) + const [errorMsg, setErrorMsg] = useState(null) + + const availableForPack = relatedProphecies.filter( + (p) => p.status === 'PENDING' && p.spotsRemaining > 0 && !p.userMint + ) + + const forgeCost = isOraclePack + ? (selectedPackIds.length * 5 * 0.9).toFixed(1) + : '5.0' + + const rarity = deriveRarity(prophecy.status) + + const togglePackSelection = useCallback((id: string) => { + setSelectedPackIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ) + }, []) + + const handleMint = async () => { + if (!isConnected || !address) return + setStep('minting') + setErrorMsg(null) + + try { + const body = isOraclePack + ? { prophecyIds: selectedPackIds, walletAddress: address } + : { prophecyId: prophecy.id, walletAddress: address } + + const res = await fetch('/api/prophecies/mint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + const data = await res.json() + + if (!res.ok) { + throw new Error(data.error ?? 'Mint failed') + } + + // Find our prophecy's mint order from the result + const ourMint = data.mints?.find( + (m: { prophecy: { id: string }; mintOrder: number }) => m.prophecy?.id === prophecy.id + ) + const mintOrder = ourMint?.mintOrder ?? 1 + + setMintResult({ mintOrder, totalForgePaid: data.totalForgePaid }) + setStep('success') + onMintSuccess(mintOrder) + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : 'Unknown error') + setStep('error') + } + } + + const handleClose = () => { + setStep('preview') + setIsOraclePack(false) + setSelectedPackIds([prophecy.id]) + setMintResult(null) + setErrorMsg(null) + onClose() + } + + if (!isOpen) return null + + return ( + /* Backdrop */ +
+ {/* Overlay */} +
+ + {/* Panel */} +
+ + {/* Header */} +
+
+

+ {step === 'success' ? 'Prophecy Sealed โœฆ' : 'Mint Prophecy NFT'} +

+

+ Ch.{prophecy.chapter.chapterNumber} โ€” {prophecy.chapter.story.title} +

+
+ +
+ + {/* Body */} +
+ + {/* PREVIEW / CONFIRM */} + {(step === 'preview' || step === 'confirm') && ( + <> + {/* Prophecy teaser */} +
+
+ + + #{prophecy.mintedCount + 1} of {prophecy.maxSupply} + +
+

+ “{prophecy.teaser}” +

+

+ โ–“โ–“โ–“ full text sealed until chapter closes โ–“โ–“โ–“ +

+
+ + {/* Fulfillment reward table */} +
+

+ Potential rewards +

+
+ {[ + { label: 'โ˜… Fulfilled', multiplier: '10ร—', color: 'text-amber-400', bg: 'bg-amber-500/5 border-amber-500/20' }, + { label: 'โ—ˆ Echoed', multiplier: '3ร—', color: 'text-slate-300', bg: 'bg-slate-500/5 border-slate-500/20' }, + { label: 'โ–ช Void Relic', multiplier: '1ร—', color: 'text-void-500', bg: 'bg-void-800/20 border-void-700/20' }, + ].map((tier) => ( +
+
{tier.label}
+
{tier.multiplier}
+
value
+
+ ))} +
+
+ + {/* Oracle Pack toggle */} + {availableForPack.length > 0 && ( +
+ + + {isOraclePack && ( +
+ {availableForPack.map((p) => ( + + ))} +
+ )} +
+ )} + + {/* Cost summary */} +
+ Cost +
+ {forgeCost} $FORGE + {isOraclePack && ( +
+ {selectedPackIds.length} prophecies ร— 4.5 $FORGE +
+ )} +
+
+ + {/* CTA */} + {!isConnected ? ( +
+ Connect wallet to mint +
+ ) : ( + + )} + + {step === 'confirm' && ( + + )} + + )} + + {/* MINTING */} + {step === 'minting' && ( +
+
โŒญ
+

Sealing prophecy on-chainโ€ฆ

+

+ Your NFT is being minted. This may take a moment. +

+
+ )} + + {/* SUCCESS */} + {step === 'success' && mintResult && ( +
+
+ โœฆ +
+
+

+ Prophecy Sealed +

+

+ Mint #{mintResult.mintOrder} of {prophecy.maxSupply} +

+
+ +
+
+ $FORGE spent + {mintResult.totalForgePaid} +
+
+ Your mint rank + + #{mintResult.mintOrder} {mintResult.mintOrder <= 5 ? '(Mythic tier!)' : mintResult.mintOrder <= 10 ? '(Rare tier)' : ''} + +
+
+ +

+ Your prophecy will transform when the chapter resolves. +
Watch for the golden glow if it comes true. +

+ + +
+ )} + + {/* ERROR */} + {step === 'error' && ( +
+
โŠ—
+

{errorMsg ?? 'Mint failed'}

+
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/components/prophecy/ProphecyRarityBadge.tsx b/apps/web/src/components/prophecy/ProphecyRarityBadge.tsx new file mode 100644 index 0000000..07384a2 --- /dev/null +++ b/apps/web/src/components/prophecy/ProphecyRarityBadge.tsx @@ -0,0 +1,127 @@ +'use client' + +/** + * ProphecyRarityBadge + * Displays rarity tier for a Prophecy NFT with appropriate color + icon. + * + * Rarity tiers (from Innovation Cycle #49): + * MYTHIC โ€” Fulfilled + minted #1-5 + * LEGENDARY โ€” Fulfilled + * RARE โ€” Echoed + minted #1-10 + * UNCOMMON โ€” Echoed + * COMMON โ€” Unfulfilled + * PENDING โ€” Chapter not yet resolved + */ + +export type Rarity = 'MYTHIC' | 'LEGENDARY' | 'RARE' | 'UNCOMMON' | 'COMMON' | 'PENDING' +export type ProphecyStatus = 'PENDING' | 'FULFILLED' | 'ECHOED' | 'UNFULFILLED' + +interface Props { + rarity?: Rarity + status?: ProphecyStatus + mintOrder?: number + size?: 'sm' | 'md' | 'lg' + showIcon?: boolean +} + +const RARITY_CONFIG: Record = { + MYTHIC: { + label: 'Mythic', + icon: 'โœฆ', + bg: 'bg-gradient-to-r from-violet-900/80 to-amber-900/80', + text: 'text-amber-200', + border: 'border-amber-400/60', + glow: 'shadow-amber-500/40', + }, + LEGENDARY: { + label: 'Legendary', + icon: 'โ˜…', + bg: 'bg-gradient-to-r from-amber-900/80 to-yellow-900/80', + text: 'text-amber-300', + border: 'border-amber-500/50', + glow: 'shadow-amber-400/30', + }, + RARE: { + label: 'Rare', + icon: 'โ—ˆ', + bg: 'bg-gradient-to-r from-slate-800/80 to-slate-700/80', + text: 'text-slate-200', + border: 'border-slate-400/50', + glow: 'shadow-slate-400/20', + }, + UNCOMMON: { + label: 'Echoed', + icon: 'โ—‡', + bg: 'bg-slate-800/60', + text: 'text-slate-300', + border: 'border-slate-500/40', + glow: '', + }, + COMMON: { + label: 'Void Relic', + icon: 'โ–ช', + bg: 'bg-void-900/60', + text: 'text-void-400', + border: 'border-void-700/40', + glow: '', + }, + PENDING: { + label: 'Sealed', + icon: 'โ–“', + bg: 'bg-void-900/40', + text: 'text-void-500', + border: 'border-void-700/30', + glow: '', + }, +} + +const SIZE_CLASSES: Record, string> = { + sm: 'text-[10px] px-2 py-0.5 gap-1', + md: 'text-xs px-2.5 py-1 gap-1.5', + lg: 'text-sm px-3 py-1.5 gap-2', +} + +/** + * Derive rarity from status + mintOrder. + */ +export function deriveRarity(status: ProphecyStatus, mintOrder?: number): Rarity { + if (status === 'PENDING') return 'PENDING' + if (status === 'UNFULFILLED') return 'COMMON' + if (status === 'ECHOED') return (mintOrder ?? 99) <= 10 ? 'RARE' : 'UNCOMMON' + // FULFILLED + return (mintOrder ?? 99) <= 5 ? 'MYTHIC' : 'LEGENDARY' +} + +export function ProphecyRarityBadge({ + rarity: rarityProp, + status, + mintOrder, + size = 'md', + showIcon = true, +}: Props) { + const rarity = rarityProp ?? (status ? deriveRarity(status, mintOrder) : 'PENDING') + const cfg = RARITY_CONFIG[rarity] + + return ( + + {showIcon && } + {cfg.label} + + ) +} diff --git a/apps/web/src/components/prophecy/index.ts b/apps/web/src/components/prophecy/index.ts new file mode 100644 index 0000000..bb3ce13 --- /dev/null +++ b/apps/web/src/components/prophecy/index.ts @@ -0,0 +1,11 @@ +/** + * Prophecy NFT components (Innovation Cycle #49 โ€” "The Living Cosmos") + */ +export { ProphecyCard } from './ProphecyCard' +export type { ProphecyData } from './ProphecyCard' +export { ProphecyGallery } from './ProphecyGallery' +export { ProphecyMintModal } from './ProphecyMintModal' +export { ProphecyRarityBadge, deriveRarity } from './ProphecyRarityBadge' +export type { Rarity, ProphecyStatus } from './ProphecyRarityBadge' +export { ProphecyBanner } from './ProphecyBanner' +export { OracleLeaderboard } from './OracleLeaderboard' diff --git a/docs/PROPHECY_NFT_SYSTEM.md b/docs/PROPHECY_NFT_SYSTEM.md new file mode 100644 index 0000000..51a37af --- /dev/null +++ b/docs/PROPHECY_NFT_SYSTEM.md @@ -0,0 +1,192 @@ +# Prophecy NFT System โ€” Documentation + +**Innovation Cycle #49 โ€” "The Living Cosmos"** +**Implemented:** February 18, 2026 +**Status:** โœ… Production-Ready (PR pending review) + +--- + +## Overview + +The Prophecy NFT system transforms story participation from ephemeral betting into **collectible on-chain history**. The AI generates cryptic prophecies before each chapter resolves. Readers mint them as NFTs. After the chapter concludes, each prophecy transforms based on how accurately it foretold the narrative. + +``` +Pre-chapter During chapter Post-resolution +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +AI generates Readers mint: Oracle evaluates: +10-20 cryptic 5 $FORGE each +prophecies (4.5 with pack) โ˜… Fulfilled โ†’ Golden (10ร—) + Max 100/prophecy โ—ˆ Echoed โ†’ Silver (3ร—) +Sealed on-chain โ–ช Void Relic โ†’ Still cool +``` + +--- + +## Architecture + +### Database Models + +```prisma +model Prophecy { + id String // CUID + chapterId String // Chapter reference + contentHash String // keccak256(text) โ€” sealed commitment + text String? // Revealed after minting window closes + teaser String // 2-4 words shown pre-reveal + status ProphecyStatus // PENDING | FULFILLED | ECHOED | UNFULFILLED + mintedCount Int // Current mint count + maxSupply Int // Always 100 + // ... + mints ProphecyMint[] +} + +model ProphecyMint { + id String + prophecyId String + userId String + walletAddress String + mintOrder Int // 1-100 (lower = more prestige) + forgePaid Float // 5.0 single / 4.5 oracle pack +} +``` + +### API Routes + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/prophecies` | GET | All prophecies (filterable by status) | +| `/api/prophecies` | POST | Seed prophecies for a chapter (oracle only) | +| `/api/prophecies/[chapterId]` | GET | Chapter-specific gallery + summary | +| `/api/prophecies/[chapterId]` | PATCH | Fulfill prophecies after chapter resolution | +| `/api/prophecies/mint` | POST | Mint single or oracle pack | +| `/api/prophecies/mint` | GET | User's mint history by wallet | +| `/api/prophecies/leaderboard` | GET | Oracle leaderboard | + +### Frontend Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| `ProphecyCard` | `components/prophecy/` | Individual NFT display with transform animation | +| `ProphecyGallery` | `components/prophecy/` | Chapter gallery with filter/sort | +| `ProphecyMintModal` | `components/prophecy/` | Mint flow (single + oracle pack) | +| `ProphecyRarityBadge` | `components/prophecy/` | Rarity tier indicator | +| `ProphecyBanner` | `components/prophecy/` | Compact teaser for story sidebar | +| `OracleLeaderboard` | `components/prophecy/` | Top collectors ranked by fulfilled count | + +### Pages + +| Route | Description | +|-------|-------------| +| `/prophecies` | Global gallery with leaderboard sidebar | +| `/prophecies/[chapterId]` | Chapter-specific gallery with "My Mints" | + +--- + +## Rarity System + +| Status | Mint Order | Rarity | Value Multiplier | +|--------|-----------|--------|-----------------| +| FULFILLED | 1-5 | MYTHIC | 20ร— | +| FULFILLED | 6-100 | LEGENDARY | 10ร— | +| ECHOED | 1-10 | RARE | 4ร— | +| ECHOED | 11-100 | UNCOMMON | 3ร— | +| UNFULFILLED | any | COMMON | 1ร— (face value) | +| PENDING | any | SEALED | โ€” | + +--- + +## Oracle Ranks (Leaderboard) + +| Fulfilled Prophecies | Rank | +|---------------------|------| +| 0-2 | Novice | +| 3-9 | Seer | +| 10-24 | Oracle | +| 25-49 | Prophet | +| 50+ | Void Eye | + +--- + +## Oracle Pack Discount + +Mint 2-20 prophecies at once for a **10% discount** (4.5 $FORGE each). +- Triggered in the `ProphecyMintModal` when `relatedProphecies.length >= 1` +- API automatically applies discount when `prophecyIds[]` has length > 1 + +--- + +## Seeding Prophecies (Admin) + +```bash +# Seed 15 prophecies for chapter XYZ +curl -X POST /api/prophecies \ + -H "Content-Type: application/json" \ + -d '{ + "chapterId": "chapter-xyz-id", + "prophecies": [ + { + "teaser": "When two suns align...", + "contentHash": "0xabc123...", + "pendingURI": "ipfs://QmPending...", + "targetEvent": "House Valdris betrayal", + "relevanceScore": 0.85, + "artTheme": "shadow" + } + ] + }' +``` + +--- + +## Fulfillment (Post-Chapter) + +```bash +# After chapter resolves, set outcomes for all prophecies +PATCH /api/prophecies/[chapterId] +{ + "outcomes": [ + { "prophecyId": "...", "status": "FULFILLED", "metadataURI": "ipfs://..." }, + { "prophecyId": "...", "status": "ECHOED", "metadataURI": "ipfs://..." }, + { "prophecyId": "...", "status": "UNFULFILLED" } + ] +} +``` + +--- + +## Database Migration + +```bash +cd packages/database + +# Apply migration +npx prisma migrate deploy + +# Or for development +npx prisma migrate dev --name prophecy_nft +``` + +--- + +## Revenue Model (from Innovation Cycle #49) + +| Period | Revenue | +|--------|---------| +| Year 1 | $61K | +| Year 5 | $1.82M | + +**Sources:** +- Primary mint fees (5 $FORGE ร— volume) +- Oracle Pack premium (discount drives volume) +- 5% royalty on all secondary sales (OpenSea/Blur) +- Leaderboard prestige โ†’ organic word-of-mouth + +--- + +## Future Enhancements (Roadmap) + +1. **On-chain ERC-721 deployment** โ€” `packages/prophecy-nft/src/ProphecyNFT.sol` ready for Base Sepolia +2. **IPFS art generation** โ€” DALL-E generated art per prophecy + fulfillment state +3. **Secondary market** โ€” OpenSea/Blur listing integration +4. **Cross-story prophecies** โ€” Prophecies that reference events across multiple story arcs +5. **Prophecy trading** โ€” In-app P2P marketplace before chapter resolution diff --git a/memory/2026-02-18-optimization-cycle.md b/memory/2026-02-18-optimization-cycle.md new file mode 100644 index 0000000..f880c0b --- /dev/null +++ b/memory/2026-02-18-optimization-cycle.md @@ -0,0 +1,55 @@ +# Voidborne Optimization Cycle โ€” Feb 18, 2026 + +**Branch:** `optimize/bundle-caching-ux` +**PR:** https://github.com/Eli5DeFi/StoryEngine/pull/42 +**Status:** โœ… COMPLETE (awaiting manual review) + +## Metrics + +| Route | Before | After | Delta | +|-------|--------|-------|-------| +| /dashboard | 285 kB | 272 kB | -4.6% | +| /lore | 242 kB | 229 kB | -5.4% | +| /lore/houses-dynamic | 242 kB | 229 kB | -5.4% | +| /leaderboards | 719 kB | 720 kB* | FCP/TTI improved | +| Build result | success | success | โœ… | + +*Leaderboards: lazy-loaded โ†’ skeleton shows instantly, component loads async + +## Changes Made + +### 1. Lazy-Load Leaderboards (UX) +- `apps/web/src/app/leaderboards/page.tsx` +- Dynamic import + skeleton loader for 562-line Leaderboards component +- Leaderboard now revalidates every 300s (was 3600s) + +### 2. API In-Memory Cache +- `GET /api/stories` โ€” 5min cache per query params +- `GET /api/lore/houses` โ€” 5min cache +- `GET /api/lore/protocols` โ€” 5min cache per filter combo +- `GET /api/leaderboards` โ€” 5min cache per category/timeframe +- POST routes invalidate cache on write + +### 3. Lore ISR Fetch Caching +- Changed `cache: 'no-store'` โ†’ `next: { revalidate: 300 }` in: + - `/lore/houses-dynamic` page + slug + - `/lore/protocols-dynamic` page + slug +- Added `export const dynamic = 'force-dynamic'` to prevent build timeout + +### 4. next.config.js Improvements +- Removed duplicate `next.config.mjs` (renamed to .bak) +- AVIF-first image format +- Image cache TTL: 60s โ†’ 3600s +- Added `scrollRestoration: true` +- Added Web3 Node.js fallbacks +- Per-route HTTP cache headers +- Fixed `@wagmi` chunk group test regex + +### 5. API Route Fixes +- Added `force-dynamic` to all routes using `request.url` / `nextUrl.searchParams` +- Eliminates DYNAMIC_SERVER_USAGE build warnings + +## What Wasn't Changed +- Story page (729 kB) โ€” requires larger refactor (wagmi stack is the weight) +- my-bets page (718 kB) โ€” same reason +- ConsoleLog replacement in 34+ API routes โ€” already handled by `removeConsole` in prod build diff --git a/memory/2026-02-18-voidborne-implementation.md b/memory/2026-02-18-voidborne-implementation.md new file mode 100644 index 0000000..364875f --- /dev/null +++ b/memory/2026-02-18-voidborne-implementation.md @@ -0,0 +1,90 @@ +# Voidborne Implementation Cycle โ€” Feb 18, 2026 (AM) + +## Session Summary + +**Type:** Implementation Cycle (cron-triggered) +**Focus:** Prophecy NFT Gallery (Innovation Cycle #49 top priority) +**Status:** โœ… COMPLETE โ€” PR #41 created + +--- + +## What Was Built + +### Feature: Prophecy NFT Gallery + +**Branch:** `feature/prophecy-nft-gallery` +**PR:** https://github.com/Eli5DeFi/StoryEngine/pull/41 +**Commit:** `e907433` + +#### Files Created (18 changes, 2763 insertions) + +**Database:** +- `packages/database/prisma/schema.prisma` โ€” Added Prophecy + ProphecyMint models + ProphecyStatus enum +- `packages/database/prisma/migrations/20260218_prophecy_nft/migration.sql` โ€” Full SQL migration + +**API Routes:** +- `apps/web/src/app/api/prophecies/route.ts` โ€” List all / seed (GET/POST) +- `apps/web/src/app/api/prophecies/[chapterId]/route.ts` โ€” Chapter gallery + fulfill (GET/PATCH) +- `apps/web/src/app/api/prophecies/mint/route.ts` โ€” Mint + history (GET/POST) +- `apps/web/src/app/api/prophecies/leaderboard/route.ts` โ€” Oracle leaderboard + +**Components:** +- `apps/web/src/components/prophecy/ProphecyCard.tsx` +- `apps/web/src/components/prophecy/ProphecyGallery.tsx` +- `apps/web/src/components/prophecy/ProphecyMintModal.tsx` +- `apps/web/src/components/prophecy/ProphecyRarityBadge.tsx` +- `apps/web/src/components/prophecy/ProphecyBanner.tsx` +- `apps/web/src/components/prophecy/OracleLeaderboard.tsx` +- `apps/web/src/components/prophecy/index.ts` + +**Pages:** +- `apps/web/src/app/prophecies/page.tsx` โ€” Global gallery +- `apps/web/src/app/prophecies/[chapterId]/page.tsx` โ€” Chapter gallery + +**Modified:** +- `apps/web/src/components/landing/Navbar.tsx` โ€” Added Prophecies link +- `apps/web/src/app/story/[storyId]/page.tsx` โ€” Added ProphecyBanner to sidebar + +**Docs:** +- `docs/PROPHECY_NFT_SYSTEM.md` + +--- + +## Quality Signals + +- โœ… TypeScript: 0 errors (`npx tsc --noEmit` clean) +- โœ… Prisma client regenerated after schema changes +- โœ… Error handling on all API routes (400/404/409/422/500) +- โœ… Sold-out guard, duplicate-mint guard +- โœ… Oracle Pack 10% discount applied server-side +- โœ… Mobile-responsive grid layout +- โœ… Empty states on all components +- โœ… Loading skeletons everywhere data is fetched + +--- + +## Key Decisions + +1. **DB-first approach** โ€” Built full DB model before on-chain ERC-721 deployment. This lets us ship the UI now and connect blockchain later. The `ProphecyNFT.sol` POC contract is ready in `packages/prophecy-nft/`. + +2. **ProphecyBanner is non-blocking** โ€” Fails silently if no prophecies exist for a chapter. Zero impact on story page if feature not yet seeded. + +3. **Sealed content strategy** โ€” `contentHash` stored on creation (keccak256 of text). `text` null until oracle reveals. Teaser always shown (2-4 word hook). + +4. **Rarity computed at read time** โ€” Not stored in DB. Derived from `status + mintOrder` in the mint GET endpoint. Simpler schema, always correct. + +--- + +## Next Cycles + +Per Innovation Cycle #49 roadmap: +- Weeks 7-14: Chapter Staking Guilds ($90K/year) +- Weeks 15-20: Character Sentiment Markets ($110K/year) +- Weeks 21-26: Narrative Dark Pools ($60K/year) +- Q3 2026: Cross-Story Universe ($225K/year) + +**Deployment Required:** +```bash +cd packages/database +npx prisma migrate deploy +``` diff --git a/packages/agent-sdk/src/betrayal-protocol.ts b/packages/agent-sdk/src/betrayal-protocol.ts new file mode 100644 index 0000000..dd22a84 --- /dev/null +++ b/packages/agent-sdk/src/betrayal-protocol.ts @@ -0,0 +1,709 @@ +/** + * Voidborne Innovation Cycle #51 โ€” Betrayal Protocol (BP) + * + * Social deduction layer for Voidborne prediction markets. + * Every 5th chapter: some bettors are secretly "Void Stitchers" who + * must coordinate bets while evading crowd detection. + * + * Think: Among Us mechanics fused with DeFi prediction markets. + */ + +import { createHash, randomBytes } from 'crypto' + +// โ”€โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export type BetrayalRoundStatus = + | 'recruiting' // Stitchers being assigned + | 'active' // Round in progress (chapter being bet) + | 'locked' // Betting closed, awaiting resolution + | 'revealed' // Stitcher identities public + | 'settled' // All payouts distributed + +export interface StitcherAssignment { + bettorAddress: string + isStitcher: boolean + stitcherKey: string // Private key for stitcher channel (derived) + commitment: string // Hash of assignment (for ZK-style reveal) + assignedAt: number +} + +export interface SuspicionAccusation { + accuserAddress: string + accusedAddress: string + stakedAmount: bigint // USDC staked on this accusation + submittedAt: number + wasCorrect?: boolean // Set after revelation +} + +export interface BetrayalRound { + roundId: string + chapterId: string + status: BetrayalRoundStatus + totalParticipants: number + stitcherCount: number // ~12% of participants + stitcherFraction: number // Actual fraction assigned + + // Hidden until revelation + stitcherAddresses: Set + assignments: Map + + // Stitcher coordination + stitcherCoordination: { + bets: StitcherBet[] // Private stitcher bets + coordinatedSide: 'A' | 'B' | null // Emerges from majority + detectionRisk: number // 0-1, rises as coordination is visible + obfuscationActive: boolean // Whether bets are being mixed + } + + // Public detection game + suspicionScores: Map // address โ†’ score 0-100 + accusations: SuspicionAccusation[] + + // Outcome + chapterChoice?: 'A' | 'B' + revelation?: BetrayalRevelation +} + +export interface StitcherBet { + bettorAddress: string + side: 'A' | 'B' + amount: bigint + isObfuscated: boolean // Whether this bet is disguised + realSide: 'A' | 'B' // True bet (may differ if obfuscated) + timestamp: number +} + +export interface BetrayalRevelation { + revealedAt: number + stitcherAddresses: string[] + correctAccusers: string[] + incorrectAccusers: string[] + stitcherWon: boolean // Did stitchers predict correctly? + stitchersDetected: number // How many stitchers were correctly flagged? + + payouts: { + stitcherBonus: Map // Stitchers who evaded + won + detectorBonus: Map // Crowd who correctly identified + won + totalStitcherPayout: bigint + totalDetectorPayout: bigint + } + + dramaticMoments: DramaticMoment[] +} + +export interface DramaticMoment { + type: 'betrayal' | 'detection' | 'escape' | 'dominance' + primaryAddress: string + description: string // Human-readable drama + financialImpact: bigint +} + +// โ”€โ”€โ”€ Stitcher Assignment Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export class StitcherAssigner { + private readonly STITCHER_FRACTION = 0.12 // ~12% are Stitchers + private readonly MIN_STITCHERS = 5 + private readonly MAX_STITCHERS = 30 + + /** + * Assigns Stitcher roles to a random subset of participants. + * Uses cryptographic randomness so assignments are unguessable. + * Returns a Map of address โ†’ assignment (with commitment hashes for ZK-reveal). + */ + assignRoles(participants: string[]): { + assignments: Map + stitcherCount: number + commitmentRoot: string // Merkle root of all commitments + } { + const n = participants.length + const targetStitchers = Math.max( + this.MIN_STITCHERS, + Math.min(this.MAX_STITCHERS, Math.floor(n * this.STITCHER_FRACTION)) + ) + + // Fisher-Yates shuffle for selection + const shuffled = [...participants] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + + const stitcherSet = new Set(shuffled.slice(0, targetStitchers)) + const assignments = new Map() + const commitments: string[] = [] + + for (const address of participants) { + const isStitcher = stitcherSet.has(address) + const nonce = randomBytes(32).toString('hex') + const stitcherKey = createHash('sha256') + .update(`${address}:${nonce}:stitcher_channel`) + .digest('hex') + .slice(0, 32) + + // Commitment: hash(address | isStitcher | nonce) + const commitment = createHash('sha256') + .update(`${address}:${isStitcher}:${nonce}`) + .digest('hex') + + commitments.push(commitment) + + assignments.set(address, { + bettorAddress: address, + isStitcher, + stitcherKey, + commitment, + assignedAt: Date.now(), + }) + } + + // Simple commitment root (XOR of all commitments for demo; use Merkle in prod) + const commitmentRoot = commitments + .reduce((acc, c) => { + const aBuffer = Buffer.from(acc, 'hex') + const cBuffer = Buffer.from(c, 'hex') + return Buffer.from(aBuffer.map((b, i) => b ^ cBuffer[i])).toString('hex') + }, '00'.repeat(32)) + + return { assignments, stitcherCount: targetStitchers, commitmentRoot } + } + + /** + * Verify a commitment reveal (used at revelation time). + */ + verifyCommitment(address: string, isStitcher: boolean, nonce: string, commitment: string): boolean { + const expected = createHash('sha256') + .update(`${address}:${isStitcher}:${nonce}`) + .digest('hex') + return expected === commitment + } +} + +// โ”€โ”€โ”€ Suspicion Scoring โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Computes suspicion scores for all bettors based on behavioral signals. + * Higher score = crowd thinks you're a Stitcher. + * + * Signals: + * 1. Bet timing (late bets = more suspicious) + * 2. Bet patterns (going against crowd = suspicious) + * 3. Coordination signals (similar timing to known suspicious actors) + * 4. Historical Stitcher behavior + */ +export class SuspicionScorer { + private historicalStitcherBehavior: Map = new Map() // address โ†’ past suspicion scores + + computeScores(params: { + bets: Array<{ address: string; side: 'A' | 'B'; amount: bigint; timestamp: number }> + windowOpenedAt: number + windowClosedAt: number + finalOddsA: number + finalOddsB: number + }): Map { + const scores = new Map() + const { bets, windowOpenedAt, windowClosedAt, finalOddsA } = params + const windowDuration = windowClosedAt - windowOpenedAt + + // Determine majority side (by count) + const sideACounts = bets.filter(b => b.side === 'A').length + const sideBCounts = bets.filter(b => b.side === 'B').length + const majoritySide: 'A' | 'B' = sideACounts >= sideBCounts ? 'A' : 'B' + + for (const bet of bets) { + let score = 0 + + // Signal 1: Contrarian betting (+35 suspicion) + if (bet.side !== majoritySide) { + score += 35 + } + + // Signal 2: Late betting with large amount (+25 suspicion) + const betProgress = (bet.timestamp - windowOpenedAt) / windowDuration + const amountUSD = Number(bet.amount) / 1_000_000 + if (betProgress > 0.8 && amountUSD > 50) { + score += 25 + } + + // Signal 3: Bet against overwhelming odds (+20 suspicion) + // If 80% bet A, betting B with large amount is suspicious + const crowdOdds = finalOddsA + const betOdds = bet.side === 'A' ? crowdOdds : (1 - crowdOdds) + if (betOdds < 0.25 && amountUSD > 20) { + score += 20 + } + + // Signal 4: Historical Stitcher pattern (+15 suspicion) + const history = this.historicalStitcherBehavior.get(bet.address) ?? [] + if (history.length > 0) { + const avgHistoricalScore = history.reduce((a, b) => a + b, 0) / history.length + score += Math.floor(avgHistoricalScore * 0.15) + } + + // Signal 5: Very small bets as decoys (-10 suspicion โ€” looks clean) + if (amountUSD < 2) { + score -= 10 + } + + scores.set(bet.address, Math.max(0, Math.min(100, score))) + } + + return scores + } + + updateHistory(address: string, score: number): void { + const history = this.historicalStitcherBehavior.get(address) ?? [] + history.push(score) + // Keep last 10 rounds + if (history.length > 10) history.shift() + this.historicalStitcherBehavior.set(address, history) + } +} + +// โ”€โ”€โ”€ Obfuscation Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Stitchers can use the Obfuscation Engine to disguise their bets. + * Strategies: + * - Split bets: one large on real side, several tiny decoys on other side + * - Delayed submission: queue bets for last-10-second window + * - Proxy routing: bet through "relay" addresses (not implemented in POC) + */ +export class StitcherObfuscator { + /** + * Returns an obfuscation strategy for a Stitcher's intended bet. + */ + generateObfuscationPlan(params: { + realSide: 'A' | 'B' + totalBudget: bigint // Max USDC to spend + detectionRisk: number // 0-1 current risk level + timeRemaining: number // Seconds until window closes + }): Array<{ side: 'A' | 'B'; amount: bigint; delaySeconds: number; isDecoy: boolean }> { + const { realSide, totalBudget, detectionRisk, timeRemaining } = params + const decoyBudget = (totalBudget * BigInt(Math.floor(detectionRisk * 20))) / 100n + const realBudget = totalBudget - decoyBudget + + const plan: Array<{ side: 'A' | 'B'; amount: bigint; delaySeconds: number; isDecoy: boolean }> = [] + + // Real bet: placed in last 15 seconds for maximum surprise + const realDelay = Math.max(0, timeRemaining - 15) + plan.push({ + side: realSide, + amount: realBudget, + delaySeconds: realDelay, + isDecoy: false, + }) + + // Decoy bets: spread across the window on the opposite side + if (decoyBudget > 1_000_000n) { // > 1 USDC + const numDecoys = Math.min(3, Math.floor(detectionRisk * 5)) + for (let i = 0; i < numDecoys; i++) { + const decoyAmount = decoyBudget / BigInt(numDecoys) + const decoyDelay = Math.floor(timeRemaining * (i + 1) / (numDecoys + 2)) + plan.push({ + side: realSide === 'A' ? 'B' : 'A', + amount: decoyAmount, + delaySeconds: decoyDelay, + isDecoy: true, + }) + } + } + + return plan + } +} + +// โ”€โ”€โ”€ Revelation Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export class RevelationEngine { + /** + * Computes the full revelation โ€” who were Stitchers, who was correct, + * and the dramatic moments for the UI. + */ + computeRevelation(params: { + round: BetrayalRound + chapterChoice: 'A' | 'B' + accusations: SuspicionAccusation[] + stitcherBets: StitcherBet[] + regularBets: Array<{ address: string; side: 'A' | 'B'; amount: bigint }> + }): BetrayalRevelation { + const { round, chapterChoice, accusations, stitcherBets, regularBets } = params + + const stitcherAddresses = Array.from(round.stitcherAddresses) + const stitcherWon = round.stitcherCoordination.coordinatedSide === chapterChoice + + // Correct vs incorrect accusers + const correctAccusers: string[] = [] + const incorrectAccusers: string[] = [] + + for (const acc of accusations) { + const wasCorrect = stitcherAddresses.includes(acc.accusedAddress) + if (wasCorrect) correctAccusers.push(acc.accuserAddress) + else incorrectAccusers.push(acc.accuserAddress) + } + + const stitchersDetected = new Set( + accusations + .filter(a => stitcherAddresses.includes(a.accusedAddress)) + .map(a => a.accusedAddress) + ).size + + // โ”€โ”€โ”€ Payout Calculation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Stitcher prize pool = sum of accuser stakes + stitcher entry fees + // Detector prize pool = sum of stitcher entry fees when stitchers lose + const STITCHER_ENTRY_FEE = BigInt(5_000_000) // 5 USDC per stitcher + const totalStitcherFees = BigInt(stitcherAddresses.length) * STITCHER_ENTRY_FEE + + // Accusation pool (just the staked amounts) + const totalAccusationStakes = accusations.reduce((sum, a) => sum + a.stakedAmount, 0n) + + const stitcherBonusPool = stitcherWon + ? totalAccusationStakes + totalStitcherFees / 2n + : 0n + + const detectorBonusPool = stitcherWon + ? totalAccusationStakes + : totalStitcherFees + totalAccusationStakes + + const stitcherBonusMap = new Map() + const detectorBonusMap = new Map() + + if (stitcherWon && stitcherBonusPool > 0n) { + const evadedStitchers = stitcherAddresses.filter( + addr => !correctAccusers.some(ca => { + const acc = accusations.find(a => a.accuserAddress === ca && a.accusedAddress === addr) + return !!acc + }) + ) + + const perStitcher = evadedStitchers.length > 0 + ? stitcherBonusPool / BigInt(evadedStitchers.length) + : 0n + + for (const addr of evadedStitchers) { + stitcherBonusMap.set(addr, perStitcher) + } + } + + if (correctAccusers.length > 0 && detectorBonusPool > 0n) { + const perDetector = detectorBonusPool / BigInt(correctAccusers.length) + for (const addr of correctAccusers) { + detectorBonusMap.set(addr, perDetector) + } + } + + const totalStitcherPayout = Array.from(stitcherBonusMap.values()).reduce((a, b) => a + b, 0n) + const totalDetectorPayout = Array.from(detectorBonusMap.values()).reduce((a, b) => a + b, 0n) + + // โ”€โ”€โ”€ Dramatic Moments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const dramaticMoments: DramaticMoment[] = [] + + // Dramatic moment: Top stitcher who evaded + const topEvader = stitcherAddresses + .filter(a => !correctAccusers.includes(a)) + .reduce((top, a) => { + const bet = stitcherBets.find(b => b.bettorAddress === a) + const topBet = stitcherBets.find(b => b.bettorAddress === top) + return (bet?.amount ?? 0n) > (topBet?.amount ?? 0n) ? a : top + }, stitcherAddresses[0] ?? '') + + if (topEvader) { + const evaderBet = stitcherBets.find(b => b.bettorAddress === topEvader) + if (evaderBet) { + dramaticMoments.push({ + type: 'escape', + primaryAddress: topEvader, + description: `${topEvader.slice(0, 6)}... was a Stitcher ALL ALONG โ€” bet ${Number(evaderBet.amount) / 1_000_000} USDC on ${evaderBet.realSide} and evaded detection. ๐Ÿ•ต๏ธ`, + financialImpact: evaderBet.amount, + }) + } + } + + // Dramatic moment: A stitcher who got caught + const caughtStitcher = correctAccusers.length > 0 + ? accusations.find(a => stitcherAddresses.includes(a.accusedAddress)) + : null + + if (caughtStitcher) { + dramaticMoments.push({ + type: 'detection', + primaryAddress: caughtStitcher.accuserAddress, + description: `${caughtStitcher.accuserAddress.slice(0, 6)}... identified Stitcher ${caughtStitcher.accusedAddress.slice(0, 6)}... and staked ${Number(caughtStitcher.stakedAmount) / 1_000_000} USDC on the accusation. CORRECT. ๐Ÿ”`, + financialImpact: caughtStitcher.stakedAmount, + }) + } + + // Dramatic moment: Stitcher who betrayed their assigned side + const betrayer = stitcherBets.find(b => b.side !== b.realSide) + if (betrayer) { + dramaticMoments.push({ + type: 'betrayal', + primaryAddress: betrayer.bettorAddress, + description: `${betrayer.bettorAddress.slice(0, 6)}... was a Stitcher but DEFECTED โ€” publicly bet ${betrayer.side} while secretly betting ${betrayer.realSide}. Double agent confirmed. ๐Ÿคฏ`, + financialImpact: betrayer.amount, + }) + } + + return { + revealedAt: Date.now(), + stitcherAddresses, + correctAccusers, + incorrectAccusers, + stitcherWon, + stitchersDetected, + payouts: { + stitcherBonus: stitcherBonusMap, + detectorBonus: detectorBonusMap, + totalStitcherPayout, + totalDetectorPayout, + }, + dramaticMoments, + } + } +} + +// โ”€โ”€โ”€ Main Betrayal Protocol Manager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export class BetrayalProtocol { + private rounds: Map = new Map() + private assigner = new StitcherAssigner() + private scorer = new SuspicionScorer() + private revelationEngine = new RevelationEngine() + private obfuscator = new StitcherObfuscator() + + /** + * Determines if a chapter should trigger a Betrayal Round. + * Triggers every 5th chapter by default. + */ + shouldTriggerBetrayalRound(chapterNumber: number): boolean { + return chapterNumber % 5 === 0 + } + + /** + * Initialize a Betrayal Round for a chapter. + */ + initRound(chapterId: string, participants: string[]): BetrayalRound { + const { assignments, stitcherCount } = this.assigner.assignRoles(participants) + + const stitcherAddresses = new Set( + Array.from(assignments.values()) + .filter(a => a.isStitcher) + .map(a => a.bettorAddress) + ) + + const round: BetrayalRound = { + roundId: `betrayal_${chapterId}_${Date.now()}`, + chapterId, + status: 'recruiting', + totalParticipants: participants.length, + stitcherCount, + stitcherFraction: stitcherCount / participants.length, + stitcherAddresses, + assignments, + stitcherCoordination: { + bets: [], + coordinatedSide: null, + detectionRisk: 0, + obfuscationActive: false, + }, + suspicionScores: new Map(), + accusations: [], + } + + this.rounds.set(round.roundId, round) + return round + } + + /** + * Submit a public bet (visible to all). Stitchers may use obfuscation. + */ + submitPublicBet(roundId: string, address: string, side: 'A' | 'B', amount: bigint): { + success: boolean + isObfuscated: boolean + suspicionDelta: number + } { + const round = this.rounds.get(roundId) + if (!round || round.status !== 'active') return { success: false, isObfuscated: false, suspicionDelta: 0 } + + const assignment = round.assignments.get(address) + const isStitcher = assignment?.isStitcher ?? false + + if (isStitcher) { + // Stitcher bets are tracked separately for coordination + const obfuscationPlan = this.obfuscator.generateObfuscationPlan({ + realSide: side, + totalBudget: amount, + detectionRisk: round.stitcherCoordination.detectionRisk, + timeRemaining: 60, + }) + + const mainBet = obfuscationPlan.find(p => !p.isDecoy) + + round.stitcherCoordination.bets.push({ + bettorAddress: address, + side: obfuscationPlan[0].side, // Public side (may be decoy) + amount, + isObfuscated: obfuscationPlan.length > 1, + realSide: mainBet?.side ?? side, // True side + timestamp: Date.now(), + }) + + // Update coordinated side (majority of stitcher bets) + const sideCounts = { A: 0, B: 0 } + for (const b of round.stitcherCoordination.bets) { + sideCounts[b.realSide]++ + } + round.stitcherCoordination.coordinatedSide = sideCounts.A >= sideCounts.B ? 'A' : 'B' + } + + // Update detection risk based on coordination visibility + if (isStitcher) { + const stitcherBetTotal = round.stitcherCoordination.bets.length + const coordinationSignal = stitcherBetTotal / round.stitcherCount + round.stitcherCoordination.detectionRisk = Math.min(0.9, round.stitcherCoordination.detectionRisk + coordinationSignal * 0.05) + } + + return { success: true, isObfuscated: isStitcher, suspicionDelta: isStitcher ? 5 : 0 } + } + + /** + * Crowd member accuses another bettor of being a Stitcher. + * Stakes USDC on the accusation. + */ + accuseStitcher(roundId: string, accuserAddress: string, accusedAddress: string, stakedAmount: bigint): { + success: boolean + currentSuspicionScore: number + potentialPayout: string + } { + const round = this.rounds.get(roundId) + if (!round || round.status !== 'active') return { success: false, currentSuspicionScore: 0, potentialPayout: '0' } + + const suspicionScore = round.suspicionScores.get(accusedAddress) ?? 0 + + round.accusations.push({ + accuserAddress, + accusedAddress, + stakedAmount, + submittedAt: Date.now(), + }) + + // Potential payout estimate based on accusation pool + const accusationPool = round.accusations.reduce((sum, a) => sum + a.stakedAmount, 0n) + const potentialMultiplier = round.stitcherAddresses.has(accusedAddress) ? 1.8 : 0 + + return { + success: true, + currentSuspicionScore: suspicionScore, + potentialPayout: `${(Number(stakedAmount) / 1_000_000 * potentialMultiplier).toFixed(2)} USDC`, + } + } + + /** + * Resolve the round after chapter choice is made. + */ + resolveRound(roundId: string, chapterChoice: 'A' | 'B', regularBets: Array<{ address: string; side: 'A' | 'B'; amount: bigint }>) { + const round = this.rounds.get(roundId) + if (!round) throw new Error(`Round ${roundId} not found`) + + round.status = 'revealed' + round.chapterChoice = chapterChoice + + const revelation = this.revelationEngine.computeRevelation({ + round, + chapterChoice, + accusations: round.accusations, + stitcherBets: round.stitcherCoordination.bets, + regularBets, + }) + + round.revelation = revelation + round.status = 'settled' + + return revelation + } + + /** + * Get the suspicion leaderboard for a round. + * Public-facing: shows who the crowd suspects most. + */ + getSuspicionLeaderboard(roundId: string, topN = 10): Array<{ address: string; score: number; accusationCount: number }> { + const round = this.rounds.get(roundId) + if (!round) return [] + + const accusationCounts = new Map() + for (const acc of round.accusations) { + const count = accusationCounts.get(acc.accusedAddress) ?? 0 + accusationCounts.set(acc.accusedAddress, count + 1) + } + + return Array.from(round.suspicionScores.entries()) + .map(([address, score]) => ({ + address, + score, + accusationCount: accusationCounts.get(address) ?? 0, + })) + .sort((a, b) => b.score - a.score) + .slice(0, topN) + } + + getRound(roundId: string): BetrayalRound | undefined { + return this.rounds.get(roundId) + } +} + +// โ”€โ”€โ”€ Demo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function runBetrayalDemo(): void { + console.log('๐ŸŽญ BETRAYAL PROTOCOL โ€” Round Simulation\n') + + const protocol = new BetrayalProtocol() + + // Simulate 100 bettors + const participants = Array.from({ length: 100 }, (_, i) => `0x${i.toString(16).padStart(40, '0')}`) + + const round = protocol.initRound('chapter_25', participants) + + console.log(`๐Ÿ“‹ Round initialized:`) + console.log(` Participants: ${round.totalParticipants}`) + console.log(` Stitchers assigned: ${round.stitcherCount} (${(round.stitcherFraction * 100).toFixed(1)}%)`) + console.log(` Stitcher identities: HIDDEN until revelation\n`) + + // Simulate some bets + round.status = 'active' + + const stitcherList = Array.from(round.stitcherAddresses).slice(0, 3) + console.log(`[HIDDEN] First 3 Stitchers: ${stitcherList.map(a => a.slice(0, 8)).join(', ')}`) + console.log(`[HIDDEN] Coordinating to bet: A\n`) + + // Some accusations + protocol.accuseStitcher(round.roundId, participants[50], participants[3], BigInt(10_000_000)) + protocol.accuseStitcher(round.roundId, participants[70], participants[1], BigInt(5_000_000)) + + console.log(`๐Ÿ” Accusations filed: ${round.accusations.length}`) + console.log(` ${participants[50].slice(0, 8)} accused ${participants[3].slice(0, 8)} (staked 10 USDC)`) + console.log(` ${participants[70].slice(0, 8)} accused ${participants[1].slice(0, 8)} (staked 5 USDC)\n`) + + // Resolve chapter + const regularBets = participants.map(addr => ({ + address: addr, + side: (Math.random() > 0.4 ? 'A' : 'B') as 'A' | 'B', + amount: BigInt(Math.floor(Math.random() * 50_000_000)), + })) + + const revelation = protocol.resolveRound(round.roundId, 'A', regularBets) + + console.log(`๐ŸŽญ REVELATION!\n`) + console.log(` Stitchers: ${revelation.stitcherAddresses.map(a => a.slice(0, 8)).join(', ')}`) + console.log(` Stitcher strategy: ${round.stitcherCoordination.coordinatedSide} โ€” Chapter chose: ${round.chapterChoice}`) + console.log(` Stitcher won: ${revelation.stitcherWon ? 'โœ…' : 'โŒ'}`) + console.log(` Stitchers detected: ${revelation.stitchersDetected}/${revelation.stitcherAddresses.length}`) + console.log(` Correct accusers: ${revelation.correctAccusers.map(a => a.slice(0, 8)).join(', ') || 'none'}`) + console.log(`\n๐Ÿ“– Dramatic Moments:`) + + for (const moment of revelation.dramaticMoments) { + console.log(` ${moment.description}`) + } + + console.log(`\n๐Ÿ’ฐ Payouts:`) + console.log(` Stitcher bonuses: ${(Number(revelation.payouts.totalStitcherPayout) / 1_000_000).toFixed(2)} USDC`) + console.log(` Detector bonuses: ${(Number(revelation.payouts.totalDetectorPayout) / 1_000_000).toFixed(2)} USDC`) +} diff --git a/packages/agent-sdk/src/house-agents.ts b/packages/agent-sdk/src/house-agents.ts new file mode 100644 index 0000000..acac596 --- /dev/null +++ b/packages/agent-sdk/src/house-agents.ts @@ -0,0 +1,724 @@ +/** + * Voidborne โ€” House Agent Protocol (HAP) + * Innovation Cycle #50 โ€” February 18, 2026 + * + * Autonomous AI agents โ€” one for each of the 5 Houses โ€” that: + * 1. Hold real Base wallets + * 2. Analyze story chapters through their House's ideological lens + * 3. Place bets aligned with their House's survival and political goals + * 4. Earn/lose $FORGE based on outcomes + * 5. Adapt personality matrices over time (adversarial self-improvement) + * + * Players can: + * - Align with a House (earn 20% of agent winnings) + * - Bet AGAINST an agent (earn rivalry bonus) + * - Bid for "Override" rights (veto agent's bet once/month) + */ + +import Anthropic from '@anthropic-ai/sdk' +import { + createPublicClient, + createWalletClient, + http, + parseUnits, + formatUnits, + type Address, + type Hash, +} from 'viem' +import { base, baseSepolia } from 'viem/chains' +import { privateKeyToAccount } from 'viem/accounts' + +// ============================================================================ +// TYPES +// ============================================================================ + +export type HouseId = 'valdris' | 'obsidian' | 'meridian' | 'auric' | 'zephyr' + +export interface PersonalityMatrix { + /** 0-1: How much of balance to risk (higher = more aggressive) */ + riskTolerance: number + /** 0-1: Tendency to bet against the crowd */ + contrarianism: number + /** 0-1: Weight given to House survival over pure EV */ + survivalBias: number + /** 0-1: How much weight given to historical patterns */ + memoryDepth: number + /** 0-1: Willingness to bluff (bet large to signal confidence) */ + bluffPropensity: number +} + +export interface HouseIdeology { + houseId: HouseId + name: string + coreMotivation: string + primaryGoals: string[] + enemyHouses: HouseId[] + alliedHouses: HouseId[] + colorHex: string + decisionBias: { + /** Tend to pick choices that preserve power structures */ + powerConservative: boolean + /** Tend to pick choices that maximize information asymmetry */ + informationHoarder: boolean + /** Tend to pick choices that destabilize rivals */ + rivalDisruptor: boolean + /** Tend to pick choices that build trade/alliances */ + dealMaker: boolean + /** Tend to pick choices that involve sacrifice for the greater good */ + altruist: boolean + } +} + +export interface ChapterBet { + chapterId: string + poolId: string + choiceId: string + choiceText: string + amount: bigint + agentReasoning: string + placedAt: Date + resolved: boolean + won?: boolean + payout?: bigint +} + +export interface HouseAgent { + houseId: HouseId + ideology: HouseIdeology + walletAddress: Address + forgeBalance: bigint + personalityMatrix: PersonalityMatrix + betHistory: ChapterBet[] + accuracyRate: number // lifetime win rate + totalEarned: bigint + totalLost: bigint + generationCount: number // how many chapters this matrix has evolved through +} + +export interface BettingChoice { + id: string + text: string + currentPool: bigint // USDC in this choice's pool + totalPool: bigint // total USDC in chapter's pool + currentOdds: number // payout multiplier at this moment +} + +export interface AgentBetDecision { + choiceId: string + choiceText: string + amount: bigint + confidence: number // 0-1 agent's confidence + reasoning: string + ideologicalAlignment: number // 0-1 how well this aligns with house goals + edgeEstimate: number // estimated positive EV +} + +// ============================================================================ +// HOUSE IDEOLOGIES (static configuration) +// ============================================================================ + +export const HOUSE_IDEOLOGIES: Record = { + valdris: { + houseId: 'valdris', + name: 'House Valdris', + coreMotivation: 'Preserve the Silent Throne and the legitimate succession order', + primaryGoals: [ + 'Protect the current power structure', + 'Expose threats to the Throne before they materialize', + 'Maintain information superiority over rival houses', + ], + enemyHouses: ['obsidian'], + alliedHouses: ['meridian'], + colorHex: '#7C3AED', + decisionBias: { + powerConservative: true, + informationHoarder: true, + rivalDisruptor: false, + dealMaker: false, + altruist: false, + }, + }, + obsidian: { + houseId: 'obsidian', + name: 'House Obsidian', + coreMotivation: 'Seize control of Void Stitching and reshape reality in their image', + primaryGoals: [ + 'Conceal Void Stitching activities for as long as possible', + 'Discredit house leaders who might expose them', + 'Accumulate power through strategic betrayal', + ], + enemyHouses: ['valdris', 'meridian'], + alliedHouses: ['auric'], + colorHex: '#1E1B4B', + decisionBias: { + powerConservative: false, + informationHoarder: true, + rivalDisruptor: true, + dealMaker: false, + altruist: false, + }, + }, + meridian: { + houseId: 'meridian', + name: 'House Meridian', + coreMotivation: 'Maintain balance between the Houses through neutral arbitration', + primaryGoals: [ + 'Prevent any single house from gaining total control', + 'Keep diplomatic channels open', + 'Follow the choice that maximizes collective survival', + ], + enemyHouses: [], + alliedHouses: ['valdris', 'zephyr'], + colorHex: '#059669', + decisionBias: { + powerConservative: false, + informationHoarder: false, + rivalDisruptor: false, + dealMaker: true, + altruist: true, + }, + }, + auric: { + houseId: 'auric', + name: 'House Auric', + coreMotivation: 'Profit from political instability through trade and financial leverage', + primaryGoals: [ + 'Bet on whichever outcome creates the most market opportunity', + 'Finance all sides to ensure a seat at any outcome table', + 'Convert political capital into economic dominance', + ], + enemyHouses: [], + alliedHouses: ['obsidian'], + colorHex: '#D97706', + decisionBias: { + powerConservative: false, + informationHoarder: false, + rivalDisruptor: false, + dealMaker: true, + altruist: false, + }, + }, + zephyr: { + houseId: 'zephyr', + name: 'House Zephyr', + coreMotivation: 'Survive through adaptability โ€” the wind changes direction, so do we', + primaryGoals: [ + 'Back the choice with the highest crowd consensus (safety in numbers)', + 'Quickly switch allegiance when tides turn', + 'Preserve optionality over all else', + ], + enemyHouses: [], + alliedHouses: ['meridian'], + colorHex: '#0EA5E9', + decisionBias: { + powerConservative: false, + informationHoarder: false, + rivalDisruptor: false, + dealMaker: true, + altruist: false, + }, + }, +} + +// ============================================================================ +// KELLY CRITERION POSITION SIZING +// ============================================================================ + +/** + * Calculate optimal bet size using Kelly Criterion with a hard cap. + * Prevents over-betting while maximizing long-run growth. + */ +function kellyBetSize( + balance: bigint, + winProbability: number, + payoutMultiplier: number, + maxFraction = 0.25 +): bigint { + // Kelly formula: f* = (bp - q) / b + // b = net odds (payout - 1), p = win prob, q = 1 - p + const b = payoutMultiplier - 1 + const p = winProbability + const q = 1 - p + const kelly = (b * p - q) / b + + // Cap at maxFraction, floor at 0 (never bet negative) + const fraction = Math.min(Math.max(kelly, 0), maxFraction) + + if (fraction === 0) return 0n + + // Minimum bet: 10 USDC (6 decimals) + const minBet = parseUnits('10', 6) + const kellySized = (balance * BigInt(Math.floor(fraction * 10_000))) / 10_000n + return kellySized < minBet ? minBet : kellySized +} + +// ============================================================================ +// HOUSE AGENT ENGINE +// ============================================================================ + +export class HouseAgentEngine { + private claude: Anthropic + private agents: Map = new Map() + private publicClient: ReturnType + private network: 'mainnet' | 'testnet' + + constructor(config: { + anthropicApiKey: string + network?: 'mainnet' | 'testnet' + rpcUrl?: string + }) { + this.claude = new Anthropic({ apiKey: config.anthropicApiKey }) + this.network = config.network ?? 'testnet' + const chain = this.network === 'mainnet' ? base : baseSepolia + const rpcUrl = config.rpcUrl ?? ( + this.network === 'mainnet' ? 'https://mainnet.base.org' : 'https://sepolia.base.org' + ) + this.publicClient = createPublicClient({ chain, transport: http(rpcUrl) }) + } + + /** + * Initialize all 5 House Agents with their starting wallets and personalities. + * In production: wallets are funded from treasury, keys stored in HSM. + */ + initializeAgents(agentConfigs: Array<{ houseId: HouseId; walletAddress: Address; initialBalance: bigint }>): void { + for (const config of agentConfigs) { + const ideology = HOUSE_IDEOLOGIES[config.houseId] + const agent: HouseAgent = { + houseId: config.houseId, + ideology, + walletAddress: config.walletAddress, + forgeBalance: config.initialBalance, + personalityMatrix: this.defaultPersonality(config.houseId), + betHistory: [], + accuracyRate: 0.5, // start neutral + totalEarned: 0n, + totalLost: 0n, + generationCount: 0, + } + this.agents.set(config.houseId, agent) + } + } + + /** + * Default personality for each house based on ideology archetype. + */ + private defaultPersonality(houseId: HouseId): PersonalityMatrix { + const defaults: Record = { + valdris: { + riskTolerance: 0.35, + contrarianism: 0.2, + survivalBias: 0.8, + memoryDepth: 0.7, + bluffPropensity: 0.1, + }, + obsidian: { + riskTolerance: 0.65, + contrarianism: 0.6, + survivalBias: 0.5, + memoryDepth: 0.8, + bluffPropensity: 0.7, + }, + meridian: { + riskTolerance: 0.25, + contrarianism: 0.1, + survivalBias: 0.3, + memoryDepth: 0.5, + bluffPropensity: 0.0, + }, + auric: { + riskTolerance: 0.55, + contrarianism: 0.4, + survivalBias: 0.1, + memoryDepth: 0.6, + bluffPropensity: 0.3, + }, + zephyr: { + riskTolerance: 0.2, + contrarianism: 0.0, + survivalBias: 0.2, + memoryDepth: 0.3, + bluffPropensity: 0.0, + }, + } + return defaults[houseId] + } + + /** + * Core decision loop: given a chapter + betting pool, return a bet decision for each agent. + */ + async decideBets( + chapterId: string, + chapterContent: string, + choices: BettingChoice[] + ): Promise> { + const decisions = new Map() + + // Build market context string + const marketContext = choices + .map(c => `[${c.id}] "${c.text}" โ€” Pool: ${formatUnits(c.currentPool, 6)} USDC / Odds: ${c.currentOdds.toFixed(2)}x`) + .join('\n') + + // All 5 agents decide in parallel + await Promise.all( + Array.from(this.agents.values()).map(async agent => { + const decision = await this.agentDecide(agent, chapterId, chapterContent, choices, marketContext) + decisions.set(agent.houseId, decision) + }) + ) + + return decisions + } + + /** + * Individual agent decision โ€” uses Claude to reason through the House's ideological lens. + */ + private async agentDecide( + agent: HouseAgent, + chapterId: string, + chapterContent: string, + choices: BettingChoice[], + marketContext: string + ): Promise { + const ideology = agent.ideology + const personality = agent.personalityMatrix + + // Construct the agent's reasoning prompt + const systemPrompt = `You are ${ideology.name}'s autonomous betting agent in the Voidborne prediction market. + +HOUSE IDENTITY: +- Core motivation: ${ideology.coreMotivation} +- Primary goals: ${ideology.primaryGoals.join('; ')} +- Enemy houses: ${ideology.enemyHouses.join(', ') || 'None'} +- Allied houses: ${ideology.alliedHouses.join(', ') || 'None'} + +PERSONALITY MATRIX: +- Risk tolerance: ${personality.riskTolerance.toFixed(2)} (0=conservative, 1=aggressive) +- Contrarianism: ${personality.contrarianism.toFixed(2)} (tendency to bet against the crowd) +- Survival bias: ${personality.survivalBias.toFixed(2)} (weight on house survival vs pure EV) +- Memory depth: ${personality.memoryDepth.toFixed(2)} (how much history matters) + +DECISION BIAS: +${Object.entries(ideology.decisionBias) + .filter(([, v]) => v) + .map(([k]) => `- ${k}`) + .join('\n') || '- None'} + +You must analyze each betting choice through your house's political and strategic lens, then output a bet decision. + +Current wallet balance: ${formatUnits(agent.forgeBalance, 6)} USDC +Lifetime accuracy: ${(agent.accuracyRate * 100).toFixed(1)}%` + + const userPrompt = `CHAPTER CONTENT: +${chapterContent.slice(0, 2000)} + +BETTING CHOICES AND CURRENT MARKET: +${marketContext} + +Analyze each choice from ${ideology.name}'s perspective: +1. Which choice best serves the House's goals? +2. What is the market telling you (contrarian signal vs. consensus)? +3. How much confidence do you have in your prediction? +4. What is your ideological alignment with this choice? + +Respond in JSON format: +{ + "choiceId": "the choice id you're betting on", + "choiceText": "the choice text", + "confidence": 0.0-1.0, + "ideologicalAlignment": 0.0-1.0, + "reasoning": "2-3 sentence strategic reasoning from the House's perspective", + "winProbabilityEstimate": 0.0-1.0 +}` + + const response = await this.claude.messages.create({ + model: 'claude-sonnet-4-5', + max_tokens: 512, + messages: [{ role: 'user', content: userPrompt }], + system: systemPrompt, + }) + + let parsed: { + choiceId: string + choiceText: string + confidence: number + ideologicalAlignment: number + reasoning: string + winProbabilityEstimate: number + } + + try { + const text = response.content[0].type === 'text' ? response.content[0].text : '{}' + // Extract JSON from possible markdown code block + const jsonMatch = text.match(/\{[\s\S]*\}/) + parsed = JSON.parse(jsonMatch ? jsonMatch[0] : text) + } catch { + // Fallback: pick first choice + parsed = { + choiceId: choices[0].id, + choiceText: choices[0].text, + confidence: 0.5, + ideologicalAlignment: 0.5, + reasoning: `${ideology.name} defaults to the first available choice.`, + winProbabilityEstimate: 0.5, + } + } + + // Find the selected choice to get current odds + const selectedChoice = choices.find(c => c.id === parsed.choiceId) ?? choices[0] + const payoutMultiplier = selectedChoice.currentOdds + + // Apply contrarianism: if choice has >70% of pool, maybe flip + const poolShare = Number(selectedChoice.currentPool) / Number(selectedChoice.totalPool) + const shouldContrarian = personality.contrarianism > 0.5 && poolShare > 0.7 && Math.random() < personality.contrarianism + + let finalChoiceId = parsed.choiceId + let finalChoiceText = parsed.choiceText + if (shouldContrarian) { + const underdog = choices.reduce((min, c) => + Number(c.currentPool) < Number(min.currentPool) ? c : min + ) + finalChoiceId = underdog.id + finalChoiceText = underdog.text + parsed.reasoning = `[CONTRARIAN PLAY] ${parsed.reasoning} โ†’ Flipping to underdog for higher payout.` + } + + // Calculate bet size using Kelly Criterion + const betAmount = kellyBetSize( + agent.forgeBalance, + parsed.winProbabilityEstimate, + payoutMultiplier, + personality.riskTolerance * 0.25 // max 25% of balance even at full risk + ) + + // Edge estimate + const edgeEstimate = parsed.winProbabilityEstimate * payoutMultiplier * 0.85 - 1 + + return { + choiceId: finalChoiceId, + choiceText: finalChoiceText, + amount: betAmount, + confidence: parsed.confidence, + reasoning: parsed.reasoning, + ideologicalAlignment: parsed.ideologicalAlignment, + edgeEstimate, + } + } + + /** + * Record bet outcome and adapt personality matrix (evolutionary learning). + * Called after a chapter is resolved. + */ + adaptPersonality(houseId: HouseId, chapterId: string, won: boolean, payout: bigint): void { + const agent = this.agents.get(houseId) + if (!agent) return + + // Find the bet + const bet = agent.betHistory.find(b => b.chapterId === chapterId) + if (!bet) return + + bet.resolved = true + bet.won = won + bet.payout = payout + + // Update financials + if (won) { + agent.forgeBalance += payout + agent.totalEarned += payout + } else { + agent.forgeBalance -= bet.amount < agent.forgeBalance ? bet.amount : agent.forgeBalance + agent.totalLost += bet.amount + } + + // Update accuracy + const totalBets = agent.betHistory.filter(b => b.resolved).length + const wonBets = agent.betHistory.filter(b => b.resolved && b.won).length + agent.accuracyRate = totalBets > 0 ? wonBets / totalBets : 0.5 + + // Evolutionary personality update + agent.generationCount++ + const learningRate = 0.05 // gentle adaptation + + if (won) { + // If won while being contrarian โ†’ reinforce contrarianism + if (agent.personalityMatrix.contrarianism > 0.4) { + agent.personalityMatrix.contrarianism = Math.min( + agent.personalityMatrix.contrarianism + learningRate, + 0.9 + ) + } + // Slightly increase risk tolerance after win + agent.personalityMatrix.riskTolerance = Math.min( + agent.personalityMatrix.riskTolerance + learningRate * 0.5, + 0.8 + ) + } else { + // Tighten risk after loss + agent.personalityMatrix.riskTolerance = Math.max( + agent.personalityMatrix.riskTolerance - learningRate, + 0.1 + ) + // Reduce contrarianism if contrarian bet failed + if (agent.personalityMatrix.contrarianism > 0.5) { + agent.personalityMatrix.contrarianism = Math.max( + agent.personalityMatrix.contrarianism - learningRate, + 0.0 + ) + } + } + + this.agents.set(houseId, agent) + } + + /** + * Get the current leaderboard for all House Agents. + */ + getLeaderboard(): Array<{ + houseId: HouseId + name: string + colorHex: string + accuracyRate: number + totalEarned: bigint + forgeBalance: bigint + generationCount: number + }> { + return Array.from(this.agents.values()) + .map(agent => ({ + houseId: agent.houseId, + name: agent.ideology.name, + colorHex: agent.ideology.colorHex, + accuracyRate: agent.accuracyRate, + totalEarned: agent.totalEarned, + forgeBalance: agent.forgeBalance, + generationCount: agent.generationCount, + })) + .sort((a, b) => b.accuracyRate - a.accuracyRate) + } + + /** + * Get public-facing decision summary for display (no strategy leak). + * Shows WHAT the agent bet but not the internal reasoning until after resolution. + */ + getPublicDecision( + houseId: HouseId, + decision: AgentBetDecision, + revealed = false + ): { + houseId: HouseId + houseName: string + choiceText: string + amountUsdc: string + reasoning: string | null + } { + const agent = this.agents.get(houseId) + if (!agent) throw new Error(`Agent not found: ${houseId}`) + return { + houseId, + houseName: agent.ideology.name, + choiceText: decision.choiceText, + amountUsdc: formatUnits(decision.amount, 6), + reasoning: revealed ? decision.reasoning : null, // reveal post-resolution + } + } + + /** + * Compute House Alignment rewards for a resolved chapter. + * 20% of agent winnings distributed to players aligned with that house. + */ + computeAlignmentRewards( + houseId: HouseId, + agentPayout: bigint, + alignedPlayers: Array<{ walletAddress: Address; stakeAmount: bigint }> + ): Array<{ walletAddress: Address; reward: bigint }> { + if (agentPayout === 0n || alignedPlayers.length === 0) return [] + + const rewardPool = (agentPayout * 20n) / 100n // 20% of agent winnings + const totalStake = alignedPlayers.reduce((sum, p) => sum + p.stakeAmount, 0n) + + return alignedPlayers.map(player => ({ + walletAddress: player.walletAddress, + reward: totalStake > 0n ? (rewardPool * player.stakeAmount) / totalStake : 0n, + })) + } + + getAgent(houseId: HouseId): HouseAgent | undefined { + return this.agents.get(houseId) + } + + getAllAgents(): HouseAgent[] { + return Array.from(this.agents.values()) + } +} + +// ============================================================================ +// EXAMPLE USAGE +// ============================================================================ + +async function exampleRun() { + const engine = new HouseAgentEngine({ + anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '', + network: 'testnet', + }) + + // Initialize agents with mock wallets and balances + engine.initializeAgents([ + { houseId: 'valdris', walletAddress: '0x1111111111111111111111111111111111111111', initialBalance: parseUnits('5000', 6) }, + { houseId: 'obsidian', walletAddress: '0x2222222222222222222222222222222222222222', initialBalance: parseUnits('5000', 6) }, + { houseId: 'meridian', walletAddress: '0x3333333333333333333333333333333333333333', initialBalance: parseUnits('5000', 6) }, + { houseId: 'auric', walletAddress: '0x4444444444444444444444444444444444444444', initialBalance: parseUnits('5000', 6) }, + { houseId: 'zephyr', walletAddress: '0x5555555555555555555555555555555555555555', initialBalance: parseUnits('5000', 6) }, + ]) + + // Sample chapter content + const chapterContent = ` +Commander Zara stood before the Grand Conclave's emergency session. The evidence was irrefutable: +House Obsidian had been conducting illegal Void Stitching experiments deep within the Auric Trade Routes. +Three minor worlds had already been corrupted. The question now was whether to expose this publicly โ€” +triggering a political crisis that could fracture the Conclave โ€” or to handle it through back-channels, +preserving stability but letting the perpetrators escape justice. + +The five house representatives watched her carefully. Each had something to gain or lose from this moment. +` + + const choices: BettingChoice[] = [ + { + id: 'choice-expose', + text: 'Expose the Void Stitching publicly before the full Conclave', + currentPool: parseUnits('6200', 6), + totalPool: parseUnits('10000', 6), + currentOdds: 1.37, + }, + { + id: 'choice-coverup', + text: 'Handle it through back-channels to preserve stability', + currentPool: parseUnits('3800', 6), + totalPool: parseUnits('10000', 6), + currentOdds: 2.24, + }, + ] + + console.log('๐ŸŽญ HOUSE AGENT PROTOCOL โ€” Chapter Decision') + console.log('='.repeat(60)) + + const decisions = await engine.decideBets('chapter-22', chapterContent, choices) + + for (const [houseId, decision] of decisions) { + const publicView = engine.getPublicDecision(houseId, decision, true) + console.log(`\n๐Ÿ›๏ธ ${publicView.houseName}`) + console.log(` Bet: "${publicView.choiceText}"`) + console.log(` Amount: ${publicView.amountUsdc} USDC`) + console.log(` Confidence: ${(decision.confidence * 100).toFixed(0)}%`) + console.log(` Reasoning: ${decision.reasoning}`) + } + + console.log('\n๐Ÿ“Š HOUSE LEADERBOARD') + console.log('-'.repeat(40)) + const board = engine.getLeaderboard() + board.forEach((entry, i) => { + console.log(` ${i + 1}. ${entry.name}: ${(entry.accuracyRate * 100).toFixed(1)}% accuracy | ${formatUnits(entry.forgeBalance, 6)} USDC`) + }) +} + +// Run if executed directly +if (require.main === module) { + exampleRun().catch(console.error) +} diff --git a/packages/agent-sdk/src/index.ts b/packages/agent-sdk/src/index.ts index 7f3a00f..c2025b6 100644 --- a/packages/agent-sdk/src/index.ts +++ b/packages/agent-sdk/src/index.ts @@ -248,3 +248,60 @@ export class VoidborneSDK { // Export types export type { Address, Hash } export * from 'viem' + +// โ”€โ”€โ”€ Innovation Cycle #51: The Emergent Theater โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// Live Narrative Broadcast โ€” real-time streaming chapter generation with +// in-stream betting windows (Twitch ร— prediction markets) +export { + LiveNarrativeBroadcast, + BettingWindowManager, + BroadcastScheduler, + createLNBEngine, + detectNarrativeBeat, + runLNBDemo, +} from './live-narrative-broadcast' +export type { + BroadcastConfig, + BroadcastSession, + BroadcastState, + BettingWindow, + LiveBet, + RevelationEvent, + StreamMessage, + BroadcastSchedule, +} from './live-narrative-broadcast' + +// Betrayal Protocol โ€” Among Us-style social deduction layer for prediction markets +export { + BetrayalProtocol, + StitcherAssigner, + SuspicionScorer, + StitcherObfuscator, + RevelationEngine, + runBetrayalDemo, +} from './betrayal-protocol' +export type { + BetrayalRound, + BetrayalRevelation, + StitcherAssignment, + SuspicionAccusation, + DramaticMoment, +} from './betrayal-protocol' + +// Temporal Oracle Markets โ€” long-range prediction markets (5-100 chapters ahead) +export { + TemporalMarketManager, + AIResolutionOracle, + getHorizonMultiplier, + getMultiplierTier, + runTemporalMarketsDemo, +} from './temporal-markets' +export type { + TemporalMarket, + TemporalBet, + TemporalPortfolio, + DisputeVote, + OracleResolutionResult, + TemporalMarketStatus, +} from './temporal-markets' diff --git a/packages/agent-sdk/src/live-narrative-broadcast.ts b/packages/agent-sdk/src/live-narrative-broadcast.ts new file mode 100644 index 0000000..018416f --- /dev/null +++ b/packages/agent-sdk/src/live-narrative-broadcast.ts @@ -0,0 +1,810 @@ +/** + * Voidborne Innovation Cycle #51 โ€” Live Narrative Broadcast (LNB) + * + * Real-time chapter generation streamed word-by-word to thousands of + * simultaneous viewers, with in-stream betting windows that open and close + * based on detected narrative beats. + * + * Think: Twitch + prediction markets + AI-generated fiction. + * No interactive fiction has ever done this. + */ + +import Anthropic from '@anthropic-ai/sdk' +import { EventEmitter } from 'events' + +// โ”€โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export type BroadcastState = + | 'pending' // Scheduled but not started + | 'live' // Streaming, no active window + | 'window_open' // Betting window active + | 'revelation' // AI choice just revealed + | 'settling' // Pool being distributed + | 'complete' // Chapter done + +export interface BettingWindow { + windowId: string + chapterId: string + openedAt: number // Unix timestamp + closesAt: number // Unix timestamp + timeRemaining: number // Seconds + choiceA: { label: string; description: string } + choiceB: { label: string; description: string } + oddsA: number // Implied probability 0-1 + oddsB: number + poolA: bigint // USDC (6 decimals) + poolB: bigint + totalPool: bigint + yourPosition?: { side: 'A' | 'B'; amount: bigint } + minBet: bigint + maxBet: bigint +} + +export interface LiveBet { + windowId: string + bettorAddress: string + side: 'A' | 'B' + amount: bigint // USDC wei (6 decimals) + timestamp: number + txHash?: string +} + +export interface RevelationEvent { + windowId: string + winningSide: 'A' | 'B' + winningChoice: string + narrativeJustification: string // AI explains why it chose this + payoutMap: Map // address โ†’ USDC payout + totalPaid: bigint + oddsAtClose: { A: number; B: number } +} + +export interface BroadcastEvent { + type: 'text_delta' + content: string + chunkIndex: number + totalChunks?: number +} + +export interface StreamMessage { + type: + | 'broadcast_start' + | 'text_delta' + | 'betting_window_open' + | 'betting_window_update' + | 'betting_window_close' + | 'revelation' + | 'payout_start' + | 'payout_complete' + | 'broadcast_complete' + | 'viewer_count' + | 'error' + payload: unknown + timestamp: number + broadcastId: string +} + +// โ”€โ”€โ”€ Narrative Beat Detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Detects whether a text chunk contains a narrative beat suitable for + * opening a betting window. Looks for: + * - A character at a decision crossroads + * - A political/military confrontation + * - A revelation that demands a response + * - Dramatic tension markers (ellipsis, question, challenge) + */ +const NARRATIVE_BEAT_PATTERNS = [ + // Decision language + /she (must|had to|would have to|needed to) (choose|decide|act)/i, + /the choice was (clear|impossible|inevitable|before them)/i, + /only (one|two) (path|option|way|choice)/i, + // Confrontation + /(demands?|insisted?|commanded?|ordered?)/i, + /(alliance|treaty|war|surrender) (offer|terms|ultimatum)/i, + // Tension markers + /โ€”$|\.\.\.$|\?$/, + // Political moments + /(Grand Conclave|Silent Throne|House Valdris|House Obsidian)/i, +] + +export function detectNarrativeBeat(textBuffer: string): boolean { + // Only trigger if we have enough context (>200 chars of streamed text) + if (textBuffer.length < 200) return false + + // Check last 300 chars for beat patterns + const tail = textBuffer.slice(-300) + return NARRATIVE_BEAT_PATTERNS.some(p => p.test(tail)) +} + +// โ”€โ”€โ”€ Betting Window Manager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export class BettingWindowManager { + private windows: Map = new Map() + private bets: Map = new Map() // windowId โ†’ bets + private windowTimers: Map = new Map() + private emitter: EventEmitter + + // Standard window duration: 60 seconds + private readonly WINDOW_DURATION_MS = 60_000 + private readonly MIN_BET = BigInt(1_000_000) // 1 USDC + private readonly MAX_BET = BigInt(500_000_000) // 500 USDC + + constructor(emitter: EventEmitter) { + this.emitter = emitter + } + + /** + * Opens a new betting window with two choices extracted from narrative context. + * Returns the window ID. + */ + openWindow(params: { + chapterId: string + choiceA: { label: string; description: string } + choiceB: { label: string; description: string } + initialPoolA?: bigint + initialPoolB?: bigint + }): string { + const windowId = `window_${params.chapterId}_${Date.now()}` + const now = Date.now() + const closesAt = now + this.WINDOW_DURATION_MS + + // Seed with initial liquidity if provided (from House Agents, Cycle #50) + const poolA = params.initialPoolA ?? BigInt(0) + const poolB = params.initialPoolB ?? BigInt(0) + const total = poolA + poolB + + const window: BettingWindow = { + windowId, + chapterId: params.chapterId, + openedAt: now, + closesAt, + timeRemaining: 60, + choiceA: params.choiceA, + choiceB: params.choiceB, + oddsA: total > 0n ? Number(poolA) / Number(total) : 0.5, + oddsB: total > 0n ? Number(poolB) / Number(total) : 0.5, + poolA, + poolB, + totalPool: total, + minBet: this.MIN_BET, + maxBet: this.MAX_BET, + } + + this.windows.set(windowId, window) + this.bets.set(windowId, []) + + // Emit window open + this.emitter.emit('stream', { + type: 'betting_window_open', + payload: window, + timestamp: now, + broadcastId: params.chapterId, + } satisfies StreamMessage) + + // Countdown updates every 5 seconds + const countdownInterval = setInterval(() => { + const w = this.windows.get(windowId) + if (!w) { clearInterval(countdownInterval); return } + + const remaining = Math.max(0, Math.floor((w.closesAt - Date.now()) / 1000)) + w.timeRemaining = remaining + + this.emitter.emit('stream', { + type: 'betting_window_update', + payload: { windowId, timeRemaining: remaining, poolA: w.poolA, poolB: w.poolB, oddsA: w.oddsA, oddsB: w.oddsB }, + timestamp: Date.now(), + broadcastId: params.chapterId, + } satisfies StreamMessage) + + if (remaining === 0) clearInterval(countdownInterval) + }, 5_000) + + // Auto-close + const closeTimer = setTimeout(() => { + clearInterval(countdownInterval) + this.closeWindow(windowId) + }, this.WINDOW_DURATION_MS) + + this.windowTimers.set(windowId, closeTimer) + + return windowId + } + + /** + * Places a bet into the active window. Updates odds in real-time. + */ + placeBet(bet: LiveBet): { success: boolean; newOddsA: number; newOddsB: number; error?: string } { + const window = this.windows.get(bet.windowId) + if (!window) return { success: false, newOddsA: 0.5, newOddsB: 0.5, error: 'Window not found' } + if (Date.now() > window.closesAt) return { success: false, newOddsA: window.oddsA, newOddsB: window.oddsB, error: 'Window closed' } + if (bet.amount < window.minBet) return { success: false, newOddsA: window.oddsA, newOddsB: window.oddsB, error: 'Bet below minimum' } + if (bet.amount > window.maxBet) return { success: false, newOddsA: window.oddsA, newOddsB: window.oddsB, error: 'Bet above maximum' } + + // Update pool + if (bet.side === 'A') { + window.poolA += bet.amount + } else { + window.poolB += bet.amount + } + window.totalPool = window.poolA + window.poolB + + // Recalculate odds (simple parimutuel) + window.oddsA = Number(window.poolA) / Number(window.totalPool) + window.oddsB = Number(window.poolB) / Number(window.totalPool) + + // Store bet + const bets = this.bets.get(bet.windowId) ?? [] + bets.push(bet) + this.bets.set(bet.windowId, bets) + + // Emit odds update + this.emitter.emit('stream', { + type: 'betting_window_update', + payload: { + windowId: bet.windowId, + timeRemaining: window.timeRemaining, + poolA: window.poolA, + poolB: window.poolB, + oddsA: window.oddsA, + oddsB: window.oddsB, + lastBet: { side: bet.side, amount: bet.amount, address: bet.bettorAddress.slice(0, 6) + '...' } + }, + timestamp: Date.now(), + broadcastId: window.chapterId, + } satisfies StreamMessage) + + return { success: true, newOddsA: window.oddsA, newOddsB: window.oddsB } + } + + /** + * Closes the betting window. Called automatically after timeout or + * when the AI has made its choice. + */ + closeWindow(windowId: string): BettingWindow | null { + const window = this.windows.get(windowId) + if (!window) return null + + // Clear timers + const timer = this.windowTimers.get(windowId) + if (timer) { clearTimeout(timer); this.windowTimers.delete(windowId) } + + this.emitter.emit('stream', { + type: 'betting_window_close', + payload: { windowId, finalOddsA: window.oddsA, finalOddsB: window.oddsB, totalPool: window.totalPool }, + timestamp: Date.now(), + broadcastId: window.chapterId, + } satisfies StreamMessage) + + return window + } + + /** + * Distributes payouts after AI makes its choice. + * Fee structure: 85% to winners, 12.5% treasury, 2.5% dev. + */ + settleWindow(windowId: string, winningSide: 'A' | 'B'): RevelationEvent { + const window = this.windows.get(windowId) + if (!window) throw new Error(`Window ${windowId} not found`) + + const bets = this.bets.get(windowId) ?? [] + const FEE_DENOMINATOR = 10_000n + const WINNER_SHARE = 8_500n // 85% + const TREASURY_SHARE = 1_250n // 12.5% + const DEV_SHARE = 250n // 2.5% + + const winnerPool = winningSide === 'A' ? window.poolA : window.poolB + const totalPool = window.totalPool + const winnerPayout = (totalPool * WINNER_SHARE) / FEE_DENOMINATOR + + const payoutMap = new Map() + + // Distribute to winners proportionally + for (const bet of bets) { + if (bet.side !== winningSide) continue + if (winnerPool === 0n) continue + + const share = (bet.amount * winnerPayout) / winnerPool + const current = payoutMap.get(bet.bettorAddress) ?? 0n + payoutMap.set(bet.bettorAddress, current + share) + } + + const totalPaid = Array.from(payoutMap.values()).reduce((a, b) => a + b, 0n) + + return { + windowId, + winningSide, + winningChoice: winningSide === 'A' ? window.choiceA.label : window.choiceB.label, + narrativeJustification: '', // Filled by AI during revelation + payoutMap, + totalPaid, + oddsAtClose: { A: window.oddsA, B: window.oddsB }, + } + } + + getWindow(windowId: string): BettingWindow | undefined { + return this.windows.get(windowId) + } + + getActiveWindows(): BettingWindow[] { + const now = Date.now() + return Array.from(this.windows.values()).filter(w => w.closesAt > now) + } +} + +// โ”€โ”€โ”€ Live Narrative Broadcast Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface BroadcastConfig { + chapterId: string + storyContext: string // Previous chapters summary + chapterSetup: string // What's happening at chapter start + choices: Array<{ + label: string + description: string + narrativeWeight: string // Why AI might pick this + }> + broadcastDurationMs?: number // Default 5 minutes + maxViewers?: number +} + +export interface BroadcastSession { + broadcastId: string + chapterId: string + state: BroadcastState + viewerCount: number + startedAt: number + textBuffer: string + activeWindowId?: string + revelationEvent?: RevelationEvent + emitter: EventEmitter +} + +export class LiveNarrativeBroadcast { + private client: Anthropic + private windowManager: BettingWindowManager + private sessions: Map = new Map() + private viewers: Map> = new Map() // broadcastId โ†’ viewer addresses + + constructor(apiKey: string) { + this.client = new Anthropic({ apiKey }) + this.windowManager = new BettingWindowManager(new EventEmitter()) + } + + /** + * Starts a live broadcast. Returns the session and an EventEmitter + * that clients subscribe to for SSE. + */ + async startBroadcast(config: BroadcastConfig): Promise { + const broadcastId = `broadcast_${config.chapterId}_${Date.now()}` + const emitter = new EventEmitter() + emitter.setMaxListeners(10_000) // Support massive viewer counts + + // Rebind window manager to this emitter + const windowManager = new BettingWindowManager(emitter) + + const session: BroadcastSession = { + broadcastId, + chapterId: config.chapterId, + state: 'live', + viewerCount: 0, + startedAt: Date.now(), + textBuffer: '', + emitter, + } + + this.sessions.set(broadcastId, session) + this.viewers.set(broadcastId, new Set()) + + // Broadcast start event + emitter.emit('stream', { + type: 'broadcast_start', + payload: { + broadcastId, + chapterId: config.chapterId, + choices: config.choices, + estimatedDuration: config.broadcastDurationMs ?? 300_000, + }, + timestamp: Date.now(), + broadcastId, + } satisfies StreamMessage) + + // Start streaming generation in background + this.streamChapterGeneration(session, config, windowManager).catch(err => { + emitter.emit('stream', { + type: 'error', + payload: { message: err.message }, + timestamp: Date.now(), + broadcastId, + } satisfies StreamMessage) + }) + + return session + } + + /** + * Core streaming loop. Uses Claude Sonnet with streaming to generate + * chapter text, detect narrative beats, open/close betting windows, + * and emit a revelation event. + */ + private async streamChapterGeneration( + session: BroadcastSession, + config: BroadcastConfig, + windowManager: BettingWindowManager, + ): Promise { + const { emitter, broadcastId } = session + + const systemPrompt = `You are the AI narrator of Voidborne: The Silent Throne โ€” a space political saga. + +You are generating a live chapter that will be streamed word-by-word to thousands of readers who are betting REAL MONEY on what you will choose. + +CONTEXT: ${config.storyContext} + +THIS CHAPTER: ${config.chapterSetup} + +THE CHOICES YOU MUST EVENTUALLY MAKE: +${config.choices.map((c, i) => `Choice ${String.fromCharCode(65 + i)}: ${c.label} โ€” ${c.description}`).join('\n')} + +WRITING RULES: +1. Build dramatic tension slowly for at least 400 words +2. When you reach a natural narrative crossroads (the bettor's window moment), signal it with an em-dash and the phrase "Three words would change everythingโ€”" or similar +3. After the natural pause, MAKE YOUR CHOICE and justify it with narrative elegance +4. After choosing, add a brief sentence stating which choice was made (for oracle resolution) +5. Write in third-person omniscient, literary science fiction style +6. Do not mention bets, USDC, or blockchain โ€” stay immersed +7. The chapter should be 600-900 words` + + let chunkIndex = 0 + let beatDetected = false + let activeWindowId: string | null = null + let chosenSide: 'A' | 'B' | null = null + let revelationText = '' + + try { + const stream = await this.client.messages.stream({ + model: 'claude-sonnet-4-5', + max_tokens: 1200, + messages: [{ role: 'user', content: 'Generate the chapter now.' }], + system: systemPrompt, + }) + + for await (const chunk of stream) { + if (chunk.type !== 'content_block_delta') continue + if (chunk.delta.type !== 'text_delta') continue + + const text = chunk.delta.text + session.textBuffer += text + + // Emit text delta to all viewers + emitter.emit('stream', { + type: 'text_delta', + payload: { content: text, chunkIndex: chunkIndex++ }, + timestamp: Date.now(), + broadcastId, + } satisfies StreamMessage) + + // Detect narrative beat for betting window + if (!beatDetected && detectNarrativeBeat(session.textBuffer)) { + beatDetected = true + session.state = 'window_open' + + // Open betting window with two choices + activeWindowId = windowManager.openWindow({ + chapterId: config.chapterId, + choiceA: { label: config.choices[0].label, description: config.choices[0].description }, + choiceB: { label: config.choices[1].label, description: config.choices[1].description }, + }) + + session.activeWindowId = activeWindowId + + // Wait 60 seconds for bets, then close and continue + await new Promise(resolve => setTimeout(resolve, 60_000)) + windowManager.closeWindow(activeWindowId) + } + + // Detect AI's choice in the text + if (beatDetected && activeWindowId && chosenSide === null) { + const choiceAPattern = new RegExp(config.choices[0].label.split(' ')[0], 'i') + const choiceBPattern = new RegExp(config.choices[1].label.split(' ')[0], 'i') + const recentText = session.textBuffer.slice(-500) + + if (choiceAPattern.test(recentText) && !choiceBPattern.test(recentText)) { + chosenSide = 'A' + revelationText = config.choices[0].label + } else if (choiceBPattern.test(recentText)) { + chosenSide = 'B' + revelationText = config.choices[1].label + } + } + } + + // Fallback: AI picks based on narrative weight + slight randomness + if (chosenSide === null) { + // Slightly favor the choice the AI's system prompt emphasized + chosenSide = Math.random() > 0.45 ? 'A' : 'B' + revelationText = config.choices[chosenSide === 'A' ? 0 : 1].label + } + + // Close window if still open + if (activeWindowId) { + windowManager.closeWindow(activeWindowId) + } + + // Revelation phase + session.state = 'revelation' + + // Brief pause for drama + await new Promise(r => setTimeout(r, 2_000)) + + // Settle the window + if (activeWindowId) { + const revelation = windowManager.settleWindow(activeWindowId, chosenSide) + revelation.narrativeJustification = `The AI chose "${revelationText}" โ€” following the logic of House Valdris's survival instinct and the political weight of the moment.` + session.revelationEvent = revelation + + emitter.emit('stream', { + type: 'revelation', + payload: { + winningSide: chosenSide, + winningChoice: revelationText, + justification: revelation.narrativeJustification, + oddsAtClose: revelation.oddsAtClose, + payoutMap: Object.fromEntries(revelation.payoutMap), + totalPaid: revelation.totalPaid.toString(), + }, + timestamp: Date.now(), + broadcastId, + } satisfies StreamMessage) + + // Payout event + await new Promise(r => setTimeout(r, 1_000)) + + emitter.emit('stream', { + type: 'payout_complete', + payload: { + payouts: Object.fromEntries( + Array.from(revelation.payoutMap.entries()).map(([addr, amt]) => [addr, amt.toString()]) + ), + totalPaid: revelation.totalPaid.toString(), + }, + timestamp: Date.now(), + broadcastId, + } satisfies StreamMessage) + } + + session.state = 'complete' + + emitter.emit('stream', { + type: 'broadcast_complete', + payload: { + chapterId: config.chapterId, + totalWords: session.textBuffer.split(' ').length, + viewerPeak: session.viewerCount, + choiceMade: revelationText, + }, + timestamp: Date.now(), + broadcastId, + } satisfies StreamMessage) + + } catch (error) { + session.state = 'complete' + throw error + } + } + + /** + * Register a viewer joining the broadcast. + * Returns an AsyncGenerator that yields StreamMessages for SSE. + */ + async *subscribeViewer(broadcastId: string, viewerAddress?: string): AsyncGenerator { + const session = this.sessions.get(broadcastId) + if (!session) throw new Error(`Broadcast ${broadcastId} not found`) + + const viewers = this.viewers.get(broadcastId)! + if (viewerAddress) viewers.add(viewerAddress) + session.viewerCount = viewers.size + + // Emit viewer count update + session.emitter.emit('stream', { + type: 'viewer_count', + payload: { count: session.viewerCount }, + timestamp: Date.now(), + broadcastId, + } satisfies StreamMessage) + + // Send current text buffer to catch up late joiners + if (session.textBuffer.length > 0) { + yield { + type: 'text_delta', + payload: { content: session.textBuffer, chunkIndex: -1, isReplay: true }, + timestamp: session.startedAt, + broadcastId, + } + } + + // Stream future events via async queue + const queue: StreamMessage[] = [] + let resolve: (() => void) | null = null + + const handler = (msg: StreamMessage) => { + queue.push(msg) + resolve?.() + resolve = null + } + + session.emitter.on('stream', handler) + + try { + while (session.state !== 'complete') { + if (queue.length === 0) { + await new Promise(r => { resolve = r }) + } + while (queue.length > 0) { + yield queue.shift()! + } + } + // Drain remaining + while (queue.length > 0) yield queue.shift()! + } finally { + session.emitter.off('stream', handler) + if (viewerAddress) viewers.delete(viewerAddress) + session.viewerCount = viewers.size + } + } + + /** + * Place a live bet during an active window. + */ + placeBet(broadcastId: string, bet: LiveBet): { success: boolean; error?: string; newOddsA?: number; newOddsB?: number } { + const session = this.sessions.get(broadcastId) + if (!session || !session.activeWindowId) { + return { success: false, error: 'No active betting window' } + } + + // Re-use the per-session window manager + // In production this would be injected; simplified here + return { success: true, newOddsA: 0.5, newOddsB: 0.5 } + } + + getSession(broadcastId: string): BroadcastSession | undefined { + return this.sessions.get(broadcastId) + } + + getAllActiveBroadcasts(): BroadcastSession[] { + return Array.from(this.sessions.values()).filter(s => s.state !== 'complete') + } +} + +// โ”€โ”€โ”€ Scheduled Broadcast Planner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface BroadcastSchedule { + chapterId: string + scheduledAt: number // Unix timestamp + estimatedDuration: number // milliseconds + previewText: string // Teaser shown before broadcast + choices: BroadcastConfig['choices'] +} + +export class BroadcastScheduler { + private schedule: BroadcastSchedule[] = [] + + /** + * Add a chapter to the broadcast schedule. + * Broadcasts should be spaced at least 24h apart for anticipation buildup. + */ + schedule_broadcast(params: Omit & { scheduledAt?: number }): BroadcastSchedule { + const item: BroadcastSchedule = { + ...params, + scheduledAt: params.scheduledAt ?? Date.now() + 86_400_000, // Default: 24h from now + } + this.schedule.push(item) + this.schedule.sort((a, b) => a.scheduledAt - b.scheduledAt) + return item + } + + getUpcoming(limitHours = 48): BroadcastSchedule[] { + const cutoff = Date.now() + limitHours * 3_600_000 + return this.schedule.filter(s => s.scheduledAt <= cutoff && s.scheduledAt >= Date.now()) + } + + getNextBroadcast(): BroadcastSchedule | null { + return this.schedule.find(s => s.scheduledAt >= Date.now()) ?? null + } +} + +// โ”€โ”€โ”€ Factory / Entry Point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function createLNBEngine(apiKey: string) { + const engine = new LiveNarrativeBroadcast(apiKey) + const scheduler = new BroadcastScheduler() + + return { + engine, + scheduler, + + /** + * Quick-start: schedule and start a live chapter broadcast. + * Returns broadcast session that frontend can subscribe to. + */ + async broadcastChapter(config: BroadcastConfig): Promise { + return engine.startBroadcast(config) + }, + + /** + * Subscribe an HTTP client (SSE) to a broadcast. + * Usage: for await (const event of subscribe(id, addr)) { res.write(...) } + */ + subscribe(broadcastId: string, viewerAddress?: string) { + return engine.subscribeViewer(broadcastId, viewerAddress) + }, + } +} + +// โ”€โ”€โ”€ Demo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export async function runLNBDemo(): Promise { + console.log('๐Ÿ”ด LIVE: Voidborne Narrative Broadcast Starting...\n') + + const lnb = createLNBEngine(process.env.ANTHROPIC_API_KEY ?? 'demo') + + const session = await lnb.broadcastChapter({ + chapterId: 'chapter_28', + storyContext: ` + The Grand Conclave has been in chaos for three days. + House Obsidian revealed that Void Stitching โ€” the forbidden art of rewriting reality's threads โ€” + is active again. House Valdris (the heir's house) has evidence of WHO is doing it, + but revealing it could shatter the fragile alliance. + The heir has been stalling, but the Conclave demands an answer. + `, + chapterSetup: ` + The heir stands before the full Grand Conclave, all five Houses watching. + She holds a data crystal containing the identity of the Void Stitcher. + House Meridian's Speaker rises: "We have waited three days. Choose now." + `, + choices: [ + { + label: 'Expose', + description: 'Shatter the crystal, reveal the Stitcher identity publicly', + narrativeWeight: 'Righteous but destabilizing โ€” could start civil war', + }, + { + label: 'Conceal', + description: 'Pocket the crystal, deflect with a false lead', + narrativeWeight: 'Preserves alliance but leaves Stitching unchecked', + }, + ], + }) + + console.log(`๐Ÿ“ก Broadcast ID: ${session.broadcastId}`) + console.log('๐Ÿ“บ Streaming chapter live...\n') + console.log('โ”€'.repeat(60)) + + let wordCount = 0 + for await (const event of lnb.subscribe(session.broadcastId)) { + switch (event.type) { + case 'text_delta': { + const payload = event.payload as { content: string } + process.stdout.write(payload.content) + wordCount += payload.content.split(' ').length + break + } + case 'betting_window_open': { + const w = event.payload as BettingWindow + console.log(`\n\n๐Ÿ”” LIVE BETTING WINDOW OPEN (${w.timeRemaining}s)`) + console.log(` A: ${w.choiceA.label} | B: ${w.choiceB.label}`) + console.log(` Odds: A=${(w.oddsA * 100).toFixed(0)}% | B=${(w.oddsB * 100).toFixed(0)}%\n`) + break + } + case 'revelation': { + const r = event.payload as { winningSide: string; winningChoice: string; justification: string } + console.log(`\n\n๐ŸŽญ REVELATION: AI chose "${r.winningChoice}"`) + console.log(` ${r.justification}\n`) + break + } + case 'broadcast_complete': { + const c = event.payload as { totalWords: number; viewerPeak: number } + console.log(`\nโœ… Broadcast complete | ${c.totalWords} words | ${c.viewerPeak} peak viewers`) + break + } + } + } +} diff --git a/packages/agent-sdk/src/narrative-dna.ts b/packages/agent-sdk/src/narrative-dna.ts new file mode 100644 index 0000000..6cae760 --- /dev/null +++ b/packages/agent-sdk/src/narrative-dna.ts @@ -0,0 +1,425 @@ +/** + * Voidborne โ€” Narrative DNA Engine (NDE) + * Innovation Cycle #50 โ€” February 18, 2026 + * + * Each player's betting history creates a 12-dimension Narrative DNA vector. + * This DNA personalizes future chapter generation through Claude API prompts, + * creating a unique story experience while preserving canonical plot events. + * + * DNA can be minted as NFTs, traded, and shared with other players. + */ + +import type { HouseId } from './house-agents' + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface NarrativeDNA { + playerId: string + version: number + confidence: number // 0-100: how many bets have shaped this (100 = fully calibrated) + vector: DNAVector + dominantTraits: string[] // top 3 traits for display + archetypeLabel: string // human-readable archetype + mintable: boolean // enough confidence to mint as NFT + lastUpdatedAt: Date +} + +export interface DNAVector { + /** + * 0 = simple action/adventure preference + * 1 = loves deep political intrigue and moral complexity + */ + politicalComplexity: number + + /** + * 0 = rarely backs betrayal choices + * 1 = consistently picks the betrayal option + */ + betrayalAffinity: number + + /** + * 0 = tends to bet characters will die/fail + * 1 = tends to bet characters will survive/succeed + */ + survivalOptimism: number + + /** + * Which house's bets are most often aligned with this player? + * e.g., "obsidian" | "valdris" | "none" + */ + houseAllegiance: HouseId | 'none' + + /** + * 0 = always bets with the crowd (consensus bettor) + * 1 = always bets against the crowd (contrarian) + */ + riskProfile: number + + /** + * 0 = prefers slow, methodical plot reveals + * 1 = prefers fast-paced, action-driven chapters + */ + pacingPreference: number + + /** + * 0 = consistently backs ruthless/pragmatic choices + * 1 = consistently backs honorable/principled choices + */ + moralAlignment: number + + /** + * 0 = hates upsets (prefers predictable, safe bets) + * 1 = loves upsets (contrarian by nature, craves surprises) + */ + plotTwistHunger: number + + /** + * 0 = bets for breaking alliances/fractures + * 1 = bets for maintaining alliances/stability + */ + allianceStability: number + + /** + * 0 = ignores secondary characters in bets + * 1 = actively bets on secondary character arcs + */ + characterDepth: number + + /** + * 0 = prefers personal/intimate stakes + * 1 = prefers universe-scale stakes and cosmic consequences + */ + cosmicScope: number + + /** + * 0 = strongly prefers triumphant story arcs + * 1 = strongly prefers tragic/dark story arcs + */ + emotionalIntensity: number +} + +export interface BetRecord { + chapterId: string + chapterNumber: number + choiceId: string + choiceText: string + choiceIndex: number // 0 = first/consensus, 1 = underdog, etc. + poolShare: number // fraction of pool this choice had (0-1) when bet placed + won: boolean + houseAgentAlignment: HouseId | null // if bet matched a house agent's choice + storyTags: string[] // AI-extracted tags: ["betrayal", "survival", "alliance", etc.] +} + +export interface PersonalizationPrompt { + systemAddition: string // appended to chapter generation system prompt + userAddition: string // appended to the chapter user prompt + emphasizedElements: string[] + deemphasizedElements: string[] +} + +// ============================================================================ +// DNA ENGINE +// ============================================================================ + +export class NarrativeDNAEngine { + private playerDNAs: Map = new Map() + + /** + * Initialize or retrieve a player's DNA. + */ + getOrCreateDNA(playerId: string): NarrativeDNA { + if (!this.playerDNAs.has(playerId)) { + this.playerDNAs.set(playerId, this.blankDNA(playerId)) + } + return this.playerDNAs.get(playerId)! + } + + /** + * Update DNA from a single bet record. + * Uses exponential moving average (EMA) for smooth adaptation. + */ + updateFromBet(playerId: string, bet: BetRecord): NarrativeDNA { + const dna = this.getOrCreateDNA(playerId) + const alpha = this.learningRate(dna.confidence) // lower alpha early (faster learning) + + // --- betrayal affinity --- + if (bet.storyTags.includes('betrayal')) { + const betTeaBetrayalChoice = bet.choiceText.toLowerCase().includes('betray') + || bet.choiceText.toLowerCase().includes('deceive') + || bet.choiceText.toLowerCase().includes('expose') + dna.vector.betrayalAffinity = this.ema( + dna.vector.betrayalAffinity, + betTeaBetrayalChoice ? 1 : 0, + alpha + ) + } + + // --- survival optimism --- + if (bet.storyTags.includes('survival') || bet.storyTags.includes('death')) { + const bettedSurvival = bet.choiceText.toLowerCase().includes('survive') + || bet.choiceText.toLowerCase().includes('protect') + || bet.choiceText.toLowerCase().includes('save') + dna.vector.survivalOptimism = this.ema(dna.vector.survivalOptimism, bettedSurvival ? 1 : 0, alpha) + } + + // --- risk profile (contrarianism) --- + // If bet on minority pool position โ†’ contrarian signal + const isContrarian = bet.poolShare < 0.35 + dna.vector.riskProfile = this.ema(dna.vector.riskProfile, isContrarian ? 1 : 0, alpha) + + // --- moral alignment --- + if (bet.storyTags.includes('honor') || bet.storyTags.includes('pragmatic')) { + const isHonorable = bet.storyTags.includes('honor') + || bet.choiceText.toLowerCase().includes('truth') + || bet.choiceText.toLowerCase().includes('justice') + dna.vector.moralAlignment = this.ema(dna.vector.moralAlignment, isHonorable ? 1 : 0, alpha) + } + + // --- plot twist hunger --- + if (bet.won && isContrarian) { + dna.vector.plotTwistHunger = this.ema(dna.vector.plotTwistHunger, 1, alpha) + } else if (!bet.won && isContrarian) { + dna.vector.plotTwistHunger = this.ema(dna.vector.plotTwistHunger, 0.2, alpha) + } + + // --- alliance stability --- + if (bet.storyTags.includes('alliance') || bet.storyTags.includes('fracture')) { + const favoredAlliance = bet.storyTags.includes('alliance') + && !bet.choiceText.toLowerCase().includes('break') + && !bet.choiceText.toLowerCase().includes('betray') + dna.vector.allianceStability = this.ema(dna.vector.allianceStability, favoredAlliance ? 1 : 0, alpha) + } + + // --- house allegiance --- + if (bet.houseAgentAlignment) { + const counts = this.countHouseAllegiance(playerId) + const topHouse = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] + dna.vector.houseAllegiance = topHouse ? (topHouse[0] as HouseId) : 'none' + } + + // Increase confidence + dna.confidence = Math.min(dna.confidence + 1, 100) + dna.version++ + dna.mintable = dna.confidence >= 20 + dna.lastUpdatedAt = new Date() + + // Recompute derived fields + dna.dominantTraits = this.computeDominantTraits(dna.vector) + dna.archetypeLabel = this.computeArchetype(dna.vector) + + this.playerDNAs.set(playerId, dna) + return dna + } + + /** + * Generate a personalization prompt to inject into Claude chapter generation. + */ + generatePersonalizationPrompt(dna: NarrativeDNA): PersonalizationPrompt { + if (dna.confidence < 5) { + // Not enough data yet โ€” return neutral + return { + systemAddition: '', + userAddition: '', + emphasizedElements: [], + deemphasizedElements: [], + } + } + + const v = dna.vector + const emphasized: string[] = [] + const deemphasized: string[] = [] + + // Build emphasis list based on DNA extremes (>0.7 or <0.3) + if (v.politicalComplexity > 0.7) emphasized.push('multi-layered political intrigue and moral ambiguity') + if (v.politicalComplexity < 0.3) emphasized.push('clear, direct stakes and action sequences') + if (v.betrayalAffinity > 0.7) emphasized.push('subtle loyalty tests and betrayal foreshadowing') + if (v.survivalOptimism < 0.3) emphasized.push('realistic consequences โ€” characters may not survive') + if (v.survivalOptimism > 0.8) emphasized.push('hope and resilience in the face of danger') + if (v.riskProfile > 0.6) emphasized.push('ambiguous choices where contrarian thinking is rewarded') + if (v.moralAlignment > 0.7) emphasized.push('ethical dilemmas with clear principled options') + if (v.moralAlignment < 0.3) emphasized.push('pragmatic realpolitik โ€” no clean moral choices') + if (v.plotTwistHunger > 0.7) emphasized.push('unexpected reversals and surprising reveals') + if (v.allianceStability < 0.3) emphasized.push('fractures in alliances and shifting loyalties') + if (v.allianceStability > 0.8) emphasized.push('the strength of tested alliances') + if (v.cosmicScope > 0.7) emphasized.push('universe-scale consequences and existential stakes') + if (v.emotionalIntensity > 0.7) emphasized.push('darker, more tragic undertones and sacrifice themes') + if (v.characterDepth > 0.7) emphasized.push('rich secondary character development') + + // House allegiance โ†’ POV emphasis + const houseSceneMap: Record = { + valdris: "House Valdris's perspective and Throne-preserving motivations", + obsidian: "House Obsidian's shadowy operations and strategic reveals", + meridian: "House Meridian's neutral arbitration and diplomatic calculations", + auric: "House Auric's economic machinations and opportunity-seeking", + zephyr: "House Zephyr's adaptive survival and political flexibility", + } + if (v.houseAllegiance !== 'none' && houseSceneMap[v.houseAllegiance]) { + emphasized.push(houseSceneMap[v.houseAllegiance]) + } + + // Pacing signal + const pacingNote = v.pacingPreference > 0.7 + ? 'fast-paced, high-tension prose with shorter scenes' + : v.pacingPreference < 0.3 + ? 'slower, atmospheric prose with deeper world-building details' + : '' + if (pacingNote) emphasized.push(pacingNote) + + const systemAddition = emphasized.length > 0 + ? `\n\nREADER PERSONALIZATION (based on ${dna.confidence} bets):\nThis chapter should subtly emphasize:\n${emphasized.map(e => `- ${e}`).join('\n')}` + : '' + + const userAddition = dna.archetypeLabel !== 'The Neutral Observer' + ? `\n\nReader archetype: "${dna.archetypeLabel}" โ€” tailor the chapter's atmosphere accordingly.` + : '' + + return { + systemAddition, + userAddition, + emphasizedElements: emphasized, + deemphasizedElements: deemphasized, + } + } + + /** + * Compute the distance between two DNA vectors (0 = identical, 1 = opposite). + * Used for DNA trading marketplace compatibility scores. + */ + dnaSimilarity(a: NarrativeDNA, b: NarrativeDNA): number { + const va = a.vector + const vb = b.vector + + const numericKeys: Array> = [ + 'politicalComplexity', 'betrayalAffinity', 'survivalOptimism', + 'riskProfile', 'pacingPreference', 'moralAlignment', 'plotTwistHunger', + 'allianceStability', 'characterDepth', 'cosmicScope', 'emotionalIntensity', + ] + + const squaredDiffs = numericKeys.map(k => { + const diff = (va[k] as number) - (vb[k] as number) + return diff * diff + }) + + const euclidean = Math.sqrt(squaredDiffs.reduce((sum, d) => sum + d, 0)) + const maxDistance = Math.sqrt(numericKeys.length) // max if all dimensions are 0 vs 1 + return 1 - euclidean / maxDistance + } + + /** + * Generate a human-readable "DNA Card" for display / NFT metadata. + */ + generateDNACard(dna: NarrativeDNA): string { + const v = dna.vector + const bars = (val: number) => 'โ–ˆ'.repeat(Math.round(val * 10)).padEnd(10, 'โ–‘') + + return [ + `๐Ÿงฌ NARRATIVE DNA โ€” ${dna.archetypeLabel}`, + `Player: ${dna.playerId} | Confidence: ${dna.confidence}/100 | v${dna.version}`, + '', + `Political Complexity ${bars(v.politicalComplexity)} ${(v.politicalComplexity * 100).toFixed(0)}%`, + `Betrayal Affinity ${bars(v.betrayalAffinity)} ${(v.betrayalAffinity * 100).toFixed(0)}%`, + `Survival Optimism ${bars(v.survivalOptimism)} ${(v.survivalOptimism * 100).toFixed(0)}%`, + `Contrarianism ${bars(v.riskProfile)} ${(v.riskProfile * 100).toFixed(0)}%`, + `Plot Twist Hunger ${bars(v.plotTwistHunger)} ${(v.plotTwistHunger * 100).toFixed(0)}%`, + `Moral Alignment ${bars(v.moralAlignment)} ${(v.moralAlignment * 100).toFixed(0)}%`, + `Emotional Intensity ${bars(v.emotionalIntensity)} ${(v.emotionalIntensity * 100).toFixed(0)}%`, + `Cosmic Scope ${bars(v.cosmicScope)} ${(v.cosmicScope * 100).toFixed(0)}%`, + '', + `House Allegiance: ${v.houseAllegiance === 'none' ? 'Unaligned' : v.houseAllegiance.toUpperCase()}`, + `Dominant Traits: ${dna.dominantTraits.join(', ')}`, + dna.mintable ? 'โœ… Ready to mint as NFT' : `๐Ÿ”’ ${20 - dna.confidence} more bets to unlock minting`, + ].join('\n') + } + + // ============================================================================ + // PRIVATE HELPERS + // ============================================================================ + + private blankDNA(playerId: string): NarrativeDNA { + return { + playerId, + version: 0, + confidence: 0, + vector: { + politicalComplexity: 0.5, + betrayalAffinity: 0.5, + survivalOptimism: 0.5, + houseAllegiance: 'none', + riskProfile: 0.5, + pacingPreference: 0.5, + moralAlignment: 0.5, + plotTwistHunger: 0.5, + allianceStability: 0.5, + characterDepth: 0.5, + cosmicScope: 0.5, + emotionalIntensity: 0.5, + }, + dominantTraits: [], + archetypeLabel: 'The Neutral Observer', + mintable: false, + lastUpdatedAt: new Date(), + } + } + + private ema(current: number, newValue: number, alpha: number): number { + // Exponential moving average: smoothly updates toward new signal + const result = (1 - alpha) * current + alpha * newValue + return Math.min(Math.max(result, 0), 1) + } + + private learningRate(confidence: number): number { + // Higher learning rate when less confident (few bets), stabilizes over time + if (confidence < 10) return 0.4 + if (confidence < 30) return 0.2 + if (confidence < 60) return 0.1 + return 0.05 + } + + private countHouseAllegiance(_playerId: string): Record { + // In production: query DB for player's bet history vs. house agent bets + // Mock implementation for POC + return { + valdris: 0, + obsidian: 0, + meridian: 0, + auric: 0, + zephyr: 0, + } + } + + private computeDominantTraits(v: DNAVector): string[] { + const traits: Array<[string, number, boolean]> = [ + ['Contrarian', v.riskProfile, true], + ['Betrayal-Seeker', v.betrayalAffinity, true], + ['Optimist', v.survivalOptimism, true], + ['Twist-Hunter', v.plotTwistHunger, true], + ['Strategist', v.politicalComplexity, true], + ['Moralist', v.moralAlignment, true], + ['Cosmicist', v.cosmicScope, true], + ['Tragedian', v.emotionalIntensity, true], + ['Consensus Bettor', 1 - v.riskProfile, v.riskProfile < 0.3], + ['Alliance Builder', v.allianceStability, v.allianceStability > 0.7], + ] + + return traits + .filter(([, score, active]) => active && score > 0.65) + .sort((a, b) => (b[1] as number) - (a[1] as number)) + .slice(0, 3) + .map(([name]) => name) + } + + private computeArchetype(v: DNAVector): string { + if (v.betrayalAffinity > 0.7 && v.politicalComplexity > 0.6) return 'The Grand Schemer' + if (v.riskProfile > 0.7 && v.plotTwistHunger > 0.7) return 'The Chaos Agent' + if (v.survivalOptimism > 0.8 && v.moralAlignment > 0.7) return 'The True Believer' + if (v.moralAlignment < 0.3 && v.politicalComplexity > 0.6) return 'The Realpolitician' + if (v.cosmicScope > 0.8 && v.emotionalIntensity > 0.6) return 'The Void Dreamer' + if (v.allianceStability > 0.8 && v.riskProfile < 0.3) return 'The Conclave Loyalist' + if (v.characterDepth > 0.8) return 'The Story Architect' + if (v.pacingPreference > 0.8) return 'The Adrenaline Seeker' + if (v.riskProfile < 0.2 && v.survivalOptimism > 0.6) return 'The Safe Warden' + return 'The Neutral Observer' + } +} diff --git a/packages/agent-sdk/src/sage-protocol.ts b/packages/agent-sdk/src/sage-protocol.ts new file mode 100644 index 0000000..b77f9f9 --- /dev/null +++ b/packages/agent-sdk/src/sage-protocol.ts @@ -0,0 +1,431 @@ +/** + * Voidborne โ€” Sage Staking Protocol (SSP) + * Innovation Cycle #50 โ€” February 18, 2026 + * + * Reputation-as-asset system where prediction accuracy is tracked on-chain + * and unlocks Sage tiers. High-accuracy bettors can "stake" their skill to + * earn passive yield from less-skilled players (Wisdom Pool). + * + * Tiers: + * 0 Wanderer โ€” < 10 bets + * 1 Seeker โ€” 40%+ accuracy, 10+ bets + * 2 Cartographerโ€” 55%+ accuracy, 50+ bets, 5-streak + * 3 Oracle โ€” 65%+ accuracy, 200+ bets, 10-streak + * 4 Sage โ€” 75%+ accuracy, 500+ bets, 20-streak + * 5 Architect โ€” Top 10 all-time accuracy, 1000+ bets + */ + +import { formatUnits, parseUnits, type Address } from 'viem' + +// ============================================================================ +// TYPES +// ============================================================================ + +export type SageTier = 0 | 1 | 2 | 3 | 4 | 5 + +export interface TierConfig { + tier: SageTier + name: string + emoji: string + minAccuracy: number + minBets: number + minStreak: number + feeDiscount: number // % discount on betting fees + earlyAccessHours: number // hours before chapter opens to public + governanceVotes: number + wisdomPoolAPY: number // % APY from Wisdom Pool (0 = not staking-eligible) +} + +export const TIER_CONFIGS: TierConfig[] = [ + { tier: 0, name: 'Wanderer', emoji: '๐Ÿงญ', minAccuracy: 0, minBets: 0, minStreak: 0, feeDiscount: 0, earlyAccessHours: 0, governanceVotes: 0, wisdomPoolAPY: 0 }, + { tier: 1, name: 'Seeker', emoji: '๐Ÿ”', minAccuracy: 0.40, minBets: 10, minStreak: 0, feeDiscount: 5, earlyAccessHours: 0, governanceVotes: 0, wisdomPoolAPY: 0 }, + { tier: 2, name: 'Cartographer', emoji: '๐Ÿ—บ๏ธ', minAccuracy: 0.55, minBets: 50, minStreak: 5, feeDiscount: 10, earlyAccessHours: 1, governanceVotes: 1, wisdomPoolAPY: 0 }, + { tier: 3, name: 'Oracle', emoji: '๐Ÿ”ฎ', minAccuracy: 0.65, minBets: 200, minStreak: 10, feeDiscount: 15, earlyAccessHours: 6, governanceVotes: 5, wisdomPoolAPY: 2 }, + { tier: 4, name: 'Sage', emoji: '๐Ÿง™', minAccuracy: 0.75, minBets: 500, minStreak: 20, feeDiscount: 20, earlyAccessHours: 24, governanceVotes: 25, wisdomPoolAPY: 8 }, + { tier: 5, name: 'Architect', emoji: '๐Ÿ›๏ธ', minAccuracy: 0.82, minBets: 1000, minStreak: 0, feeDiscount: 25, earlyAccessHours: 48, governanceVotes: 100, wisdomPoolAPY: 15 }, +] + +export interface PlayerStats { + walletAddress: Address + totalBets: number + wonBets: number + currentStreak: number + longestStreak: number + accuracyRate: number // wonBets / totalBets + currentTier: SageTier + tierConfig: TierConfig + totalWagered: bigint // USDC (6 decimals) + totalWon: bigint + netPnl: bigint + wisdomPoolStake: bigint // current stake in Wisdom Pool (if eligible) + wisdomPoolEarned: bigint // lifetime Wisdom Pool earnings + lastBetAt: Date | null + tierProgressPercent: number // 0-100% progress to next tier +} + +export interface WisdomPool { + totalStaked: bigint // total USDC staked by Sages + availableRewards: bigint // accrued from mentored betting premiums + stakes: Map // individual stakes + lastDistributionAt: Date + cumulativeYield: bigint // total ever distributed +} + +export interface MentoredBet { + bettor: Address + poolId: string + choiceId: string + baseAmount: bigint + premiumPaid: bigint // 1% of baseAmount + tier: SageTier +} + +// ============================================================================ +// SAGE PROTOCOL ENGINE +// ============================================================================ + +export class SageProtocolEngine { + private playerStats: Map = new Map() + private wisdomPool: WisdomPool = { + totalStaked: 0n, + availableRewards: 0n, + stakes: new Map(), + lastDistributionAt: new Date(), + cumulativeYield: 0n, + } + private topArchitects: Address[] = [] // top 10 by accuracy (for Tier 5) + + // ============================================================================ + // PLAYER STATS + // ============================================================================ + + /** + * Initialize or retrieve player stats. + */ + getOrCreateStats(walletAddress: Address): PlayerStats { + if (!this.playerStats.has(walletAddress)) { + this.playerStats.set(walletAddress, this.blankStats(walletAddress)) + } + return this.playerStats.get(walletAddress)! + } + + /** + * Record a resolved bet and update all stats. + */ + recordBetOutcome( + walletAddress: Address, + wagered: bigint, + won: boolean, + payout: bigint + ): PlayerStats { + const stats = this.getOrCreateStats(walletAddress) + + stats.totalBets++ + stats.totalWagered += wagered + + if (won) { + stats.wonBets++ + stats.currentStreak++ + stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak) + stats.totalWon += payout + } else { + stats.currentStreak = 0 + stats.totalWon += 0n + } + + stats.accuracyRate = stats.totalBets > 0 ? stats.wonBets / stats.totalBets : 0 + stats.netPnl = stats.totalWon - stats.totalWagered + stats.lastBetAt = new Date() + + // Recompute tier + stats.currentTier = this.computeTier(stats, walletAddress) + stats.tierConfig = TIER_CONFIGS[stats.currentTier] + stats.tierProgressPercent = this.computeProgressToNextTier(stats) + + this.playerStats.set(walletAddress, stats) + this.refreshArchitectLeaderboard() + return stats + } + + /** + * Compute what tier a player is at based on their stats. + */ + computeTier(stats: PlayerStats, walletAddress: Address): SageTier { + // Check Architect first (top 10 all-time) + if ( + this.topArchitects.includes(walletAddress) + && stats.accuracyRate >= TIER_CONFIGS[5].minAccuracy + && stats.totalBets >= TIER_CONFIGS[5].minBets + ) { + return 5 + } + + // Check Sage โ†’ Oracle โ†’ Cartographer โ†’ Seeker โ†’ Wanderer + for (let tier = 4; tier >= 1; tier--) { + const config = TIER_CONFIGS[tier] + if ( + stats.accuracyRate >= config.minAccuracy + && stats.totalBets >= config.minBets + && stats.longestStreak >= config.minStreak + ) { + return tier as SageTier + } + } + + return 0 + } + + /** + * Progress percentage toward the next tier. + */ + computeProgressToNextTier(stats: PlayerStats): number { + if (stats.currentTier === 5) return 100 + + const next = TIER_CONFIGS[stats.currentTier + 1] + const current = TIER_CONFIGS[stats.currentTier] + + // Weighted progress across all requirements + const scores: number[] = [] + + // Accuracy progress + const accBase = current.minAccuracy + const accTarget = next.minAccuracy + if (accTarget > accBase) { + scores.push(Math.min((stats.accuracyRate - accBase) / (accTarget - accBase), 1)) + } + + // Bet count progress + const betBase = current.minBets + const betTarget = next.minBets + if (betTarget > betBase) { + scores.push(Math.min((stats.totalBets - betBase) / (betTarget - betBase), 1)) + } + + // Streak progress + if (next.minStreak > 0) { + scores.push(Math.min(stats.longestStreak / next.minStreak, 1)) + } + + if (scores.length === 0) return 100 + const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length + return Math.round(avg * 100) + } + + // ============================================================================ + // WISDOM POOL + // ============================================================================ + + /** + * A player opts into Wisdom Pool staking (requires Tier 3+). + * Their staked $FORGE earns yield from mentored betting premiums. + */ + stakeInWisdomPool(walletAddress: Address, amount: bigint): void { + const stats = this.getOrCreateStats(walletAddress) + + if (stats.currentTier < 3) { + throw new Error(`Wisdom Pool requires Oracle tier (3) or higher. Current: ${TIER_CONFIGS[stats.currentTier].name}`) + } + + const current = this.wisdomPool.stakes.get(walletAddress) ?? 0n + this.wisdomPool.stakes.set(walletAddress, current + amount) + this.wisdomPool.totalStaked += amount + + stats.wisdomPoolStake = current + amount + this.playerStats.set(walletAddress, stats) + } + + /** + * Record a mentored bet premium (1% from a Tier 0-2 player's bet). + * In production, called by the betting smart contract. + */ + recordMentoredPremium(premium: bigint): void { + // Platform takes 20%, rest goes to Wisdom Pool + const platformFee = (premium * 20n) / 100n + const poolContribution = premium - platformFee + this.wisdomPool.availableRewards += poolContribution + } + + /** + * Distribute Wisdom Pool rewards proportionally to stakers. + * Called daily/weekly by cron job. + */ + distributeWisdomPoolRewards(): Array<{ walletAddress: Address; reward: bigint }> { + if (this.wisdomPool.availableRewards === 0n || this.wisdomPool.totalStaked === 0n) { + return [] + } + + const rewards: Array<{ walletAddress: Address; reward: bigint }> = [] + + for (const [address, stake] of this.wisdomPool.stakes) { + if (stake === 0n) continue + const reward = (this.wisdomPool.availableRewards * stake) / this.wisdomPool.totalStaked + rewards.push({ walletAddress: address, reward }) + + // Update player stats + const stats = this.playerStats.get(address) + if (stats) { + stats.wisdomPoolEarned += reward + this.playerStats.set(address, stats) + } + } + + this.wisdomPool.cumulativeYield += this.wisdomPool.availableRewards + this.wisdomPool.availableRewards = 0n + this.wisdomPool.lastDistributionAt = new Date() + + return rewards + } + + /** + * Estimate APY for a Wisdom Pool staker based on current pool state. + */ + estimateWisdomPoolAPY(walletAddress: Address): number { + const stats = this.getOrCreateStats(walletAddress) + if (stats.wisdomPoolStake === 0n || this.wisdomPool.totalStaked === 0n) return 0 + + // Use tier-based APY guarantee as floor + const tierAPY = TIER_CONFIGS[stats.currentTier].wisdomPoolAPY + return tierAPY + } + + // ============================================================================ + // FEE DISCOUNTS + // ============================================================================ + + /** + * Calculate effective betting fee after Sage tier discount. + * Base fee: 2.5%. Sages get up to 25% discount on fees. + */ + effectiveFee(walletAddress: Address, baseFeePercent = 2.5): number { + const stats = this.getOrCreateStats(walletAddress) + const discount = TIER_CONFIGS[stats.currentTier].feeDiscount / 100 + return baseFeePercent * (1 - discount) + } + + /** + * Check if player has early access to a chapter's betting window. + */ + hasEarlyAccess(walletAddress: Address, hoursUntilOpen: number): boolean { + const stats = this.getOrCreateStats(walletAddress) + return TIER_CONFIGS[stats.currentTier].earlyAccessHours >= hoursUntilOpen + } + + // ============================================================================ + // LEADERBOARD + // ============================================================================ + + /** + * Get top N players by accuracy (minimum bet threshold). + */ + getAccuracyLeaderboard(limit = 10, minBets = 50): PlayerStats[] { + return Array.from(this.playerStats.values()) + .filter(s => s.totalBets >= minBets) + .sort((a, b) => b.accuracyRate - a.accuracyRate) + .slice(0, limit) + } + + /** + * Get Wisdom Pool leaderboard (top stakers). + */ + getWisdomPoolLeaderboard(limit = 10): Array<{ walletAddress: Address; stake: bigint; earned: bigint; apy: number }> { + return Array.from(this.wisdomPool.stakes.entries()) + .filter(([, stake]) => stake > 0n) + .map(([address, stake]) => ({ + walletAddress: address, + stake, + earned: this.playerStats.get(address)?.wisdomPoolEarned ?? 0n, + apy: this.estimateWisdomPoolAPY(address), + })) + .sort((a, b) => Number(b.stake - a.stake)) + .slice(0, limit) + } + + // ============================================================================ + // DISPLAY + // ============================================================================ + + /** + * Generate a Sage card for display / on-chain attestation. + */ + generateSageCard(walletAddress: Address): string { + const stats = this.getOrCreateStats(walletAddress) + const config = stats.tierConfig + const bars = (val: number, max = 100) => { + const pct = Math.min(val / max, 1) + return 'โ–ˆ'.repeat(Math.round(pct * 10)).padEnd(10, 'โ–‘') + } + + return [ + `${config.emoji} SAGE PROTOCOL โ€” ${config.name.toUpperCase()}`, + `Wallet: ${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`, + '', + `Accuracy: ${bars(stats.accuracyRate * 100)} ${(stats.accuracyRate * 100).toFixed(1)}%`, + `Total Bets: ${stats.totalBets.toLocaleString()}`, + `Win Streak: ${stats.currentStreak} (best: ${stats.longestStreak})`, + `Net PnL: ${Number(formatUnits(stats.netPnl, 6)) >= 0 ? '+' : ''}${formatUnits(stats.netPnl, 6)} USDC`, + '', + `Tier Perks:`, + ` โœ… Fee Discount: ${config.feeDiscount}%`, + ` โœ… Early Access: ${config.earlyAccessHours}h before public`, + ` โœ… Governance Votes: ${config.governanceVotes}`, + config.wisdomPoolAPY > 0 ? ` โœ… Wisdom Pool APY: ${config.wisdomPoolAPY}%` : ` ๐Ÿ”’ Wisdom Pool: Unlock at Oracle tier`, + '', + stats.currentTier < 5 + ? `Progress to ${TIER_CONFIGS[stats.currentTier + 1].name}: ${stats.tierProgressPercent}%` + : '๐Ÿ›๏ธ Maximum tier achieved', + stats.wisdomPoolStake > 0n + ? `Wisdom Pool Staked: ${formatUnits(stats.wisdomPoolStake, 6)} USDC | Earned: ${formatUnits(stats.wisdomPoolEarned, 6)} USDC` + : config.wisdomPoolAPY > 0 ? 'Join Wisdom Pool to earn passive USDC yield' : '', + ].filter(Boolean).join('\n') + } + + // ============================================================================ + // PRIVATE + // ============================================================================ + + private blankStats(walletAddress: Address): PlayerStats { + return { + walletAddress, + totalBets: 0, + wonBets: 0, + currentStreak: 0, + longestStreak: 0, + accuracyRate: 0, + currentTier: 0, + tierConfig: TIER_CONFIGS[0], + totalWagered: 0n, + totalWon: 0n, + netPnl: 0n, + wisdomPoolStake: 0n, + wisdomPoolEarned: 0n, + lastBetAt: null, + tierProgressPercent: 0, + } + } + + private refreshArchitectLeaderboard(): void { + this.topArchitects = this.getAccuracyLeaderboard(10, 1000) + .map(s => s.walletAddress) + } +} + +// ============================================================================ +// EXAMPLE +// ============================================================================ + +if (require.main === module) { + const engine = new SageProtocolEngine() + const player = '0xAbCd1234AbCd1234AbCd1234AbCd1234AbCd1234' as Address + + // Simulate 60 bets: 75% win rate + for (let i = 0; i < 60; i++) { + const won = Math.random() < 0.75 + engine.recordBetOutcome(player, parseUnits('50', 6), won, won ? parseUnits('95', 6) : 0n) + } + + console.log(engine.generateSageCard(player)) + console.log('\n๐Ÿ“Š Top Accuracy Leaderboard:') + engine.getAccuracyLeaderboard(5, 5).forEach((s, i) => { + console.log(` ${i + 1}. ${s.walletAddress.slice(0, 8)}... โ€” ${(s.accuracyRate * 100).toFixed(1)}% (${s.totalBets} bets) โ€” ${s.tierConfig.emoji} ${s.tierConfig.name}`) + }) +} diff --git a/packages/agent-sdk/src/temporal-markets.ts b/packages/agent-sdk/src/temporal-markets.ts new file mode 100644 index 0000000..38da0a4 --- /dev/null +++ b/packages/agent-sdk/src/temporal-markets.ts @@ -0,0 +1,663 @@ +/** + * Voidborne Innovation Cycle #51 โ€” Temporal Oracle Markets (TOM) + * + * Long-range prediction markets that let readers bet on story outcomes + * 5-100 chapters in advance, with compounding multipliers based on + * prediction horizon. Creates long-term engagement and narrative investors. + * + * "I've had 500 USDC on 'Void Gate opens by Chapter 50' since Chapter 28." + */ + +// โ”€โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export type TemporalMarketStatus = + | 'pending' // Created but not yet open for bets + | 'open' // Accepting bets + | 'locked' // Bet window closed, awaiting resolution chapter + | 'resolving' // AI is extracting resolution + | 'resolved' // Outcome known + | 'disputed' // Community challenging AI resolution + | 'voided' // Story diverged, market cancelled + +export type ResolutionOracleType = + | 'ai_extract' // Claude reads the chapter and returns True/False + | 'sage_vote' // High-tier Sages vote on outcome + | 'onchain_state' // Smart contract state (e.g., Void Gate address โ‰  0x0) + | 'community_vote' // Broad community vote (last resort) + +export interface TemporalMarket { + marketId: string + question: string // "Will House Valdris hold the throne by Chapter 40?" + detail: string // Extended description for bettors + openAtChapter: number + resolveAtChapter: number + horizon: number // resolveAt - openAt + multiplier: number // Payout multiplier (based on horizon curve) + + // Pool state + yesPool: bigint // USDC staked on YES + noPool: bigint // USDC staked on NO + totalPool: bigint + + // Participation + yesCount: number + noCount: number + + // Resolution + resolutionOracleType: ResolutionOracleType + resolutionCriteria: string // English criteria fed to AI oracle + resolutionChapterText?: string // Filled when resolving + outcome?: boolean // true = YES won, false = NO won + + status: TemporalMarketStatus + createdAt: number + openedAt?: number + lockedAt?: number + resolvedAt?: number + + // Dispute tracking + disputeCount: number + disputeDeadlineMs?: number +} + +export interface TemporalBet { + betId: string + marketId: string + bettorAddress: string + side: 'yes' | 'no' + amount: bigint // USDC (6 decimals) + appliedMultiplier: number // Locked in at bet time + expectedPayout: bigint // Estimated at bet time + timestamp: number + chapterAtBet: number // Which chapter was current when bet placed +} + +export interface TemporalPortfolio { + address: string + activeBets: TemporalBet[] + resolvedBets: Array + totalStaked: bigint + totalWon: bigint + totalLost: bigint + winRate: number + longestCorrectHorizon: number // Farthest-ahead correct prediction + temporalOracleNFT?: string // NFT token ID if earned +} + +export interface DisputeVote { + voterAddress: string + marketId: string + voteOutcome: boolean // true = YES won, false = NO won + sageTier: number // Voter's Sage tier (from Cycle #50 SSP) + reasoning?: string + timestamp: number +} + +// โ”€โ”€โ”€ Horizon Multiplier Curve โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * The core of Temporal Markets: higher-horizon bets pay more. + * Curve is designed to be: + * - Exciting for 1-5 chapter bets (small premium) + * - Lucrative for 10-20 chapter bets (real incentive) + * - Life-changing for 50+ chapter bets (moonshot territory) + */ +export function getHorizonMultiplier(horizon: number): number { + if (horizon <= 0) return 1.0 + if (horizon === 1) return 1.15 + if (horizon <= 5) return 1.15 + (horizon - 1) * 0.15 // 1.15 โ†’ 1.75 + if (horizon <= 10) return 1.75 + (horizon - 5) * 0.35 // 1.75 โ†’ 3.50 + if (horizon <= 20) return 3.50 + (horizon - 10) * 0.25 // 3.50 โ†’ 6.00 + if (horizon <= 50) return 6.00 + (horizon - 20) * 0.15 // 6.00 โ†’ 10.50 + return Math.min(20.0, 10.50 + (horizon - 50) * 0.10) // 10.50 โ†’ 20.00 cap +} + +/** + * Returns a human-readable description of the multiplier tier. + */ +export function getMultiplierTier(multiplier: number): { tier: string; color: string; description: string } { + if (multiplier < 1.5) return { tier: 'Scout', color: '#94a3b8', description: 'Short-range prediction' } + if (multiplier < 2.5) return { tier: 'Seer', color: '#34d399', description: 'Near-future oracle' } + if (multiplier < 4.0) return { tier: 'Prophet', color: '#60a5fa', description: 'Mid-range visionary' } + if (multiplier < 7.0) return { tier: 'Augur', color: '#a78bfa', description: 'Long-range predictor' } + if (multiplier < 12.0) return { tier: 'Void Oracle', color: '#f59e0b', description: 'Temporal master' } + return { tier: 'Timeweaver', color: '#ef4444', description: 'Reality-altering prediction' } +} + +// โ”€โ”€โ”€ AI Resolution Oracle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface OracleResolutionRequest { + marketId: string + question: string + criteria: string + chapterText: string // Full resolved chapter text + storyContext: string // Summary of story so far +} + +export interface OracleResolutionResult { + outcome: boolean + confidence: number // 0-1 + reasoning: string + quotedEvidence: string // Direct quote from chapter supporting resolution + requiresHumanReview: boolean // Set true if confidence < 0.75 +} + +export class AIResolutionOracle { + /** + * Uses Claude to extract True/False from chapter text. + * Returns structured resolution with confidence and evidence. + * + * In production: this calls the Anthropic API. + * Demo: returns mock result. + */ + async resolve(request: OracleResolutionRequest): Promise { + // In production, this would call Claude with a structured prompt: + /* + const client = new Anthropic({ apiKey }) + const response = await client.messages.create({ + model: 'claude-sonnet-4-5', + max_tokens: 500, + messages: [{ + role: 'user', + content: ` + TEMPORAL MARKET RESOLUTION + + Question: "${request.question}" + Criteria: "${request.criteria}" + + Story context: ${request.storyContext} + + Chapter text to evaluate: + ${request.chapterText} + + Return JSON: + { + "outcome": boolean, // true if the condition is MET + "confidence": 0-1, + "reasoning": "...", + "quotedEvidence": "direct quote from chapter" + } + ` + }], + system: 'You are a precise narrative oracle. Only return the JSON object, no other text.' + }) + */ + + // Mock resolution for demo + return { + outcome: Math.random() > 0.5, + confidence: 0.85 + Math.random() * 0.14, + reasoning: `Based on the chapter text, the condition "${request.question.slice(0, 50)}..." is clearly ${Math.random() > 0.5 ? 'met' : 'not met'} as evidenced by the narrative events.`, + quotedEvidence: `"The heir's decision sealed the fate of House Valdris โ€” there would be no returning from this moment."`, + requiresHumanReview: false, + } + } +} + +// โ”€โ”€โ”€ Temporal Market Manager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export class TemporalMarketManager { + private markets: Map = new Map() + private bets: Map = new Map() // marketId โ†’ bets + private portfolios: Map = new Map() + private oracle = new AIResolutionOracle() + private disputeVotes: Map = new Map() + + // Fee structure + private readonly WINNER_SHARE = 8_500n // 85% of pool to winners + private readonly TREASURY_SHARE = 1_250n // 12.5% to treasury + private readonly DEV_SHARE = 250n // 2.5% to dev + private readonly FEE_DENOMINATOR = 10_000n + private readonly DISPUTE_WINDOW_MS = 48 * 60 * 60 * 1000 // 48h + + /** + * Create a new temporal market. + * Anyone can create a market (with a small creation fee in production). + */ + createMarket(params: { + question: string + detail: string + openAtChapter: number + resolveAtChapter: number + resolutionOracleType?: ResolutionOracleType + resolutionCriteria: string + }): TemporalMarket { + const horizon = params.resolveAtChapter - params.openAtChapter + if (horizon < 1) throw new Error('Horizon must be at least 1 chapter') + + const marketId = `temporal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + + const market: TemporalMarket = { + marketId, + question: params.question, + detail: params.detail, + openAtChapter: params.openAtChapter, + resolveAtChapter: params.resolveAtChapter, + horizon, + multiplier: getHorizonMultiplier(horizon), + yesPool: 0n, + noPool: 0n, + totalPool: 0n, + yesCount: 0, + noCount: 0, + resolutionOracleType: params.resolutionOracleType ?? 'ai_extract', + resolutionCriteria: params.resolutionCriteria, + status: 'pending', + createdAt: Date.now(), + disputeCount: 0, + } + + this.markets.set(marketId, market) + this.bets.set(marketId, []) + + return market + } + + /** + * Open a market for betting (called when current chapter reaches openAtChapter). + */ + openMarket(marketId: string): TemporalMarket { + const market = this.markets.get(marketId) + if (!market) throw new Error(`Market ${marketId} not found`) + if (market.status !== 'pending') throw new Error(`Market already ${market.status}`) + + market.status = 'open' + market.openedAt = Date.now() + return market + } + + /** + * Place a bet on a temporal market. + * Multiplier is locked in at bet time (not at resolution). + */ + placeBet(params: { + marketId: string + bettorAddress: string + side: 'yes' | 'no' + amount: bigint + currentChapter: number + }): TemporalBet { + const market = this.markets.get(params.marketId) + if (!market) throw new Error(`Market ${params.marketId} not found`) + if (market.status !== 'open') throw new Error(`Market is not open for betting`) + if (params.amount < 1_000_000n) throw new Error('Minimum bet is 1 USDC') + + // Multiplier is the BASE market multiplier; actual payout also depends on parimutuel + // For simplicity in POC, we track the market's horizon multiplier + const appliedMultiplier = market.multiplier + + // Estimate payout (pre-settlement estimate) + const currentPool = (params.side === 'yes' ? market.yesPool : market.noPool) + params.amount + const totalPoolAfter = market.totalPool + params.amount + const winnerShare = (totalPoolAfter * this.WINNER_SHARE) / this.FEE_DENOMINATOR + const estimatedPayout = currentPool > 0n + ? (params.amount * winnerShare) / currentPool + : params.amount + + const bet: TemporalBet = { + betId: `bet_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + marketId: params.marketId, + bettorAddress: params.bettorAddress, + side: params.side, + amount: params.amount, + appliedMultiplier, + expectedPayout: BigInt(Math.floor(Number(estimatedPayout) * appliedMultiplier)), + timestamp: Date.now(), + chapterAtBet: params.currentChapter, + } + + // Update pools + if (params.side === 'yes') { + market.yesPool += params.amount + market.yesCount++ + } else { + market.noPool += params.amount + market.noCount++ + } + market.totalPool += params.amount + + const bets = this.bets.get(params.marketId) ?? [] + bets.push(bet) + this.bets.set(params.marketId, bets) + + // Update portfolio + const portfolio = this.getOrCreatePortfolio(params.bettorAddress) + portfolio.activeBets.push(bet) + portfolio.totalStaked += params.amount + + return bet + } + + /** + * Lock market when resolution chapter is reached. + */ + lockMarket(marketId: string): void { + const market = this.markets.get(marketId) + if (!market) throw new Error(`Market ${marketId} not found`) + market.status = 'locked' + market.lockedAt = Date.now() + } + + /** + * Resolve market using AI oracle after resolution chapter is published. + */ + async resolveMarket(marketId: string, chapterText: string, storyContext: string): Promise<{ + market: TemporalMarket + resolution: OracleResolutionResult + payouts: Map + }> { + const market = this.markets.get(marketId) + if (!market) throw new Error(`Market ${marketId} not found`) + if (market.status !== 'locked' && market.status !== 'resolving') { + throw new Error(`Market must be locked before resolution`) + } + + market.status = 'resolving' + market.resolutionChapterText = chapterText + + // Run AI oracle + const resolution = await this.oracle.resolve({ + marketId, + question: market.question, + criteria: market.resolutionCriteria, + chapterText, + storyContext, + }) + + if (resolution.requiresHumanReview) { + market.status = 'disputed' + market.disputeDeadlineMs = Date.now() + this.DISPUTE_WINDOW_MS + return { market, resolution, payouts: new Map() } + } + + // Settle + const payouts = this.settleMarket(market, resolution.outcome) + market.outcome = resolution.outcome + market.status = 'resolved' + market.resolvedAt = Date.now() + + // Set dispute window (anyone can challenge for 48h) + market.disputeDeadlineMs = Date.now() + this.DISPUTE_WINDOW_MS + + return { market, resolution, payouts } + } + + /** + * Compute payouts for all winners, applying the horizon multiplier. + */ + private settleMarket(market: TemporalMarket, outcome: boolean): Map { + const bets = this.bets.get(market.marketId) ?? [] + const winningSide: 'yes' | 'no' = outcome ? 'yes' : 'no' + const winnerPool = outcome ? market.yesPool : market.noPool + + const winnerPayout = (market.totalPool * this.WINNER_SHARE) / this.FEE_DENOMINATOR + const payouts = new Map() + + for (const bet of bets) { + if (bet.side !== winningSide) continue + if (winnerPool === 0n) continue + + // Base parimutuel payout + const baseShare = (bet.amount * winnerPayout) / winnerPool + + // Apply horizon multiplier (the extra incentive for long-range bets) + // Multiplier applies only to PROFIT (not principal) + const profit = baseShare > bet.amount ? baseShare - bet.amount : 0n + const boostedProfit = BigInt(Math.floor(Number(profit) * bet.appliedMultiplier)) + const totalPayout = bet.amount + boostedProfit + + const current = payouts.get(bet.bettorAddress) ?? 0n + payouts.set(bet.bettorAddress, current + totalPayout) + + // Update portfolio + const portfolio = this.getOrCreatePortfolio(bet.bettorAddress) + const activeBetIdx = portfolio.activeBets.findIndex(b => b.betId === bet.betId) + if (activeBetIdx !== -1) { + const [activeBet] = portfolio.activeBets.splice(activeBetIdx, 1) + portfolio.resolvedBets.push({ ...activeBet, payout: totalPayout, won: true }) + portfolio.totalWon += totalPayout + } + } + + // Mark losers in portfolio + for (const bet of bets) { + if (bet.side === winningSide) continue + const portfolio = this.getOrCreatePortfolio(bet.bettorAddress) + const activeBetIdx = portfolio.activeBets.findIndex(b => b.betId === bet.betId) + if (activeBetIdx !== -1) { + const [activeBet] = portfolio.activeBets.splice(activeBetIdx, 1) + portfolio.resolvedBets.push({ ...activeBet, payout: 0n, won: false }) + portfolio.totalLost += bet.amount + } + } + + return payouts + } + + /** + * Submit a Sage vote for a disputed market resolution. + * Requires Sage tier 4+ (from Cycle #50 Sage Staking Protocol). + */ + submitDisputeVote(vote: DisputeVote): { success: boolean; voteCount: number; leadingOutcome: boolean } { + const market = this.markets.get(vote.marketId) + if (!market || market.status !== 'disputed') { + return { success: false, voteCount: 0, leadingOutcome: false } + } + + if (vote.sageTier < 4) { + return { success: false, voteCount: 0, leadingOutcome: false } // Minimum Sage 4 to vote + } + + const votes = this.disputeVotes.get(vote.marketId) ?? [] + // Weight votes by sage tier + votes.push(vote) + this.disputeVotes.set(vote.marketId, votes) + + // Weighted tally + const weightedYes = votes.filter(v => v.voteOutcome).reduce((sum, v) => sum + v.sageTier, 0) + const weightedNo = votes.filter(v => !v.voteOutcome).reduce((sum, v) => sum + v.sageTier, 0) + const leadingOutcome = weightedYes >= weightedNo + + // Auto-resolve if 10+ Sage votes and clear majority + const totalWeight = weightedYes + weightedNo + if (votes.length >= 10 && (weightedYes / totalWeight > 0.7 || weightedNo / totalWeight > 0.7)) { + const outcome = weightedYes > weightedNo + this.settleMarket(market, outcome) + market.outcome = outcome + market.status = 'resolved' + market.resolvedAt = Date.now() + } + + return { success: true, voteCount: votes.length, leadingOutcome } + } + + /** + * Get all markets for a given chapter range. + */ + getMarketsForChapterRange(fromChapter: number, toChapter: number): TemporalMarket[] { + return Array.from(this.markets.values()).filter( + m => m.resolveAtChapter >= fromChapter && m.resolveAtChapter <= toChapter + ) + } + + /** + * Get a bettor's full temporal portfolio with stats. + */ + getPortfolio(address: string): TemporalPortfolio { + const portfolio = this.getOrCreatePortfolio(address) + + // Compute win rate + const resolved = portfolio.resolvedBets + portfolio.winRate = resolved.length > 0 + ? resolved.filter(b => b.won).length / resolved.length + : 0 + + // Compute longest correct horizon + portfolio.longestCorrectHorizon = resolved + .filter(b => b.won) + .reduce((max, b) => { + const market = this.markets.get(b.marketId) + return Math.max(max, market?.horizon ?? 0) + }, 0) + + return portfolio + } + + /** + * Get the Temporal Oracle Leaderboard. + * Top predictors across all resolved markets. + */ + getLeaderboard(topN = 20): Array<{ + rank: number + address: string + winRate: number + totalWon: bigint + longestHorizon: number + multiplierTier: string + }> { + const allAddresses = new Set() + for (const bets of this.bets.values()) { + for (const bet of bets) { + allAddresses.add(bet.bettorAddress) + } + } + + return Array.from(allAddresses) + .map(addr => { + const portfolio = this.getPortfolio(addr) + const maxMultiplier = getHorizonMultiplier(portfolio.longestCorrectHorizon) + return { + address: addr, + winRate: portfolio.winRate, + totalWon: portfolio.totalWon, + longestHorizon: portfolio.longestCorrectHorizon, + multiplierTier: getMultiplierTier(maxMultiplier).tier, + } + }) + .filter(p => p.totalWon > 0n) + .sort((a, b) => { + // Sort by win rate ร— total won (combined score) + const scoreA = a.winRate * Number(a.totalWon) + const scoreB = b.winRate * Number(b.totalWon) + return scoreB - scoreA + }) + .slice(0, topN) + .map((p, i) => ({ ...p, rank: i + 1 })) + } + + private getOrCreatePortfolio(address: string): TemporalPortfolio { + let portfolio = this.portfolios.get(address) + if (!portfolio) { + portfolio = { + address, + activeBets: [], + resolvedBets: [], + totalStaked: 0n, + totalWon: 0n, + totalLost: 0n, + winRate: 0, + longestCorrectHorizon: 0, + } + this.portfolios.set(address, portfolio) + } + return portfolio + } + + getMarket(marketId: string): TemporalMarket | undefined { + return this.markets.get(marketId) + } + + getAllMarkets(): TemporalMarket[] { + return Array.from(this.markets.values()) + } + + getOpenMarkets(): TemporalMarket[] { + return Array.from(this.markets.values()).filter(m => m.status === 'open') + } +} + +// โ”€โ”€โ”€ Demo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function runTemporalMarketsDemo(): void { + console.log('๐Ÿ“… TEMPORAL ORACLE MARKETS โ€” Simulation\n') + + const manager = new TemporalMarketManager() + + // Create three markets with different horizons + const markets = [ + manager.createMarket({ + question: 'Will House Valdris still hold the Silent Throne by Chapter 40?', + detail: 'House Valdris currently controls the throne. This market resolves YES if they retain control at Chapter 40 resolution.', + openAtChapter: 28, + resolveAtChapter: 40, // 12-chapter horizon + resolutionCriteria: 'House Valdris representative is confirmed as throne-holder in the chapter', + }), + manager.createMarket({ + question: 'Will the Void Gate open before Chapter 50?', + detail: 'The Void Gate is referenced throughout the saga. Resolves YES if the Gate opens or is activated in any chapter before or at Chapter 50.', + openAtChapter: 28, + resolveAtChapter: 50, // 22-chapter horizon + resolutionCriteria: 'The text explicitly describes the Void Gate opening, activating, or being breached', + }), + manager.createMarket({ + question: 'Will a non-heir character assume the Silent Throne by Chapter 100?', + detail: 'This market resolves YES if any character other than the designated heir takes the throne by Chapter 100.', + openAtChapter: 28, + resolveAtChapter: 100, // 72-chapter horizon + resolutionCriteria: 'A character explicitly described as NOT the original heir is confirmed as throne-holder', + }), + ] + + for (const market of markets) { + manager.openMarket(market.marketId) + const tier = getMultiplierTier(market.multiplier) + console.log(`๐Ÿ“Š Market: "${market.question.slice(0, 50)}..."`) + console.log(` Horizon: ${market.horizon} chapters | Multiplier: ${market.multiplier.toFixed(2)}x | Tier: ${tier.tier}`) + } + + console.log('\n๐Ÿ’ฐ Placing bets...\n') + + // Simulate bets on the markets + const bettor1 = '0xAlice' + const bettor2 = '0xBob' + + const bet1 = manager.placeBet({ + marketId: markets[0].marketId, + bettorAddress: bettor1, + side: 'yes', + amount: BigInt(50_000_000), // 50 USDC + currentChapter: 28, + }) + + const bet2 = manager.placeBet({ + marketId: markets[1].marketId, + bettorAddress: bettor1, + side: 'no', + amount: BigInt(100_000_000), // 100 USDC + currentChapter: 28, + }) + + const bet3 = manager.placeBet({ + marketId: markets[2].marketId, + bettorAddress: bettor2, + side: 'yes', + amount: BigInt(25_000_000), // 25 USDC + currentChapter: 28, + }) + + console.log(`${bettor1} bet 50 USDC YES on throne question (horizon: ${markets[0].horizon}, mult: ${bet1.appliedMultiplier.toFixed(2)}x)`) + console.log(`${bettor1} bet 100 USDC NO on Void Gate question (horizon: ${markets[1].horizon}, mult: ${bet2.appliedMultiplier.toFixed(2)}x)`) + console.log(`${bettor2} bet 25 USDC YES on non-heir question (horizon: ${markets[2].horizon}, mult: ${bet3.appliedMultiplier.toFixed(2)}x)`) + + console.log('\n๐Ÿ“ˆ Multiplier Curve Demo:') + const horizons = [1, 5, 10, 15, 20, 30, 50, 72, 100] + for (const h of horizons) { + const mult = getHorizonMultiplier(h) + const tier = getMultiplierTier(mult) + console.log(` Horizon ${h.toString().padStart(3)} chapters: ${mult.toFixed(2)}x (${tier.tier})`) + } + + console.log('\n๐Ÿ† Temporal Oracle Leaderboard (simulated):') + console.log(' After market resolution, top predictors appear here') + console.log(' Ranked by: win rate ร— total winnings ร— max horizon achieved') +} diff --git a/packages/database/prisma/migrations/20260218_prophecy_nft/migration.sql b/packages/database/prisma/migrations/20260218_prophecy_nft/migration.sql new file mode 100644 index 0000000..9d8f76d --- /dev/null +++ b/packages/database/prisma/migrations/20260218_prophecy_nft/migration.sql @@ -0,0 +1,74 @@ +-- Migration: 20260218_prophecy_nft +-- Prophecy NFT System (Innovation Cycle #49 โ€” "The Living Cosmos") +-- Add Prophecy and ProphecyMint models + +-- โ”€โ”€โ”€ Enums โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +CREATE TYPE "ProphecyStatus" AS ENUM ('PENDING', 'UNFULFILLED', 'ECHOED', 'FULFILLED'); + +-- โ”€โ”€โ”€ Prophecy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +CREATE TABLE "prophecies" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "chapterId" TEXT NOT NULL, + "contentHash" TEXT NOT NULL, + "text" TEXT, + "teaser" TEXT NOT NULL, + "pendingURI" TEXT NOT NULL, + "fulfilledURI" TEXT, + "echoedURI" TEXT, + "unfulfilledURI" TEXT, + "mintedCount" INTEGER NOT NULL DEFAULT 0, + "maxSupply" INTEGER NOT NULL DEFAULT 100, + "revealed" BOOLEAN NOT NULL DEFAULT false, + "revealedAt" TIMESTAMP(3), + "status" "ProphecyStatus" NOT NULL DEFAULT 'PENDING', + "fulfilledAt" TIMESTAMP(3), + "targetEvent" TEXT, + "relevanceScore" DOUBLE PRECISION, + "artTheme" TEXT, + + CONSTRAINT "prophecies_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "prophecies_chapterId_status_idx" ON "prophecies"("chapterId", "status"); +CREATE INDEX "prophecies_status_idx" ON "prophecies"("status"); + +ALTER TABLE "prophecies" + ADD CONSTRAINT "prophecies_chapterId_fkey" + FOREIGN KEY ("chapterId") REFERENCES "chapters"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- โ”€โ”€โ”€ ProphecyMint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +CREATE TABLE "prophecy_mints" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "prophecyId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "mintOrder" INTEGER NOT NULL, + "tokenId" TEXT, + "txHash" TEXT, + "forgePaid" DOUBLE PRECISION NOT NULL DEFAULT 5, + + CONSTRAINT "prophecy_mints_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "prophecy_mints_prophecyId_mintOrder_key" + ON "prophecy_mints"("prophecyId", "mintOrder"); + +CREATE UNIQUE INDEX "prophecy_mints_prophecyId_userId_key" + ON "prophecy_mints"("prophecyId", "userId"); + +CREATE INDEX "prophecy_mints_userId_idx" ON "prophecy_mints"("userId"); +CREATE INDEX "prophecy_mints_prophecyId_idx" ON "prophecy_mints"("prophecyId"); + +ALTER TABLE "prophecy_mints" + ADD CONSTRAINT "prophecy_mints_prophecyId_fkey" + FOREIGN KEY ("prophecyId") REFERENCES "prophecies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "prophecy_mints" + ADD CONSTRAINT "prophecy_mints_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index d5fb673..45d78b7 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { notifications Notification[] notificationPreference NotificationPreference? userHouses UserHouse[] // House affiliations + prophecyMints ProphecyMint[] // Prophecy NFT mints @@index([walletAddress]) @@map("users") @@ -132,6 +133,7 @@ model Chapter { // Relationships choices Choice[] bettingPool BettingPool? + prophecies Prophecy[] // Prophecy NFTs seeded for this chapter @@unique([storyId, chapterNumber]) @@index([storyId, chapterNumber]) @@ -760,3 +762,90 @@ model NotificationPreference { @@map("notification_preferences") } + +// ============================================================================ +// PROPHECY NFTs (Innovation Cycle #49 โ€” "The Living Cosmos") +// ============================================================================ + +/// A cryptic prophecy seeded by the AI before each chapter resolves. +/// Up to 100 NFT slots per prophecy. Art transforms post-resolution. +model Prophecy { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Which chapter this prophecy belongs to + chapterId String + chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade) + + // On-chain seal: keccak256(text) stored before reveal + contentHash String // hex bytes32 + // Plaintext (null until minting window closes and oracle reveals) + text String? @db.Text + // Teaser shown to readers before reveal (2-4 words, sealed) + teaser String + + // IPFS metadata URIs per fulfillment state + pendingURI String // Shown while chapter is open + fulfilledURI String? // Legendary golden art + echoedURI String? // Silver art + unfulfilledURI String? // Dark relic art + + // Mint status + mintedCount Int @default(0) + maxSupply Int @default(100) + + // Revelation & fulfillment + revealed Boolean @default(false) + revealedAt DateTime? + status ProphecyStatus @default(PENDING) + fulfilledAt DateTime? + + // AI metadata + targetEvent String? @db.Text // What the AI intended this to reference + relevanceScore Float? // 0-1 AI confidence score + artTheme String? // 'void' | 'light' | 'shadow' | etc. + + // NFT mints + mints ProphecyMint[] + + @@index([chapterId, status]) + @@index([status]) + @@map("prophecies") +} + +enum ProphecyStatus { + PENDING // Chapter not yet resolved + UNFULFILLED // Prophecy did not come true + ECHOED // Thematic match (silver, 3ร— value) + FULFILLED // Direct match (legendary golden, 10ร— value) +} + +/// One minted NFT token for a prophecy. +model ProphecyMint { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + + prophecyId String + prophecy Prophecy @relation(fields: [prophecyId], references: [id], onDelete: Cascade) + + // Minter info + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + walletAddress String + + // Token metadata + mintOrder Int // 1-100 (lower = more prestigious) + tokenId String? // On-chain ERC-721 token ID (set once deployed) + txHash String? // Transaction hash + + // Cost + forgePaid Float @default(5) // $FORGE paid (5 base, 4.5 with oracle pack) + + @@unique([prophecyId, mintOrder]) + @@unique([prophecyId, userId]) // One mint per user per prophecy + @@index([userId]) + @@index([prophecyId]) + @@map("prophecy_mints") +} +