1
0
Fork 0

Compare commits

...

2 commits

4 changed files with 248 additions and 65 deletions

View file

@ -1,6 +1,6 @@
{
"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.",
"license": "MIT",
"type": "module",

View file

@ -13,9 +13,9 @@ import { OpordGame } from "./OpordGame";
import { OpordLeaderboard } from "./OpordLeaderboard";
const TIER_OPTIONS = [
{ value: "recruit", label: "Recruit", detail: "4 questions · 45s recall" },
{ value: "veteran", label: "Veteran", detail: "6 questions · 60s recall" },
{ value: "elite", label: "Elite", detail: "8 questions · 75s recall" },
{ value: "recruit", label: "Recruit", detail: "4 questions · 45s recall · core order" },
{ value: "veteran", label: "Veteran", detail: "6 questions · 60s recall · +coordination annex" },
{ value: "elite", label: "Elite", detail: "8 questions · 85s recall · full order" },
] as const satisfies ReadonlyArray<{
readonly value: OpordDifficulty;
readonly label: string;
@ -140,8 +140,10 @@ export function OpordArena({
<span className="font-medium text-foreground">Recall</span>
<span>
Questions come one at a time with no going back, hard-capped at{" "}
{OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. The server only
accepts runs inside a plausible time band, so answers scored{" "}
{OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. Veteran and elite
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
the clock.
</span>

View file

@ -77,6 +77,7 @@ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number)
function buildBriefingRows(
measureCtx: CanvasRenderingContext2D,
scenario: OpordScenario,
difficulty: OpordDifficulty,
maxWidth: number,
): BriefingRow[] {
const push = (rows: BriefingRow[], kind: "title" | "subtitle" | "header" | "text", text: string) => {
@ -111,16 +112,39 @@ function buildBriefingRows(
"text",
`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");
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
push(rows, "header", "5. COMMAND & SIGNAL");
if (difficulty === "recruit") {
push(rows, "header", "4. SERVICE SUPPORT");
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: "ABORT CODEWORD:", value: scenario.abortCodeword });
rows.push({ kind: "gap" });
return rows;
}
function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): void {
function drawBriefing(
canvas: HTMLCanvasElement,
scenario: OpordScenario,
difficulty: OpordDifficulty,
): void {
const parent = canvas.parentElement;
const availableWidth = parent
? 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.scale(dpr, dpr);
const textWidth = width - CANVAS_PAD_X * 2;
const rows = buildBriefingRows(measure, scenario, textWidth);
const rows = buildBriefingRows(measure, scenario, difficulty, textWidth);
let height = 20;
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);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
drawBriefing(canvas, scenario);
}, [scenario]);
drawBriefing(canvas, scenario, difficulty);
}, [scenario, difficulty]);
return (
<div
@ -473,7 +503,7 @@ export function OpordGame({
</Button>
</div>
</div>
<OpordBriefingCanvas scenario={scenario} />
<OpordBriefingCanvas scenario={scenario} difficulty={difficulty} />
</div>
)}

View file

@ -2,9 +2,11 @@
* 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 briefing for a limited "memo" window, then answers quiz
* questions about the details. Every function is deterministic for a given
* seed so scenarios are reproducible and testable.
* 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";
@ -39,7 +41,7 @@ export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
perfectBonus: 150,
},
veteran: {
studyHintSeconds: 18,
studyHintSeconds: 20,
questionCount: 6,
quizSeconds: 60,
correctPoints: 150,
@ -47,11 +49,12 @@ export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
perfectBonus: 300,
},
elite: {
studyHintSeconds: 12,
// Extra clock headroom: elite relational questions include time arithmetic.
studyHintSeconds: 15,
questionCount: 8,
quizSeconds: 75,
quizSeconds: 85,
correctPoints: 200,
parTimeMs: 58_000,
parTimeMs: 68_000,
perfectBonus: 500,
},
};
@ -171,6 +174,14 @@ 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 CARDINAL_DIRECTIONS = [
"NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST",
] as const;
export interface OpordScenario {
readonly seed: number;
/** e.g. "IRON VIGIL" */
@ -196,6 +207,14 @@ export interface OpordScenario {
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;
}
/** 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;
}
/** 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 {
const rng = mulberry32(seed);
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),
friendlyUnit: pick(rng, FRIENDLY_UNITS),
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),
).slice(0, 3);
const builders: readonly (() => OpordQuestion)[] = [
const gridBuilder = () =>
// The objective grid is the core skill: always asked first.
() =>
choiceQuestion(
"grid",
"What is the 8-digit grid of the objective?",
formatOpordGrid(scenario.grid),
[
formatOpordGrid(scenario.grid.split("").reverse().join("")),
formatOpordGrid(scenario.grid.slice(4) + scenario.grid.slice(0, 4)),
formatOpordGrid(
scenario.grid
.split("")
.map((digit) => String((Number(digit) + 1) % 10))
.join(""),
),
],
rng,
),
() =>
choiceQuestion(
"grid",
"What is the 8-digit grid of the objective?",
formatOpordGrid(scenario.grid),
[
formatOpordGrid(scenario.grid.split("").reverse().join("")),
formatOpordGrid(scenario.grid.slice(4) + scenario.grid.slice(0, 4)),
formatOpordGrid(
scenario.grid
.split("")
.map((digit) => String((Number(digit) + 1) % 10))
.join(""),
),
],
rng,
);
const recallBuilders: Record<string, () => OpordQuestion> = {
azimuth: () =>
choiceQuestion(
"azimuth",
"What azimuth (in degrees) is the main axis of advance?",
@ -326,7 +355,7 @@ export function buildOpordQuestions(
azimuthDecoys,
rng,
),
() =>
"h-hour": () =>
choiceQuestion(
"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)],
rng,
),
() =>
codeword: () =>
choiceQuestion(
"codeword",
"What is the authentication codeword?",
@ -342,7 +371,7 @@ export function buildOpordQuestions(
codewordDistractors,
rng,
),
() =>
abort: () =>
choiceQuestion(
"abort",
"What is the abort codeword?",
@ -350,7 +379,7 @@ export function buildOpordQuestions(
codewordDistractors,
rng,
),
() =>
callsign: () =>
choiceQuestion(
"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])}`),
rng,
),
() =>
frequency: () =>
choiceQuestion(
"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)),
rng,
),
() =>
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,
),
() =>
enemy: () =>
choiceQuestion(
"enemy",
"How many enemy vehicles are on the objective?",
@ -390,7 +407,7 @@ export function buildOpordQuestions(
.slice(0, 3),
rng,
),
() =>
objective: () =>
choiceQuestion(
"objective",
"What is the objective named?",
@ -398,7 +415,7 @@ export function buildOpordQuestions(
shuffle(rng, OBJECTIVE_NAMES.filter((name) => name !== scenario.objective)).slice(0, 3),
rng,
),
() =>
friendly: () =>
choiceQuestion(
"friendly",
"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),
rng,
),
];
};
const [first, ...rest] = shuffle(rng, builders.slice(1));
const ordered = [builders[0], first, ...rest].map((build) => build());
return ordered.slice(0, OPORD_DIFFICULTIES[difficulty].questionCount);
// 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?`,
`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 {