diff --git a/docs/AGENT_STATUS.md b/docs/AGENT_STATUS.md new file mode 100644 index 00000000..aa39924f --- /dev/null +++ b/docs/AGENT_STATUS.md @@ -0,0 +1,38 @@ +# Agent Status + +> Updated at the end of each sprint by the responsible agent. + +--- + +## Gamma Agent + +### Sprint 0 — Foundation + +| Deliverable | Status | +|---|---| +| Audit `firestore.rules` and `firestore.indexes.json` | ✅ shipped | +| Document current collection shapes in `docs/DATA_MODEL.md` | ✅ shipped | +| Create shared contract file `src/lib/sharedTypes.ts` | ✅ shipped | +| Create feature flags registry `src/lib/featureFlags.ts` | ✅ shipped | +| Create server stubs: `battlePass.js`, `ranked.js`, `crews.js`, `dailyRewards.js` | ✅ shipped | +| Add read-only Firestore stubs: `dailyStreaks`, `missions`, `battlePass`, `crews`, `rankedSeasons`, `shareLinks` | ✅ shipped | + +### Sprint 1 — Core Engagement Loop + +| Deliverable | Status | PR | +|---|---|---| +| Charge Up system (8h free forge timer, capped rarity) | ✅ shipped | #354 | +| Daily login streak (7-day escalating Ozzies rewards) | ✅ shipped | #355 | +| Daily Missions (3/day, 10 templates, 5 types, XP+Ozzies) | ✅ shipped | #356 | +| Weekly Heat card + weather quest rotation | ✅ shipped | #357 | +| Battle Pass (30-tier, 6-week season, free+premium tracks) | ✅ shipped | #358 | + +--- + +## Charlie Agent + +### Sprint 0 + +| Deliverable | Status | +|---|---| +| _(awaiting Charlie's first sprint)_ | — | diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 00000000..be26b933 --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,301 @@ +# Firestore Data Model + +> Auto-generated from `firestore.rules`, `firestore.indexes.json`, and `src/lib/types.ts`. +> Last updated: Sprint 0. + +--- + +## Collections Overview + +| Collection | Scope | Key | Access Pattern | +|---|---|---|---| +| `users/{uid}/cards` | Sub-collection | `cardId` | Owner read/write | +| `users/{uid}/decks` | Sub-collection | `deckId` | Owner read/write | +| `userProfiles` | Top-level | `uid` | Owner + admin read; validated create/update | +| `userLookup` | Top-level | `uid` | Any authed read; owner create/update | +| `imageCache` | Top-level | `cacheKey` (layer+seed hash) | Public read; authed create; no update; admin delete | +| `trades` | Top-level | `tradeId` | Participant + pending-browse read; offerer create; recipient/offerer update | +| `referralClaims` | Top-level | `{referrerUid}_{visitorKey}` | Referrer read; anyone create (no self-referral); immutable | +| `arena` | Top-level | `uid` | Any authed read; owner write/delete | +| `battleResults` | Top-level | `resultId` | Participant read; server-only write | +| `leaderboard` | Top-level | `uid` | Any authed read; owner write | +| `factionImages` | Top-level | `factionKey` (slug) | Public read; admin write/delete | + +--- + +## Document Shapes + +### `users/{uid}/cards/{cardId}` — CardPayload + +Owner-only sub-collection. Each document mirrors `CardPayload` from `src/lib/types.ts`. + +``` +{ + id: string, // same as doc ID + version: string, + seed: string, // "frameSeed::backgroundSeed::characterSeed" + frameSeed: string, + backgroundSeed: string, + characterSeed: string, + prompts: { + archetype: Archetype, + rarity: Rarity, + style: Style, + vibe?: Vibe, // deprecated + district: District, + accentColor: string, + gender: Gender, + ageGroup: AgeGroup, + bodyType: BodyType, + hairLength?: HairLength, + hairColor?: HairColor, + skinTone?: SkinTone, + faceCharacter?: FaceCharacter, + shoeStyle?: ShoeStyle, + }, + identity: { + name: string, + crew: Faction, + serialNumber: string, + age?: string, + }, + stats: { + speed: number, + stealth: number, + tech: number, + grit: number, + rep: number, + }, + traits: { + passiveTrait: { name: string, description: string }, + activeAbility: { name: string, description: string }, + personalityTags: string[], + }, + visuals: { + helmetStyle: string, + boardStyle: string, + jacketStyle: string, + colorScheme: string, + accentColor: string, + storagePackStyle: string, + }, + flavorText: string, + tags: string[], + ozzies?: number, // $1.00–$100.00 + board?: BoardConfig, + boardLoadout?: BoardLoadout, + boardImageUrl?: string, + createdAt: string, + imageUrl?: string, // legacy single-image + backgroundImageUrl?: string, + characterImageUrl?: string, + frameImageUrl?: string, + conlang?: ConlangOverlay, + discovery?: { + displayArchetype?: string, + revealedFaction?: Faction, + isSecretReveal?: boolean, + logoMark?: string, + unlockedAt?: string, + }, +} +``` + +### `users/{uid}/decks/{deckId}` — DeckPayload + +Owner-only sub-collection. + +``` +{ + id: string, + version: string, + name: string, + cards: CardPayload[], // embedded card array + createdAt: string, + updatedAt: string, + sortOrder?: number, + battleReady?: boolean, +} +``` + +### `userProfiles/{uid}` + +Private profile. Owner + admin read. Validated field allowlist on create/update. + +``` +{ + uid: string, + email: string, + emailLower: string, + displayName: string, + discoveredFactions: any, // faction discovery state + updatedAt: Timestamp, +} +``` + +**Create allowlist:** `uid`, `email`, `emailLower`, `displayName`, `discoveredFactions`, `updatedAt`. +**Update allowlist:** `email`, `emailLower`, `displayName`, `discoveredFactions`, `updatedAt`. + +### `userLookup/{uid}` + +Minimal public directory for trade recipient lookup. + +``` +{ + uid: string, + emailLower: string, + displayName: string, + updatedAt: Timestamp, +} +``` + +### `imageCache/{cacheKey}` + +Fal.ai image URL cache keyed by layer+seed. Public read, authed create, immutable. + +``` +{ + imageUrl: string, // must match fal.media or Firebase Storage URL pattern + createdAt: Timestamp, + prompt?: string, // ≤ 512 chars + layer?: string, // ≤ 64 chars + seed?: string, // ≤ 512 chars +} +``` + +### `trades/{tradeId}` — TradePayload + +Peer-to-peer card trades and Community Market listings. + +``` +{ + id: string, + fromUid: string, + fromEmail: string, + toUid: string, + toEmail: string, + offeredCardId?: string, + offeredCard: CardPayload, // embedded snapshot + status: "pending" | "accepted" | "declined" | "cancelled", + createdAt: string, + updatedAt: string, +} +``` + +### `referralClaims/{referrerUid}_{visitorKey}` + +Immutable referral tracking. Unauthenticated create allowed (no self-referral). + +``` +{ + referrerUid: string, + visitorKey: string, + claimedAt: Timestamp, +} +``` + +### `arena/{uid}` — ArenaEntry + +Public battle-ready deck listings. Owner write/delete. + +``` +{ + uid: string, + displayName: string, + deckId: string, + deckName: string, + cardCount: number, + battleSummary?: { + deckPower: number, + strongestStat: StatKey, + strongestStatTotal: number, + synergyBonusPct: number, + archetypeHint: string, + }, + battleDeck?: BattleCardSnapshot[], + readiedAt: string, +} +``` + +### `battleResults/{resultId}` — BattleResult + +Server-written battle outcomes. Participant read only. + +``` +{ + id: string, + challengerUid: string, + challengerDeckId: string, + challengerDeckName: string, + defenderUid: string, + defenderDeckId: string, + defenderDeckName: string, + winnerUid: string, + challengerScore: number, + defenderScore: number, + wagerPoints: number, + winningDeckCardIds: string[], + challengerCardResolutions: BattleCardResolution[], + defenderCardResolutions: BattleCardResolution[], + createdAt: string, +} +``` + +### `leaderboard/{uid}` — LeaderboardEntry + +Public leaderboard. Owner write. + +``` +{ + uid: string, + displayName: string, + deckName: string, + cardCount: number, + deckPower: number, + ozzies: number, + strongestStat: StatKey, + strongestStatTotal: number, + synergyBonusPct: number, + archetypeHint: string, + updatedAt: string, +} +``` + +### `factionImages/{factionKey}` + +Faction background images. Public read, admin write. + +``` +{ + imageUrl: string, // faction background image URL + updatedAt?: Timestamp, +} +``` + +--- + +## Composite Indexes (`firestore.indexes.json`) + +| Collection | Fields | Query Scope | +|---|---|---| +| `trades` | `status` ASC, `createdAt` DESC | COLLECTION | +| `leaderboard` | `deckPower` DESC, `ozzies` DESC | COLLECTION | + +--- + +## New Collections (Sprint 0 — read-only stubs) + +The following collections are defined in `firestore.rules` as read-only stubs +(authenticated read, no client write) pending full implementation: + +| Collection | Purpose | Owner | +|---|---|---| +| `dailyStreaks/{uid}` | Daily login streak tracking | Gamma | +| `missions/{missionId}` | Per-user mission / quest progress | Gamma | +| `battlePass/{uid}` | Battle pass tier + XP state | Gamma | +| `crews/{crewId}` | Player crew / guild membership | Charlie | +| `rankedSeasons/{seasonId}` | Ranked season config + standings | Charlie | +| `shareLinks/{linkId}` | Shareable card / deck links | Charlie | + +Document shapes for these collections will be defined in `src/lib/sharedTypes.ts` +as implementation progresses. diff --git a/firestore.rules b/firestore.rules index 66fd49a7..0ad97277 100644 --- a/firestore.rules +++ b/firestore.rules @@ -159,5 +159,54 @@ service cloud.firestore { && request.auth.token.admin == true; } + // ══════════════════════════════════════════════════════════════════════════ + // New collections — read-only stubs (Sprint 0) + // Client reads allowed for authenticated users; all writes are server-only + // until the corresponding feature passes QA and the flag is enabled. + // ══════════════════════════════════════════════════════════════════════════ + + // ── Daily Streaks ──────────────────────────────────────────────────────── + // Per-user daily login streak. Doc ID = uid. + match /dailyStreaks/{uid} { + allow read: if request.auth != null && request.auth.uid == uid; + allow create, update, delete: if false; + } + + // ── Missions ───────────────────────────────────────────────────────────── + // Per-user mission / quest progress. + match /missions/{missionId} { + allow read: if request.auth != null + && resource.data.uid == request.auth.uid; + allow create, update, delete: if false; + } + + // ── Battle Pass ────────────────────────────────────────────────────────── + // Per-user battle pass tier + XP state. Doc ID = uid. + match /battlePass/{uid} { + allow read: if request.auth != null && request.auth.uid == uid; + allow create, update, delete: if false; + } + + // ── Crews ──────────────────────────────────────────────────────────────── + // Player crew / guild. Any authed user can browse; writes are server-only. + match /crews/{crewId} { + allow read: if request.auth != null; + allow create, update, delete: if false; + } + + // ── Ranked Seasons ─────────────────────────────────────────────────────── + // Season config + standings. Any authed user can read. + match /rankedSeasons/{seasonId} { + allow read: if request.auth != null; + allow create, update, delete: if false; + } + + // ── Share Links ────────────────────────────────────────────────────────── + // Publicly viewable card / deck share links. + match /shareLinks/{linkId} { + allow read: if true; + allow create, update, delete: if false; + } + } } diff --git a/server/battlePass.js b/server/battlePass.js new file mode 100644 index 00000000..a694ee94 --- /dev/null +++ b/server/battlePass.js @@ -0,0 +1,16 @@ +/** + * server/battlePass.js — Battle pass progression handlers. + * Stubbed as no-op handlers so routes can be wired without errors. + */ + +export function getBattlePassState(_req, res) { + res.json({ ok: true, data: null }); +} + +export function claimBattlePassReward(_req, res) { + res.json({ ok: true, data: null }); +} + +export function advanceBattlePassTier(_req, res) { + res.json({ ok: true, data: null }); +} diff --git a/server/crews.js b/server/crews.js new file mode 100644 index 00000000..c4a1119d --- /dev/null +++ b/server/crews.js @@ -0,0 +1,20 @@ +/** + * server/crews.js — Crew / guild management handlers. + * Stubbed as no-op handlers so routes can be wired without errors. + */ + +export function getCrew(_req, res) { + res.json({ ok: true, data: null }); +} + +export function createCrew(_req, res) { + res.json({ ok: true, data: null }); +} + +export function joinCrew(_req, res) { + res.json({ ok: true, data: null }); +} + +export function leaveCrew(_req, res) { + res.json({ ok: true, data: null }); +} diff --git a/server/dailyRewards.js b/server/dailyRewards.js new file mode 100644 index 00000000..ddf5e32b --- /dev/null +++ b/server/dailyRewards.js @@ -0,0 +1,12 @@ +/** + * server/dailyRewards.js — Daily login reward / streak handlers. + * Stubbed as no-op handlers so routes can be wired without errors. + */ + +export function getDailyStreak(_req, res) { + res.json({ ok: true, data: null }); +} + +export function claimDailyReward(_req, res) { + res.json({ ok: true, data: null }); +} diff --git a/server/ranked.js b/server/ranked.js new file mode 100644 index 00000000..691ab453 --- /dev/null +++ b/server/ranked.js @@ -0,0 +1,16 @@ +/** + * server/ranked.js — Ranked season handlers. + * Stubbed as no-op handlers so routes can be wired without errors. + */ + +export function getCurrentSeason(_req, res) { + res.json({ ok: true, data: null }); +} + +export function getSeasonStandings(_req, res) { + res.json({ ok: true, data: [] }); +} + +export function submitRankedResult(_req, res) { + res.json({ ok: true, data: null }); +} diff --git a/src/lib/featureFlags.ts b/src/lib/featureFlags.ts new file mode 100644 index 00000000..a414e468 --- /dev/null +++ b/src/lib/featureFlags.ts @@ -0,0 +1,45 @@ +/** + * featureFlags.ts — Central feature-flag registry. + * + * Every new system ships behind a flag here. Default is `false` in production + * until QA passes. Flags can be toggled at build time via environment + * variables (VITE_FF_*) or at runtime through an admin panel (future). + * + * Naming convention: SCREAMING_SNAKE matching the system name. + */ + +function envFlag(key: string, fallback: boolean = false): boolean { + if (typeof import.meta !== "undefined" && import.meta.env) { + const val = (import.meta.env as Record)[key]; + if (val === "true" || val === "1") return true; + if (val === "false" || val === "0") return false; + } + return fallback; +} + +export const featureFlags = { + /** Daily login streaks + rewards UI. @owner gamma */ + DAILY_REWARDS: envFlag("VITE_FF_DAILY_REWARDS", false), + + /** Mission / quest tracker panel. @owner gamma */ + MISSIONS: envFlag("VITE_FF_MISSIONS", false), + + /** Battle pass tier progression + premium track. @owner gamma */ + BATTLE_PASS: envFlag("VITE_FF_BATTLE_PASS", false), + + /** Crew / guild system. @owner charlie */ + CREWS: envFlag("VITE_FF_CREWS", false), + + /** Ranked seasons + seasonal leaderboard. @owner charlie */ + RANKED_SEASONS: envFlag("VITE_FF_RANKED_SEASONS", false), + + /** Shareable card / deck links. @owner charlie */ + SHARE_LINKS: envFlag("VITE_FF_SHARE_LINKS", false), +} as const; + +export type FeatureFlagKey = keyof typeof featureFlags; + +/** Runtime check — use in components / hooks to gate UI. */ +export function isEnabled(flag: FeatureFlagKey): boolean { + return featureFlags[flag]; +} diff --git a/src/lib/sharedTypes.ts b/src/lib/sharedTypes.ts new file mode 100644 index 00000000..a12d0407 --- /dev/null +++ b/src/lib/sharedTypes.ts @@ -0,0 +1,127 @@ +/** + * sharedTypes.ts — Append-only contract file shared between Gamma and Charlie agents. + * + * Rules: + * 1. Never remove or rename an existing type. + * 2. New fields on existing interfaces must be optional (?:). + * 3. Add new types at the bottom of the relevant section. + * 4. Every addition must include a JSDoc comment with the sprint and owner. + */ + +import type { CardPayload } from "./types"; + +// ── Daily Streaks (Gamma) ──────────────────────────────────────────────────── + +/** @sprint 0 @owner gamma — Per-user daily login streak. Doc ID = uid. */ +export interface DailyStreak { + uid: string; + currentStreak: number; + longestStreak: number; + lastClaimDate: string; + totalClaims: number; + updatedAt: string; +} + +// ── Missions (Gamma) ───────────────────────────────────────────────────────── + +/** @sprint 0 @owner gamma */ +export type MissionStatus = "active" | "completed" | "expired"; + +/** @sprint 0 @owner gamma */ +export interface Mission { + id: string; + uid: string; + title: string; + description: string; + type: string; + target: number; + progress: number; + status: MissionStatus; + rewardXp: number; + createdAt: string; + expiresAt?: string; + completedAt?: string; +} + +// ── Battle Pass (Gamma) ────────────────────────────────────────────────────── + +/** @sprint 0 @owner gamma */ +export interface BattlePassState { + uid: string; + seasonId: string; + tier: number; + xp: number; + xpToNextTier: number; + isPremium: boolean; + claimedRewards: number[]; + updatedAt: string; +} + +// ── Crews (Charlie) ────────────────────────────────────────────────────────── + +/** @sprint 0 @owner charlie */ +export interface Crew { + id: string; + name: string; + tag: string; + leaderUid: string; + memberUids: string[]; + maxMembers: number; + createdAt: string; + updatedAt: string; +} + +// ── Ranked Seasons (Charlie) ───────────────────────────────────────────────── + +/** @sprint 0 @owner charlie */ +export interface RankedSeason { + id: string; + name: string; + startDate: string; + endDate: string; + isActive: boolean; + createdAt: string; +} + +/** @sprint 0 @owner charlie */ +export interface RankedEntry { + uid: string; + seasonId: string; + displayName: string; + rating: number; + wins: number; + losses: number; + rank: number; + updatedAt: string; +} + +// ── Share Links (Charlie) ──────────────────────────────────────────────────── + +/** @sprint 0 @owner charlie */ +export type ShareLinkType = "card" | "deck"; + +/** @sprint 0 @owner charlie */ +export interface ShareLink { + id: string; + ownerUid: string; + type: ShareLinkType; + /** ID of the card or deck being shared. */ + targetId: string; + /** Snapshot of the shared content at link-creation time. */ + snapshot: Partial | Record; + views: number; + createdAt: string; + expiresAt?: string; +} + +// ── Shared enums / constants ───────────────────────────────────────────────── + +/** @sprint 0 @owner gamma — XP reward tiers used across battle pass, missions, and daily rewards. */ +export const XP_REWARD = { + DAILY_LOGIN: 50, + MISSION_COMPLETE: 100, + BATTLE_WIN: 75, + BATTLE_LOSS: 25, +} as const; + +export type XpRewardKey = keyof typeof XP_REWARD;