add land navigation minigame rules lib, stats schema, event type and migration
This commit is contained in:
parent
20b24f254f
commit
62df673249
7 changed files with 24329 additions and 1 deletions
|
|
@ -132,6 +132,7 @@ export const Profiles: CollectionConfig = {
|
||||||
radioTraffic?: { xpEarned?: number | null };
|
radioTraffic?: { xpEarned?: number | null };
|
||||||
aimTrainer?: { xpEarned?: number | null };
|
aimTrainer?: { xpEarned?: number | null };
|
||||||
callForFire?: { xpEarned?: number | null };
|
callForFire?: { xpEarned?: number | null };
|
||||||
|
landNav?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
voiceover?: { xpEarned?: number | null };
|
voiceover?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
|
|
@ -148,6 +149,7 @@ export const Profiles: CollectionConfig = {
|
||||||
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
||||||
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
||||||
const callForFireXp = progressionData?.minigames?.callForFire?.xpEarned ?? 0;
|
const callForFireXp = progressionData?.minigames?.callForFire?.xpEarned ?? 0;
|
||||||
|
const landNavXp = progressionData?.minigames?.landNav?.xpEarned ?? 0;
|
||||||
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
||||||
|
|
||||||
if (awardIds.length > 0) {
|
if (awardIds.length > 0) {
|
||||||
|
|
@ -192,6 +194,8 @@ export const Profiles: CollectionConfig = {
|
||||||
xpAcc += aimTrainerXp;
|
xpAcc += aimTrainerXp;
|
||||||
// Call for Fire: XP from effective artillery adjustments
|
// Call for Fire: XP from effective artillery adjustments
|
||||||
xpAcc += callForFireXp;
|
xpAcc += callForFireXp;
|
||||||
|
// Land Navigation: XP from passed navigation courses
|
||||||
|
xpAcc += landNavXp;
|
||||||
// Voiceover submissions: XP awarded per accepted submission
|
// Voiceover submissions: XP awarded per accepted submission
|
||||||
xpAcc += voiceoverXp;
|
xpAcc += voiceoverXp;
|
||||||
|
|
||||||
|
|
@ -623,6 +627,60 @@ export const Profiles: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "landNav",
|
||||||
|
label: "Land Navigation",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "gamesPlayed",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wins",
|
||||||
|
label: "Wins",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "losses",
|
||||||
|
label: "Losses",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestScore",
|
||||||
|
label: "Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "xpEarned",
|
||||||
|
label: "XP Earned",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestWaypointSeconds",
|
||||||
|
label: "Fastest Time on Best Waypoint Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestRouteSeconds",
|
||||||
|
label: "Fastest Time on Best Route Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestExpeditionSeconds",
|
||||||
|
label: "Fastest Time on Best Expedition Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
465
src/lib/land-nav.ts
Normal file
465
src/lib/land-nav.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
/**
|
||||||
|
* Land Navigation minigame rules.
|
||||||
|
*
|
||||||
|
* The player navigates a 10x10 grid map (100 m cells) through a sequence of
|
||||||
|
* navigation legs. Three leg types appear on a course:
|
||||||
|
*
|
||||||
|
* - "locate": click the map cell that contains a named feature (or that a
|
||||||
|
* 6-figure grid reference points at).
|
||||||
|
* - "azimuth": given a start cell, an azimuth in degrees and a distance in
|
||||||
|
* metres, click the destination cell.
|
||||||
|
* - "declination": like azimuth, but the compass reads a magnetic azimuth
|
||||||
|
* that must first be converted with the local declination (east is
|
||||||
|
* subtracted, west is added). The map shows no ray for these legs.
|
||||||
|
* - "identify": given a 6-figure grid reference, pick which named feature
|
||||||
|
* sits there from four options.
|
||||||
|
*
|
||||||
|
* A course is passed when at least `passLegs` of its legs are answered
|
||||||
|
* correctly before the time limit expires.
|
||||||
|
*
|
||||||
|
* Pure module: client-safe, no Payload imports. Server actions use the caps
|
||||||
|
* here to validate submitted results.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LAND_NAV_DIFFICULTY_KEYS = ["waypoint", "route", "expedition"] as const;
|
||||||
|
|
||||||
|
export type LandNavDifficulty = (typeof LAND_NAV_DIFFICULTY_KEYS)[number];
|
||||||
|
|
||||||
|
export function isLandNavDifficulty(value: unknown): value is LandNavDifficulty {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && (LAND_NAV_DIFFICULTY_KEYS as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandNavDifficultyConfig {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
legs: number;
|
||||||
|
passLegs: number;
|
||||||
|
features: number;
|
||||||
|
parSeconds: number;
|
||||||
|
maxSeconds: number;
|
||||||
|
baseScore: number;
|
||||||
|
perLegBonus: number;
|
||||||
|
perSecondBonus: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LAND_NAV_DIFFICULTIES: Record<LandNavDifficulty, LandNavDifficultyConfig> = {
|
||||||
|
waypoint: {
|
||||||
|
label: "Waypoint",
|
||||||
|
description: "A short orientation hop. 4 legs, 3 must be correct.",
|
||||||
|
legs: 4,
|
||||||
|
passLegs: 3,
|
||||||
|
features: 6,
|
||||||
|
parSeconds: 120,
|
||||||
|
maxSeconds: 300,
|
||||||
|
baseScore: 200,
|
||||||
|
perLegBonus: 40,
|
||||||
|
perSecondBonus: 1,
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
label: "Route",
|
||||||
|
description: "A full patrol route. 6 legs, 5 must be correct.",
|
||||||
|
legs: 6,
|
||||||
|
passLegs: 5,
|
||||||
|
features: 9,
|
||||||
|
parSeconds: 180,
|
||||||
|
maxSeconds: 420,
|
||||||
|
baseScore: 320,
|
||||||
|
perLegBonus: 50,
|
||||||
|
perSecondBonus: 1,
|
||||||
|
},
|
||||||
|
expedition: {
|
||||||
|
label: "Expedition",
|
||||||
|
description: "A long-distance cross-country leg set. 8 legs, 6 must be correct.",
|
||||||
|
legs: 8,
|
||||||
|
passLegs: 6,
|
||||||
|
features: 12,
|
||||||
|
parSeconds: 240,
|
||||||
|
maxSeconds: 540,
|
||||||
|
baseScore: 460,
|
||||||
|
perLegBonus: 60,
|
||||||
|
perSecondBonus: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GRID_COLS = 10;
|
||||||
|
export const GRID_ROWS = 10;
|
||||||
|
export const METERS_PER_CELL = 100;
|
||||||
|
|
||||||
|
/** Server-side validation caps. */
|
||||||
|
export const MAX_LEGS = LAND_NAV_DIFFICULTIES.expedition.legs;
|
||||||
|
export const MAX_SECONDS = LAND_NAV_DIFFICULTIES.expedition.maxSeconds;
|
||||||
|
export const MAX_FEATURES = LAND_NAV_DIFFICULTIES.expedition.features;
|
||||||
|
export const MAX_AZIMUTH = 359;
|
||||||
|
export const MAX_DISTANCE_CELLS = 4;
|
||||||
|
|
||||||
|
export interface Cell {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeatureType = "hill" | "lake" | "ruins" | "tower" | "bridge" | "copse";
|
||||||
|
|
||||||
|
export interface MapFeature extends Cell {
|
||||||
|
id: string;
|
||||||
|
type: FeatureType;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegKind = "locate" | "azimuth" | "declination" | "identify";
|
||||||
|
|
||||||
|
export interface LandNavLeg {
|
||||||
|
kind: LegKind;
|
||||||
|
/** Human-readable instruction shown above the map. */
|
||||||
|
prompt: string;
|
||||||
|
/** Correct answer cell (locate, azimuth and declination legs). */
|
||||||
|
answerCell: Cell;
|
||||||
|
/** Correct answer feature id (identify legs). */
|
||||||
|
answerFeatureId: string;
|
||||||
|
/** Options for identify legs, in display order. */
|
||||||
|
options?: string[];
|
||||||
|
/** Azimuth/declination legs: start cell, azimuth degrees (0 = north, 90 = east), distance. */
|
||||||
|
startCell?: Cell;
|
||||||
|
azimuth?: number;
|
||||||
|
distanceM?: number;
|
||||||
|
/** Declination legs: magnetic-to-grid correction in degrees, positive = east. */
|
||||||
|
declination?: number;
|
||||||
|
/** Locate/declination legs: the referenced feature name, grid reference or magnetic azimuth. */
|
||||||
|
reference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandNavCourse {
|
||||||
|
difficulty: LandNavDifficulty;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
easting0: number;
|
||||||
|
northing0: number;
|
||||||
|
features: MapFeature[];
|
||||||
|
legs: LandNavLeg[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_TYPE_NAMES: Record<FeatureType, string[]> = {
|
||||||
|
hill: ["Hill", "Knoll", "Ridge"],
|
||||||
|
lake: ["Lake", "Tarn", "Pond"],
|
||||||
|
ruins: ["Ruins of", "Old Works of", "Ruined Post at"],
|
||||||
|
tower: ["Tower", "Signal Mast", "Watchtower"],
|
||||||
|
bridge: ["Bridge", "Ford", "Weir"],
|
||||||
|
copse: ["Copse", "Wood", "Thicket"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_LABELS: Record<FeatureType, string> = {
|
||||||
|
hill: "Hill",
|
||||||
|
lake: "Water",
|
||||||
|
ruins: "Ruins",
|
||||||
|
tower: "Tower",
|
||||||
|
bridge: "Bridge",
|
||||||
|
copse: "Wood",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function featureTypeLabel(type: FeatureType): string {
|
||||||
|
return FEATURE_LABELS[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad3(value: number): string {
|
||||||
|
return String(Math.max(0, Math.min(999, value))).padStart(3, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 6-figure grid reference (100 m precision) for a cell on this course. */
|
||||||
|
export function gridRef(course: Pick<LandNavCourse, "easting0" | "northing0">, cell: Cell): string {
|
||||||
|
return `${pad3(course.easting0 + cell.col)}${pad3(course.northing0 + cell.row)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameCell(a: Cell, b: Cell): boolean {
|
||||||
|
return a.col === b.col && a.row === b.row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inBounds(cell: Cell): boolean {
|
||||||
|
return cell.col >= 0 && cell.col < GRID_COLS && cell.row >= 0 && cell.row < GRID_ROWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>(list: readonly T[], rng: () => number): T {
|
||||||
|
return list[Math.floor(rng() * list.length)] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffled<T>(list: readonly T[], rng: () => number): T[] {
|
||||||
|
const copy = [...list];
|
||||||
|
for (let i = copy.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
const tmp = copy[i] as T;
|
||||||
|
copy[i] = copy[j] as T;
|
||||||
|
copy[j] = tmp;
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomName(type: FeatureType, rng: () => number): string {
|
||||||
|
const prefixes = FEATURE_TYPE_NAMES[type];
|
||||||
|
const prefix = pick(prefixes, rng);
|
||||||
|
const number = 100 + Math.floor(rng() * 800);
|
||||||
|
if (type === "hill" || type === "lake") {
|
||||||
|
return `${prefix} ${number}`;
|
||||||
|
}
|
||||||
|
if (type === "ruins") {
|
||||||
|
return `${prefix} ${pick(["Kessel", "Vorna", "Tallmark", "Brantis", "Osten"], rng)}`;
|
||||||
|
}
|
||||||
|
return `${prefix} ${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a full course: sheet origin, named features and a mixed sequence
|
||||||
|
* of navigation legs. `rng` is injectable for deterministic tests.
|
||||||
|
*/
|
||||||
|
export function createCourse(
|
||||||
|
difficulty: LandNavDifficulty,
|
||||||
|
rng: () => number = Math.random,
|
||||||
|
): LandNavCourse {
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[difficulty];
|
||||||
|
const easting0 = 20 + Math.floor(rng() * 60);
|
||||||
|
const northing0 = 20 + Math.floor(rng() * 60);
|
||||||
|
|
||||||
|
const types = shuffled(
|
||||||
|
["hill", "lake", "ruins", "tower", "bridge", "copse"] as FeatureType[],
|
||||||
|
rng,
|
||||||
|
);
|
||||||
|
|
||||||
|
const usedCells = new Set<string>();
|
||||||
|
const cellKey = (cell: Cell) => `${cell.col},${cell.row}`;
|
||||||
|
const nextCell = (): Cell => {
|
||||||
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
|
const cell = { col: Math.floor(rng() * GRID_COLS), row: Math.floor(rng() * GRID_ROWS) };
|
||||||
|
if (!usedCells.has(cellKey(cell))) {
|
||||||
|
usedCells.add(cellKey(cell));
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic fallback: linear probe.
|
||||||
|
for (let col = 0; col < GRID_COLS; col += 1) {
|
||||||
|
for (let row = 0; row < GRID_ROWS; row += 1) {
|
||||||
|
const cell = { col, row };
|
||||||
|
if (!usedCells.has(cellKey(cell))) {
|
||||||
|
usedCells.add(cellKey(cell));
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { col: 0, row: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const features: MapFeature[] = [];
|
||||||
|
for (let i = 0; i < config.features; i += 1) {
|
||||||
|
const type = types[i % types.length] as FeatureType;
|
||||||
|
const cell = nextCell();
|
||||||
|
features.push({
|
||||||
|
id: `f${i}`,
|
||||||
|
type,
|
||||||
|
name: randomName(type, rng),
|
||||||
|
...cell,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const course: LandNavCourse = {
|
||||||
|
difficulty,
|
||||||
|
cols: GRID_COLS,
|
||||||
|
rows: GRID_ROWS,
|
||||||
|
easting0,
|
||||||
|
northing0,
|
||||||
|
features,
|
||||||
|
legs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the leg sequence: rotate through kinds so every run mixes tasks.
|
||||||
|
const kindCycle: LegKind[] = shuffled(["locate", "azimuth", "identify"], rng);
|
||||||
|
for (let i = 0; i < config.legs; i += 1) {
|
||||||
|
let kind = kindCycle[i % kindCycle.length] as LegKind;
|
||||||
|
if (kind === "azimuth" && difficulty !== "waypoint" && rng() < 0.5) {
|
||||||
|
kind = "declination";
|
||||||
|
}
|
||||||
|
course.legs.push(createLeg(course, kind, rng));
|
||||||
|
}
|
||||||
|
|
||||||
|
return course;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLeg(course: LandNavCourse, kind: LegKind, rng: () => number): LandNavLeg {
|
||||||
|
if (kind === "azimuth") {
|
||||||
|
return createAzimuthLeg(course, rng);
|
||||||
|
}
|
||||||
|
if (kind === "declination") {
|
||||||
|
return createDeclinationLeg(course, rng);
|
||||||
|
}
|
||||||
|
if (kind === "identify") {
|
||||||
|
return createIdentifyLeg(course, rng);
|
||||||
|
}
|
||||||
|
return createLocateLeg(course, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocateLeg(course: LandNavCourse, rng: () => number): LandNavLeg {
|
||||||
|
const feature = pick(course.features, rng);
|
||||||
|
const byName = rng() < 0.5;
|
||||||
|
return {
|
||||||
|
kind: "locate",
|
||||||
|
prompt: byName
|
||||||
|
? `Click the grid square containing ${feature.name}.`
|
||||||
|
: `Click the grid square at ${gridRef(course, feature)}.`,
|
||||||
|
answerCell: { col: feature.col, row: feature.row },
|
||||||
|
answerFeatureId: feature.id,
|
||||||
|
reference: byName ? feature.name : gridRef(course, feature),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAzimuthLeg(course: LandNavCourse, rng: () => number): LandNavLeg {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
const distanceCells = 2 + Math.floor(rng() * (MAX_DISTANCE_CELLS - 1));
|
||||||
|
const azimuth = Math.floor(rng() * 360);
|
||||||
|
const dest = {
|
||||||
|
col: Math.floor(rng() * GRID_COLS),
|
||||||
|
row: Math.floor(rng() * GRID_ROWS),
|
||||||
|
};
|
||||||
|
const radians = (azimuth * Math.PI) / 180;
|
||||||
|
const start = {
|
||||||
|
col: dest.col - Math.round(Math.sin(radians) * distanceCells),
|
||||||
|
row: dest.row + Math.round(Math.cos(radians) * distanceCells),
|
||||||
|
};
|
||||||
|
if (inBounds(start) && inBounds(dest) && !sameCell(start, dest)) {
|
||||||
|
return {
|
||||||
|
kind: "azimuth",
|
||||||
|
prompt: `From ${gridRef(course, start)}, travel ${azimuth}\u00b0 for ${
|
||||||
|
distanceCells * METERS_PER_CELL
|
||||||
|
} m. Click your destination grid square.`,
|
||||||
|
answerCell: dest,
|
||||||
|
answerFeatureId: "",
|
||||||
|
startCell: start,
|
||||||
|
azimuth,
|
||||||
|
distanceM: distanceCells * METERS_PER_CELL,
|
||||||
|
reference: gridRef(course, start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic fallback: straight north leg from a safe start.
|
||||||
|
const dest = { col: 4, row: 2 };
|
||||||
|
const start = { col: 4, row: 6 };
|
||||||
|
return {
|
||||||
|
kind: "azimuth",
|
||||||
|
prompt: `From ${gridRef(course, start)}, travel 0\u00b0 for ${
|
||||||
|
4 * METERS_PER_CELL
|
||||||
|
} m. Click your destination grid square.`,
|
||||||
|
answerCell: dest,
|
||||||
|
answerFeatureId: "",
|
||||||
|
startCell: start,
|
||||||
|
azimuth: 0,
|
||||||
|
distanceM: 4 * METERS_PER_CELL,
|
||||||
|
reference: gridRef(course, start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeclinationLeg(course: LandNavCourse, rng: () => number): LandNavLeg {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
const distanceCells = 2 + Math.floor(rng() * (MAX_DISTANCE_CELLS - 1));
|
||||||
|
const declination = (1 + Math.floor(rng() * 19)) * (rng() < 0.5 ? -1 : 1);
|
||||||
|
const magneticAzimuth = Math.floor(rng() * 360);
|
||||||
|
const gridAzimuth = (((magneticAzimuth - declination) % 360) + 360) % 360;
|
||||||
|
const dest = {
|
||||||
|
col: Math.floor(rng() * GRID_COLS),
|
||||||
|
row: Math.floor(rng() * GRID_ROWS),
|
||||||
|
};
|
||||||
|
const radians = (gridAzimuth * Math.PI) / 180;
|
||||||
|
const start = {
|
||||||
|
col: dest.col - Math.round(Math.sin(radians) * distanceCells),
|
||||||
|
row: dest.row + Math.round(Math.cos(radians) * distanceCells),
|
||||||
|
};
|
||||||
|
if (inBounds(start) && inBounds(dest) && !sameCell(start, dest)) {
|
||||||
|
const direction = declination > 0 ? "east" : "west";
|
||||||
|
return {
|
||||||
|
kind: "declination",
|
||||||
|
prompt: `From ${gridRef(course, start)}, your compass reads a magnetic azimuth of ${magneticAzimuth}\u00b0. Local declination is ${Math.abs(declination)}\u00b0 ${direction}. Convert to a grid azimuth and travel ${
|
||||||
|
distanceCells * METERS_PER_CELL
|
||||||
|
} m. Click your destination grid square.`,
|
||||||
|
answerCell: dest,
|
||||||
|
answerFeatureId: "",
|
||||||
|
startCell: start,
|
||||||
|
azimuth: gridAzimuth,
|
||||||
|
distanceM: distanceCells * METERS_PER_CELL,
|
||||||
|
declination,
|
||||||
|
reference: gridRef(course, start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dest = { col: 5, row: 3 };
|
||||||
|
const start = { col: 5, row: 6 };
|
||||||
|
return {
|
||||||
|
kind: "declination",
|
||||||
|
prompt: `From ${gridRef(course, start)}, your compass reads a magnetic azimuth of 10\u00b0. Local declination is 5\u00b0 east. Convert to a grid azimuth and travel ${
|
||||||
|
3 * METERS_PER_CELL
|
||||||
|
} m. Click your destination grid square.`,
|
||||||
|
answerCell: dest,
|
||||||
|
answerFeatureId: "",
|
||||||
|
startCell: start,
|
||||||
|
azimuth: 5,
|
||||||
|
distanceM: 3 * METERS_PER_CELL,
|
||||||
|
declination: 5,
|
||||||
|
reference: gridRef(course, start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdentifyLeg(course: LandNavCourse, rng: () => number): LandNavLeg {
|
||||||
|
const answer = pick(course.features, rng);
|
||||||
|
const distractors = shuffled(
|
||||||
|
course.features.filter((f) => f.id !== answer.id),
|
||||||
|
rng,
|
||||||
|
).slice(0, 3);
|
||||||
|
const options = shuffled([answer, ...distractors], rng).map((f) => f.name);
|
||||||
|
return {
|
||||||
|
kind: "identify",
|
||||||
|
prompt: `Which feature sits at grid ${gridRef(course, answer)}?`,
|
||||||
|
answerCell: { col: answer.col, row: answer.row },
|
||||||
|
answerFeatureId: answer.id,
|
||||||
|
options,
|
||||||
|
reference: gridRef(course, answer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the clicked cell answers the leg. */
|
||||||
|
export function isLegCorrect(leg: LandNavLeg, cell: Cell): boolean {
|
||||||
|
return sameCell(leg.answerCell, cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the chosen option name answers an identify leg. */
|
||||||
|
export function isOptionCorrect(leg: LandNavLeg, course: LandNavCourse, option: string): boolean {
|
||||||
|
if (leg.kind !== "identify") return false;
|
||||||
|
const feature = course.features.find((f) => f.id === leg.answerFeatureId);
|
||||||
|
return feature !== undefined && feature.name === option;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score for a finished run. Passing runs earn the base plus per-leg and time
|
||||||
|
* bonuses; failed runs earn a small participation score per correct leg.
|
||||||
|
*/
|
||||||
|
export function calculateScore(
|
||||||
|
difficulty: LandNavDifficulty,
|
||||||
|
legsCorrect: number,
|
||||||
|
seconds: number,
|
||||||
|
won: boolean,
|
||||||
|
): number {
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[difficulty];
|
||||||
|
if (!won) {
|
||||||
|
return legsCorrect * 10;
|
||||||
|
}
|
||||||
|
const timeBonus = Math.max(0, Math.min(config.parSeconds / 2, config.parSeconds - seconds));
|
||||||
|
return Math.round(
|
||||||
|
config.baseScore + legsCorrect * config.perLegBonus + timeBonus * config.perSecondBonus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XP awarded for a run: 1 XP per 50 score on a pass, 0 on a fail. */
|
||||||
|
export function calculateXp(score: number, won: boolean): number {
|
||||||
|
if (!won) return 0;
|
||||||
|
return Math.max(1, Math.floor(score / 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-difficulty fastest-time stat field on the profile. */
|
||||||
|
export const BEST_TIME_FIELDS: Record<LandNavDifficulty, string> = {
|
||||||
|
waypoint: "bestWaypointSeconds",
|
||||||
|
route: "bestRouteSeconds",
|
||||||
|
expedition: "bestExpeditionSeconds",
|
||||||
|
};
|
||||||
23751
src/migrations/20260908_142859_add_land_nav_stats.json
Normal file
23751
src/migrations/20260908_142859_add_land_nav_stats.json
Normal file
File diff suppressed because it is too large
Load diff
25
src/migrations/20260908_142859_add_land_nav_stats.ts
Normal file
25
src/migrations/20260908_142859_add_land_nav_stats.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_games_played" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_wins" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_losses" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_best_score" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_xp_earned" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_best_waypoint_seconds" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_best_route_seconds" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_land_nav_best_expedition_seconds" numeric DEFAULT 0;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_games_played";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_wins";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_losses";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_best_score";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_xp_earned";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_best_waypoint_seconds";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_best_route_seconds";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_land_nav_best_expedition_seconds";`)
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,7 @@ import * as migration_20260907_090245_add_mission_ready_reminder_fields from './
|
||||||
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
|
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
|
||||||
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
|
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
|
||||||
import * as migration_20260908_054314_add_call_for_fire_stats from './20260908_054314_add_call_for_fire_stats';
|
import * as migration_20260908_054314_add_call_for_fire_stats from './20260908_054314_add_call_for_fire_stats';
|
||||||
|
import * as migration_20260908_142859_add_land_nav_stats from './20260908_142859_add_land_nav_stats';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -280,6 +281,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260908_054314_add_call_for_fire_stats.up,
|
up: migration_20260908_054314_add_call_for_fire_stats.up,
|
||||||
down: migration_20260908_054314_add_call_for_fire_stats.down,
|
down: migration_20260908_054314_add_call_for_fire_stats.down,
|
||||||
name: '20260908_054314_add_call_for_fire_stats'
|
name: '20260908_054314_add_call_for_fire_stats',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260908_142859_add_land_nav_stats.up,
|
||||||
|
down: migration_20260908_142859_add_land_nav_stats.down,
|
||||||
|
name: '20260908_142859_add_land_nav_stats'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2047,6 +2047,16 @@ export interface Profile {
|
||||||
bestCheckpointMissions?: number | null;
|
bestCheckpointMissions?: number | null;
|
||||||
bestCompoundMissions?: number | null;
|
bestCompoundMissions?: number | null;
|
||||||
};
|
};
|
||||||
|
landNav?: {
|
||||||
|
gamesPlayed?: number | null;
|
||||||
|
wins?: number | null;
|
||||||
|
losses?: number | null;
|
||||||
|
bestScore?: number | null;
|
||||||
|
xpEarned?: number | null;
|
||||||
|
bestWaypointSeconds?: number | null;
|
||||||
|
bestRouteSeconds?: number | null;
|
||||||
|
bestExpeditionSeconds?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
voiceover?: {
|
voiceover?: {
|
||||||
xpEarned?: number | null;
|
xpEarned?: number | null;
|
||||||
|
|
@ -4451,6 +4461,18 @@ export interface ProfilesSelect<T extends boolean = true> {
|
||||||
bestCheckpointMissions?: T;
|
bestCheckpointMissions?: T;
|
||||||
bestCompoundMissions?: T;
|
bestCompoundMissions?: T;
|
||||||
};
|
};
|
||||||
|
landNav?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
gamesPlayed?: T;
|
||||||
|
wins?: T;
|
||||||
|
losses?: T;
|
||||||
|
bestScore?: T;
|
||||||
|
xpEarned?: T;
|
||||||
|
bestWaypointSeconds?: T;
|
||||||
|
bestRouteSeconds?: T;
|
||||||
|
bestExpeditionSeconds?: T;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
voiceover?:
|
voiceover?:
|
||||||
| T
|
| T
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export const EventTypes = {
|
||||||
MinigameRadioTraffic: "minigame:radio-traffic",
|
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||||
MinigameAimTrainer: "minigame:aim-trainer",
|
MinigameAimTrainer: "minigame:aim-trainer",
|
||||||
MinigameCallForFire: "minigame:call-for-fire",
|
MinigameCallForFire: "minigame:call-for-fire",
|
||||||
|
MinigameLandNav: "minigame:land-nav",
|
||||||
|
|
||||||
// Voiceover
|
// Voiceover
|
||||||
VoiceoverSubmit: "voiceover:submit",
|
VoiceoverSubmit: "voiceover:submit",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue