Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1000 lines
35 KiB
TypeScript
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),
|
|
};
|
|
}
|