Compare commits
9 commits
bf5c53249a
...
4143749c39
| Author | SHA1 | Date | |
|---|---|---|---|
| 4143749c39 | |||
| 0e65260ea0 | |||
| 62df673249 | |||
| 20b24f254f | |||
| c76710502e | |||
| cce22c1fcc | |||
| 4adc0ca452 | |||
| f1d7a6774c | |||
| caca11a60b |
45 changed files with 100084 additions and 4 deletions
231
src/app/(frontend)/call-for-fire/actions.ts
Normal file
231
src/app/(frontend)/call-for-fire/actions.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
import {
|
||||||
|
BEST_MISSIONS_FIELDS,
|
||||||
|
CALL_FOR_FIRE_DIFFICULTIES,
|
||||||
|
MAX_MISSIONS_PER_RUN,
|
||||||
|
METERS_PER_CELL,
|
||||||
|
calculateScore,
|
||||||
|
calculateXp,
|
||||||
|
isCallForFireDifficulty,
|
||||||
|
type CallForFireDifficulty,
|
||||||
|
} from "@/lib/call-for-fire";
|
||||||
|
|
||||||
|
export type { CallForFireDifficulty };
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallForFireStats {
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
bestScore: number;
|
||||||
|
callForFireXp: number;
|
||||||
|
xpEarned: number;
|
||||||
|
bestPatrolMissions: number;
|
||||||
|
bestCheckpointMissions: number;
|
||||||
|
bestCompoundMissions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BestMissionsField = (typeof BEST_MISSIONS_FIELDS)[CallForFireDifficulty];
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: hdrs,
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitCallForFireResult(input: {
|
||||||
|
difficulty: CallForFireDifficulty;
|
||||||
|
roundsUsed: number;
|
||||||
|
seconds: number;
|
||||||
|
finalDistanceM: number;
|
||||||
|
won: boolean;
|
||||||
|
}): Promise<ActionResult<CallForFireStats>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
if (typeof input.difficulty !== "string" || !isCallForFireDifficulty(input.difficulty)) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficulty = input.difficulty;
|
||||||
|
const difficultyConfig = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.roundsUsed) ||
|
||||||
|
input.roundsUsed <= 0 ||
|
||||||
|
input.roundsUsed > Math.min(difficultyConfig.maxRounds, MAX_MISSIONS_PER_RUN)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.seconds) ||
|
||||||
|
input.seconds <= 0 ||
|
||||||
|
input.seconds > difficultyConfig.maxSeconds
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof input.finalDistanceM !== "number" ||
|
||||||
|
!Number.isFinite(input.finalDistanceM) ||
|
||||||
|
input.finalDistanceM < 0 ||
|
||||||
|
input.finalDistanceM > difficultyConfig.size * METERS_PER_CELL
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (typeof input.won !== "boolean") {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (input.won && input.finalDistanceM > difficultyConfig.lethalRadiusM) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const profile = profileRes.docs[0];
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, error: "No profile found for this user." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const progression = (profile.progression ?? {}) as {
|
||||||
|
xp?: unknown;
|
||||||
|
minigames?: {
|
||||||
|
callForFire?: {
|
||||||
|
gamesPlayed?: number;
|
||||||
|
wins?: number;
|
||||||
|
losses?: number;
|
||||||
|
bestScore?: number;
|
||||||
|
xpEarned?: number;
|
||||||
|
bestPatrolMissions?: number;
|
||||||
|
bestCheckpointMissions?: number;
|
||||||
|
bestCompoundMissions?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const current = progression.minigames?.callForFire ?? {};
|
||||||
|
const score = calculateScore(
|
||||||
|
difficulty,
|
||||||
|
input.roundsUsed,
|
||||||
|
input.seconds,
|
||||||
|
input.finalDistanceM,
|
||||||
|
input.won,
|
||||||
|
);
|
||||||
|
const runXp = calculateXp(score, input.won);
|
||||||
|
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
||||||
|
const wins = (current.wins ?? 0) + (input.won ? 1 : 0);
|
||||||
|
const losses = (current.losses ?? 0) + (input.won ? 0 : 1);
|
||||||
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
||||||
|
const totalXp = (current.xpEarned ?? 0) + runXp;
|
||||||
|
const bestMissionsField = BEST_MISSIONS_FIELDS[difficulty];
|
||||||
|
const previousBestMissions = current[bestMissionsField as keyof typeof current] ?? 0;
|
||||||
|
const isBestRun =
|
||||||
|
input.won && (previousBestMissions === 0 || input.roundsUsed < previousBestMissions);
|
||||||
|
const bestMissions = isBestRun ? input.roundsUsed : previousBestMissions;
|
||||||
|
|
||||||
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progressionRest,
|
||||||
|
minigames: {
|
||||||
|
...(progressionRest.minigames ?? {}),
|
||||||
|
callForFire: {
|
||||||
|
gamesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
bestScore,
|
||||||
|
xpEarned: totalXp,
|
||||||
|
bestPatrolMissions: current.bestPatrolMissions ?? 0,
|
||||||
|
bestCheckpointMissions: current.bestCheckpointMissions ?? 0,
|
||||||
|
bestCompoundMissions: current.bestCompoundMissions ?? 0,
|
||||||
|
[bestMissionsField]: bestMissions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
broadcastToUser("profile-update", Number(user.id), {
|
||||||
|
reason: "xp",
|
||||||
|
xpGained: runXp,
|
||||||
|
profileId: profile.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.won) {
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.MinigameCallForFire,
|
||||||
|
message: `Brought fire onto target in ${difficultyConfig.label} with ${input.roundsUsed} round(s) (+${runXp} XP)`,
|
||||||
|
actor: user.id as number,
|
||||||
|
targetCollection: "users",
|
||||||
|
targetId: user.id as number,
|
||||||
|
data: {
|
||||||
|
difficulty,
|
||||||
|
roundsUsed: input.roundsUsed,
|
||||||
|
seconds: input.seconds,
|
||||||
|
finalDistanceM: input.finalDistanceM,
|
||||||
|
score,
|
||||||
|
xpEarned: runXp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
gamesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
bestScore,
|
||||||
|
callForFireXp: totalXp,
|
||||||
|
xpEarned: runXp,
|
||||||
|
bestPatrolMissions:
|
||||||
|
bestMissionsField === "bestPatrolMissions"
|
||||||
|
? bestMissions
|
||||||
|
: (current.bestPatrolMissions ?? 0),
|
||||||
|
bestCheckpointMissions:
|
||||||
|
bestMissionsField === "bestCheckpointMissions"
|
||||||
|
? bestMissions
|
||||||
|
: (current.bestCheckpointMissions ?? 0),
|
||||||
|
bestCompoundMissions:
|
||||||
|
bestMissionsField === "bestCompoundMissions"
|
||||||
|
? bestMissions
|
||||||
|
: (current.bestCompoundMissions ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/app/(frontend)/call-for-fire/page.tsx
Normal file
66
src/app/(frontend)/call-for-fire/page.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Target } from "lucide-react";
|
||||||
|
import { CallForFireArena } from "@/components/frontend/call-for-fire/CallForFireArena";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Call for Fire (Polaris Task Force)",
|
||||||
|
description:
|
||||||
|
"Adjust artillery onto hidden targets across three sectors to earn XP for the task force.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CallForFirePage() {
|
||||||
|
const headers = await nextHeaders();
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect(`/login?returnTo=${encodeURIComponent("/call-for-fire")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
select: {
|
||||||
|
progression: {
|
||||||
|
minigames: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const callForFire = profileRes.docs[0]?.progression?.minigames?.callForFire ?? {};
|
||||||
|
const stats = {
|
||||||
|
gamesPlayed: callForFire.gamesPlayed ?? 0,
|
||||||
|
wins: callForFire.wins ?? 0,
|
||||||
|
losses: callForFire.losses ?? 0,
|
||||||
|
bestScore: callForFire.bestScore ?? 0,
|
||||||
|
callForFireXp: callForFire.xpEarned ?? 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
bestPatrolMissions: callForFire.bestPatrolMissions ?? 0,
|
||||||
|
bestCheckpointMissions: callForFire.bestCheckpointMissions ?? 0,
|
||||||
|
bestCompoundMissions: callForFire.bestCompoundMissions ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Call for Fire</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Adjust artillery onto the hidden target. Fewer fire missions score higher.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CallForFireArena initialStats={stats} currentUserId={user.id as number} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/app/(frontend)/land-nav/actions.ts
Normal file
218
src/app/(frontend)/land-nav/actions.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
import {
|
||||||
|
BEST_TIME_FIELDS,
|
||||||
|
LAND_NAV_DIFFICULTIES,
|
||||||
|
MAX_LEGS,
|
||||||
|
MAX_SECONDS,
|
||||||
|
calculateScore,
|
||||||
|
calculateXp,
|
||||||
|
isLandNavDifficulty,
|
||||||
|
type LandNavDifficulty,
|
||||||
|
} from "@/lib/land-nav";
|
||||||
|
|
||||||
|
export type { LandNavDifficulty };
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandNavStats {
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
bestScore: number;
|
||||||
|
landNavXp: number;
|
||||||
|
xpEarned: number;
|
||||||
|
bestWaypointSeconds: number;
|
||||||
|
bestRouteSeconds: number;
|
||||||
|
bestExpeditionSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BestTimeField = (typeof BEST_TIME_FIELDS)[LandNavDifficulty];
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: hdrs,
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitLandNavResult(input: {
|
||||||
|
difficulty: LandNavDifficulty;
|
||||||
|
legsCorrect: number;
|
||||||
|
seconds: number;
|
||||||
|
won: boolean;
|
||||||
|
}): Promise<ActionResult<LandNavStats>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
if (typeof input.difficulty !== "string" || !isLandNavDifficulty(input.difficulty)) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficulty = input.difficulty;
|
||||||
|
const difficultyConfig = LAND_NAV_DIFFICULTIES[difficulty];
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.legsCorrect) ||
|
||||||
|
input.legsCorrect < 0 ||
|
||||||
|
input.legsCorrect > Math.min(difficultyConfig.legs, MAX_LEGS)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.seconds) ||
|
||||||
|
input.seconds <= 0 ||
|
||||||
|
input.seconds > difficultyConfig.maxSeconds
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (typeof input.won !== "boolean") {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (input.won && input.legsCorrect < difficultyConfig.passLegs) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!input.won &&
|
||||||
|
input.legsCorrect >= difficultyConfig.passLegs &&
|
||||||
|
input.seconds >= difficultyConfig.maxSeconds
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const profile = profileRes.docs[0];
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, error: "No profile found for this user." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const progression = (profile.progression ?? {}) as {
|
||||||
|
xp?: unknown;
|
||||||
|
minigames?: {
|
||||||
|
landNav?: {
|
||||||
|
gamesPlayed?: number;
|
||||||
|
wins?: number;
|
||||||
|
losses?: number;
|
||||||
|
bestScore?: number;
|
||||||
|
xpEarned?: number;
|
||||||
|
bestWaypointSeconds?: number;
|
||||||
|
bestRouteSeconds?: number;
|
||||||
|
bestExpeditionSeconds?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const current = progression.minigames?.landNav ?? {};
|
||||||
|
const score = calculateScore(difficulty, input.legsCorrect, input.seconds, input.won);
|
||||||
|
const runXp = calculateXp(score, input.won);
|
||||||
|
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
||||||
|
const wins = (current.wins ?? 0) + (input.won ? 1 : 0);
|
||||||
|
const losses = (current.losses ?? 0) + (input.won ? 0 : 1);
|
||||||
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
||||||
|
const totalXp = (current.xpEarned ?? 0) + runXp;
|
||||||
|
const bestTimeField = BEST_TIME_FIELDS[difficulty];
|
||||||
|
const previousBestTime = current[bestTimeField as keyof typeof current] ?? 0;
|
||||||
|
const isBestTime = input.won && (previousBestTime === 0 || input.seconds < previousBestTime);
|
||||||
|
const bestTime = isBestTime ? input.seconds : previousBestTime;
|
||||||
|
|
||||||
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progressionRest,
|
||||||
|
minigames: {
|
||||||
|
...(progressionRest.minigames ?? {}),
|
||||||
|
landNav: {
|
||||||
|
gamesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
bestScore,
|
||||||
|
xpEarned: totalXp,
|
||||||
|
bestWaypointSeconds: current.bestWaypointSeconds ?? 0,
|
||||||
|
bestRouteSeconds: current.bestRouteSeconds ?? 0,
|
||||||
|
bestExpeditionSeconds: current.bestExpeditionSeconds ?? 0,
|
||||||
|
[bestTimeField]: bestTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
broadcastToUser("profile-update", Number(user.id), {
|
||||||
|
reason: "xp",
|
||||||
|
xpGained: runXp,
|
||||||
|
profileId: profile.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.won) {
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.MinigameLandNav,
|
||||||
|
message: `Completed a Land Navigation course (${difficultyConfig.label}) with score ${score} (+${runXp} XP)`,
|
||||||
|
actor: user.id as number,
|
||||||
|
targetCollection: "users",
|
||||||
|
targetId: user.id as number,
|
||||||
|
data: {
|
||||||
|
difficulty,
|
||||||
|
legsCorrect: input.legsCorrect,
|
||||||
|
legsTotal: difficultyConfig.legs,
|
||||||
|
seconds: input.seconds,
|
||||||
|
score,
|
||||||
|
xpEarned: runXp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
gamesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
bestScore,
|
||||||
|
landNavXp: totalXp,
|
||||||
|
xpEarned: runXp,
|
||||||
|
bestWaypointSeconds:
|
||||||
|
bestTimeField === "bestWaypointSeconds" ? bestTime : (current.bestWaypointSeconds ?? 0),
|
||||||
|
bestRouteSeconds:
|
||||||
|
bestTimeField === "bestRouteSeconds" ? bestTime : (current.bestRouteSeconds ?? 0),
|
||||||
|
bestExpeditionSeconds:
|
||||||
|
bestTimeField === "bestExpeditionSeconds"
|
||||||
|
? bestTime
|
||||||
|
: (current.bestExpeditionSeconds ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/(frontend)/land-nav/page.tsx
Normal file
67
src/app/(frontend)/land-nav/page.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Compass } from "lucide-react";
|
||||||
|
import { LandNavArena } from "@/components/frontend/land-nav/LandNavArena";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Land Navigation (Polaris Task Force)",
|
||||||
|
description:
|
||||||
|
"Navigate grid squares, azimuths and terrain features across three course difficulties to earn XP for the task force.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LandNavPage() {
|
||||||
|
const headers = await nextHeaders();
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect(`/login?returnTo=${encodeURIComponent("/land-nav")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
select: {
|
||||||
|
progression: {
|
||||||
|
minigames: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const landNav = profileRes.docs[0]?.progression?.minigames?.landNav ?? {};
|
||||||
|
const stats = {
|
||||||
|
gamesPlayed: landNav.gamesPlayed ?? 0,
|
||||||
|
wins: landNav.wins ?? 0,
|
||||||
|
losses: landNav.losses ?? 0,
|
||||||
|
bestScore: landNav.bestScore ?? 0,
|
||||||
|
landNavXp: landNav.xpEarned ?? 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
bestWaypointSeconds: landNav.bestWaypointSeconds ?? 0,
|
||||||
|
bestRouteSeconds: landNav.bestRouteSeconds ?? 0,
|
||||||
|
bestExpeditionSeconds: landNav.bestExpeditionSeconds ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Compass className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Land Navigation</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click grid squares, shoot azimuths and identify terrain features. Faster courses score
|
||||||
|
higher.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LandNavArena initialStats={stats} currentUserId={user.id as number} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/(frontend)/story/actions.ts
Normal file
50
src/app/(frontend)/story/actions.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import type { StoryBeatState } from "@/payload-types";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { markBeatRead, setBeatReaction, type StoryReaction } from "@/lib/story";
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||||
|
if (!isPayloadUser(user)) throw new Error("Unauthorized");
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markStoryBeatRead(
|
||||||
|
beatId: number,
|
||||||
|
): Promise<{ success: boolean; error?: string; data?: StoryBeatState }> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
const state = await markBeatRead(payload, { user, beatId });
|
||||||
|
return { success: true, data: state };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStoryBeatReaction(
|
||||||
|
beatId: number,
|
||||||
|
reaction: StoryReaction | null,
|
||||||
|
): Promise<{ success: boolean; error?: string; data?: { reaction: StoryReaction | null } }> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
const result = await setBeatReaction(payload, { user, beatId, reaction });
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/app/(frontend)/story/page.tsx
Normal file
173
src/app/(frontend)/story/page.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||||
|
import type { StoryBeat, StoryBeatState } from "@/payload-types";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { BEAT_BODY_CLASSES } from "@/lib/story/richtextClasses";
|
||||||
|
import { BeatReactions } from "@/components/frontend/story/BeatReactions";
|
||||||
|
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
|
||||||
|
import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
|
||||||
|
import type { StoryReaction } from "@/lib/story/reactions";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Chronicle",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReactionCounts = Record<StoryReaction, number>;
|
||||||
|
|
||||||
|
const DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
function emptyCounts(): ReactionCounts {
|
||||||
|
return { like: 0, heart: 0, salute: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBeats(beats: StoryBeat[]): StoryBeat[] {
|
||||||
|
return [...beats].sort((a, b) => {
|
||||||
|
const timeA = a.occurredAt ? Date.parse(a.occurredAt) : Number.POSITIVE_INFINITY;
|
||||||
|
const timeB = b.occurredAt ? Date.parse(b.occurredAt) : Number.POSITIVE_INFINITY;
|
||||||
|
if (timeA !== timeB) return timeA - timeB;
|
||||||
|
const orderA = a.order ?? Number.POSITIVE_INFINITY;
|
||||||
|
const orderB = b.order ?? Number.POSITIVE_INFINITY;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function StoryPage() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const headers = await nextHeaders();
|
||||||
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
|
if (!isPayloadUser(user)) return null;
|
||||||
|
|
||||||
|
const beatsResult = await payload.find({
|
||||||
|
collection: "story-beats",
|
||||||
|
where: { status: { equals: "published" } },
|
||||||
|
limit: 200,
|
||||||
|
depth: 1,
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
const sorted = sortBeats(beatsResult.docs as StoryBeat[]);
|
||||||
|
|
||||||
|
const statesResult = await payload.find({
|
||||||
|
collection: "story-beat-states",
|
||||||
|
where: { user: { equals: user.id } },
|
||||||
|
limit: 500,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
const stateByBeat = new Map<number, StoryBeatState>();
|
||||||
|
for (const state of statesResult.docs as StoryBeatState[]) {
|
||||||
|
const beatId = typeof state.beat === "number" ? state.beat : state.beat.id;
|
||||||
|
stateByBeat.set(beatId, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatIds = sorted.map((beat) => beat.id);
|
||||||
|
const counts = new Map<number, ReactionCounts>();
|
||||||
|
for (const id of beatIds) counts.set(id, emptyCounts());
|
||||||
|
if (beatIds.length > 0) {
|
||||||
|
const reactionDocs = await payload.find({
|
||||||
|
collection: "story-beat-states",
|
||||||
|
where: {
|
||||||
|
and: [{ beat: { in: beatIds } }, { reaction: { not_equals: null } }],
|
||||||
|
},
|
||||||
|
limit: 1000,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
for (const doc of reactionDocs.docs as StoryBeatState[]) {
|
||||||
|
const beatId = typeof doc.beat === "number" ? doc.beat : doc.beat.id;
|
||||||
|
const beatCounts = counts.get(beatId);
|
||||||
|
if (doc.reaction && beatCounts) beatCounts[doc.reaction] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readerBeats: StoryViewerBeat[] = sorted.map((beat, index) => ({
|
||||||
|
id: beat.id,
|
||||||
|
title: beat.title,
|
||||||
|
chapter: index + 1,
|
||||||
|
dateLabel: beat.occurredAt ? DATE_FORMAT.format(new Date(beat.occurredAt)) : null,
|
||||||
|
body: beat.body,
|
||||||
|
initialRead: Boolean(stateByBeat.get(beat.id)?.readAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
<header className="mb-12 text-center">
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">
|
||||||
|
The Unit Chronicle
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 font-serif text-4xl font-bold">Story Chronicle</h1>
|
||||||
|
<p className="mt-3 text-muted-foreground">
|
||||||
|
Every operation leaves a mark. These are the beats of our story, told in
|
||||||
|
order.
|
||||||
|
</p>
|
||||||
|
{sorted.length > 0 && (
|
||||||
|
<div className="mt-5 flex justify-center">
|
||||||
|
<StoryViewer beats={readerBeats} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
No published chapters yet. Check back soon.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-16">
|
||||||
|
{sorted.map((beat, index) => {
|
||||||
|
const state = stateByBeat.get(beat.id);
|
||||||
|
const mission =
|
||||||
|
beat.mission && typeof beat.mission !== "number" ? beat.mission : null;
|
||||||
|
const missionLabel = mission
|
||||||
|
? mission.codeName
|
||||||
|
? `${mission.codeName} · ${mission.name}`
|
||||||
|
: mission.name
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={beat.id}
|
||||||
|
data-beat-id={beat.id}
|
||||||
|
className="relative border-l border-border pl-8"
|
||||||
|
>
|
||||||
|
<span className="absolute -left-[9px] top-1.5 size-4 rounded-full border-2 border-background bg-primary" />
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-xs uppercase tracking-widest text-muted-foreground">
|
||||||
|
Chapter {index + 1}
|
||||||
|
</p>
|
||||||
|
<BeatReadMarker beatId={beat.id} initialRead={Boolean(state?.readAt)} />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-2 font-serif text-2xl font-semibold">{beat.title}</h2>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
{beat.occurredAt && <span>{DATE_FORMAT.format(new Date(beat.occurredAt))}</span>}
|
||||||
|
{missionLabel && (
|
||||||
|
<span className="rounded border border-border px-2 py-0.5">
|
||||||
|
{missionLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<RichText data={beat.body} className={cn(BEAT_BODY_CLASSES)} />
|
||||||
|
</div>
|
||||||
|
<BeatReactions
|
||||||
|
beatId={beat.id}
|
||||||
|
initialReaction={state?.reaction ?? null}
|
||||||
|
initialCounts={counts.get(beat.id) ?? emptyCounts()}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,26 @@ import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/component
|
||||||
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
|
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
|
||||||
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
|
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
|
||||||
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
||||||
|
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||||
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||||
import { default as default_b16af798585cda7cfed92f7b1dd0c707 } from '@/components/admin/shims/ShimContentField'
|
import { default as default_b16af798585cda7cfed92f7b1dd0c707 } from '@/components/admin/shims/ShimContentField'
|
||||||
|
|
@ -39,6 +59,26 @@ export const importMap = {
|
||||||
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
|
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
|
||||||
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
|
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
|
||||||
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
||||||
|
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
|
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||||
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
|
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||||
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
|
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ const TARGET_COLLECTIONS = [
|
||||||
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
||||||
{ label: "Voice Scripts", value: "voice-scripts" },
|
{ label: "Voice Scripts", value: "voice-scripts" },
|
||||||
{ label: "Voice Submissions", value: "voice-submissions" },
|
{ label: "Voice Submissions", value: "voice-submissions" },
|
||||||
|
{ label: "Story Beats", value: "story-beats" },
|
||||||
|
{ label: "Story Beat States", value: "story-beat-states" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const GameEventLogs: CollectionConfig = {
|
export const GameEventLogs: CollectionConfig = {
|
||||||
|
|
|
||||||
73
src/collections/story/StoryBeatStates.ts
Normal file
73
src/collections/story/StoryBeatStates.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
|
|
||||||
|
function isStaff(user: Parameters<typeof hasRoles>[1]): boolean {
|
||||||
|
return hasRoles(["admin", "developer"], user);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAFF_ROLES = ["admin", "developer"];
|
||||||
|
|
||||||
|
export const StoryBeatStates: CollectionConfig = {
|
||||||
|
slug: "story-beat-states",
|
||||||
|
admin: {
|
||||||
|
group: "Story",
|
||||||
|
useAsTitle: "beat",
|
||||||
|
defaultColumns: ["user", "beat", "readAt", "reaction"],
|
||||||
|
description: "Per-user read markers and reactions for story beats.",
|
||||||
|
// The admin-panel `user` is a client-side user type, so check its roles
|
||||||
|
// structurally rather than through hasRoles.
|
||||||
|
hidden: ({ user }) =>
|
||||||
|
user?.collection !== "users" ||
|
||||||
|
!STAFF_ROLES.some((role) => (user.roles ?? []).includes(role)),
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
// Users can read/update only their own state; staff see everything.
|
||||||
|
read: ({ req }) => {
|
||||||
|
if (!req.user) return false;
|
||||||
|
if (isStaff(req.user)) return true;
|
||||||
|
return { user: { equals: req.user.id } };
|
||||||
|
},
|
||||||
|
create: ({ req }) => !!req.user,
|
||||||
|
update: ({ req }) => {
|
||||||
|
if (!req.user) return false;
|
||||||
|
if (isStaff(req.user)) return true;
|
||||||
|
return { user: { equals: req.user.id } };
|
||||||
|
},
|
||||||
|
delete: ({ req }) => isStaff(req.user),
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "users",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "beat",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "story-beats",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "readAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
description: "When the user first read the beat.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reaction",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "Like", value: "like" },
|
||||||
|
{ label: "Heart", value: "heart" },
|
||||||
|
{ label: "Salute", value: "salute" },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: "The user's current reaction to the beat, if any.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
82
src/collections/story/StoryBeats.ts
Normal file
82
src/collections/story/StoryBeats.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
import { FixedToolbarFeature, lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
|
|
||||||
|
function isStoryAuthor(user: Parameters<typeof hasRoles>[1]): boolean {
|
||||||
|
return hasRoles(["admin", "developer"], user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StoryBeats: CollectionConfig = {
|
||||||
|
slug: "story-beats",
|
||||||
|
admin: {
|
||||||
|
group: "Story",
|
||||||
|
useAsTitle: "title",
|
||||||
|
defaultColumns: ["title", "status", "occurredAt", "order", "mission"],
|
||||||
|
listSearchableFields: ["title", "body"],
|
||||||
|
description:
|
||||||
|
"Chronicle story beats. Drafts are visible to staff only; published beats are readable by any logged-in user on /story.",
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
// Guests see nothing; admin/developer see everything; any other logged-in
|
||||||
|
// user sees only published beats.
|
||||||
|
read: ({ req }) => {
|
||||||
|
if (!req.user) return false;
|
||||||
|
if (isStoryAuthor(req.user)) return true;
|
||||||
|
return { status: { equals: "published" } };
|
||||||
|
},
|
||||||
|
create: ({ req }) => isStoryAuthor(req.user),
|
||||||
|
update: ({ req }) => isStoryAuthor(req.user),
|
||||||
|
delete: ({ req }) => isStoryAuthor(req.user),
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "occurredAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
description: "In-story timestamp used to order the chronicle.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "order",
|
||||||
|
type: "number",
|
||||||
|
admin: {
|
||||||
|
description: "Tie-breaker for beats that share the same timestamp.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "draft",
|
||||||
|
index: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Draft", value: "draft" },
|
||||||
|
{ label: "Published", value: "published" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "body",
|
||||||
|
type: "richText",
|
||||||
|
required: true,
|
||||||
|
editor: lexicalEditor({
|
||||||
|
features: ({ defaultFeatures }) => [...defaultFeatures, FixedToolbarFeature()],
|
||||||
|
}),
|
||||||
|
admin: {
|
||||||
|
description: "Rich text body of the beat, rendered on /story with the Lexical renderer.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mission",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "missions",
|
||||||
|
admin: {
|
||||||
|
description: "Optional mission this beat is tied to. Beats may stand alone.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -131,6 +131,8 @@ export const Profiles: CollectionConfig = {
|
||||||
eodMinesweeper?: { xpEarned?: number | null };
|
eodMinesweeper?: { xpEarned?: number | null };
|
||||||
radioTraffic?: { xpEarned?: number | null };
|
radioTraffic?: { xpEarned?: number | null };
|
||||||
aimTrainer?: { xpEarned?: number | null };
|
aimTrainer?: { xpEarned?: number | null };
|
||||||
|
callForFire?: { xpEarned?: number | null };
|
||||||
|
landNav?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
voiceover?: { xpEarned?: number | null };
|
voiceover?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
|
|
@ -146,6 +148,8 @@ export const Profiles: CollectionConfig = {
|
||||||
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
||||||
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
||||||
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
||||||
|
const callForFireXp = progressionData?.minigames?.callForFire?.xpEarned ?? 0;
|
||||||
|
const landNavXp = progressionData?.minigames?.landNav?.xpEarned ?? 0;
|
||||||
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
||||||
|
|
||||||
if (awardIds.length > 0) {
|
if (awardIds.length > 0) {
|
||||||
|
|
@ -188,6 +192,10 @@ export const Profiles: CollectionConfig = {
|
||||||
xpAcc += eodXp;
|
xpAcc += eodXp;
|
||||||
xpAcc += radioTrafficXp;
|
xpAcc += radioTrafficXp;
|
||||||
xpAcc += aimTrainerXp;
|
xpAcc += aimTrainerXp;
|
||||||
|
// Call for Fire: XP from effective artillery adjustments
|
||||||
|
xpAcc += callForFireXp;
|
||||||
|
// Land Navigation: XP from passed navigation courses
|
||||||
|
xpAcc += landNavXp;
|
||||||
// Voiceover submissions: XP awarded per accepted submission
|
// Voiceover submissions: XP awarded per accepted submission
|
||||||
xpAcc += voiceoverXp;
|
xpAcc += voiceoverXp;
|
||||||
|
|
||||||
|
|
@ -565,6 +573,114 @@ export const Profiles: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "callForFire",
|
||||||
|
label: "Call for Fire",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "gamesPlayed",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wins",
|
||||||
|
label: "Wins",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "losses",
|
||||||
|
label: "Losses",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestScore",
|
||||||
|
label: "Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "xpEarned",
|
||||||
|
label: "XP Earned",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestPatrolMissions",
|
||||||
|
label: "Fewest Missions on Best Patrol Support Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestCheckpointMissions",
|
||||||
|
label: "Fewest Missions on Best Checkpoint Defence Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestCompoundMissions",
|
||||||
|
label: "Fewest Missions on Best Compound Strike Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "landNav",
|
||||||
|
label: "Land Navigation",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "gamesPlayed",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wins",
|
||||||
|
label: "Wins",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "losses",
|
||||||
|
label: "Losses",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestScore",
|
||||||
|
label: "Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "xpEarned",
|
||||||
|
label: "XP Earned",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestWaypointSeconds",
|
||||||
|
label: "Fastest Time on Best Waypoint Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestRouteSeconds",
|
||||||
|
label: "Fastest Time on Best Route Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestExpeditionSeconds",
|
||||||
|
label: "Fastest Time on Best Expedition Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
PlaneTakeoff,
|
PlaneTakeoff,
|
||||||
Radio,
|
Radio,
|
||||||
Bomb,
|
Bomb,
|
||||||
|
Target,
|
||||||
|
Compass,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavigationCommand {
|
export interface NavigationCommand {
|
||||||
|
|
@ -38,8 +40,22 @@ export interface NavigationCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const navigationCommands: NavigationCommand[] = [
|
export const navigationCommands: NavigationCommand[] = [
|
||||||
{ id: "nav:dashboard", label: "Dashboard", icon: Bolt, href: "/", keywords: ["home", "main"], shortcut: "G D" },
|
{
|
||||||
{ id: "nav:roster", label: "Roster", icon: Users, href: "/roster", keywords: ["members", "personnel"], shortcut: "G R" },
|
id: "nav:dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
icon: Bolt,
|
||||||
|
href: "/",
|
||||||
|
keywords: ["home", "main"],
|
||||||
|
shortcut: "G D",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:roster",
|
||||||
|
label: "Roster",
|
||||||
|
icon: Users,
|
||||||
|
href: "/roster",
|
||||||
|
keywords: ["members", "personnel"],
|
||||||
|
shortcut: "G R",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "nav:transfers",
|
id: "nav:transfers",
|
||||||
label: "Transfers",
|
label: "Transfers",
|
||||||
|
|
@ -247,6 +263,20 @@ export const navigationCommands: NavigationCommand[] = [
|
||||||
href: "/eod",
|
href: "/eod",
|
||||||
keywords: ["minigame", "eod", "mines", "training"],
|
keywords: ["minigame", "eod", "mines", "training"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "nav:call-for-fire",
|
||||||
|
label: "Call for Fire",
|
||||||
|
icon: Target,
|
||||||
|
href: "/call-for-fire",
|
||||||
|
keywords: ["minigame", "artillery", "observer", "fire mission", "training"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:land-nav",
|
||||||
|
label: "Land Navigation",
|
||||||
|
icon: Compass,
|
||||||
|
href: "/land-nav",
|
||||||
|
keywords: ["minigame", "navigation", "map", "grid", "azimuth", "training"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "nav:account",
|
id: "nav:account",
|
||||||
label: "Account",
|
label: "Account",
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@ export function AppSidebar({
|
||||||
{hasLogistics && (
|
{hasLogistics && (
|
||||||
<NavCore label="Infrastructure" items={data.navLogistics} />
|
<NavCore label="Infrastructure" items={data.navLogistics} />
|
||||||
)}
|
)}
|
||||||
|
<NavCore label="Chronicle" items={data.navStory} />
|
||||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
||||||
<NavCore label="Commerce" items={data.navCommerce} />
|
<NavCore label="Commerce" items={data.navCommerce} />
|
||||||
<NavCore label="Personal" items={data.navPersonal} />
|
<NavCore label="Personal" items={data.navPersonal} />
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import {
|
||||||
Bomb,
|
Bomb,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
|
Compass,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
LogOut,
|
LogOut,
|
||||||
Mic,
|
Mic,
|
||||||
Radio,
|
Radio,
|
||||||
|
Target,
|
||||||
Users2,
|
Users2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -167,6 +169,18 @@ export function NavUser({
|
||||||
Qualification Course
|
Qualification Course
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/call-for-fire">
|
||||||
|
<Target />
|
||||||
|
Call for Fire
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/land-nav">
|
||||||
|
<Compass />
|
||||||
|
Land Navigation
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Kanban,
|
Kanban,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
Package,
|
Package,
|
||||||
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
Send,
|
Send,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
|
|
@ -149,6 +150,13 @@ export const sidebarData = {
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
navStory: [
|
||||||
|
{
|
||||||
|
title: "Chronicle",
|
||||||
|
url: "/story",
|
||||||
|
icon: ScrollText,
|
||||||
|
},
|
||||||
|
],
|
||||||
navSecondary: [
|
navSecondary: [
|
||||||
{
|
{
|
||||||
title: "Feedback",
|
title: "Feedback",
|
||||||
|
|
|
||||||
47
src/components/frontend/call-for-fire/CallForFireArena.tsx
Normal file
47
src/components/frontend/call-for-fire/CallForFireArena.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { CallForFireDifficulty, CallForFireStats } from "@/app/(frontend)/call-for-fire/actions";
|
||||||
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
import { CallForFireLeaderboard } from "./CallForFireLeaderboard";
|
||||||
|
import { FireMissionBoard } from "./FireMissionBoard";
|
||||||
|
|
||||||
|
export function CallForFireArena({
|
||||||
|
initialStats,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
initialStats: CallForFireStats;
|
||||||
|
currentUserId: number | string;
|
||||||
|
}) {
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [selectedDifficulty, setSelectedDifficulty] = useState<CallForFireDifficulty>("patrol");
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
const activeTeams = useRealtimePresence("call-for-fire", playing, true);
|
||||||
|
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||||
|
setPlaying(phase === "playing");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing && isMobile) setOpenMobile(false);
|
||||||
|
}, [isMobile, playing, setOpenMobile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<FireMissionBoard
|
||||||
|
initialStats={initialStats}
|
||||||
|
onGameRecorded={() => setRefreshKey((key) => key + 1)}
|
||||||
|
onPhaseChange={onPhaseChange}
|
||||||
|
onDifficultyChange={setSelectedDifficulty}
|
||||||
|
/>
|
||||||
|
<CallForFireLeaderboard
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
activeTeams={activeTeams}
|
||||||
|
difficulty={selectedDifficulty}
|
||||||
|
onDifficultyChange={setSelectedDifficulty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
src/components/frontend/call-for-fire/CallForFireLeaderboard.tsx
Normal file
251
src/components/frontend/call-for-fire/CallForFireLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
import type { CallForFireDifficulty } from "@/app/(frontend)/call-for-fire/actions";
|
||||||
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApiProfile {
|
||||||
|
id: number;
|
||||||
|
profileTitle?: string | null;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
displayName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
} | null;
|
||||||
|
progression?: {
|
||||||
|
minigames?: {
|
||||||
|
callForFire?: Record<string, number | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
id: number;
|
||||||
|
userId: number | null;
|
||||||
|
name: string;
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
bestScore: number;
|
||||||
|
xpEarned: number;
|
||||||
|
bestMissions: number;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isOutsideTopTen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallForFireLeaderboardProps {
|
||||||
|
refreshKey?: number;
|
||||||
|
currentUserId?: number | string;
|
||||||
|
activeTeams?: PresenceParticipant[];
|
||||||
|
difficulty: CallForFireDifficulty;
|
||||||
|
onDifficultyChange: (difficulty: CallForFireDifficulty) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_LABELS: Record<CallForFireDifficulty, string> = {
|
||||||
|
patrol: "Patrol",
|
||||||
|
checkpoint: "Checkpoint",
|
||||||
|
compound: "Compound",
|
||||||
|
};
|
||||||
|
|
||||||
|
function rankClass(index: number) {
|
||||||
|
if (index === 0) return "font-bold text-amber-400";
|
||||||
|
if (index === 1) return "font-semibold text-slate-300";
|
||||||
|
if (index === 2) return "font-semibold text-amber-700";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallForFireLeaderboard({
|
||||||
|
refreshKey = 0,
|
||||||
|
currentUserId,
|
||||||
|
activeTeams = [],
|
||||||
|
difficulty,
|
||||||
|
onDifficultyChange,
|
||||||
|
}: CallForFireLeaderboardProps) {
|
||||||
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
const missionsField = `progression.minigames.callForFire.best${DIFFICULTY_LABELS[difficulty]}Missions`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: missionsField,
|
||||||
|
limit: "100",
|
||||||
|
depth: "1",
|
||||||
|
});
|
||||||
|
params.set(`where[${missionsField}][greater_than]`, "0");
|
||||||
|
|
||||||
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
return res.json() as Promise<{ docs?: ApiProfile[] }>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => {
|
||||||
|
const game = doc.progression?.minigames?.callForFire ?? {};
|
||||||
|
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
||||||
|
const name =
|
||||||
|
user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`;
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
name,
|
||||||
|
gamesPlayed: game.gamesPlayed ?? 0,
|
||||||
|
wins: game.wins ?? 0,
|
||||||
|
bestScore: game.bestScore ?? 0,
|
||||||
|
xpEarned: game.xpEarned ?? 0,
|
||||||
|
bestMissions: game[`best${DIFFICULTY_LABELS[difficulty]}Missions`] ?? 0,
|
||||||
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const scored = mapped
|
||||||
|
.filter((row) => row.bestMissions > 0)
|
||||||
|
.sort((a, b) => a.bestMissions - b.bestMissions || b.bestScore - a.bestScore);
|
||||||
|
const visible = scored.slice(0, 10);
|
||||||
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
}
|
||||||
|
setRows(visible);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [difficulty, refreshKey, currentUserId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
|
<Trophy className="size-4 text-amber-400" />
|
||||||
|
<h2 className="text-sm font-semibold">Call for Fire Leaderboard</h2>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Select
|
||||||
|
value={difficulty}
|
||||||
|
onValueChange={(value) => onDifficultyChange(value as CallForFireDifficulty)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Leaderboard sector" className="h-8 w-[150px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="patrol">Patrol Support</SelectItem>
|
||||||
|
<SelectItem value="checkpoint">Checkpoint Defence</SelectItem>
|
||||||
|
<SelectItem value="compound">Compound Strike</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span>Top 10 · fewest missions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-emerald-400">
|
||||||
|
{activeTeams.length} observers on the net
|
||||||
|
</span>
|
||||||
|
{activeTeams.length > 0 && (
|
||||||
|
<span className="ml-1 text-foreground">
|
||||||
|
({activeTeams.map((team) => team.name).join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="space-y-2 px-4 py-4">
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-3/4 animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-border" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
No effective missions yet. Take the first sector!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="px-4 py-2 font-medium">#</th>
|
||||||
|
<th className="px-2 py-2 font-medium">Operator</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Missions</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Games</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Wins</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">XP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
row.isCurrentUser && "bg-accent/40",
|
||||||
|
row.isOutsideTopTen && "text-muted-foreground opacity-70",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
row.isOutsideTopTen
|
||||||
|
? "Operator status (not currently ranked in the top 10)"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={rankClass(index)}>
|
||||||
|
{row.isOutsideTopTen ? "-" : index + 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="max-w-[180px] truncate">{row.name}</span>
|
||||||
|
{row.isCurrentUser && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||||
|
)}
|
||||||
|
{row.isOutsideTopTen && (
|
||||||
|
<span className="shrink-0 text-[10px] font-normal uppercase tracking-wide text-muted-foreground">
|
||||||
|
unranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.userId !== null &&
|
||||||
|
activeTeams.some((team) => team.id === row.userId) && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-emerald-400"
|
||||||
|
title="Firing now"
|
||||||
|
aria-label="Firing now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
|
{row.bestMissions}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.gamesPlayed}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.wins}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.xpEarned}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
688
src/components/frontend/call-for-fire/FireMissionBoard.tsx
Normal file
688
src/components/frontend/call-for-fire/FireMissionBoard.tsx
Normal file
|
|
@ -0,0 +1,688 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
import { Crosshair, Radio, RotateCcw, Target, Timer } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
submitCallForFireResult,
|
||||||
|
type CallForFireDifficulty,
|
||||||
|
type CallForFireStats,
|
||||||
|
} from "@/app/(frontend)/call-for-fire/actions";
|
||||||
|
import {
|
||||||
|
CALL_FOR_FIRE_DIFFICULTIES,
|
||||||
|
METERS_PER_CELL,
|
||||||
|
applyCorrection,
|
||||||
|
createScenario,
|
||||||
|
distanceMeters,
|
||||||
|
gridDesignation,
|
||||||
|
calculateScore,
|
||||||
|
isEffectiveHit,
|
||||||
|
observerReport,
|
||||||
|
type FireScenario,
|
||||||
|
type GridPoint,
|
||||||
|
} from "@/lib/call-for-fire";
|
||||||
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
|
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||||
|
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Phase = "ready" | "playing" | "over";
|
||||||
|
|
||||||
|
type LastRun = {
|
||||||
|
readonly won: boolean;
|
||||||
|
readonly roundsUsed: number;
|
||||||
|
readonly seconds: number;
|
||||||
|
readonly finalDistanceM: number;
|
||||||
|
readonly score: number;
|
||||||
|
readonly xpEarned: number;
|
||||||
|
readonly best: number;
|
||||||
|
readonly reason?: "rounds" | "time";
|
||||||
|
readonly error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Impact = {
|
||||||
|
readonly point: GridPoint;
|
||||||
|
readonly effective: boolean;
|
||||||
|
readonly spotting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_KEYS: readonly CallForFireDifficulty[] = ["patrol", "checkpoint", "compound"];
|
||||||
|
const CORRECTION_STEP = 50;
|
||||||
|
|
||||||
|
function formatClock(totalSeconds: number): string {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FireMissionBoard({
|
||||||
|
initialStats: serverStats,
|
||||||
|
onGameRecorded,
|
||||||
|
onPhaseChange,
|
||||||
|
onDifficultyChange,
|
||||||
|
}: {
|
||||||
|
initialStats: CallForFireStats;
|
||||||
|
onGameRecorded: () => void;
|
||||||
|
onPhaseChange: (phase: Phase) => void;
|
||||||
|
onDifficultyChange: (difficulty: CallForFireDifficulty) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
|
const [difficulty, setDifficulty] = useState<CallForFireDifficulty>("patrol");
|
||||||
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
|
const [scenario, setScenario] = useState<FireScenario | null>(null);
|
||||||
|
const [impacts, setImpacts] = useState<Impact[]>([]);
|
||||||
|
const [lastImpact, setLastImpact] = useState<GridPoint | null>(null);
|
||||||
|
const [radioLog, setRadioLog] = useState<string[]>([]);
|
||||||
|
const [roundsUsed, setRoundsUsed] = useState(0);
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||||
|
const [rangeM, setRangeM] = useState(0);
|
||||||
|
const [deflectionM, setDeflectionM] = useState(0);
|
||||||
|
const [stats, setStats] = useState<CallForFireStats>(serverStats);
|
||||||
|
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const statsRef = useRef<CallForFireStats>(serverStats);
|
||||||
|
const secondsLeftRef = useRef(0);
|
||||||
|
|
||||||
|
const config = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||||
|
const roundsLeft = config.maxRounds - roundsUsed;
|
||||||
|
const elapsedSeconds = config.maxSeconds - secondsLeftRef.current;
|
||||||
|
|
||||||
|
const setPhaseSafe = useCallback(
|
||||||
|
(nextPhase: Phase) => {
|
||||||
|
setPhase(nextPhase);
|
||||||
|
onPhaseChange(nextPhase);
|
||||||
|
},
|
||||||
|
[onPhaseChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPhaseChange("ready");
|
||||||
|
}, [onPhaseChange]);
|
||||||
|
|
||||||
|
const reset = useCallback(
|
||||||
|
(nextDifficulty: CallForFireDifficulty = difficulty) => {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
if (expiredRef.current) return;
|
||||||
|
setDifficulty(nextDifficulty);
|
||||||
|
onDifficultyChange(nextDifficulty);
|
||||||
|
setScenario(null);
|
||||||
|
setImpacts([]);
|
||||||
|
setLastImpact(null);
|
||||||
|
setRadioLog([]);
|
||||||
|
setRoundsUsed(0);
|
||||||
|
setRangeM(0);
|
||||||
|
setDeflectionM(0);
|
||||||
|
setLastRun(null);
|
||||||
|
secondsLeftRef.current = CALL_FOR_FIRE_DIFFICULTIES[nextDifficulty].maxSeconds;
|
||||||
|
setSecondsLeft(CALL_FOR_FIRE_DIFFICULTIES[nextDifficulty].maxSeconds);
|
||||||
|
setPhaseSafe("ready");
|
||||||
|
},
|
||||||
|
[difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordResult = useCallback(
|
||||||
|
async (won: boolean, used: number, seconds: number, finalDistanceM: number) => {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
if (expiredRef.current) {
|
||||||
|
setLastRun({
|
||||||
|
won,
|
||||||
|
roundsUsed: used,
|
||||||
|
seconds,
|
||||||
|
finalDistanceM,
|
||||||
|
score: 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: 0,
|
||||||
|
error: "Your session expired. Result not recorded.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submittingRef.current = true;
|
||||||
|
setSubmitting(true);
|
||||||
|
const result = await submitCallForFireResult({
|
||||||
|
difficulty,
|
||||||
|
roundsUsed: used,
|
||||||
|
seconds,
|
||||||
|
finalDistanceM,
|
||||||
|
won,
|
||||||
|
});
|
||||||
|
submittingRef.current = false;
|
||||||
|
setSubmitting(false);
|
||||||
|
const score = calculateScore(difficulty, used, seconds, finalDistanceM, won);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
statsRef.current = result.data;
|
||||||
|
setStats(result.data);
|
||||||
|
onGameRecorded();
|
||||||
|
if (result.data.xpEarned > 0) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PROFILE_UPDATE_EVENT, {
|
||||||
|
detail: { reason: "xp", xpGained: result.data.xpEarned },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.setTimeout(() => router.refresh(), 1700);
|
||||||
|
}
|
||||||
|
setLastRun({
|
||||||
|
won,
|
||||||
|
roundsUsed: used,
|
||||||
|
seconds,
|
||||||
|
finalDistanceM,
|
||||||
|
score,
|
||||||
|
xpEarned: result.data.xpEarned,
|
||||||
|
best: result.data.bestScore,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLastRun({
|
||||||
|
won,
|
||||||
|
roundsUsed: used,
|
||||||
|
seconds,
|
||||||
|
finalDistanceM,
|
||||||
|
score,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: statsRef.current.bestScore,
|
||||||
|
error: result.error ?? "Could not record fire mission.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[difficulty, expiredRef, onGameRecorded, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishGameRef = useRef<(won: boolean, reason?: "rounds" | "time") => void>(() => {});
|
||||||
|
finishGameRef.current = (won: boolean, reason?: "rounds" | "time") => {
|
||||||
|
if (submittingRef.current || phase === "over") return;
|
||||||
|
setPhaseSafe("over");
|
||||||
|
if (!scenario || !lastImpact) return;
|
||||||
|
const seconds = reason === "time" ? config.maxSeconds : elapsedSeconds;
|
||||||
|
const distance = distanceMeters(scenario.target, lastImpact);
|
||||||
|
void recordResult(won, roundsUsed, seconds, distance);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "playing") return;
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setSecondsLeft((prev) => {
|
||||||
|
const next = prev - 1;
|
||||||
|
secondsLeftRef.current = next;
|
||||||
|
if (next <= 0) {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
finishGameRef.current(false, "time");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
const begin = useCallback(() => {
|
||||||
|
if (expiredRef.current || submittingRef.current) return;
|
||||||
|
const nextScenario = createScenario(difficulty);
|
||||||
|
const report = observerReport(nextScenario.target, nextScenario.spotting);
|
||||||
|
setScenario(nextScenario);
|
||||||
|
setImpacts([
|
||||||
|
{ point: nextScenario.spotting, effective: false, spotting: true },
|
||||||
|
]);
|
||||||
|
setLastImpact(nextScenario.spotting);
|
||||||
|
setRadioLog([
|
||||||
|
`Splash, over. Spotting round ${gridDesignation(nextScenario.spotting)}: ${report.message}`,
|
||||||
|
]);
|
||||||
|
setRoundsUsed(1);
|
||||||
|
secondsLeftRef.current = config.maxSeconds;
|
||||||
|
setSecondsLeft(config.maxSeconds);
|
||||||
|
setRangeM(0);
|
||||||
|
setDeflectionM(0);
|
||||||
|
setLastRun(null);
|
||||||
|
setPhaseSafe("playing");
|
||||||
|
}, [config.maxSeconds, difficulty, expiredRef, setPhaseSafe]);
|
||||||
|
|
||||||
|
const fireMission = useCallback(() => {
|
||||||
|
if (phase !== "playing" || submittingRef.current || expiredRef.current) return;
|
||||||
|
if (!scenario || !lastImpact) return;
|
||||||
|
|
||||||
|
const impactPoint = applyCorrection(lastImpact, { rangeM, deflectionM }, config.size);
|
||||||
|
const distance = distanceMeters(scenario.target, impactPoint);
|
||||||
|
const effective = isEffectiveHit(scenario.target, impactPoint, config.lethalRadiusM);
|
||||||
|
const nextImpacts = [
|
||||||
|
...impacts,
|
||||||
|
{ point: impactPoint, effective, spotting: false },
|
||||||
|
];
|
||||||
|
const nextUsed = roundsUsed + 1;
|
||||||
|
setImpacts(nextImpacts);
|
||||||
|
setLastImpact(impactPoint);
|
||||||
|
setRoundsUsed(nextUsed);
|
||||||
|
setRangeM(0);
|
||||||
|
setDeflectionM(0);
|
||||||
|
|
||||||
|
if (effective) {
|
||||||
|
setRadioLog((log) =>
|
||||||
|
[
|
||||||
|
...log,
|
||||||
|
`Splash, over. Effective on grid ${gridDesignation(impactPoint)}. Target destroyed.`,
|
||||||
|
].slice(-6),
|
||||||
|
);
|
||||||
|
setPhaseSafe("over");
|
||||||
|
void recordResult(true, nextUsed, elapsedSeconds, distance);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = observerReport(scenario.target, impactPoint);
|
||||||
|
setRadioLog((log) =>
|
||||||
|
[
|
||||||
|
...log,
|
||||||
|
`Impact grid ${gridDesignation(impactPoint)}: ${report.message}`,
|
||||||
|
].slice(-6),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextUsed >= config.maxRounds) {
|
||||||
|
setPhaseSafe("over");
|
||||||
|
void recordResult(false, nextUsed, elapsedSeconds, distance);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
config.lethalRadiusM,
|
||||||
|
config.maxRounds,
|
||||||
|
config.size,
|
||||||
|
elapsedSeconds,
|
||||||
|
expiredRef,
|
||||||
|
impacts,
|
||||||
|
lastImpact,
|
||||||
|
phase,
|
||||||
|
rangeM,
|
||||||
|
roundsUsed,
|
||||||
|
scenario,
|
||||||
|
setPhaseSafe,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timeCritical = phase === "playing" && secondsLeft <= 15;
|
||||||
|
const statusClass =
|
||||||
|
phase === "over"
|
||||||
|
? lastRun?.won
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10"
|
||||||
|
: "border-destructive/40 bg-destructive/10"
|
||||||
|
: "border-border bg-card";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-5xl flex-col gap-4">
|
||||||
|
{expired && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-6 text-center">
|
||||||
|
<span className="text-xl font-black uppercase tracking-widest text-amber-400">
|
||||||
|
Session Ending Soon
|
||||||
|
</span>
|
||||||
|
<span className="max-w-md text-sm text-white/80">
|
||||||
|
Your session is about to end. The app will refresh when it expires.
|
||||||
|
</span>
|
||||||
|
<LoginLink>
|
||||||
|
<Button type="button" size="sm">
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
</LoginLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex select-none flex-col gap-4 rounded-xl border border-border bg-card p-4 shadow">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Target className="size-4 text-amber-500" />
|
||||||
|
Fire mission console
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Bracket the hidden target from the spotting report, then walk fire on with range and
|
||||||
|
deflection corrections.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 font-mono text-sm tabular-nums">
|
||||||
|
{phase !== "ready" && (
|
||||||
|
<span
|
||||||
|
className={cn("flex items-center gap-1.5", timeCritical && "text-destructive")}
|
||||||
|
aria-label={`${formatClock(Math.max(0, secondsLeft))} remaining`}
|
||||||
|
>
|
||||||
|
<Timer className="size-4" />
|
||||||
|
{formatClock(Math.max(0, secondsLeft))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{phase !== "ready" && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
aria-label={`${Math.max(0, roundsLeft)} rounds remaining`}
|
||||||
|
>
|
||||||
|
<Crosshair className="size-4 text-amber-400" />
|
||||||
|
{Math.max(0, roundsLeft)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
disabled={phase === "playing"}
|
||||||
|
className={cn(
|
||||||
|
"m-0 flex min-w-0 flex-col gap-4 border-0 p-0 transition-opacity duration-300",
|
||||||
|
phase === "playing" && "pointer-events-none opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-2 md:grid-cols-3">
|
||||||
|
{DIFFICULTY_KEYS.map((key) => {
|
||||||
|
const option = CALL_FOR_FIRE_DIFFICULTIES[key];
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
variant={difficulty === key ? "default" : "outline"}
|
||||||
|
className="h-auto justify-start px-3 py-2 text-left"
|
||||||
|
disabled={submitting || expired}
|
||||||
|
aria-pressed={difficulty === key}
|
||||||
|
onClick={() => reset(key)}
|
||||||
|
>
|
||||||
|
<span className="flex flex-col items-start gap-0.5">
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span className="text-[10px] font-normal opacity-75">
|
||||||
|
{option.size}×{option.size} grid · {option.maxRounds} rounds max ·{" "}
|
||||||
|
{option.parRounds} par · {formatClock(option.maxSeconds)} limit
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
statusClass,
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{phase === "ready" && (
|
||||||
|
<span>Ready to observe. Begin the fire mission to receive your spotting round.</span>
|
||||||
|
)}
|
||||||
|
{phase === "playing" && (
|
||||||
|
<span>
|
||||||
|
Live fire mission: correct range (ADD/DROP) and deflection (RIGHT/LEFT) from the
|
||||||
|
last impact.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{phase === "over" && lastRun?.won && (
|
||||||
|
<span>
|
||||||
|
Fire effect achieved. {config.label} target destroyed with {lastRun.roundsUsed}{" "}
|
||||||
|
round{lastRun.roundsUsed === 1 ? "" : "s"} in {lastRun.seconds}s.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{phase === "over" && !lastRun?.won && lastRun?.reason === "rounds" && (
|
||||||
|
<span>Mission failed. Rounds expended before effect. Adjust and try again.</span>
|
||||||
|
)}
|
||||||
|
{phase === "over" && !lastRun?.won && lastRun?.reason === "time" && (
|
||||||
|
<span>Mission failed. Time expired before fire effect.</span>
|
||||||
|
)}
|
||||||
|
{phase === "over" && !lastRun && <span>Recording fire mission…</span>}
|
||||||
|
{lastRun?.won && (
|
||||||
|
<span className="text-xs text-emerald-500">
|
||||||
|
Score {lastRun.score} · accuracy {Math.round(lastRun.finalDistanceM)}m · +
|
||||||
|
{lastRun.xpEarned} XP earned.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lastRun?.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
|
||||||
|
</div>
|
||||||
|
{phase === "ready" && (
|
||||||
|
<Button type="button" size="sm" disabled={submitting || expired} onClick={begin}>
|
||||||
|
<Crosshair /> Begin fire mission
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{phase === "over" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={submitting || expired}
|
||||||
|
onClick={() => reset()}
|
||||||
|
>
|
||||||
|
<RotateCcw /> New fire mission
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 items-start justify-center overflow-auto rounded-lg border border-border bg-background p-2"
|
||||||
|
style={{ containerType: "inline-size" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid min-w-max gap-1"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--board-cols": config.size,
|
||||||
|
"--cell-size": `max(1.25rem, min(2rem, calc((100cqw - ${(config.size + 1) * 0.25}rem) / ${config.size})))`,
|
||||||
|
gridTemplateColumns: `auto repeat(var(--board-cols), var(--cell-size))`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
role="grid"
|
||||||
|
aria-label={`${config.label} fire mission grid`}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
{Array.from({ length: config.size }, (_, column) => (
|
||||||
|
<div
|
||||||
|
key={`col-${column}`}
|
||||||
|
className="flex size-[var(--cell-size)] items-center justify-center font-mono text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{String.fromCharCode(65 + column)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: config.size }, (_, row) => (
|
||||||
|
<ImpactRow
|
||||||
|
key={`row-${row}`}
|
||||||
|
row={row}
|
||||||
|
size={config.size}
|
||||||
|
impacts={impacts}
|
||||||
|
target={phase === "over" ? scenario?.target : undefined}
|
||||||
|
disabled={phase !== "playing" || expired}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/50 p-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
fireMission();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<Crosshair className="size-3.5" />
|
||||||
|
Correction (metres, from last impact)
|
||||||
|
</div>
|
||||||
|
<CorrectionField
|
||||||
|
id="cff-range"
|
||||||
|
label="Range (+ ADD / - DROP)"
|
||||||
|
value={rangeM}
|
||||||
|
disabled={phase !== "playing"}
|
||||||
|
onChange={setRangeM}
|
||||||
|
/>
|
||||||
|
<CorrectionField
|
||||||
|
id="cff-deflection"
|
||||||
|
label="Deflection (+ RIGHT / - LEFT)"
|
||||||
|
value={deflectionM}
|
||||||
|
disabled={phase !== "playing"}
|
||||||
|
onChange={setDeflectionM}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={phase !== "playing" || submitting || expired}>
|
||||||
|
Fire mission
|
||||||
|
</Button>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
1 cell = {METERS_PER_CELL}m. ADD pushes the burst north, RIGHT pushes it east.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="min-w-0 rounded-lg border border-border/70 bg-background/50 px-3 py-2">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<Radio className="size-3.5" />
|
||||||
|
Observer net
|
||||||
|
</div>
|
||||||
|
{radioLog.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Net quiet. Awaiting your first call.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 font-mono text-xs tabular-nums">
|
||||||
|
{radioLog.map((line, index) => (
|
||||||
|
<li
|
||||||
|
key={`${index}-${line}`}
|
||||||
|
className={cn(
|
||||||
|
line.includes("Effective") ? "text-emerald-400" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
> {line}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Read the spotting report, correct range and deflection, and walk the rounds onto the target
|
||||||
|
before time runs out
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span>Missions</span>
|
||||||
|
<span className="font-semibold text-foreground">{stats.gamesPlayed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span>Wins</span>
|
||||||
|
<span className="font-semibold text-foreground">{stats.wins}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span>Best score</span>
|
||||||
|
<span className="font-semibold text-foreground">{stats.bestScore}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span>XP from fires</span>
|
||||||
|
<span className="font-semibold text-foreground">{stats.callForFireXp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImpactRow({
|
||||||
|
row,
|
||||||
|
size,
|
||||||
|
impacts,
|
||||||
|
target,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
row: number;
|
||||||
|
size: number;
|
||||||
|
impacts: Impact[];
|
||||||
|
target?: GridPoint;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
const impactByColumn = new Map<number, Impact>();
|
||||||
|
for (const impact of impacts) {
|
||||||
|
if (impact.point.y === row) {
|
||||||
|
impactByColumn.set(impact.point.x, impact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex size-[var(--cell-size)] items-center justify-center font-mono text-[10px] text-muted-foreground">
|
||||||
|
{row + 1}
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: size }, (_, column) => {
|
||||||
|
const impact = impactByColumn.get(column);
|
||||||
|
const isTarget = target !== undefined && target.x === column && target.y === row;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${row}-${column}`}
|
||||||
|
role="gridcell"
|
||||||
|
aria-label={`grid ${gridDesignation({ x: column, y: row })}${
|
||||||
|
impact ? (impact.effective ? " effective impact" : impact.spotting ? " spotting round" : " impact") : ""
|
||||||
|
}`}
|
||||||
|
className={cn(
|
||||||
|
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors",
|
||||||
|
"border-border bg-card",
|
||||||
|
impact?.effective &&
|
||||||
|
"border-emerald-400 bg-emerald-600/80 text-emerald-50 shadow-[0_0_8px_rgb(16_185_129_/_0.6)]",
|
||||||
|
impact && !impact.effective && impact.spotting &&
|
||||||
|
"border-sky-400/80 bg-sky-500/20 text-sky-200",
|
||||||
|
impact && !impact.effective && !impact.spotting &&
|
||||||
|
"border-amber-500/70 bg-amber-500/20 text-amber-200",
|
||||||
|
isTarget && "border-destructive bg-destructive/25 text-red-300",
|
||||||
|
disabled && "opacity-90",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{impact ? (
|
||||||
|
<Crosshair
|
||||||
|
className={cn("size-3.5", impact.spotting && "text-sky-300")}
|
||||||
|
strokeWidth={impact.spotting ? 2.5 : 2}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CorrectionField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (next: number) => void;
|
||||||
|
}) {
|
||||||
|
const clamp = (next: number) => Math.min(2000, Math.max(-2000, next));
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={id} className="text-xs text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="size-8 shrink-0 p-0"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Decrease ${label}`}
|
||||||
|
onClick={() => onChange(clamp(value - CORRECTION_STEP))}
|
||||||
|
>
|
||||||
|
-{CORRECTION_STEP}
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
step={CORRECTION_STEP}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-8 text-center font-mono tabular-nums"
|
||||||
|
onChange={(event) => {
|
||||||
|
const parsed = Number(event.target.value);
|
||||||
|
onChange(Number.isFinite(parsed) ? clamp(Math.round(parsed)) : 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="size-8 shrink-0 p-0"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Increase ${label}`}
|
||||||
|
onClick={() => onChange(clamp(value + CORRECTION_STEP))}
|
||||||
|
>
|
||||||
|
+{CORRECTION_STEP}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
378
src/components/frontend/land-nav/LandNavArena.tsx
Normal file
378
src/components/frontend/land-nav/LandNavArena.tsx
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Compass, RotateCcw } from "lucide-react";
|
||||||
|
import type { LandNavStats } from "@/app/(frontend)/land-nav/actions";
|
||||||
|
import { submitLandNavResult } from "@/app/(frontend)/land-nav/actions";
|
||||||
|
import {
|
||||||
|
LAND_NAV_DIFFICULTIES,
|
||||||
|
calculateScore,
|
||||||
|
createCourse,
|
||||||
|
gridRef,
|
||||||
|
isLegCorrect,
|
||||||
|
isOptionCorrect,
|
||||||
|
type Cell,
|
||||||
|
type LandNavCourse,
|
||||||
|
type LandNavDifficulty,
|
||||||
|
type LandNavLeg,
|
||||||
|
} from "@/lib/land-nav";
|
||||||
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
|
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||||
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
|
import { playXp } from "@/components/frontend/qualification/sounds";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LandNavLeaderboard } from "./LandNavLeaderboard";
|
||||||
|
import { LandNavMap, type MapClickResult } from "./LandNavMap";
|
||||||
|
|
||||||
|
type Phase = "ready" | "playing" | "over";
|
||||||
|
|
||||||
|
interface LandNavArenaProps {
|
||||||
|
initialStats: LandNavStats;
|
||||||
|
currentUserId: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunResult {
|
||||||
|
legsCorrect: number;
|
||||||
|
legsTotal: number;
|
||||||
|
seconds: number;
|
||||||
|
won: boolean;
|
||||||
|
score: number;
|
||||||
|
xpEarned: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_ORDER: LandNavDifficulty[] = ["waypoint", "route", "expedition"];
|
||||||
|
|
||||||
|
export function LandNavArena({ initialStats, currentUserId }: LandNavArenaProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
|
const [difficulty, setDifficulty] = useState<LandNavDifficulty>("waypoint");
|
||||||
|
const [course, setCourse] = useState<LandNavCourse | null>(null);
|
||||||
|
const [legIndex, setLegIndex] = useState(0);
|
||||||
|
const [legsCorrect, setLegsCorrect] = useState(0);
|
||||||
|
const [seconds, setSeconds] = useState(0);
|
||||||
|
const [lastResult, setLastResult] = useState<MapClickResult | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<RunResult | null>(null);
|
||||||
|
const [stats, setStats] = useState<LandNavStats>(initialStats);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const phaseRef = useRef<Phase>("ready");
|
||||||
|
const legsCorrectRef = useRef(0);
|
||||||
|
|
||||||
|
const playing = phase === "playing";
|
||||||
|
const activeTeams = useRealtimePresence("land-nav", playing, true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
phaseRef.current = phase;
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setSeconds((s) => s + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || !course) return;
|
||||||
|
if (seconds >= LAND_NAV_DIFFICULTIES[course.difficulty].maxSeconds) {
|
||||||
|
finishRun(legsCorrectRef.current, true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [seconds, playing, course]);
|
||||||
|
|
||||||
|
const startRun = useCallback(
|
||||||
|
(selected: LandNavDifficulty) => {
|
||||||
|
if (expiredRef.current) return;
|
||||||
|
setDifficulty(selected);
|
||||||
|
setCourse(createCourse(selected));
|
||||||
|
setLegIndex(0);
|
||||||
|
setLegsCorrect(0);
|
||||||
|
legsCorrectRef.current = 0;
|
||||||
|
setSeconds(0);
|
||||||
|
setLastResult(null);
|
||||||
|
setFeedback(null);
|
||||||
|
setResult(null);
|
||||||
|
setPhase("playing");
|
||||||
|
},
|
||||||
|
[expiredRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishRun = useCallback(
|
||||||
|
(finalLegsCorrect: number, timedOut: boolean) => {
|
||||||
|
if (!course || submittingRef.current || phaseRef.current !== "playing") return;
|
||||||
|
submittingRef.current = true;
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[course.difficulty];
|
||||||
|
const won = !timedOut && finalLegsCorrect >= config.passLegs;
|
||||||
|
const runSeconds = Math.max(1, seconds);
|
||||||
|
const score = calculateScore(course.difficulty, finalLegsCorrect, runSeconds, won);
|
||||||
|
setPhase("over");
|
||||||
|
phaseRef.current = "over";
|
||||||
|
|
||||||
|
if (expiredRef.current) {
|
||||||
|
setResult({
|
||||||
|
legsCorrect: finalLegsCorrect,
|
||||||
|
legsTotal: config.legs,
|
||||||
|
seconds: runSeconds,
|
||||||
|
won,
|
||||||
|
score,
|
||||||
|
xpEarned: 0,
|
||||||
|
});
|
||||||
|
setFeedback("Your session expired. Result not recorded.");
|
||||||
|
submittingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitLandNavResult({
|
||||||
|
difficulty: course.difficulty,
|
||||||
|
legsCorrect: finalLegsCorrect,
|
||||||
|
seconds: runSeconds,
|
||||||
|
won,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setStats(res.data);
|
||||||
|
setResult({
|
||||||
|
legsCorrect: finalLegsCorrect,
|
||||||
|
legsTotal: config.legs,
|
||||||
|
seconds: runSeconds,
|
||||||
|
won,
|
||||||
|
score,
|
||||||
|
xpEarned: res.data.xpEarned,
|
||||||
|
});
|
||||||
|
if (res.data.xpEarned > 0) {
|
||||||
|
playXp();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PROFILE_UPDATE_EVENT, {
|
||||||
|
detail: { reason: "xp", xpGained: res.data.xpEarned },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setTimeout(() => router.refresh(), 1700);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setResult({
|
||||||
|
legsCorrect: finalLegsCorrect,
|
||||||
|
legsTotal: config.legs,
|
||||||
|
seconds: runSeconds,
|
||||||
|
won,
|
||||||
|
score,
|
||||||
|
xpEarned: 0,
|
||||||
|
});
|
||||||
|
setFeedback(res.error ?? "Result not recorded.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submittingRef.current = false;
|
||||||
|
setRefreshKey((key) => key + 1);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[course, seconds, expiredRef, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const answerLeg = useCallback(
|
||||||
|
(correct: boolean, clickResult: MapClickResult | null, optionName?: string) => {
|
||||||
|
if (!course || phaseRef.current !== "playing") return;
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[course.difficulty];
|
||||||
|
const leg = course.legs[legIndex] as LandNavLeg;
|
||||||
|
setLastResult(clickResult);
|
||||||
|
const nextCorrect = legsCorrect + (correct ? 1 : 0);
|
||||||
|
legsCorrectRef.current = nextCorrect;
|
||||||
|
setLegsCorrect(nextCorrect);
|
||||||
|
setFeedback(
|
||||||
|
correct
|
||||||
|
? `Correct, ${leg.kind === "identify" ? optionName : gridRef(course, leg.answerCell)}.`
|
||||||
|
: `Wrong. The answer was ${
|
||||||
|
leg.kind === "identify"
|
||||||
|
? (course.features.find((f) => f.id === leg.answerFeatureId)?.name ?? "unknown")
|
||||||
|
: gridRef(course, leg.answerCell)
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
if (legIndex + 1 >= config.legs) {
|
||||||
|
setTimeout(() => finishRun(nextCorrect, false), 350);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => setLegIndex((i) => i + 1), 350);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[course, legIndex, legsCorrect, finishRun],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCellClick = useCallback(
|
||||||
|
(cell: Cell) => {
|
||||||
|
if (!course || phaseRef.current !== "playing") return;
|
||||||
|
const leg = course.legs[legIndex] as LandNavLeg;
|
||||||
|
if (leg.kind === "identify") return;
|
||||||
|
const correct = isLegCorrect(leg, cell);
|
||||||
|
answerLeg(correct, { cell, correct });
|
||||||
|
},
|
||||||
|
[course, legIndex, answerLeg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionClick = useCallback(
|
||||||
|
(option: string) => {
|
||||||
|
if (!course || phaseRef.current !== "playing") return;
|
||||||
|
const leg = course.legs[legIndex] as LandNavLeg;
|
||||||
|
if (leg.kind !== "identify") return;
|
||||||
|
const correct = isOptionCorrect(leg, course, option);
|
||||||
|
answerLeg(correct, null, option);
|
||||||
|
},
|
||||||
|
[course, legIndex, answerLeg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leg: LandNavLeg | null = course ? (course.legs[legIndex] ?? null) : null;
|
||||||
|
const config = course ? LAND_NAV_DIFFICULTIES[course.difficulty] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<div className="flex w-full max-w-[520px] flex-wrap items-center justify-between gap-3 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">Games {stats.gamesPlayed}</Badge>
|
||||||
|
<Badge variant="outline">Wins {stats.wins}</Badge>
|
||||||
|
<Badge variant="outline">Losses {stats.losses}</Badge>
|
||||||
|
<Badge variant="outline">Best {stats.bestScore}</Badge>
|
||||||
|
</div>
|
||||||
|
{phase === "ready" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => startRun(difficulty)}>
|
||||||
|
<Compass className="size-4" />
|
||||||
|
Start Course
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{phase === "over" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => startRun(difficulty)}>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
Run Again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === "ready" && (
|
||||||
|
<Card className="w-full max-w-[520px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Compass className="size-4 text-muted-foreground" />
|
||||||
|
Choose your course
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
{DIFFICULTY_ORDER.map((key) => {
|
||||||
|
const cfg = LAND_NAV_DIFFICULTIES[key];
|
||||||
|
const selected = difficulty === key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDifficulty(key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-4 py-3 text-left transition-colors",
|
||||||
|
selected ? "border-primary bg-accent/40" : "border-border hover:bg-accent/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{cfg.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{cfg.legs} legs · par {Math.floor(cfg.parSeconds / 60)}:
|
||||||
|
{String(cfg.parSeconds % 60).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{cfg.description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "playing" && course && leg && config && (
|
||||||
|
<Card className="w-full max-w-[520px]">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center justify-between text-sm font-medium">
|
||||||
|
<span>
|
||||||
|
Leg {legIndex + 1} of {config.legs} · {config.label}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{Math.floor((config.maxSeconds - seconds) / 60)}:
|
||||||
|
{String((config.maxSeconds - seconds) % 60).padStart(2, "0")} left
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium">{leg.prompt}</p>
|
||||||
|
<LandNavMap
|
||||||
|
course={course}
|
||||||
|
leg={leg.kind === "identify" ? null : leg}
|
||||||
|
lastResult={lastResult}
|
||||||
|
locked={leg.kind === "identify"}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
/>
|
||||||
|
{leg.kind === "identify" && leg.options && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{leg.options.map((option) => (
|
||||||
|
<Button key={option} variant="outline" onClick={() => handleOptionClick(option)}>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{feedback && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
lastResult?.correct === false ? "text-red-400" : "text-emerald-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{feedback}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Correct legs: {legsCorrect} · pass at {config.passLegs}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "over" && result && (
|
||||||
|
<Card className="w-full max-w-[520px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{result.won ? "Course completed" : "Course failed"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-1 text-sm">
|
||||||
|
<p>
|
||||||
|
Legs correct: {result.legsCorrect}/{result.legsTotal}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Time: {Math.floor(result.seconds / 60)}:{String(result.seconds % 60).padStart(2, "0")}
|
||||||
|
</p>
|
||||||
|
<p>Score: {result.score}</p>
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
result.xpEarned > 0 ? "font-medium text-emerald-400" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{result.xpEarned > 0 ? `+${result.xpEarned} XP earned` : "No XP earned"}
|
||||||
|
</p>
|
||||||
|
{feedback && <p className="text-xs text-muted-foreground">{feedback}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expired && (
|
||||||
|
<p className="text-sm text-amber-400">Your session expired. Result not recorded.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LandNavLeaderboard
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
activeTeams={activeTeams}
|
||||||
|
difficulty={difficulty}
|
||||||
|
onDifficultyChange={setDifficulty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/components/frontend/land-nav/LandNavLeaderboard.tsx
Normal file
263
src/components/frontend/land-nav/LandNavLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
import type { LandNavDifficulty } from "@/app/(frontend)/land-nav/actions";
|
||||||
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApiProfile {
|
||||||
|
id: number;
|
||||||
|
profileTitle?: string | null;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
displayName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
} | null;
|
||||||
|
progression?: {
|
||||||
|
minigames?: {
|
||||||
|
landNav?: Record<string, number | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
id: number;
|
||||||
|
userId: number | null;
|
||||||
|
name: string;
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
bestScore: number;
|
||||||
|
xpEarned: number;
|
||||||
|
bestSeconds: number;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isOutsideTopTen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LandNavLeaderboardProps {
|
||||||
|
refreshKey?: number;
|
||||||
|
currentUserId?: number | string;
|
||||||
|
activeTeams?: PresenceParticipant[];
|
||||||
|
difficulty: LandNavDifficulty;
|
||||||
|
onDifficultyChange: (difficulty: LandNavDifficulty) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_TIME_FIELDS: Record<LandNavDifficulty, string> = {
|
||||||
|
waypoint: "bestWaypointSeconds",
|
||||||
|
route: "bestRouteSeconds",
|
||||||
|
expedition: "bestExpeditionSeconds",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_LABELS: Record<LandNavDifficulty, string> = {
|
||||||
|
waypoint: "Waypoint",
|
||||||
|
route: "Route",
|
||||||
|
expedition: "Expedition",
|
||||||
|
};
|
||||||
|
|
||||||
|
function rankClass(index: number) {
|
||||||
|
if (index === 0) return "font-bold text-amber-400";
|
||||||
|
if (index === 1) return "font-semibold text-slate-300";
|
||||||
|
if (index === 2) return "font-semibold text-amber-700";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${String(secs).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LandNavLeaderboard({
|
||||||
|
refreshKey = 0,
|
||||||
|
currentUserId,
|
||||||
|
activeTeams = [],
|
||||||
|
difficulty,
|
||||||
|
onDifficultyChange,
|
||||||
|
}: LandNavLeaderboardProps) {
|
||||||
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
const timeField = `progression.minigames.landNav.${DIFFICULTY_TIME_FIELDS[difficulty]}`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: timeField,
|
||||||
|
limit: "100",
|
||||||
|
depth: "1",
|
||||||
|
});
|
||||||
|
params.set(`where[${timeField}][greater_than]`, "0");
|
||||||
|
|
||||||
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
return res.json() as Promise<{ docs?: ApiProfile[] }>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => {
|
||||||
|
const game = doc.progression?.minigames?.landNav ?? {};
|
||||||
|
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
||||||
|
const name =
|
||||||
|
user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`;
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
name,
|
||||||
|
gamesPlayed: game.gamesPlayed ?? 0,
|
||||||
|
wins: game.wins ?? 0,
|
||||||
|
bestScore: game.bestScore ?? 0,
|
||||||
|
xpEarned: game.xpEarned ?? 0,
|
||||||
|
bestSeconds: game[DIFFICULTY_TIME_FIELDS[difficulty]] ?? 0,
|
||||||
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const scored = mapped
|
||||||
|
.filter((row) => row.bestSeconds > 0)
|
||||||
|
.sort((a, b) => a.bestSeconds - b.bestSeconds || b.bestScore - a.bestScore);
|
||||||
|
const visible = scored.slice(0, 10);
|
||||||
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
}
|
||||||
|
setRows(visible);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [difficulty, refreshKey, currentUserId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
|
<Trophy className="size-4 text-amber-400" />
|
||||||
|
<h2 className="text-sm font-semibold">Land Nav Leaderboard</h2>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Select
|
||||||
|
value={difficulty}
|
||||||
|
onValueChange={(value) => onDifficultyChange(value as LandNavDifficulty)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Leaderboard course" className="h-8 w-[140px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="waypoint">Waypoint</SelectItem>
|
||||||
|
<SelectItem value="route">Route</SelectItem>
|
||||||
|
<SelectItem value="expedition">Expedition</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span>Top 10 · fastest time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-emerald-400">
|
||||||
|
{activeTeams.length} navigators on the course
|
||||||
|
</span>
|
||||||
|
{activeTeams.length > 0 && (
|
||||||
|
<span className="ml-1 text-foreground">
|
||||||
|
({activeTeams.map((team) => team.name).join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="space-y-2 px-4 py-4">
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-3/4 animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-border" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
No recorded courses yet. Be the first to navigate one!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="px-4 py-2 font-medium">#</th>
|
||||||
|
<th className="px-2 py-2 font-medium">Operator</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Best Time</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Games</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Wins</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">XP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
row.isCurrentUser && "bg-accent/40",
|
||||||
|
row.isOutsideTopTen && "text-muted-foreground opacity-70",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
row.isOutsideTopTen
|
||||||
|
? "Operator status (not currently ranked in the top 10)"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={rankClass(index)}>
|
||||||
|
{row.isOutsideTopTen ? "-" : index + 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="max-w-[180px] truncate">{row.name}</span>
|
||||||
|
{row.isCurrentUser && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||||
|
)}
|
||||||
|
{row.isOutsideTopTen && (
|
||||||
|
<span className="shrink-0 text-[10px] font-normal uppercase tracking-wide text-muted-foreground">
|
||||||
|
unranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.userId !== null &&
|
||||||
|
activeTeams.some((team) => team.id === row.userId) && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-emerald-400"
|
||||||
|
title="Navigating now"
|
||||||
|
aria-label="Navigating now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
|
{formatSeconds(row.bestSeconds)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.gamesPlayed}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.wins}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.xpEarned}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
src/components/frontend/land-nav/LandNavMap.tsx
Normal file
247
src/components/frontend/land-nav/LandNavMap.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { type Cell, type LandNavCourse, type LandNavLeg, featureTypeLabel } from "@/lib/land-nav";
|
||||||
|
|
||||||
|
export interface MapClickResult {
|
||||||
|
cell: Cell;
|
||||||
|
correct: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LandNavMapProps {
|
||||||
|
course: LandNavCourse;
|
||||||
|
leg: LandNavLeg | null;
|
||||||
|
lastResult: MapClickResult | null;
|
||||||
|
locked: boolean;
|
||||||
|
onCellClick: (cell: Cell) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
background: "#101a12",
|
||||||
|
grid: "#2c4531",
|
||||||
|
gridMajor: "#3d5c43",
|
||||||
|
text: "#8fb39a",
|
||||||
|
hill: "#a9805a",
|
||||||
|
water: "#4f83b8",
|
||||||
|
ruins: "#9aa0a6",
|
||||||
|
tower: "#c9c9c9",
|
||||||
|
bridge: "#b58a4e",
|
||||||
|
copse: "#4e8c4a",
|
||||||
|
start: "#f4d35e",
|
||||||
|
correct: "#4ade80",
|
||||||
|
wrong: "#f87171",
|
||||||
|
};
|
||||||
|
|
||||||
|
function featureColor(type: MapFeatureType): string {
|
||||||
|
switch (type) {
|
||||||
|
case "hill":
|
||||||
|
return COLORS.hill;
|
||||||
|
case "lake":
|
||||||
|
return COLORS.water;
|
||||||
|
case "ruins":
|
||||||
|
return COLORS.ruins;
|
||||||
|
case "tower":
|
||||||
|
return COLORS.tower;
|
||||||
|
case "bridge":
|
||||||
|
return COLORS.bridge;
|
||||||
|
case "copse":
|
||||||
|
return COLORS.copse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapFeatureType = LandNavCourse["features"][number]["type"];
|
||||||
|
|
||||||
|
export function LandNavMap({ course, leg, lastResult, locked, onCellClick }: LandNavMapProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [hover, setHover] = useState<Cell | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!canvas || !wrap) return;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const width = wrap.clientWidth;
|
||||||
|
const cell = width / course.cols;
|
||||||
|
const height = cell * course.rows;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = width * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.background;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.font = "10px ui-monospace, monospace";
|
||||||
|
ctx.fillStyle = COLORS.text;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
for (let c = 0; c < course.cols; c += 1) {
|
||||||
|
const label = String(course.easting0 + c).padStart(3, "0");
|
||||||
|
ctx.fillText(label, (c + 0.5) * cell, 11);
|
||||||
|
}
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
for (let r = 0; r < course.rows; r += 1) {
|
||||||
|
const label = String(course.northing0 + r).padStart(3, "0");
|
||||||
|
ctx.fillText(label, 3, (r + 0.5) * cell + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = COLORS.grid;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let c = 0; c <= course.cols; c += 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(c * cell + 0.5, 0);
|
||||||
|
ctx.lineTo(c * cell + 0.5, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let r = 0; r <= course.rows; r += 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, r * cell + 0.5);
|
||||||
|
ctx.lineTo(width, r * cell + 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of course.features) {
|
||||||
|
const cx = (feature.col + 0.5) * cell;
|
||||||
|
const cy = (feature.row + 0.5) * cell;
|
||||||
|
const color = featureColor(feature.type);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
switch (feature.type) {
|
||||||
|
case "hill":
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - cell * 0.22);
|
||||||
|
ctx.lineTo(cx - cell * 0.26, cy + cell * 0.16);
|
||||||
|
ctx.lineTo(cx + cell * 0.26, cy + cell * 0.16);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
case "lake":
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, cell * 0.26, cell * 0.17, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
case "ruins":
|
||||||
|
ctx.fillRect(cx - cell * 0.2, cy - cell * 0.12, cell * 0.16, cell * 0.16);
|
||||||
|
ctx.fillRect(cx - cell * 0.02, cy - cell * 0.02, cell * 0.16, cell * 0.16);
|
||||||
|
ctx.fillRect(cx + cell * 0.08, cy - cell * 0.22, cell * 0.12, cell * 0.12);
|
||||||
|
break;
|
||||||
|
case "tower":
|
||||||
|
ctx.fillRect(cx - cell * 0.05, cy - cell * 0.26, cell * 0.1, cell * 0.42);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy - cell * 0.3, cell * 0.07, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
case "bridge":
|
||||||
|
ctx.fillRect(cx - cell * 0.28, cy - cell * 0.06, cell * 0.56, cell * 0.12);
|
||||||
|
break;
|
||||||
|
case "copse":
|
||||||
|
for (const [dx, dy] of [
|
||||||
|
[-0.14, -0.08],
|
||||||
|
[0.12, -0.12],
|
||||||
|
[0, 0.1],
|
||||||
|
]) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx + dx * cell, cy + dy * cell, cell * 0.11, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.font = "9px ui-sans-serif, system-ui";
|
||||||
|
const label = feature.name;
|
||||||
|
ctx.fillText(label, cx, cy + cell * 0.38, cell * 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leg && (leg.kind === "azimuth" || leg.kind === "declination") && leg.startCell) {
|
||||||
|
const sx = (leg.startCell.col + 0.5) * cell;
|
||||||
|
const sy = (leg.startCell.row + 0.5) * cell;
|
||||||
|
ctx.fillStyle = COLORS.start;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, cell * 0.18, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
if (leg.kind === "azimuth") {
|
||||||
|
ctx.strokeStyle = COLORS.start;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
const radians = ((leg.azimuth ?? 0) * Math.PI) / 180;
|
||||||
|
const ray = cell * 1.6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx, sy);
|
||||||
|
ctx.lineTo(sx + Math.sin(radians) * ray, sy - Math.cos(radians) * ray);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hover && !locked) {
|
||||||
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.5)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(hover.col * cell + 1, hover.row * cell + 1, cell - 2, cell - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResult) {
|
||||||
|
ctx.fillStyle = lastResult.correct ? COLORS.correct : COLORS.wrong;
|
||||||
|
ctx.globalAlpha = 0.35;
|
||||||
|
ctx.fillRect(lastResult.cell.col * cell, lastResult.cell.row * cell, cell, cell);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.strokeStyle = lastResult.correct ? COLORS.correct : COLORS.wrong;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(
|
||||||
|
lastResult.cell.col * cell + 1,
|
||||||
|
lastResult.cell.row * cell + 1,
|
||||||
|
cell - 2,
|
||||||
|
cell - 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
const observer = new ResizeObserver(draw);
|
||||||
|
observer.observe(wrap);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [course, leg, lastResult, hover, locked]);
|
||||||
|
|
||||||
|
const cellFromEvent = (clientX: number, clientY: number): Cell | null => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return null;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const colSize = rect.width / course.cols;
|
||||||
|
const rowSize = rect.height / course.rows;
|
||||||
|
const col = Math.floor((clientX - rect.left) / colSize);
|
||||||
|
const row = Math.floor((clientY - rect.top) / rowSize);
|
||||||
|
if (col < 0 || col >= course.cols || row < 0 || row >= course.rows) return null;
|
||||||
|
return { col, row };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="w-full">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
role="grid"
|
||||||
|
aria-label="Land navigation map"
|
||||||
|
className={`block w-full rounded-lg border border-border ${locked ? "cursor-default" : "cursor-crosshair"}`}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
if (locked) return;
|
||||||
|
setHover(cellFromEvent(e.clientX, e.clientY));
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (locked) return;
|
||||||
|
const cell = cellFromEvent(e.clientX, e.clientY);
|
||||||
|
if (cell) onCellClick(cell);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-center text-[10px] text-muted-foreground">
|
||||||
|
Symbols: {featureTypeLabel("hill")} triangle, {featureTypeLabel("lake")} oval,{" "}
|
||||||
|
{featureTypeLabel("ruins")} blocks, {featureTypeLabel("tower")} mast,{" "}
|
||||||
|
{featureTypeLabel("bridge")} bar, {featureTypeLabel("copse")} clusters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/frontend/story/BeatReactions.tsx
Normal file
87
src/components/frontend/story/BeatReactions.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Flag, Heart, ThumbsUp } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { setStoryBeatReaction } from "@/app/(frontend)/story/actions";
|
||||||
|
import {
|
||||||
|
REACTION_LABELS,
|
||||||
|
STORY_REACTIONS,
|
||||||
|
type StoryReaction,
|
||||||
|
} from "@/lib/story/reactions";
|
||||||
|
|
||||||
|
const REACTION_ICONS: Record<StoryReaction, typeof ThumbsUp> = {
|
||||||
|
like: ThumbsUp,
|
||||||
|
heart: Heart,
|
||||||
|
salute: Flag,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReactionCounts = Record<StoryReaction, number>;
|
||||||
|
|
||||||
|
export function BeatReactions({
|
||||||
|
beatId,
|
||||||
|
initialReaction,
|
||||||
|
initialCounts,
|
||||||
|
}: {
|
||||||
|
beatId: number;
|
||||||
|
initialReaction: StoryReaction | null;
|
||||||
|
initialCounts: ReactionCounts;
|
||||||
|
}) {
|
||||||
|
const [current, setCurrent] = useState<StoryReaction | null>(initialReaction);
|
||||||
|
const [counts, setCounts] = useState<ReactionCounts>(initialCounts);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function toggle(reaction: StoryReaction) {
|
||||||
|
if (pending) return;
|
||||||
|
const next: StoryReaction | null = current === reaction ? null : reaction;
|
||||||
|
const previous = current;
|
||||||
|
startTransition(async () => {
|
||||||
|
setCounts((prev) => {
|
||||||
|
const nextCounts = { ...prev };
|
||||||
|
if (previous) nextCounts[previous] = Math.max(0, nextCounts[previous] - 1);
|
||||||
|
if (next) nextCounts[next] = nextCounts[next] + 1;
|
||||||
|
return nextCounts;
|
||||||
|
});
|
||||||
|
setCurrent(next);
|
||||||
|
const result = await setStoryBeatReaction(beatId, next);
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
setCounts((prev) => {
|
||||||
|
const reverted = { ...prev };
|
||||||
|
if (next) reverted[next] = Math.max(0, reverted[next] - 1);
|
||||||
|
if (previous) reverted[previous] = reverted[previous] + 1;
|
||||||
|
return reverted;
|
||||||
|
});
|
||||||
|
setCurrent(previous);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||||
|
{STORY_REACTIONS.map((reaction) => {
|
||||||
|
const Icon = REACTION_ICONS[reaction];
|
||||||
|
const active = current === reaction;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={reaction}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={`${REACTION_LABELS[reaction]} (${counts[reaction]})`}
|
||||||
|
onClick={() => toggle(reaction)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1.5 text-xs",
|
||||||
|
active && "border-primary bg-primary/10 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
<span>{counts[reaction]}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/frontend/story/BeatReadMarker.tsx
Normal file
58
src/components/frontend/story/BeatReadMarker.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { markStoryBeatRead } from "@/app/(frontend)/story/actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function BeatReadMarker({
|
||||||
|
beatId,
|
||||||
|
initialRead,
|
||||||
|
}: {
|
||||||
|
beatId: number;
|
||||||
|
initialRead: boolean;
|
||||||
|
}) {
|
||||||
|
const [read, setRead] = useState(initialRead);
|
||||||
|
const markerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const markedRef = useRef(initialRead);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (markedRef.current) return;
|
||||||
|
const el = markerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting || markedRef.current) continue;
|
||||||
|
markedRef.current = true;
|
||||||
|
void markStoryBeatRead(beatId).then((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
setRead(true);
|
||||||
|
observer.disconnect();
|
||||||
|
} else {
|
||||||
|
markedRef.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [beatId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={markerRef}
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
aria-label={read ? "Read" : "Unread"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-2 rounded-full",
|
||||||
|
read ? "bg-emerald-500" : "animate-pulse bg-amber-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">{read ? "Read" : "Unread"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/frontend/story/StoryViewer.tsx
Normal file
116
src/components/frontend/story/StoryViewer.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { BookOpen, X } from "lucide-react";
|
||||||
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
|
||||||
|
import type { StoryBeat } from "@/payload-types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
|
||||||
|
|
||||||
|
export type StoryViewerBeat = {
|
||||||
|
readonly id: number;
|
||||||
|
readonly title: string;
|
||||||
|
readonly chapter: number;
|
||||||
|
readonly dateLabel: string | null;
|
||||||
|
readonly body: StoryBeat["body"];
|
||||||
|
readonly initialRead: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StoryViewer({ beats }: { readonly beats: readonly StoryViewerBeat[] }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
closeButtonRef.current?.focus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (beats.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
aria-label="Open immersive story reader"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<BookOpen className="size-4" />
|
||||||
|
Immersive view
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="story-reader-title"
|
||||||
|
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-black text-white"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 top-0 z-10 flex justify-end p-4 sm:p-6">
|
||||||
|
<Button
|
||||||
|
ref={closeButtonRef}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Exit reader mode"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="pointer-events-auto text-white hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-[100dvh] px-6 pb-24 pt-24 sm:px-8 sm:pt-28">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
<h1 id="story-reader-title" className="sr-only">
|
||||||
|
The Unit Chronicle
|
||||||
|
</h1>
|
||||||
|
<ol className="flex flex-col gap-24">
|
||||||
|
{beats.map((beat) => (
|
||||||
|
<li key={beat.id} className="scroll-mt-24">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-neutral-500">
|
||||||
|
Chapter {beat.chapter}
|
||||||
|
</p>
|
||||||
|
<div className="[&_.text-muted-foreground]:text-neutral-500">
|
||||||
|
<BeatReadMarker beatId={beat.id} initialRead={beat.initialRead} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 font-serif text-3xl font-semibold leading-tight text-white sm:text-4xl">
|
||||||
|
{beat.title}
|
||||||
|
</h2>
|
||||||
|
{beat.dateLabel && (
|
||||||
|
<p className="mt-3 text-sm text-neutral-500">{beat.dateLabel}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-8">
|
||||||
|
<RichText data={beat.body} className={cn(READER_BODY_CLASSES)} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
src/lib/call-for-fire.ts
Normal file
317
src/lib/call-for-fire.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
/**
|
||||||
|
* Call for Fire: artillery observer bracketing game.
|
||||||
|
*
|
||||||
|
* A hostile target sits at a hidden grid position. The observer (player)
|
||||||
|
* receives a spotting round that lands offset from the target, then issues
|
||||||
|
* iterative fire corrections ("add 200, right 100") within a limited number
|
||||||
|
* of rounds and a time limit. Each impact returns an observer report
|
||||||
|
* (bearing + range band) until a round lands within the lethal radius.
|
||||||
|
* Score rewards accuracy of the final round (distance from target) and
|
||||||
|
* efficiency (rounds used, time taken).
|
||||||
|
*
|
||||||
|
* Pure module: client-safe, no Payload imports. Server actions use the caps
|
||||||
|
* here to validate submitted results.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CALL_FOR_FIRE_GRID_MIN = 0;
|
||||||
|
|
||||||
|
/** Absolute cap on rounds per run across all difficulties (validation). */
|
||||||
|
export const MAX_MISSIONS_PER_RUN = 24;
|
||||||
|
|
||||||
|
/** Grid cells per axis bounds across all difficulties (validation). */
|
||||||
|
export const MIN_GRID_SIZE = 8;
|
||||||
|
export const MAX_GRID_SIZE = 20;
|
||||||
|
|
||||||
|
/** Real-world metres represented by one grid cell (correction granularity). */
|
||||||
|
export const METERS_PER_CELL = 50;
|
||||||
|
|
||||||
|
export type CallForFireDifficulty = "patrol" | "checkpoint" | "compound";
|
||||||
|
|
||||||
|
export interface CallForFireDifficultyConfig {
|
||||||
|
label: string;
|
||||||
|
/** Grid is size x size cells. */
|
||||||
|
size: number;
|
||||||
|
/** Impact achieves effect when distance from target (metres) <= lethalRadiusM. */
|
||||||
|
lethalRadiusM: number;
|
||||||
|
/** Expected round count; score bonus for each round under par. */
|
||||||
|
parRounds: number;
|
||||||
|
/** Hard cap on rounds per run (spotting round included). */
|
||||||
|
maxRounds: number;
|
||||||
|
/** Base score awarded on an effective mission. */
|
||||||
|
baseScore: number;
|
||||||
|
/** Score per round under par. */
|
||||||
|
perRoundBonus: number;
|
||||||
|
/** Expected completion time in seconds; 1 score point per second under par. */
|
||||||
|
parSeconds: number;
|
||||||
|
/** Hard time limit in seconds; exceeding it fails the run. */
|
||||||
|
maxSeconds: number;
|
||||||
|
/** Hidden target is placed at least this many cells from any edge. */
|
||||||
|
targetInset: number;
|
||||||
|
/** Spotting round lands this many cells from the target (min). */
|
||||||
|
spotMinCells: number;
|
||||||
|
/** Spotting round lands this many cells from the target (max). */
|
||||||
|
spotMaxCells: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CALL_FOR_FIRE_DIFFICULTIES: Record<
|
||||||
|
CallForFireDifficulty,
|
||||||
|
CallForFireDifficultyConfig
|
||||||
|
> = {
|
||||||
|
patrol: {
|
||||||
|
label: "Patrol Support",
|
||||||
|
size: 10,
|
||||||
|
lethalRadiusM: 75,
|
||||||
|
parRounds: 4,
|
||||||
|
maxRounds: 8,
|
||||||
|
baseScore: 100,
|
||||||
|
perRoundBonus: 25,
|
||||||
|
parSeconds: 90,
|
||||||
|
maxSeconds: 180,
|
||||||
|
targetInset: 3,
|
||||||
|
spotMinCells: 3,
|
||||||
|
spotMaxCells: 6,
|
||||||
|
},
|
||||||
|
checkpoint: {
|
||||||
|
label: "Checkpoint Defence",
|
||||||
|
size: 12,
|
||||||
|
lethalRadiusM: 50,
|
||||||
|
parRounds: 6,
|
||||||
|
maxRounds: 10,
|
||||||
|
baseScore: 300,
|
||||||
|
perRoundBonus: 40,
|
||||||
|
parSeconds: 120,
|
||||||
|
maxSeconds: 240,
|
||||||
|
targetInset: 4,
|
||||||
|
spotMinCells: 4,
|
||||||
|
spotMaxCells: 8,
|
||||||
|
},
|
||||||
|
compound: {
|
||||||
|
label: "Compound Strike",
|
||||||
|
size: 14,
|
||||||
|
lethalRadiusM: 50,
|
||||||
|
parRounds: 8,
|
||||||
|
maxRounds: 12,
|
||||||
|
baseScore: 600,
|
||||||
|
perRoundBonus: 60,
|
||||||
|
parSeconds: 150,
|
||||||
|
maxSeconds: 300,
|
||||||
|
targetInset: 5,
|
||||||
|
spotMinCells: 5,
|
||||||
|
spotMaxCells: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_KEYS: CallForFireDifficulty[] = ["patrol", "checkpoint", "compound"];
|
||||||
|
|
||||||
|
export function isCallForFireDifficulty(value: string): value is CallForFireDifficulty {
|
||||||
|
return DIFFICULTY_KEYS.includes(value as CallForFireDifficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-difficulty profile field for the fewest rounds used to achieve effect. */
|
||||||
|
export const BEST_MISSIONS_FIELDS: Record<CallForFireDifficulty, string> = {
|
||||||
|
patrol: "bestPatrolMissions",
|
||||||
|
checkpoint: "bestCheckpointMissions",
|
||||||
|
compound: "bestCompoundMissions",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GridPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function euclideanDistance(a: GridPoint, b: GridPoint): number {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance from an impact to the target in metres. */
|
||||||
|
export function distanceMeters(target: GridPoint, impact: GridPoint): number {
|
||||||
|
return euclideanDistance(target, impact) * METERS_PER_CELL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when an impact at `impact` achieves effect on a target at `target`. */
|
||||||
|
export function isEffectiveHit(
|
||||||
|
target: GridPoint,
|
||||||
|
impact: GridPoint,
|
||||||
|
lethalRadiusM: number,
|
||||||
|
): boolean {
|
||||||
|
return distanceMeters(target, impact) <= lethalRadiusM;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const;
|
||||||
|
|
||||||
|
export type CompassBearing = (typeof COMPASS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearing from an impact toward the target. Grid y grows downward, so north
|
||||||
|
* is negative dy. Due north is "N"; ties split clockwise from due north.
|
||||||
|
*/
|
||||||
|
export function bearingOf(target: GridPoint, impact: GridPoint): CompassBearing {
|
||||||
|
const dx = target.x - impact.x;
|
||||||
|
const dy = target.y - impact.y;
|
||||||
|
|
||||||
|
if (dx === 0 && dy === 0) {
|
||||||
|
return "N";
|
||||||
|
}
|
||||||
|
|
||||||
|
// atan2 expects y up: negate dy. Angle 0 = north, clockwise positive.
|
||||||
|
const angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
|
||||||
|
const normalized = (angle + 360) % 360;
|
||||||
|
const index = Math.round(normalized / 45) % 8;
|
||||||
|
return COMPASS[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RangeBand = "dangerClose" | "close" | "medium" | "far";
|
||||||
|
|
||||||
|
export const RANGE_BANDS: { band: RangeBand; label: string; maxDistance: number }[] = [
|
||||||
|
{ band: "dangerClose", label: "danger close", maxDistance: 2 },
|
||||||
|
{ band: "close", label: "close", maxDistance: 4 },
|
||||||
|
{ band: "medium", label: "mid", maxDistance: 7 },
|
||||||
|
{ band: "far", label: "far", maxDistance: Number.POSITIVE_INFINITY },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Human range band from an impact to the target (euclidean cells). */
|
||||||
|
export function rangeBandOf(target: GridPoint, impact: GridPoint): RangeBand {
|
||||||
|
const distance = euclideanDistance(target, impact);
|
||||||
|
for (const entry of RANGE_BANDS) {
|
||||||
|
if (distance <= entry.maxDistance) {
|
||||||
|
return entry.band;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "far";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeBandLabel(band: RangeBand): string {
|
||||||
|
return RANGE_BANDS.find((entry) => entry.band === band)?.label ?? "far";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObserverReport {
|
||||||
|
bearing: CompassBearing;
|
||||||
|
band: RangeBand;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radio-style observer report for a missed impact. */
|
||||||
|
export function observerReport(target: GridPoint, impact: GridPoint): ObserverReport {
|
||||||
|
const bearing = bearingOf(target, impact);
|
||||||
|
const band = rangeBandOf(target, impact);
|
||||||
|
return {
|
||||||
|
bearing,
|
||||||
|
band,
|
||||||
|
message: `Shot, over. Target bearing ${bearing}, range ${rangeBandLabel(band)}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fire correction in metres: range (+ADD / -DROP) and deflection (+RIGHT / -LEFT). */
|
||||||
|
export interface Correction {
|
||||||
|
rangeM: number;
|
||||||
|
deflectionM: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a grid point into the size x size board. */
|
||||||
|
export function clampPoint(point: GridPoint, size: number): GridPoint {
|
||||||
|
return {
|
||||||
|
x: Math.min(size - 1, Math.max(0, point.x)),
|
||||||
|
y: Math.min(size - 1, Math.max(0, point.y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a correction to the last impact. ADD moves the impact further from
|
||||||
|
* the observer (north, -y); RIGHT shifts it east (+x). Metres round to the
|
||||||
|
* nearest cell and the result clamps to the board.
|
||||||
|
*/
|
||||||
|
export function applyCorrection(
|
||||||
|
lastImpact: GridPoint,
|
||||||
|
correction: Correction,
|
||||||
|
size: number,
|
||||||
|
): GridPoint {
|
||||||
|
const dx = Math.round(correction.deflectionM / METERS_PER_CELL);
|
||||||
|
const dy = -Math.round(correction.rangeM / METERS_PER_CELL);
|
||||||
|
return clampPoint({ x: lastImpact.x + dx, y: lastImpact.y + dy }, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score for a run: base, plus a bonus per round under par, plus a bonus per
|
||||||
|
* second under par, plus a bonus for final-round accuracy (metres inside the
|
||||||
|
* lethal radius). Losses score 0.
|
||||||
|
*/
|
||||||
|
export function calculateScore(
|
||||||
|
difficulty: CallForFireDifficulty,
|
||||||
|
roundsUsed: number,
|
||||||
|
seconds: number,
|
||||||
|
finalDistanceM: number,
|
||||||
|
won: boolean,
|
||||||
|
): number {
|
||||||
|
if (!won) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const config = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||||
|
const roundsBonus = Math.max(0, config.parRounds - roundsUsed) * config.perRoundBonus;
|
||||||
|
const timeBonus = Math.max(0, config.parSeconds - seconds);
|
||||||
|
const accuracyBonus = Math.max(0, Math.round(config.lethalRadiusM - finalDistanceM));
|
||||||
|
return config.baseScore + roundsBonus + timeBonus + accuracyBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XP for a run: 1 XP per 50 score, minimum 1 on a win. Losses earn nothing. */
|
||||||
|
export function calculateXp(score: number, won: boolean): number {
|
||||||
|
if (!won) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.floor(score / 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uniform random point in the interior region reserved for the hidden target. */
|
||||||
|
export function randomTargetPosition(
|
||||||
|
difficulty: CallForFireDifficulty,
|
||||||
|
rng: () => number = Math.random,
|
||||||
|
): GridPoint {
|
||||||
|
const { size, targetInset } = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||||
|
const min = targetInset;
|
||||||
|
const max = size - 1 - targetInset;
|
||||||
|
return {
|
||||||
|
x: min + Math.floor(rng() * (max - min + 1)),
|
||||||
|
y: min + Math.floor(rng() * (max - min + 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Random compass direction; magnitude uniform in [min, max] cells. */
|
||||||
|
export function randomSpottingOffset(
|
||||||
|
difficulty: CallForFireDifficulty,
|
||||||
|
rng: () => number = Math.random,
|
||||||
|
): GridPoint {
|
||||||
|
const { spotMinCells, spotMaxCells } = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||||
|
const magnitude = spotMinCells + rng() * (spotMaxCells - spotMinCells);
|
||||||
|
const angle = rng() * Math.PI * 2;
|
||||||
|
return {
|
||||||
|
x: Math.round(Math.cos(angle) * magnitude),
|
||||||
|
y: Math.round(Math.sin(angle) * magnitude),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FireScenario {
|
||||||
|
/** Hidden target position. */
|
||||||
|
target: GridPoint;
|
||||||
|
/** Where the initial spotting round lands. */
|
||||||
|
spotting: GridPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a randomized scenario: hidden target plus a spotting round landing
|
||||||
|
* a random distance and direction from it, clamped to the board.
|
||||||
|
*/
|
||||||
|
export function createScenario(
|
||||||
|
difficulty: CallForFireDifficulty,
|
||||||
|
rng: () => number = Math.random,
|
||||||
|
): FireScenario {
|
||||||
|
const { size } = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||||
|
const target = randomTargetPosition(difficulty, rng);
|
||||||
|
const offset = randomSpottingOffset(difficulty, rng);
|
||||||
|
const spotting = clampPoint({ x: target.x + offset.x, y: target.y + offset.y }, size);
|
||||||
|
return { target, spotting };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grid designation like "E5" for display (column letter + 1-based row). */
|
||||||
|
export function gridDesignation(point: GridPoint): string {
|
||||||
|
const column = String.fromCharCode(65 + (point.x % 26));
|
||||||
|
return `${column}${point.y + 1}`;
|
||||||
|
}
|
||||||
465
src/lib/land-nav.ts
Normal file
465
src/lib/land-nav.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
/**
|
||||||
|
* Land Navigation minigame rules.
|
||||||
|
*
|
||||||
|
* The player navigates a 10x10 grid map (100 m cells) through a sequence of
|
||||||
|
* navigation legs. Three leg types appear on a course:
|
||||||
|
*
|
||||||
|
* - "locate": click the map cell that contains a named feature (or that a
|
||||||
|
* 6-figure grid reference points at).
|
||||||
|
* - "azimuth": given a start cell, an azimuth in degrees and a distance in
|
||||||
|
* metres, click the destination cell.
|
||||||
|
* - "declination": like azimuth, but the compass reads a magnetic azimuth
|
||||||
|
* that must first be converted with the local declination (east is
|
||||||
|
* subtracted, west is added). The map shows no ray for these legs.
|
||||||
|
* - "identify": given a 6-figure grid reference, pick which named feature
|
||||||
|
* sits there from four options.
|
||||||
|
*
|
||||||
|
* A course is passed when at least `passLegs` of its legs are answered
|
||||||
|
* correctly before the time limit expires.
|
||||||
|
*
|
||||||
|
* Pure module: client-safe, no Payload imports. Server actions use the caps
|
||||||
|
* here to validate submitted results.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LAND_NAV_DIFFICULTY_KEYS = ["waypoint", "route", "expedition"] as const;
|
||||||
|
|
||||||
|
export type LandNavDifficulty = (typeof LAND_NAV_DIFFICULTY_KEYS)[number];
|
||||||
|
|
||||||
|
export function isLandNavDifficulty(value: unknown): value is LandNavDifficulty {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && (LAND_NAV_DIFFICULTY_KEYS as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandNavDifficultyConfig {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
legs: number;
|
||||||
|
passLegs: number;
|
||||||
|
features: number;
|
||||||
|
parSeconds: number;
|
||||||
|
maxSeconds: number;
|
||||||
|
baseScore: number;
|
||||||
|
perLegBonus: number;
|
||||||
|
perSecondBonus: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LAND_NAV_DIFFICULTIES: Record<LandNavDifficulty, LandNavDifficultyConfig> = {
|
||||||
|
waypoint: {
|
||||||
|
label: "Waypoint",
|
||||||
|
description: "A short orientation hop. 4 legs, 3 must be correct.",
|
||||||
|
legs: 4,
|
||||||
|
passLegs: 3,
|
||||||
|
features: 6,
|
||||||
|
parSeconds: 120,
|
||||||
|
maxSeconds: 300,
|
||||||
|
baseScore: 200,
|
||||||
|
perLegBonus: 40,
|
||||||
|
perSecondBonus: 1,
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
label: "Route",
|
||||||
|
description: "A full patrol route. 6 legs, 5 must be correct.",
|
||||||
|
legs: 6,
|
||||||
|
passLegs: 5,
|
||||||
|
features: 9,
|
||||||
|
parSeconds: 180,
|
||||||
|
maxSeconds: 420,
|
||||||
|
baseScore: 320,
|
||||||
|
perLegBonus: 50,
|
||||||
|
perSecondBonus: 1,
|
||||||
|
},
|
||||||
|
expedition: {
|
||||||
|
label: "Expedition",
|
||||||
|
description: "A long-distance cross-country leg set. 8 legs, 6 must be correct.",
|
||||||
|
legs: 8,
|
||||||
|
passLegs: 6,
|
||||||
|
features: 12,
|
||||||
|
parSeconds: 240,
|
||||||
|
maxSeconds: 540,
|
||||||
|
baseScore: 460,
|
||||||
|
perLegBonus: 60,
|
||||||
|
perSecondBonus: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GRID_COLS = 10;
|
||||||
|
export const GRID_ROWS = 10;
|
||||||
|
export const METERS_PER_CELL = 100;
|
||||||
|
|
||||||
|
/** Server-side validation caps. */
|
||||||
|
export const MAX_LEGS = LAND_NAV_DIFFICULTIES.expedition.legs;
|
||||||
|
export const MAX_SECONDS = LAND_NAV_DIFFICULTIES.expedition.maxSeconds;
|
||||||
|
export const MAX_FEATURES = LAND_NAV_DIFFICULTIES.expedition.features;
|
||||||
|
export const MAX_AZIMUTH = 359;
|
||||||
|
export const MAX_DISTANCE_CELLS = 4;
|
||||||
|
|
||||||
|
export interface Cell {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeatureType = "hill" | "lake" | "ruins" | "tower" | "bridge" | "copse";
|
||||||
|
|
||||||
|
export interface MapFeature extends Cell {
|
||||||
|
id: string;
|
||||||
|
type: FeatureType;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegKind = "locate" | "azimuth" | "declination" | "identify";
|
||||||
|
|
||||||
|
export interface LandNavLeg {
|
||||||
|
kind: LegKind;
|
||||||
|
/** Human-readable instruction shown above the map. */
|
||||||
|
prompt: string;
|
||||||
|
/** Correct answer cell (locate, azimuth and declination legs). */
|
||||||
|
answerCell: Cell;
|
||||||
|
/** Correct answer feature id (identify legs). */
|
||||||
|
answerFeatureId: string;
|
||||||
|
/** Options for identify legs, in display order. */
|
||||||
|
options?: string[];
|
||||||
|
/** Azimuth/declination legs: start cell, azimuth degrees (0 = north, 90 = east), distance. */
|
||||||
|
startCell?: Cell;
|
||||||
|
azimuth?: number;
|
||||||
|
distanceM?: number;
|
||||||
|
/** Declination legs: magnetic-to-grid correction in degrees, positive = east. */
|
||||||
|
declination?: number;
|
||||||
|
/** Locate/declination legs: the referenced feature name, grid reference or magnetic azimuth. */
|
||||||
|
reference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandNavCourse {
|
||||||
|
difficulty: LandNavDifficulty;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
easting0: number;
|
||||||
|
northing0: number;
|
||||||
|
features: MapFeature[];
|
||||||
|
legs: LandNavLeg[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_TYPE_NAMES: Record<FeatureType, string[]> = {
|
||||||
|
hill: ["Hill", "Knoll", "Ridge"],
|
||||||
|
lake: ["Lake", "Tarn", "Pond"],
|
||||||
|
ruins: ["Ruins of", "Old Works of", "Ruined Post at"],
|
||||||
|
tower: ["Tower", "Signal Mast", "Watchtower"],
|
||||||
|
bridge: ["Bridge", "Ford", "Weir"],
|
||||||
|
copse: ["Copse", "Wood", "Thicket"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_LABELS: Record<FeatureType, string> = {
|
||||||
|
hill: "Hill",
|
||||||
|
lake: "Water",
|
||||||
|
ruins: "Ruins",
|
||||||
|
tower: "Tower",
|
||||||
|
bridge: "Bridge",
|
||||||
|
copse: "Wood",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function featureTypeLabel(type: FeatureType): string {
|
||||||
|
return FEATURE_LABELS[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad3(value: number): string {
|
||||||
|
return String(Math.max(0, Math.min(999, value))).padStart(3, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 6-figure grid reference (100 m precision) for a cell on this course. */
|
||||||
|
export function gridRef(course: Pick<LandNavCourse, "easting0" | "northing0">, cell: Cell): string {
|
||||||
|
return `${pad3(course.easting0 + cell.col)}${pad3(course.northing0 + cell.row)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameCell(a: Cell, b: Cell): boolean {
|
||||||
|
return a.col === b.col && a.row === b.row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inBounds(cell: Cell): boolean {
|
||||||
|
return cell.col >= 0 && cell.col < GRID_COLS && cell.row >= 0 && cell.row < GRID_ROWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>(list: readonly T[], rng: () => number): T {
|
||||||
|
return list[Math.floor(rng() * list.length)] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffled<T>(list: readonly T[], rng: () => number): T[] {
|
||||||
|
const copy = [...list];
|
||||||
|
for (let i = copy.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
const tmp = copy[i] as T;
|
||||||
|
copy[i] = copy[j] as T;
|
||||||
|
copy[j] = tmp;
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomName(type: FeatureType, rng: () => number): string {
|
||||||
|
const prefixes = FEATURE_TYPE_NAMES[type];
|
||||||
|
const prefix = pick(prefixes, rng);
|
||||||
|
const number = 100 + Math.floor(rng() * 800);
|
||||||
|
if (type === "hill" || type === "lake") {
|
||||||
|
return `${prefix} ${number}`;
|
||||||
|
}
|
||||||
|
if (type === "ruins") {
|
||||||
|
return `${prefix} ${pick(["Kessel", "Vorna", "Tallmark", "Brantis", "Osten"], rng)}`;
|
||||||
|
}
|
||||||
|
return `${prefix} ${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a full course: sheet origin, named features and a mixed sequence
|
||||||
|
* of navigation legs. `rng` is injectable for deterministic tests.
|
||||||
|
*/
|
||||||
|
export function createCourse(
|
||||||
|
difficulty: LandNavDifficulty,
|
||||||
|
rng: () => number = Math.random,
|
||||||
|
): LandNavCourse {
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[difficulty];
|
||||||
|
const easting0 = 20 + Math.floor(rng() * 60);
|
||||||
|
const northing0 = 20 + Math.floor(rng() * 60);
|
||||||
|
|
||||||
|
const types = shuffled(
|
||||||
|
["hill", "lake", "ruins", "tower", "bridge", "copse"] as FeatureType[],
|
||||||
|
rng,
|
||||||
|
);
|
||||||
|
|
||||||
|
const usedCells = new Set<string>();
|
||||||
|
const cellKey = (cell: Cell) => `${cell.col},${cell.row}`;
|
||||||
|
const nextCell = (): Cell => {
|
||||||
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
|
const cell = { col: Math.floor(rng() * GRID_COLS), row: Math.floor(rng() * GRID_ROWS) };
|
||||||
|
if (!usedCells.has(cellKey(cell))) {
|
||||||
|
usedCells.add(cellKey(cell));
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic fallback: linear probe.
|
||||||
|
for (let col = 0; col < GRID_COLS; col += 1) {
|
||||||
|
for (let row = 0; row < GRID_ROWS; row += 1) {
|
||||||
|
const cell = { col, row };
|
||||||
|
if (!usedCells.has(cellKey(cell))) {
|
||||||
|
usedCells.add(cellKey(cell));
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { col: 0, row: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const features: MapFeature[] = [];
|
||||||
|
for (let i = 0; i < config.features; i += 1) {
|
||||||
|
const type = types[i % types.length] as FeatureType;
|
||||||
|
const cell = nextCell();
|
||||||
|
features.push({
|
||||||
|
id: `f${i}`,
|
||||||
|
type,
|
||||||
|
name: randomName(type, rng),
|
||||||
|
...cell,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const course: LandNavCourse = {
|
||||||
|
difficulty,
|
||||||
|
cols: GRID_COLS,
|
||||||
|
rows: GRID_ROWS,
|
||||||
|
easting0,
|
||||||
|
northing0,
|
||||||
|
features,
|
||||||
|
legs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the leg sequence: rotate through kinds so every run mixes tasks.
|
||||||
|
const kindCycle: LegKind[] = shuffled(["locate", "azimuth", "identify"], rng);
|
||||||
|
for (let i = 0; i < config.legs; i += 1) {
|
||||||
|
let kind = kindCycle[i % kindCycle.length] as LegKind;
|
||||||
|
if (kind === "azimuth" && difficulty !== "waypoint" && rng() < 0.5) {
|
||||||
|
kind = "declination";
|
||||||
|
}
|
||||||
|
course.legs.push(createLeg(course, kind, rng));
|
||||||
|
}
|
||||||
|
|
||||||
|
return course;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLeg(course: LandNavCourse, kind: LegKind, rng: () => number): LandNavLeg {
|
||||||
|
if (kind === "azimuth") {
|
||||||
|
return createAzimuthLeg(course, rng);
|
||||||
|
}
|
||||||
|
if (kind === "declination") {
|
||||||
|
return createDeclinationLeg(course, rng);
|
||||||
|
}
|
||||||
|
if (kind === "identify") {
|
||||||
|
return createIdentifyLeg(course, rng);
|
||||||
|
}
|
||||||
|
return createLocateLeg(course, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocateLeg(course: LandNavCourse, rng: () => number): LandNavLeg {
|
||||||
|
const feature = pick(course.features, rng);
|
||||||
|
const byName = rng() < 0.5;
|
||||||
|
return {
|
||||||
|
kind: "locate",
|
||||||
|
prompt: byName
|
||||||
|
? `Click the grid square containing ${feature.name}.`
|
||||||
|
: `Click the grid square at ${gridRef(course, feature)}.`,
|
||||||
|
answerCell: { col: feature.col, row: feature.row },
|
||||||
|
answerFeatureId: feature.id,
|
||||||
|
reference: byName ? feature.name : gridRef(course, feature),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAzimuthLeg(course: LandNavCourse, rng: () => number): LandNavLeg {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
const distanceCells = 2 + Math.floor(rng() * (MAX_DISTANCE_CELLS - 1));
|
||||||
|
const azimuth = Math.floor(rng() * 360);
|
||||||
|
const dest = {
|
||||||
|
col: Math.floor(rng() * GRID_COLS),
|
||||||
|
row: Math.floor(rng() * GRID_ROWS),
|
||||||
|
};
|
||||||
|
const radians = (azimuth * Math.PI) / 180;
|
||||||
|
const start = {
|
||||||
|
col: dest.col - Math.round(Math.sin(radians) * distanceCells),
|
||||||
|
row: dest.row + Math.round(Math.cos(radians) * distanceCells),
|
||||||
|
};
|
||||||
|
if (inBounds(start) && inBounds(dest) && !sameCell(start, dest)) {
|
||||||
|
return {
|
||||||
|
kind: "azimuth",
|
||||||
|
prompt: `From ${gridRef(course, start)}, travel ${azimuth}\u00b0 for ${
|
||||||
|
distanceCells * METERS_PER_CELL
|
||||||
|
} m. Click your destination grid square.`,
|
||||||
|
answerCell: dest,
|
||||||
|
answerFeatureId: "",
|
||||||
|
startCell: start,
|
||||||
|
azimuth,
|
||||||
|
distanceM: distanceCells * METERS_PER_CELL,
|
||||||
|
reference: gridRef(course, start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic fallback: straight north leg from a safe start.
|
||||||
|
const dest = { col: 4, row: 2 };
|
||||||
|
const start = { col: 4, row: 6 };
|
||||||
|
return {
|
||||||
|
kind: "azimuth",
|
||||||
|
prompt: `From ${gridRef(course, start)}, travel 0\u00b0 for ${
|
||||||
|
4 * METERS_PER_CELL
|
||||||
|
} m. Click your destination grid square.`,
|
||||||
|
answerCell: dest,
|
||||||
|
answerFeatureId: "",
|
||||||
|
startCell: start,
|
||||||
|
azimuth: 0,
|
||||||
|
distanceM: 4 * METERS_PER_CELL,
|
||||||
|
reference: gridRef(course, start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeclinationLeg(course: LandNavCourse, rng: () => number): LandNavLeg {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
const distanceCells = 2 + Math.floor(rng() * (MAX_DISTANCE_CELLS - 1));
|
||||||
|
const declination = (1 + Math.floor(rng() * 19)) * (rng() < 0.5 ? -1 : 1);
|
||||||
|
const magneticAzimuth = Math.floor(rng() * 360);
|
||||||
|
const gridAzimuth = (((magneticAzimuth - declination) % 360) + 360) % 360;
|
||||||
|
const dest = {
|
||||||
|
col: Math.floor(rng() * GRID_COLS),
|
||||||
|
row: Math.floor(rng() * GRID_ROWS),
|
||||||
|
};
|
||||||
|
const radians = (gridAzimuth * Math.PI) / 180;
|
||||||
|
const start = {
|
||||||
|
col: dest.col - Math.round(Math.sin(radians) * distanceCells),
|
||||||
|
row: dest.row + Math.round(Math.cos(radians) * distanceCells),
|
||||||
|
};
|
||||||
|
if (inBounds(start) && inBounds(dest) && !sameCell(start, dest)) {
|
||||||
|
const direction = declination > 0 ? "east" : "west";
|
||||||
|
return {
|
||||||
|
kind: "declination",
|
||||||
|
prompt: `From ${gridRef(course, start)}, your compass reads a magnetic azimuth of ${magneticAzimuth}\u00b0. Local declination is ${Math.abs(declination)}\u00b0 ${direction}. Convert to a grid azimuth and travel ${
|
||||||
|
distanceCells * METERS_PER_CELL
|
||||||
|
} m. Click your destination grid square.`,
|
||||||
|
answerCell: dest,
|
||||||
|
answerFeatureId: "",
|
||||||
|
startCell: start,
|
||||||
|
azimuth: gridAzimuth,
|
||||||
|
distanceM: distanceCells * METERS_PER_CELL,
|
||||||
|
declination,
|
||||||
|
reference: gridRef(course, start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dest = { col: 5, row: 3 };
|
||||||
|
const start = { col: 5, row: 6 };
|
||||||
|
return {
|
||||||
|
kind: "declination",
|
||||||
|
prompt: `From ${gridRef(course, start)}, your compass reads a magnetic azimuth of 10\u00b0. Local declination is 5\u00b0 east. Convert to a grid azimuth and travel ${
|
||||||
|
3 * METERS_PER_CELL
|
||||||
|
} m. Click your destination grid square.`,
|
||||||
|
answerCell: dest,
|
||||||
|
answerFeatureId: "",
|
||||||
|
startCell: start,
|
||||||
|
azimuth: 5,
|
||||||
|
distanceM: 3 * METERS_PER_CELL,
|
||||||
|
declination: 5,
|
||||||
|
reference: gridRef(course, start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdentifyLeg(course: LandNavCourse, rng: () => number): LandNavLeg {
|
||||||
|
const answer = pick(course.features, rng);
|
||||||
|
const distractors = shuffled(
|
||||||
|
course.features.filter((f) => f.id !== answer.id),
|
||||||
|
rng,
|
||||||
|
).slice(0, 3);
|
||||||
|
const options = shuffled([answer, ...distractors], rng).map((f) => f.name);
|
||||||
|
return {
|
||||||
|
kind: "identify",
|
||||||
|
prompt: `Which feature sits at grid ${gridRef(course, answer)}?`,
|
||||||
|
answerCell: { col: answer.col, row: answer.row },
|
||||||
|
answerFeatureId: answer.id,
|
||||||
|
options,
|
||||||
|
reference: gridRef(course, answer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the clicked cell answers the leg. */
|
||||||
|
export function isLegCorrect(leg: LandNavLeg, cell: Cell): boolean {
|
||||||
|
return sameCell(leg.answerCell, cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the chosen option name answers an identify leg. */
|
||||||
|
export function isOptionCorrect(leg: LandNavLeg, course: LandNavCourse, option: string): boolean {
|
||||||
|
if (leg.kind !== "identify") return false;
|
||||||
|
const feature = course.features.find((f) => f.id === leg.answerFeatureId);
|
||||||
|
return feature !== undefined && feature.name === option;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score for a finished run. Passing runs earn the base plus per-leg and time
|
||||||
|
* bonuses; failed runs earn a small participation score per correct leg.
|
||||||
|
*/
|
||||||
|
export function calculateScore(
|
||||||
|
difficulty: LandNavDifficulty,
|
||||||
|
legsCorrect: number,
|
||||||
|
seconds: number,
|
||||||
|
won: boolean,
|
||||||
|
): number {
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[difficulty];
|
||||||
|
if (!won) {
|
||||||
|
return legsCorrect * 10;
|
||||||
|
}
|
||||||
|
const timeBonus = Math.max(0, Math.min(config.parSeconds / 2, config.parSeconds - seconds));
|
||||||
|
return Math.round(
|
||||||
|
config.baseScore + legsCorrect * config.perLegBonus + timeBonus * config.perSecondBonus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XP awarded for a run: 1 XP per 50 score on a pass, 0 on a fail. */
|
||||||
|
export function calculateXp(score: number, won: boolean): number {
|
||||||
|
if (!won) return 0;
|
||||||
|
return Math.max(1, Math.floor(score / 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-difficulty fastest-time stat field on the profile. */
|
||||||
|
export const BEST_TIME_FIELDS: Record<LandNavDifficulty, string> = {
|
||||||
|
waypoint: "bestWaypointSeconds",
|
||||||
|
route: "bestRouteSeconds",
|
||||||
|
expedition: "bestExpeditionSeconds",
|
||||||
|
};
|
||||||
130
src/lib/story/index.ts
Normal file
130
src/lib/story/index.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { StoryBeatState, User } from "@/payload-types";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { STORY_REACTIONS, type StoryReaction } from "./reactions";
|
||||||
|
|
||||||
|
export { STORY_REACTIONS, REACTION_LABELS, type StoryReaction } from "./reactions";
|
||||||
|
|
||||||
|
async function findBeatState(
|
||||||
|
payload: Payload,
|
||||||
|
userId: number,
|
||||||
|
beatId: number,
|
||||||
|
): Promise<StoryBeatState | null> {
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: "story-beat-states",
|
||||||
|
where: {
|
||||||
|
and: [{ user: { equals: userId } }, { beat: { equals: beatId } }],
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return (result.docs[0] as StoryBeatState | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateBeatState(
|
||||||
|
payload: Payload,
|
||||||
|
userId: number,
|
||||||
|
beatId: number,
|
||||||
|
): Promise<StoryBeatState> {
|
||||||
|
const existing = await findBeatState(payload, userId, beatId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = await payload.create({
|
||||||
|
collection: "story-beat-states",
|
||||||
|
data: {
|
||||||
|
user: userId,
|
||||||
|
beat: beatId,
|
||||||
|
readAt: null,
|
||||||
|
reaction: null,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
return created as StoryBeatState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a story beat as read for the given user. The beat's read access gate
|
||||||
|
* (published only, staff see all) is enforced via overrideAccess: false, so
|
||||||
|
* marking a draft read as a normal user throws.
|
||||||
|
*
|
||||||
|
* Idempotent: a second call returns the existing state without changing readAt.
|
||||||
|
*/
|
||||||
|
export async function markBeatRead(
|
||||||
|
payload: Payload,
|
||||||
|
{ user, beatId }: { user: User; beatId: number },
|
||||||
|
): Promise<StoryBeatState> {
|
||||||
|
const beat = await payload.findByID({
|
||||||
|
collection: "story-beats",
|
||||||
|
id: beatId,
|
||||||
|
user,
|
||||||
|
overrideAccess: false,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
const state = await findOrCreateBeatState(payload, user.id, beatId);
|
||||||
|
if (state.readAt) return state;
|
||||||
|
const readAt = new Date().toISOString();
|
||||||
|
const updated = await payload.update({
|
||||||
|
collection: "story-beat-states",
|
||||||
|
id: state.id,
|
||||||
|
data: { readAt },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
emitGameEvent(payload, {
|
||||||
|
type: EventTypes.StoryBeatRead,
|
||||||
|
message: `${user.displayName || user.username} read the story beat "${beat.title}"`,
|
||||||
|
actor: user.id,
|
||||||
|
targetCollection: "story-beats",
|
||||||
|
targetId: beat.id,
|
||||||
|
data: { beatId: beat.id },
|
||||||
|
});
|
||||||
|
return updated as StoryBeatState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set (or toggle off) the user's reaction on a story beat. Passing the user's
|
||||||
|
* current reaction clears it; passing a different one replaces it.
|
||||||
|
*
|
||||||
|
* The beat's read access gate is enforced via overrideAccess: false.
|
||||||
|
*/
|
||||||
|
export async function setBeatReaction(
|
||||||
|
payload: Payload,
|
||||||
|
{
|
||||||
|
user,
|
||||||
|
beatId,
|
||||||
|
reaction,
|
||||||
|
}: { user: User; beatId: number; reaction: StoryReaction | null },
|
||||||
|
): Promise<{ reaction: StoryReaction | null }> {
|
||||||
|
if (reaction !== null && !STORY_REACTIONS.includes(reaction)) {
|
||||||
|
throw new Error("Invalid reaction.");
|
||||||
|
}
|
||||||
|
const beat = await payload.findByID({
|
||||||
|
collection: "story-beats",
|
||||||
|
id: beatId,
|
||||||
|
user,
|
||||||
|
overrideAccess: false,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
const state = await findOrCreateBeatState(payload, user.id, beatId);
|
||||||
|
const next: StoryReaction | null = state.reaction === reaction ? null : reaction;
|
||||||
|
await payload.update({
|
||||||
|
collection: "story-beat-states",
|
||||||
|
id: state.id,
|
||||||
|
data: { reaction: next },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
if (next !== null) {
|
||||||
|
emitGameEvent(payload, {
|
||||||
|
type: EventTypes.StoryBeatReact,
|
||||||
|
message: `${user.displayName || user.username} reacted ${next} to the story beat "${beat.title}"`,
|
||||||
|
actor: user.id,
|
||||||
|
targetCollection: "story-beats",
|
||||||
|
targetId: beat.id,
|
||||||
|
data: { beatId: beat.id, reaction: next },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { reaction: next };
|
||||||
|
}
|
||||||
9
src/lib/story/reactions.ts
Normal file
9
src/lib/story/reactions.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const STORY_REACTIONS = ["like", "heart", "salute"] as const;
|
||||||
|
|
||||||
|
export type StoryReaction = (typeof STORY_REACTIONS)[number];
|
||||||
|
|
||||||
|
export const REACTION_LABELS: Record<StoryReaction, string> = {
|
||||||
|
like: "Like",
|
||||||
|
heart: "Heart",
|
||||||
|
salute: "Salute",
|
||||||
|
};
|
||||||
44
src/lib/story/richtextClasses.ts
Normal file
44
src/lib/story/richtextClasses.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export const BEAT_BODY_CLASSES = [
|
||||||
|
"max-w-none",
|
||||||
|
"[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold",
|
||||||
|
"[&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-lg [&_h2]:font-semibold",
|
||||||
|
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold",
|
||||||
|
"[&_h4]:mt-4 [&_h4]:mb-2 [&_h4]:text-sm [&_h4]:font-semibold",
|
||||||
|
"[&_p]:mb-4 [&_p]:leading-7",
|
||||||
|
"[&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6",
|
||||||
|
"[&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6",
|
||||||
|
"[&_li]:mb-1",
|
||||||
|
"[&_blockquote]:mb-4 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/50 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground",
|
||||||
|
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4",
|
||||||
|
"[&_strong]:font-semibold",
|
||||||
|
"[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-sm",
|
||||||
|
"[&_pre]:mb-4 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-muted [&_pre]:p-4 [&_pre]:text-sm",
|
||||||
|
"[&_hr]:my-6 [&_hr]:border-border",
|
||||||
|
"[&_img]:my-4 [&_img]:rounded",
|
||||||
|
"[&_table]:mb-4 [&_table]:w-full [&_table]:text-sm",
|
||||||
|
"[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left",
|
||||||
|
"[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const READER_BODY_CLASSES = [
|
||||||
|
"text-neutral-200",
|
||||||
|
"max-w-none",
|
||||||
|
"[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-white",
|
||||||
|
"[&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-white",
|
||||||
|
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-white",
|
||||||
|
"[&_h4]:mt-4 [&_h4]:mb-2 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-neutral-100",
|
||||||
|
"[&_p]:mb-4 [&_p]:leading-8",
|
||||||
|
"[&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6",
|
||||||
|
"[&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6",
|
||||||
|
"[&_li]:mb-1 [&_li]:marker:text-neutral-500",
|
||||||
|
"[&_blockquote]:mb-4 [&_blockquote]:border-l-2 [&_blockquote]:border-white/30 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-neutral-300",
|
||||||
|
"[&_a]:text-white [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-neutral-300",
|
||||||
|
"[&_strong]:font-semibold [&_strong]:text-white",
|
||||||
|
"[&_code]:rounded [&_code]:bg-neutral-900 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-sm [&_code]:text-neutral-100",
|
||||||
|
"[&_pre]:mb-4 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-4 [&_pre]:text-sm [&_pre]:text-neutral-100",
|
||||||
|
"[&_hr]:my-6 [&_hr]:border-white/20",
|
||||||
|
"[&_img]:my-4 [&_img]:rounded",
|
||||||
|
"[&_table]:mb-4 [&_table]:w-full [&_table]:text-sm",
|
||||||
|
"[&_th]:border [&_th]:border-white/20 [&_th]:bg-neutral-900 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:text-white",
|
||||||
|
"[&_td]:border [&_td]:border-white/20 [&_td]:px-2 [&_td]:py-1",
|
||||||
|
] as const;
|
||||||
23624
src/migrations/20260907_120754_add_story_beats.json
Normal file
23624
src/migrations/20260907_120754_add_story_beats.json
Normal file
File diff suppressed because it is too large
Load diff
69
src/migrations/20260907_120754_add_story_beats.ts
Normal file
69
src/migrations/20260907_120754_add_story_beats.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TYPE "public"."enum_story_beats_status" AS ENUM('draft', 'published');
|
||||||
|
CREATE TYPE "public"."enum_story_beat_states_reaction" AS ENUM('like', 'heart', 'salute');
|
||||||
|
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'story-beats';
|
||||||
|
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'story-beat-states';
|
||||||
|
CREATE TABLE "story_beats" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"occurred_at" timestamp(3) with time zone,
|
||||||
|
"order" numeric,
|
||||||
|
"status" "enum_story_beats_status" DEFAULT 'draft' NOT NULL,
|
||||||
|
"body" varchar NOT NULL,
|
||||||
|
"mission_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "story_beat_states" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"beat_id" integer NOT NULL,
|
||||||
|
"read_at" timestamp(3) with time zone,
|
||||||
|
"reaction" "enum_story_beat_states_reaction",
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "story_beats_id" integer;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "story_beat_states_id" integer;
|
||||||
|
ALTER TABLE "story_beats" ADD CONSTRAINT "story_beats_mission_id_missions_id_fk" FOREIGN KEY ("mission_id") REFERENCES "public"."missions"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "story_beat_states" ADD CONSTRAINT "story_beat_states_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "story_beat_states" ADD CONSTRAINT "story_beat_states_beat_id_story_beats_id_fk" FOREIGN KEY ("beat_id") REFERENCES "public"."story_beats"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
CREATE INDEX "story_beats_mission_idx" ON "story_beats" USING btree ("mission_id");
|
||||||
|
CREATE INDEX "story_beats_updated_at_idx" ON "story_beats" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "story_beats_created_at_idx" ON "story_beats" USING btree ("created_at");
|
||||||
|
CREATE INDEX "story_beat_states_user_idx" ON "story_beat_states" USING btree ("user_id");
|
||||||
|
CREATE INDEX "story_beat_states_beat_idx" ON "story_beat_states" USING btree ("beat_id");
|
||||||
|
CREATE INDEX "story_beat_states_updated_at_idx" ON "story_beat_states" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "story_beat_states_created_at_idx" ON "story_beat_states" USING btree ("created_at");
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_story_beats_fk" FOREIGN KEY ("story_beats_id") REFERENCES "public"."story_beats"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_story_beat_states_fk" FOREIGN KEY ("story_beat_states_id") REFERENCES "public"."story_beat_states"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_story_beats_id_idx" ON "payload_locked_documents_rels" USING btree ("story_beats_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_story_beat_states_id_idx" ON "payload_locked_documents_rels" USING btree ("story_beat_states_id");`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "story_beats" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "story_beat_states" DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP TABLE "story_beats" CASCADE;
|
||||||
|
DROP TABLE "story_beat_states" CASCADE;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_story_beats_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_story_beat_states_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
||||||
|
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||||
|
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'market-state', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers', 'voice-scripts', 'voice-submissions');
|
||||||
|
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
||||||
|
DROP INDEX "payload_locked_documents_rels_story_beats_id_idx";
|
||||||
|
DROP INDEX "payload_locked_documents_rels_story_beat_states_id_idx";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "story_beats_id";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "story_beat_states_id";
|
||||||
|
DROP TYPE "public"."enum_story_beats_status";
|
||||||
|
DROP TYPE "public"."enum_story_beat_states_reaction";`)
|
||||||
|
}
|
||||||
23639
src/migrations/20260907_124024_convert_story_beats_body_to_lexical.json
Normal file
23639
src/migrations/20260907_124024_convert_story_beats_body_to_lexical.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,93 @@
|
||||||
|
import {
|
||||||
|
convertLexicalToMarkdown,
|
||||||
|
convertMarkdownToLexical,
|
||||||
|
sanitizeServerEditorConfig,
|
||||||
|
} from "@payloadcms/richtext-lexical";
|
||||||
|
import type {
|
||||||
|
FeatureProviderServer,
|
||||||
|
SanitizedServerEditorConfig,
|
||||||
|
ServerEditorConfig,
|
||||||
|
} from "@payloadcms/richtext-lexical";
|
||||||
|
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
|
||||||
|
|
||||||
|
type RichTextFieldLike = {
|
||||||
|
editor?: {
|
||||||
|
features?: FeatureProviderServer[];
|
||||||
|
editorConfig?: { lexical?: ServerEditorConfig["lexical"] };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveEditorConfig(
|
||||||
|
payload: MigrateUpArgs["payload"],
|
||||||
|
): Promise<SanitizedServerEditorConfig> {
|
||||||
|
const fields = payload.collections["story-beats"].config.fields;
|
||||||
|
const bodyField = fields.find(
|
||||||
|
(field) => "name" in field && (field as { name?: string }).name === "body",
|
||||||
|
) as RichTextFieldLike | undefined;
|
||||||
|
const editor = bodyField?.editor;
|
||||||
|
const features = editor?.features;
|
||||||
|
if (!editor || !features?.length) {
|
||||||
|
throw new Error("story-beats body richText editor features not found in sanitized config");
|
||||||
|
}
|
||||||
|
return sanitizeServerEditorConfig(
|
||||||
|
{ features, lexical: editor.editorConfig?.lexical },
|
||||||
|
payload.config,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBeatBodies(
|
||||||
|
db: MigrateUpArgs["db"],
|
||||||
|
): Promise<Array<{ id: number; raw: unknown }>> {
|
||||||
|
const result = await db.execute(sql`SELECT "id", "body"::text AS body FROM "story_beats"`);
|
||||||
|
const rows = result.rows as Array<{ id: number; body: string | null }>;
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
raw: row.body == null ? null : (JSON.parse(row.body) as unknown),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function up({ db, payload }: MigrateUpArgs): Promise<void> {
|
||||||
|
// Stored markdown strings are not valid JSON, so wrap every value as a JSON
|
||||||
|
// string scalar while switching the column type to jsonb.
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "story_beats" ALTER COLUMN "body" SET DATA TYPE jsonb USING to_jsonb("body"::text);
|
||||||
|
CREATE INDEX "story_beats_status_idx" ON "story_beats" USING btree ("status");`);
|
||||||
|
|
||||||
|
const editorConfig = await resolveEditorConfig(payload);
|
||||||
|
const beats = await readBeatBodies(db);
|
||||||
|
|
||||||
|
for (const beat of beats) {
|
||||||
|
// Already a lexical editor state object; nothing to convert.
|
||||||
|
if (beat.raw && typeof beat.raw === "object") continue;
|
||||||
|
const markdown = typeof beat.raw === "string" ? beat.raw : "";
|
||||||
|
const lexical = convertMarkdownToLexical({ editorConfig, markdown });
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE "story_beats" SET "body" = ${JSON.stringify(lexical)}::jsonb WHERE "id" = ${beat.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload }: MigrateDownArgs): Promise<void> {
|
||||||
|
const editorConfig = await resolveEditorConfig(payload);
|
||||||
|
const beats = await readBeatBodies(db);
|
||||||
|
|
||||||
|
for (const beat of beats) {
|
||||||
|
let markdown = "";
|
||||||
|
if (beat.raw && typeof beat.raw === "object") {
|
||||||
|
markdown = convertLexicalToMarkdown({
|
||||||
|
data: beat.raw as Parameters<typeof convertLexicalToMarkdown>[0]["data"],
|
||||||
|
editorConfig,
|
||||||
|
});
|
||||||
|
} else if (typeof beat.raw === "string") {
|
||||||
|
markdown = beat.raw;
|
||||||
|
}
|
||||||
|
// Store as a jsonb string scalar so the column type change below can extract it.
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE "story_beats" SET "body" = ${JSON.stringify(markdown)}::jsonb WHERE "id" = ${beat.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP INDEX "story_beats_status_idx";
|
||||||
|
ALTER TABLE "story_beats" ALTER COLUMN "body" SET DATA TYPE varchar USING "body" #>> '{}';`);
|
||||||
|
}
|
||||||
23695
src/migrations/20260908_054314_add_call_for_fire_stats.json
Normal file
23695
src/migrations/20260908_054314_add_call_for_fire_stats.json
Normal file
File diff suppressed because it is too large
Load diff
25
src/migrations/20260908_054314_add_call_for_fire_stats.ts
Normal file
25
src/migrations/20260908_054314_add_call_for_fire_stats.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_call_for_fire_games_played" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_call_for_fire_wins" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_call_for_fire_losses" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_call_for_fire_best_score" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_call_for_fire_xp_earned" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_call_for_fire_best_patrol_missions" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_call_for_fire_best_checkpoint_missions" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_call_for_fire_best_compound_missions" numeric DEFAULT 0;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_call_for_fire_games_played";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_call_for_fire_wins";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_call_for_fire_losses";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_call_for_fire_best_score";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_call_for_fire_xp_earned";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_call_for_fire_best_patrol_missions";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_call_for_fire_best_checkpoint_missions";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_call_for_fire_best_compound_missions";`)
|
||||||
|
}
|
||||||
23751
src/migrations/20260908_142859_add_land_nav_stats.json
Normal file
23751
src/migrations/20260908_142859_add_land_nav_stats.json
Normal file
File diff suppressed because it is too large
Load diff
25
src/migrations/20260908_142859_add_land_nav_stats.ts
Normal file
25
src/migrations/20260908_142859_add_land_nav_stats.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_games_played" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_wins" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_losses" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_best_score" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_xp_earned" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_best_waypoint_seconds" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_best_route_seconds" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_best_expedition_seconds" numeric DEFAULT 0;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_games_played";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_wins";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_losses";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_best_score";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_xp_earned";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_best_waypoint_seconds";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_best_route_seconds";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_best_expedition_seconds";`)
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,10 @@ import * as migration_20260907_043339_add_voice_script_description from './20260
|
||||||
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
|
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
|
||||||
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
|
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
|
||||||
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
|
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
|
||||||
|
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
|
||||||
|
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
|
||||||
|
import * as migration_20260908_054314_add_call_for_fire_stats from './20260908_054314_add_call_for_fire_stats';
|
||||||
|
import * as migration_20260908_142859_add_land_nav_stats from './20260908_142859_add_land_nav_stats';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -262,6 +266,26 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
|
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
|
||||||
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
|
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
|
||||||
name: '20260907_090245_add_mission_ready_reminder_fields'
|
name: '20260907_090245_add_mission_ready_reminder_fields',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260907_120754_add_story_beats.up,
|
||||||
|
down: migration_20260907_120754_add_story_beats.down,
|
||||||
|
name: '20260907_120754_add_story_beats',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260907_124024_convert_story_beats_body_to_lexical.up,
|
||||||
|
down: migration_20260907_124024_convert_story_beats_body_to_lexical.down,
|
||||||
|
name: '20260907_124024_convert_story_beats_body_to_lexical',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260908_054314_add_call_for_fire_stats.up,
|
||||||
|
down: migration_20260908_054314_add_call_for_fire_stats.down,
|
||||||
|
name: '20260908_054314_add_call_for_fire_stats',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260908_142859_add_land_nav_stats.up,
|
||||||
|
down: migration_20260908_142859_add_land_nav_stats.down,
|
||||||
|
name: '20260908_142859_add_land_nav_stats'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,8 @@ export interface Config {
|
||||||
labels: Label;
|
labels: Label;
|
||||||
maps: Map;
|
maps: Map;
|
||||||
'narrative-events': NarrativeEvent;
|
'narrative-events': NarrativeEvent;
|
||||||
|
'story-beats': StoryBeat;
|
||||||
|
'story-beat-states': StoryBeatState;
|
||||||
'mission-files': MissionFile;
|
'mission-files': MissionFile;
|
||||||
'mod-lists': ModList;
|
'mod-lists': ModList;
|
||||||
'game-servers': GameServer;
|
'game-servers': GameServer;
|
||||||
|
|
@ -189,6 +191,8 @@ export interface Config {
|
||||||
labels: LabelsSelect<false> | LabelsSelect<true>;
|
labels: LabelsSelect<false> | LabelsSelect<true>;
|
||||||
maps: MapsSelect<false> | MapsSelect<true>;
|
maps: MapsSelect<false> | MapsSelect<true>;
|
||||||
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
||||||
|
'story-beats': StoryBeatsSelect<false> | StoryBeatsSelect<true>;
|
||||||
|
'story-beat-states': StoryBeatStatesSelect<false> | StoryBeatStatesSelect<true>;
|
||||||
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
|
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
|
||||||
'mod-lists': ModListsSelect<false> | ModListsSelect<true>;
|
'mod-lists': ModListsSelect<false> | ModListsSelect<true>;
|
||||||
'game-servers': GameServersSelect<false> | GameServersSelect<true>;
|
'game-servers': GameServersSelect<false> | GameServersSelect<true>;
|
||||||
|
|
@ -1882,6 +1886,8 @@ export interface GameEventLog {
|
||||||
| 'assignment-transfers'
|
| 'assignment-transfers'
|
||||||
| 'voice-scripts'
|
| 'voice-scripts'
|
||||||
| 'voice-submissions'
|
| 'voice-submissions'
|
||||||
|
| 'story-beats'
|
||||||
|
| 'story-beat-states'
|
||||||
)
|
)
|
||||||
| null;
|
| null;
|
||||||
targetId?: number | null;
|
targetId?: number | null;
|
||||||
|
|
@ -2031,6 +2037,26 @@ export interface Profile {
|
||||||
eliteBestAccuracy?: number | null;
|
eliteBestAccuracy?: number | null;
|
||||||
xpEarned?: number | null;
|
xpEarned?: number | null;
|
||||||
};
|
};
|
||||||
|
callForFire?: {
|
||||||
|
gamesPlayed?: number | null;
|
||||||
|
wins?: number | null;
|
||||||
|
losses?: number | null;
|
||||||
|
bestScore?: number | null;
|
||||||
|
xpEarned?: number | null;
|
||||||
|
bestPatrolMissions?: number | null;
|
||||||
|
bestCheckpointMissions?: number | null;
|
||||||
|
bestCompoundMissions?: number | null;
|
||||||
|
};
|
||||||
|
landNav?: {
|
||||||
|
gamesPlayed?: number | null;
|
||||||
|
wins?: number | null;
|
||||||
|
losses?: number | null;
|
||||||
|
bestScore?: number | null;
|
||||||
|
xpEarned?: number | null;
|
||||||
|
bestWaypointSeconds?: number | null;
|
||||||
|
bestRouteSeconds?: number | null;
|
||||||
|
bestExpeditionSeconds?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
voiceover?: {
|
voiceover?: {
|
||||||
xpEarned?: number | null;
|
xpEarned?: number | null;
|
||||||
|
|
@ -3535,6 +3561,70 @@ export interface NarrativeEvent {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Chronicle story beats. Drafts are visible to staff only; published beats are readable by any logged-in user on /story.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "story-beats".
|
||||||
|
*/
|
||||||
|
export interface StoryBeat {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* In-story timestamp used to order the chronicle.
|
||||||
|
*/
|
||||||
|
occurredAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Tie-breaker for beats that share the same timestamp.
|
||||||
|
*/
|
||||||
|
order?: number | null;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
/**
|
||||||
|
* Rich text body of the beat, rendered on /story with the Lexical renderer.
|
||||||
|
*/
|
||||||
|
body: {
|
||||||
|
root: {
|
||||||
|
type: string;
|
||||||
|
children: {
|
||||||
|
type: any;
|
||||||
|
version: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}[];
|
||||||
|
direction: ('ltr' | 'rtl') | null;
|
||||||
|
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||||
|
indent: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Optional mission this beat is tied to. Beats may stand alone.
|
||||||
|
*/
|
||||||
|
mission?: (number | null) | Mission;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Per-user read markers and reactions for story beats.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "story-beat-states".
|
||||||
|
*/
|
||||||
|
export interface StoryBeatState {
|
||||||
|
id: number;
|
||||||
|
user: number | User;
|
||||||
|
beat: number | StoryBeat;
|
||||||
|
/**
|
||||||
|
* When the user first read the beat.
|
||||||
|
*/
|
||||||
|
readAt?: string | null;
|
||||||
|
/**
|
||||||
|
* The user's current reaction to the beat, if any.
|
||||||
|
*/
|
||||||
|
reaction?: ('like' | 'heart' | 'salute') | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "game-servers".
|
* via the `definition` "game-servers".
|
||||||
|
|
@ -3905,6 +3995,14 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'narrative-events';
|
relationTo: 'narrative-events';
|
||||||
value: number | NarrativeEvent;
|
value: number | NarrativeEvent;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'story-beats';
|
||||||
|
value: number | StoryBeat;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'story-beat-states';
|
||||||
|
value: number | StoryBeatState;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'mission-files';
|
relationTo: 'mission-files';
|
||||||
value: number | MissionFile;
|
value: number | MissionFile;
|
||||||
|
|
@ -4351,6 +4449,30 @@ export interface ProfilesSelect<T extends boolean = true> {
|
||||||
eliteBestAccuracy?: T;
|
eliteBestAccuracy?: T;
|
||||||
xpEarned?: T;
|
xpEarned?: T;
|
||||||
};
|
};
|
||||||
|
callForFire?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
gamesPlayed?: T;
|
||||||
|
wins?: T;
|
||||||
|
losses?: T;
|
||||||
|
bestScore?: T;
|
||||||
|
xpEarned?: T;
|
||||||
|
bestPatrolMissions?: T;
|
||||||
|
bestCheckpointMissions?: T;
|
||||||
|
bestCompoundMissions?: T;
|
||||||
|
};
|
||||||
|
landNav?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
gamesPlayed?: T;
|
||||||
|
wins?: T;
|
||||||
|
losses?: T;
|
||||||
|
bestScore?: T;
|
||||||
|
xpEarned?: T;
|
||||||
|
bestWaypointSeconds?: T;
|
||||||
|
bestRouteSeconds?: T;
|
||||||
|
bestExpeditionSeconds?: T;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
voiceover?:
|
voiceover?:
|
||||||
| T
|
| T
|
||||||
|
|
@ -5446,6 +5568,32 @@ export interface NarrativeEventsSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "story-beats_select".
|
||||||
|
*/
|
||||||
|
export interface StoryBeatsSelect<T extends boolean = true> {
|
||||||
|
title?: T;
|
||||||
|
occurredAt?: T;
|
||||||
|
order?: T;
|
||||||
|
status?: T;
|
||||||
|
body?: T;
|
||||||
|
mission?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "story-beat-states_select".
|
||||||
|
*/
|
||||||
|
export interface StoryBeatStatesSelect<T extends boolean = true> {
|
||||||
|
user?: T;
|
||||||
|
beat?: T;
|
||||||
|
readAt?: T;
|
||||||
|
reaction?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "mission-files_select".
|
* via the `definition` "mission-files_select".
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ import { Projects } from "@/collections/projects/Projects";
|
||||||
import { Sprints } from "@/collections/projects/Sprints";
|
import { Sprints } from "@/collections/projects/Sprints";
|
||||||
import { Releases } from "@/collections/projects/Releases";
|
import { Releases } from "@/collections/projects/Releases";
|
||||||
import { Labels } from "@/collections/projects/Labels";
|
import { Labels } from "@/collections/projects/Labels";
|
||||||
|
import { StoryBeats } from "@/collections/story/StoryBeats";
|
||||||
|
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
|
||||||
import { migrations } from "./migrations";
|
import { migrations } from "./migrations";
|
||||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
@ -224,6 +226,10 @@ const collections = [
|
||||||
Maps,
|
Maps,
|
||||||
NarrativeEvents,
|
NarrativeEvents,
|
||||||
|
|
||||||
|
// Story
|
||||||
|
StoryBeats,
|
||||||
|
StoryBeatStates,
|
||||||
|
|
||||||
// Server
|
// Server
|
||||||
MissionFiles,
|
MissionFiles,
|
||||||
ModLists,
|
ModLists,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ interface GameEventInput {
|
||||||
| "assignment-transfers"
|
| "assignment-transfers"
|
||||||
| "market-state"
|
| "market-state"
|
||||||
| "voice-scripts"
|
| "voice-scripts"
|
||||||
| "voice-submissions";
|
| "voice-submissions"
|
||||||
|
| "story-beats"
|
||||||
|
| "story-beat-states";
|
||||||
targetId?: number;
|
targetId?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ export const EventTypes = {
|
||||||
MinigameEod: "minigame:eod",
|
MinigameEod: "minigame:eod",
|
||||||
MinigameRadioTraffic: "minigame:radio-traffic",
|
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||||
MinigameAimTrainer: "minigame:aim-trainer",
|
MinigameAimTrainer: "minigame:aim-trainer",
|
||||||
|
MinigameCallForFire: "minigame:call-for-fire",
|
||||||
|
MinigameLandNav: "minigame:land-nav",
|
||||||
|
|
||||||
// Voiceover
|
// Voiceover
|
||||||
VoiceoverSubmit: "voiceover:submit",
|
VoiceoverSubmit: "voiceover:submit",
|
||||||
|
|
@ -152,6 +154,10 @@ export const EventTypes = {
|
||||||
WikiPageUnlock: "wiki:page-unlock",
|
WikiPageUnlock: "wiki:page-unlock",
|
||||||
WikiPageDelete: "wiki:page-delete",
|
WikiPageDelete: "wiki:page-delete",
|
||||||
|
|
||||||
|
// Story
|
||||||
|
StoryBeatRead: "story:beat-read",
|
||||||
|
StoryBeatReact: "story:beat-react",
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
ProjectCreate: "project:create",
|
ProjectCreate: "project:create",
|
||||||
ProjectUpdate: "project:update",
|
ProjectUpdate: "project:update",
|
||||||
|
|
|
||||||
237
tests/int/call-for-fire.int.spec.ts
Normal file
237
tests/int/call-for-fire.int.spec.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BEST_MISSIONS_FIELDS,
|
||||||
|
CALL_FOR_FIRE_DIFFICULTIES,
|
||||||
|
MAX_MISSIONS_PER_RUN,
|
||||||
|
METERS_PER_CELL,
|
||||||
|
applyCorrection,
|
||||||
|
bearingOf,
|
||||||
|
calculateScore,
|
||||||
|
calculateXp,
|
||||||
|
createScenario,
|
||||||
|
distanceMeters,
|
||||||
|
euclideanDistance,
|
||||||
|
gridDesignation,
|
||||||
|
isCallForFireDifficulty,
|
||||||
|
isEffectiveHit,
|
||||||
|
observerReport,
|
||||||
|
randomTargetPosition,
|
||||||
|
rangeBandLabel,
|
||||||
|
rangeBandOf,
|
||||||
|
} from "@/lib/call-for-fire";
|
||||||
|
|
||||||
|
function seededRng(seed: number): () => number {
|
||||||
|
let state = seed;
|
||||||
|
return () => {
|
||||||
|
state = (state * 1103515245 + 12345) % 2147483648;
|
||||||
|
return state / 2147483648;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("call-for-fire difficulty configs", () => {
|
||||||
|
it("has three difficulties with sane bounds", () => {
|
||||||
|
expect(Object.keys(CALL_FOR_FIRE_DIFFICULTIES).sort()).toEqual([
|
||||||
|
"checkpoint",
|
||||||
|
"compound",
|
||||||
|
"patrol",
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const key of Object.keys(CALL_FOR_FIRE_DIFFICULTIES) as Array<
|
||||||
|
keyof typeof CALL_FOR_FIRE_DIFFICULTIES
|
||||||
|
>) {
|
||||||
|
const config = CALL_FOR_FIRE_DIFFICULTIES[key];
|
||||||
|
expect(config.maxRounds).toBeLessThanOrEqual(MAX_MISSIONS_PER_RUN);
|
||||||
|
expect(config.parRounds).toBeLessThan(config.maxRounds);
|
||||||
|
expect(config.lethalRadiusM).toBeGreaterThan(0);
|
||||||
|
expect(config.lethalRadiusM).toBeLessThan((config.size / 2) * METERS_PER_CELL);
|
||||||
|
expect(config.targetInset).toBeGreaterThan(config.lethalRadiusM / METERS_PER_CELL);
|
||||||
|
expect(config.parSeconds).toBeLessThan(config.maxSeconds);
|
||||||
|
expect(config.spotMinCells).toBeGreaterThan(0);
|
||||||
|
expect(config.spotMaxCells).toBeGreaterThan(config.spotMinCells);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes valid difficulty keys only", () => {
|
||||||
|
expect(isCallForFireDifficulty("patrol")).toBe(true);
|
||||||
|
expect(isCallForFireDifficulty("checkpoint")).toBe(true);
|
||||||
|
expect(isCallForFireDifficulty("compound")).toBe(true);
|
||||||
|
expect(isCallForFireDifficulty("training")).toBe(false);
|
||||||
|
expect(isCallForFireDifficulty("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps difficulties to profile stat fields", () => {
|
||||||
|
expect(BEST_MISSIONS_FIELDS.patrol).toBe("bestPatrolMissions");
|
||||||
|
expect(BEST_MISSIONS_FIELDS.checkpoint).toBe("bestCheckpointMissions");
|
||||||
|
expect(BEST_MISSIONS_FIELDS.compound).toBe("bestCompoundMissions");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("call-for-fire geometry", () => {
|
||||||
|
it("computes euclidean distance", () => {
|
||||||
|
expect(euclideanDistance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
|
||||||
|
expect(euclideanDistance({ x: 2, y: 2 }, { x: 2, y: 2 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts cells to metres", () => {
|
||||||
|
expect(distanceMeters({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(250);
|
||||||
|
expect(METERS_PER_CELL).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts impacts within the lethal radius as effective", () => {
|
||||||
|
const target = { x: 5, y: 5 };
|
||||||
|
// One cell = 50m; the diagonal of one cell is ~70.7m, inside the 75m patrol radius.
|
||||||
|
expect(isEffectiveHit(target, { x: 5, y: 5 }, 75)).toBe(true);
|
||||||
|
expect(isEffectiveHit(target, { x: 5, y: 4 }, 75)).toBe(true);
|
||||||
|
expect(isEffectiveHit(target, { x: 6, y: 6 }, 75)).toBe(true);
|
||||||
|
// Two cells straight out is 100m, well outside the radius.
|
||||||
|
expect(isEffectiveHit(target, { x: 7, y: 5 }, 75)).toBe(false);
|
||||||
|
expect(isEffectiveHit(target, { x: 7, y: 6 }, 75)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports compass bearings with north up", () => {
|
||||||
|
const target = { x: 5, y: 5 };
|
||||||
|
expect(bearingOf(target, { x: 5, y: 6 })).toBe("N");
|
||||||
|
expect(bearingOf(target, { x: 5, y: 4 })).toBe("S");
|
||||||
|
expect(bearingOf(target, { x: 4, y: 5 })).toBe("E");
|
||||||
|
expect(bearingOf(target, { x: 6, y: 5 })).toBe("W");
|
||||||
|
expect(bearingOf(target, { x: 4, y: 4 })).toBe("SE");
|
||||||
|
expect(bearingOf(target, { x: 6, y: 6 })).toBe("NW");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns north for a direct overlay impact", () => {
|
||||||
|
expect(bearingOf({ x: 3, y: 3 }, { x: 3, y: 3 })).toBe("N");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buckets ranges into bands", () => {
|
||||||
|
const target = { x: 0, y: 0 };
|
||||||
|
expect(rangeBandOf(target, { x: 2, y: 0 })).toBe("dangerClose");
|
||||||
|
expect(rangeBandOf(target, { x: 3, y: 0 })).toBe("close");
|
||||||
|
expect(rangeBandOf(target, { x: 4, y: 4 })).toBe("medium");
|
||||||
|
expect(rangeBandOf(target, { x: 9, y: 0 })).toBe("far");
|
||||||
|
expect(rangeBandLabel("dangerClose")).toBe("danger close");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a radio-style report for misses", () => {
|
||||||
|
const report = observerReport({ x: 5, y: 5 }, { x: 5, y: 8 });
|
||||||
|
expect(report.bearing).toBe("N");
|
||||||
|
expect(report.band).toBe("close");
|
||||||
|
expect(report.message).toBe("Shot, over. Target bearing N, range close.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("call-for-fire corrections", () => {
|
||||||
|
it("moves impacts north on ADD and east on RIGHT", () => {
|
||||||
|
const size = 10;
|
||||||
|
// ADD 200 (4 cells north) and RIGHT 100 (2 cells east).
|
||||||
|
expect(applyCorrection({ x: 5, y: 7 }, { rangeM: 200, deflectionM: 100 }, size)).toEqual({
|
||||||
|
x: 7,
|
||||||
|
y: 3,
|
||||||
|
});
|
||||||
|
// DROP 100 (2 cells south) and LEFT 150 (3 cells west) move the opposite way.
|
||||||
|
expect(applyCorrection({ x: 5, y: 5 }, { rangeM: -100, deflectionM: -150 }, size)).toEqual({
|
||||||
|
x: 2,
|
||||||
|
y: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds metres to the nearest cell", () => {
|
||||||
|
expect(applyCorrection({ x: 5, y: 5 }, { rangeM: 74, deflectionM: 26 }, 10)).toEqual({
|
||||||
|
x: 6,
|
||||||
|
y: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps corrected impacts to the board", () => {
|
||||||
|
expect(applyCorrection({ x: 0, y: 0 }, { rangeM: 5000, deflectionM: -5000 }, 10)).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
expect(applyCorrection({ x: 9, y: 9 }, { rangeM: -5000, deflectionM: 5000 }, 10)).toEqual({
|
||||||
|
x: 9,
|
||||||
|
y: 9,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("call-for-fire scenarios", () => {
|
||||||
|
it("places the spotting round within the difficulty offset band", () => {
|
||||||
|
for (const key of ["patrol", "checkpoint", "compound"] as const) {
|
||||||
|
const { size, spotMinCells, spotMaxCells, targetInset } = CALL_FOR_FIRE_DIFFICULTIES[key];
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const { target, spotting } = createScenario(key);
|
||||||
|
const offsetCells = Math.max(
|
||||||
|
Math.abs(target.x - spotting.x),
|
||||||
|
Math.abs(target.y - spotting.y),
|
||||||
|
);
|
||||||
|
// Chebyshev distance stays in band (rounding may add one cell).
|
||||||
|
expect(offsetCells).toBeLessThanOrEqual(spotMaxCells + 1);
|
||||||
|
// Targets stay interior; spotting rounds stay on the board.
|
||||||
|
expect(target.x).toBeGreaterThanOrEqual(targetInset);
|
||||||
|
expect(spotting.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(spotting.x).toBeLessThan(size);
|
||||||
|
expect(spotting.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(spotting.y).toBeLessThan(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic for a seeded rng", () => {
|
||||||
|
const a = createScenario("patrol", seededRng(42));
|
||||||
|
const b = createScenario("patrol", seededRng(42));
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates different scenarios across seeds", () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let seed = 1; seed <= 20; seed++) {
|
||||||
|
seen.add(JSON.stringify(createScenario("checkpoint", seededRng(seed))));
|
||||||
|
}
|
||||||
|
expect(seen.size).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps target placement inside the interior inset region", () => {
|
||||||
|
for (const key of ["patrol", "checkpoint", "compound"] as const) {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const point = randomTargetPosition(key);
|
||||||
|
const { size, targetInset } = CALL_FOR_FIRE_DIFFICULTIES[key];
|
||||||
|
expect(point.x).toBeGreaterThanOrEqual(targetInset);
|
||||||
|
expect(point.x).toBeLessThanOrEqual(size - 1 - targetInset);
|
||||||
|
expect(point.y).toBeGreaterThanOrEqual(targetInset);
|
||||||
|
expect(point.y).toBeLessThanOrEqual(size - 1 - targetInset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats grid designations", () => {
|
||||||
|
expect(gridDesignation({ x: 0, y: 0 })).toBe("A1");
|
||||||
|
expect(gridDesignation({ x: 4, y: 6 })).toBe("E7");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("call-for-fire scoring", () => {
|
||||||
|
it("awards base plus round, time, and accuracy bonuses on a win", () => {
|
||||||
|
// Patrol: base 100, 4 rounds under par 4? none; 60s under par 90; 75m accuracy.
|
||||||
|
expect(calculateScore("patrol", 4, 30, 0, true)).toBe(100 + 60 + 75);
|
||||||
|
// Checkpoint: base 300 + 2 rounds under par * 40 + 0 time + 50 accuracy.
|
||||||
|
expect(calculateScore("checkpoint", 4, 120, 0, true)).toBe(300 + 2 * 40 + 50);
|
||||||
|
// Compound: base 600 + 3 rounds under par * 60 + 20s + 25 accuracy.
|
||||||
|
expect(calculateScore("compound", 5, 130, 25, true)).toBe(600 + 3 * 60 + 20 + 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never awards negative bonuses for slow or wasteful runs", () => {
|
||||||
|
expect(calculateScore("patrol", 8, 180, 75, true)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores losses as zero", () => {
|
||||||
|
expect(calculateScore("patrol", 2, 30, 0, false)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives xp from score with a floor of one on a win", () => {
|
||||||
|
expect(calculateXp(149, true)).toBe(2);
|
||||||
|
expect(calculateXp(100, true)).toBe(2);
|
||||||
|
expect(calculateXp(50, true)).toBe(1);
|
||||||
|
expect(calculateXp(10, true)).toBe(1);
|
||||||
|
expect(calculateXp(600, false)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
140
tests/int/land-nav.int.spec.ts
Normal file
140
tests/int/land-nav.int.spec.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
GRID_COLS,
|
||||||
|
GRID_ROWS,
|
||||||
|
LAND_NAV_DIFFICULTIES,
|
||||||
|
calculateScore,
|
||||||
|
calculateXp,
|
||||||
|
createCourse,
|
||||||
|
gridRef,
|
||||||
|
isLegCorrect,
|
||||||
|
isOptionCorrect,
|
||||||
|
isLandNavDifficulty,
|
||||||
|
} from "@/lib/land-nav";
|
||||||
|
|
||||||
|
function seededRng(seed: number) {
|
||||||
|
let state = seed;
|
||||||
|
return () => {
|
||||||
|
state = (state * 1664525 + 1013904223) % 4294967296;
|
||||||
|
return state / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("land-nav lib", () => {
|
||||||
|
it("guards the difficulty keys", () => {
|
||||||
|
expect(isLandNavDifficulty("waypoint")).toBe(true);
|
||||||
|
expect(isLandNavDifficulty("route")).toBe(true);
|
||||||
|
expect(isLandNavDifficulty("expedition")).toBe(true);
|
||||||
|
expect(isLandNavDifficulty("patrol")).toBe(false);
|
||||||
|
expect(isLandNavDifficulty(42)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a valid course for every difficulty", () => {
|
||||||
|
for (const difficulty of ["waypoint", "route", "expedition"] as const) {
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[difficulty];
|
||||||
|
const course = createCourse(difficulty, seededRng(1));
|
||||||
|
expect(course.legs).toHaveLength(config.legs);
|
||||||
|
expect(course.features).toHaveLength(config.features);
|
||||||
|
expect(course.cols).toBe(GRID_COLS);
|
||||||
|
expect(course.rows).toBe(GRID_ROWS);
|
||||||
|
|
||||||
|
const featureCells = new Set(course.features.map((f) => `${f.col},${f.row}`));
|
||||||
|
expect(featureCells.size).toBe(config.features);
|
||||||
|
for (const feature of course.features) {
|
||||||
|
expect(feature.col).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(feature.col).toBeLessThan(GRID_COLS);
|
||||||
|
expect(feature.row).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(feature.row).toBeLessThan(GRID_ROWS);
|
||||||
|
expect(feature.name.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const leg of course.legs) {
|
||||||
|
expect(leg.prompt.length).toBeGreaterThan(0);
|
||||||
|
expect(leg.answerCell.col).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(leg.answerCell.col).toBeLessThan(GRID_COLS);
|
||||||
|
expect(leg.answerCell.row).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(leg.answerCell.row).toBeLessThan(GRID_ROWS);
|
||||||
|
if (leg.kind === "azimuth") {
|
||||||
|
expect(leg.azimuth).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(leg.azimuth).toBeLessThanOrEqual(359);
|
||||||
|
expect(leg.distanceM).toBeGreaterThan(0);
|
||||||
|
expect(leg.startCell).toBeDefined();
|
||||||
|
}
|
||||||
|
if (leg.kind === "identify") {
|
||||||
|
expect(leg.options).toHaveLength(4);
|
||||||
|
expect(new Set(leg.options).size).toBe(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces 6-figure grid references", () => {
|
||||||
|
const course = createCourse("waypoint", seededRng(7));
|
||||||
|
const ref = gridRef(course, { col: 0, row: 0 });
|
||||||
|
expect(ref).toMatch(/^\d{6}$/);
|
||||||
|
expect(ref).toBe(
|
||||||
|
`${String(course.easting0).padStart(3, "0")}${String(course.northing0).padStart(3, "0")}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores passes higher than fails and awards XP only on a pass", () => {
|
||||||
|
const passScore = calculateScore("route", 6, 60, true);
|
||||||
|
const failScore = calculateScore("route", 2, 60, false);
|
||||||
|
expect(passScore).toBeGreaterThan(failScore);
|
||||||
|
const faster = calculateScore("route", 6, 30, true);
|
||||||
|
expect(faster).toBeGreaterThanOrEqual(passScore);
|
||||||
|
expect(calculateXp(passScore, true)).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(calculateXp(passScore, false)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates leg answers", () => {
|
||||||
|
const course = createCourse("waypoint", seededRng(3));
|
||||||
|
const locateLeg = course.legs.find((leg) => leg.kind === "locate");
|
||||||
|
const identifyLeg = course.legs.find((leg) => leg.kind === "identify");
|
||||||
|
expect(locateLeg).toBeDefined();
|
||||||
|
expect(identifyLeg).toBeDefined();
|
||||||
|
if (locateLeg) {
|
||||||
|
expect(isLegCorrect(locateLeg, locateLeg.answerCell)).toBe(true);
|
||||||
|
expect(isLegCorrect(locateLeg, { col: 0, row: 0 })).toBe(
|
||||||
|
locateLeg.answerCell.col === 0 && locateLeg.answerCell.row === 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (identifyLeg) {
|
||||||
|
const answerFeature = course.features.find((f) => f.id === identifyLeg.answerFeatureId);
|
||||||
|
expect(answerFeature).toBeDefined();
|
||||||
|
expect(isOptionCorrect(identifyLeg, course, answerFeature!.name)).toBe(true);
|
||||||
|
const wrongOption = (identifyLeg.options ?? []).find(
|
||||||
|
(option) => option !== answerFeature!.name,
|
||||||
|
);
|
||||||
|
expect(isOptionCorrect(identifyLeg, course, wrongOption ?? "")).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates declination legs on harder courses with a consistent conversion", () => {
|
||||||
|
let seen = 0;
|
||||||
|
for (let seed = 1; seed <= 40 && seen < 5; seed += 1) {
|
||||||
|
const course = createCourse("expedition", seededRng(seed));
|
||||||
|
const declLegs = course.legs.filter((leg) => leg.kind === "declination");
|
||||||
|
seen += declLegs.length;
|
||||||
|
for (const leg of declLegs) {
|
||||||
|
expect(leg.declination).not.toBe(0);
|
||||||
|
expect(leg.startCell).toBeDefined();
|
||||||
|
const radians = ((leg.azimuth ?? 0) * Math.PI) / 180;
|
||||||
|
const distanceCells = (leg.distanceM ?? 0) / 100;
|
||||||
|
const dx = Math.round(Math.sin(radians) * distanceCells);
|
||||||
|
const dy = -Math.round(Math.cos(radians) * distanceCells) || 0;
|
||||||
|
expect(leg.answerCell.col - leg.startCell!.col).toBe(dx);
|
||||||
|
expect(leg.answerCell.row - leg.startCell!.row).toBe(dy);
|
||||||
|
expect(leg.prompt).toMatch(/declination/i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(seen).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never generates declination legs on the waypoint course", () => {
|
||||||
|
for (let seed = 1; seed <= 20; seed += 1) {
|
||||||
|
const course = createCourse("waypoint", seededRng(seed));
|
||||||
|
expect(course.legs.every((leg) => leg.kind !== "declination")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
325
tests/int/story-beats.int.spec.ts
Normal file
325
tests/int/story-beats.int.spec.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
import { getPayload, Payload } from "payload";
|
||||||
|
import config from "@/payload.config";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { StoryBeat, StoryBeatState, User } from "@/payload-types";
|
||||||
|
import { markBeatRead, setBeatReaction } from "@/lib/story";
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
|
||||||
|
const RUN = `story-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
function lexicalBody(text: string): StoryBeat["body"] {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
type: "root",
|
||||||
|
format: "",
|
||||||
|
indent: 0,
|
||||||
|
version: 1,
|
||||||
|
direction: null,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
format: "",
|
||||||
|
indent: 0,
|
||||||
|
version: 1,
|
||||||
|
direction: null,
|
||||||
|
textFormat: 0,
|
||||||
|
textStyle: "",
|
||||||
|
children: [
|
||||||
|
{ type: "text", format: 0, version: 1, mode: "normal", style: "", text, detail: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as StoryBeat["body"];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Story beats", () => {
|
||||||
|
let admin: User;
|
||||||
|
let adminId: number;
|
||||||
|
let user: User;
|
||||||
|
let userId: number;
|
||||||
|
let otherUser: User;
|
||||||
|
let otherUserId: number;
|
||||||
|
const beatIds: number[] = [];
|
||||||
|
const stateIds: number[] = [];
|
||||||
|
let draftBeatId: number;
|
||||||
|
let publishedBeatId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
||||||
|
const createTestUser = async (name: string, roles: User["roles"]) => {
|
||||||
|
return (await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
username: `${RUN}-${name}`,
|
||||||
|
discordUsername: `${RUN}-${name}`,
|
||||||
|
displayName: name.toUpperCase(),
|
||||||
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||||
|
password: "Test123",
|
||||||
|
roles,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as User;
|
||||||
|
};
|
||||||
|
|
||||||
|
admin = await createTestUser("admin", ["admin"]);
|
||||||
|
adminId = admin.id;
|
||||||
|
user = await createTestUser("user", ["user"]);
|
||||||
|
userId = user.id;
|
||||||
|
otherUser = await createTestUser("other", ["user"]);
|
||||||
|
otherUserId = otherUser.id;
|
||||||
|
|
||||||
|
const draftBeat = (await payload.create({
|
||||||
|
collection: "story-beats",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Draft beat`,
|
||||||
|
status: "draft",
|
||||||
|
body: lexicalBody("A draft beat that should never be public."),
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as StoryBeat;
|
||||||
|
draftBeatId = draftBeat.id;
|
||||||
|
beatIds.push(draftBeatId);
|
||||||
|
|
||||||
|
const publishedBeat = (await payload.create({
|
||||||
|
collection: "story-beats",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Published beat`,
|
||||||
|
status: "published",
|
||||||
|
body: lexicalBody("A published beat body."),
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as StoryBeat;
|
||||||
|
publishedBeatId = publishedBeat.id;
|
||||||
|
beatIds.push(publishedBeatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of stateIds) {
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "story-beat-states", id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of beatIds) {
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "story-beats", id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of [adminId, userId, otherUserId]) {
|
||||||
|
const accounts = await payload
|
||||||
|
.find({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
where: { ownerUser: { equals: id } },
|
||||||
|
limit: 5,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const account of accounts?.docs ?? []) {
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
const profiles = await payload
|
||||||
|
.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: id } },
|
||||||
|
limit: 5,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const profile of profiles?.docs ?? []) {
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "users", id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("visibility gate", () => {
|
||||||
|
it("hides all beats from guests", async () => {
|
||||||
|
await expect(
|
||||||
|
payload.find({ collection: "story-beats", overrideAccess: false }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
payload.findByID({
|
||||||
|
collection: "story-beats",
|
||||||
|
id: publishedBeatId,
|
||||||
|
overrideAccess: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only published beats to normal users", async () => {
|
||||||
|
const result = (await payload.find({
|
||||||
|
collection: "story-beats",
|
||||||
|
where: { title: { like: RUN } },
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as { docs: StoryBeat[] };
|
||||||
|
expect(result.docs).toHaveLength(1);
|
||||||
|
expect(result.docs[0]?.id).toBe(publishedBeatId);
|
||||||
|
expect(result.docs[0]?.status).toBe("published");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
payload.findByID({
|
||||||
|
collection: "story-beats",
|
||||||
|
id: draftBeatId,
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
depth: 0,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows all beats to admin/developer authors", async () => {
|
||||||
|
const result = (await payload.find({
|
||||||
|
collection: "story-beats",
|
||||||
|
where: { title: { like: RUN } },
|
||||||
|
overrideAccess: false,
|
||||||
|
user: admin,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as { docs: StoryBeat[] };
|
||||||
|
const ids = result.docs.map((beat) => beat.id).sort();
|
||||||
|
expect(ids).toEqual([draftBeatId, publishedBeatId].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks normal users from creating beats", async () => {
|
||||||
|
await expect(
|
||||||
|
payload.create({
|
||||||
|
collection: "story-beats",
|
||||||
|
data: { title: "nope", status: "draft", body: lexicalBody("nope") },
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
depth: 0,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows admin authors to create and publish beats", async () => {
|
||||||
|
const created = (await payload.create({
|
||||||
|
collection: "story-beats",
|
||||||
|
data: { title: `${RUN} Authoring check`, status: "draft", body: lexicalBody("wip") },
|
||||||
|
overrideAccess: false,
|
||||||
|
user: admin,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as StoryBeat;
|
||||||
|
beatIds.push(created.id);
|
||||||
|
const published = (await payload.update({
|
||||||
|
collection: "story-beats",
|
||||||
|
id: created.id,
|
||||||
|
data: { status: "published" },
|
||||||
|
overrideAccess: false,
|
||||||
|
user: admin,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as StoryBeat;
|
||||||
|
expect(published.status).toBe("published");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("read state", () => {
|
||||||
|
it("marks a published beat read for a normal user", async () => {
|
||||||
|
const state = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
||||||
|
stateIds.push(state.id);
|
||||||
|
expect(state.readAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent: a second read does not change readAt", async () => {
|
||||||
|
const first = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
||||||
|
const second = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
||||||
|
expect(second.id).toBe(first.id);
|
||||||
|
expect(second.readAt).toBe(first.readAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to mark a draft beat read for a normal user", async () => {
|
||||||
|
await expect(
|
||||||
|
markBeatRead(payload, { user, beatId: draftBeatId }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes state docs to their owner", async () => {
|
||||||
|
const result = (await payload.find({
|
||||||
|
collection: "story-beat-states",
|
||||||
|
where: { id: { in: stateIds } },
|
||||||
|
overrideAccess: false,
|
||||||
|
user: otherUser,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as { docs: StoryBeatState[] };
|
||||||
|
expect(result.docs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reactions", () => {
|
||||||
|
it("sets and replaces reactions", async () => {
|
||||||
|
const heart = await setBeatReaction(payload, {
|
||||||
|
user,
|
||||||
|
beatId: publishedBeatId,
|
||||||
|
reaction: "heart",
|
||||||
|
});
|
||||||
|
expect(heart.reaction).toBe("heart");
|
||||||
|
const salute = await setBeatReaction(payload, {
|
||||||
|
user,
|
||||||
|
beatId: publishedBeatId,
|
||||||
|
reaction: "salute",
|
||||||
|
});
|
||||||
|
expect(salute.reaction).toBe("salute");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the same reaction off", async () => {
|
||||||
|
const set = await setBeatReaction(payload, {
|
||||||
|
user,
|
||||||
|
beatId: publishedBeatId,
|
||||||
|
reaction: "like",
|
||||||
|
});
|
||||||
|
expect(set.reaction).toBe("like");
|
||||||
|
const cleared = await setBeatReaction(payload, {
|
||||||
|
user,
|
||||||
|
beatId: publishedBeatId,
|
||||||
|
reaction: "like",
|
||||||
|
});
|
||||||
|
expect(cleared.reaction).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid reaction values", async () => {
|
||||||
|
await expect(
|
||||||
|
setBeatReaction(payload, {
|
||||||
|
user,
|
||||||
|
beatId: publishedBeatId,
|
||||||
|
reaction: "fire" as never,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Invalid reaction.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses reactions on draft beats for normal users", async () => {
|
||||||
|
await expect(
|
||||||
|
setBeatReaction(payload, { user, beatId: draftBeatId, reaction: "heart" }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists reaction state on the user's beat-state doc", async () => {
|
||||||
|
await setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "salute" });
|
||||||
|
const result = (await payload.find({
|
||||||
|
collection: "story-beat-states",
|
||||||
|
where: { and: [{ user: { equals: userId } }, { beat: { equals: publishedBeatId } }] },
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as { docs: StoryBeatState[] };
|
||||||
|
expect(result.docs).toHaveLength(1);
|
||||||
|
expect(result.docs[0]?.reaction).toBe("salute");
|
||||||
|
expect(result.docs[0]?.readAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue