diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index bf79005..6e6e922 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -15,9 +15,10 @@ try { expectedBattleId: 'first-battle-zhuo-commandery', mapWidth: 12, mapHeight: 8, - validUnitIds: new Set(['liu-bei']) + validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1']) }; const validState = createValidBattleSaveState(); + const patchUnit = (index, patch) => validState.units.map((unit, unitIndex) => (unitIndex === index ? { ...unit, ...patch } : unit)); 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.'); @@ -35,19 +36,26 @@ try { ['invalid activeFaction', { activeFaction: 'neutral' }], ['invalid rosterTab', { rosterTab: 'neutral' }], ['invalid actedUnitIds', { actedUnitIds: 'liu-bei' }], + ['unknown acted unit id', { actedUnitIds: ['ghost-unit'] }], ['invalid battleLog', { battleLog: 'hit' }], ['invalid attackIntents', { attackIntents: [{ attackerId: 'liu-bei' }] }], + ['unknown attack intent unit id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'ghost-unit' }] }], ['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' }] }], - ['unknown unit id', { units: [{ ...validState.units[0], id: 'ghost-unit' }] }], - ['duplicate unit id', { units: [{ ...validState.units[0] }, { ...validState.units[0], x: 2 }] }], + ['invalid unit hp', { units: patchUnit(0, { hp: 99, maxHp: 30 }) }], + ['invalid unit x', { units: patchUnit(0, { x: 12 }) }], + ['invalid unit direction', { units: patchUnit(0, { direction: 'down' }) }], + ['unknown unit id', { units: patchUnit(0, { id: 'ghost-unit' }) }], + ['duplicate unit id', { units: [validState.units[0], { ...validState.units[0], x: 2 }, validState.units[2]] }], ['invalid bonds', { bonds: [{ ...validState.bonds[0], unitIds: ['liu-bei'] }] }], + ['unknown bond unit id', { bonds: [{ ...validState.bonds[0], unitIds: ['liu-bei', 'ghost-unit'] }] }], ['invalid item stock count', { itemStocks: { 'liu-bei': { bean: -1 } } }], + ['unknown item stock unit id', { itemStocks: { 'ghost-unit': { bean: 1 } } }], ['invalid buff turns', { battleBuffs: [{ ...validState.battleBuffs[0], turns: 0 }] }], + ['unknown buff unit id', { battleBuffs: [{ ...validState.battleBuffs[0], unitId: 'ghost-unit' }] }], ['invalid status kind', { battleStatuses: [{ ...validState.battleStatuses[0], kind: 'poison' }] }], + ['unknown status unit id', { battleStatuses: [{ ...validState.battleStatuses[0], unitId: 'ghost-unit' }] }], ['invalid stats shape', { battleStats: { 'liu-bei': { damageDealt: 10 } } }], + ['unknown stats unit id', { battleStats: { 'ghost-unit': validState.battleStats['liu-bei'] } }], ['invalid enemy usable keys', { enemyUsableUseKeys: [1] }], ['invalid triggered events', { triggeredBattleEvents: [1] }] ]; @@ -58,7 +66,7 @@ try { }); assert( - !isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu']) }), + !isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1', 'zhang-fei']) }), 'Expected save missing a current battle unit to be rejected.' ); @@ -92,6 +100,32 @@ function createValidBattleSaveState() { y: 2, direction: 'south', equipment: {} + }, + { + id: 'guan-yu', + level: 2, + exp: 5, + hp: 32, + maxHp: 34, + attack: 14, + move: 4, + x: 2, + y: 2, + direction: 'south', + equipment: {} + }, + { + id: 'rebel-1', + level: 1, + exp: 0, + hp: 20, + maxHp: 20, + attack: 8, + move: 3, + x: 8, + y: 4, + direction: 'west', + equipment: {} } ], bonds: [ diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 9db9b11..3efcb79 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -116,20 +116,20 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida } if ( - !isStringArray(state.actedUnitIds) || + !isUnitIdArray(state.actedUnitIds, options) || !isBattleLog(state.battleLog) || - !isAttackIntentArray(state.attackIntents) || + !isAttackIntentArray(state.attackIntents, options) || !areSavedBattleUnitsValid(state.units, options) || - !isBondArray(state.bonds) + !isBondArray(state.bonds, options) ) { return false; } if ( - !isOptionalNestedCountRecord(state.itemStocks) || - !isOptionalBuffArray(state.battleBuffs) || - !isOptionalStatusArray(state.battleStatuses) || - !isOptionalStatsRecord(state.battleStats) || + !isOptionalNestedCountRecord(state.itemStocks, options) || + !isOptionalBuffArray(state.battleBuffs, options) || + !isOptionalStatusArray(state.battleStatuses, options) || + !isOptionalStatsRecord(state.battleStats, options) || !isOptionalStringArray(state.enemyUsableUseKeys) || !isOptionalStringArray(state.triggeredBattleEvents) ) { @@ -171,6 +171,10 @@ function isStringArray(value: unknown) { return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); } +function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && isKnownUnitId(entry, options)); +} + function isOptionalStringArray(value: unknown) { return value === undefined || isStringArray(value); } @@ -179,10 +183,17 @@ function isBattleLog(value: unknown) { return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); } -function isAttackIntentArray(value: unknown) { +function isAttackIntentArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && - value.every((intent) => isRecord(intent) && typeof intent.attackerId === 'string' && typeof intent.targetId === 'string') + value.every( + (intent) => + isRecord(intent) && + typeof intent.attackerId === 'string' && + typeof intent.targetId === 'string' && + isKnownUnitId(intent.attackerId, options) && + isKnownUnitId(intent.targetId, options) + ) ); } @@ -238,7 +249,7 @@ function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOpt return value.direction === undefined || unitDirections.has(value.direction as UnitDirection); } -function isBondArray(value: unknown) { +function isBondArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && value.every( @@ -247,7 +258,7 @@ function isBondArray(value: unknown) { typeof bond.id === 'string' && Array.isArray(bond.unitIds) && bond.unitIds.length === 2 && - bond.unitIds.every((unitId) => typeof unitId === 'string') && + bond.unitIds.every((unitId) => typeof unitId === 'string' && isKnownUnitId(unitId, options)) && isPositiveInteger(bond.level) && isNonNegativeFiniteNumber(bond.exp) && isNonNegativeFiniteNumber(bond.battleExp) @@ -255,7 +266,7 @@ function isBondArray(value: unknown) { ); } -function isOptionalNestedCountRecord(value: unknown) { +function isOptionalNestedCountRecord(value: unknown, options: BattleSaveValidationOptions) { if (value === undefined) { return true; } @@ -264,12 +275,15 @@ function isOptionalNestedCountRecord(value: unknown) { return false; } - return Object.values(value).every( - (stocks) => isRecord(stocks) && Object.values(stocks).every((count) => Number.isInteger(count) && Number(count) >= 0) + return Object.entries(value).every( + ([unitId, stocks]) => + isKnownUnitId(unitId, options) && + isRecord(stocks) && + Object.values(stocks).every((count) => Number.isInteger(count) && Number(count) >= 0) ); } -function isOptionalBuffArray(value: unknown) { +function isOptionalBuffArray(value: unknown, options: BattleSaveValidationOptions) { return ( value === undefined || (Array.isArray(value) && @@ -277,6 +291,7 @@ function isOptionalBuffArray(value: unknown) { (buff) => isRecord(buff) && typeof buff.unitId === 'string' && + isKnownUnitId(buff.unitId, options) && typeof buff.label === 'string' && isPositiveInteger(buff.turns) && isFiniteNumber(buff.attackBonus) && @@ -286,7 +301,7 @@ function isOptionalBuffArray(value: unknown) { ); } -function isOptionalStatusArray(value: unknown) { +function isOptionalStatusArray(value: unknown, options: BattleSaveValidationOptions) { return ( value === undefined || (Array.isArray(value) && @@ -294,6 +309,7 @@ function isOptionalStatusArray(value: unknown) { (status) => isRecord(status) && typeof status.unitId === 'string' && + isKnownUnitId(status.unitId, options) && isBattleStatusKind(status.kind) && typeof status.label === 'string' && isPositiveInteger(status.turns) && @@ -302,7 +318,7 @@ function isOptionalStatusArray(value: unknown) { ); } -function isOptionalStatsRecord(value: unknown) { +function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOptions) { if (value === undefined) { return true; } @@ -311,8 +327,9 @@ function isOptionalStatsRecord(value: unknown) { return false; } - return Object.values(value).every( - (stats) => + return Object.entries(value).every( + ([unitId, stats]) => + isKnownUnitId(unitId, options) && isRecord(stats) && isNonNegativeFiniteNumber(stats.damageDealt) && isNonNegativeFiniteNumber(stats.damageTaken) && @@ -321,3 +338,7 @@ function isOptionalStatsRecord(value: unknown) { isNonNegativeFiniteNumber(stats.support) ); } + +function isKnownUnitId(unitId: string, options: BattleSaveValidationOptions) { + return !options.validUnitIds || options.validUnitIds.has(unitId); +}