Skip to main content

Implementation Plan - Sprint 15: The Global Market & Gacha System (REVISED)

This document outlines the detailed, file-by-file architectural implementation plan for Sprint 15 (The Global Market & Gacha System) in the pcmtg-core monorepo. It establishes a secure, zero-trust, serverless-safe backend transaction ledger utilizing Google Cloud Memorystore (Redis) rate-limiting, coupled with a declared ideological faction system and a premium glassmorphic frontend interface.

🛠 Architectural Constraints & Rules

1. Atomic Economy (Zero-Trust)

To prevent double-spending and race conditions, all market purchases (items, policies, and card packs) must execute within a prisma.$transaction block.
  • A pessimistic row lock is obtained on the player’s wallet using raw SQL FOR UPDATE before any validation or balance checks.
  • User balances must be validated to remain >= 0 after deducting costs.
  • The deduction of the cost, creation of the OwnedCard relation, and insertion of the TransactionLog receipt must occur atomically.

2. Cloud Run Horizontal Scaling (Redis Rate Limiter)

  • Problem: Stateless Cloud Run containers scale horizontally; local in-memory Maps are useless across multiple container instances and allow Sybil attacks.
  • Solution: Utilize the Google Cloud Memorystore (Redis) instance provisioned in Sprint 2. Implement a Redis sliding window rate-limiter keyed on req.playerId to enforce a strict limit of 5 requests per 10 seconds across all /market/* endpoints.

3. Identity & The “Based Pill” Defection Mechanic

  • Problem: Dynamically deriving a player’s quadrant from ELO coordinates breaks the “Based Pill” ideological defection mechanic (Project Bible 3.2.5), which allows players to swap quadrants without resetting their coordinate footprint.
  • Solution: Query the player’s explicitly declared quadrant string field directly from the PlayerAccount table. Map native currency strictly based on this string:
    • AUTH_RIGHT \rightarrow dinars
    • AUTH_LEFT \rightarrow labor
    • LIB_LEFT \rightarrow pronouns
    • LIB_RIGHT \rightarrow monke

4. Asymmetrical Cross-Quadrant Gacha (The Meme Bazaar Economy)

  • Problem: Generic ‘STANDARD’ and ‘PREMIUM’ packs violate the asymmetrical cross-quadrant economy (Project Bible 3.3.3).
  • Solution: Refactor POST /market/buy-pack to accept { targetQuadrant } in the payload:
    • Native Synthesis Pack: Cost is 20 units of Native Currency if targetQuadrant matches the player’s declared native quadrant.
    • Defector Synthesis Pack: Cost is 50 units of Native Currency if targetQuadrant is different (representing buying into an opposing ideology).
    • Query Filter: The Gacha candidates query must strictly filter by affinity = targetQuadrant and cardType = 'MEME'.
    • Cryptographic Gacha Math: Drop rates are calculated server-side using the Node.js native crypto module (crypto.randomInt) to prevent client-side seed prediction.

🗺 Proposed Changes

1. Database & Schema Layer

[MODIFY] schema.prisma

Add the explicitly declared quadrant field to the PlayerAccount model so players can defect/swap quadrants without losing their coordinate footprints.
model PlayerAccount {
  id           String    @id @default(dbgenerated("uuid_generate_v4()")) @map("player_id") @db.Uuid
  username     String    @unique @db.VarChar(50)
  email        String    @unique @db.VarChar(255)
  elo          Int       @default(1000) @map("elo")
  elo_x_axis   Decimal   @default(0.0000) @db.Decimal(8, 4)
  elo_y_axis   Decimal   @default(0.0000) @db.Decimal(8, 4)
  elo_z_axis   Decimal   @default(0.0000) @db.Decimal(8, 4)
  
  // Declared ideological quadrant (Direct field for defection mechanics)
  quadrant     String    @default("AUTH_RIGHT") @map("quadrant")
  
  created_at   DateTime? @default(now()) @db.Timestamptz
  last_login   DateTime? @db.Timestamptz
  
  playerWallet PlayerWallet?
  transactions TransactionLog[] 
  ownedCards   OwnedCard[] 

  @@map("player_accounts")
}
[!NOTE] A corresponding migration (or raw SQL run during setup) will add this column: ALTER TABLE player_accounts ADD COLUMN IF NOT EXISTS quadrant VARCHAR(20) DEFAULT 'AUTH_RIGHT';

2. Backend Layer (The Transaction & Security Engine)

[MODIFY] package.json

Install ioredis to manage high-throughput sliding-window rate limiting on Memorystore.
"dependencies": {
  ...
  "ioredis": "^5.4.1"
}

[NEW] redis.js

Export a centralized Redis connection pool for connection reuse across serverless requests.
const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379', 10),
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    const delay = Math.min(times * 100, 3000);
    return delay;
  }
});

redis.on('error', (err) => {
  console.error('[REDIS CLIENT ERROR]:', err.message);
});

module.exports = redis;

[NEW] rateLimiter.js

Atomic sliding window rate limiter middleware utilizing Redis multi-exec pipelines.
const redis = require('../utils/redis');

const marketRateLimiter = async (req, res, next) => {
  const playerId = req.playerId;
  if (!playerId) {
    return res.status(401).json({ error: 'Unauthorized: Missing player identity' });
  }

  const key = `rate_limit:market:${playerId}`;
  const now = Date.now();
  const windowMs = 10000; // 10 seconds
  const clearBefore = now - windowMs;

  try {
    const multi = redis.multi();
    multi.zremrangebyscore(key, 0, clearBefore);
    multi.zadd(key, now, now);
    multi.zcard(key);
    multi.expire(key, 10); // TTL cleanup

    const results = await multi.exec();
    
    // ZCARD command is at index 2 of the multi pipeline results
    const requestCount = results[2][1];

    if (requestCount > 5) {
      return res.status(429).json({
        error: 'Too Many Requests',
        message: 'Rate limit exceeded. You can only make 5 transactions/requests every 10 seconds.'
      });
    }

    next();
  } catch (error) {
    console.error('[RATE LIMITER ERROR - FAILING OPEN]:', error.message);
    // Fail-open to preserve playability if Redis encounters a temporary outage
    next();
  }
};

module.exports = marketRateLimiter;

[NEW] market.js

The core market controller router that handles purchasing single modifier items/policies and opening cross-quadrant Gacha packs.
  • Declared Quadrant helper:
    const { QUADRANT_CURRENCY_MAP } = require('../utils/cardUtils');
    
    async function derivePlayerQuadrantAndCurrency(tx, playerId) {
      const player = await tx.playerAccount.findUnique({
        where: { id: playerId },
        select: { quadrant: true }
      });
    
      if (!player) {
        throw new Error('Player account not found');
      }
    
      const quadrant = player.quadrant || 'AUTH_RIGHT';
      const currency = QUADRANT_CURRENCY_MAP[quadrant];
    
      if (!currency) {
        throw new Error(`Invalid player quadrant alignment: ${quadrant}`);
      }
    
      return { quadrant, currency };
    }
    
  • GET /market/spot
    • Fetches the active list of cards with cardType equal to 'ITEM' or 'POLICY' from the Card table.
    • Returns card items with their details, metadata, and costs across currencies.
  • POST /market/buy-item
    • Payload: { cardId }
    • Prisma Interactive Transaction Flow:
      1. Lock the player’s wallet row using raw SQL FOR UPDATE to ensure atomic isolation:
        await tx.$executeRaw`SELECT * FROM player_wallets WHERE player_id = ${req.playerId}::uuid FOR UPDATE;`;
        
      2. Query player’s declared quadrant and map their native wallet currency (e.g. 'AUTH_RIGHT' \rightarrow 'dinars').
      3. Fetch target modifier card by cardId and validate cost (e.g. if player currency is dinars, read marketCostDinars from the card). Cost must be >0> 0.
      4. Guard against duplicates: Check if the player already owns this specific item or policy in the OwnedCard table. If so, abort with 400 Bad Request to prevent duplicate passive modifiers.
      5. Deduct the cost from the appropriate native currency column in the wallet, ensuring the final balance 0\ge 0.
      6. Mint the card to the user: Create record in OwnedCard.
      7. Write transaction log: Record entry in TransactionLog with type 'MARKET_BUY_ITEM'.
  • POST /market/buy-pack
    • Payload: { targetQuadrant } (e.g. 'AUTH_RIGHT', 'AUTH_LEFT', 'LIB_LEFT', 'LIB_RIGHT')
    • Prisma Interactive Transaction Flow:
      1. Lock player’s wallet row using raw SQL FOR UPDATE.
      2. Query player’s declared native quadrant and currency mapping.
      3. Determine the Gacha pack cost based on cross-quadrant dynamics:
        • If targetQuadrant === player.quadrant: Cost is 20 units (Native Synthesis Pack).
        • If targetQuadrant !== player.quadrant: Cost is 50 units (Defector Synthesis Pack).
      4. Verify and deduct funds from the player’s native currency field.
      5. Cryptographic Gacha Rolling:
        • Roll rarity for each of the 3 cards in the pack using server-side crypto.randomInt(0, 100):
          • Native/Defector Synthesis Drop Rates:
            • COMMON: 60% (Roll 0-59)
            • UNCOMMON: 25% (Roll 60-84)
            • RARE: 10% (Roll 85-94)
            • EPIC: 4% (Roll 95-98)
            • LEGENDARY: 1% (Roll 99)
        • For each rolled rarity, query Card registry strictly filtering by:
          • cardType = 'MEME'
          • affinity = targetQuadrant (Mapped from QuadrantAffinity enum)
          • rarity = rolledRarity
        • Randomly select a card ID from the candidates using crypto.randomInt(0, candidates.length).
        • Graceful Fallback: If no cards are found for the rolled rarity under the target quadrant, search for adjacent rarities of targetQuadrant or a default card to prevent empty roll errors.
      6. Create 3 OwnedCard entries in the database representing the opened cards.
      7. Log transaction in TransactionLog with type 'MARKET_BUY_PACK'.
      8. Commit transaction and return full details of the 3 minted cards in the response.

[MODIFY] index.js

  • Import the new market router and mount it under /market.
  • Import and apply the Redis marketRateLimiter middleware to protect /market/* routes.

3. Frontend Layer (Marketplace UI Shell)

[NEW] page.tsx

An extremely premium, responsive, glassmorphic marketplace interface structured with React state-driven components.
  • Key Features & Interactive State Flows:
    • Tabs: Toggle between 'SPOT_MARKET' (Modifier Items/Policies) and 'MEME_BAZAAR' (Gacha Pack Synthesis).
    • Active State Syncing:
      • Displays real-time balances for the player’s native currency with interactive visual shifts.
      • Synchronizes directly with the global <WalletOverlay /> component via incrementing a walletSyncTrigger counter state upon a successful transaction, forcing a silent backend refresh of current funds.
    • Spot Market Item Cards:
      • Highlight items with glassmorphic cards.
      • If the user already owns a single-use modifier card, overlay a high-contrast "OWNED" banner and disable the purchase button.
    • Meme Bazaar Pack Opening Overlay:
      • Offers 4 synthesized pack options, representing the 4 political quadrants. Hovering dynamically highlights the pack cost (20 native currency if matching the player’s declared native faction, 50 native currency if different, with a clean visual tooltip explaining the “Cross-Quadrant Ideological Premium”).
      • Clicking buy triggers a cinematic opening overlay with:
        1. Shaking booster pack animation via framer-motion.
        2. Tearing pack wrapper clip-path animation.
        3. Revealing 3 cards positioned side-by-side, face down.
        4. Clicking each card triggers a realistic 3D Y-axis flip (rotateY: 180) to reveal the meme.
        5. Each card features an elegant, rarity-colored drop shadow glow:
          • COMMON: Subtle silver glow (shadow-zinc-500/20)
          • UNCOMMON: Emerald radiance (shadow-emerald-500/35)
          • RARE: Ocean cobalt glow (shadow-blue-500/50)
          • EPIC: Regal purple neon aura (shadow-purple-500/70 shadow-[0_0_20px_rgba(168,85,247,0.4)])
          • LEGENDARY: Golden celestial fire (shadow-amber-500/90 shadow-[0_0_35px_rgba(245,158,11,0.6)] animate-pulse)

🔒 Exploitation Defense Guardrails

  1. Anti-Sybil Rate Limiting (Redis Sliding Window): Stateless instances running under Cloud Run load-balancer pools read and write to Google Memorystore. Parallel “double-click” requests are instantly logged, evaluated, and throttled atomically across container instances.
  2. Double-Spend Row Locks: Using SELECT ... FOR UPDATE row locks on the PlayerWallet within an interactive transaction prevents multi-threaded balance bypass hacks. No reads or state determinations are trusted until the exclusive lock is acquired.
  3. Declared Quadrant Enforcement: Player quadrant values are read directly from the declared database record, safeguarding ideological faction structures and defection mechanics.
  4. Passive Duplicate Protection: The backend transaction engine explicitly aborts card minting if a player already owns an item/policy modifier, sealing the game against duplicate passive buffs.

🧪 Verification & Testing Plan

Automated Test Suite

We will construct an integration test script backend/scripts/test-market.js to execute and verify:
  • Rate Limiting Resilience: Simulate a rapid-fire burst of 10 requests within 1 second. Verify that the first 5 requests return 200/201 OK, and requests 6 through 10 are strictly blocked with 429 Too Many Requests.
  • Race Condition Resistance: Fire 5 concurrent item purchase requests for the same unique card item simultaneously using Promise.all. Verify that exactly one succeeds, while the other 4 return error codes indicating “Duplicate Item” or “Sufficient Row Locked” aborts.
  • Asymmetric Pricing Compliance: Execute test mock purchases for players in different quadrants buying matching/different packs. Verify that native purchases charge 20 units and cross-quadrant purchases charge 50 units.
  • Gacha Distribution Math: Open 100 Gacha packs in test mode and log the frequency distribution to ensure it closely maps to weighted drop rate probabilities.

Manual Verification

  • Verify high-fidelity rendering, responsive sizing, and fluid 3D card flips across desktop, tablet, and mobile breakpoints.
  • Ensure visual indicators dynamically switch symbols based on user faction (e.g. đ, ⚒, ❀, 🐒).
  • Confirm global <WalletOverlay /> syncs immediately without requiring full page loads.