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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue