diff --git a/package.json b/package.json index 369aa85..37f7c22 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs new file mode 100644 index 0000000..6d25a52 --- /dev/null +++ b/scripts/verify-battle-save-normalization.mjs @@ -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); + } +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 54ccfce..cec3586 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -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', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 18798bb..fe64ef8 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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 & { - 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>; - battleBuffs?: BattleBuffState[]; - battleStatuses?: BattleStatusState[]; - battleStats?: Record; - 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) { diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts new file mode 100644 index 0000000..378dc11 --- /dev/null +++ b/src/game/state/battleSaveState.ts @@ -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 & { + 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>; + battleBuffs?: BattleSaveBuffState[]; + battleStatuses?: BattleSaveStatusState[]; + battleStats?: Record; + enemyUsableUseKeys?: string[]; + triggeredBattleEvents?: string[]; +}; + +type BattleSaveValidationOptions = { + expectedBattleId?: string; + mapWidth?: number; + mapHeight?: number; +}; + +const unitDirections = new Set(['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 { + 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) + ); +}