feat(opord): add garrison, ROE, intel, and secondary objectives with tier scaling
This commit is contained in:
parent
d8ee14ee8d
commit
50b9b7e46c
3 changed files with 251 additions and 9 deletions
|
|
@ -131,8 +131,10 @@ export function OpordArena({
|
|||
<span className="font-medium text-foreground">Study</span>
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue