1
0
Fork 0

feat(opord): add garrison, ROE, intel, and secondary objectives with tier scaling

This commit is contained in:
Jason Fraley 2026-09-08 12:53:36 -04:00
parent d8ee14ee8d
commit 50b9b7e46c
3 changed files with 251 additions and 9 deletions

View file

@ -131,8 +131,10 @@ export function OpordArena({
<span className="font-medium text-foreground">Study</span> <span className="font-medium text-foreground">Study</span>
<span> <span>
The order covers the objective grid, azimuth, H-HOUR, phase lines, codewords, 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 callsign, and radio frequency. Veteran tiers add garrison tasks, rules of
menus blocked: there is no text to select, and "Begin recall" starts the quiz 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. whenever you are ready.
</span> </span>
</div> </div>

View file

@ -89,11 +89,17 @@ function buildBriefingRows(
}; };
const rows: BriefingRow[] = []; 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, "subtitle", "COPY 1 OF 1 · POLARIS TASK FORCE");
push(rows, "title", `OPERATION ${scenario.operationName}`); push(rows, "title", `OPERATION ${scenario.operationName}`);
push(rows, "subtitle", `OPERATIONS ORDER NO. ${String(scenario.seed).slice(-4).padStart(4, "0")}`); push(rows, "subtitle", `OPERATIONS ORDER NO. ${String(scenario.seed).slice(-4).padStart(4, "0")}`);
rows.push({ kind: "rule" }); rows.push({ kind: "rule" });
push(rows, "header", "1. SITUATION"); header("SITUATION");
const enemyStrength = const enemyStrength =
difficulty === "recruit" difficulty === "recruit"
? `${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s)` ? `${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s)`
@ -103,24 +109,57 @@ function buildBriefingRows(
"text", "text",
`Enemy: ${enemyStrength} dug in on objective ${scenario.objective} at grid ${formatOpordGrid(scenario.grid)}.`, `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, "text", `Friendly: ${scenario.friendlyUnit} in support aboard ${scenario.transport}.`);
push(rows, "header", "2. MISSION"); header("MISSION");
push( push(
rows, rows,
"text", "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")}.`, `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( push(
rows, rows,
"text", "text",
`Line of departure is phase line ${scenario.ldPhaseLine}. Subsequent phase lines: ${scenario.otherPhaseLines.length > 0 ? scenario.otherPhaseLines.join(", ") : "none assigned"}.`, `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") { if (difficulty === "recruit") {
push(rows, "header", "4. SERVICE SUPPORT"); header("SERVICE SUPPORT");
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`); push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
} else { } else {
push(rows, "header", "4. COORDINATION"); header("COORDINATION");
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`); push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
push( push(
rows, rows,
@ -129,7 +168,7 @@ function buildBriefingRows(
); );
} }
if (difficulty === "elite") { if (difficulty === "elite") {
push(rows, "header", "5. TIMING & CONTINGENCY"); header("TIMING & CONTINGENCY");
push( push(
rows, rows,
"text", "text",
@ -145,7 +184,7 @@ function buildBriefingRows(
); );
push(rows, "text", `Casualty evacuation lifts from ${scenario.casevacLz}.`); 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: "AUTHENTICATION CODEWORD:", value: scenario.codeword });
rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword }); rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword });
if (difficulty === "elite") { if (difficulty === "elite") {

View file

@ -233,6 +233,34 @@ export interface OpordScenario {
readonly withdrawalAzimuth: number; readonly withdrawalAzimuth: number;
/** Element action on abort; one of the abortActionBank templates. */ /** Element action on abort; one of the abortActionBank templates. */
readonly abortAction: string; 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". */ /** 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 // Shared by scenario generation (stores one entry as abortAction) and question
// building (offers the rest as distractors); every template reads only fields // building (offers the rest as distractors); every template reads only fields
// the elite briefing renders. // 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( function abortActionBank(
scenario: Pick< scenario: Pick<
OpordScenario, OpordScenario,
@ -303,6 +382,13 @@ export function generateOpordScenario(seed: number): OpordScenario {
abortActionBank({ rallyPoint, ldPhaseLine, otherPhaseLines, withdrawalAzimuth, casevacLz, friendlyUnit }), 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 { return {
seed, seed,
operationName, operationName,
@ -332,6 +418,20 @@ export function generateOpordScenario(seed: number): OpordScenario {
exfilOffsetMin, exfilOffsetMin,
withdrawalAzimuth, withdrawalAzimuth,
abortAction, 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}`), .map((name) => `LZ ${name}`),
rng, 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 // Relational questions link two rendered briefing elements instead of
@ -683,6 +850,26 @@ export function buildOpordQuestions(
).slice(0, 3), ).slice(0, 3),
rng, 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]; const tier = OPORD_DIFFICULTIES[difficulty];
@ -712,6 +899,10 @@ export function buildOpordQuestions(
recallBuilders.distance, recallBuilders.distance,
recallBuilders.squads, recallBuilders.squads,
recallBuilders.mortars, recallBuilders.mortars,
recallBuilders.garrisonCount,
recallBuilders.uniformMark,
recallBuilders.civilianStatus,
recallBuilders.secondaryObjective,
] ]
: [ : [
recallBuilders.codeword, recallBuilders.codeword,
@ -727,6 +918,13 @@ export function buildOpordQuestions(
recallBuilders.mortars, recallBuilders.mortars,
recallBuilders.checkIn, recallBuilders.checkIn,
recallBuilders.casevac, recallBuilders.casevac,
recallBuilders.garrisonCount,
recallBuilders.uniformMark,
recallBuilders.civilianStatus,
recallBuilders.secondaryObjective,
recallBuilders.tertiaryObjective,
recallBuilders.securedBuilding,
recallBuilders.priorityAsset,
]; ];
const relationalPool: readonly (() => OpordQuestion)[] = const relationalPool: readonly (() => OpordQuestion)[] =
difficulty === "veteran" difficulty === "veteran"
@ -737,6 +935,7 @@ export function buildOpordQuestions(
relationalBuilders["pl-sequence"], relationalBuilders["pl-sequence"],
relationalBuilders["transport-link"], relationalBuilders["transport-link"],
relationalBuilders["callsign-net"], relationalBuilders["callsign-net"],
relationalBuilders["overwatch-post"],
] ]
: [ : [
relationalBuilders["azimuth-cardinal"], relationalBuilders["azimuth-cardinal"],
@ -750,6 +949,8 @@ export function buildOpordQuestions(
relationalBuilders["resupply-time"], relationalBuilders["resupply-time"],
relationalBuilders["exfil-time"], relationalBuilders["exfil-time"],
relationalBuilders["withdrawal-cardinal"], relationalBuilders["withdrawal-cardinal"],
relationalBuilders["overwatch-post"],
relationalBuilders["intel-link"],
]; ];
const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 5 : 8; const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 5 : 8;
const relational = shuffle(rng, relationalPool).slice(0, relationalCount); const relational = shuffle(rng, relationalPool).slice(0, relationalCount);