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 };
|
||||
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
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_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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue