From 026fd3a7a3b65bd9fecf55f404f332d1ceebdcba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 04:31:18 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Sprint=200:=20foundation=20scaffolding=20?= =?UTF-8?q?=E2=80=94=20data=20model=20docs,=20shared=20types,=20feature=20?= =?UTF-8?q?flags,=20server=20stubs,=20Firestore=20rule=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/DATA_MODEL.md: audit of all existing Firestore collections with document shapes - docs/AGENT_STATUS.md: daily sync artifact for Gamma/Charlie coordination - src/lib/sharedTypes.ts: append-only shared contract file (DailyStreak, Mission, BattlePassState, Crew, RankedSeason, RankedEntry, ShareLink) - src/lib/featureFlags.ts: central feature-flag registry (all default false) - server/battlePass.js, server/ranked.js, server/crews.js, server/dailyRewards.js: no-op handler stubs - firestore.rules: read-only stub rules for dailyStreaks, missions, battlePass, crews, rankedSeasons, shareLinks Co-authored-by: SP Digital --- docs/AGENT_STATUS.md | 28 ++++ docs/DATA_MODEL.md | 301 ++++++++++++++++++++++++++++++++++++++++ firestore.rules | 49 +++++++ server/battlePass.js | 16 +++ server/crews.js | 20 +++ server/dailyRewards.js | 12 ++ server/ranked.js | 16 +++ src/lib/featureFlags.ts | 45 ++++++ src/lib/sharedTypes.ts | 127 +++++++++++++++++ 9 files changed, 614 insertions(+) create mode 100644 docs/AGENT_STATUS.md create mode 100644 docs/DATA_MODEL.md create mode 100644 server/battlePass.js create mode 100644 server/crews.js create mode 100644 server/dailyRewards.js create mode 100644 server/ranked.js create mode 100644 src/lib/featureFlags.ts create mode 100644 src/lib/sharedTypes.ts diff --git a/docs/AGENT_STATUS.md b/docs/AGENT_STATUS.md new file mode 100644 index 00000000..82b2e7a6 --- /dev/null +++ b/docs/AGENT_STATUS.md @@ -0,0 +1,28 @@ +# 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 | + +--- + +## 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; From e600cab4f7486792f5beb1dae9c7d4cf079adc7c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 04:49:37 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Daily=20Missions=20system=20?= =?UTF-8?q?=E2=80=94=203=20rotating=20missions=20per=20day=20with=20XP=20+?= =?UTF-8?q?=20Ozzies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/dailyMissions.ts: date-seeded mission selection from 10 templates, progress tracking, localStorage persistence - src/hooks/useDailyMissions.ts: React hook with trackProgress() for type-based advancement - src/components/DailyMissionsPanel.tsx: mission cards with progress bars, rewards, completion state - CardForge.tsx: mounted DailyMissionsPanel above forge layout - index.css: daily mission panel and card styles Mission types: forge, battle, trade, delivery run, collection save Each day selects 3 from different types via PRNG seeded on date key Co-authored-by: SP Digital --- src/components/DailyMissionsPanel.tsx | 64 ++++++++++ src/hooks/useDailyMissions.ts | 57 +++++++++ src/index.css | 124 +++++++++++++++++++ src/lib/dailyMissions.ts | 172 ++++++++++++++++++++++++++ src/pages/CardForge.tsx | 5 + 5 files changed, 422 insertions(+) create mode 100644 src/components/DailyMissionsPanel.tsx create mode 100644 src/hooks/useDailyMissions.ts create mode 100644 src/lib/dailyMissions.ts diff --git a/src/components/DailyMissionsPanel.tsx b/src/components/DailyMissionsPanel.tsx new file mode 100644 index 00000000..fefc83e8 --- /dev/null +++ b/src/components/DailyMissionsPanel.tsx @@ -0,0 +1,64 @@ +import type { DailyMissionsState } from "../hooks/useDailyMissions"; + +interface DailyMissionsPanelProps { + dailyMissions: DailyMissionsState; +} + +const TYPE_ICONS: Record = { + forge: "\u{1F3B4}", + battle: "\u2694\uFE0F", + trade: "\u{1F91D}", + mission: "\u{1F6F9}", + collection: "\u{1F4E6}", +}; + +export function DailyMissionsPanel({ dailyMissions }: DailyMissionsPanelProps) { + if (!dailyMissions.enabled || dailyMissions.missions.length === 0) return null; + + return ( +
+
+

