1
0
Fork 0

feat(opord): scale tiers to 10/15/20 questions with expanded briefings

This commit is contained in:
Jason Fraley 2026-09-08 12:34:37 -04:00
parent 987250b60a
commit 0996b9b7c2
4 changed files with 188 additions and 20 deletions

View file

@ -13,9 +13,9 @@ import { OpordGame } from "./OpordGame";
import { OpordLeaderboard } from "./OpordLeaderboard"; import { OpordLeaderboard } from "./OpordLeaderboard";
const TIER_OPTIONS = [ const TIER_OPTIONS = [
{ value: "recruit", label: "Recruit", detail: "4 questions · 45s recall · core order" }, { value: "recruit", label: "Recruit", detail: "10 questions · 110s recall · core order" },
{ value: "veteran", label: "Veteran", detail: "6 questions · 60s recall · +coordination annex" }, { value: "veteran", label: "Veteran", detail: "15 questions · 165s recall · +coordination annex" },
{ value: "elite", label: "Elite", detail: "8 questions · 85s recall · full order" }, { value: "elite", label: "Elite", detail: "20 questions · 225s recall · full order" },
] as const satisfies ReadonlyArray<{ ] as const satisfies ReadonlyArray<{
readonly value: OpordDifficulty; readonly value: OpordDifficulty;
readonly label: string; readonly label: string;

View file

@ -94,10 +94,14 @@ function buildBriefingRows(
push(rows, "subtitle", `OPERATIONS ORDER NO. ${String(scenario.seed).slice(-4).padStart(4, "0")}`); push(rows, "subtitle", `OPERATIONS ORDER NO. ${String(scenario.seed).slice(-4).padStart(4, "0")}`);
rows.push({ kind: "rule" }); rows.push({ kind: "rule" });
push(rows, "header", "1. SITUATION"); push(rows, "header", "1. SITUATION");
const enemyStrength =
difficulty === "recruit"
? `${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s)`
: `${scenario.enemyVehicles} vehicle(s), ${scenario.enemySquads} infantry squad(s), and ${scenario.enemyMortars} mortar team(s)`;
push( push(
rows, rows,
"text", "text",
`Enemy: ${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s) dug in on objective ${scenario.objective} at grid ${formatOpordGrid(scenario.grid)}.`, `Enemy: ${enemyStrength} dug in on objective ${scenario.objective} at grid ${formatOpordGrid(scenario.grid)}.`,
); );
push(rows, "text", `Friendly: ${scenario.friendlyUnit} in support aboard ${scenario.transport}.`); push(rows, "text", `Friendly: ${scenario.friendlyUnit} in support aboard ${scenario.transport}.`);
push(rows, "header", "2. MISSION"); push(rows, "header", "2. MISSION");
@ -132,10 +136,25 @@ function buildBriefingRows(
`Movement from the line of departure to the objective takes ${scenario.enRouteMin} minutes.`, `Movement from the line of departure to the objective takes ${scenario.enRouteMin} minutes.`,
); );
push(rows, "text", `On abort, return to ${scenario.rallyPoint} and await further orders.`); push(rows, "text", `On abort, return to ${scenario.rallyPoint} and await further orders.`);
push(rows, "text", `Resupply is scheduled at H+${scenario.resupplyOffsetMin} minutes.`);
push(rows, "text", `Exfiltration no later than H+${scenario.exfilOffsetMin} minutes.`);
push(
rows,
"text",
`Withdrawal route runs on azimuth ${String(scenario.withdrawalAzimuth).padStart(3, "0")}.`,
);
push(rows, "text", `Casualty evacuation lifts from ${scenario.casevacLz}.`);
} }
push(rows, "header", difficulty === "elite" ? "6. COMMAND & SIGNAL" : "5. COMMAND & SIGNAL"); push(rows, "header", difficulty === "elite" ? "6. COMMAND & SIGNAL" : "5. COMMAND & SIGNAL");
rows.push({ kind: "kv", label: "AUTHENTICATION CODEWORD:", value: scenario.codeword }); rows.push({ kind: "kv", label: "AUTHENTICATION CODEWORD:", value: scenario.codeword });
rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword }); rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword });
if (difficulty === "elite") {
rows.push({
kind: "kv",
label: "STATUS CHECK-INS:",
value: `every ${scenario.checkInMinutes} minutes`,
});
}
rows.push({ kind: "gap" }); rows.push({ kind: "gap" });
return rows; return rows;
} }

View file

