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);