/** * 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 = { 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(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(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 { // 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 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 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), }; }