@ -31,30 +31,32 @@ export interface OpordTierConfig {
/** Grace added to the quiz limit for network/rounding before the server rejects. */ /** Grace added to the quiz limit for network/rounding before the server rejects. */
export const QUIZ_TIME_GRACE_MS = 2_500; export const QUIZ_TIME_GRACE_MS = 2_500;
// Tiers keep the 4:6:8 ratio scaled to 10:15:20 questions; the recall clock
// (~11s/question) and par time (~8s/question) scale with the question count.
export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = { export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
recruit: { recruit: {
studyHintSeconds: 25, studyHintSeconds: 30,
questionCount: 4, questionCount: 10,
quizSeconds: 45, quizSeconds: 110,
correctPoints: 100, correctPoints: 100,
parTimeMs: 32_000, parTimeMs: 80_000,
perfectBonus: 150, perfectBonus: 150,
}, },
veteran: { veteran: {
studyHintSeconds: 20, studyHintSeconds: 25,
questionCount: 6, questionCount: 15,
quizSeconds: 60, quizSeconds: 165,
correctPoints: 150, correctPoints: 150,
parTimeMs: 45_000, parTimeMs: 120_000,
perfectBonus: 300, perfectBonus: 300,
}, },
elite: { elite: {
// Extra clock headroom: elite relational questions include time arithmetic. // Extra clock headroom: elite relational questions include time arithmetic.
studyHintSeconds: 15, studyHintSeconds: 20,
questionCount: 8, questionCount: 20,
quizSeconds: 85, quizSeconds: 225,
correctPoints: 200, correctPoints: 200,
parTimeMs: 68_000, parTimeMs: 160_000,
perfectBonus: 500, perfectBonus: 500,
}, },
}; };
@ -178,6 +180,8 @@ const RALLY_POINT_NAMES = ["EAGLE", "WOLF", "TITAN", "SABER", "IRONHIDE", "BASTI
const AIRCRAFT_CALLSIGNS = ["RAVEN", "THUNDER", "HARRIER", "OSPREY", "COMET", "GHOST"] 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 = [ const CARDINAL_DIRECTIONS = [
"NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST", "NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST",
] as const; ] as const;
@ -215,6 +219,18 @@ export interface OpordScenario {
readonly airSupportOffsetMin: number; readonly airSupportOffsetMin: number;
/** Minutes of movement from the line of departure to the objective. */ /** Minutes of movement from the line of departure to the objective. */
readonly enRouteMin: number; 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;
} }
/** Formats a 6-digit grid Arma map style: three digits, hyphen, three digits, e.g. "034-672". */ /** Formats a 6-digit grid Arma map style: three digits, hyphen, three digits, e.g. "034-672". */
@ -241,6 +257,13 @@ export function generateOpordScenario(seed: number): OpordScenario {
const abortCodeword = pick(rng, CODEWORDS.filter((word) => word !== codeword)); const abortCodeword = pick(rng, CODEWORDS.filter((word) => word !== codeword));
const callsign = `${pick(rng, CALLSIGNS)} ${pick(rng, [1, 2, 3, 5, 6, 7])}`; 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 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;
return { return {
seed, seed,
@ -258,12 +281,18 @@ export function generateOpordScenario(seed: number): OpordScenario {
frequency, frequency,
enemyVehicles: pickInt(rng, 1, 4), enemyVehicles: pickInt(rng, 1, 4),
enemySquads: pickInt(rng, 1, 3), enemySquads: pickInt(rng, 1, 3),
enemyMortars: pickInt(rng, 1, 3),
friendlyUnit: pick(rng, FRIENDLY_UNITS), friendlyUnit: pick(rng, FRIENDLY_UNITS),
transport: pick(rng, TRANSPORT_OPTIONS), transport: pick(rng, TRANSPORT_OPTIONS),
rallyPoint: `RALLY POINT ${pick(rng, RALLY_POINT_NAMES)}`, rallyPoint: `RALLY POINT ${pick(rng, RALLY_POINT_NAMES)}`,
aircraftCallsign: `${pick(rng, AIRCRAFT_CALLSIGNS)} ${pick(rng, [1, 2, 3, 4])}`, aircraftCallsign: `${pick(rng, AIRCRAFT_CALLSIGNS)} ${pick(rng, [1, 2, 3, 4])}`,
airSupportOffsetMin: pick(rng, [15, 20, 30, 45]), airSupportOffsetMin: pick(rng, [15, 20, 30, 45]),
enRouteMin: pickInt(rng, 18, 42), enRouteMin: pickInt(rng, 18, 42),
checkInMinutes: pick(rng, [10, 15, 20, 30]),
casevacLz: `LZ ${pick(rng, LZ_NAMES)}`,
resupplyOffsetMin,
exfilOffsetMin,
withdrawalAzimuth,
}; };
} }
@ -432,6 +461,57 @@ export function buildOpordQuestions(
shuffle(rng, FRIENDLY_UNITS.filter((unit) => unit !== scenario.friendlyUnit)).slice(0, 3), shuffle(rng, FRIENDLY_UNITS.filter((unit) => unit !== scenario.friendlyUnit)).slice(0, 3),
rng, 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,
),
}; };
// Relational questions link two rendered briefing elements instead of // Relational questions link two rendered briefing elements instead of
@ -517,6 +597,57 @@ export function buildOpordQuestions(
shuffle(rng, TRANSPORT_OPTIONS.filter((option) => option !== scenario.transport)).slice(0, 3), shuffle(rng, TRANSPORT_OPTIONS.filter((option) => option !== scenario.transport)).slice(0, 3),
rng, 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,
),
}; };
const tier = OPORD_DIFFICULTIES[difficulty]; const tier = OPORD_DIFFICULTIES[difficulty];
@ -531,6 +662,8 @@ export function buildOpordQuestions(
recallBuilders.enemy, recallBuilders.enemy,
recallBuilders.callsign, recallBuilders.callsign,
recallBuilders.friendly, recallBuilders.friendly,
recallBuilders.distance,
recallBuilders.squads,
] ]
: difficulty === "veteran" : difficulty === "veteran"
? [ ? [
@ -541,6 +674,9 @@ export function buildOpordQuestions(
recallBuilders.enemy, recallBuilders.enemy,
recallBuilders.objective, recallBuilders.objective,
recallBuilders.friendly, recallBuilders.friendly,
recallBuilders.distance,
recallBuilders.squads,
recallBuilders.mortars,
] ]
: [ : [
recallBuilders.codeword, recallBuilders.codeword,
@ -550,6 +686,12 @@ export function buildOpordQuestions(
recallBuilders.objective, recallBuilders.objective,
recallBuilders.friendly, recallBuilders.friendly,
recallBuilders["h-hour"], recallBuilders["h-hour"],
recallBuilders.frequency,
recallBuilders.distance,
recallBuilders.squads,
recallBuilders.mortars,
recallBuilders.checkIn,
recallBuilders.casevac,
]; ];
const relationalPool: readonly (() => OpordQuestion)[] = const relationalPool: readonly (() => OpordQuestion)[] =
difficulty === "veteran" difficulty === "veteran"
@ -557,17 +699,24 @@ export function buildOpordQuestions(
relationalBuilders["azimuth-cardinal"], relationalBuilders["azimuth-cardinal"],
relationalBuilders["time-cas"], relationalBuilders["time-cas"],
relationalBuilders["phase-first"], relationalBuilders["phase-first"],
relationalBuilders["pl-sequence"],
relationalBuilders["transport-link"],
relationalBuilders["callsign-net"],
] ]
: [ : [
relationalBuilders["azimuth-cardinal"], relationalBuilders["azimuth-cardinal"],
relationalBuilders["time-cas"], relationalBuilders["time-cas"],
relationalBuilders["phase-first"], relationalBuilders["phase-first"],
relationalBuilders["pl-sequence"],
relationalBuilders["movement-eta"], relationalBuilders["movement-eta"],
relationalBuilders["abort-rally"], relationalBuilders["abort-rally"],
relationalBuilders["callsign-net"], relationalBuilders["callsign-net"],
relationalBuilders["transport-link"], relationalBuilders["transport-link"],
relationalBuilders["resupply-time"],
relationalBuilders["exfil-time"],
relationalBuilders["withdrawal-cardinal"],
]; ];
const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 2 : 4; const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 5 : 8;
const relational = shuffle(rng, relationalPool).slice(0, relationalCount); const relational = shuffle(rng, relationalPool).slice(0, relationalCount);
const recall = shuffle(rng, recallPool).slice(0, tier.questionCount - 1 - relationalCount); const recall = shuffle(rng, recallPool).slice(0, tier.questionCount - 1 - relationalCount);
return [gridBuilder(), ...shuffle(rng, [...relational, ...recall]).map((build) => build())]; return [gridBuilder(), ...shuffle(rng, [...relational, ...recall]).map((build) => build())];

View file

@ -28,13 +28,13 @@ async function playOpordRound(page: Page): Promise<void> {
const questionCounter = page.getByText(/Question \d+ of \d+/); const questionCounter = page.getByText(/Question \d+ of \d+/);
await expect(questionCounter).toBeVisible(); await expect(questionCounter).toBeVisible();
await expect(questionCounter).toContainText("Question 1 of 4"); await expect(questionCounter).toContainText("Question 1 of 10");
// Question 1 is always the objective grid: answer it correctly. // Question 1 is always the objective grid: answer it correctly.
await page.locator(QUIZ_OPTION_BUTTON).filter({ hasText: grid }).first().click(); await page.locator(QUIZ_OPTION_BUTTON).filter({ hasText: grid }).first().click();
for (let index = 1; index < 4; index += 1) { for (let index = 1; index < 10; index += 1) {
await expect(questionCounter).toContainText(`Question ${index + 1} of 4`, { await expect(questionCounter).toContainText(`Question ${index + 1} of 10`, {
timeout: 10_000, timeout: 10_000,
}); });
await page.locator(QUIZ_OPTION_BUTTON).first().click(); await page.locator(QUIZ_OPTION_BUTTON).first().click();