diff --git a/src/components/frontend/opord/OpordArena.tsx b/src/components/frontend/opord/OpordArena.tsx index 35b70c2..a07fc43 100644 --- a/src/components/frontend/opord/OpordArena.tsx +++ b/src/components/frontend/opord/OpordArena.tsx @@ -131,8 +131,10 @@ export function OpordArena({ Study The order covers the objective grid, azimuth, H-HOUR, phase lines, codewords, - callsign, and radio frequency. It is rendered as an image with copy and context - menus blocked: there is no text to select, and "Begin recall" starts the quiz + callsign, and radio frequency. Veteran tiers add garrison tasks, rules of + engagement, and a secondary objective; elite adds intelligence targets and a + tertiary objective. It is rendered as an image with copy and context menus + blocked: there is no text to select, and "Begin recall" starts the quiz whenever you are ready. diff --git a/src/components/frontend/opord/OpordGame.tsx b/src/components/frontend/opord/OpordGame.tsx index 73c27d6..38cc6d1 100644 --- a/src/components/frontend/opord/OpordGame.tsx +++ b/src/components/frontend/opord/OpordGame.tsx @@ -89,11 +89,17 @@ function buildBriefingRows( }; 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" }); - push(rows, "header", "1. SITUATION"); + header("SITUATION"); const enemyStrength = difficulty === "recruit" ? `${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s)` @@ -103,24 +109,57 @@ function buildBriefingRows( "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}.`); - push(rows, "header", "2. MISSION"); + 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")}.`, ); - push(rows, "header", "3. EXECUTION"); + 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") { - push(rows, "header", "4. SERVICE SUPPORT"); + header("SERVICE SUPPORT"); push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`); } else { - push(rows, "header", "4. COORDINATION"); + header("COORDINATION"); push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`); push( rows, @@ -129,7 +168,7 @@ function buildBriefingRows( ); } if (difficulty === "elite") { - push(rows, "header", "5. TIMING & CONTINGENCY"); + header("TIMING & CONTINGENCY"); push( rows, "text", @@ -145,7 +184,7 @@ function buildBriefingRows( ); push(rows, "text", `Casualty evacuation lifts from ${scenario.casevacLz}.`); } - push(rows, "header", difficulty === "elite" ? "6. COMMAND & SIGNAL" : "5. COMMAND & SIGNAL"); + 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") { diff --git a/src/lib/opord-recall.ts b/src/lib/opord-recall.ts index 9aa95c6..b7a30d7 100644 --- a/src/lib/opord-recall.ts +++ b/src/lib/opord-recall.ts @@ -233,6 +233,34 @@ export interface OpordScenario { readonly withdrawalAzimuth: number; /** Element action on abort; one of the abortActionBank templates. */ readonly abortAction: string; + /** e.g. "Fire Team ALPHA"; friendly maneuver element tasked in the veteran+ execution annex. */ + readonly maneuverElement: string; + /** Building number (10-99) the maneuver element secures; distinct from every other building number. */ + readonly securedBuilding: number; + /** e.g. "ROMEO 2"; overwatch element named in the veteran+ execution annex. */ + readonly overwatchCallsign: string; + /** e.g. "the water tower"; where the overwatch element sets in (veteran+). */ + readonly overwatchPosition: string; + /** e.g. "the northern approach"; what the overwatch element covers (veteran+). */ + readonly overwatchTarget: string; + /** Enemy garrison headcount reported across two buildings (veteran+). */ + readonly garrisonCount: number; + /** The two garrison building numbers, both distinct from all other building numbers. */ + readonly garrisonBuildings: readonly [number, number]; + /** e.g. "a red armband"; positive-identification mark distinguishing enemy uniforms (veteran+). */ + readonly enemyUniformMark: string; + /** e.g. "possible on the objective"; rendered as "Civilian presence ${...}" (veteran+). */ + readonly civilianStatus: string; + /** e.g. "the shipment manifests"; priority collection target (elite). */ + readonly intelItem: string; + /** Building number (10-99) holding the collection target, distinct from all others (elite). */ + readonly intelBuilding: number; + /** e.g. "two field guns"; identified enemy priority asset (elite). */ + readonly priorityAsset: string; + /** e.g. "the comms relay"; secondary objective task (veteran+). */ + readonly secondaryObjective: string; + /** e.g. "the mortar position"; tertiary objective task, distinct from secondary (elite). */ + readonly tertiaryObjective: string; } /** Formats a 6-digit grid Arma map style: three digits, hyphen, three digits, e.g. "034-672". */ @@ -248,6 +276,57 @@ export function cardinalFromAzimuth(azimuth: number): string { // Shared by scenario generation (stores one entry as abortAction) and question // building (offers the rest as distractors); every template reads only fields // the elite briefing renders. +const FIRE_TEAMS = ["ALPHA", "BRAVO", "CHARLIE", "DELTA"] as const; +const OVERWATCH_CALLSIGNS = ["ROMEO", "SIERRA", "TANGO", "YANKEE"] as const; +const OVERWATCH_POSITIONS = [ + "the church bell tower", + "the water tower", + "the grain silo", + "the parking structure", + "the rooftop terrace", + "Hill 140", +] as const; +const OVERWATCH_TARGETS = [ + "the northern approach", + "the eastern avenue", + "the rail underpass", + "the main street junction", + "the western courtyard", + "the market square", +] as const; +const UNIFORM_MARKS = [ + "a red armband", + "a white brassard", + "an orange chevron", + "a blue shoulder patch", + "a yellow pennant", +] as const; +const CIVILIAN_STATUS = [ + "not reported", + "possible on the objective", + "confirmed sheltering in place", + "evacuated before H-HOUR", +] as const; +const INTEL_ITEMS = [ + "the shipment manifests", + "the commander's map case", + "the Signals Intelligence kit", + "the comms shack hard drive", +] as const; +const PRIORITY_ASSETS = [ + "two field guns", + "a troop transport vehicle", + "a mobile SAM launcher", + "a fuel bladder farm", +] as const; +const OBJECTIVE_TASKS = [ + "the comms relay", + "the mortar position", + "the ammunition cache", + "the fuel depot", + "the vehicle park", +] as const; + function abortActionBank( scenario: Pick< OpordScenario, @@ -303,6 +382,13 @@ export function generateOpordScenario(seed: number): OpordScenario { abortActionBank({ rallyPoint, ldPhaseLine, otherPhaseLines, withdrawalAzimuth, casevacLz, friendlyUnit }), ); + const buildings: number[] = []; + while (buildings.length < 4) { + const candidate = pickInt(rng, 10, 99); + if (!buildings.includes(candidate)) buildings.push(candidate); + } + const secondaryObjective = pick(rng, OBJECTIVE_TASKS); + return { seed, operationName, @@ -332,6 +418,20 @@ export function generateOpordScenario(seed: number): OpordScenario { exfilOffsetMin, withdrawalAzimuth, abortAction, + maneuverElement: `Fire Team ${pick(rng, FIRE_TEAMS)}`, + securedBuilding: buildings[0], + overwatchCallsign: `${pick(rng, OVERWATCH_CALLSIGNS)} ${pick(rng, [2, 3, 4, 6])}`, + overwatchPosition: pick(rng, OVERWATCH_POSITIONS), + overwatchTarget: pick(rng, OVERWATCH_TARGETS), + garrisonCount: pickInt(rng, 6, 24), + garrisonBuildings: [buildings[1], buildings[2]], + enemyUniformMark: pick(rng, UNIFORM_MARKS), + civilianStatus: pick(rng, CIVILIAN_STATUS), + intelItem: pick(rng, INTEL_ITEMS), + intelBuilding: buildings[3], + priorityAsset: pick(rng, PRIORITY_ASSETS), + secondaryObjective, + tertiaryObjective: pick(rng, OBJECTIVE_TASKS.filter((task) => task !== secondaryObjective)), }; } @@ -551,6 +651,73 @@ export function buildOpordQuestions( .map((name) => `LZ ${name}`), rng, ), + garrisonCount: () => + choiceQuestion( + "garrison-count", + "How many garrisoned personnel are reported on the objective?", + `${scenario.garrisonCount}`, + [scenario.garrisonCount - 4, scenario.garrisonCount - 2, scenario.garrisonCount + 2, scenario.garrisonCount + 4] + .filter((value) => value >= 1 && value !== scenario.garrisonCount) + .slice(0, 3) + .map(String), + rng, + ), + uniformMark: () => + choiceQuestion( + "uniform-mark", + "Which distinguishing mark identifies enemy personnel?", + scenario.enemyUniformMark, + shuffle(rng, UNIFORM_MARKS.filter((mark) => mark !== scenario.enemyUniformMark)).slice(0, 3), + rng, + ), + civilianStatus: () => + choiceQuestion( + "civilian-status", + "What civilian presence is reported for the objective?", + scenario.civilianStatus, + shuffle(rng, CIVILIAN_STATUS.filter((status) => status !== scenario.civilianStatus)).slice(0, 3), + rng, + ), + secondaryObjective: () => + choiceQuestion( + "secondary-objective", + "What is the element's secondary objective?", + `Neutralize ${scenario.secondaryObjective}`, + shuffle(rng, OBJECTIVE_TASKS.filter((task) => task !== scenario.secondaryObjective)) + .slice(0, 3) + .map((task) => `Neutralize ${task}`), + rng, + ), + tertiaryObjective: () => + choiceQuestion( + "tertiary-objective", + "What is the element's tertiary objective?", + `Neutralize ${scenario.tertiaryObjective}`, + shuffle(rng, OBJECTIVE_TASKS.filter((task) => task !== scenario.tertiaryObjective)) + .slice(0, 3) + .map((task) => `Neutralize ${task}`), + rng, + ), + securedBuilding: () => + choiceQuestion( + "secured-building", + `Which building does ${scenario.maneuverElement} secure?`, + `Building ${scenario.securedBuilding}`, + [ + `Building ${scenario.intelBuilding}`, + `Building ${scenario.garrisonBuildings[0]}`, + `Building ${scenario.garrisonBuildings[1]}`, + ], + rng, + ), + priorityAsset: () => + choiceQuestion( + "priority-asset", + "Which enemy priority asset was identified on the objective?", + scenario.priorityAsset, + shuffle(rng, PRIORITY_ASSETS.filter((asset) => asset !== scenario.priorityAsset)).slice(0, 3), + rng, + ), }; // Relational questions link two rendered briefing elements instead of @@ -683,6 +850,26 @@ export function buildOpordQuestions( ).slice(0, 3), rng, ), + "overwatch-post": () => + choiceQuestion( + "overwatch-post", + `From which position does ${scenario.overwatchCallsign} overwatch ${scenario.overwatchTarget}?`, + scenario.overwatchPosition, + shuffle(rng, OVERWATCH_POSITIONS.filter((position) => position !== scenario.overwatchPosition)).slice(0, 3), + rng, + ), + "intel-link": () => + choiceQuestion( + "intel-link", + `From where is ${scenario.intelItem} recovered?`, + `Building ${scenario.intelBuilding}`, + [ + `Building ${scenario.securedBuilding}`, + `Building ${scenario.garrisonBuildings[0]}`, + `Building ${scenario.garrisonBuildings[1]}`, + ], + rng, + ), }; const tier = OPORD_DIFFICULTIES[difficulty]; @@ -712,6 +899,10 @@ export function buildOpordQuestions( recallBuilders.distance, recallBuilders.squads, recallBuilders.mortars, + recallBuilders.garrisonCount, + recallBuilders.uniformMark, + recallBuilders.civilianStatus, + recallBuilders.secondaryObjective, ] : [ recallBuilders.codeword, @@ -727,6 +918,13 @@ export function buildOpordQuestions( recallBuilders.mortars, recallBuilders.checkIn, recallBuilders.casevac, + recallBuilders.garrisonCount, + recallBuilders.uniformMark, + recallBuilders.civilianStatus, + recallBuilders.secondaryObjective, + recallBuilders.tertiaryObjective, + recallBuilders.securedBuilding, + recallBuilders.priorityAsset, ]; const relationalPool: readonly (() => OpordQuestion)[] = difficulty === "veteran" @@ -737,6 +935,7 @@ export function buildOpordQuestions( relationalBuilders["pl-sequence"], relationalBuilders["transport-link"], relationalBuilders["callsign-net"], + relationalBuilders["overwatch-post"], ] : [ relationalBuilders["azimuth-cardinal"], @@ -750,6 +949,8 @@ export function buildOpordQuestions( relationalBuilders["resupply-time"], relationalBuilders["exfil-time"], relationalBuilders["withdrawal-cardinal"], + relationalBuilders["overwatch-post"], + relationalBuilders["intel-link"], ]; const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 5 : 8; const relational = shuffle(rng, relationalPool).slice(0, relationalCount);