Compare commits
2 commits
b2bdbb9ea6
...
3daa7a3601
| Author | SHA1 | Date | |
|---|---|---|---|
| 3daa7a3601 | |||
| 3fefb8b4a3 |
4 changed files with 248 additions and 65 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.17",
|
"version": "0.2.18",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
{ value: "recruit", label: "Recruit", detail: "4 questions · 45s recall · core order" },
|
||||||
{ value: "veteran", label: "Veteran", detail: "6 questions · 60s recall" },
|
{ value: "veteran", label: "Veteran", detail: "6 questions · 60s recall · +coordination annex" },
|
||||||
{ value: "elite", label: "Elite", detail: "8 questions · 75s recall" },
|
{ value: "elite", label: "Elite", detail: "8 questions · 85s recall · full order" },
|
||||||
] as const satisfies ReadonlyArray<{
|
] as const satisfies ReadonlyArray<{
|
||||||
readonly value: OpordDifficulty;
|
readonly value: OpordDifficulty;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
|
@ -140,8 +140,10 @@ export function OpordArena({
|
||||||
<span className="font-medium text-foreground">Recall</span>
|
<span className="font-medium text-foreground">Recall</span>
|
||||||
<span>
|
<span>
|
||||||
Questions come one at a time with no going back, hard-capped at{" "}
|
Questions come one at a time with no going back, hard-capped at{" "}
|
||||||
{OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. The server only
|
{OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. Veteran and elite
|
||||||
accepts runs inside a plausible time band, so answers scored{" "}
|
briefings add annexes, and their quizzes link elements together: a course direction
|
||||||
|
from the azimuth, a H+minute time, the abort rally plan. The server only accepts runs
|
||||||
|
inside a plausible time band, so answers scored{" "}
|
||||||
{OPORD_DIFFICULTIES[difficulty].correctPoints} points each have to be earned against
|
{OPORD_DIFFICULTIES[difficulty].correctPoints} points each have to be earned against
|
||||||
the clock.
|
the clock.
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number)
|
||||||
function buildBriefingRows(
|
function buildBriefingRows(
|
||||||
measureCtx: CanvasRenderingContext2D,
|
measureCtx: CanvasRenderingContext2D,
|
||||||
scenario: OpordScenario,
|
scenario: OpordScenario,
|
||||||
|
difficulty: OpordDifficulty,
|
||||||
maxWidth: number,
|
maxWidth: number,
|
||||||
): BriefingRow[] {
|
): BriefingRow[] {
|
||||||
const push = (rows: BriefingRow[], kind: "title" | "subtitle" | "header" | "text", text: string) => {
|
const push = (rows: BriefingRow[], kind: "title" | "subtitle" | "header" | "text", text: string) => {
|
||||||
|
|
@ -111,16 +112,39 @@ function buildBriefingRows(
|
||||||
"text",
|
"text",
|
||||||
`Line of departure is phase line ${scenario.ldPhaseLine}. Subsequent phase lines: ${scenario.otherPhaseLines.length > 0 ? scenario.otherPhaseLines.join(", ") : "none assigned"}.`,
|
`Line of departure is phase line ${scenario.ldPhaseLine}. Subsequent phase lines: ${scenario.otherPhaseLines.length > 0 ? scenario.otherPhaseLines.join(", ") : "none assigned"}.`,
|
||||||
);
|
);
|
||||||
push(rows, "header", "4. SERVICE SUPPORT");
|
if (difficulty === "recruit") {
|
||||||
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
|
push(rows, "header", "4. SERVICE SUPPORT");
|
||||||
push(rows, "header", "5. COMMAND & SIGNAL");
|
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
|
||||||
|
} else {
|
||||||
|
push(rows, "header", "4. COORDINATION");
|
||||||
|
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`Close air support (${scenario.aircraftCallsign}) on station at H+${scenario.airSupportOffsetMin} minutes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (difficulty === "elite") {
|
||||||
|
push(rows, "header", "5. TIMING & CONTINGENCY");
|
||||||
|
push(
|
||||||
|
rows,
|
||||||
|
"text",
|
||||||
|
`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, "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 });
|
||||||
rows.push({ kind: "gap" });
|
rows.push({ kind: "gap" });
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): void {
|
function drawBriefing(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
scenario: OpordScenario,
|
||||||
|
difficulty: OpordDifficulty,
|
||||||
|
): void {
|
||||||
const parent = canvas.parentElement;
|
const parent = canvas.parentElement;
|
||||||
const availableWidth = parent
|
const availableWidth = parent
|
||||||
? parent.clientWidth - CANVAS_PAD_X * 2
|
? parent.clientWidth - CANVAS_PAD_X * 2
|
||||||
|
|
@ -133,7 +157,7 @@ function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): void
|
||||||
measure.setTransform(1, 0, 0, 1, 0, 0);
|
measure.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
measure.scale(dpr, dpr);
|
measure.scale(dpr, dpr);
|
||||||
const textWidth = width - CANVAS_PAD_X * 2;
|
const textWidth = width - CANVAS_PAD_X * 2;
|
||||||
const rows = buildBriefingRows(measure, scenario, textWidth);
|
const rows = buildBriefingRows(measure, scenario, difficulty, textWidth);
|
||||||
|
|
||||||
let height = 20;
|
let height = 20;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
@ -201,14 +225,20 @@ function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpordBriefingCanvas({ scenario }: { scenario: OpordScenario }) {
|
function OpordBriefingCanvas({
|
||||||
|
scenario,
|
||||||
|
difficulty,
|
||||||
|
}: {
|
||||||
|
scenario: OpordScenario;
|
||||||
|
difficulty: OpordDifficulty;
|
||||||
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
drawBriefing(canvas, scenario);
|
drawBriefing(canvas, scenario, difficulty);
|
||||||
}, [scenario]);
|
}, [scenario, difficulty]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -473,7 +503,7 @@ export function OpordGame({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<OpordBriefingCanvas scenario={scenario} />
|
<OpordBriefingCanvas scenario={scenario} difficulty={difficulty} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
* OPORD Recall game rules. Pure module (no Payload imports), safe on client and server.
|
* 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
|
* A randomized operations order (OPORD) is generated from a numeric seed. The
|
||||||
* player studies the briefing for a limited "memo" window, then answers quiz
|
* player studies the self-paced briefing, then answers a timed recall quiz.
|
||||||
* questions about the details. Every function is deterministic for a given
|
* Briefing scope grows with the tier (recruit core order, veteran coordination
|
||||||
* seed so scenarios are reproducible and testable.
|
* 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";
|
export type OpordDifficulty = "recruit" | "veteran" | "elite";
|
||||||
|
|
@ -39,7 +41,7 @@ export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
|
||||||
perfectBonus: 150,
|
perfectBonus: 150,
|
||||||
},
|
},
|
||||||
veteran: {
|
veteran: {
|
||||||
studyHintSeconds: 18,
|
studyHintSeconds: 20,
|
||||||
questionCount: 6,
|
questionCount: 6,
|
||||||
quizSeconds: 60,
|
quizSeconds: 60,
|
||||||
correctPoints: 150,
|
correctPoints: 150,
|
||||||
|
|
@ -47,11 +49,12 @@ export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
|
||||||
perfectBonus: 300,
|
perfectBonus: 300,
|
||||||
},
|
},
|
||||||
elite: {
|
elite: {
|
||||||
studyHintSeconds: 12,
|
// Extra clock headroom: elite relational questions include time arithmetic.
|
||||||
|
studyHintSeconds: 15,
|
||||||
questionCount: 8,
|
questionCount: 8,
|
||||||
quizSeconds: 75,
|
quizSeconds: 85,
|
||||||
correctPoints: 200,
|
correctPoints: 200,
|
||||||
parTimeMs: 58_000,
|
parTimeMs: 68_000,
|
||||||
perfectBonus: 500,
|
perfectBonus: 500,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -171,6 +174,14 @@ const TRANSPORT_OPTIONS = [
|
||||||
"hardened GMG trucks", "unarmored transports", "M-ATVs", "tracked APCs",
|
"hardened GMG trucks", "unarmored transports", "M-ATVs", "tracked APCs",
|
||||||
] as const;
|
] 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 CARDINAL_DIRECTIONS = [
|
||||||
|
"NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export interface OpordScenario {
|
export interface OpordScenario {
|
||||||
readonly seed: number;
|
readonly seed: number;
|
||||||
/** e.g. "IRON VIGIL" */
|
/** e.g. "IRON VIGIL" */
|
||||||
|
|
@ -196,6 +207,14 @@ export interface OpordScenario {
|
||||||
readonly enemySquads: number;
|
readonly enemySquads: number;
|
||||||
readonly friendlyUnit: string;
|
readonly friendlyUnit: string;
|
||||||
readonly transport: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formats an 8-digit grid as two 4-digit halves, e.g. "0346 7219". */
|
/** Formats an 8-digit grid as two 4-digit halves, e.g. "0346 7219". */
|
||||||
|
|
@ -203,6 +222,11 @@ export function formatOpordGrid(grid: string): string {
|
||||||
return grid.length === 8 ? `${grid.slice(0, 4)} ${grid.slice(4)}` : grid;
|
return grid.length === 8 ? `${grid.slice(0, 4)} ${grid.slice(4)}` : 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];
|
||||||
|
}
|
||||||
|
|
||||||
export function generateOpordScenario(seed: number): OpordScenario {
|
export function generateOpordScenario(seed: number): OpordScenario {
|
||||||
const rng = mulberry32(seed);
|
const rng = mulberry32(seed);
|
||||||
const operationName = `${pick(rng, OPERATION_ADJECTIVES)} ${pick(rng, OPERATION_NOUNS)}`;
|
const operationName = `${pick(rng, OPERATION_ADJECTIVES)} ${pick(rng, OPERATION_NOUNS)}`;
|
||||||
|
|
@ -236,6 +260,10 @@ export function generateOpordScenario(seed: number): OpordScenario {
|
||||||
enemySquads: pickInt(rng, 1, 3),
|
enemySquads: 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)}`,
|
||||||
|
aircraftCallsign: `${pick(rng, AIRCRAFT_CALLSIGNS)} ${pick(rng, [1, 2, 3, 4])}`,
|
||||||
|
airSupportOffsetMin: pick(rng, [15, 20, 30, 45]),
|
||||||
|
enRouteMin: pickInt(rng, 18, 42),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,26 +327,27 @@ export function buildOpordQuestions(
|
||||||
CODEWORDS.filter((word) => word !== scenario.codeword && word !== scenario.abortCodeword),
|
CODEWORDS.filter((word) => word !== scenario.codeword && word !== scenario.abortCodeword),
|
||||||
).slice(0, 3);
|
).slice(0, 3);
|
||||||
|
|
||||||
const builders: readonly (() => OpordQuestion)[] = [
|
const gridBuilder = () =>
|
||||||
// The objective grid is the core skill: always asked first.
|
// The objective grid is the core skill: always asked first.
|
||||||
() =>
|
choiceQuestion(
|
||||||
choiceQuestion(
|
"grid",
|
||||||
"grid",
|
"What is the 8-digit grid of the objective?",
|
||||||
"What is the 8-digit grid of the objective?",
|
formatOpordGrid(scenario.grid),
|
||||||
formatOpordGrid(scenario.grid),
|
[
|
||||||
[
|
formatOpordGrid(scenario.grid.split("").reverse().join("")),
|
||||||
formatOpordGrid(scenario.grid.split("").reverse().join("")),
|
formatOpordGrid(scenario.grid.slice(4) + scenario.grid.slice(0, 4)),
|
||||||
formatOpordGrid(scenario.grid.slice(4) + scenario.grid.slice(0, 4)),
|
formatOpordGrid(
|
||||||
formatOpordGrid(
|
scenario.grid
|
||||||
scenario.grid
|
.split("")
|
||||||
.split("")
|
.map((digit) => String((Number(digit) + 1) % 10))
|
||||||
.map((digit) => String((Number(digit) + 1) % 10))
|
.join(""),
|
||||||
.join(""),
|
),
|
||||||
),
|
],
|
||||||
],
|
rng,
|
||||||
rng,
|
);
|
||||||
),
|
|
||||||
() =>
|
const recallBuilders: Record<string, () => OpordQuestion> = {
|
||||||
|
azimuth: () =>
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"azimuth",
|
"azimuth",
|
||||||
"What azimuth (in degrees) is the main axis of advance?",
|
"What azimuth (in degrees) is the main axis of advance?",
|
||||||
|
|
@ -326,7 +355,7 @@ export function buildOpordQuestions(
|
||||||
azimuthDecoys,
|
azimuthDecoys,
|
||||||
rng,
|
rng,
|
||||||
),
|
),
|
||||||
() =>
|
"h-hour": () =>
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"h-hour",
|
"h-hour",
|
||||||
"What is H-HOUR?",
|
"What is H-HOUR?",
|
||||||
|
|
@ -334,7 +363,7 @@ export function buildOpordQuestions(
|
||||||
[zuluShift(scenario.hHour, 30), zuluShift(scenario.hHour, -45), zuluShift(scenario.hHour, 75)],
|
[zuluShift(scenario.hHour, 30), zuluShift(scenario.hHour, -45), zuluShift(scenario.hHour, 75)],
|
||||||
rng,
|
rng,
|
||||||
),
|
),
|
||||||
() =>
|
codeword: () =>
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"codeword",
|
"codeword",
|
||||||
"What is the authentication codeword?",
|
"What is the authentication codeword?",
|
||||||
|
|
@ -342,7 +371,7 @@ export function buildOpordQuestions(
|
||||||
codewordDistractors,
|
codewordDistractors,
|
||||||
rng,
|
rng,
|
||||||
),
|
),
|
||||||
() =>
|
abort: () =>
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"abort",
|
"abort",
|
||||||
"What is the abort codeword?",
|
"What is the abort codeword?",
|
||||||
|
|
@ -350,7 +379,7 @@ export function buildOpordQuestions(
|
||||||
codewordDistractors,
|
codewordDistractors,
|
||||||
rng,
|
rng,
|
||||||
),
|
),
|
||||||
() =>
|
callsign: () =>
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"callsign",
|
"callsign",
|
||||||
"What is your element's callsign?",
|
"What is your element's callsign?",
|
||||||
|
|
@ -360,7 +389,7 @@ export function buildOpordQuestions(
|
||||||
.map((name) => `${name} ${pick(rng, [1, 2, 3, 5, 6, 7])}`),
|
.map((name) => `${name} ${pick(rng, [1, 2, 3, 5, 6, 7])}`),
|
||||||
rng,
|
rng,
|
||||||
),
|
),
|
||||||
() =>
|
frequency: () =>
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"frequency",
|
"frequency",
|
||||||
"What is the assigned radio frequency?",
|
"What is the assigned radio frequency?",
|
||||||
|
|
@ -368,19 +397,7 @@ export function buildOpordQuestions(
|
||||||
[0.25, -0.25, 0.5].map((delta) => (Number(scenario.frequency) + delta).toFixed(3)),
|
[0.25, -0.25, 0.5].map((delta) => (Number(scenario.frequency) + delta).toFixed(3)),
|
||||||
rng,
|
rng,
|
||||||
),
|
),
|
||||||
() =>
|
enemy: () =>
|
||||||
choiceQuestion(
|
|
||||||
"ld",
|
|
||||||
"Which phase line is the line of departure?",
|
|
||||||
scenario.ldPhaseLine,
|
|
||||||
scenario.otherPhaseLines.concat(
|
|
||||||
shuffle(rng, PHASE_LINE_NAMES.filter((name) => name !== scenario.ldPhaseLine)).filter(
|
|
||||||
(name) => !scenario.otherPhaseLines.includes(name),
|
|
||||||
)[0],
|
|
||||||
),
|
|
||||||
rng,
|
|
||||||
),
|
|
||||||
() =>
|
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"enemy",
|
"enemy",
|
||||||
"How many enemy vehicles are on the objective?",
|
"How many enemy vehicles are on the objective?",
|
||||||
|
|
@ -390,7 +407,7 @@ export function buildOpordQuestions(
|
||||||
.slice(0, 3),
|
.slice(0, 3),
|
||||||
rng,
|
rng,
|
||||||
),
|
),
|
||||||
() =>
|
objective: () =>
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"objective",
|
"objective",
|
||||||
"What is the objective named?",
|
"What is the objective named?",
|
||||||
|
|
@ -398,7 +415,7 @@ export function buildOpordQuestions(
|
||||||
shuffle(rng, OBJECTIVE_NAMES.filter((name) => name !== scenario.objective)).slice(0, 3),
|
shuffle(rng, OBJECTIVE_NAMES.filter((name) => name !== scenario.objective)).slice(0, 3),
|
||||||
rng,
|
rng,
|
||||||
),
|
),
|
||||||
() =>
|
friendly: () =>
|
||||||
choiceQuestion(
|
choiceQuestion(
|
||||||
"friendly",
|
"friendly",
|
||||||
"Which friendly unit is in support?",
|
"Which friendly unit is in support?",
|
||||||
|
|
@ -406,11 +423,145 @@ 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,
|
||||||
),
|
),
|
||||||
];
|
};
|
||||||
|
|
||||||
const [first, ...rest] = shuffle(rng, builders.slice(1));
|
// Relational questions link two rendered briefing elements instead of
|
||||||
const ordered = [builders[0], first, ...rest].map((build) => build());
|
// recalling a single value. Contract: every input an entry reads must be
|
||||||
return ordered.slice(0, OPORD_DIFFICULTIES[difficulty].questionCount);
|
// 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?`,
|
||||||
|
`Return to ${scenario.rallyPoint} and await further orders`,
|
||||||
|
[
|
||||||
|
"Continue the mission as planned",
|
||||||
|
"Mark the objective with smoke and hold in place",
|
||||||
|
`Request close air support from ${scenario.aircraftCallsign}`,
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
: difficulty === "veteran"
|
||||||
|
? [
|
||||||
|
recallBuilders.codeword,
|
||||||
|
recallBuilders.abort,
|
||||||
|
recallBuilders.callsign,
|
||||||
|
recallBuilders.frequency,
|
||||||
|
recallBuilders.enemy,
|
||||||
|
recallBuilders.objective,
|
||||||
|
recallBuilders.friendly,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
recallBuilders.codeword,
|
||||||
|
recallBuilders.abort,
|
||||||
|
recallBuilders.callsign,
|
||||||
|
recallBuilders.enemy,
|
||||||
|
recallBuilders.objective,
|
||||||
|
recallBuilders.friendly,
|
||||||
|
recallBuilders["h-hour"],
|
||||||
|
];
|
||||||
|
const relationalPool: readonly (() => OpordQuestion)[] =
|
||||||
|
difficulty === "veteran"
|
||||||
|
? [
|
||||||
|
relationalBuilders["azimuth-cardinal"],
|
||||||
|
relationalBuilders["time-cas"],
|
||||||
|
relationalBuilders["phase-first"],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
relationalBuilders["azimuth-cardinal"],
|
||||||
|
relationalBuilders["time-cas"],
|
||||||
|
relationalBuilders["phase-first"],
|
||||||
|
relationalBuilders["movement-eta"],
|
||||||
|
relationalBuilders["abort-rally"],
|
||||||
|
relationalBuilders["callsign-net"],
|
||||||
|
relationalBuilders["transport-link"],
|
||||||
|
];
|
||||||
|
const relationalCount = difficulty === "recruit" ? 0 : difficulty === "veteran" ? 2 : 4;
|
||||||
|
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 {
|
export interface OpordRunResult {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue