From 3fefb8b4a37d722293e6126c4ec7899ec019e8b2 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 11:58:15 -0400 Subject: [PATCH] feat(minigames): tier-scaled briefings with relational questions for OPORD Recall --- src/components/frontend/opord/OpordArena.tsx | 12 +- src/components/frontend/opord/OpordGame.tsx | 48 +++- src/lib/opord-recall.ts | 251 +++++++++++++++---- 3 files changed, 247 insertions(+), 64 deletions(-) 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 {