1
0
Fork 0

add land navigation minigame rules lib, stats schema, event type and migration

This commit is contained in:
Jason Fraley 2026-09-08 10:34:55 -04:00
parent 20b24f254f
commit 62df673249
7 changed files with 24329 additions and 1 deletions

View file

@ -132,6 +132,7 @@ export const Profiles: CollectionConfig = {
radioTraffic?: { xpEarned?: number | null };
aimTrainer?: { xpEarned?: number | null };
callForFire?: { xpEarned?: number | null };
landNav?: { xpEarned?: number | null };
};
voiceover?: { xpEarned?: number | null };
};
@ -148,6 +149,7 @@ export const Profiles: CollectionConfig = {
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
const callForFireXp = progressionData?.minigames?.callForFire?.xpEarned ?? 0;
const landNavXp = progressionData?.minigames?.landNav?.xpEarned ?? 0;
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
if (awardIds.length > 0) {
@ -192,6 +194,8 @@ export const Profiles: CollectionConfig = {
xpAcc += aimTrainerXp;
// Call for Fire: XP from effective artillery adjustments
xpAcc += callForFireXp;
// Land Navigation: XP from passed navigation courses
xpAcc += landNavXp;
// Voiceover submissions: XP awarded per accepted submission
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
View 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",
};

File diff suppressed because it is too large Load diff

View 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";`)
}

View file

@ -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_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_142859_add_land_nav_stats from './20260908_142859_add_land_nav_stats';
export const migrations = [
{
@ -280,6 +281,11 @@ export const migrations = [
{
up: migration_20260908_054314_add_call_for_fire_stats.up,
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'
},
];

View file

@ -2047,6 +2047,16 @@ export interface Profile {
bestCheckpointMissions?: 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?: {
xpEarned?: number | null;
@ -4451,6 +4461,18 @@ export interface ProfilesSelect<T extends boolean = true> {
bestCheckpointMissions?: T;
bestCompoundMissions?: T;
};
landNav?:
| T
| {
gamesPlayed?: T;
wins?: T;
losses?: T;
bestScore?: T;
xpEarned?: T;
bestWaypointSeconds?: T;
bestRouteSeconds?: T;
bestExpeditionSeconds?: T;
};
};
voiceover?:
| T

View file

@ -77,6 +77,7 @@ export const EventTypes = {
MinigameRadioTraffic: "minigame:radio-traffic",
MinigameAimTrainer: "minigame:aim-trainer",
MinigameCallForFire: "minigame:call-for-fire",
MinigameLandNav: "minigame:land-nav",
// Voiceover
VoiceoverSubmit: "voiceover:submit",