From 0996b9b7c2a8a384b894ee0f3fbcbdb3b714081c Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 12:34:37 -0400 Subject: [PATCH] feat(opord): scale tiers to 10/15/20 questions with expanded briefings --- src/components/frontend/opord/OpordArena.tsx | 6 +- src/components/frontend/opord/OpordGame.tsx | 21 ++- src/lib/opord-recall.ts | 175 +++++++++++++++++-- tests/e2e/opord-recall.e2e.spec.ts | 6 +- 4 files changed, 188 insertions(+), 20 deletions(-) diff --git a/src/components/frontend/opord/OpordArena.tsx b/src/components/frontend/opord/OpordArena.tsx index 07b4960..35b70c2 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 · core order" }, - { value: "veteran", label: "Veteran", detail: "6 questions · 60s recall · +coordination annex" }, - { value: "elite", label: "Elite", detail: "8 questions · 85s recall · full order" }, + { value: "recruit", label: "Recruit", detail: "10 questions · 110s recall · core order" }, + { value: "veteran", label: "Veteran", detail: "15 questions · 165s recall · +coordination annex" }, + { value: "elite", label: "Elite", detail: "20 questions · 225s recall · full order" }, ] as const satisfies ReadonlyArray<{ readonly value: OpordDifficulty; readonly label: string; diff --git a/src/components/frontend/opord/OpordGame.tsx b/src/components/frontend/opord/OpordGame.tsx index 11c763e..f5f4509 100644 --- a/src/components/frontend/opord/OpordGame.tsx +++ b/src/components/frontend/opord/OpordGame.tsx @@ -94,10 +94,14 @@ function buildBriefingRows( push(rows, "subtitle", `OPERATIONS ORDER NO. ${String(scenario.seed).slice(-4).padStart(4, "0")}`); rows.push({ kind: "rule" }); push(rows, "header", "1. 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: ${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s) dug in on objective ${scenario.objective} at grid ${formatOpordGrid(scenario.grid)}.`, + `Enemy: ${enemyStrength} dug in on objective ${scenario.objective} at grid ${formatOpordGrid(scenario.grid)}.`, ); push(rows, "text", `Friendly: ${scenario.friendlyUnit} in support aboard ${scenario.transport}.`); push(rows, "header", "2. MISSION"); @@ -132,10 +136,25 @@ function buildBriefingRows( `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, "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}.`); } 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 }); + if (difficulty === "elite") { + rows.push({ + kind: "kv", + label: "STATUS CHECK-INS:", + value: `every ${scenario.checkInMinutes} minutes`, + }); + } rows.push({ kind: "gap" }); return rows; } diff --git a/src/lib/opord-recall.ts b/src/lib/opord-recall.ts index 7a4ac94..3f3cb40 100644 --- a/src/lib/opord-recall.ts +++ b/src/lib/opord-recall.ts @@ -31,30 +31,32 @@ export interface OpordTierConfig { /** Grace added to the quiz limit for network/rounding before the server rejects. */ export const QUIZ_TIME_GRACE_MS = 2_500; +// Tiers keep the 4:6:8 ratio scaled to 10:15:20 questions; the recall clock +// (~11s/question) and par time (~8s/question) scale with the question count. export const OPORD_DIFFICULTIES: Record = { recruit: { - studyHintSeconds: 25, - questionCount: 4, - quizSeconds: 45, + studyHintSeconds: 30, + questionCount: 10, + quizSeconds: 110, correctPoints: 100, - parTimeMs: 32_000, + parTimeMs: 80_000, perfectBonus: 150, }, veteran: { - studyHintSeconds: 20, - questionCount: 6, - quizSeconds: 60, + studyHintSeconds: 25, + questionCount: 15, + quizSeconds: 165, correctPoints: 150, - parTimeMs: 45_000, + parTimeMs: 120_000, perfectBonus: 300, }, elite: { // Extra clock headroom: elite relational questions include time arithmetic. - studyHintSeconds: 15, - questionCount: 8, - quizSeconds: 85, + studyHintSeconds: 20, + questionCount: 20, + quizSeconds: 225, correctPoints: 200, - parTimeMs: 68_000, + parTimeMs: 160_000, perfectBonus: 500, }, }; @@ -178,6 +180,8 @@ const RALLY_POINT_NAMES = ["EAGLE", "WOLF", "TITAN", "SABER", "IRONHIDE", "BASTI const AIRCRAFT_CALLSIGNS = ["RAVEN", "THUNDER", "HARRIER", "OSPREY", "COMET", "GHOST"] as const; +const LZ_NAMES = ["BISON", "TARPAN", "KESTREL", "MAMMOTH", "OBELISK", "SANDPIPER"] as const; + const CARDINAL_DIRECTIONS = [ "NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST", ] as const; @@ -215,6 +219,18 @@ export interface OpordScenario { readonly airSupportOffsetMin: number; /** Minutes of movement from the line of departure to the objective. */ readonly enRouteMin: number; + /** Enemy mortar teams supporting the objective (1-3), rendered at veteran+ tiers. */ + readonly enemyMortars: number; + /** Minutes between mandatory status check-ins with the TOC. */ + readonly checkInMinutes: number; + /** e.g. "LZ KESTREL"; designated casualty evacuation landing zone. */ + readonly casevacLz: string; + /** Minutes after H-HOUR when resupply reaches the objective (90-180). */ + readonly resupplyOffsetMin: number; + /** Minutes after H-HOUR when the element must be off the objective; always later than resupply. */ + readonly exfilOffsetMin: number; + /** Egress azimuth in degrees (1-359); never equals the main axis azimuth. */ + readonly withdrawalAzimuth: number; } /** Formats a 6-digit grid Arma map style: three digits, hyphen, three digits, e.g. "034-672". */ @@ -241,6 +257,13 @@ export function generateOpordScenario(seed: number): OpordScenario { const abortCodeword = pick(rng, CODEWORDS.filter((word) => word !== codeword)); const callsign = `${pick(rng, CALLSIGNS)} ${pick(rng, [1, 2, 3, 5, 6, 7])}`; const frequency = `${pickInt(rng, 28, 45)}.${String(pickInt(rng, 0, 999)).padStart(3, "0")}`; + const resupplyOffsetMin = pick(rng, [90, 120, 150, 180]); + // Exfil always follows resupply, so the two timing questions never share an answer. + const exfilOffsetMin = resupplyOffsetMin + pick(rng, [30, 60, 90]); + // Offsets of 120-210 degrees keep the withdrawal clear of the main axis + // azimuth and its cardinal direction, so the two questions never collide. + const wrappedWithdrawal = (azimuth + pick(rng, [120, 150, 180, 210])) % 360; + const withdrawalAzimuth = wrappedWithdrawal === 0 ? 359 : wrappedWithdrawal; return { seed, @@ -258,12 +281,18 @@ export function generateOpordScenario(seed: number): OpordScenario { frequency, enemyVehicles: pickInt(rng, 1, 4), enemySquads: pickInt(rng, 1, 3), + enemyMortars: 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), + checkInMinutes: pick(rng, [10, 15, 20, 30]), + casevacLz: `LZ ${pick(rng, LZ_NAMES)}`, + resupplyOffsetMin, + exfilOffsetMin, + withdrawalAzimuth, }; } @@ -432,6 +461,57 @@ export function buildOpordQuestions( shuffle(rng, FRIENDLY_UNITS.filter((unit) => unit !== scenario.friendlyUnit)).slice(0, 3), rng, ), + distance: () => + choiceQuestion( + "distance", + "How far must the element advance to reach the objective?", + `${scenario.distanceKm} km`, + [scenario.distanceKm - 3, scenario.distanceKm + 2, scenario.distanceKm + 7] + .map((value) => Math.max(1, value)) + .map((value) => `${value} km`), + rng, + ), + squads: () => + choiceQuestion( + "squads", + "How many enemy infantry squads are on the objective?", + `${scenario.enemySquads}`, + ["1", "2", "3", "4", "5"] + .filter((count) => count !== String(scenario.enemySquads)) + .slice(0, 3), + rng, + ), + mortars: () => + choiceQuestion( + "mortars", + "How many enemy mortar teams support the objective?", + `${scenario.enemyMortars}`, + ["1", "2", "3", "4"] + .filter((count) => count !== String(scenario.enemyMortars)) + .slice(0, 3), + rng, + ), + checkIn: () => + choiceQuestion( + "check-in", + "How often must the element check in with the TOC?", + `Every ${scenario.checkInMinutes} minutes`, + [10, 15, 20, 30] + .filter((value) => value !== scenario.checkInMinutes) + .slice(0, 3) + .map((value) => `Every ${value} minutes`), + rng, + ), + casevac: () => + choiceQuestion( + "casevac", + "Which landing zone is designated for casualty evacuation?", + scenario.casevacLz, + shuffle(rng, LZ_NAMES.filter((name) => scenario.casevacLz !== `LZ ${name}`)) + .slice(0, 3) + .map((name) => `LZ ${name}`), + rng, + ), }; // Relational questions link two rendered briefing elements instead of @@ -517,6 +597,57 @@ export function buildOpordQuestions( shuffle(rng, TRANSPORT_OPTIONS.filter((option) => option !== scenario.transport)).slice(0, 3), rng, ), + "pl-sequence": () => + choiceQuestion( + "pl-sequence", + "Which phase line does the element cross immediately after the line of departure?", + scenario.otherPhaseLines[0], + [ + scenario.otherPhaseLines[1], + ...shuffle( + rng, + PHASE_LINE_NAMES.filter( + (name) => name !== scenario.ldPhaseLine && !scenario.otherPhaseLines.includes(name), + ), + ).slice(0, 2), + ], + rng, + ), + "resupply-time": () => + choiceQuestion( + "resupply-time", + `Resupply is scheduled at H+${scenario.resupplyOffsetMin} minutes. What time is that (Zulu)?`, + zuluShift(scenario.hHour, scenario.resupplyOffsetMin), + [ + zuluShift(scenario.hHour, scenario.resupplyOffsetMin - 15), + zuluShift(scenario.hHour, scenario.resupplyOffsetMin + 15), + zuluShift(scenario.hHour, scenario.resupplyOffsetMin + 30), + ], + rng, + ), + "exfil-time": () => + choiceQuestion( + "exfil-time", + `The element must be off the objective no later than H+${scenario.exfilOffsetMin} minutes. What is the latest (Zulu) exfil time?`, + zuluShift(scenario.hHour, scenario.exfilOffsetMin), + [ + zuluShift(scenario.hHour, scenario.exfilOffsetMin - 15), + zuluShift(scenario.hHour, scenario.exfilOffsetMin + 15), + zuluShift(scenario.hHour, scenario.exfilOffsetMin + 30), + ], + rng, + ), + "withdrawal-cardinal": () => + choiceQuestion( + "withdrawal-cardinal", + `The withdrawal route runs on azimuth ${pad3(scenario.withdrawalAzimuth)}. Which direction is that?`, + cardinalFromAzimuth(scenario.withdrawalAzimuth), + shuffle( + rng, + CARDINAL_DIRECTIONS.filter((dir) => dir !== cardinalFromAzimuth(scenario.withdrawalAzimuth)), + ).slice(0, 3), + rng, + ), }; const tier = OPORD_DIFFICULTIES[difficulty]; @@ -531,6 +662,8 @@ export function buildOpordQuestions( recallBuilders.enemy, recallBuilders.callsign, recallBuilders.friendly, + recallBuilders.distance, + recallBuilders.squads, ] : difficulty === "veteran" ? [ @@ -541,6 +674,9 @@ export function buildOpordQuestions( recallBuilders.enemy, recallBuilders.objective, recallBuilders.friendly, + recallBuilders.distance, + recallBuilders.squads, + recallBuilders.mortars, ] : [ recallBuilders.codeword, @@ -550,6 +686,12 @@ export function buildOpordQuestions( recallBuilders.objective, recallBuilders.friendly, recallBuilders["h-hour"], + recallBuilders.frequency, + recallBuilders.distance, + recallBuilders.squads, + recallBuilders.mortars, + recallBuilders.checkIn, + recallBuilders.casevac, ]; const relationalPool: readonly (() => OpordQuestion)[] = difficulty === "veteran" @@ -557,17 +699,24 @@ export function buildOpordQuestions( relationalBuilders["azimuth-cardinal"], relationalBuilders["time-cas"], relationalBuilders["phase-first"], + relationalBuilders["pl-sequence"], + relationalBuilders["transport-link"], + relationalBuilders["callsign-net"], ] : [ relationalBuilders["azimuth-cardinal"], relationalBuilders["time-cas"], relationalBuilders["phase-first"], + relationalBuilders["pl-sequence"], relationalBuilders["movement-eta"], relationalBuilders["abort-rally"], relationalBuilders["callsign-net"], relationalBuilders["transport-link"], + relationalBuilders["resupply-time"], + relationalBuilders["exfil-time"], + relationalBuilders["withdrawal-cardinal"], ]; - const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 2 : 4; + const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 5 : 8; 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())]; diff --git a/tests/e2e/opord-recall.e2e.spec.ts b/tests/e2e/opord-recall.e2e.spec.ts index d2f2a9b..3b1210e 100644 --- a/tests/e2e/opord-recall.e2e.spec.ts +++ b/tests/e2e/opord-recall.e2e.spec.ts @@ -28,13 +28,13 @@ async function playOpordRound(page: Page): Promise { const questionCounter = page.getByText(/Question \d+ of \d+/); await expect(questionCounter).toBeVisible(); - await expect(questionCounter).toContainText("Question 1 of 4"); + 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 < 4; index += 1) { - await expect(questionCounter).toContainText(`Question ${index + 1} of 4`, { + 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();