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/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/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 && ( 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/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/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/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") +} +