diff --git a/src/components/frontend/opord/OpordArena.tsx b/src/components/frontend/opord/OpordArena.tsx
index c60d54a..522816a 100644
--- a/src/components/frontend/opord/OpordArena.tsx
+++ b/src/components/frontend/opord/OpordArena.tsx
@@ -13,9 +13,9 @@ import { OpordGame } from "./OpordGame";
import { OpordLeaderboard } from "./OpordLeaderboard";
const TIER_OPTIONS = [
- { value: "recruit", label: "Recruit", detail: "4 questions · 45s recall" },
- { value: "veteran", label: "Veteran", detail: "6 questions · 60s recall" },
- { value: "elite", label: "Elite", detail: "8 questions · 75s recall" },
+ { value: "recruit", label: "Recruit", detail: "4 questions · 45s recall · core order" },
+ { value: "veteran", label: "Veteran", detail: "6 questions · 60s recall · +coordination annex" },
+ { value: "elite", label: "Elite", detail: "8 questions · 85s recall · full order" },
] as const satisfies ReadonlyArray<{
readonly value: OpordDifficulty;
readonly label: string;
@@ -140,8 +140,10 @@ export function OpordArena({
Recall
Questions come one at a time with no going back, hard-capped at{" "}
- {OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. The server only
- accepts runs inside a plausible time band, so answers scored{" "}
+ {OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. Veteran and elite
+ briefings add annexes, and their quizzes link elements together: a course direction
+ from the azimuth, a H+minute time, the abort rally plan. The server only accepts runs
+ inside a plausible time band, so answers scored{" "}
{OPORD_DIFFICULTIES[difficulty].correctPoints} points each have to be earned against
the clock.
diff --git a/src/components/frontend/opord/OpordGame.tsx b/src/components/frontend/opord/OpordGame.tsx
index 56a55ae..b252ae3 100644
--- a/src/components/frontend/opord/OpordGame.tsx
+++ b/src/components/frontend/opord/OpordGame.tsx
@@ -77,6 +77,7 @@ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number)
function buildBriefingRows(
measureCtx: CanvasRenderingContext2D,
scenario: OpordScenario,
+ difficulty: OpordDifficulty,
maxWidth: number,
): BriefingRow[] {
const push = (rows: BriefingRow[], kind: "title" | "subtitle" | "header" | "text", text: string) => {
@@ -111,16 +112,39 @@ function buildBriefingRows(
"text",
`Line of departure is phase line ${scenario.ldPhaseLine}. Subsequent phase lines: ${scenario.otherPhaseLines.length > 0 ? scenario.otherPhaseLines.join(", ") : "none assigned"}.`,
);
- push(rows, "header", "4. SERVICE SUPPORT");
- push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
- push(rows, "header", "5. COMMAND & SIGNAL");
+ if (difficulty === "recruit") {
+ push(rows, "header", "4. SERVICE SUPPORT");
+ push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
+ } else {
+ push(rows, "header", "4. 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") {
+ push(rows, "header", "5. TIMING & CONTINGENCY");
+ push(
+ rows,
+ "text",
+ `Movement from the line of departure to the objective takes ${scenario.enRouteMin} minutes.`,
+ );
+ push(rows, "text", `On abort, return to ${scenario.rallyPoint} and await further orders.`);
+ }
+ push(rows, "header", difficulty === "elite" ? "6. COMMAND & SIGNAL" : "5. COMMAND & SIGNAL");
rows.push({ kind: "kv", label: "AUTHENTICATION CODEWORD:", value: scenario.codeword });
rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword });
rows.push({ kind: "gap" });
return rows;
}
-function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): void {
+function drawBriefing(
+ canvas: HTMLCanvasElement,
+ scenario: OpordScenario,
+ difficulty: OpordDifficulty,
+): void {
const parent = canvas.parentElement;
const availableWidth = parent
? parent.clientWidth - CANVAS_PAD_X * 2
@@ -133,7 +157,7 @@ function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): void
measure.setTransform(1, 0, 0, 1, 0, 0);
measure.scale(dpr, dpr);
const textWidth = width - CANVAS_PAD_X * 2;
- const rows = buildBriefingRows(measure, scenario, textWidth);
+ const rows = buildBriefingRows(measure, scenario, difficulty, textWidth);
let height = 20;
for (const row of rows) {
@@ -201,14 +225,20 @@ function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): void
}
}
-function OpordBriefingCanvas({ scenario }: { scenario: OpordScenario }) {
+function OpordBriefingCanvas({
+ scenario,
+ difficulty,
+}: {
+ scenario: OpordScenario;
+ difficulty: OpordDifficulty;
+}) {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
- drawBriefing(canvas, scenario);
- }, [scenario]);
+ drawBriefing(canvas, scenario, difficulty);
+ }, [scenario, difficulty]);
return (
-
+
)}
diff --git a/src/lib/opord-recall.ts b/src/lib/opord-recall.ts
index dff2e14..bc845c0 100644
--- a/src/lib/opord-recall.ts
+++ b/src/lib/opord-recall.ts
@@ -2,9 +2,11 @@
* OPORD Recall game rules. Pure module (no Payload imports), safe on client and server.
*
* A randomized operations order (OPORD) is generated from a numeric seed. The
- * player studies the briefing for a limited "memo" window, then answers quiz
- * questions about the details. Every function is deterministic for a given
- * seed so scenarios are reproducible and testable.
+ * player studies the self-paced briefing, then answers a timed recall quiz.
+ * Briefing scope grows with the tier (recruit core order, veteran coordination
+ * annex, elite timing and contingency annex), and higher tiers ask relational
+ * questions whose answers link two rendered briefing elements. Every function
+ * is deterministic for a given seed so scenarios are reproducible and testable.
*/
export type OpordDifficulty = "recruit" | "veteran" | "elite";
@@ -39,7 +41,7 @@ export const OPORD_DIFFICULTIES: Record = {
perfectBonus: 150,
},
veteran: {
- studyHintSeconds: 18,
+ studyHintSeconds: 20,
questionCount: 6,
quizSeconds: 60,
correctPoints: 150,
@@ -47,11 +49,12 @@ export const OPORD_DIFFICULTIES: Record = {
perfectBonus: 300,
},
elite: {
- studyHintSeconds: 12,
+ // Extra clock headroom: elite relational questions include time arithmetic.
+ studyHintSeconds: 15,
questionCount: 8,
- quizSeconds: 75,
+ quizSeconds: 85,
correctPoints: 200,
- parTimeMs: 58_000,
+ parTimeMs: 68_000,
perfectBonus: 500,
},
};
@@ -171,6 +174,14 @@ const TRANSPORT_OPTIONS = [
"hardened GMG trucks", "unarmored transports", "M-ATVs", "tracked APCs",
] as const;
+const RALLY_POINT_NAMES = ["EAGLE", "WOLF", "TITAN", "SABER", "IRONHIDE", "BASTION"] as const;
+
+const AIRCRAFT_CALLSIGNS = ["RAVEN", "THUNDER", "HARRIER", "OSPREY", "COMET", "GHOST"] as const;
+
+const CARDINAL_DIRECTIONS = [
+ "NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST",
+] as const;
+
export interface OpordScenario {
readonly seed: number;
/** e.g. "IRON VIGIL" */
@@ -196,6 +207,14 @@ export interface OpordScenario {
readonly enemySquads: number;
readonly friendlyUnit: string;
readonly transport: string;
+ /** e.g. "RALLY POINT EAGLE"; rendered in the elite contingency annex. */
+ readonly rallyPoint: string;
+ /** e.g. "RAVEN 2"; close air support asset named in the veteran+ coordination annex. */
+ readonly aircraftCallsign: string;
+ /** Minutes after H-HOUR when close air support is on station. */
+ readonly airSupportOffsetMin: number;
+ /** Minutes of movement from the line of departure to the objective. */
+ readonly enRouteMin: number;
}
/** Formats an 8-digit grid as two 4-digit halves, e.g. "0346 7219". */
@@ -203,6 +222,11 @@ export function formatOpordGrid(grid: string): string {
return grid.length === 8 ? `${grid.slice(0, 4)} ${grid.slice(4)}` : grid;
}
+/** Buckets a 1-359 azimuth onto the eight winds; 359 and 1 both resolve to NORTH. */
+export function cardinalFromAzimuth(azimuth: number): string {
+ return CARDINAL_DIRECTIONS[Math.round(azimuth / 45) % 8];
+}
+
export function generateOpordScenario(seed: number): OpordScenario {
const rng = mulberry32(seed);
const operationName = `${pick(rng, OPERATION_ADJECTIVES)} ${pick(rng, OPERATION_NOUNS)}`;
@@ -236,6 +260,10 @@ export function generateOpordScenario(seed: number): OpordScenario {
enemySquads: pickInt(rng, 1, 3),
friendlyUnit: pick(rng, FRIENDLY_UNITS),
transport: pick(rng, TRANSPORT_OPTIONS),
+ rallyPoint: `RALLY POINT ${pick(rng, RALLY_POINT_NAMES)}`,
+ aircraftCallsign: `${pick(rng, AIRCRAFT_CALLSIGNS)} ${pick(rng, [1, 2, 3, 4])}`,
+ airSupportOffsetMin: pick(rng, [15, 20, 30, 45]),
+ enRouteMin: pickInt(rng, 18, 42),
};
}
@@ -299,26 +327,27 @@ export function buildOpordQuestions(
CODEWORDS.filter((word) => word !== scenario.codeword && word !== scenario.abortCodeword),
).slice(0, 3);
- const builders: readonly (() => OpordQuestion)[] = [
+ const gridBuilder = () =>
// The objective grid is the core skill: always asked first.
- () =>
- choiceQuestion(
- "grid",
- "What is the 8-digit grid of the objective?",
- formatOpordGrid(scenario.grid),
- [
- formatOpordGrid(scenario.grid.split("").reverse().join("")),
- formatOpordGrid(scenario.grid.slice(4) + scenario.grid.slice(0, 4)),
- formatOpordGrid(
- scenario.grid
- .split("")
- .map((digit) => String((Number(digit) + 1) % 10))
- .join(""),
- ),
- ],
- rng,
- ),
- () =>
+ choiceQuestion(
+ "grid",
+ "What is the 8-digit grid of the objective?",
+ formatOpordGrid(scenario.grid),
+ [
+ formatOpordGrid(scenario.grid.split("").reverse().join("")),
+ formatOpordGrid(scenario.grid.slice(4) + scenario.grid.slice(0, 4)),
+ formatOpordGrid(
+ scenario.grid
+ .split("")
+ .map((digit) => String((Number(digit) + 1) % 10))
+ .join(""),
+ ),
+ ],
+ rng,
+ );
+
+ const recallBuilders: Record OpordQuestion> = {
+ azimuth: () =>
choiceQuestion(
"azimuth",
"What azimuth (in degrees) is the main axis of advance?",
@@ -326,7 +355,7 @@ export function buildOpordQuestions(
azimuthDecoys,
rng,
),
- () =>
+ "h-hour": () =>
choiceQuestion(
"h-hour",
"What is H-HOUR?",
@@ -334,7 +363,7 @@ export function buildOpordQuestions(
[zuluShift(scenario.hHour, 30), zuluShift(scenario.hHour, -45), zuluShift(scenario.hHour, 75)],
rng,
),
- () =>
+ codeword: () =>
choiceQuestion(
"codeword",
"What is the authentication codeword?",
@@ -342,7 +371,7 @@ export function buildOpordQuestions(
codewordDistractors,
rng,
),
- () =>
+ abort: () =>
choiceQuestion(
"abort",
"What is the abort codeword?",
@@ -350,7 +379,7 @@ export function buildOpordQuestions(
codewordDistractors,
rng,
),
- () =>
+ callsign: () =>
choiceQuestion(
"callsign",
"What is your element's callsign?",
@@ -360,7 +389,7 @@ export function buildOpordQuestions(
.map((name) => `${name} ${pick(rng, [1, 2, 3, 5, 6, 7])}`),
rng,
),
- () =>
+ frequency: () =>
choiceQuestion(
"frequency",
"What is the assigned radio frequency?",
@@ -368,19 +397,7 @@ export function buildOpordQuestions(
[0.25, -0.25, 0.5].map((delta) => (Number(scenario.frequency) + delta).toFixed(3)),
rng,
),
- () =>
- choiceQuestion(
- "ld",
- "Which phase line is the line of departure?",
- scenario.ldPhaseLine,
- scenario.otherPhaseLines.concat(
- shuffle(rng, PHASE_LINE_NAMES.filter((name) => name !== scenario.ldPhaseLine)).filter(
- (name) => !scenario.otherPhaseLines.includes(name),
- )[0],
- ),
- rng,
- ),
- () =>
+ enemy: () =>
choiceQuestion(
"enemy",
"How many enemy vehicles are on the objective?",
@@ -390,7 +407,7 @@ export function buildOpordQuestions(
.slice(0, 3),
rng,
),
- () =>
+ objective: () =>
choiceQuestion(
"objective",
"What is the objective named?",
@@ -398,7 +415,7 @@ export function buildOpordQuestions(
shuffle(rng, OBJECTIVE_NAMES.filter((name) => name !== scenario.objective)).slice(0, 3),
rng,
),
- () =>
+ friendly: () =>
choiceQuestion(
"friendly",
"Which friendly unit is in support?",
@@ -406,11 +423,145 @@ export function buildOpordQuestions(
shuffle(rng, FRIENDLY_UNITS.filter((unit) => unit !== scenario.friendlyUnit)).slice(0, 3),
rng,
),
- ];
+ };
- const [first, ...rest] = shuffle(rng, builders.slice(1));
- const ordered = [builders[0], first, ...rest].map((build) => build());
- return ordered.slice(0, OPORD_DIFFICULTIES[difficulty].questionCount);
+ // Relational questions link two rendered briefing elements instead of
+ // recalling a single value. Contract: every input an entry reads must be
+ // drawn in the briefing sections rendered for the tiers that include it.
+ const relationalBuilders: Record OpordQuestion> = {
+ "azimuth-cardinal": () =>
+ choiceQuestion(
+ "azimuth-cardinal",
+ `The main axis of advance runs on azimuth ${pad3(scenario.azimuth)}. Which direction is that?`,
+ cardinalFromAzimuth(scenario.azimuth),
+ shuffle(
+ rng,
+ CARDINAL_DIRECTIONS.filter((dir) => dir !== cardinalFromAzimuth(scenario.azimuth)),
+ ).slice(0, 3),
+ rng,
+ ),
+ "time-cas": () =>
+ choiceQuestion(
+ "time-cas",
+ `Close air support (${scenario.aircraftCallsign}) is on station at H+${scenario.airSupportOffsetMin} minutes. What time is that (Zulu)?`,
+ zuluShift(scenario.hHour, scenario.airSupportOffsetMin),
+ [
+ zuluShift(scenario.hHour, scenario.airSupportOffsetMin - 15),
+ zuluShift(scenario.hHour, scenario.airSupportOffsetMin + 15),
+ zuluShift(scenario.hHour, scenario.airSupportOffsetMin + 30),
+ ],
+ rng,
+ ),
+ "phase-first": () =>
+ choiceQuestion(
+ "phase-first",
+ "Which phase line does the element cross first?",
+ scenario.ldPhaseLine,
+ scenario.otherPhaseLines.concat(
+ shuffle(rng, PHASE_LINE_NAMES.filter((name) => name !== scenario.ldPhaseLine)).filter(
+ (name) => !scenario.otherPhaseLines.includes(name),
+ )[0],
+ ),
+ rng,
+ ),
+ "movement-eta": () =>
+ choiceQuestion(
+ "movement-eta",
+ `The element crosses the line of departure at H-HOUR and the movement takes ${scenario.enRouteMin} minutes. When does it reach the objective?`,
+ zuluShift(scenario.hHour, scenario.enRouteMin),
+ [
+ zuluShift(scenario.hHour, scenario.enRouteMin - 10),
+ zuluShift(scenario.hHour, scenario.enRouteMin + 10),
+ zuluShift(scenario.hHour, 0),
+ ],
+ rng,
+ ),
+ "abort-rally": () =>
+ choiceQuestion(
+ "abort-rally",
+ `If the abort codeword "${scenario.abortCodeword}" is broadcast, what does your element do?`,
+ `Return to ${scenario.rallyPoint} and await further orders`,
+ [
+ "Continue the mission as planned",
+ "Mark the objective with smoke and hold in place",
+ `Request close air support from ${scenario.aircraftCallsign}`,
+ ],
+ rng,
+ ),
+ "callsign-net": () =>
+ choiceQuestion(
+ "callsign-net",
+ `Which net does element ${scenario.callsign} monitor?`,
+ `${scenario.frequency} MHz`,
+ [
+ `${(Number(scenario.frequency) + 0.35).toFixed(3)} MHz`,
+ `${(Number(scenario.frequency) - 0.3).toFixed(3)} MHz`,
+ `${(Number(scenario.frequency) + 0.75).toFixed(3)} MHz`,
+ ],
+ rng,
+ ),
+ "transport-link": () =>
+ choiceQuestion(
+ "transport-link",
+ `Which transport carries ${scenario.friendlyUnit}?`,
+ scenario.transport,
+ shuffle(rng, TRANSPORT_OPTIONS.filter((option) => option !== scenario.transport)).slice(0, 3),
+ rng,
+ ),
+ };
+
+ const tier = OPORD_DIFFICULTIES[difficulty];
+ const recallPool: readonly (() => OpordQuestion)[] =
+ difficulty === "recruit"
+ ? [
+ recallBuilders.azimuth,
+ recallBuilders["h-hour"],
+ recallBuilders.codeword,
+ recallBuilders.abort,
+ recallBuilders.objective,
+ recallBuilders.enemy,
+ recallBuilders.callsign,
+ recallBuilders.friendly,
+ ]
+ : difficulty === "veteran"
+ ? [
+ recallBuilders.codeword,
+ recallBuilders.abort,
+ recallBuilders.callsign,
+ recallBuilders.frequency,
+ recallBuilders.enemy,
+ recallBuilders.objective,
+ recallBuilders.friendly,
+ ]
+ : [
+ recallBuilders.codeword,
+ recallBuilders.abort,
+ recallBuilders.callsign,
+ recallBuilders.enemy,
+ recallBuilders.objective,
+ recallBuilders.friendly,
+ recallBuilders["h-hour"],
+ ];
+ const relationalPool: readonly (() => OpordQuestion)[] =
+ difficulty === "veteran"
+ ? [
+ relationalBuilders["azimuth-cardinal"],
+ relationalBuilders["time-cas"],
+ relationalBuilders["phase-first"],
+ ]
+ : [
+ relationalBuilders["azimuth-cardinal"],
+ relationalBuilders["time-cas"],
+ relationalBuilders["phase-first"],
+ relationalBuilders["movement-eta"],
+ relationalBuilders["abort-rally"],
+ relationalBuilders["callsign-net"],
+ relationalBuilders["transport-link"],
+ ];
+ const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 2 : 4;
+ const relational = shuffle(rng, relationalPool).slice(0, relationalCount);
+ const recall = shuffle(rng, recallPool).slice(0, tier.questionCount - 1 - relationalCount);
+ return [gridBuilder(), ...shuffle(rng, [...relational, ...recall]).map((build) => build())];
}
export interface OpordRunResult {