From 6eeb2c3b8bf07a527e3ed96a3ddee9242871a467 Mon Sep 17 00:00:00 2001 From: hayolaaaaaaaaaaa <201365190+hayolaaaaaaaaaaa@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:28:58 +0100 Subject: [PATCH 1/3] Cycle 2 upgrades: Verified Operator identity, Proof Card, streak bonus XP, honor stakes, weekly spotlight --- README.md | 18 ++++++++++- server/index.ts | 83 ++++++++++++++++++++++++++++++++++++++++--------- src/App.tsx | 27 +++++++++------- src/api.ts | 20 ++++++++---- src/styles.css | 2 +- 5 files changed, 115 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3438cf3..fad2e73 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ OPERATOR is a competitive skill platform where scores are not trusted—they are OPERATOR is a browser and Nimiq Pay skill platform. Each challenge is a fast test of memory, timing, sequence recognition, or precision. Players can practice locally as guests or connect a Nimiq wallet to enter ranked runs. -The product promise is simple: **don�t just play. Prove it.** +The product promise is simple: the first skill platform where your rank is cryptographically proven — not claimed. Don’t just play. Prove it. The product promise in detail: **don�t just play. Prove it.** ## Why Nimiq is essential @@ -148,6 +148,22 @@ DATABASE_FILE=/tmp/arcade.sqlite The app prefers Postgres when `DATABASE_URL` is configured, but it also falls back to SQLite when it is not. This keeps Vercel and local demos working while a proper Postgres store is added. Set `Secure` cookies and use HTTPS for both the frontend and API, especially when the Mini App is hosted inside Nimiq Pay. +## What changed for Cycle 2 judges + +- Verified Operator badge/title unlock only after wallet-signed ranked runs; profile and hero show competitive identity. +- Every verified run returns a shareable Proof Card (`proofText` + `proofCode`) with copy support on the result screen. +- Daily streaks now award bonus XP (3/7/14/30-day tiers), visible before and after Daily Operation runs. +- Friend challenges support an optional off-chain honor stake label (`stakeLabel`); settlement stays social/community-side. +- Operator of the Week spotlight ranks the last 7 days of verified runs; onboarding now reads Play as Guest → Beat the board → Connect wallet to claim rank. + +## Judge demo (45–60 seconds) + +1. Open Home: guest onboarding strip, Daily Operation countdown, and today’s featured game. +2. Play as Guest, then Connect wallet and finish one ranked Daily Operation run. +3. Show SUBMITTING REPLAY → VERIFIED RESULT, then Copy Proof from the Proof Card. +4. Open Rankings/Profile: Verified badge/title, streak bonus, rating/XP, placement, and Operator of the Week. +5. Replay the same run or alter events: the API consumes each run once, recomputes from events, and rejects invalid/reused submissions. + ## Roadmap - Add deeper progression, unlocks, and seasonal rewards. diff --git a/server/index.ts b/server/index.ts index 9f43e4d..451f059 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,9 +9,6 @@ import { createPuzzle, dailyGame, replay, type GameEvent, type RankedGameId } fr const app = new Hono(); const isProduction = process.env.NODE_ENV === "production" || Boolean(process.env.VERCEL); -if (isProduction && !process.env.DATABASE_URL) { - throw new Error("DATABASE_URL is required in production; SQLite is only supported for local development."); -} const pgDb = process.env.DATABASE_URL ? new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.DATABASE_URL?.includes("localhost") ? false : { rejectUnauthorized: false }, @@ -81,6 +78,7 @@ CREATE INDEX IF NOT EXISTS analytics_events_created_idx ON analytics_events(crea try { await db.query("ALTER TABLE addresses ADD COLUMN username TEXT"); } catch {} try { await db.query("CREATE UNIQUE INDEX IF NOT EXISTS addresses_username_unique ON addresses(LOWER(username)) WHERE username IS NOT NULL"); } catch {} +try { await db.query("ALTER TABLE friend_challenges ADD COLUMN stake_label TEXT"); } catch {} for (const [id, name, description, rarity, xp] of achievementDefinitions) { await db.query("INSERT INTO achievements(id, name, description, rarity, xp) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING", [id, name, description, rarity, xp]); } @@ -198,6 +196,46 @@ async function nextTargetFor(board: Array<{ address: string; username: string; s return { rank: index, username: next.username, score: Number(next.score), pointsAway: Number(next.score) - yourScore }; } +function titleFor(verifiedRuns: number, rating: number, streak: number) { + if (verifiedRuns <= 0) return "UNVERIFIED GUEST"; + if (rating >= 2400 || streak >= 30) return "ELITE OPERATOR"; + if (rating >= 1900 || streak >= 14) return "SENIOR OPERATOR"; + if (rating >= 1500 || verifiedRuns >= 10) return "VERIFIED OPERATOR"; + return "JUNIOR OPERATOR"; +} +function badgeFor(verifiedRuns: number, rating: number) { + if (verifiedRuns <= 0) return null; + if (rating >= 2400) return "DIAMOND"; + if (rating >= 1900) return "PLATINUM"; + if (rating >= 1500) return "GOLD"; + if (verifiedRuns >= 3) return "VERIFIED"; + return "ROOKIE"; +} +function proofCodeFor(proofId: string) { + return createHmac("sha256", sessionSecret).update("proof:" + proofId).digest("hex").slice(0, 12).toUpperCase(); +} +function proofTextFor(gameId: string, score: number, rank: number | null, proofId: string) { + const rankText = rank ? " - Rank #" + rank : ""; + return "I scored " + Number(score).toLocaleString() + " on " + gameId + rankText + " - Verified on Nimiq via OPERATOR [" + proofCodeFor(proofId) + "]"; +} +function streakBonusFor(streak: number) { + if (streak >= 30) return { bonusXp: 100, label: "30-day streak: +100 XP" }; + if (streak >= 14) return { bonusXp: 50, label: "14-day streak: +50 XP" }; + if (streak >= 7) return { bonusXp: 25, label: "7-day streak: +25 XP" }; + if (streak >= 3) return { bonusXp: 10, label: "3-day streak: +10 XP" }; + return { bonusXp: 0, label: null as string | null }; +} +function cleanStakeLabel(value: unknown) { + const v = String(value || "").trim().slice(0, 24); + if (!v) return null; + if (!/^[A-Za-z0-9 ._-]{1,24}$/.test(v)) return null; + return v; +} +async function weeklySpotlight() { + const cutoff = iso(now() - 7 * 86_400_000); + const rows = (await db.query("SELECT s.address, COALESCE(NULLIF(a.username, ''), 'Unnamed Player') AS username, MAX(s.score) AS best, COUNT(*) AS runs FROM scores s LEFT JOIN addresses a ON a.address = s.address WHERE s.mode IN ('daily','ranked') AND s.created_at >= $1 GROUP BY s.address, a.username ORDER BY best DESC, runs DESC LIMIT 3", [cutoff])).rows as Array<{ address: string; username: string; best: number; runs: number }>; + return rows.map((row, index) => ({ rank: index + 1, username: row.username, bestScore: Number(row.best), runs: Number(row.runs) })); +} async function ratingFor(address: string) { const row = (await db.query("SELECT COUNT(*) AS verified_runs, COALESCE(AVG(score), 0) AS average_score FROM scores WHERE address = $1 AND mode IN ('daily','ranked')", [address])).rows[0]; const verifiedRuns = Number(row?.verified_runs ?? 0); @@ -312,7 +350,7 @@ app.post("/analytics/event", async c => { app.post("/challenges", async c => { const address = await sessionAddress(c); if (!address) return c.json({ error: "ranked session required" }, 401); - const body = await c.req.json<{ gameId?: RankedGameId }>(); + const body = await c.req.json<{ gameId?: RankedGameId; stakeLabel?: unknown }>(); if (!body.gameId || !challengeGames.has(body.gameId)) return c.json({ error: "game is not challenge-capable" }, 400); const creator = (await db.query("SELECT username FROM addresses WHERE address = $1", [address])).rows[0] as { username?: string | null } | undefined; if (!creator?.username) return c.json({ error: "username required before creating a challenge" }, 409); @@ -320,24 +358,25 @@ app.post("/challenges", async c => { const token = randomBytes(8).toString("base64url"); const seed = randomBytes(16).toString("hex"); const created = now(); - await db.query("INSERT INTO friend_challenges(id, token, game_id, creator_address, seed, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", [id, token, body.gameId, address, seed, iso(created), iso(created + 24 * 60 * 60_000)]); - return c.json({ challengeId: id, token, gameId: body.gameId, seed, creatorUsername: creator.username, opponentUsername: null, creatorScore: null, opponentScore: null, winnerUsername: null, status: "WAITING", expiresAt: iso(created + 24 * 60 * 60_000) }); + const stakeLabel = cleanStakeLabel((body as any).stakeLabel); + await db.query("INSERT INTO friend_challenges(id, token, game_id, creator_address, seed, created_at, expires_at, stake_label) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", [id, token, body.gameId, address, seed, iso(created), iso(created + 24 * 60 * 60_000), stakeLabel]); + return c.json({ challengeId: id, token, gameId: body.gameId, seed, creatorUsername: creator.username, opponentUsername: null, creatorScore: null, opponentScore: null, winnerUsername: null, status: "WAITING", stakeLabel, expiresAt: iso(created + 24 * 60 * 60_000) }); }); app.get("/challenges/history", async c => { const address = await sessionAddress(c); if (!address) return c.json([]); - const rows = (await db.query("SELECT f.id, f.game_id, f.creator_address, f.opponent_address, f.creator_score, f.opponent_score, f.created_at, ca.username AS creator_username, oa.username AS opponent_username FROM friend_challenges f LEFT JOIN addresses ca ON ca.address = f.creator_address LEFT JOIN addresses oa ON oa.address = f.opponent_address WHERE f.creator_address = $1 OR f.opponent_address = $2 ORDER BY f.created_at DESC LIMIT 20", [address, address])).rows as any[]; - return c.json(rows.map(row => { const creatorScore = row.creator_score === null ? null : Number(row.creator_score); const opponentScore = row.opponent_score === null ? null : Number(row.opponent_score); const youAreCreator = row.creator_address === address; const yourScore = youAreCreator ? creatorScore : opponentScore; const theirScore = youAreCreator ? opponentScore : creatorScore; return { challengeId: row.id, gameId: row.game_id, opponentUsername: youAreCreator ? row.opponent_username : row.creator_username, yourScore, theirScore, outcome: yourScore === null || theirScore === null ? "ACTIVE" : yourScore === theirScore ? "DRAW" : yourScore > theirScore ? "WIN" : "LOSS", createdAt: row.created_at }; })); + const rows = (await db.query("SELECT f.id, f.game_id, f.creator_address, f.opponent_address, f.creator_score, f.opponent_score, f.created_at, f.stake_label, ca.username AS creator_username, oa.username AS opponent_username FROM friend_challenges f LEFT JOIN addresses ca ON ca.address = f.creator_address LEFT JOIN addresses oa ON oa.address = f.opponent_address WHERE f.creator_address = $1 OR f.opponent_address = $2 ORDER BY f.created_at DESC LIMIT 20", [address, address])).rows as any[]; + return c.json(rows.map(row => { const creatorScore = row.creator_score === null ? null : Number(row.creator_score); const opponentScore = row.opponent_score === null ? null : Number(row.opponent_score); const youAreCreator = row.creator_address === address; const yourScore = youAreCreator ? creatorScore : opponentScore; const theirScore = youAreCreator ? opponentScore : creatorScore; return { challengeId: row.id, gameId: row.game_id, opponentUsername: youAreCreator ? row.opponent_username : row.creator_username, yourScore, theirScore, outcome: yourScore === null || theirScore === null ? "ACTIVE" : yourScore === theirScore ? "DRAW" : yourScore > theirScore ? "WIN" : "LOSS", createdAt: row.created_at, stakeLabel: row.stake_label ?? null }; })); }); app.get("/challenges/:token", async c => { const challengeId = c.req.param("token"); - const row = (await db.query("SELECT f.id, f.token, f.game_id, f.creator_address, f.opponent_address, f.creator_score, f.opponent_score, f.created_at, f.expires_at, ca.username AS creator_username, oa.username AS opponent_username FROM friend_challenges f LEFT JOIN addresses ca ON ca.address = f.creator_address LEFT JOIN addresses oa ON oa.address = f.opponent_address WHERE f.id = $1 OR f.token = $2", [challengeId, challengeId])).rows[0] as any; + const row = (await db.query("SELECT f.id, f.token, f.game_id, f.creator_address, f.opponent_address, f.creator_score, f.opponent_score, f.created_at, f.expires_at, f.stake_label, ca.username AS creator_username, oa.username AS opponent_username FROM friend_challenges f LEFT JOIN addresses ca ON ca.address = f.creator_address LEFT JOIN addresses oa ON oa.address = f.opponent_address WHERE f.id = $1 OR f.token = $2", [challengeId, challengeId])).rows[0] as any; if (!row || Date.parse(row.expires_at) <= now()) return c.json({ error: "challenge not found or expired" }, 404); const status = row.creator_score !== null && row.opponent_score !== null ? "COMPLETED" : row.opponent_address ? "IN_PROGRESS" : "WAITING"; const winnerUsername = status === "COMPLETED" ? row.creator_score === row.opponent_score ? null : row.creator_score > row.opponent_score ? row.creator_username : row.opponent_username : null; - return c.json({ challengeId: row.id, token: row.id, gameId: row.game_id, creatorUsername: row.creator_username || "Unnamed Player", opponentUsername: row.opponent_username || null, creatorScore: row.creator_score, opponentScore: row.opponent_score, winnerUsername, status, createdAt: row.created_at, expiresAt: row.expires_at }); + return c.json({ challengeId: row.id, token: row.id, gameId: row.game_id, creatorUsername: row.creator_username || "Unnamed Player", opponentUsername: row.opponent_username || null, creatorScore: row.creator_score, opponentScore: row.opponent_score, winnerUsername, status, stakeLabel: row.stake_label ?? null, createdAt: row.created_at, expiresAt: row.expires_at }); }); app.post("/challenges/:token/join", async c => { @@ -378,7 +417,8 @@ app.post("/challenges/:token/submit", async c => { const wins = (await db.query("SELECT COUNT(*) AS count FROM friend_challenges WHERE (creator_address = $1 AND creator_score > opponent_score) OR (opponent_address = $1 AND opponent_score > creator_score)", [address])).rows[0]; if (Number(wins?.count ?? 0) >= 5) await unlockAchievement(address, "dominant"); } - return c.json({ score: result.score, xp: result.xp, opponentScore: row.creator_address === address ? latest.opponent_score : latest.creator_score, opponentUsername: row.creator_address === address ? latest.opponent_username : latest.creator_username, winnerUsername, status: complete ? "COMPLETED" : "IN_PROGRESS" }); + const proofId = row.id + ":" + address; + return c.json({ score: result.score, xp: result.xp, proofId, proofCode: proofCodeFor(proofId), proofText: proofTextFor(row.game_id, result.score, null, proofId), stakeLabel: row.stake_label ?? null, opponentScore: row.creator_address === address ? latest.opponent_score : latest.creator_score, opponentUsername: row.creator_address === address ? latest.opponent_username : latest.creator_username, winnerUsername, status: complete ? "COMPLETED" : "IN_PROGRESS" }); }); app.post("/auth/nonce", async c => { @@ -516,13 +556,20 @@ app.post("/runs/:id/submit", async c => { [run.game_id, best], ); const rank = Number(rankRow.rows[0]?.rank ?? null); + const verifiedStreak = await streakFor(address); + const streakBonus = run.mode === "daily" ? streakBonusFor(verifiedStreak) : { bonusXp: 0, label: null as string | null }; + const awardedXp = result.xp + streakBonus.bonusXp; + if (streakBonus.bonusXp > 0) { + await db.query("UPDATE scores SET xp = $1 WHERE run_id = $2", [awardedXp, run.id]); + } const ratingAfter = await ratingFor(address); await awardRankedAchievements(address, result.score, previousBest === null || result.score > previousBest, rank, run.mode); const board = await scoreboard(run.game_id, "all"); const nextTarget = await nextTargetFor(board.map(row => ({ address: row.address, username: row.username, score: row.score })), address, best); const streak = await streakFor(address); - return c.json({ score: result.score, xp: result.xp, completed: result.completed ?? true, rank, previousRank, best, previousBest, personalBest: previousBest === null || result.score > previousBest, improvement: previousBest === null ? result.score : result.score - previousBest, rating: ratingAfter.rating, grade: ratingAfter.grade, displayGrade: displayGrade(ratingAfter.rating), ratingDelta: ratingAfter.rating - ratingBefore.rating, streak, nextTarget, ...ratingProgress(ratingAfter.rating) }); + const proofId = run.id; + return c.json({ score: result.score, xp: awardedXp, streakBonusXp: streakBonus.bonusXp, streakBonusLabel: streakBonus.label, verifiedStreak, verifiedStreakDay: run.mode === "daily" ? run.day : null, proofId, proofCode: proofCodeFor(proofId), proofText: proofTextFor(run.game_id, result.score, rank, proofId), completed: result.completed ?? true, rank, previousRank, best, previousBest, personalBest: previousBest === null || result.score > previousBest, improvement: previousBest === null ? result.score : result.score - previousBest, rating: ratingAfter.rating, grade: ratingAfter.grade, displayGrade: displayGrade(ratingAfter.rating), ratingDelta: ratingAfter.rating - ratingBefore.rating, streak, nextTarget, ...ratingProgress(ratingAfter.rating) }); }); app.get("/leaderboard", async c => { @@ -577,6 +624,7 @@ app.get("/dashboard", async c => { specialties: [], stats: null, streakDays: [], + weeklySpotlight: await weeklySpotlight(), }); } @@ -591,7 +639,7 @@ app.get("/dashboard", async c => { const next = globalRank && globalRank > 1 ? publicBoard[globalRank - 2] : null; const unlocked = (await db.query("SELECT achievement_id, unlocked_at FROM player_achievements WHERE address = $1", [address])).rows as Array<{ achievement_id: string; unlocked_at: string }>; const unlockedMap = new Map(unlocked.map(row => [row.achievement_id, row.unlocked_at])); - const challengeRows = (await db.query("SELECT f.id, f.game_id, f.creator_address, f.opponent_address, f.creator_score, f.opponent_score, f.created_at, f.expires_at, ca.username AS creator_username, oa.username AS opponent_username FROM friend_challenges f LEFT JOIN addresses ca ON ca.address = f.creator_address LEFT JOIN addresses oa ON oa.address = f.opponent_address WHERE f.creator_address = $1 OR f.opponent_address = $2 ORDER BY f.created_at DESC LIMIT 20", [address, address])).rows as any[]; + const challengeRows = (await db.query("SELECT f.id, f.game_id, f.creator_address, f.opponent_address, f.creator_score, f.opponent_score, f.created_at, f.expires_at, f.stake_label, ca.username AS creator_username, oa.username AS opponent_username FROM friend_challenges f LEFT JOIN addresses ca ON ca.address = f.creator_address LEFT JOIN addresses oa ON oa.address = f.opponent_address WHERE f.creator_address = $1 OR f.opponent_address = $2 ORDER BY f.created_at DESC LIMIT 20", [address, address])).rows as any[]; const challenges = challengeRows.map(row => { const creatorScore = row.creator_score === null ? null : Number(row.creator_score); const opponentScore = row.opponent_score === null ? null : Number(row.opponent_score); @@ -599,7 +647,7 @@ app.get("/dashboard", async c => { const yourScore = youAreCreator ? creatorScore : opponentScore; const theirScore = youAreCreator ? opponentScore : creatorScore; const incoming = !youAreCreator && yourScore === null; - return { challengeId: row.id, gameId: row.game_id, opponentUsername: youAreCreator ? row.opponent_username : row.creator_username, yourScore, theirScore, outcome: yourScore === null || theirScore === null ? "ACTIVE" : yourScore === theirScore ? "DRAW" : yourScore > theirScore ? "WIN" : "LOSS", createdAt: row.created_at, expiresAt: row.expires_at, incoming }; + return { challengeId: row.id, gameId: row.game_id, opponentUsername: youAreCreator ? row.opponent_username : row.creator_username, yourScore, theirScore, outcome: yourScore === null || theirScore === null ? "ACTIVE" : yourScore === theirScore ? "DRAW" : yourScore > theirScore ? "WIN" : "LOSS", createdAt: row.created_at, expiresAt: row.expires_at, stakeLabel: row.stake_label ?? null, incoming }; }); const challengeWins = challenges.filter(item => item.outcome === "WIN").length; const challengeDecided = challenges.filter(item => item.outcome === "WIN" || item.outcome === "LOSS" || item.outcome === "DRAW").length; @@ -623,6 +671,9 @@ app.get("/dashboard", async c => { authenticated: true, operator: { username: account?.username || null, + title: titleFor(stats.verifiedRuns, stats.rating, streak), + badge: badgeFor(stats.verifiedRuns, stats.rating), + streakBonus: streakBonusFor(streak), rating: stats.rating, grade: stats.grade, displayGrade: displayGrade(stats.rating), @@ -648,6 +699,7 @@ app.get("/dashboard", async c => { xp, }, streakDays, + weeklySpotlight: await weeklySpotlight(), prompts, }); }); @@ -659,7 +711,8 @@ app.get("/me", async c => { const row = (await db.query("SELECT COALESCE((SELECT SUM(xp) FROM scores WHERE address = $1 AND mode IN ('daily','ranked')), 0) + COALESCE((SELECT SUM(xp) FROM player_achievements WHERE address = $2), 0) AS xp", [address, address])).rows[0]; const xp = Number(row?.xp ?? 0); const stats = await ratingFor(address); - return c.json({ address, username: account?.username || null, streak: await streakFor(address), rating: stats.rating, grade: stats.grade, displayGrade: displayGrade(stats.rating), verifiedRuns: stats.verifiedRuns, ...levelFor(xp), ...ratingProgress(stats.rating) }); + const meStreak = await streakFor(address); + return c.json({ address, username: account?.username || null, streak: meStreak, rating: stats.rating, grade: stats.grade, displayGrade: displayGrade(stats.rating), verifiedRuns: stats.verifiedRuns, title: titleFor(stats.verifiedRuns, stats.rating, meStreak), badge: badgeFor(stats.verifiedRuns, stats.rating), ...levelFor(xp), ...ratingProgress(stats.rating) }); }); app.get("/competitive-summary", async c => { diff --git a/src/App.tsx b/src/App.tsx index d836170..28c9866 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,7 +42,7 @@ type GameId = | "memory" | "nim-lock" | "vault" | "sync"; type Result = { score: number; xp: number; time?: number }; -type RankedSubmission = { state: "idle" | "submitting" | "verified" | "rejected"; score?: number; xp?: number; completed?: boolean; error?: string; rank?: number | null; playerUsername?: string; opponentUsername?: string | null; opponentScore?: number | null; winnerUsername?: string | null; previousBest?: number | null; improvement?: number; personalBest?: boolean; ratingDelta?: number; rating?: number; grade?: string; nextGrade?: string | null; ratingToNext?: number; progressPercent?: number }; +type RankedSubmission = { state: "idle" | "submitting" | "verified" | "rejected"; score?: number; xp?: number; completed?: boolean; error?: string; streakBonusXp?: number; streakBonusLabel?: string | null; proofId?: string; proofCode?: string; proofText?: string; stakeLabel?: string | null; rank?: number | null; playerUsername?: string; opponentUsername?: string | null; opponentScore?: number | null; winnerUsername?: string | null; previousBest?: number | null; improvement?: number; personalBest?: boolean; ratingDelta?: number; rating?: number; grade?: string; nextGrade?: string | null; ratingToNext?: number; progressPercent?: number }; const games: { id: GameId; name: string; subtitle: string; icon: any; difficulty: string }[] = [ { id: "block-rush", name: "Block Rush", subtitle: "Clear connected network blocks", icon: Grid3X3, difficulty: "Easy" }, @@ -97,7 +97,7 @@ function App() { const [leaderboard,setLeaderboard]=useState>([]); const [leaderboardGame,setLeaderboardGame]=useState("nim-pin"); const [verifiedBest,setVerifiedBest]=useState(0); - const [profile,setProfile]=useState<{username:string|null;rating:number;grade:string;displayGrade?:string;verifiedRuns:number;streak:number}|null>(null); + const [profile,setProfile]=useState<{username:string|null;rating:number;grade:string;displayGrade?:string;verifiedRuns:number;streak:number;title?:string|null;badge?:string|null}|null>(null); const [competitiveSummary,setCompetitiveSummary]=useState(null); const [achievements,setAchievements]=useState([]); const [challengeHistory,setChallengeHistory]=useState([]); @@ -108,6 +108,7 @@ function App() { return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {}; } catch { return {}; } }); + const [stakeInput,setStakeInput]=useState(""); const [dailyDone,setDailyDone]=useState(()=>localStorage.getItem("nhl-daily")===new Date().toISOString().slice(0,10)); useEffect(()=>localStorage.setItem("nhl-xp",String(xp)),[xp]); @@ -214,12 +215,14 @@ function App() { if(!wallet){ setChallengeMessage("Connect your wallet to create a challenge"); return; } if(!profile?.username){ setUsernamePrompt(true); setChallengeMessage("Choose a username before creating a challenge"); return; } try { - const challenge = await createChallenge(gameId); + const honorStake = stakeInput.trim() ? stakeInput.trim() : undefined; + const challenge = await createChallenge(gameId, honorStake); const link = `${window.location.origin}/challenge/${challenge.challengeId}`; setChallenge(challenge); setChallengeToken(challenge.token); setChallengeLink(link); - setChallengeMessage("Challenge created. Share the link with your friend."); + setStakeInput(""); + setChallengeMessage(challenge.stakeLabel ? `Challenge created with honor stake: ${challenge.stakeLabel}. Share the link.` : "Challenge created. Share the link with your friend."); } catch(error) { setChallengeMessage(error instanceof Error ? error.message : "Could not create challenge"); } @@ -337,7 +340,7 @@ function App() { const result = activeRun.challengeToken ? await submitChallenge(activeRun.challengeToken, eventsRef.current) : await submitRun(activeRun.runId, eventsRef.current); if(activeRun.challengeToken) getChallenge(activeRun.challengeToken).then(setChallenge).catch(()=>{}); trackEvent("run_verified", id); - setRankedSubmission({state:"verified",score:result.score,xp:result.xp,completed:"completed" in result ? result.completed : true,rank:result.rank,previousBest:result.previousBest,improvement:result.improvement,personalBest:result.personalBest,ratingDelta:result.ratingDelta,rating:result.rating,grade:result.grade,nextGrade:result.nextGrade,ratingToNext:result.ratingToNext,progressPercent:result.progressPercent,playerUsername:profile?.username || "You",...(activeRun.challengeToken && "opponentScore" in result ? {opponentUsername:result.opponentUsername,opponentScore:result.opponentScore,winnerUsername:result.winnerUsername} : {})}); + setRankedSubmission({state:"verified",score:result.score,xp:result.xp,completed:"completed" in result ? result.completed : true,rank:result.rank,streakBonusXp:"streakBonusXp" in result ? result.streakBonusXp : undefined,streakBonusLabel:"streakBonusLabel" in result ? result.streakBonusLabel : undefined,proofId:"proofId" in result ? result.proofId : undefined,proofCode:"proofCode" in result ? result.proofCode : undefined,proofText:"proofText" in result ? result.proofText : undefined,stakeLabel:"stakeLabel" in result ? (result as any).stakeLabel ?? null : undefined,previousBest:result.previousBest,improvement:result.improvement,personalBest:result.personalBest,ratingDelta:result.ratingDelta,rating:result.rating,grade:result.grade,nextGrade:result.nextGrade,ratingToNext:result.ratingToNext,progressPercent:result.progressPercent,playerUsername:profile?.username || "You",...(activeRun.challengeToken && "opponentScore" in result ? {opponentUsername:result.opponentUsername,opponentScore:result.opponentScore,winnerUsername:result.winnerUsername} : {})}); setLeaderboardGame(id); setVerifiedBest("best" in result ? result.best : result.score); setScores(x=>({...x,[id]:Math.max(x[id]||0,"best" in result ? result.best : result.score)})); @@ -372,19 +375,19 @@ function App() {
{wallet ? : }
{wallet ? <>LIVE / VERIFIED SESSION{wallet} : walletError ? <>WALLET CONNECTION FAILED{walletError} : <>WALLET{isNimiqPay()?"Nimiq Pay detected - ready to verify":"Connect with Nimiq Hub to play ranked"}}
{view === "operations" && <> -
OPERATOR STATUS

{operator?.username || profile?.username || "GUEST OPERATOR"}

{dashboardLoading ? "SYNCING VERIFIED DATA" : operator?.displayGrade || profile?.displayGrade || "UNRANKED"}

GLOBAL RANK{operator?.globalRank ? `#${operator.globalRank}` : "—"}
RATING{operator?.rating?.toLocaleString() || "—"}
LEVEL / XP{operator ? `${operator.level} / ${operator.xp.toLocaleString()}` : "—"}
STREAK{operator?.streak ? `🔥 ${operator.streak} DAYS` : "—"}
-
FEATURED GAME · VERIFIED SKILL RUN
PLAY. COMPETE. EARN.

{dashboardDaily?.completed ? "MISSION\nCOMPLETE" : "LEVEL UP\nYOUR"}
{dailyGame?.name.toUpperCase() ?? "NIM PIN"}

{dashboardDaily?.completed ? "Your score is on the board. Improve it only if the daily rules permit another attempt." : wallet ? "Your daily skill test is ready. Make the run count." : "Enter the Nimiq Arcade. Play free, then connect to take your place on the board."}

DAILY DROP
ENDS IN{formatCountdown(dailyRemaining)}
YOUR BEST{dashboardDaily?.score?.toLocaleString() || "—"}
DAILY RANK{dashboardDaily?.rank ? `#${dashboardDaily.rank}` : "—"}
+
OPERATOR STATUS · THE FIRST SKILL PLATFORM WHERE RANK IS PROVEN, NOT CLAIMED

{operator?.username || profile?.username || "GUEST OPERATOR"}

{operator?.badge ? VERIFIED {operator.badge} : wallet ? PROVE IT · FINISH A RANKED RUN : GUEST · CONNECT TO VERIFY}{operator?.title && {operator.title}}

{dashboardLoading ? "SYNCING VERIFIED DATA" : operator?.displayGrade || profile?.displayGrade || "UNRANKED"}

GLOBAL RANK{operator?.globalRank ? `#${operator.globalRank}` : "—"}
RATING{operator?.rating?.toLocaleString() || "—"}
LEVEL / XP{operator ? `${operator.level} / ${operator.xp.toLocaleString()}` : "—"}
STREAK{operator?.streak ? `🔥 ${operator.streak} DAYS` : "—"}
+
FEATURED GAME · VERIFIED SKILL RUN
PLAY. COMPETE. EARN.

{dashboardDaily?.completed ? "MISSION\nCOMPLETE" : "LEVEL UP\nYOUR"}
{dailyGame?.name.toUpperCase() ?? "NIM PIN"}

{dashboardDaily?.completed ? "Your score is on the board. Improve it only if the daily rules permit another attempt." : wallet ? "Your daily skill test is ready. Make the run count." : "Enter the Nimiq Arcade. Play free, then connect to take your place on the board."}

1 PLAY AS GUEST2 BEAT THE BOARD3 CONNECT WALLET TO CLAIM RANK
DAILY DROP
ENDS IN{formatCountdown(dailyRemaining)}
YOUR BEST{dashboardDaily?.score?.toLocaleString() || "—"}
DAILY RANK{dashboardDaily?.rank ? `#${dashboardDaily.rank}` : "—"}
SEASON01
OPERATORS-
RANKED RUNS-
STATUSLIVE / ONLINE
-
01 TODAY'S OPERATION
ONE VERIFIED ATTEMPT

{dailyGame?.name.toUpperCase() ?? "NIM PIN"}

ENDS IN / {formatCountdown(dailyRemaining)}
YOUR SCORE: {competitiveSummary?.daily?.score?.toLocaleString() || "-"}DAILY RANK: {competitiveSummary?.daily?.rank ? `#${competitiveSummary.daily.rank}` : "-"}TOP SCORE: {competitiveSummary?.daily?.topScore?.toLocaleString() || "-"}

{competitiveSummary?.daily?.pointsToNext ? `Beat the next player by ${competitiveSummary.daily.pointsToNext} points.` : "Complete today's operation to enter the daily board."}

+
01 TODAY'S OPERATION
ONE VERIFIED ATTEMPT

{dailyGame?.name.toUpperCase() ?? "NIM PIN"}

ENDS IN / {formatCountdown(dailyRemaining)}
YOUR SCORE: {competitiveSummary?.daily?.score?.toLocaleString() || "-"}DAILY RANK: {competitiveSummary?.daily?.rank ? `#${competitiveSummary.daily.rank}` : "-"}TOP SCORE: {competitiveSummary?.daily?.topScore?.toLocaleString() || "-"}STREAK BONUS: {operator?.streakBonus?.bonusXp ? `+${operator.streakBonus.bonusXp} XP` : "WIN 3 DAILY STREAKS"}

{competitiveSummary?.daily?.pointsToNext ? `Beat the next player by ${competitiveSummary.daily.pointsToNext} points.` : "Complete today's operation to enter the daily board."}

02

POPULAR GAMES

{wallet ? "WALLET VERIFIED" : "SIGN IN TO COMPETE"}
{wallet ? "RESULTS COUNT" : "RESULTS STAY LOCKED"}

{games.filter(g=>rankedGames.includes(g.id)).map((g,i)=>{const Icon=g.icon;return })}
03

PRACTICE GAMES

PLAY FREELY
NO SIGN-IN REQUIRED

{games.filter(g=>!rankedGames.includes(g.id)).map((g,i)=>{const Icon=g.icon;return })}
03

RANKINGS

GLOBAL
VERIFIED

{globalRank ? getGlobalRankText(globalRank, pointsAway ?? 0) : "CONNECT TO SEE YOUR RANK"}
{competitiveSummary?.nextTarget ? `${competitiveSummary.nextTarget.pointsAway} points to pass @${competitiveSummary.nextTarget.username}` : "Complete a verified run to set your rank."}
{leaderboard.length ? leaderboard.map((row,index)=>
{String(index+1).padStart(2,"0")}{row.username}{row.score.toLocaleString()}->
) :
{wallet ? "NO VERIFIED SCORES YET" : "CONNECT TO RANK"}{wallet ? "Complete a ranked challenge to appear here." : "Guest scores stay on this device and never enter the board."}
}
YOUR BEST{wallet ? (competitiveSummary?.personalBest?.score || verifiedBest || scores[leaderboardGame] || "-") : "GUEST"}{wallet ? "VERIFIED OPERATOR" : "VERIFICATION REQUIRED"}
-
04 CHALLENGE A FRIEND

Same puzzle.

Same seed.

One winner.

{challengeMessage || (challenge ? challenge.status === "WAITING" ? "Waiting for opponent..." : `${challenge.opponentUsername || "Opponent"} joined. Beat their score.` : "Compete asynchronously with a friend.")}

{!challenge && }{challenge && <>CHALLENGE CREATED}
+
04 CHALLENGE A FRIEND
setStakeInput(event.target.value)} placeholder="e.g. Winner picks dinner" maxLength={24}/>

Same puzzle.

Same seed.

One winner.

{challengeMessage || (challenge ? challenge.status === "WAITING" ? "Waiting for opponent..." : `${challenge.opponentUsername || "Opponent"} joined. Beat their score.` : "Compete asynchronously with a friend.")}

{!challenge && }{challenge && <>CHALLENGE CREATED}
05OPERATOR PROFILE
LVL {level}
OPERATOR RATING
{(competitiveSummary?.rating ?? profile?.rating)?.toLocaleString()||"-"}
{competitiveSummary?.grade || (profile ? `${profile.grade}` : "CONNECT WALLET TO BUILD RATING")}{competitiveSummary?.nextGrade &&

{competitiveSummary.ratingToNext} RATING TO {competitiveSummary.nextGrade}

}
USERNAME{profile?.username || "UNNAMED PLAYER"}{wallet && }
CURRENT XP{displayXp.toLocaleString()}
STREAK{competitiveSummary?.streak || profile?.streak ? `${competitiveSummary?.streak || profile?.streak} DAYS` : "-"}
-
06 ACHIEVEMENTS
{achievements.map(item=>
{item.unlocked ? "*" : "-"}
{item.name}{item.description}{item.unlockedAt && UNLOCKED {new Date(item.unlockedAt).toLocaleDateString()}}
{item.rarity}
)}{!achievements.length &&

Connect your wallet to track achievements.

}
07 RECENT CHALLENGES
{challengeHistory.slice(0,5).map(item=>
@{item.opponentUsername || "Unnamed Player"}{item.yourScore !== null && item.theirScore !== null ? `${item.yourScore.toLocaleString()} - ${item.theirScore.toLocaleString()}` : "WAITING"}
{item.outcome}{item.outcome !== "ACTIVE" && }
)}{!challengeHistory.length &&

Create a challenge to start your rivalry history.

}
+
OPERATOR OF THE WEEK · COMMUNITY SPOTLIGHT
{(dashboard?.weeklySpotlight || []).length ? (dashboard?.weeklySpotlight || []).map(item =>
RANK #{item.rank}@{item.username}{item.bestScore.toLocaleString()} PTS · {item.runs} RUNS
) :

No verified runs in the last 7 days. Be the first Operator of the Week.

}
06 ACHIEVEMENTS
{achievements.map(item=>
{item.unlocked ? "*" : "-"}
{item.name}{item.description}{item.unlockedAt && UNLOCKED {new Date(item.unlockedAt).toLocaleDateString()}}
{item.rarity}
)}{!achievements.length &&

Connect your wallet to track achievements.

}
07 RECENT CHALLENGES
{challengeHistory.slice(0,5).map(item=>
@{item.opponentUsername || "Unnamed Player"}{item.yourScore !== null && item.theirScore !== null ? `${item.yourScore.toLocaleString()} - ${item.theirScore.toLocaleString()}` : "WAITING"}
{item.outcome}{item.outcome !== "ACTIVE" && }
)}{!challengeHistory.length &&

Create a challenge to start your rivalry history.

}
} {view === "rankings" &&
COMPETITIVE INTELLIGENCE

RANKINGS

Verified results only. Your position is highlighted wherever you compete.

{(["all","daily","weekly","season"] as RankedPeriod[]).map(period=>)}
{leaderboard.length ? leaderboard.map((row,index)=>
#{row.rank || index + 1}{row.isYou ? "YOU · " : ""}{row.username}{row.score.toLocaleString()}{row.isYou ? "CURRENT" : ""}
) :
NO RESULTS FOR THIS VIEWTry another period or make the first verified run.
}
} - {view === "profile" &&
COMPETITIVE IDENTITY

{operator?.username || profile?.username || "GUEST OPERATOR"}

{operator?.displayGrade || "UNRANKED"}{operator?.rating?.toLocaleString() || "—"} RATING

{operator?.streak ? `🔥 ${operator.streak} day streak` : "Your competitive record begins with a verified run."}

{wallet && }
STATISTICS

GAMES PLAYED{dashboard?.stats?.gamesPlayed ?? 0}

CHALLENGE WINS{dashboard?.stats?.wins ?? 0}

WIN RATE{dashboard?.stats?.winRate !== null && dashboard?.stats?.winRate !== undefined ? `${dashboard.stats.winRate}%` : "—"}

BEST SCORE{dashboard?.stats?.bestScore?.toLocaleString() ?? "—"}

SPECIALTIES{dashboard?.specialties?.length ? dashboard.specialties.slice(0,4).map(item=>

{games.find(game=>game.id===item.gameId)?.name || item.gameId}#{item.rank} · {item.best?.toLocaleString()}

) :

Your best games will appear after verified runs.

}
ACHIEVEMENTS · {dashboard?.achievements.unlocked ?? 0}/{dashboard?.achievements.total ?? 0}{dashboard?.achievements.items.filter(item=>item.unlocked).slice(0,4).map(item=>

✦ {item.name}{item.rarity}

) ||

Connect to track progress.

}
MATCH HISTORY{dashboard?.matchHistory?.length ? dashboard.matchHistory.slice(0,5).map((item,index)=>

{games.find(game=>game.id===item.gameId)?.name || item.gameId}{item.score.toLocaleString()} · +{item.xp} XP

) :

No verified runs yet.

}
} + {view === "profile" &&
COMPETITIVE IDENTITY · PROVEN ON NIMIQ, NOT CLAIMED

{operator?.username || profile?.username || "GUEST OPERATOR"}

{(operator?.badge || profile?.badge) ? VERIFIED {(operator?.badge || profile?.badge) as string} : UNVERIFIED · FINISH A RANKED RUN}{(operator?.title || profile?.title) && {(operator?.title || profile?.title) as string}}
{operator?.displayGrade || "UNRANKED"}{operator?.rating?.toLocaleString() || "—"} RATING

{operator?.streakBonus?.label ? `${operator.streakBonus.label} · ` : ""}{operator?.streak ? `🔥 ${operator.streak} day streak` : "Your competitive record begins with a verified run."}

{wallet && }
STATISTICS

GAMES PLAYED{dashboard?.stats?.gamesPlayed ?? 0}

CHALLENGE WINS{dashboard?.stats?.wins ?? 0}

WIN RATE{dashboard?.stats?.winRate !== null && dashboard?.stats?.winRate !== undefined ? `${dashboard.stats.winRate}%` : "—"}

BEST SCORE{dashboard?.stats?.bestScore?.toLocaleString() ?? "—"}

SPECIALTIES{dashboard?.specialties?.length ? dashboard.specialties.slice(0,4).map(item=>

{games.find(game=>game.id===item.gameId)?.name || item.gameId}#{item.rank} · {item.best?.toLocaleString()}

) :

Your best games will appear after verified runs.

}
ACHIEVEMENTS · {dashboard?.achievements.unlocked ?? 0}/{dashboard?.achievements.total ?? 0}{dashboard?.achievements.items.filter(item=>item.unlocked).slice(0,4).map(item=>

✦ {item.name}{item.rarity}

) ||

Connect to track progress.

}
MATCH HISTORY{dashboard?.matchHistory?.length ? dashboard.matchHistory.slice(0,5).map((item,index)=>

{games.find(game=>game.id===item.gameId)?.name || item.gameId}{item.score.toLocaleString()} · +{item.xp} XP

) :

No verified runs yet.

}
}
OPERATORCOMPETITIVE SKILL CHALLENGES, POWERED BY NIMIQNO PRIVATE KEYS ARE EVER EXPOSED
{briefing && game.id===briefing.id)!} mode={briefing.mode} briefing={gameBriefings[briefing.id]} rating={profile?.rating} onClose={()=>setBriefing(null)} onStart={()=>{const selected=briefing;setBriefing(null);void beginGame(selected.id,selected.mode)}}/>} @@ -438,7 +441,7 @@ function RankedResult({submission,preview,onRestart}:{submission:RankedSubmissio const challengeComplete = submission.opponentScore !== null && submission.opponentScore !== undefined; const isChallenge = Object.prototype.hasOwnProperty.call(submission,"opponentScore"); const outcome = challengeComplete ? submission.winnerUsername === submission.playerUsername ? "YOU WIN" : submission.winnerUsername ? "YOU LOSE" : "DRAW" : "WAITING FOR OPPONENT"; - return
{challengeComplete ? "CHALLENGE COMPLETE" : submission.completed === false ? "VERIFIED PARTIAL RESULT" : "VERIFIED RESULT"}{challengeComplete ?
@{submission.playerUsername}{submission.score?.toLocaleString()}
@{submission.opponentUsername || "Opponent"}{(submission.opponentScore ?? 0).toLocaleString()}
{outcome}
: <>

{submission.score?.toLocaleString()}

{submission.completed === false &&

RUN ENDED — VERIFIED PROGRESS SAVED

}{submission.personalBest ? <>

NEW PERSONAL BEST {submission.improvement && submission.improvement > 0 ? `+${submission.improvement} points` : ""}

PREVIOUS: {(submission.previousBest ?? 0).toLocaleString()}NEW: {(submission.score ?? 0).toLocaleString()}
:

BEST: {(submission.previousBest ?? submission.score ?? 0).toLocaleString()} - {Math.abs(submission.improvement || 0)} points to improve

}

+{submission.xp} XP {submission.ratingDelta ? `- ${submission.ratingDelta > 0 ? "+" : ""}${submission.ratingDelta} RATING` : ""}

{submission.rank ? `GLOBAL RANK #${submission.rank}` : "RANKING UPDATED"} {submission.nextGrade && submission.ratingToNext ? `- ${submission.ratingToNext} TO ${submission.nextGrade}` : ""}

}
; + return
{challengeComplete ? "CHALLENGE COMPLETE" : submission.completed === false ? "VERIFIED PARTIAL RESULT" : "VERIFIED RESULT"}
PROOF CARD · CRYPTOGRAPHICALLY VERIFIED, NOT CLAIMED{submission.proofText ? {submission.proofText} : Verified on Nimiq via OPERATOR}{submission.proofCode && PROOF {submission.proofCode}}
{challengeComplete ?
@{submission.playerUsername}{submission.score?.toLocaleString()}
@{submission.opponentUsername || "Opponent"}{(submission.opponentScore ?? 0).toLocaleString()}
{outcome}
: <>

{submission.score?.toLocaleString()}

{submission.completed === false &&

RUN ENDED — VERIFIED PROGRESS SAVED

}{submission.personalBest ? <>

NEW PERSONAL BEST {submission.improvement && submission.improvement > 0 ? `+${submission.improvement} points` : ""}

PREVIOUS: {(submission.previousBest ?? 0).toLocaleString()}NEW: {(submission.score ?? 0).toLocaleString()}
:

BEST: {(submission.previousBest ?? submission.score ?? 0).toLocaleString()} - {Math.abs(submission.improvement || 0)} points to improve

}

+{submission.xp} XP{submission.streakBonusXp ? ` (${submission.streakBonusLabel || `STREAK +${submission.streakBonusXp} XP`})` : ""} {submission.ratingDelta ? `- ${submission.ratingDelta > 0 ? "+" : ""}${submission.ratingDelta} RATING` : ""}

{submission.rank ? `GLOBAL RANK #${submission.rank}` : "RANKING UPDATED"} {submission.nextGrade && submission.ratingToNext ? `- ${submission.ratingToNext} TO ${submission.nextGrade}` : ""}

}
; } return
PREVIEW

{preview.score.toLocaleString()}

Waiting for validation...

; } diff --git a/src/api.ts b/src/api.ts index ad4134b..123b773 100644 --- a/src/api.ts +++ b/src/api.ts @@ -52,7 +52,7 @@ export type RankedPeriod = "all" | "daily" | "weekly" | "season"; export async function submitRun(runId: string, events: unknown[]) { const response = await fetch(`${API_URL}/runs/${runId}/submit`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ events }) }); if (!response.ok) throw new Error((await response.json()).error || "Run was rejected"); - return await response.json() as { score: number; xp: number; completed?: boolean; rank: number | null; previousRank?: number | null; best: number; previousBest?: number | null; personalBest?: boolean; improvement?: number; rating?: number; grade?: string; displayGrade?: string; ratingDelta?: number; nextGrade?: string | null; nextGradeRating?: number | null; ratingToNext?: number; progressPercent?: number; ranked?: boolean; streak?: number; nextTarget?: NextTarget | null }; + return await response.json() as { score: number; xp: number; completed?: boolean; rank: number | null; previousRank?: number | null; best: number; previousBest?: number | null; personalBest?: boolean; improvement?: number; streakBonusXp?: number; streakBonusLabel?: string | null; verifiedStreak?: number; verifiedStreakDay?: string | null; proofId?: string; proofCode?: string; proofText?: string; rating?: number; grade?: string; displayGrade?: string; ratingDelta?: number; nextGrade?: string | null; nextGradeRating?: number | null; ratingToNext?: number; progressPercent?: number; ranked?: boolean; streak?: number; nextTarget?: NextTarget | null }; } export async function getLeaderboard(gameId: string = "all", period: RankedPeriod = "all") { @@ -64,7 +64,7 @@ export async function getLeaderboard(gameId: string = "all", period: RankedPerio export async function getMe() { const response = await fetch(`${API_URL}/me`, { credentials: "include" }); if (!response.ok) throw new Error("Could not load operator profile"); - return await response.json() as { address: string | null; username: string | null; xp: number; streak: number; rating: number; grade: string; displayGrade?: string; verifiedRuns: number; level?: number }; + return await response.json() as { address: string | null; username: string | null; xp: number; streak: number; rating: number; grade: string; displayGrade?: string; verifiedRuns: number; title?: string | null; badge?: string | null; level?: number }; } export type CompetitiveSummary = { @@ -94,7 +94,7 @@ export async function getCompetitiveSummary(gameId: string) { } export type Achievement = { id: string; name: string; description: string; rarity: string; xp: number; unlocked: boolean; unlockedAt?: string }; -export type ChallengeHistory = { challengeId: string; gameId: string; opponentUsername: string | null; yourScore: number | null; theirScore: number | null; outcome: "ACTIVE" | "DRAW" | "WIN" | "LOSS"; createdAt: string }; +export type ChallengeHistory = { challengeId: string; gameId: string; stakeLabel?: string | null; opponentUsername: string | null; yourScore: number | null; theirScore: number | null; outcome: "ACTIVE" | "DRAW" | "WIN" | "LOSS"; createdAt: string }; export async function getAchievements() { const response = await fetch(`${API_URL}/achievements`, { credentials: "include" }); @@ -129,18 +129,22 @@ export type Challenge = { challengeId: string; token: string; gameId: string; + stakeLabel?: string | null; seed?: string; creatorUsername: string; opponentUsername: string | null; creatorScore: number | null; opponentScore: number | null; + proofId?: string; + proofCode?: string; + proofText?: string; winnerUsername: string | null; status: "WAITING" | "IN_PROGRESS" | "COMPLETED" | "EXPIRED"; expiresAt: string; }; -export async function createChallenge(gameId: string) { - const response = await fetch(`${API_URL}/challenges`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ gameId }) }); +export async function createChallenge(gameId: string, stakeLabel?: string) { + const response = await fetch(`${API_URL}/challenges`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(stakeLabel ? { gameId, stakeLabel } : { gameId }) }); if (!response.ok) throw new Error((await response.json()).error || "Could not create challenge"); return await response.json() as Challenge & { seed: string }; } @@ -164,11 +168,14 @@ export async function submitChallenge(challengeId: string, events: unknown[]) { } export type DashboardGame = { gameId: string; best: number | null; rank: number | null; ranked: boolean; challengeCapable: boolean }; -export type DashboardChallenge = { challengeId: string; gameId: string; opponentUsername: string | null; yourScore: number | null; theirScore: number | null; outcome: "ACTIVE" | "DRAW" | "WIN" | "LOSS"; createdAt: string; expiresAt: string; incoming: boolean }; +export type DashboardChallenge = { challengeId: string; gameId: string; stakeLabel?: string | null; opponentUsername: string | null; yourScore: number | null; theirScore: number | null; outcome: "ACTIVE" | "DRAW" | "WIN" | "LOSS"; createdAt: string; expiresAt: string; incoming: boolean }; export type Dashboard = { authenticated: boolean; operator: { username: string | null; + title?: string | null; + badge?: string | null; + streakBonus?: { bonusXp: number; label: string | null }; rating: number; grade: string; displayGrade: string; @@ -184,6 +191,7 @@ export type Dashboard = { ratingToNext?: number; progressPercent?: number; } | null; + weeklySpotlight?: Array<{ rank: number; username: string; bestScore: number; runs: number }>; daily: { gameId: string; endsAt: string; score: number | null; rank: number | null; completed: boolean; topScore: number | null; pointsToNext: number | null }; games: DashboardGame[]; nearbyPlayers: Array<{ rank: number; username: string; score: number; isYou: boolean }>; diff --git a/src/styles.css b/src/styles.css index 606826b..932ad33 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,5 +1,5 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap'); -.verified-nav{display:flex;align-items:center;gap:7px;color:var(--cyan);font-weight:600} +.verified-badge{display:inline-flex;align-items:center;gap:6px;margin-top:10px;padding:6px 12px;border:1px solid rgba(255,216,74,.65);border-radius:999px;background:rgba(255,216,74,.12);color:#ffe34d;font:600 11px "JetBrains Mono";letter-spacing:.12em}.verified-badge.pending{border-color:rgba(0,200,255,.45);background:rgba(0,200,255,.08);color:#8fe6ff}.operator-title{display:block;margin-top:8px;color:#9babbc;font:500 11px "JetBrains Mono";letter-spacing:.18em}.onboarding-strip{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0}.onboarding-strip span{padding:7px 11px;border:1px solid var(--line);border-radius:999px;background:rgba(255,255,255,.03);color:#9babbc;font:600 10px "JetBrains Mono";letter-spacing:.1em}.proof-card{margin:16px auto 4px;max-width:520px;padding:14px;border:1px dashed rgba(0,200,255,.55);border-radius:14px;background:rgba(0,200,255,.06);display:grid;gap:8px}.proof-card span{color:#8fe6ff;font:600 10px "JetBrains Mono";letter-spacing:.12em}.proof-card b{color:#fff;font-size:13px;line-height:1.5}.proof-card i{color:#ffe34d;font:600 11px "JetBrains Mono";font-style:normal;letter-spacing:.1em}.stake-row{display:grid;gap:6px;margin-bottom:12px;text-align:left}.stake-row label{color:#8fe6ff;font:600 10px "JetBrains Mono";letter-spacing:.12em}.stake-row input{padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#0b1424;color:#fff}.spotlight-strip{padding:34px 0 0}.spotlight-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:14px}.spotlight-card{padding:16px;border:1px solid rgba(255,216,74,.35);border-radius:16px;background:linear-gradient(145deg,rgba(43,32,8,.85),rgba(10,18,36,.92));display:grid;gap:6px}.spotlight-card span{color:#ffe34d;font:600 10px "JetBrains Mono";letter-spacing:.12em}.spotlight-card b{font-size:17px}.spotlight-card strong{color:#9babbc;font-size:12px}@media(max-width:700px){.spotlight-grid{grid-template-columns:1fr}}.verified-nav{display:flex;align-items:center;gap:7px;color:var(--cyan);font-weight:600} .briefing-backdrop{z-index:40}.briefing-modal{position:relative;width:min(620px,100%);padding:36px;border:1px solid rgba(0,200,255,.48);background:linear-gradient(145deg,#101b2b,#07111e);box-shadow:0 30px 110px rgba(0,0,0,.6)}.briefing-icon{display:grid;place-items:center;width:58px;height:58px;margin-bottom:22px;border:1px solid rgba(0,200,255,.48);color:var(--cyan);background:rgba(0,200,255,.08)}.briefing-modal>small{font:10px "JetBrains Mono";letter-spacing:.14em;color:var(--cyan)}.briefing-modal h2{margin:11px 0 4px;font:600 clamp(35px,6vw,64px) "Space Grotesk";letter-spacing:-.07em}.briefing-subtitle{margin:0;color:#9babbc}.briefing-steps{display:grid;gap:0;margin:28px 0;border-top:1px solid var(--line)}.briefing-steps>div{display:grid;grid-template-columns:48px 1fr;gap:14px;padding:15px 0;border-bottom:1px solid var(--line)}.briefing-steps span{color:var(--gold);font:12px "JetBrains Mono"}.briefing-steps p{display:grid;gap:5px;margin:0;color:#b4c1cf;font-size:13px;line-height:1.45}.briefing-steps b{color:#eff7ff;font:10px "JetBrains Mono";letter-spacing:.1em}.briefing-proof{padding:13px 15px;margin:0 0 23px;border-left:2px solid var(--cyan);background:rgba(0,200,255,.06);color:#d6effa;font-size:12px;line-height:1.5}.briefing-start{width:100%}@media(max-width:560px){.briefing-modal{padding:28px 20px}.briefing-modal h2{font-size:40px}.briefing-steps>div{grid-template-columns:37px 1fr}} :root{font-family:"Inter",sans-serif;color:#F5F7FA;background:#080D18;--gold:#FFD84A;--gold2:#9E8330;--cyan:#00C8FF;--navy:#080D18;--panel:#101827;--muted:#8B96A8;--line:rgba(139,150,168,.2)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(circle at 50% 18%,rgba(0,200,255,.08),transparent 35%),#080D18}button,a{font:inherit;color:inherit}button{cursor:pointer}a{text-decoration:none}.site,.game-shell{max-width:1260px;margin:auto;padding:0 38px}.nav{height:76px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.wordmark{border:0;background:none;display:flex;align-items:center;gap:8px;padding:0}.nimiq-mark{color:var(--gold);font-size:24px;transform:rotate(45deg)}.wordmark-main{font-size:17px;font-weight:600;letter-spacing:.08em}.wordmark-sub{font:8px "JetBrains Mono";letter-spacing:.08em;color:#8b96a5;border-left:1px solid var(--line);padding-left:9px;width:65px;text-align:left}.nav-links{display:flex;gap:38px;font-size:13px;color:#aab3bf}.nav-links a:hover{color:white}.connect{border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.035);border-radius:24px;padding:10px 17px;font-size:12px;font-weight:600}.connect i{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--gold);margin-right:8px}.hero-editorial{min-height:680px;display:grid;grid-template-columns:1.15fr .85fr;align-items:center;position:relative}.hero-editorial:after{content:"";position:absolute;bottom:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--line),transparent)}.hero-copy{padding:85px 0 100px}.kicker{font:10px "JetBrains Mono";letter-spacing:.15em;color:#8995a4}.kicker span{display:inline-block;width:28px;height:1px;background:var(--gold2);margin-right:10px;vertical-align:middle}.hero-copy h1{font-size:clamp(68px,9vw,128px);line-height:.86;letter-spacing:-.07em;font-weight:600;margin:27px 0}.hero-copy h1 em{font-style:normal;color:var(--gold)}.hero-copy p{max-width:570px;color:var(--muted);font-size:16px;line-height:1.7}.hero-actions{display:flex;gap:28px;align-items:center;margin-top:34px}.gold-btn{border:0;background:var(--gold);color:#121820;border-radius:2px;padding:14px 18px;font-size:10px;font-weight:700;letter-spacing:.04em}.gold-btn:hover{background:#fff0b2}.gold-btn b{font-size:16px;margin-left:12px}.text-btn{font:10px "JetBrains Mono";letter-spacing:.08em;color:#aeb8c4}.text-btn:hover{color:var(--gold)}.hero-emblem{height:380px;position:relative;display:grid;place-items:center}.core{width:112px;height:112px;border:1px solid rgba(255,225,138,.65);display:grid;place-items:center;color:var(--gold);font-size:45px;transform:rotate(45deg);box-shadow:0 0 90px rgba(255,225,138,.07)}.orbit{position:absolute;border:1px solid rgba(255,225,138,.12);border-radius:50%}.orbit.a{width:290px;height:290px;border-left-color:rgba(255,225,138,.45);transform:rotate(35deg)}.orbit.b{width:380px;height:180px;border-top-color:rgba(255,225,138,.28);transform:rotate(-35deg)}.hero-emblem small{position:absolute;bottom:35px;font:9px "JetBrains Mono";color:#667386;letter-spacing:.15em}.season-strip{display:grid;grid-template-columns:repeat(4,1fr);border-bottom:1px solid var(--line)}.season-strip div{padding:25px;border-right:1px solid var(--line)}.season-strip div:last-child{border:0}.season-strip small,.feature-card>div>small,.feature-side small,.profile-card small{display:block;font:9px "JetBrains Mono";letter-spacing:.13em;color:#697789}.season-strip b{font-size:20px;font-weight:500}.season-strip .live{color:var(--gold);font:11px "JetBrains Mono"}.feature-section,.lab-section,.leaderboard-section,.profile-section{padding:105px 0}.section-label{font:9px "JetBrains Mono";color:#697789;letter-spacing:.12em;margin-bottom:17px}.section-label span{margin-left:14px}.feature-card{min-height:310px;border:1px solid var(--line);background:linear-gradient(125deg,#07111e,#040c16);display:grid;grid-template-columns:1fr .4fr;padding:47px 50px;position:relative;overflow:hidden}.feature-card:after{content:"";position:absolute;width:480px;height:480px;border:1px solid rgba(255,225,138,.07);border-radius:50%;right:-260px;top:-100px}.feature-icon{width:43px;height:43px;border:1px solid rgba(255,225,138,.35);display:grid;place-items:center;color:var(--gold);margin-bottom:30px}.feature-card h2{font-size:52px;letter-spacing:-.05em;margin:8px 0}.feature-card p{max-width:580px;color:#7d8998;line-height:1.6;font-size:14px}.feature-side{border-left:1px solid var(--line);padding-left:45px;display:flex;flex-direction:column;justify-content:center;gap:25px;z-index:1}.feature-side strong{display:block;font-size:22px;font-weight:500;margin-top:5px}.gold-btn.compact{padding:13px 15px}.section-heading{display:flex;justify-content:space-between;align-items:end;margin-bottom:32px}.section-heading>div{display:flex;gap:16px;align-items:center}.section-heading>div>span{font:10px "JetBrains Mono";color:var(--gold2)}.section-heading h2{font-size:39px;letter-spacing:-.04em;margin:0}.section-heading p{text-align:right;color:#697789;font:10px "JetBrains Mono";line-height:1.6}.game-list{border-top:1px solid var(--line)}.editorial-game{width:100%;min-height:94px;border:0;border-bottom:1px solid var(--line);background:transparent;display:grid;grid-template-columns:70px 45px 1fr 100px 30px;align-items:center;text-align:left;padding:0 13px;transition:.2s}.editorial-game:hover{background:rgba(255,255,255,.025);padding-left:20px}.editorial-game>span,.editorial-game>small{font:9px "JetBrains Mono";color:#647284}.editorial-game svg{color:#8996a6}.editorial-game b{font-size:18px;font-weight:500}.editorial-game div small{display:block;color:#697789;font-size:11px;margin-top:5px}.editorial-game>small{text-align:right}.editorial-game>strong{text-align:right;color:var(--gold);font-size:18px}.leaderboard-section{display:grid;grid-template-columns:.75fr 1.25fr;gap:75px;border-top:1px solid var(--line)}.leaderboard-section>.section-heading{display:block}.leaderboard-section>.section-heading>div{margin-bottom:20px}.leaderboard-section>.section-heading p{text-align:left}.leaderboard-table{border-top:1px solid var(--line)}.rank-row{height:66px;display:grid;grid-template-columns:55px 1fr 100px 30px;align-items:center;border-bottom:1px solid var(--line);font:12px "JetBrains Mono"}.rank-row>span:first-child{color:var(--gold2)}.rank-row>b{text-align:right;font-weight:400}.rank-row i{text-align:right;color:#667386;font-style:normal}.your-rank{margin-top:27px;background:#07111d;border:1px solid var(--line);padding:18px 20px;display:grid;grid-template-columns:1fr auto auto;gap:25px;align-items:center;font:9px "JetBrains Mono";color:#718093}.your-rank b{font-size:21px;color:var(--gold);font-weight:500}.your-rank strong{font-weight:500;color:#bfc7d0}.profile-section{border-top:1px solid var(--line)}.profile-card{border:1px solid var(--line);background:#050e18;padding:36px 42px}.profile-head{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:20px;font:10px "JetBrains Mono"}.profile-head>div:first-child{display:flex;gap:15px}.profile-head>div:first-child span{color:var(--gold2)}.profile-head>div:last-child{color:#788597}.profile-head b{color:var(--gold);font-size:23px}.profile-main{display:grid;grid-template-columns:1fr 1fr;gap:80px;padding-top:42px}.big-xp{font-size:62px;letter-spacing:-.06em;margin:6px 0 15px}.xp-line{height:3px;background:#17202b;max-width:480px}.xp-line i{display:block;height:100%;background:var(--gold)}.profile-stats{display:grid;grid-template-columns:repeat(3,1fr);align-content:center}.profile-stats div{border-left:1px solid var(--line);padding-left:20px}.profile-stats b{display:block;font-size:18px;font-weight:500;margin-top:7px}footer{border-top:1px solid var(--line);padding:23px 0 35px;display:flex;justify-content:space-between;font:8px "JetBrains Mono";letter-spacing:.08em;color:#536172}.game-stage{min-height:calc(100vh - 77px);display:grid;place-items:center;padding:50px 0}.back-editorial{border:0;background:none;font:10px "JetBrains Mono";color:#7e8b9c}.game-nav-title{font:10px "JetBrains Mono";color:#aab3bf;letter-spacing:.1em} /* Focused challenge styling */.challenge{width:min(760px,100%);text-align:center}.challenge.narrow{max-width:500px}.hud{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:17px;margin-bottom:30px}.hud small{display:block;font:9px "JetBrains Mono";color:#697789;text-align:left}.hud b{display:block;font-size:26px;margin-top:5px}.timer{font:12px "JetBrains Mono";color:var(--gold);display:flex;gap:7px;align-items:center}.block-board{display:grid;grid-template-columns:repeat(11,1fr);gap:4px}.block{aspect-ratio:1;border:0;border-radius:2px}.block.cyan{background:#ffe18a}.block.lime{background:#d7c176}.block.violet{background:#8996a6}.block.empty{visibility:hidden}.hint{color:#687789;font-size:11px}.nim-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;max-width:520px;margin:auto}.node{aspect-ratio:1;border:1px solid var(--line);background:#07121f}.node.active{border-color:rgba(255,225,138,.7);box-shadow:0 0 45px rgba(255,225,138,.13)}.node span{display:block;width:11px;height:11px;border-radius:50%;background:#405063;margin:auto}.node.active span{background:var(--gold);box-shadow:0 0 16px var(--gold)}.pin-display{font:34px "JetBrains Mono";letter-spacing:.2em;margin:25px}.keypad,.memory-options{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;max-width:320px;margin:auto}.keypad button,.key-row button,.hexpad button,.memory-options button{border:1px solid var(--line);background:#07121f;border-radius:3px;padding:16px}.sequence{display:flex;justify-content:center;gap:10px;flex-wrap:wrap;margin:30px}.sequence span{font:22px "JetBrains Mono";border:1px solid var(--line);padding:13px;color:#566476}.sequence .seen{color:var(--gold)}.key-row{display:flex;justify-content:center;gap:8px}.key-row button{width:50px}.memory-code{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin:35px 0}.memory-code b,.node-code span{font:24px "JetBrains Mono";padding:15px;border:1px solid var(--line);color:var(--gold)}.memory-options{grid-template-columns:repeat(4,1fr);max-width:none}.locks{display:flex;justify-content:center;gap:14px;flex-wrap:wrap}.lock-ring{width:140px;aspect-ratio:1;border:1px solid rgba(255,225,138,.3);background:#07121f;border-radius:50%;position:relative}.lock-ring:before{content:"";position:absolute;inset:18px;border:1px dashed rgba(255,225,138,.18);border-radius:50%}.lock-ring i{position:absolute;width:7px;height:7px;border-radius:50%;background:#667487;left:50%;top:8px}.lock-ring span{position:absolute;left:50%;top:50%;color:var(--gold)}.lock-ring em{position:absolute;top:-14px;left:calc(50% - 5px);font-style:normal}.sync-track{height:50px;background:#07121f;border:1px solid var(--line);position:relative;margin:55px 0}.sync-target{position:absolute;left:42%;width:16%;height:100%;border-left:1px solid var(--gold);border-right:1px solid var(--gold)}.sync-cursor{position:absolute;top:4px;width:3px;height:40px;background:var(--gold)}.huge{width:100%;justify-content:center;padding:17px}.node-code{display:flex;justify-content:center;gap:10px;margin:35px}.hexpad{display:grid;grid-template-columns:repeat(8,1fr);gap:7px}.result{border:1px solid var(--line);background:#07111d;padding:45px 25px}.result-icon{width:64px;height:64px;border:1px solid rgba(255,225,138,.4);display:grid;place-items:center;color:var(--gold);margin:auto}.result small{font:9px "JetBrains Mono";color:#697789}.result h2{font-size:60px;color:var(--gold)} From c7f67c0b5a5c14f0d3b35849a951200a50b53d51 Mon Sep 17 00:00:00 2001 From: hayolaaaaaaaaaaa <201365190+hayolaaaaaaaaaaa@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:50:06 +0100 Subject: [PATCH 2/3] Remove Run again from game result screens; ranked runs stay single-use via operations --- src/App.tsx | 11 ++++++----- src/games.tsx | 14 ++++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 28c9866..47b723d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -430,18 +430,19 @@ function Game({id,run,rankedSubmission,onEvent,onFinish}:{id:GameId;run:Run|null } } -function ResultBox({result,onRestart}:{result:Result;onRestart:()=>void}) { - return
CHALLENGE COMPLETE

{result.score.toLocaleString()}

+{result.xp} XP

+function ResultBox({result,onRestart}:{result:Result;onRestart?:()=>void}) { + void onRestart; + return
CHALLENGE COMPLETE

{result.score.toLocaleString()}

+{result.xp} XP

Return to operations to play a new challenge.

} -function RankedResult({submission,preview,onRestart}:{submission:RankedSubmission;preview:Result;onRestart:()=>void}) { +function RankedResult({submission,preview,onRestart}:{submission:RankedSubmission;preview:Result;onRestart?:()=>void}) { if (submission.state === "submitting") return
SUBMITTING REPLAY

...

Validating run...

; - if (submission.state === "rejected") return
RUN NOT ACCEPTED

REJECTED

{submission.error || "The server could not verify this replay."}

; + if (submission.state === "rejected") return
RUN NOT ACCEPTED

REJECTED

{submission.error || "The server could not verify this replay."}

Ranked runs are single-use. Start a new verified run from operations.

; if (submission.state === "verified") { const challengeComplete = submission.opponentScore !== null && submission.opponentScore !== undefined; const isChallenge = Object.prototype.hasOwnProperty.call(submission,"opponentScore"); const outcome = challengeComplete ? submission.winnerUsername === submission.playerUsername ? "YOU WIN" : submission.winnerUsername ? "YOU LOSE" : "DRAW" : "WAITING FOR OPPONENT"; - return
{challengeComplete ? "CHALLENGE COMPLETE" : submission.completed === false ? "VERIFIED PARTIAL RESULT" : "VERIFIED RESULT"}
PROOF CARD · CRYPTOGRAPHICALLY VERIFIED, NOT CLAIMED{submission.proofText ? {submission.proofText} : Verified on Nimiq via OPERATOR}{submission.proofCode && PROOF {submission.proofCode}}
{challengeComplete ?
@{submission.playerUsername}{submission.score?.toLocaleString()}
@{submission.opponentUsername || "Opponent"}{(submission.opponentScore ?? 0).toLocaleString()}
{outcome}
: <>

{submission.score?.toLocaleString()}

{submission.completed === false &&

RUN ENDED — VERIFIED PROGRESS SAVED

}{submission.personalBest ? <>

NEW PERSONAL BEST {submission.improvement && submission.improvement > 0 ? `+${submission.improvement} points` : ""}

PREVIOUS: {(submission.previousBest ?? 0).toLocaleString()}NEW: {(submission.score ?? 0).toLocaleString()}
:

BEST: {(submission.previousBest ?? submission.score ?? 0).toLocaleString()} - {Math.abs(submission.improvement || 0)} points to improve

}

+{submission.xp} XP{submission.streakBonusXp ? ` (${submission.streakBonusLabel || `STREAK +${submission.streakBonusXp} XP`})` : ""} {submission.ratingDelta ? `- ${submission.ratingDelta > 0 ? "+" : ""}${submission.ratingDelta} RATING` : ""}

{submission.rank ? `GLOBAL RANK #${submission.rank}` : "RANKING UPDATED"} {submission.nextGrade && submission.ratingToNext ? `- ${submission.ratingToNext} TO ${submission.nextGrade}` : ""}

}
; + return
{challengeComplete ? "CHALLENGE COMPLETE" : submission.completed === false ? "VERIFIED PARTIAL RESULT" : "VERIFIED RESULT"}
PROOF CARD · CRYPTOGRAPHICALLY VERIFIED, NOT CLAIMED{submission.proofText ? {submission.proofText} : Verified on Nimiq via OPERATOR}{submission.proofCode && PROOF {submission.proofCode}}
{challengeComplete ?
@{submission.playerUsername}{submission.score?.toLocaleString()}
@{submission.opponentUsername || "Opponent"}{(submission.opponentScore ?? 0).toLocaleString()}
{outcome}
: <>

{submission.score?.toLocaleString()}

{submission.completed === false &&

RUN ENDED — VERIFIED PROGRESS SAVED

}{submission.personalBest ? <>

NEW PERSONAL BEST {submission.improvement && submission.improvement > 0 ? `+${submission.improvement} points` : ""}

PREVIOUS: {(submission.previousBest ?? 0).toLocaleString()}NEW: {(submission.score ?? 0).toLocaleString()}
:

BEST: {(submission.previousBest ?? submission.score ?? 0).toLocaleString()} - {Math.abs(submission.improvement || 0)} points to improve

}

+{submission.xp} XP{submission.streakBonusXp ? ` (${submission.streakBonusLabel || `STREAK +${submission.streakBonusXp} XP`})` : ""} {submission.ratingDelta ? `- ${submission.ratingDelta > 0 ? "+" : ""}${submission.ratingDelta} RATING` : ""}

{submission.rank ? `GLOBAL RANK #${submission.rank}` : "RANKING UPDATED"} {submission.nextGrade && submission.ratingToNext ? `- ${submission.ratingToNext} TO ${submission.nextGrade}` : ""}

}

Verified runs are single-use. Return to operations for your next challenge.

; } return
PREVIEW

{preview.score.toLocaleString()}

Waiting for validation...

; } diff --git a/src/games.tsx b/src/games.tsx index dbf54f3..bc8929b 100644 --- a/src/games.tsx +++ b/src/games.tsx @@ -79,12 +79,14 @@ export function Game({ id, run, rankedSubmission, onEvent, onFinish, onPlayAgain } function ResultBox({ result, onRestart }: { result: Result; onRestart: () => void }) { - return
PRACTICE COMPLETE

{result.score.toLocaleString()}

+{result.xp} XP (local)

; + void onRestart; + return
PRACTICE COMPLETE

{result.score.toLocaleString()}

+{result.xp} XP (local)

Return to operations to play a new challenge.

; } -function RankedResult({ submission, preview, onRestart, onViewRankings }: { submission: RankedSubmission; preview: Result; onRestart: () => void; onViewRankings?: () => void }) { +function RankedResult({ submission, preview, onRestart, onViewRankings }: { submission: RankedSubmission; preview: Result; onRestart?: () => void; onViewRankings?: () => void }) { + void onRestart; if (submission.state === "submitting") return
SUBMITTING REPLAY

...

Validating run...

; - if (submission.state === "rejected") return
RUN NOT ACCEPTED

REJECTED

{submission.error || "The server could not verify this replay."}

; + if (submission.state === "rejected") return
RUN NOT ACCEPTED

REJECTED

{submission.error || "The server could not verify this replay."}

Ranked runs are single-use. Start a new verified run from operations.

; if (submission.state === "verified") { const challengeComplete = submission.opponentScore !== null && submission.opponentScore !== undefined; const isChallenge = Object.prototype.hasOwnProperty.call(submission, "opponentScore"); @@ -109,7 +111,6 @@ function RankedResult({ submission, preview, onRestart, onViewRankings }: { subm {submission.nextTarget &&
YOUR NEXT TARGET#{submission.nextTarget.rank} @{submission.nextTarget.username}+{submission.nextTarget.pointsAway} points
} }
- {onViewRankings && }
; @@ -118,9 +119,10 @@ function RankedResult({ submission, preview, onRestart, onViewRankings }: { subm } function rankedOrLocal(ranked: boolean, rankedSubmission: RankedSubmission, preview: Result, localRestart: () => void, onPlayAgain?: () => void, onViewRankings?: () => void) { - const restart = onPlayAgain || localRestart; + void localRestart; + void onPlayAgain; return ranked - ? + ? : ; } From ce41aa14822f73468303619bbd12aaf9d004504b Mon Sep 17 00:00:00 2001 From: hayolaaaaaaaaaaa <201365190+hayolaaaaaaaaaaa@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:13:13 +0100 Subject: [PATCH 3/3] Rename Nimiq Arcade to OPERATOR across lobby, hero, and wallet prompt --- src/App.tsx | 4 ++-- src/nimiq.ts | 2 +- src/styles.css | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 47b723d..d2c4ee6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -372,11 +372,11 @@ function App() { const liveChallenges = dashboard?.challenges || []; return
-
{wallet ? : }
+
{wallet ? : }
{wallet ? <>LIVE / VERIFIED SESSION{wallet} : walletError ? <>WALLET CONNECTION FAILED{walletError} : <>WALLET{isNimiqPay()?"Nimiq Pay detected - ready to verify":"Connect with Nimiq Hub to play ranked"}}
{view === "operations" && <>
OPERATOR STATUS · THE FIRST SKILL PLATFORM WHERE RANK IS PROVEN, NOT CLAIMED

{operator?.username || profile?.username || "GUEST OPERATOR"}

{operator?.badge ? VERIFIED {operator.badge} : wallet ? PROVE IT · FINISH A RANKED RUN : GUEST · CONNECT TO VERIFY}{operator?.title && {operator.title}}

{dashboardLoading ? "SYNCING VERIFIED DATA" : operator?.displayGrade || profile?.displayGrade || "UNRANKED"}

GLOBAL RANK{operator?.globalRank ? `#${operator.globalRank}` : "—"}
RATING{operator?.rating?.toLocaleString() || "—"}
LEVEL / XP{operator ? `${operator.level} / ${operator.xp.toLocaleString()}` : "—"}
STREAK{operator?.streak ? `🔥 ${operator.streak} DAYS` : "—"}
-
FEATURED GAME · VERIFIED SKILL RUN
PLAY. COMPETE. EARN.

{dashboardDaily?.completed ? "MISSION\nCOMPLETE" : "LEVEL UP\nYOUR"}
{dailyGame?.name.toUpperCase() ?? "NIM PIN"}

{dashboardDaily?.completed ? "Your score is on the board. Improve it only if the daily rules permit another attempt." : wallet ? "Your daily skill test is ready. Make the run count." : "Enter the Nimiq Arcade. Play free, then connect to take your place on the board."}

1 PLAY AS GUEST2 BEAT THE BOARD3 CONNECT WALLET TO CLAIM RANK
DAILY DROP
ENDS IN{formatCountdown(dailyRemaining)}
YOUR BEST{dashboardDaily?.score?.toLocaleString() || "—"}
DAILY RANK{dashboardDaily?.rank ? `#${dashboardDaily.rank}` : "—"}
+
FEATURED GAME · VERIFIED SKILL RUN
PLAY. COMPETE. EARN.

{dashboardDaily?.completed ? "MISSION\nCOMPLETE" : "LEVEL UP\nYOUR"}
{dailyGame?.name.toUpperCase() ?? "NIM PIN"}

{dashboardDaily?.completed ? "Your score is on the board. Improve it only if the daily rules permit another attempt." : wallet ? "Your daily skill test is ready. Make the run count." : "Enter OPERATOR. Play free, then connect to take your place on the board."}

1 PLAY AS GUEST2 BEAT THE BOARD3 CONNECT WALLET TO CLAIM RANK
DAILY DROP
ENDS IN{formatCountdown(dailyRemaining)}
YOUR BEST{dashboardDaily?.score?.toLocaleString() || "—"}
DAILY RANK{dashboardDaily?.rank ? `#${dashboardDaily.rank}` : "—"}
SEASON01
OPERATORS-
RANKED RUNS-
STATUSLIVE / ONLINE
01 TODAY'S OPERATION
ONE VERIFIED ATTEMPT

{dailyGame?.name.toUpperCase() ?? "NIM PIN"}

ENDS IN / {formatCountdown(dailyRemaining)}
YOUR SCORE: {competitiveSummary?.daily?.score?.toLocaleString() || "-"}DAILY RANK: {competitiveSummary?.daily?.rank ? `#${competitiveSummary.daily.rank}` : "-"}TOP SCORE: {competitiveSummary?.daily?.topScore?.toLocaleString() || "-"}STREAK BONUS: {operator?.streakBonus?.bonusXp ? `+${operator.streakBonus.bonusXp} XP` : "WIN 3 DAILY STREAKS"}

{competitiveSummary?.daily?.pointsToNext ? `Beat the next player by ${competitiveSummary.daily.pointsToNext} points.` : "Complete today's operation to enter the daily board."}

02

POPULAR GAMES

{wallet ? "WALLET VERIFIED" : "SIGN IN TO COMPETE"}
{wallet ? "RESULTS COUNT" : "RESULTS STAY LOCKED"}

{games.filter(g=>rankedGames.includes(g.id)).map((g,i)=>{const Icon=g.icon;return })}
diff --git a/src/nimiq.ts b/src/nimiq.ts index 9e75ea5..5d3da4c 100644 --- a/src/nimiq.ts +++ b/src/nimiq.ts @@ -27,7 +27,7 @@ export async function connectNimiq(): Promise { signed.signer = accounts[0]; } else { const hub = new HubApi(HUB_URL); - signed = await hub.signMessage({ appName: "Nimiq Skill Arcade", message }); + signed = await hub.signMessage({ appName: "OPERATOR", message }); } const toHex = (value: string | Uint8Array) => { if (typeof value !== "string") return Array.from(value).map(byte => byte.toString(16).padStart(2, "0")).join(""); diff --git a/src/styles.css b/src/styles.css index 932ad33..8d9559b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -30,7 +30,7 @@ @media(max-width:560px){.game-shell .nav{height:auto;min-height:64px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;padding:10px 0}.game-shell .nav .wordmark{display:none}.game-shell .game-nav-title{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;font-size:8px}.back-editorial{min-height:44px;padding:0;text-align:left}.game-stage{min-height:calc(100vh - 64px);padding:26px 0 88px;place-items:start center}.challenge{width:100%}.hud{gap:10px;margin-bottom:22px}.hud b{font-size:22px}.timer{font-size:11px;white-space:nowrap}.pin-display{margin:22px 0;font-size:29px;letter-spacing:.16em}.keypad{max-width:100%;grid-template-columns:repeat(3,minmax(0,1fr))}.keypad button,.key-row button,.hexpad button,.memory-options button{min-height:48px;padding:12px;font-size:16px}.key-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));width:100%}.key-row button{width:auto}.memory-options{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.memory-code{margin:26px 0;gap:6px}.memory-code b,.node-code span{font-size:19px;padding:11px}.locks{gap:8px}.lock-ring{width:min(27vw,108px)}.lock-ring:before{inset:13px}.lock-ring i{top:5px}.sync-track{margin:32px 0}.result{padding:32px 18px}.result h2{font-size:clamp(42px,15vw,58px);overflow-wrap:anywhere}.briefing-modal{max-height:calc(100vh - 28px);overflow:auto}.briefing-start,.gold-btn,.challenge-player,.copy-btn{min-height:44px}.maze-controls button{min-width:48px;min-height:48px}} @media(max-width:360px){.game-shell .game-nav-title{max-width:122px;font-size:7px}.hud b{font-size:19px}.timer{font-size:10px}.pin-display{font-size:25px;letter-spacing:.12em}.lock-ring{width:82px}.operator-status>div{padding-left:12px;padding-right:12px}.status-stat small{font-size:8px}.status-stat b{font-size:14px}} -/* Nimiq Arcade lobby skin. Game flows and verified-data components are unchanged. */ +/* OPERATOR lobby skin. Game flows and verified-data components are unchanged. */ :root{--gold:#ffe34d;--cyan:#62d9ff;--violet:#8d5cff;--line:rgba(173,151,255,.2)} body{background:radial-gradient(circle at 78% 8%,rgba(104,65,255,.2),transparent 26%),radial-gradient(circle at 18% 24%,rgba(0,201,255,.13),transparent 28%),#080719;color:#f7f5ff} body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.32;background-image:linear-gradient(rgba(143,106,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(143,106,255,.06) 1px,transparent 1px);background-size:38px 38px;mask-image:linear-gradient(to bottom,#000,transparent 72%)}