Daily Missions

+ + {dailyMissions.completedCount}/{dailyMissions.totalCount} + {dailyMissions.allComplete && " \u2728"} + +
+ +
+ {dailyMissions.missions.map((mission) => { + const pct = mission.target > 0 ? (mission.progress / mission.target) * 100 : 0; + const isComplete = mission.status === "completed"; + + return ( +
+
+ {TYPE_ICONS[mission.type] ?? "\u{1F3AF}"} +
+
+ {mission.title} + {mission.description} +
+
+
+
+
+ +{mission.rewardXp} XP + +{mission.rewardOzzies} +
+ {isComplete && ( + {"\u2713"} + )} +
+ ); + })} +
+
+ ); +} diff --git a/src/hooks/useDailyMissions.ts b/src/hooks/useDailyMissions.ts new file mode 100644 index 00000000..d960f1c4 --- /dev/null +++ b/src/hooks/useDailyMissions.ts @@ -0,0 +1,57 @@ +import { useCallback, useState } from "react"; +import { + advanceMissionsByType, + getDailyMissions, + type DailyMission, + type MissionTemplate, +} from "../lib/dailyMissions"; +import { isEnabled } from "../lib/featureFlags"; + +export interface DailyMissionsState { + enabled: boolean; + missions: DailyMission[]; + completedCount: number; + totalCount: number; + allComplete: boolean; + trackProgress: (type: MissionTemplate["type"], increment?: number) => DailyMission[]; + refresh: () => void; +} + +export function useDailyMissions(): DailyMissionsState { + const enabled = isEnabled("MISSIONS"); + const [missions, setMissions] = useState(() => + enabled ? getDailyMissions() : [], + ); + + const completedCount = missions.filter((m) => m.status === "completed").length; + const totalCount = missions.length; + const allComplete = totalCount > 0 && completedCount === totalCount; + + const trackProgress = useCallback( + (type: MissionTemplate["type"], increment: number = 1) => { + if (!enabled) return []; + const updated = advanceMissionsByType(type, increment); + if (updated.length > 0) { + setMissions(getDailyMissions()); + } + return updated; + }, + [enabled], + ); + + const refresh = useCallback(() => { + if (enabled) { + setMissions(getDailyMissions()); + } + }, [enabled]); + + return { + enabled, + missions, + completedCount, + totalCount, + allComplete, + trackProgress, + refresh, + }; +} diff --git a/src/index.css b/src/index.css index f169d04a..5454bb66 100644 --- a/src/index.css +++ b/src/index.css @@ -722,6 +722,130 @@ button { cursor: pointer; font-family: var(--font); transition: all 0.2s ease; } margin-bottom: 12px; } +/* ── Daily Missions panel ──────────────────────────────────────────────────── */ +.daily-missions-panel { + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + background: linear-gradient(135deg, rgba(10, 18, 28, 0.95), rgba(20, 10, 30, 0.92)); +} + +.daily-missions-panel__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.daily-missions-panel__title { + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent2); +} + +.daily-missions-panel__progress { + font-family: var(--font); + font-weight: bold; + font-size: 14px; + color: var(--accent); +} + +.daily-missions-panel__list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.daily-mission-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(5, 5, 14, 0.6); + transition: border-color 0.2s, opacity 0.2s; + position: relative; +} + +.daily-mission-card--complete { + border-color: var(--accent); + opacity: 0.7; +} + +.daily-mission-card__icon { + font-size: 20px; + min-width: 28px; + text-align: center; +} + +.daily-mission-card__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.daily-mission-card__title { + font-size: 13px; + font-weight: bold; + color: var(--text); +} + +.daily-mission-card__desc { + font-size: 11px; + color: var(--text-dim); +} + +.daily-mission-card__bar-track { + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; + margin-top: 2px; +} + +.daily-mission-card__bar-fill { + height: 100%; + border-radius: 2px; + background: var(--accent); + transition: width 0.3s ease; +} + +.daily-mission-card--complete .daily-mission-card__bar-fill { + background: var(--accent); +} + +.daily-mission-card__reward { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + font-size: 11px; + white-space: nowrap; +} + +.daily-mission-card__xp { + color: var(--accent2); + font-weight: bold; +} + +.daily-mission-card__ozzies { + color: var(--accent); +} + +.daily-mission-card__check { + position: absolute; + top: 4px; + right: 8px; + font-size: 14px; + color: var(--accent); + font-weight: bold; +} + .forge-welcome-panel { border-color: rgba(0, 255, 136, 0.32); background: diff --git a/src/lib/dailyMissions.ts b/src/lib/dailyMissions.ts new file mode 100644 index 00000000..ad91bc36 --- /dev/null +++ b/src/lib/dailyMissions.ts @@ -0,0 +1,172 @@ +/** + * dailyMissions.ts — Daily mission system. + * + * Each day, 3 missions are selected from a template pool via a date-seeded + * PRNG so every player sees the same set. Missions track progress locally + * and reward XP + Ozzies on completion. + */ + +import type { MissionStatus } from "./sharedTypes"; + +export interface MissionTemplate { + id: string; + title: string; + description: string; + type: "forge" | "battle" | "trade" | "mission" | "collection"; + target: number; + rewardXp: number; + rewardOzzies: number; +} + +export interface DailyMission { + id: string; + templateId: string; + title: string; + description: string; + type: MissionTemplate["type"]; + target: number; + progress: number; + status: MissionStatus; + rewardXp: number; + rewardOzzies: number; + dateKey: string; +} + +export const MISSION_TEMPLATES: MissionTemplate[] = [ + { id: "forge_1", title: "Forge a Card", description: "Create any card in the Card Forge.", type: "forge", target: 1, rewardXp: 50, rewardOzzies: 15 }, + { id: "forge_2", title: "Forge 2 Cards", description: "Create 2 cards in the Card Forge.", type: "forge", target: 2, rewardXp: 100, rewardOzzies: 30 }, + { id: "battle_1", title: "Win a Battle", description: "Win a battle in the Arena.", type: "battle", target: 1, rewardXp: 75, rewardOzzies: 25 }, + { id: "battle_2", title: "Enter 2 Battles", description: "Participate in 2 battles.", type: "battle", target: 2, rewardXp: 100, rewardOzzies: 35 }, + { id: "trade_1", title: "Send a Trade", description: "Offer a card on the Community Market.", type: "trade", target: 1, rewardXp: 50, rewardOzzies: 20 }, + { id: "trade_2", title: "Complete a Trade", description: "Have a trade accepted or accept an incoming trade.", type: "trade", target: 1, rewardXp: 75, rewardOzzies: 30 }, + { id: "mission_1", title: "Complete a Delivery Run", description: "Finish any delivery mission successfully.", type: "mission", target: 1, rewardXp: 75, rewardOzzies: 25 }, + { id: "mission_2", title: "Complete 2 Delivery Runs", description: "Finish 2 delivery missions.", type: "mission", target: 2, rewardXp: 120, rewardOzzies: 40 }, + { id: "collection_1", title: "Save to Collection", description: "Save a card to your Collection.", type: "collection", target: 1, rewardXp: 50, rewardOzzies: 15 }, + { id: "collection_2", title: "Grow Your Collection", description: "Save 3 cards to your Collection.", type: "collection", target: 3, rewardXp: 100, rewardOzzies: 40 }, +]; + +const MISSIONS_PER_DAY = 3; +const STORAGE_KEY = "skpd_daily_missions"; + +function dateSeed(dateKey: string): number { + let hash = 0; + for (let i = 0; i < dateKey.length; i++) { + hash = ((hash << 5) - hash + dateKey.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +function selectDailyTemplates(dateKey: string): MissionTemplate[] { + const seed = dateSeed(dateKey); + const pool = [...MISSION_TEMPLATES]; + const selected: MissionTemplate[] = []; + const usedTypes = new Set(); + + for (let i = 0; i < MISSIONS_PER_DAY && pool.length > 0; i++) { + const eligibleIndices = pool + .map((t, idx) => ({ t, idx })) + .filter(({ t }) => !usedTypes.has(t.type)); + const candidates = eligibleIndices.length > 0 ? eligibleIndices : pool.map((t, idx) => ({ t, idx })); + const pick = candidates[(seed + i * 7 + i * i) % candidates.length]; + selected.push(pick.t); + usedTypes.add(pick.t.type); + pool.splice(pick.idx, 1); + } + + return selected; +} + +export function getDateKey(date: Date = new Date()): string { + return date.toISOString().slice(0, 10); +} + +interface StoredMissions { + dateKey: string; + missions: DailyMission[]; +} + +function loadStoredMissions(): StoredMissions | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as StoredMissions; + } catch { + return null; + } +} + +function saveStoredMissions(data: StoredMissions): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +export function getDailyMissions(dateKey?: string): DailyMission[] { + const today = dateKey ?? getDateKey(); + const stored = loadStoredMissions(); + + if (stored && stored.dateKey === today) { + return stored.missions; + } + + const templates = selectDailyTemplates(today); + const missions: DailyMission[] = templates.map((t) => ({ + id: `${today}_${t.id}`, + templateId: t.id, + title: t.title, + description: t.description, + type: t.type, + target: t.target, + progress: 0, + status: "active" as MissionStatus, + rewardXp: t.rewardXp, + rewardOzzies: t.rewardOzzies, + dateKey: today, + })); + + saveStoredMissions({ dateKey: today, missions }); + return missions; +} + +export function advanceMissionProgress( + missionId: string, + increment: number = 1, +): DailyMission | null { + const today = getDateKey(); + const stored = loadStoredMissions(); + if (!stored || stored.dateKey !== today) return null; + + const mission = stored.missions.find((m) => m.id === missionId); + if (!mission || mission.status !== "active") return null; + + mission.progress = Math.min(mission.progress + increment, mission.target); + if (mission.progress >= mission.target) { + mission.status = "completed"; + } + + saveStoredMissions(stored); + return { ...mission }; +} + +export function advanceMissionsByType( + type: MissionTemplate["type"], + increment: number = 1, +): DailyMission[] { + const today = getDateKey(); + const stored = loadStoredMissions(); + if (!stored || stored.dateKey !== today) return []; + + const updated: DailyMission[] = []; + for (const mission of stored.missions) { + if (mission.type === type && mission.status === "active") { + mission.progress = Math.min(mission.progress + increment, mission.target); + if (mission.progress >= mission.target) { + mission.status = "completed"; + } + updated.push({ ...mission }); + } + } + + if (updated.length > 0) { + saveStoredMissions(stored); + } + return updated; +} diff --git a/src/pages/CardForge.tsx b/src/pages/CardForge.tsx index 5e89ef8e..b33a7bd4 100644 --- a/src/pages/CardForge.tsx +++ b/src/pages/CardForge.tsx @@ -1,3 +1,4 @@ +import { DailyMissionsPanel } from "../components/DailyMissionsPanel"; import { ForgeControlsPanel } from "./cardForge/ForgeControlsPanel"; import { ForgePreviewPanel } from "./cardForge/ForgePreviewPanel"; import { ForgeResultOverlays } from "./cardForge/ForgeResultOverlays"; @@ -15,6 +16,7 @@ import { SKIN_TONES, } from "./cardForge/constants"; import { useCardForgeController } from "./cardForge/useCardForgeController"; +import { useDailyMissions } from "../hooks/useDailyMissions"; import { isImageGenConfigured } from "../services/imageGen"; export function CardForge() { @@ -64,6 +66,7 @@ export function CardForge() { tierCanSave, viewing3D, } = useCardForgeController(); + const dailyMissions = useDailyMissions(); return (
@@ -95,6 +98,8 @@ export function CardForge() {
+ +