Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Players can:
- Compete on daily challenges and leaderboards
- Challenge friends with the same seeded puzzle

Five verified competitive games power the daily rotation: **NIM PIN**, **Key Sequence**, **Address Memory**, **NIM Lock**, and **NIM Cipher** — deterministic seeded puzzles with server replay validators.

---

### Why Nimiq is essential
Expand All @@ -45,3 +47,16 @@ Gameplay stays off-chain for speed. Only verified results and qualified rewards
4. Player submits input events (not a score)
5. Server replays the events against the seed
6. Only the server-calculated score is accepted
```

The client never submits a score. Each run is bound to the authenticated operator, consumed once, and recomputed from recorded events — altered or reused submissions are rejected.

---

### Cycle 2: verified identity & rewards

- Verified Operator badge/title unlock only after wallet-signed ranked runs.
- Every verified run returns a shareable **Proof Card** (`proofText` + `proofCode`) with copy support on the result screen.
- Daily streaks award bonus XP (3/7/14/30-day tiers), visible before and after Daily Operation runs.
- **Operator of the Week** spotlight ranks the last 7 days of verified runs.
- Friend challenge honor stakes (`stakeLabel`) stay off-chain and settle socially.
83 changes: 68 additions & 15 deletions server/index.ts

Large diffs are not rendered by default.

38 changes: 21 additions & 17 deletions src/App.tsx

Large diffs are not rendered by default.

20 changes: 14 additions & 6 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand 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 = {
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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 };
}
Expand All @@ -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;
Expand All @@ -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 }>;
Expand Down
14 changes: 8 additions & 6 deletions src/games.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,14 @@ export function Game({ id, run, rankedSubmission, onEvent, onFinish, onPlayAgain
}

function ResultBox({ result, onRestart }: { result: Result; onRestart: () => void }) {
return <div className="result"><div className="result-icon"><Trophy/></div><small>PRACTICE COMPLETE</small><h2>{result.score.toLocaleString()}</h2><p>+{result.xp} XP (local)</p><button className="primary" onClick={onRestart}><RotateCcw size={16}/> Play again</button></div>;
void onRestart;
return <div className="result"><div className="result-icon"><Trophy/></div><small>PRACTICE COMPLETE</small><h2>{result.score.toLocaleString()}</h2><p>+{result.xp} XP (local)</p><p className="result-hint">Return to operations to play a new challenge.</p></div>;
}

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 <div className="result"><div className="result-icon"><Clock3/></div><small>SUBMITTING REPLAY</small><h2>...</h2><p>Validating run...</p></div>;
if (submission.state === "rejected") return <div className="result"><div className="result-icon"><Shield/></div><small>RUN NOT ACCEPTED</small><h2>REJECTED</h2><p>{submission.error || "The server could not verify this replay."}</p><button className="primary" onClick={onRestart}><RotateCcw size={16}/> Try again</button></div>;
if (submission.state === "rejected") return <div className="result"><div className="result-icon"><Shield/></div><small>RUN NOT ACCEPTED</small><h2>REJECTED</h2><p>{submission.error || "The server could not verify this replay."}</p><p className="result-hint">Ranked runs are single-use. Start a new verified run from operations.</p></div>;
if (submission.state === "verified") {
const challengeComplete = submission.opponentScore !== null && submission.opponentScore !== undefined;
const isChallenge = Object.prototype.hasOwnProperty.call(submission, "opponentScore");
Expand All @@ -109,7 +111,6 @@ function RankedResult({ submission, preview, onRestart, onViewRankings }: { subm
{submission.nextTarget && <div className="next-target-card"><small>YOUR NEXT TARGET</small><b>#{submission.nextTarget.rank} @{submission.nextTarget.username}</b><span>+{submission.nextTarget.pointsAway} points</span></div>}
</>}
<div className="result-actions">
<button className="primary" onClick={isChallenge ? () => { window.location.href = "/"; } : onRestart}><RotateCcw size={16}/> {isChallenge ? "Back to operations" : shortBy ? "Try again" : "Play again"}</button>
{onViewRankings && <button className="ghost-btn" onClick={onViewRankings}>View rankings</button>}
</div>
</div>;
Expand All @@ -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
? <RankedResult submission={rankedSubmission} preview={preview} onRestart={restart} onViewRankings={onViewRankings}/>
? <RankedResult submission={rankedSubmission} preview={preview} onViewRankings={onViewRankings}/>
: <ResultBox result={preview} onRestart={localRestart}/>;
}

Expand Down
2 changes: 1 addition & 1 deletion src/nimiq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function connectNimiq(): Promise<string | null> {
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("");
Expand Down
Loading
Loading