Harden battle save validation
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"verify:battle-data": "node scripts/verify-battle-scenario-data.mjs",
|
||||
"verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs",
|
||||
"verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs",
|
||||
"verify:battle-save": "node scripts/verify-battle-save-normalization.mjs",
|
||||
"verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs",
|
||||
"verify:campaign-recruits": "node scripts/verify-campaign-recruit-data.mjs",
|
||||
"verify:equipment-catalog": "node scripts/verify-equipment-catalog-data.mjs",
|
||||
|
||||
138
scripts/verify-battle-save-normalization.mjs
Normal file
138
scripts/verify-battle-save-normalization.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const { isValidBattleSaveState, normalizeBattleSaveSlot, parseBattleSaveState } = await server.ssrLoadModule(
|
||||
'/src/game/state/battleSaveState.ts'
|
||||
);
|
||||
|
||||
const options = {
|
||||
expectedBattleId: 'first-battle-zhuo-commandery',
|
||||
mapWidth: 12,
|
||||
mapHeight: 8
|
||||
};
|
||||
const validState = createValidBattleSaveState();
|
||||
|
||||
assert(normalizeBattleSaveSlot(0, 3) === 1, 'Expected slot 0 to normalize to slot 1.');
|
||||
assert(normalizeBattleSaveSlot(Number.NaN, 3) === 1, 'Expected NaN slot to normalize to slot 1.');
|
||||
assert(normalizeBattleSaveSlot(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.');
|
||||
assert(isValidBattleSaveState(validState, options), 'Expected valid battle save to pass validation.');
|
||||
assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.');
|
||||
assert(parseBattleSaveState('{', options) === undefined, 'Expected malformed JSON to be ignored.');
|
||||
assert(parseBattleSaveState('', options) === undefined, 'Expected empty save payload to be ignored.');
|
||||
|
||||
const rejectedCases = [
|
||||
['wrong battle id', { battleId: 'other-battle' }],
|
||||
['missing battle id', { battleId: undefined }],
|
||||
['invalid savedAt', { savedAt: 'not-a-date' }],
|
||||
['invalid turnNumber', { turnNumber: 0 }],
|
||||
['invalid activeFaction', { activeFaction: 'neutral' }],
|
||||
['invalid rosterTab', { rosterTab: 'neutral' }],
|
||||
['invalid actedUnitIds', { actedUnitIds: 'liu-bei' }],
|
||||
['invalid battleLog', { battleLog: 'hit' }],
|
||||
['invalid attackIntents', { attackIntents: [{ attackerId: 'liu-bei' }] }],
|
||||
['invalid units array', { units: {} }],
|
||||
['invalid unit hp', { units: [{ ...validState.units[0], hp: 99, maxHp: 30 }] }],
|
||||
['invalid unit x', { units: [{ ...validState.units[0], x: 12 }] }],
|
||||
['invalid unit direction', { units: [{ ...validState.units[0], direction: 'down' }] }],
|
||||
['invalid bonds', { bonds: [{ ...validState.bonds[0], unitIds: ['liu-bei'] }] }],
|
||||
['invalid item stock count', { itemStocks: { 'liu-bei': { bean: -1 } } }],
|
||||
['invalid buff turns', { battleBuffs: [{ ...validState.battleBuffs[0], turns: 0 }] }],
|
||||
['invalid status kind', { battleStatuses: [{ ...validState.battleStatuses[0], kind: 'poison' }] }],
|
||||
['invalid stats shape', { battleStats: { 'liu-bei': { damageDealt: 10 } } }],
|
||||
['invalid enemy usable keys', { enemyUsableUseKeys: [1] }],
|
||||
['invalid triggered events', { triggeredBattleEvents: [1] }]
|
||||
];
|
||||
|
||||
rejectedCases.forEach(([label, patch]) => {
|
||||
const candidate = { ...validState, ...patch };
|
||||
assert(!isValidBattleSaveState(candidate, options), `Expected invalid battle save to be rejected: ${label}`);
|
||||
});
|
||||
|
||||
console.log('Verified battle save normalization and corrupted battle save rejection.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function createValidBattleSaveState() {
|
||||
return {
|
||||
version: 1,
|
||||
battleId: 'first-battle-zhuo-commandery',
|
||||
campaignStep: 'first-battle',
|
||||
savedAt: '2026-07-05T00:00:00.000Z',
|
||||
turnNumber: 2,
|
||||
activeFaction: 'ally',
|
||||
rosterTab: 'ally',
|
||||
actedUnitIds: ['liu-bei'],
|
||||
attackIntents: [{ attackerId: 'liu-bei', targetId: 'rebel-1' }],
|
||||
battleLog: ['Liu Bei attacked.'],
|
||||
units: [
|
||||
{
|
||||
id: 'liu-bei',
|
||||
level: 2,
|
||||
exp: 10,
|
||||
hp: 28,
|
||||
maxHp: 30,
|
||||
attack: 10,
|
||||
move: 4,
|
||||
x: 1,
|
||||
y: 2,
|
||||
direction: 'south',
|
||||
equipment: {}
|
||||
}
|
||||
],
|
||||
bonds: [
|
||||
{
|
||||
id: 'taoyuan',
|
||||
unitIds: ['liu-bei', 'guan-yu'],
|
||||
title: 'Oath',
|
||||
level: 1,
|
||||
exp: 0,
|
||||
battleExp: 0,
|
||||
description: 'Shared oath.'
|
||||
}
|
||||
],
|
||||
itemStocks: { 'liu-bei': { bean: 2 } },
|
||||
battleBuffs: [
|
||||
{
|
||||
unitId: 'liu-bei',
|
||||
label: 'Focus',
|
||||
turns: 2,
|
||||
attackBonus: 1,
|
||||
hitBonus: 10,
|
||||
criticalBonus: 0
|
||||
}
|
||||
],
|
||||
battleStatuses: [
|
||||
{
|
||||
unitId: 'rebel-1',
|
||||
kind: 'burn',
|
||||
label: 'Burn',
|
||||
turns: 2,
|
||||
power: 4
|
||||
}
|
||||
],
|
||||
battleStats: {
|
||||
'liu-bei': {
|
||||
damageDealt: 12,
|
||||
damageTaken: 3,
|
||||
defeats: 1,
|
||||
actions: 2,
|
||||
support: 0
|
||||
}
|
||||
},
|
||||
enemyUsableUseKeys: ['rebel-1:shout'],
|
||||
triggeredBattleEvents: ['first-objective-near']
|
||||
};
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process';
|
||||
const checks = [
|
||||
'scripts/verify-campaign-flow-data.mjs',
|
||||
'scripts/verify-campaign-save-normalization.mjs',
|
||||
'scripts/verify-battle-save-normalization.mjs',
|
||||
'scripts/verify-battle-scenario-data.mjs',
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-campaign-recruit-data.mjs',
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
type CampaignSortieItemAssignments,
|
||||
type CampaignStep
|
||||
} from '../state/campaignState';
|
||||
import { normalizeBattleSaveSlot, parseBattleSaveState, type BattleSaveState } from '../state/battleSaveState';
|
||||
import { palette } from '../ui/palette';
|
||||
import { startLazyScene } from './lazyScenes';
|
||||
|
||||
@@ -1500,32 +1501,6 @@ type TargetingGuideNotice = {
|
||||
tone: number;
|
||||
};
|
||||
|
||||
type SavedUnitState = Pick<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
|
||||
equipment: UnitData['equipment'];
|
||||
direction?: UnitDirection;
|
||||
};
|
||||
|
||||
type BattleSaveState = {
|
||||
version: 1;
|
||||
battleId: string;
|
||||
campaignStep?: CampaignStep;
|
||||
savedAt: string;
|
||||
turnNumber: number;
|
||||
activeFaction: ActiveFaction;
|
||||
rosterTab: RosterTab;
|
||||
actedUnitIds: string[];
|
||||
attackIntents: AttackIntent[];
|
||||
battleLog: string[];
|
||||
units: SavedUnitState[];
|
||||
bonds: BondState[];
|
||||
itemStocks?: Record<string, Record<string, number>>;
|
||||
battleBuffs?: BattleBuffState[];
|
||||
battleStatuses?: BattleStatusState[];
|
||||
battleStats?: Record<string, UnitBattleStats>;
|
||||
enemyUsableUseKeys?: string[];
|
||||
triggeredBattleEvents?: string[];
|
||||
};
|
||||
|
||||
const maxEquipmentLevel = 9;
|
||||
const maxCharacterLevel = 99;
|
||||
let battleScenario = defaultBattleScenario;
|
||||
@@ -11362,30 +11337,19 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private battleSaveStorageKeyForSlot(slot: number) {
|
||||
return `${battleSaveStorageKey}:slot-${Phaser.Math.Clamp(Math.floor(slot), 1, campaignSaveSlotCount)}`;
|
||||
return `${battleSaveStorageKey}:slot-${normalizeBattleSaveSlot(slot, campaignSaveSlotCount)}`;
|
||||
}
|
||||
|
||||
private readBattleSaveState(slot: number) {
|
||||
const normalizedSlot = Phaser.Math.Clamp(Math.floor(slot), 1, campaignSaveSlotCount);
|
||||
const normalizedSlot = normalizeBattleSaveSlot(slot, campaignSaveSlotCount);
|
||||
const raw =
|
||||
window.localStorage.getItem(this.battleSaveStorageKeyForSlot(normalizedSlot)) ??
|
||||
(normalizedSlot === 1 ? window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey) : undefined);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = JSON.parse(raw) as BattleSaveState;
|
||||
if (!state || state.version !== 1 || !Array.isArray(state.units)) {
|
||||
return undefined;
|
||||
}
|
||||
if (state.battleId && state.battleId !== battleScenario.id) {
|
||||
return undefined;
|
||||
}
|
||||
return state;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return parseBattleSaveState(raw, {
|
||||
expectedBattleId: battleScenario.id,
|
||||
mapWidth: battleMap.width,
|
||||
mapHeight: battleMap.height
|
||||
});
|
||||
}
|
||||
|
||||
private saveBattleState(slot = 1) {
|
||||
|
||||
300
src/game/state/battleSaveState.ts
Normal file
300
src/game/state/battleSaveState.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { BattleBond, UnitData } from '../data/scenario';
|
||||
import type { CampaignStep } from './campaignState';
|
||||
import type { UnitDirection } from '../data/unitAssets';
|
||||
|
||||
export type BattleSaveFaction = 'ally' | 'enemy';
|
||||
export type BattleSaveRosterTab = 'ally' | 'enemy';
|
||||
|
||||
export type BattleSaveAttackIntent = {
|
||||
attackerId: string;
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
export type BattleSaveBondState = BattleBond & {
|
||||
battleExp: number;
|
||||
};
|
||||
|
||||
export type SavedBattleUnitState = Pick<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
|
||||
equipment: UnitData['equipment'];
|
||||
direction?: UnitDirection;
|
||||
};
|
||||
|
||||
export type BattleSaveBuffState = {
|
||||
unitId: string;
|
||||
label: string;
|
||||
turns: number;
|
||||
attackBonus: number;
|
||||
hitBonus: number;
|
||||
criticalBonus: number;
|
||||
};
|
||||
|
||||
export type BattleSaveStatusKind = 'burn' | 'confusion';
|
||||
|
||||
export type BattleSaveStatusState = {
|
||||
unitId: string;
|
||||
kind: BattleSaveStatusKind;
|
||||
label: string;
|
||||
turns: number;
|
||||
power: number;
|
||||
};
|
||||
|
||||
export type BattleSaveUnitStats = {
|
||||
damageDealt: number;
|
||||
damageTaken: number;
|
||||
defeats: number;
|
||||
actions: number;
|
||||
support: number;
|
||||
};
|
||||
|
||||
export type BattleSaveState = {
|
||||
version: 1;
|
||||
battleId: string;
|
||||
campaignStep?: CampaignStep;
|
||||
savedAt: string;
|
||||
turnNumber: number;
|
||||
activeFaction: BattleSaveFaction;
|
||||
rosterTab: BattleSaveRosterTab;
|
||||
actedUnitIds: string[];
|
||||
attackIntents: BattleSaveAttackIntent[];
|
||||
battleLog: string[];
|
||||
units: SavedBattleUnitState[];
|
||||
bonds: BattleSaveBondState[];
|
||||
itemStocks?: Record<string, Record<string, number>>;
|
||||
battleBuffs?: BattleSaveBuffState[];
|
||||
battleStatuses?: BattleSaveStatusState[];
|
||||
battleStats?: Record<string, BattleSaveUnitStats>;
|
||||
enemyUsableUseKeys?: string[];
|
||||
triggeredBattleEvents?: string[];
|
||||
};
|
||||
|
||||
type BattleSaveValidationOptions = {
|
||||
expectedBattleId?: string;
|
||||
mapWidth?: number;
|
||||
mapHeight?: number;
|
||||
};
|
||||
|
||||
const unitDirections = new Set<UnitDirection>(['south', 'east', 'north', 'west']);
|
||||
|
||||
export function normalizeBattleSaveSlot(slot: number, slotCount: number) {
|
||||
const safeSlot = Number.isFinite(slot) ? Math.floor(slot) : 1;
|
||||
return Math.min(Math.max(safeSlot, 1), slotCount);
|
||||
}
|
||||
|
||||
export function parseBattleSaveState(raw: string | null | undefined, options: BattleSaveValidationOptions = {}) {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = JSON.parse(raw) as unknown;
|
||||
return isValidBattleSaveState(state, options) ? state : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidBattleSaveState(state: unknown, options: BattleSaveValidationOptions = {}): state is BattleSaveState {
|
||||
if (!isRecord(state) || state.version !== 1 || !Array.isArray(state.units)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof state.battleId !== 'string' || state.battleId.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.expectedBattleId && state.battleId !== options.expectedBattleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidIsoLikeDate(state.savedAt) || !isPositiveInteger(state.turnNumber)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isBattleFaction(state.activeFaction) || !isBattleFaction(state.rosterTab)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!isStringArray(state.actedUnitIds) ||
|
||||
!isBattleLog(state.battleLog) ||
|
||||
!isAttackIntentArray(state.attackIntents) ||
|
||||
!state.units.every((unit) => isSavedBattleUnitState(unit, options)) ||
|
||||
!isBondArray(state.bonds)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!isOptionalNestedCountRecord(state.itemStocks) ||
|
||||
!isOptionalBuffArray(state.battleBuffs) ||
|
||||
!isOptionalStatusArray(state.battleStatuses) ||
|
||||
!isOptionalStatsRecord(state.battleStats) ||
|
||||
!isOptionalStringArray(state.enemyUsableUseKeys) ||
|
||||
!isOptionalStringArray(state.triggeredBattleEvents)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown) {
|
||||
return Number.isInteger(value) && Number(value) >= 1;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isValidIsoLikeDate(value: unknown) {
|
||||
return typeof value === 'string' && value.length > 0 && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function isBattleFaction(value: unknown): value is BattleSaveFaction {
|
||||
return value === 'ally' || value === 'enemy';
|
||||
}
|
||||
|
||||
function isBattleStatusKind(value: unknown): value is BattleSaveStatusKind {
|
||||
return value === 'burn' || value === 'confusion';
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown) {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
||||
}
|
||||
|
||||
function isOptionalStringArray(value: unknown) {
|
||||
return value === undefined || isStringArray(value);
|
||||
}
|
||||
|
||||
function isBattleLog(value: unknown) {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
||||
}
|
||||
|
||||
function isAttackIntentArray(value: unknown) {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((intent) => isRecord(intent) && typeof intent.attackerId === 'string' && typeof intent.targetId === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOptions) {
|
||||
if (!isRecord(value) || typeof value.id !== 'string' || value.id.length === 0 || !isRecord(value.equipment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!isPositiveInteger(value.level) ||
|
||||
!isNonNegativeFiniteNumber(value.exp) ||
|
||||
!isNonNegativeFiniteNumber(value.hp) ||
|
||||
!isPositiveInteger(value.maxHp) ||
|
||||
Number(value.hp) > Number(value.maxHp) ||
|
||||
!isFiniteNumber(value.attack) ||
|
||||
!isNonNegativeFiniteNumber(value.move) ||
|
||||
!Number.isInteger(value.x) ||
|
||||
!Number.isInteger(value.y)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.mapWidth !== undefined && (Number(value.x) < 0 || Number(value.x) >= options.mapWidth)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.mapHeight !== undefined && (Number(value.y) < 0 || Number(value.y) >= options.mapHeight)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.direction === undefined || unitDirections.has(value.direction as UnitDirection);
|
||||
}
|
||||
|
||||
function isBondArray(value: unknown) {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(bond) =>
|
||||
isRecord(bond) &&
|
||||
typeof bond.id === 'string' &&
|
||||
Array.isArray(bond.unitIds) &&
|
||||
bond.unitIds.length === 2 &&
|
||||
bond.unitIds.every((unitId) => typeof unitId === 'string') &&
|
||||
isPositiveInteger(bond.level) &&
|
||||
isNonNegativeFiniteNumber(bond.exp) &&
|
||||
isNonNegativeFiniteNumber(bond.battleExp)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalNestedCountRecord(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(value).every(
|
||||
(stocks) => isRecord(stocks) && Object.values(stocks).every((count) => Number.isInteger(count) && Number(count) >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalBuffArray(value: unknown) {
|
||||
return (
|
||||
value === undefined ||
|
||||
(Array.isArray(value) &&
|
||||
value.every(
|
||||
(buff) =>
|
||||
isRecord(buff) &&
|
||||
typeof buff.unitId === 'string' &&
|
||||
typeof buff.label === 'string' &&
|
||||
isPositiveInteger(buff.turns) &&
|
||||
isFiniteNumber(buff.attackBonus) &&
|
||||
isFiniteNumber(buff.hitBonus) &&
|
||||
isFiniteNumber(buff.criticalBonus)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalStatusArray(value: unknown) {
|
||||
return (
|
||||
value === undefined ||
|
||||
(Array.isArray(value) &&
|
||||
value.every(
|
||||
(status) =>
|
||||
isRecord(status) &&
|
||||
typeof status.unitId === 'string' &&
|
||||
isBattleStatusKind(status.kind) &&
|
||||
typeof status.label === 'string' &&
|
||||
isPositiveInteger(status.turns) &&
|
||||
isFiniteNumber(status.power)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalStatsRecord(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(value).every(
|
||||
(stats) =>
|
||||
isRecord(stats) &&
|
||||
isNonNegativeFiniteNumber(stats.damageDealt) &&
|
||||
isNonNegativeFiniteNumber(stats.damageTaken) &&
|
||||
isNonNegativeFiniteNumber(stats.defeats) &&
|
||||
isNonNegativeFiniteNumber(stats.actions) &&
|
||||
isNonNegativeFiniteNumber(stats.support)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user