Compare commits
25 commits
bf5c53249a
...
2c8e305ff5
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c8e305ff5 | |||
| 251bb0506f | |||
| 8439dd7e17 | |||
| baa1863c2f | |||
| 840bc27646 | |||
| 246a5796f8 | |||
| 50b9b7e46c | |||
| d8ee14ee8d | |||
| 53dec0b127 | |||
| 0996b9b7c2 | |||
| 987250b60a | |||
| c8c0267d54 | |||
| 8206ee2303 | |||
| e52a637f96 | |||
| 494b60db3b | |||
| 5bde9707a3 | |||
| bf5468e461 | |||
| 4594912325 | |||
| 3daa7a3601 | |||
| 3fefb8b4a3 | |||
| b2bdbb9ea6 | |||
| 95663924ea | |||
| 225434d8fc | |||
| eaa0325b6d | |||
| caca11a60b |
41 changed files with 75135 additions and 5 deletions
|
|
@ -362,7 +362,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
||||||
|
|
||||||
**Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed.
|
**Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed.
|
||||||
|
|
||||||
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` — never a bare version number. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
|
**Version bumps and release tags: `main` deploys only**: whenever a `/git` or `/git-master` workflow lands work on `main` (or a deploy is being cut), bump the version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` (never a bare version number). The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits, but never use a bare `git push --tags`, which sends only tags and not the branch. **Feature branches must never bump versions or push release tags**: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped. Release tags exist solely to mark `main` deploys. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump on a `main` deploy (or pushing the deploy commit without its tag) makes releases indistinguishable and breaks version history.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,8 @@ Recommended flow:
|
||||||
4. Coolify waits for `/api/health` to come back 200, then routes traffic.
|
4. Coolify waits for `/api/health` to come back 200, then routes traffic.
|
||||||
5. If a new migration shipped with that deploy, run it via `docker exec` or a Scheduled Task (Sec. 7).
|
5. If a new migration shipped with that deploy, run it via `docker exec` or a Scheduled Task (Sec. 7).
|
||||||
|
|
||||||
|
> **Tags only from `main` deploys.** Create and push the `vX.Y.Z` release tag only when deploying from `main`. Never bump versions or push release tags from feature branches: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped.
|
||||||
|
|
||||||
> **Version metadata in builds.** The image build embeds git info into `src/generated/versionInfo.json` (shown in the admin VersionOverlay and on `/api/version`). Coolify **deletes `.git`** from the build context, so the build reads the commit SHA from the `SOURCE_COMMIT` build arg instead of `git` (see the Dockerfile's `ARG SOURCE_COMMIT` block). For that to work, enable **"Include Source Commit in Build"** under the application's *Advanced* settings (and leave "Inject Build Args to Dockerfile" on, which is the default) — otherwise the version overlay falls back to `version` only.
|
> **Version metadata in builds.** The image build embeds git info into `src/generated/versionInfo.json` (shown in the admin VersionOverlay and on `/api/version`). Coolify **deletes `.git`** from the build context, so the build reads the commit SHA from the `SOURCE_COMMIT` build arg instead of `git` (see the Dockerfile's `ARG SOURCE_COMMIT` block). For that to work, enable **"Include Source Commit in Build"** under the application's *Advanced* settings (and leave "Inject Build Args to Dockerfile" on, which is the default) — otherwise the version overlay falls back to `version` only.
|
||||||
>
|
>
|
||||||
> **Commit metadata availability.** On Coolify builds, `SOURCE_COMMIT` is the commit SHA, but tags, dates, and subjects are not injected automatically. If the build environment provides `GIT_COMMIT_DATE` and `GIT_COMMIT_SUBJECT`, the generator records them as `lastUpdated` and `commitMessage`; otherwise `lastUpdated` falls back to the image build time and the commit message remains unavailable. The existing `canSeeCommit` permission still gates commit details in the overlay.
|
> **Commit metadata availability.** On Coolify builds, `SOURCE_COMMIT` is the commit SHA, but tags, dates, and subjects are not injected automatically. If the build environment provides `GIT_COMMIT_DATE` and `GIT_COMMIT_SUBJECT`, the generator records them as `lastUpdated` and `commitMessage`; otherwise `lastUpdated` falls back to the image build time and the commit message remains unavailable. The existing `canSeeCommit` permission still gates commit details in the overlay.
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ For an Arma 3 unit deployment we self-host on **Coolify** with one container per
|
||||||
|
|
||||||
### CI version bumps
|
### CI version bumps
|
||||||
|
|
||||||
Run `bun run version:bump -- --base "$CI_PREVIOUS_SHA" --head "$CI_SHA"` before the production build. The script bumps minor for product changes under `src/app`, `src/components/frontend`, `src/lib`, `src/collections`, `src/bot`, `src/hooks`, `src/migrations`, or `src/utils`, and patch for other deployable changes. Use `--dry-run` to inspect the decision. Commit the changed `package.json` back to the deployment branch (for example, with a `[skip ci]` commit if your CI provider supports it); the existing `generate:version-info` build step will publish the new version to the app.
|
Run `bun run version:bump -- --base "$CI_PREVIOUS_SHA" --head "$CI_SHA"` before the production build. The script bumps minor for product changes under `src/app`, `src/components/frontend`, `src/lib`, `src/collections`, `src/bot`, `src/hooks`, `src/migrations`, or `src/utils`, and patch for other deployable changes. Use `--dry-run` to inspect the decision. Commit the changed `package.json` back to the deployment branch (for example, with a `[skip ci]` commit if your CI provider supports it); the existing `generate:version-info` build step will publish the new version to the app. Version tags (`vX.Y.Z`) ship only on `main` deploys: never bump versions or push release tags from feature branches, because parallel bumps desync `package.json` from the tags that actually deploy.
|
||||||
|
|
||||||
For the full setup — per-env Postgres provisioning, env vars, scheduled jobs, persistent media volume, SSE single-instance caveat, rollbacks, troubleshooting — see **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
|
For the full setup — per-env Postgres provisioning, env vars, scheduled jobs, persistent media volume, SSE single-instance caveat, rollbacks, troubleshooting — see **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.9",
|
"version": "0.2.22",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
215
src/app/(frontend)/opord/actions.ts
Normal file
215
src/app/(frontend)/opord/actions.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
|
||||||
|
import {
|
||||||
|
OPORD_DIFFICULTIES,
|
||||||
|
QUIZ_TIME_GRACE_MS,
|
||||||
|
calculateOpordAccuracy,
|
||||||
|
calculateOpordScore,
|
||||||
|
calculateOpordXp,
|
||||||
|
isOpordDifficulty,
|
||||||
|
minOpordRunTimeMs,
|
||||||
|
type OpordDifficulty,
|
||||||
|
} from "@/lib/opord-recall";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpordStats {
|
||||||
|
gamesPlayed: number;
|
||||||
|
bestScore: number;
|
||||||
|
bestAccuracy: number;
|
||||||
|
recruitBestScore: number;
|
||||||
|
veteranBestScore: number;
|
||||||
|
eliteBestScore: number;
|
||||||
|
opordRecallXp: number;
|
||||||
|
xpEarned: number;
|
||||||
|
score: number;
|
||||||
|
accuracy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpordRunInput = {
|
||||||
|
difficulty: OpordDifficulty;
|
||||||
|
correct: number;
|
||||||
|
timeMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 submitOpordRecallResult(
|
||||||
|
input: OpordRunInput,
|
||||||
|
): Promise<ActionResult<OpordStats>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
if (!isOpordDifficulty(input.difficulty)) {
|
||||||
|
return { success: false, error: "Invalid difficulty." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = OPORD_DIFFICULTIES[input.difficulty];
|
||||||
|
const { correct, timeMs } = input;
|
||||||
|
const minTimeMs = minOpordRunTimeMs(input.difficulty);
|
||||||
|
const maxTimeMs = tier.quizSeconds * 1000 + QUIZ_TIME_GRACE_MS;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(correct) ||
|
||||||
|
correct < 0 ||
|
||||||
|
correct > tier.questionCount ||
|
||||||
|
typeof timeMs !== "number" ||
|
||||||
|
!Number.isFinite(timeMs) ||
|
||||||
|
timeMs < minTimeMs ||
|
||||||
|
timeMs > maxTimeMs
|
||||||
|
) {
|
||||||
|
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?: {
|
||||||
|
opordRecall?: {
|
||||||
|
gamesPlayed?: number;
|
||||||
|
bestScore?: number;
|
||||||
|
bestAccuracy?: number;
|
||||||
|
recruitBestScore?: number;
|
||||||
|
veteranBestScore?: number;
|
||||||
|
eliteBestScore?: number;
|
||||||
|
xpEarned?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const current = progression.minigames?.opordRecall ?? {};
|
||||||
|
|
||||||
|
const score = calculateOpordScore(input.difficulty, correct, timeMs);
|
||||||
|
const accuracy = calculateOpordAccuracy(correct, tier.questionCount);
|
||||||
|
const tierBestScoreKey = `${input.difficulty}BestScore` as
|
||||||
|
| "recruitBestScore"
|
||||||
|
| "veteranBestScore"
|
||||||
|
| "eliteBestScore";
|
||||||
|
const isNewTierBest = score > (current[tierBestScoreKey] ?? 0);
|
||||||
|
const xp = calculateOpordXp(score, isNewTierBest);
|
||||||
|
|
||||||
|
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
||||||
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
||||||
|
const bestAccuracy =
|
||||||
|
Math.round(Math.max(current.bestAccuracy ?? 0, accuracy) * 10000) / 10000;
|
||||||
|
const tierBestScore = Math.max(current[tierBestScoreKey] ?? 0, score);
|
||||||
|
const opordRecallXp = (current.xpEarned ?? 0) + xp;
|
||||||
|
|
||||||
|
const recruitBestScore =
|
||||||
|
input.difficulty === "recruit" ? tierBestScore : (current.recruitBestScore ?? 0);
|
||||||
|
const veteranBestScore =
|
||||||
|
input.difficulty === "veteran" ? tierBestScore : (current.veteranBestScore ?? 0);
|
||||||
|
const eliteBestScore =
|
||||||
|
input.difficulty === "elite" ? tierBestScore : (current.eliteBestScore ?? 0);
|
||||||
|
|
||||||
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progressionRest,
|
||||||
|
minigames: {
|
||||||
|
...(progressionRest.minigames ?? {}),
|
||||||
|
opordRecall: {
|
||||||
|
gamesPlayed,
|
||||||
|
bestScore,
|
||||||
|
bestAccuracy,
|
||||||
|
recruitBestScore,
|
||||||
|
veteranBestScore,
|
||||||
|
eliteBestScore,
|
||||||
|
xpEarned: opordRecallXp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
broadcastToUser("profile-update", Number(user.id), {
|
||||||
|
reason: "xp",
|
||||||
|
xpGained: xp,
|
||||||
|
profileId: profile.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (score > 0) {
|
||||||
|
const pct = Math.round(accuracy * 100);
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.MinigameOpordRecall,
|
||||||
|
message: `recalled an ${input.difficulty} briefing with ${correct}/${tier.questionCount} details correct (${pct}% accuracy, +${xp} XP)`,
|
||||||
|
actor: user.id as number,
|
||||||
|
targetCollection: "users",
|
||||||
|
targetId: user.id as number,
|
||||||
|
data: {
|
||||||
|
difficulty: input.difficulty,
|
||||||
|
correct,
|
||||||
|
total: tier.questionCount,
|
||||||
|
score,
|
||||||
|
accuracy,
|
||||||
|
timeMs: Math.round(timeMs),
|
||||||
|
xpEarned: xp,
|
||||||
|
bestScore,
|
||||||
|
tierBestScore,
|
||||||
|
opordRecallXp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
gamesPlayed,
|
||||||
|
bestScore,
|
||||||
|
bestAccuracy,
|
||||||
|
recruitBestScore,
|
||||||
|
veteranBestScore,
|
||||||
|
eliteBestScore,
|
||||||
|
opordRecallXp,
|
||||||
|
xpEarned: xp,
|
||||||
|
score,
|
||||||
|
accuracy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} 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)/opord/page.tsx
Normal file
66
src/app/(frontend)/opord/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 { ScrollText } from "lucide-react";
|
||||||
|
import { OpordArena } from "@/components/frontend/opord/OpordArena";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "OPORD Recall (Polaris Task Force)",
|
||||||
|
description: "Study a live operations order, then recall the details under pressure.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OpordPage() {
|
||||||
|
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("/opord")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 opordRecall = profileRes.docs[0]?.progression?.minigames?.opordRecall ?? {};
|
||||||
|
const stats = {
|
||||||
|
gamesPlayed: opordRecall.gamesPlayed ?? 0,
|
||||||
|
bestScore: opordRecall.bestScore ?? 0,
|
||||||
|
bestAccuracy: opordRecall.bestAccuracy ?? 0,
|
||||||
|
recruitBestScore: opordRecall.recruitBestScore ?? 0,
|
||||||
|
veteranBestScore: opordRecall.veteranBestScore ?? 0,
|
||||||
|
eliteBestScore: opordRecall.eliteBestScore ?? 0,
|
||||||
|
opordRecallXp: opordRecall.xpEarned ?? 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
score: 0,
|
||||||
|
accuracy: 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">
|
||||||
|
<ScrollText className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">OPORD Recall</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Study a live operations order, then recall the details under pressure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OpordArena 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,7 @@ 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 };
|
||||||
|
opordRecall?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
voiceover?: { xpEarned?: number | null };
|
voiceover?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
|
|
@ -146,6 +147,7 @@ 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 opordRecallXp = progressionData?.minigames?.opordRecall?.xpEarned ?? 0;
|
||||||
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
||||||
|
|
||||||
if (awardIds.length > 0) {
|
if (awardIds.length > 0) {
|
||||||
|
|
@ -188,6 +190,7 @@ export const Profiles: CollectionConfig = {
|
||||||
xpAcc += eodXp;
|
xpAcc += eodXp;
|
||||||
xpAcc += radioTrafficXp;
|
xpAcc += radioTrafficXp;
|
||||||
xpAcc += aimTrainerXp;
|
xpAcc += aimTrainerXp;
|
||||||
|
xpAcc += opordRecallXp;
|
||||||
// Voiceover submissions: XP awarded per accepted submission
|
// Voiceover submissions: XP awarded per accepted submission
|
||||||
xpAcc += voiceoverXp;
|
xpAcc += voiceoverXp;
|
||||||
|
|
||||||
|
|
@ -565,6 +568,54 @@ export const Profiles: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "opordRecall",
|
||||||
|
label: "OPORD Recall",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "gamesPlayed",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestScore",
|
||||||
|
label: "Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestAccuracy",
|
||||||
|
label: "Best Accuracy",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recruitBestScore",
|
||||||
|
label: "Recruit Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "veteranBestScore",
|
||||||
|
label: "Veteran Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "eliteBestScore",
|
||||||
|
label: "Elite Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "xpEarned",
|
||||||
|
label: "XP Earned",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
PlaneTakeoff,
|
PlaneTakeoff,
|
||||||
Radio,
|
Radio,
|
||||||
Bomb,
|
Bomb,
|
||||||
|
ScrollText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavigationCommand {
|
export interface NavigationCommand {
|
||||||
|
|
@ -247,6 +248,13 @@ export const navigationCommands: NavigationCommand[] = [
|
||||||
href: "/eod",
|
href: "/eod",
|
||||||
keywords: ["minigame", "eod", "mines", "training"],
|
keywords: ["minigame", "eod", "mines", "training"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "nav:opord-recall",
|
||||||
|
label: "OPORD Recall",
|
||||||
|
icon: ScrollText,
|
||||||
|
href: "/opord",
|
||||||
|
keywords: ["minigame", "briefing", "memory", "operations order", "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} />
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,12 @@ export function NavUser({
|
||||||
Qualification Course
|
Qualification Course
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/opord">
|
||||||
|
<FileTextIcon />
|
||||||
|
OPORD Recall
|
||||||
|
</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",
|
||||||
|
|
|
||||||
168
src/components/frontend/opord/OpordArena.tsx
Normal file
168
src/components/frontend/opord/OpordArena.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { OPORD_DIFFICULTIES, type OpordDifficulty } from "@/lib/opord-recall";
|
||||||
|
import type { OpordStats } from "@/app/(frontend)/opord/actions";
|
||||||
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { OpordGame } from "./OpordGame";
|
||||||
|
import { OpordLeaderboard } from "./OpordLeaderboard";
|
||||||
|
|
||||||
|
const TIER_OPTIONS = [
|
||||||
|
{ value: "recruit", label: "Recruit", detail: "10 questions · 130s recall · core order" },
|
||||||
|
{ value: "veteran", label: "Veteran", detail: "15 questions · 195s recall · extended order" },
|
||||||
|
{ value: "elite", label: "Elite", detail: "20 questions · 265s recall · full order" },
|
||||||
|
] as const satisfies ReadonlyArray<{
|
||||||
|
readonly value: OpordDifficulty;
|
||||||
|
readonly label: string;
|
||||||
|
readonly detail: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const TIER_STAT_KEYS = {
|
||||||
|
recruit: "recruitBestScore",
|
||||||
|
veteran: "veteranBestScore",
|
||||||
|
elite: "eliteBestScore",
|
||||||
|
} as const satisfies Record<OpordDifficulty, "recruitBestScore" | "veteranBestScore" | "eliteBestScore">;
|
||||||
|
|
||||||
|
export function OpordArena({
|
||||||
|
initialStats,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
initialStats: OpordStats;
|
||||||
|
currentUserId: number | string;
|
||||||
|
}) {
|
||||||
|
const [stats, setStats] = useState<OpordStats>(initialStats);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [difficulty, setDifficulty] = useState<OpordDifficulty>("recruit");
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
const activeOperators = useRealtimePresence("opord", playing, true);
|
||||||
|
|
||||||
|
const handlePhaseChange = useCallback((nextPhase: "ready" | "briefing" | "quiz" | "over") => {
|
||||||
|
setPlaying(nextPhase === "briefing" || nextPhase === "quiz");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGameRecorded = useCallback((nextStats: OpordStats) => {
|
||||||
|
setStats(nextStats);
|
||||||
|
setRefreshKey((key) => key + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing && isMobile) setOpenMobile(false);
|
||||||
|
}, [isMobile, playing, setOpenMobile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-6">
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Choose a briefing tier</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Study the order for as long as you like. Once the recall quiz starts, the clock is
|
||||||
|
unforgiving.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<fieldset
|
||||||
|
disabled={playing}
|
||||||
|
className={cn(
|
||||||
|
"m-0 min-w-0 border-0 p-0 transition-opacity duration-200 motion-reduce:transition-none",
|
||||||
|
playing && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroup
|
||||||
|
value={difficulty}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "recruit" || value === "veteran" || value === "elite") {
|
||||||
|
setDifficulty(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="OPORD Recall difficulty"
|
||||||
|
className="grid gap-2 md:grid-cols-3"
|
||||||
|
>
|
||||||
|
{TIER_OPTIONS.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
htmlFor={`opord-tier-${option.value}`}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-background px-3 py-3 transition-colors focus-within:ring-2 focus-within:ring-ring motion-reduce:transition-none",
|
||||||
|
difficulty === option.value && "border-primary bg-accent",
|
||||||
|
playing && "cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
id={`opord-tier-${option.value}`}
|
||||||
|
value={option.value}
|
||||||
|
aria-label={`${option.label} briefing tier`}
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{option.label}</span>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{stats[TIER_STAT_KEYS[option.value]]} pts
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{option.detail}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</fieldset>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<OpordGame
|
||||||
|
key={difficulty}
|
||||||
|
initialStats={stats}
|
||||||
|
difficulty={difficulty}
|
||||||
|
onGameRecordedAction={handleGameRecorded}
|
||||||
|
onPhaseChangeAction={handlePhaseChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="w-full max-w-3xl">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">Recall protocol</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 text-xs text-muted-foreground sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium text-foreground">Study</span>
|
||||||
|
<span>
|
||||||
|
The order covers the objective grid, azimuth, H-HOUR, phase lines, codewords,
|
||||||
|
callsign, and radio frequency. Veteran tiers add garrison tasks, rules of
|
||||||
|
engagement, and a secondary objective; elite adds intelligence targets and a
|
||||||
|
tertiary objective. Study it at your own pace, then press "Begin recall" when
|
||||||
|
you are ready for the quiz.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium text-foreground">Recall</span>
|
||||||
|
<span>
|
||||||
|
Questions come one at a time with no going back, and the whole quiz is capped at{" "}
|
||||||
|
{OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. Each correct answer
|
||||||
|
is worth {OPORD_DIFFICULTIES[difficulty].correctPoints} points, so speed and accuracy
|
||||||
|
both count.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium text-foreground">Debrief</span>
|
||||||
|
<span>
|
||||||
|
A perfect run inside {OPORD_DIFFICULTIES[difficulty].parTimeMs / 1000}s earns a{" "}
|
||||||
|
{OPORD_DIFFICULTIES[difficulty].perfectBonus}-point bonus. Each tier tracks its own
|
||||||
|
best score.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<OpordLeaderboard
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
difficulty={difficulty}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
activeOperators={activeOperators}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
745
src/components/frontend/opord/OpordGame.tsx
Normal file
745
src/components/frontend/opord/OpordGame.tsx
Normal file
|
|
@ -0,0 +1,745 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { BookOpen, ClipboardCheck, Timer } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
OPORD_DIFFICULTIES,
|
||||||
|
QUESTION_UNLOCK_DELAY_MS,
|
||||||
|
buildOpordQuestions,
|
||||||
|
formatOpordGrid,
|
||||||
|
generateOpordScenario,
|
||||||
|
gradeOpordRun,
|
||||||
|
normalizeOpordAnswer,
|
||||||
|
type OpordDifficulty,
|
||||||
|
type OpordQuestion,
|
||||||
|
type OpordScenario,
|
||||||
|
} from "@/lib/opord-recall";
|
||||||
|
import type { OpordStats } from "@/app/(frontend)/opord/actions";
|
||||||
|
import { submitOpordRecallResult } from "@/app/(frontend)/opord/actions";
|
||||||
|
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||||
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Phase = "ready" | "briefing" | "quiz" | "over";
|
||||||
|
|
||||||
|
const ANSWER_FEEDBACK_MS = 900;
|
||||||
|
|
||||||
|
const CANVAS_PAD_X = 18;
|
||||||
|
const CANVAS_MAX_W = 660;
|
||||||
|
|
||||||
|
type BriefingRow =
|
||||||
|
| { kind: "title" | "subtitle" | "header" | "text"; text: string }
|
||||||
|
| { kind: "kv"; label: string; value: string }
|
||||||
|
| { kind: "rule" }
|
||||||
|
| { kind: "gap" };
|
||||||
|
|
||||||
|
const ROW_FONT: Record<Exclude<BriefingRow["kind"], "rule" | "gap" | "kv">, string> = {
|
||||||
|
title: "bold 17px ui-monospace, Menlo, monospace",
|
||||||
|
subtitle: "10px ui-monospace, Menlo, monospace",
|
||||||
|
header: "bold 12px ui-monospace, Menlo, monospace",
|
||||||
|
text: "12px ui-monospace, Menlo, monospace",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_COLOR: Record<Exclude<BriefingRow["kind"], "rule" | "gap" | "kv">, string> = {
|
||||||
|
title: "#fafafa",
|
||||||
|
subtitle: "#71717a",
|
||||||
|
header: "#fbbf24",
|
||||||
|
text: "#a1a1aa",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_HEIGHT: Record<Exclude<BriefingRow["kind"], "rule" | "gap" | "kv">, number> = {
|
||||||
|
title: 26,
|
||||||
|
subtitle: 14,
|
||||||
|
header: 24,
|
||||||
|
text: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||||||
|
const words = text.split(" ");
|
||||||
|
const lines: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
for (const word of words) {
|
||||||
|
const candidate = current.length === 0 ? word : `${current} ${word}`;
|
||||||
|
if (ctx.measureText(candidate).width > maxWidth && current.length > 0) {
|
||||||
|
lines.push(current);
|
||||||
|
current = word;
|
||||||
|
} else {
|
||||||
|
current = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.length > 0) lines.push(current);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBriefingRows(
|
||||||
|
measureCtx: CanvasRenderingContext2D,
|
||||||
|
scenario: OpordScenario,
|
||||||
|
difficulty: OpordDifficulty,
|
||||||
|
maxWidth: number,
|
||||||
|
): BriefingRow[] {
|
||||||
|
const push = (rows: BriefingRow[], kind: "title" | "subtitle" | "header" | "text", text: string) => {
|
||||||
|
const font = ROW_FONT[kind];
|
||||||
|
measureCtx.font = font;
|
||||||
|
for (const line of wrapText(measureCtx, text, maxWidth)) {
|
||||||
|
rows.push({ kind, text: line });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: BriefingRow[] = [];
|
||||||
|
let sectionNo = 0;
|
||||||
|
const header = (label: string) => {
|
||||||
|
sectionNo += 1;
|
||||||
|
push(rows, "header", `${sectionNo}. ${label}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(rows, "subtitle", "COPY 1 OF 1 · POLARIS TASK FORCE");
|
||||||
|
push(rows, "title", `OPERATION ${scenario.operationName}`);
|
||||||
|
push(rows, "subtitle", `OPERATIONS ORDER NO. ${String(scenario.seed).slice(-4).padStart(4, "0")}`);
|
||||||
|
rows.push({ kind: "rule" });
|
||||||
|
header("SITUATION");
|
||||||
|
const enemyStrength =
|
||||||
|
difficulty === "recruit"
|
||||||
|
? `${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s)`
|
||||||
|
: `${scenario.enemyVehicles} vehicle(s), ${scenario.enemySquads} infantry squad(s), and ${scenario.enemyMortars} mortar team(s)`;
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`Enemy: ${enemyStrength} dug in on objective ${scenario.objective} at grid ${formatOpordGrid(scenario.grid)}.`,
|
||||||
|
);
|
||||||
|
if (difficulty !== "recruit") {
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`Enemy garrison: ${scenario.garrisonCount} personnel reported across Buildings ${scenario.garrisonBuildings[0]} and ${scenario.garrisonBuildings[1]}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
push(rows, "text", `Friendly: ${scenario.friendlyUnit} in support aboard ${scenario.transport}.`);
|
||||||
|
header("MISSION");
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`At H-HOUR ${scenario.hHour}, seize objective ${scenario.objective} (grid ${formatOpordGrid(scenario.grid)}), ${scenario.distanceKm} km on azimuth ${String(scenario.azimuth).padStart(3, "0")}.`,
|
||||||
|
);
|
||||||
|
header("EXECUTION");
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`Line of departure is phase line ${scenario.ldPhaseLine}. Subsequent phase lines: ${scenario.otherPhaseLines.length > 0 ? scenario.otherPhaseLines.join(", ") : "none assigned"}.`,
|
||||||
|
);
|
||||||
|
if (difficulty !== "recruit") {
|
||||||
|
push(rows, "text", `${scenario.maneuverElement} moves in and secures Building ${scenario.securedBuilding}.`);
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`${scenario.overwatchCallsign} moves to ${scenario.overwatchPosition} and assumes overwatch of ${scenario.overwatchTarget}.`,
|
||||||
|
);
|
||||||
|
push(rows, "text", `Secondary objective: neutralize ${scenario.secondaryObjective}.`);
|
||||||
|
}
|
||||||
|
if (difficulty === "elite") {
|
||||||
|
push(rows, "text", `Tertiary objective: neutralize ${scenario.tertiaryObjective}.`);
|
||||||
|
}
|
||||||
|
if (difficulty !== "recruit") {
|
||||||
|
header("RULES OF ENGAGEMENT");
|
||||||
|
push(rows, "text", `Enemy uniforms mirror ours: positive ID requires ${scenario.enemyUniformMark}.`);
|
||||||
|
push(rows, "text", `Civilian presence ${scenario.civilianStatus}.`);
|
||||||
|
}
|
||||||
|
if (difficulty === "elite") {
|
||||||
|
header("INTELLIGENCE");
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`Priority collection target: ${scenario.intelItem}, held in Building ${scenario.intelBuilding}.`,
|
||||||
|
);
|
||||||
|
push(rows, "text", `Priority enemy asset identified: ${scenario.priorityAsset}.`);
|
||||||
|
}
|
||||||
|
if (difficulty === "recruit") {
|
||||||
|
header("SERVICE SUPPORT");
|
||||||
|
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
|
||||||
|
} else {
|
||||||
|
header("COORDINATION");
|
||||||
|
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`Close air support (${scenario.aircraftCallsign}) on station at H+${scenario.airSupportOffsetMin} minutes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (difficulty === "elite") {
|
||||||
|
header("TIMING & CONTINGENCY");
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`Movement from the line of departure to the objective takes ${scenario.enRouteMin} minutes.`,
|
||||||
|
);
|
||||||
|
push(rows, "text", `On abort, ${scenario.abortAction}.`);
|
||||||
|
push(rows, "text", `Resupply is scheduled at H+${scenario.resupplyOffsetMin} minutes.`);
|
||||||
|
push(rows, "text", `Exfiltration no later than H+${scenario.exfilOffsetMin} minutes.`);
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`Withdrawal route runs on azimuth ${String(scenario.withdrawalAzimuth).padStart(3, "0")}.`,
|
||||||
|
);
|
||||||
|
push(rows, "text", `Casualty evacuation lifts from ${scenario.casevacLz}.`);
|
||||||
|
}
|
||||||
|
header("COMMAND & SIGNAL");
|
||||||
|
rows.push({ kind: "kv", label: "AUTHENTICATION CODEWORD:", value: scenario.codeword });
|
||||||
|
rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword });
|
||||||
|
if (difficulty === "elite") {
|
||||||
|
rows.push({
|
||||||
|
kind: "kv",
|
||||||
|
label: "STATUS CHECK-INS:",
|
||||||
|
value: `every ${scenario.checkInMinutes} minutes`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.push({ kind: "gap" });
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBriefing(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
scenario: OpordScenario,
|
||||||
|
difficulty: OpordDifficulty,
|
||||||
|
): void {
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
const availableWidth = parent
|
||||||
|
? parent.clientWidth - CANVAS_PAD_X * 2
|
||||||
|
: CANVAS_MAX_W - CANVAS_PAD_X * 2;
|
||||||
|
const width = Math.min(CANVAS_MAX_W, Math.max(320, availableWidth));
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
const measure = canvas.getContext("2d");
|
||||||
|
if (!measure) return;
|
||||||
|
measure.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
measure.scale(dpr, dpr);
|
||||||
|
const textWidth = width - CANVAS_PAD_X * 2;
|
||||||
|
const rows = buildBriefingRows(measure, scenario, difficulty, textWidth);
|
||||||
|
|
||||||
|
let height = 20;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.kind === "rule") height += 14;
|
||||||
|
else if (row.kind === "gap") height += 8;
|
||||||
|
else if (row.kind === "kv") height += ROW_HEIGHT.text;
|
||||||
|
else height += ROW_HEIGHT[row.kind];
|
||||||
|
}
|
||||||
|
height += 20;
|
||||||
|
|
||||||
|
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(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
ctx.fillStyle = "#0a0a0b";
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
let y = 20;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.kind === "rule") {
|
||||||
|
y += 7;
|
||||||
|
ctx.strokeStyle = "#27272a";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(CANVAS_PAD_X, y);
|
||||||
|
ctx.lineTo(width - CANVAS_PAD_X, y);
|
||||||
|
ctx.stroke();
|
||||||
|
y += 7;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.kind === "gap") {
|
||||||
|
y += 8;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.kind === "kv") {
|
||||||
|
ctx.font = ROW_FONT.text;
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.textBaseline = "alphabetic";
|
||||||
|
ctx.fillStyle = "#a1a1aa";
|
||||||
|
ctx.fillText(row.label, CANVAS_PAD_X, y + ROW_HEIGHT.text - 5);
|
||||||
|
const labelWidth = ctx.measureText(`${row.label} `).width;
|
||||||
|
ctx.fillStyle = "#fafafa";
|
||||||
|
ctx.font = "bold 12px ui-monospace, Menlo, monospace";
|
||||||
|
ctx.fillText(row.value, CANVAS_PAD_X + labelWidth, y + ROW_HEIGHT.text - 5);
|
||||||
|
y += ROW_HEIGHT.text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ctx.font = ROW_FONT[row.kind];
|
||||||
|
ctx.fillStyle = ROW_COLOR[row.kind];
|
||||||
|
ctx.textBaseline = "alphabetic";
|
||||||
|
if (row.kind === "title" || row.kind === "subtitle") {
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(row.text, width / 2, y + ROW_HEIGHT[row.kind] - 8);
|
||||||
|
} else {
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText(row.text, CANVAS_PAD_X, y + ROW_HEIGHT[row.kind] - 6);
|
||||||
|
}
|
||||||
|
y += ROW_HEIGHT[row.kind];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpordBriefingCanvas({
|
||||||
|
scenario,
|
||||||
|
difficulty,
|
||||||
|
}: {
|
||||||
|
scenario: OpordScenario;
|
||||||
|
difficulty: OpordDifficulty;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
drawBriefing(canvas, scenario, difficulty);
|
||||||
|
}, [scenario, difficulty]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full select-none"
|
||||||
|
onCopy={(event) => event.preventDefault()}
|
||||||
|
onCut={(event) => event.preventDefault()}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (
|
||||||
|
(event.ctrlKey || event.metaKey) &&
|
||||||
|
(key === "a" || key === "c" || key === "x" || key === "s" || key === "p")
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="block rounded-xl border border-border"
|
||||||
|
aria-label="Operations order briefing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetHint({ className }: { className?: string }) {
|
||||||
|
const keyClass =
|
||||||
|
"rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px] leading-none";
|
||||||
|
return (
|
||||||
|
<p className={cn("text-xs text-muted-foreground", className)}>
|
||||||
|
Press{" "}
|
||||||
|
<kbd className={keyClass}>Esc</kbd> or <kbd className={keyClass}>R</kbd> at any point to
|
||||||
|
reset the exercise.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metre({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-background px-4 py-3 text-center">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpordGame({
|
||||||
|
initialStats,
|
||||||
|
difficulty,
|
||||||
|
onGameRecordedAction,
|
||||||
|
onPhaseChangeAction,
|
||||||
|
}: {
|
||||||
|
initialStats: OpordStats;
|
||||||
|
difficulty: OpordDifficulty;
|
||||||
|
onGameRecordedAction: (stats: OpordStats) => void;
|
||||||
|
onPhaseChangeAction: (phase: Phase) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
|
const tier = OPORD_DIFFICULTIES[difficulty];
|
||||||
|
|
||||||
|
const phaseRef = useRef<Phase>("ready");
|
||||||
|
const statsRef = useRef<OpordStats>(initialStats);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const scenarioSeedRef = useRef(0);
|
||||||
|
const quizStartedAtRef = useRef(0);
|
||||||
|
const answersRef = useRef<Array<{ question: OpordQuestion; value: string }>>([]);
|
||||||
|
const lastAnswerAtRef = useRef(0);
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
|
const [scenario, setScenario] = useState<OpordScenario | null>(null);
|
||||||
|
const [questions, setQuestions] = useState<readonly OpordQuestion[]>([]);
|
||||||
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
const [typedAnswer, setTypedAnswer] = useState("");
|
||||||
|
const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000);
|
||||||
|
const [unlockRemainingMs, setUnlockRemainingMs] = useState(0);
|
||||||
|
const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [lastRun, setLastRun] = useState<{
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
score: number;
|
||||||
|
xpEarned: number;
|
||||||
|
best: number;
|
||||||
|
error?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const setPhaseSafe = useCallback(
|
||||||
|
(next: Phase) => {
|
||||||
|
phaseRef.current = next;
|
||||||
|
setPhase(next);
|
||||||
|
onPhaseChangeAction?.(next);
|
||||||
|
},
|
||||||
|
[onPhaseChangeAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishRun = useCallback(async () => {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
const timeMs = Math.max(1, Date.now() - quizStartedAtRef.current);
|
||||||
|
const correct = answersRef.current.filter(({ question, value }) => {
|
||||||
|
if (question.kind === "input") {
|
||||||
|
return normalizeOpordAnswer(value) === normalizeOpordAnswer(question.answer);
|
||||||
|
}
|
||||||
|
return Number(value) === question.correctIndex;
|
||||||
|
}).length;
|
||||||
|
const preview = gradeOpordRun(difficulty, correct, timeMs, true);
|
||||||
|
|
||||||
|
if (expiredRef.current) {
|
||||||
|
setLastRun({
|
||||||
|
correct,
|
||||||
|
total: preview.total,
|
||||||
|
score: preview.score,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: statsRef.current.bestScore,
|
||||||
|
error: "Your session expired. Score not recorded.",
|
||||||
|
});
|
||||||
|
setPhaseSafe("over");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submittingRef.current = true;
|
||||||
|
setSubmitting(true);
|
||||||
|
const res = await submitOpordRecallResult({ difficulty, correct, timeMs });
|
||||||
|
submittingRef.current = false;
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
// Esc/R can reset the run while the submission is in flight; global stats
|
||||||
|
// stay in sync, but the run-result UI is skipped for a cancelled run.
|
||||||
|
const cancelled = phaseRef.current !== "quiz";
|
||||||
|
if (res.success && res.data) {
|
||||||
|
statsRef.current = res.data;
|
||||||
|
onGameRecordedAction(res.data);
|
||||||
|
if (res.data.xpEarned > 0) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PROFILE_UPDATE_EVENT, {
|
||||||
|
detail: { reason: "xp", xpGained: res.data.xpEarned },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.setTimeout(() => router.refresh(), 1700);
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setLastRun({
|
||||||
|
correct,
|
||||||
|
total: preview.total,
|
||||||
|
score: res.data.score,
|
||||||
|
xpEarned: res.data.xpEarned,
|
||||||
|
best: res.data.bestScore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!cancelled) {
|
||||||
|
setLastRun({
|
||||||
|
correct,
|
||||||
|
total: preview.total,
|
||||||
|
score: preview.score,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: statsRef.current.bestScore,
|
||||||
|
error: res.error ?? "Could not record score.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!cancelled) setPhaseSafe("over");
|
||||||
|
}, [difficulty, expiredRef, onGameRecordedAction, router, setPhaseSafe]);
|
||||||
|
|
||||||
|
const startBriefing = useCallback(() => {
|
||||||
|
if (expiredRef.current) return;
|
||||||
|
const seedParam = Number(new URLSearchParams(window.location.search).get("seed"));
|
||||||
|
const seed =
|
||||||
|
Number.isInteger(seedParam) && seedParam > 0 && seedParam < 2147483647
|
||||||
|
? seedParam
|
||||||
|
: Date.now() % 2147483647;
|
||||||
|
scenarioSeedRef.current = seed;
|
||||||
|
const nextScenario = generateOpordScenario(seed);
|
||||||
|
setScenario(nextScenario);
|
||||||
|
setQuestions(buildOpordQuestions(nextScenario, difficulty, seed));
|
||||||
|
setQuestionIndex(0);
|
||||||
|
setTypedAnswer("");
|
||||||
|
answersRef.current = [];
|
||||||
|
lastAnswerAtRef.current = 0;
|
||||||
|
setLastAnswerWasCorrect(null);
|
||||||
|
setLastRun(null);
|
||||||
|
setQuizRemainingMs(tier.quizSeconds * 1000);
|
||||||
|
setPhaseSafe("briefing");
|
||||||
|
}, [difficulty, expiredRef, setPhaseSafe, tier.quizSeconds]);
|
||||||
|
|
||||||
|
const beginQuiz = useCallback(() => {
|
||||||
|
if (phaseRef.current !== "briefing") return;
|
||||||
|
quizStartedAtRef.current = Date.now();
|
||||||
|
setPhaseSafe("quiz");
|
||||||
|
}, [setPhaseSafe]);
|
||||||
|
|
||||||
|
const resetGame = useCallback(() => {
|
||||||
|
scenarioSeedRef.current = 0;
|
||||||
|
setScenario(null);
|
||||||
|
setQuestions([]);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
setTypedAnswer("");
|
||||||
|
answersRef.current = [];
|
||||||
|
lastAnswerAtRef.current = 0;
|
||||||
|
setLastAnswerWasCorrect(null);
|
||||||
|
setLastRun(null);
|
||||||
|
setQuizRemainingMs(tier.quizSeconds * 1000);
|
||||||
|
setPhaseSafe("ready");
|
||||||
|
}, [setPhaseSafe, tier.quizSeconds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
|
const target = event.target;
|
||||||
|
const typing =
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
(target instanceof HTMLElement && target.isContentEditable);
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "escape" || (key === "r" && !typing)) resetGame();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [resetGame]);
|
||||||
|
|
||||||
|
const answerCurrent = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (phaseRef.current !== "quiz") return;
|
||||||
|
const question = questions[questionIndex];
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (quizRemainingMs <= 0 || unlockRemainingMs > 0) return;
|
||||||
|
lastAnswerAtRef.current = now;
|
||||||
|
answersRef.current.push({ question, value });
|
||||||
|
setLastAnswerWasCorrect(
|
||||||
|
question.kind === "input"
|
||||||
|
? normalizeOpordAnswer(value) === normalizeOpordAnswer(question.answer)
|
||||||
|
: Number(value) === question.correctIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setLastAnswerWasCorrect(null);
|
||||||
|
setTypedAnswer("");
|
||||||
|
if (phaseRef.current !== "quiz") return;
|
||||||
|
if (questionIndex + 1 >= questions.length) {
|
||||||
|
void finishRun();
|
||||||
|
} else {
|
||||||
|
setQuestionIndex((index) => index + 1);
|
||||||
|
}
|
||||||
|
}, ANSWER_FEEDBACK_MS);
|
||||||
|
},
|
||||||
|
[finishRun, questionIndex, questions, quizRemainingMs, unlockRemainingMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "quiz") return;
|
||||||
|
const deadline = quizStartedAtRef.current + tier.quizSeconds * 1000;
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
setQuizRemainingMs(0);
|
||||||
|
void finishRun();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuizRemainingMs(remaining);
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [phase, tier.quizSeconds, finishRun]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "quiz") return;
|
||||||
|
setUnlockRemainingMs(QUESTION_UNLOCK_DELAY_MS);
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setUnlockRemainingMs((remaining) => Math.max(0, remaining - 100));
|
||||||
|
}, 100);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [phase, questionIndex]);
|
||||||
|
|
||||||
|
const question = questions[questionIndex];
|
||||||
|
const answersLocked =
|
||||||
|
lastAnswerWasCorrect !== null || quizRemainingMs <= 0 || unlockRemainingMs > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-3xl flex-col items-center gap-4">
|
||||||
|
{phase === "ready" && (
|
||||||
|
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-8 text-center">
|
||||||
|
<BookOpen aria-hidden="true" className="size-10 text-muted-foreground" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-xl font-bold uppercase tracking-widest">OPORD Recall</h2>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
|
Study the operations order at your own pace, then take a timed recall quiz:{" "}
|
||||||
|
{tier.quizSeconds} seconds for {tier.questionCount} questions.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Best score this tier:{" "}
|
||||||
|
{statsRef.current[
|
||||||
|
`${difficulty}BestScore` as "recruitBestScore" | "veteranBestScore" | "eliteBestScore"
|
||||||
|
] ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="lg" onClick={startBriefing} disabled={expired}>
|
||||||
|
Receive briefing
|
||||||
|
</Button>
|
||||||
|
<ResetHint />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "briefing" && scenario && (
|
||||||
|
<div className="flex w-full flex-col items-center gap-3">
|
||||||
|
<div className="flex w-full items-center justify-between gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Timer aria-hidden="true" className="size-4" />
|
||||||
|
Study at your own pace
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Recall quiz: {tier.quizSeconds}s once started
|
||||||
|
</span>
|
||||||
|
<Button type="button" size="sm" onClick={beginQuiz}>
|
||||||
|
Begin recall
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-2xl text-center text-xs text-muted-foreground">
|
||||||
|
For fairness, please don't take pictures of or reference the order while playing.
|
||||||
|
Thank you.
|
||||||
|
</p>
|
||||||
|
<OpordBriefingCanvas scenario={scenario} difficulty={difficulty} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "quiz" && question && (
|
||||||
|
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-6">
|
||||||
|
<div className="flex w-full items-center justify-between text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Question {questionIndex + 1} of {questions.length}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 font-mono text-sm font-bold tabular-nums",
|
||||||
|
quizRemainingMs <= 10_000 && "text-red-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardCheck aria-hidden="true" className="size-3.5" />
|
||||||
|
{Math.ceil(quizRemainingMs / 1000)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1 w-full overflow-hidden rounded-full bg-border transition-colors",
|
||||||
|
lastAnswerWasCorrect === true && "bg-emerald-500",
|
||||||
|
lastAnswerWasCorrect === false && "bg-red-500",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{unlockRemainingMs > 0 && lastAnswerWasCorrect === null && (
|
||||||
|
<div className="mx-auto w-full max-w-xs space-y-1">
|
||||||
|
<p className="text-center font-mono text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
Answering opens in {Math.ceil(unlockRemainingMs / 1000)}s
|
||||||
|
</p>
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-amber-400 transition-[width] duration-100 ease-linear"
|
||||||
|
style={{ width: `${(unlockRemainingMs / QUESTION_UNLOCK_DELAY_MS) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-lg font-semibold">{question.prompt}</p>
|
||||||
|
|
||||||
|
{question.kind === "choice" ? (
|
||||||
|
<div className="grid w-full gap-2 sm:grid-cols-2">
|
||||||
|
{question.options.map((option, index) => (
|
||||||
|
<Button
|
||||||
|
key={`${question.id}-${index}`}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto min-h-11 justify-center whitespace-normal break-words font-mono"
|
||||||
|
disabled={answersLocked}
|
||||||
|
onClick={() => answerCurrent(String(index))}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="flex w-full max-w-xs flex-col gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (typedAnswer.trim().length === 0 || answersLocked) return;
|
||||||
|
answerCurrent(typedAnswer);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={typedAnswer}
|
||||||
|
onChange={(event) => setTypedAnswer(event.target.value)}
|
||||||
|
className="text-center font-mono"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
maxLength={12}
|
||||||
|
placeholder="Type your answer"
|
||||||
|
aria-label="Answer"
|
||||||
|
disabled={answersLocked}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={answersLocked}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "over" && (
|
||||||
|
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-8 text-center">
|
||||||
|
<h2 className="text-xl font-bold uppercase tracking-widest">Debrief</h2>
|
||||||
|
<div className="grid w-full max-w-md grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Metre
|
||||||
|
label="Recalled"
|
||||||
|
value={`${lastRun?.correct ?? 0}/${lastRun?.total ?? tier.questionCount}`}
|
||||||
|
/>
|
||||||
|
<Metre label="Score" value={lastRun?.score ?? 0} />
|
||||||
|
<Metre label="XP" value={`+${lastRun?.xpEarned ?? 0}`} />
|
||||||
|
<Metre label="Best" value={lastRun?.best ?? statsRef.current.bestScore} />
|
||||||
|
</div>
|
||||||
|
{lastRun?.error && <p className="text-sm text-red-400">{lastRun.error}</p>}
|
||||||
|
{submitting && <p className="text-xs text-muted-foreground">Recording score…</p>}
|
||||||
|
<Button type="button" size="lg" onClick={startBriefing} disabled={expired || submitting}>
|
||||||
|
New briefing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expired && phase !== "ready" && (
|
||||||
|
<p className="text-xs text-amber-400">
|
||||||
|
Your session is about to end. The app will refresh when it expires.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase !== "ready" && <ResetHint className="text-center" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/components/frontend/opord/OpordLeaderboard.tsx
Normal file
219
src/components/frontend/opord/OpordLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
|
||||||
|
import type { OpordDifficulty } from "@/lib/opord-recall";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { OPORD_LEADERBOARD_FIELDS, readOpordProfiles } from "./opordLeaderboardData";
|
||||||
|
|
||||||
|
type LeaderboardRow = {
|
||||||
|
readonly id: number;
|
||||||
|
readonly userId: number | null;
|
||||||
|
readonly name: string;
|
||||||
|
readonly gamesPlayed: number;
|
||||||
|
readonly bestScore: number;
|
||||||
|
readonly bestAccuracy: number;
|
||||||
|
readonly isCurrentUser: boolean;
|
||||||
|
readonly isOutsideTopTen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function rankClass(index: number): string {
|
||||||
|
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 OpordLeaderboard({
|
||||||
|
refreshKey = 0,
|
||||||
|
difficulty,
|
||||||
|
currentUserId,
|
||||||
|
activeOperators = [],
|
||||||
|
}: {
|
||||||
|
readonly refreshKey?: number;
|
||||||
|
readonly difficulty: OpordDifficulty;
|
||||||
|
readonly currentUserId?: number | string;
|
||||||
|
readonly activeOperators?: readonly PresenceParticipant[];
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<readonly LeaderboardRow[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState<string | null>(null);
|
||||||
|
const tier = OPORD_LEADERBOARD_FIELDS[difficulty];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setFailed(null);
|
||||||
|
setRows(null);
|
||||||
|
const field = `progression.minigames.opordRecall.${tier.scoreField}`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: `-${field}`,
|
||||||
|
limit: "100",
|
||||||
|
depth: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
return readOpordProfiles(payload);
|
||||||
|
})
|
||||||
|
.then((profiles) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const mapped: LeaderboardRow[] = profiles.map((profile) => {
|
||||||
|
const user =
|
||||||
|
typeof profile.user === "object" && profile.user !== null ? profile.user : null;
|
||||||
|
const userId = user?.id ?? (typeof profile.user === "number" ? profile.user : null);
|
||||||
|
const name =
|
||||||
|
user?.displayName ||
|
||||||
|
user?.payloadDisplayName ||
|
||||||
|
profile.profileTitle ||
|
||||||
|
user?.username ||
|
||||||
|
`Operator #${profile.id}`;
|
||||||
|
const opordRecall = profile.opordRecall;
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
gamesPlayed: opordRecall?.gamesPlayed ?? 0,
|
||||||
|
bestScore: opordRecall?.[tier.scoreField] ?? 0,
|
||||||
|
bestAccuracy: opordRecall?.bestAccuracy ?? 0,
|
||||||
|
isCurrentUser: userId !== null && String(userId) === String(currentUserId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const scored = mapped
|
||||||
|
.filter((row) => row.bestScore > 0)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.bestScore - left.bestScore || right.bestAccuracy - left.bestAccuracy,
|
||||||
|
);
|
||||||
|
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((error: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setFailed(error instanceof Error ? error.message : "leaderboard fetch failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentUserId, difficulty, refreshKey, tier.scoreField]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-3xl overflow-hidden">
|
||||||
|
<CardHeader className="gap-1 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy aria-hidden="true" className="size-4 text-amber-400" />
|
||||||
|
<CardTitle className="text-sm">OPORD Recall leaderboard</CardTitle>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{tier.label} · Top 10</span>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Best score for the selected briefing tier.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<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">{activeOperators.length}</span>{" "}
|
||||||
|
{activeOperators.length === 1 ? "operator is" : "operators are"} studying briefings
|
||||||
|
{activeOperators.length > 0 && (
|
||||||
|
<span className="ml-1 text-foreground">
|
||||||
|
({activeOperators.map((operator) => operator.name).join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{failed !== null ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="flex flex-col gap-2 px-4 py-4">
|
||||||
|
<Skeleton className="h-4 w-full motion-reduce:animate-none" />
|
||||||
|
<Skeleton className="h-4 w-3/4 motion-reduce:animate-none" />
|
||||||
|
<Skeleton className="h-4 w-1/2 motion-reduce:animate-none" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
No scores logged for this tier yet. Be the first to brief up.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-lg 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">Games</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Accuracy</th>
|
||||||
|
<th
|
||||||
|
aria-sort="descending"
|
||||||
|
className="px-4 py-2 text-right font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Score
|
||||||
|
</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-48 truncate">{row.name}</span>
|
||||||
|
{row.isCurrentUser && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||||
|
)}
|
||||||
|
{row.isOutsideTopTen && (
|
||||||
|
<span className="shrink-0 text-xs font-normal uppercase tracking-wide text-muted-foreground">
|
||||||
|
unranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.userId !== null && activeOperators.some((operator) => operator.id === row.userId) && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-emerald-400"
|
||||||
|
title="Playing now"
|
||||||
|
aria-label="Playing now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-mono tabular-nums text-muted-foreground">
|
||||||
|
{row.gamesPlayed}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-mono tabular-nums text-muted-foreground">
|
||||||
|
{Math.round(row.bestAccuracy * 100)}%
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono font-semibold tabular-nums">
|
||||||
|
{row.bestScore}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/frontend/opord/opordLeaderboardData.ts
Normal file
95
src/components/frontend/opord/opordLeaderboardData.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import type { OpordDifficulty } from "@/lib/opord-recall";
|
||||||
|
|
||||||
|
export type OpordLeaderboardFields = {
|
||||||
|
readonly label: string;
|
||||||
|
readonly scoreField: keyof OpordApiStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpordApiStats = {
|
||||||
|
readonly gamesPlayed: number | null;
|
||||||
|
readonly bestScore: number | null;
|
||||||
|
readonly bestAccuracy: number | null;
|
||||||
|
readonly recruitBestScore: number | null;
|
||||||
|
readonly veteranBestScore: number | null;
|
||||||
|
readonly eliteBestScore: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiUser = {
|
||||||
|
readonly id: number;
|
||||||
|
readonly username: string | null;
|
||||||
|
readonly displayName: string | null;
|
||||||
|
readonly payloadDisplayName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiProfile = {
|
||||||
|
readonly id: number;
|
||||||
|
readonly profileTitle: string | null;
|
||||||
|
readonly user: ApiUser | number | null;
|
||||||
|
readonly opordRecall: OpordApiStats | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPORD_LEADERBOARD_FIELDS = {
|
||||||
|
recruit: { label: "Recruit", scoreField: "recruitBestScore" },
|
||||||
|
veteran: { label: "Veteran", scoreField: "veteranBestScore" },
|
||||||
|
elite: { label: "Elite", scoreField: "eliteBestScore" },
|
||||||
|
} as const satisfies Record<OpordDifficulty, OpordLeaderboardFields>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUser(value: unknown): ApiUser | number | null {
|
||||||
|
const numericId = readNumber(value);
|
||||||
|
if (numericId !== null) return numericId;
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const id = readNumber(value.id);
|
||||||
|
if (id === null) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
username: readString(value.username),
|
||||||
|
displayName: readString(value.displayName),
|
||||||
|
payloadDisplayName: readString(value.payloadDisplayName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOpordRecall(value: unknown): OpordApiStats | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
return {
|
||||||
|
gamesPlayed: readNumber(value.gamesPlayed),
|
||||||
|
bestScore: readNumber(value.bestScore),
|
||||||
|
bestAccuracy: readNumber(value.bestAccuracy),
|
||||||
|
recruitBestScore: readNumber(value.recruitBestScore),
|
||||||
|
veteranBestScore: readNumber(value.veteranBestScore),
|
||||||
|
eliteBestScore: readNumber(value.eliteBestScore),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProfile(value: unknown): ApiProfile | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const id = readNumber(value.id);
|
||||||
|
if (id === null) return null;
|
||||||
|
const progression = isRecord(value.progression) ? value.progression : null;
|
||||||
|
const minigames = progression && isRecord(progression.minigames) ? progression.minigames : null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
profileTitle: readString(value.profileTitle),
|
||||||
|
user: readUser(value.user),
|
||||||
|
opordRecall: minigames ? readOpordRecall(minigames.opordRecall) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOpordProfiles(value: unknown): readonly ApiProfile[] {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value.docs)) return [];
|
||||||
|
return value.docs.flatMap((item) => {
|
||||||
|
const profile = readProfile(item);
|
||||||
|
return profile ? [profile] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1000
src/lib/opord-recall.ts
Normal file
1000
src/lib/opord-recall.ts
Normal file
File diff suppressed because it is too large
Load diff
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" #>> '{}';`);
|
||||||
|
}
|
||||||
23688
src/migrations/20260908_152021_add_opord_recall_minigame_stats.json
Normal file
23688
src/migrations/20260908_152021_add_opord_recall_minigame_stats.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,23 @@
|
||||||
|
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_opord_recall_games_played" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_best_score" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_best_accuracy" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_recruit_best_score" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_veteran_best_score" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_elite_best_score" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_xp_earned" numeric DEFAULT 0;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_games_played";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_best_score";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_best_accuracy";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_recruit_best_score";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_veteran_best_score";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_elite_best_score";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_xp_earned";`)
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,9 @@ 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_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -262,6 +265,21 @@ 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_152021_add_opord_recall_minigame_stats.up,
|
||||||
|
down: migration_20260908_152021_add_opord_recall_minigame_stats.down,
|
||||||
|
name: '20260908_152021_add_opord_recall_minigame_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,15 @@ export interface Profile {
|
||||||
eliteBestAccuracy?: number | null;
|
eliteBestAccuracy?: number | null;
|
||||||
xpEarned?: number | null;
|
xpEarned?: number | null;
|
||||||
};
|
};
|
||||||
|
opordRecall?: {
|
||||||
|
gamesPlayed?: number | null;
|
||||||
|
bestScore?: number | null;
|
||||||
|
bestAccuracy?: number | null;
|
||||||
|
recruitBestScore?: number | null;
|
||||||
|
veteranBestScore?: number | null;
|
||||||
|
eliteBestScore?: number | null;
|
||||||
|
xpEarned?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
voiceover?: {
|
voiceover?: {
|
||||||
xpEarned?: number | null;
|
xpEarned?: number | null;
|
||||||
|
|
@ -3535,6 +3550,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 +3984,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 +4438,17 @@ export interface ProfilesSelect<T extends boolean = true> {
|
||||||
eliteBestAccuracy?: T;
|
eliteBestAccuracy?: T;
|
||||||
xpEarned?: T;
|
xpEarned?: T;
|
||||||
};
|
};
|
||||||
|
opordRecall?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
gamesPlayed?: T;
|
||||||
|
bestScore?: T;
|
||||||
|
bestAccuracy?: T;
|
||||||
|
recruitBestScore?: T;
|
||||||
|
veteranBestScore?: T;
|
||||||
|
eliteBestScore?: T;
|
||||||
|
xpEarned?: T;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
voiceover?:
|
voiceover?:
|
||||||
| T
|
| T
|
||||||
|
|
@ -5446,6 +5544,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,7 @@ 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",
|
||||||
|
MinigameOpordRecall: "minigame:opord-recall",
|
||||||
|
|
||||||
// Voiceover
|
// Voiceover
|
||||||
VoiceoverSubmit: "voiceover:submit",
|
VoiceoverSubmit: "voiceover:submit",
|
||||||
|
|
@ -152,6 +153,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",
|
||||||
|
|
|
||||||
66
tests/e2e/opord-recall.e2e.spec.ts
Normal file
66
tests/e2e/opord-recall.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { formatOpordGrid, generateOpordScenario } from "../../src/lib/opord-recall";
|
||||||
|
|
||||||
|
const FIXED_SEED = 424242;
|
||||||
|
const QUIZ_OPTION_BUTTON = "button.font-mono";
|
||||||
|
|
||||||
|
async function login(page: Page): Promise<void> {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill("#username", "dev");
|
||||||
|
await page.fill("#password", "Test123");
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playOpordRound(page: Page): Promise<void> {
|
||||||
|
await page.goto(`/opord?seed=${FIXED_SEED}`);
|
||||||
|
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Receive briefing" }).click();
|
||||||
|
await expect(page.getByText("Study at your own pace")).toBeVisible();
|
||||||
|
|
||||||
|
// The briefing is drawn on canvas, so derive the expected grid from the lib.
|
||||||
|
const scenario = generateOpordScenario(FIXED_SEED);
|
||||||
|
const grid = formatOpordGrid(scenario.grid);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Begin recall" }).click();
|
||||||
|
|
||||||
|
const questionCounter = page.getByText(/Question \d+ of \d+/);
|
||||||
|
await expect(questionCounter).toBeVisible();
|
||||||
|
await expect(questionCounter).toContainText("Question 1 of 10");
|
||||||
|
|
||||||
|
// Question 1 is always the objective grid: answer it correctly.
|
||||||
|
await page.locator(QUIZ_OPTION_BUTTON).filter({ hasText: grid }).first().click();
|
||||||
|
|
||||||
|
for (let index = 1; index < 10; index += 1) {
|
||||||
|
await expect(questionCounter).toContainText(`Question ${index + 1} of 10`, {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await page.locator(QUIZ_OPTION_BUTTON).first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Debrief" })).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("OPORD Recall", () => {
|
||||||
|
test("plays a full round and records the score", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await playOpordRound(page);
|
||||||
|
|
||||||
|
// The grid answer alone scores 100; the XP metre shows a positive bonus.
|
||||||
|
await expect(page.locator("body")).toContainText(/Recalled/);
|
||||||
|
await expect(page.getByText(/^\+\d+$/).first()).toBeVisible();
|
||||||
|
|
||||||
|
// The briefing must not expose selectable text to copy.
|
||||||
|
const bodyText = await page.locator("body").innerText();
|
||||||
|
expect(bodyText).not.toContain(formatOpordGrid(generateOpordScenario(FIXED_SEED).grid));
|
||||||
|
|
||||||
|
// The leaderboard refetches after the run and shows the recorded run for this user.
|
||||||
|
const leaderboard = page.getByRole("table");
|
||||||
|
await expect(leaderboard).toBeVisible({ timeout: 15_000 });
|
||||||
|
const myRow = leaderboard.locator("tbody tr").filter({ hasText: "(you)" }).first();
|
||||||
|
await expect(myRow).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(myRow.locator("td").last()).toContainText(/\d{3}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
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