1
0
Fork 0
polaris-task-force/src/lib/minigames/opord-recall.ts
2026-09-09 00:02:59 -04:00

1000 lines
35 KiB
TypeScript

/**
* OPORD Recall game rules. Pure module (no Payload imports), safe on client and server.
*
* A randomized operations order (OPORD) is generated from a numeric seed. The
* player studies the self-paced briefing, then answers a timed recall quiz.
* Briefing scope grows with the tier (recruit core order, veteran coordination
* annex, elite timing and contingency annex), and higher tiers ask relational
* questions whose answers link two rendered briefing elements. Every function
* is deterministic for a given seed so scenarios are reproducible and testable.
*/
export type OpordDifficulty = "recruit" | "veteran" | "elite";
const DIFFICULTY_VALUES: readonly OpordDifficulty[] = ["recruit", "veteran", "elite"];
export interface OpordTierConfig {
/** Suggested study time shown to the player (briefing itself is self-paced). */
readonly studyHintSeconds: number;
/** Number of quiz questions asked after the briefing. */
readonly questionCount: number;
/** Hard recall time limit in seconds; server rejects runs slower than this. */
readonly quizSeconds: number;
/** Points awarded per correct answer. */
readonly correctPoints: number;
/** Total answer time under which a perfect run earns the speed bonus. */
readonly parTimeMs: number;
/** Bonus for a perfect run inside the par time. */
readonly perfectBonus: number;
}
/** Grace added to the quiz limit for network/rounding before the server rejects. */
export const QUIZ_TIME_GRACE_MS = 2_500;
/**
* Countdown each question shows before answering unlocks; the client disables
* the options/input while it runs, and the server's minimum-run-time floor is
* derived from it.
*/
export const QUESTION_UNLOCK_DELAY_MS = 2_000;
// Tiers keep the 4:6:8 ratio scaled to 10:15:20 questions. The recall clock
// (~11s/question of active answering) and par time (~8s/question) scale with
// the question count; the per-question QUESTION_UNLOCK_DELAY_MS wait runs
// inside the clock, so both budgets include questionCount x 2s of locked time.
export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
recruit: {
studyHintSeconds: 30,
questionCount: 10,
quizSeconds: 130,
correctPoints: 100,
parTimeMs: 100_000,
perfectBonus: 150,
},
veteran: {
studyHintSeconds: 25,
questionCount: 15,
quizSeconds: 195,
correctPoints: 150,
parTimeMs: 150_000,
perfectBonus: 300,
},
elite: {
// Extra clock headroom: elite relational questions include time arithmetic.
studyHintSeconds: 20,
questionCount: 20,
quizSeconds: 265,
correctPoints: 200,
parTimeMs: 200_000,
perfectBonus: 500,
},
};
/**
* Fastest total quiz time a human could plausibly post: every question incurs
* the mandatory QUESTION_UNLOCK_DELAY_MS countdown before answering opens,
* plus a small read/submit buffer. The server rejects submissions below this
* as scripted.
*/
export function minOpordRunTimeMs(difficulty: OpordDifficulty): number {
return OPORD_DIFFICULTIES[difficulty].questionCount * QUESTION_UNLOCK_DELAY_MS + 500;
}
// XP tuning: modest flat rewards, replays motivated by score.
const COMPLETION_XP = 2;
const POINTS_PER_XP = 250;
const SCORE_XP_RATE = 1;
const NEW_TIER_RECORD_XP = 2;
const XP_CAP = 15;
export function isOpordDifficulty(value: unknown): value is OpordDifficulty {
return typeof value === "string" && (DIFFICULTY_VALUES as readonly string[]).includes(value);
}
export function calculateOpordAccuracy(correct: number, total: number): number {
if (total <= 0) return 0;
return Math.min(1, Math.max(0, correct / total));
}
export function calculateOpordScore(
difficulty: OpordDifficulty,
correct: number,
timeMs: number,
): number {
const tier = OPORD_DIFFICULTIES[difficulty];
const perfect = correct >= tier.questionCount && timeMs <= tier.parTimeMs;
return Math.max(0, correct * tier.correctPoints + (perfect ? tier.perfectBonus : 0));
}
export function calculateOpordXp(score: number, isNewTierBest: boolean): number {
const xp =
COMPLETION_XP +
Math.floor(score / POINTS_PER_XP) * SCORE_XP_RATE +
(isNewTierBest ? NEW_TIER_RECORD_XP : 0);
return Math.min(XP_CAP, xp);
}
/** Deterministic 32-bit RNG (same family the economy tick uses). */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pick<T>(rng: () => number, values: readonly T[]): T {
return values[Math.floor(rng() * values.length) % values.length];
}
function pickInt(rng: () => number, min: number, max: number): number {
return min + Math.floor(rng() * (max - min + 1));
}
function shuffle<T>(rng: () => number, values: readonly T[]): T[] {
const result = [...values];
for (let i = result.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = result[i];
result[i] = result[j];
result[j] = tmp;
}
return result;
}
/** Normalizes a typed answer for comparison: uppercase, alphanumerics only. */
export function normalizeOpordAnswer(value: string): string {
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
const OPERATION_ADJECTIVES = [
"IRON", "CRIMSON", "SILENT", "BROKEN", "AMBER", "HOLLOW", "STONE", "BLACK",
"SCARLET", "FROZEN", "BURNING", "SHADOW", "GRIM", "SABLE", "RED", "LAST",
] as const;
const OPERATION_NOUNS = [
"VIGIL", "HAMMER", "ANVIL", "LANCE", "SABRE", "FURY", "GUARDIAN", "SENTINEL",
"THUNDER", "ARROW", "BLADE", "SHIELD", "TALON", "SPIRE", "CIPHER", "PILGRIM",
] as const;
const OBJECTIVE_NAMES = [
"TRIDENT", "BEACON", "FOOTHILL", "ANVIL", "RELAY", "CITADEL", "OUTPOST", "GATEWAY",
"SPUR", "BASION", "LIGHTHOUSE", "CROSSING",
] as const;
const PHASE_LINE_NAMES = [
"AMBER", "CRIMSON", "COBALT", "JADE", "BURNT", "SLATE", "RUST", "IVORY",
] as const;
const CODEWORDS = [
"HAMMER", "VESPER", "TALISMAN", "NIGHTFALL", "WAYPOINT", "KEYSTONE", "MIRAGE", "TOTEM",
"LANTERN", "PENDULUM",
] as const;
const CALLSIGNS = [
"VIPER", "RAPTOR", "NOMAD", "WOLFPACK", "HAVOC", "WARDEN", "REAPER", "FALCON",
] as const;
const FRIENDLY_UNITS = [
"2nd Rifle Platoon", "3rd Rifle Platoon", "Weapons Squad", "1st Fire Team", "Recon Detachment",
] as const;
const TRANSPORT_OPTIONS = [
"hardened GMG trucks", "unarmored transports", "M-ATVs", "tracked APCs",
] as const;
const RALLY_POINT_NAMES = ["EAGLE", "WOLF", "TITAN", "SABER", "IRONHIDE", "BASTION"] as const;
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;
export interface OpordScenario {
readonly seed: number;
/** e.g. "IRON VIGIL" */
readonly operationName: string;
/** e.g. "TRIDENT" */
readonly objective: string;
/** Six-digit grid as digits only, e.g. "034672". */
readonly grid: string;
/** Three-digit degrees, e.g. 47. */
readonly azimuth: number;
readonly distanceKm: number;
/** Zero-padded Zulu time, e.g. "0430Z". */
readonly hHour: string;
readonly ldPhaseLine: string;
readonly otherPhaseLines: readonly string[];
readonly codeword: string;
readonly abortCodeword: string;
/** e.g. "VIPER 6" */
readonly callsign: string;
/** e.g. "32.450" */
readonly frequency: string;
readonly enemyVehicles: number;
readonly enemySquads: number;
readonly friendlyUnit: string;
readonly transport: string;
/** e.g. "RALLY POINT EAGLE"; rendered in the elite contingency annex. */
readonly rallyPoint: string;
/** e.g. "RAVEN 2"; close air support asset named in the veteran+ coordination annex. */
readonly aircraftCallsign: string;
/** Minutes after H-HOUR when close air support is on station. */
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;
/** 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". */
export function formatOpordGrid(grid: string): string {
return grid.length === 6 ? `${grid.slice(0, 3)}-${grid.slice(3)}` : grid;
}
/** Buckets a 1-359 azimuth onto the eight winds; 359 and 1 both resolve to NORTH. */
export function cardinalFromAzimuth(azimuth: number): string {
return CARDINAL_DIRECTIONS[Math.round(azimuth / 45) % 8];
}
// 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,
"rallyPoint" | "ldPhaseLine" | "otherPhaseLines" | "withdrawalAzimuth" | "casevacLz" | "friendlyUnit"
>,
): readonly string[] {
return [
`Return to ${scenario.rallyPoint} and await further orders`,
`Retire to phase line ${scenario.otherPhaseLines[1]} and hold`,
`Withdraw on azimuth ${pad3(scenario.withdrawalAzimuth)} to ${scenario.rallyPoint}`,
`Regroup at ${scenario.casevacLz} and await transport`,
`Fall back to ${scenario.friendlyUnit} and consolidate`,
`Hold short of phase line ${scenario.ldPhaseLine} and await orders`,
];
}
export function generateOpordScenario(seed: number): OpordScenario {
const rng = mulberry32(seed);
const operationName = `${pick(rng, OPERATION_ADJECTIVES)} ${pick(rng, OPERATION_NOUNS)}`;
const objective = pick(rng, OBJECTIVE_NAMES);
const grid = Array.from({ length: 6 }, () => Math.floor(rng() * 10)).join("");
const azimuth = pickInt(rng, 1, 359);
const distanceKm = pickInt(rng, 4, 24);
const hHourMinutes = pickInt(rng, 0, 23) * 100 + pick(rng, [0, 15, 30, 45]);
const hHour = `${String(hHourMinutes).padStart(4, "0")}Z`;
const phaseLines = shuffle(rng, PHASE_LINE_NAMES);
const codeword = pick(rng, CODEWORDS);
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;
const enemyVehicles = pickInt(rng, 1, 4);
const enemySquads = pickInt(rng, 1, 3);
const enemyMortars = pickInt(rng, 1, 3);
const friendlyUnit = pick(rng, FRIENDLY_UNITS);
const transport = pick(rng, TRANSPORT_OPTIONS);
const rallyPoint = `RALLY POINT ${pick(rng, RALLY_POINT_NAMES)}`;
const aircraftCallsign = `${pick(rng, AIRCRAFT_CALLSIGNS)} ${pick(rng, [1, 2, 3, 4])}`;
const airSupportOffsetMin = pick(rng, [15, 20, 30, 45]);
const enRouteMin = pickInt(rng, 18, 42);
const checkInMinutes = pick(rng, [10, 15, 20, 30]);
const casevacLz = `LZ ${pick(rng, LZ_NAMES)}`;
const ldPhaseLine = phaseLines[0];
const otherPhaseLines = phaseLines.slice(1, 3);
const abortAction = pick(
rng,
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,
objective,
grid,
azimuth,
distanceKm,
hHour,
ldPhaseLine,
otherPhaseLines,
codeword,
abortCodeword,
callsign,
frequency,
enemyVehicles,
enemySquads,
enemyMortars,
friendlyUnit,
transport,
rallyPoint,
aircraftCallsign,
airSupportOffsetMin,
enRouteMin,
checkInMinutes,
casevacLz,
resupplyOffsetMin,
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)),
};
}
export type OpordQuestion =
| {
readonly kind: "choice";
readonly id: string;
readonly prompt: string;
readonly options: readonly string[];
readonly correctIndex: number;
}
| {
readonly kind: "input";
readonly id: string;
readonly prompt: string;
readonly answer: string;
};
function choiceQuestion(
id: string,
prompt: string,
correct: string,
distractors: readonly string[],
rng: () => number,
): Extract<OpordQuestion, { kind: "choice" }> {
// Deduplicate distractors so the graded index always matches the visible text.
const unique = distractors.filter((value, index) => value !== correct && distractors.indexOf(value) === index);
const options = shuffle(rng, [correct, ...unique]);
return {
kind: "choice",
id,
prompt,
options,
correctIndex: options.indexOf(correct),
};
}
function pad3(value: number): string {
return String(value).padStart(3, "0");
}
function zuluShift(hHour: string, shiftMinutes: number): string {
const hours = Number(hHour.slice(0, 2));
const minutes = Number(hHour.slice(2, 4));
const total = (((hours * 60 + minutes + shiftMinutes) % 1440) + 1440) % 1440;
return `${pad3(Math.floor(total / 60) * 100 + (total % 60)).padStart(4, "0")}Z`;
}
/** Builds the tier's quiz for a scenario. Deterministic for a given seed. */
export function buildOpordQuestions(
scenario: OpordScenario,
difficulty: OpordDifficulty,
seed: number,
): readonly OpordQuestion[] {
const rng = mulberry32(seed ^ 0x9e3779b9);
const azimuthDecoys = [scenario.azimuth + 10, scenario.azimuth + 100, scenario.azimuth + 5].map(
(value) => pad3(((value - 1 + 360) % 360) + 1),
);
const codewordDistractors = shuffle(
rng,
CODEWORDS.filter((word) => word !== scenario.codeword && word !== scenario.abortCodeword),
).slice(0, 3);
const gridVariants = [
scenario.grid.split("").reverse().join(""),
scenario.grid.slice(3) + scenario.grid.slice(0, 3),
scenario.grid
.split("")
.map((digit) => String((Number(digit) + 1) % 10))
.join(""),
scenario.grid
.split("")
.map((digit) => String((Number(digit) + 2) % 10))
.join(""),
];
// A palindrome or half-repeating grid collapses one variant onto the answer;
// dedupe first so the question always shows four distinct options.
const gridDistractors = gridVariants
.filter((value, index) => value !== scenario.grid && gridVariants.indexOf(value) === index)
.slice(0, 3);
const gridBuilder = () =>
// The objective grid is the core skill: always asked first.
choiceQuestion(
"grid",
"What is the 6-digit grid of the objective?",
formatOpordGrid(scenario.grid),
gridDistractors.map((value) => formatOpordGrid(value)),
rng,
);
const recallBuilders: Record<string, () => OpordQuestion> = {
azimuth: () =>
choiceQuestion(
"azimuth",
"What azimuth (in degrees) is the main axis of advance?",
pad3(scenario.azimuth),
azimuthDecoys,
rng,
),
"h-hour": () =>
choiceQuestion(
"h-hour",
"What is H-HOUR?",
scenario.hHour,
[zuluShift(scenario.hHour, 30), zuluShift(scenario.hHour, -45), zuluShift(scenario.hHour, 75)],
rng,
),
codeword: () =>
choiceQuestion(
"codeword",
"What is the authentication codeword?",
scenario.codeword,
codewordDistractors,
rng,
),
abort: () =>
choiceQuestion(
"abort",
"What is the abort codeword?",
scenario.abortCodeword,
codewordDistractors,
rng,
),
callsign: () =>
choiceQuestion(
"callsign",
"What is your element's callsign?",
scenario.callsign,
shuffle(rng, CALLSIGNS.filter((name) => !scenario.callsign.startsWith(name)))
.slice(0, 3)
.map((name) => `${name} ${pick(rng, [1, 2, 3, 5, 6, 7])}`),
rng,
),
frequency: () =>
choiceQuestion(
"frequency",
"What is the assigned radio frequency?",
scenario.frequency,
[0.25, -0.25, 0.5].map((delta) => (Number(scenario.frequency) + delta).toFixed(3)),
rng,
),
enemy: () =>
choiceQuestion(
"enemy",
"How many enemy vehicles are on the objective?",
`${scenario.enemyVehicles}`,
["1", "2", "3", "4", "5"]
.filter((count) => count !== String(scenario.enemyVehicles))
.slice(0, 3),
rng,
),
objective: () =>
choiceQuestion(
"objective",
"What is the objective named?",
scenario.objective,
shuffle(rng, OBJECTIVE_NAMES.filter((name) => name !== scenario.objective)).slice(0, 3),
rng,
),
friendly: () =>
choiceQuestion(
"friendly",
"Which friendly unit is in support?",
scenario.friendlyUnit,
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,
),
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
// recalling a single value. Contract: every input an entry reads must be
// drawn in the briefing sections rendered for the tiers that include it.
const relationalBuilders: Record<string, () => OpordQuestion> = {
"azimuth-cardinal": () =>
choiceQuestion(
"azimuth-cardinal",
`The main axis of advance runs on azimuth ${pad3(scenario.azimuth)}. Which direction is that?`,
cardinalFromAzimuth(scenario.azimuth),
shuffle(
rng,
CARDINAL_DIRECTIONS.filter((dir) => dir !== cardinalFromAzimuth(scenario.azimuth)),
).slice(0, 3),
rng,
),
"time-cas": () =>
choiceQuestion(
"time-cas",
`Close air support (${scenario.aircraftCallsign}) is on station at H+${scenario.airSupportOffsetMin} minutes. What time is that (Zulu)?`,
zuluShift(scenario.hHour, scenario.airSupportOffsetMin),
[
zuluShift(scenario.hHour, scenario.airSupportOffsetMin - 15),
zuluShift(scenario.hHour, scenario.airSupportOffsetMin + 15),
zuluShift(scenario.hHour, scenario.airSupportOffsetMin + 30),
],
rng,
),
"phase-first": () =>
choiceQuestion(
"phase-first",
"Which phase line does the element cross first?",
scenario.ldPhaseLine,
scenario.otherPhaseLines.concat(
shuffle(rng, PHASE_LINE_NAMES.filter((name) => name !== scenario.ldPhaseLine)).filter(
(name) => !scenario.otherPhaseLines.includes(name),
)[0],
),
rng,
),
"movement-eta": () =>
choiceQuestion(
"movement-eta",
`The element crosses the line of departure at H-HOUR and the movement takes ${scenario.enRouteMin} minutes. When does it reach the objective?`,
zuluShift(scenario.hHour, scenario.enRouteMin),
[
zuluShift(scenario.hHour, scenario.enRouteMin - 10),
zuluShift(scenario.hHour, scenario.enRouteMin + 10),
zuluShift(scenario.hHour, 0),
],
rng,
),
"abort-rally": () =>
choiceQuestion(
"abort-rally",
`If the abort codeword "${scenario.abortCodeword}" is broadcast, what does your element do?`,
scenario.abortAction,
abortActionBank(scenario).filter((action) => action !== scenario.abortAction),
rng,
),
"callsign-net": () =>
choiceQuestion(
"callsign-net",
`Which net does element ${scenario.callsign} monitor?`,
`${scenario.frequency} MHz`,
[
`${(Number(scenario.frequency) + 0.35).toFixed(3)} MHz`,
`${(Number(scenario.frequency) - 0.3).toFixed(3)} MHz`,
`${(Number(scenario.frequency) + 0.75).toFixed(3)} MHz`,
],
rng,
),
"transport-link": () =>
choiceQuestion(
"transport-link",
`Which transport carries ${scenario.friendlyUnit}?`,
scenario.transport,
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,
),
"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 recallPool: readonly (() => OpordQuestion)[] =
difficulty === "recruit"
? [
recallBuilders.azimuth,
recallBuilders["h-hour"],
recallBuilders.codeword,
recallBuilders.abort,
recallBuilders.objective,
recallBuilders.enemy,
recallBuilders.callsign,
recallBuilders.friendly,
recallBuilders.distance,
recallBuilders.squads,
]
: difficulty === "veteran"
? [
recallBuilders.codeword,
recallBuilders.abort,
recallBuilders.callsign,
recallBuilders.frequency,
recallBuilders.enemy,
recallBuilders.objective,
recallBuilders.friendly,
recallBuilders.distance,
recallBuilders.squads,
recallBuilders.mortars,
recallBuilders.garrisonCount,
recallBuilders.uniformMark,
recallBuilders.civilianStatus,
recallBuilders.secondaryObjective,
]
: [
recallBuilders.codeword,
recallBuilders.abort,
recallBuilders.callsign,
recallBuilders.enemy,
recallBuilders.objective,
recallBuilders.friendly,
recallBuilders["h-hour"],
recallBuilders.frequency,
recallBuilders.distance,
recallBuilders.squads,
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"
? [
relationalBuilders["azimuth-cardinal"],
relationalBuilders["time-cas"],
relationalBuilders["phase-first"],
relationalBuilders["pl-sequence"],
relationalBuilders["transport-link"],
relationalBuilders["callsign-net"],
relationalBuilders["overwatch-post"],
]
: [
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"],
relationalBuilders["overwatch-post"],
relationalBuilders["intel-link"],
];
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())];
}
export interface OpordRunResult {
readonly difficulty: OpordDifficulty;
readonly correct: number;
readonly total: number;
readonly timeMs: number;
readonly score: number;
readonly accuracy: number;
readonly xp: number;
}
/** Grades a completed run from raw counts (used by both UI preview and server). */
export function gradeOpordRun(
difficulty: OpordDifficulty,
correct: number,
timeMs: number,
isNewTierBest: boolean,
): OpordRunResult {
const tier = OPORD_DIFFICULTIES[difficulty];
const boundedCorrect = Math.min(Math.max(0, correct), tier.questionCount);
const score = calculateOpordScore(difficulty, boundedCorrect, timeMs);
return {
difficulty,
correct: boundedCorrect,
total: tier.questionCount,
timeMs,
score,
accuracy: calculateOpordAccuracy(boundedCorrect, tier.questionCount),
xp: calculateOpordXp(score, isNewTierBest),
};
}