diff --git a/package.json b/package.json index 8f8b272..4c8b6c8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "generate:sfx": "node scripts/generate-sfx.mjs", "preview": "vite preview --host 0.0.0.0", "measure:performance": "node scripts/measure-performance.mjs", + "verify:battle-data": "node scripts/verify-battle-scenario-data.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:flow": "node scripts/verify-flow.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", diff --git a/scripts/verify-battle-scenario-data.mjs b/scripts/verify-battle-scenario-data.mjs new file mode 100644 index 0000000..0348a4b --- /dev/null +++ b/scripts/verify-battle-scenario-data.mjs @@ -0,0 +1,262 @@ +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { unitClasses, terrainRules } = await server.ssrLoadModule('/src/game/data/battleRules.ts'); + const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); + + const scenarioEntries = Object.entries(battleScenarios); + const scenarioIds = new Set(scenarioEntries.map(([id]) => id)); + const classKeys = new Set(Object.keys(unitClasses)); + const terrainKeys = new Set(Object.keys(terrainRules)); + const itemKeys = new Set(Object.keys(itemCatalog)); + const errors = []; + + scenarioEntries.forEach(([scenarioId, scenario]) => { + const context = scenarioId; + if (scenario.id !== scenarioId) { + errors.push(`${context}: scenario id does not match battleScenarios key`); + } + assertNonEmptyString(errors, scenario.title, `${context}: title`); + assertNonEmptyString(errors, scenario.victoryConditionLabel, `${context}: victory condition label`); + assertNonEmptyString(errors, scenario.defeatConditionLabel, `${context}: defeat condition label`); + + validateMap(errors, scenario, terrainKeys, context); + validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context); + validateObjectives(errors, scenario, context); + validateSortie(errors, scenario, classKeys, context); + validateRewards(errors, scenario, scenarioIds, context); + }); + + if (errors.length) { + console.error(`Battle scenario data verification failed with ${errors.length} issue(s):`); + errors.slice(0, 80).forEach((error) => console.error(`- ${error}`)); + if (errors.length > 80) { + console.error(`- ...and ${errors.length - 80} more`); + } + process.exitCode = 1; + } else { + const unitCount = scenarioEntries.reduce((total, [, scenario]) => total + scenario.units.length, 0); + console.log(`Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, objectives, sortie data, and equipment.`); + } +} finally { + await server.close(); +} + +function validateMap(errors, scenario, terrainKeys, context) { + const { map } = scenario; + if (!map || !Number.isInteger(map.width) || !Number.isInteger(map.height) || map.width < 1 || map.height < 1) { + errors.push(`${context}: invalid map dimensions`); + return; + } + if (!Array.isArray(map.terrain) || map.terrain.length !== map.height) { + errors.push(`${context}: terrain row count does not match map height`); + return; + } + + map.terrain.forEach((row, y) => { + if (!Array.isArray(row) || row.length !== map.width) { + errors.push(`${context}: terrain row ${y} does not match map width`); + return; + } + row.forEach((terrain, x) => { + if (!terrainKeys.has(terrain)) { + errors.push(`${context}: unknown terrain "${terrain}" at ${x},${y}`); + } + }); + }); +} + +function validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context) { + const unitIds = new Set(); + const occupiedTiles = new Map(); + const allies = []; + const enemies = []; + + scenario.units.forEach((unit) => { + const unitContext = `${context}/${unit.id}`; + if (!unit.id) { + errors.push(`${context}: unit without id`); + return; + } + if (unitIds.has(unit.id)) { + errors.push(`${context}: duplicate unit id "${unit.id}"`); + } + unitIds.add(unit.id); + + if (unit.faction === 'ally') { + allies.push(unit); + } else if (unit.faction === 'enemy') { + enemies.push(unit); + } else { + errors.push(`${unitContext}: invalid faction "${unit.faction}"`); + } + + if (!classKeys.has(unit.classKey)) { + errors.push(`${unitContext}: unknown classKey "${unit.classKey}"`); + } + if (!Number.isInteger(unit.level) || unit.level < 1) { + errors.push(`${unitContext}: invalid level ${unit.level}`); + } + if (!Number.isInteger(unit.hp) || !Number.isInteger(unit.maxHp) || unit.hp < 1 || unit.maxHp < 1 || unit.hp > unit.maxHp) { + errors.push(`${unitContext}: invalid hp ${unit.hp}/${unit.maxHp}`); + } + if (!Number.isInteger(unit.move) || unit.move < 1) { + errors.push(`${unitContext}: invalid move ${unit.move}`); + } + if (!isInBounds(unit, scenario.map)) { + errors.push(`${unitContext}: position ${unit.x},${unit.y} is outside ${scenario.map.width}x${scenario.map.height}`); + } else { + const tileKey = `${unit.x},${unit.y}`; + const existingUnitId = occupiedTiles.get(tileKey); + if (existingUnitId) { + errors.push(`${context}: units "${existingUnitId}" and "${unit.id}" share tile ${tileKey}`); + } + occupiedTiles.set(tileKey, unit.id); + } + + equipmentSlots.forEach((slot) => { + const equipment = unit.equipment?.[slot]; + if (!equipment) { + errors.push(`${unitContext}: missing ${slot} equipment`); + return; + } + if (!itemKeys.has(equipment.itemId)) { + errors.push(`${unitContext}: unknown ${slot} item "${equipment.itemId}"`); + } + if (!Number.isInteger(equipment.level) || equipment.level < 1 || equipment.level > 9) { + errors.push(`${unitContext}: invalid ${slot} level ${equipment.level}`); + } + if (!Number.isInteger(equipment.exp) || equipment.exp < 0) { + errors.push(`${unitContext}: invalid ${slot} exp ${equipment.exp}`); + } + }); + }); + + if (!allies.length) { + errors.push(`${context}: scenario has no allied units`); + } + if (!enemies.length) { + errors.push(`${context}: scenario has no enemy units`); + } +} + +function validateObjectives(errors, scenario, context) { + const unitIds = new Set(scenario.units.map((unit) => unit.id)); + const objectiveIds = new Set(); + + if (!scenario.leaderUnitId || !unitIds.has(scenario.leaderUnitId)) { + errors.push(`${context}: leaderUnitId "${scenario.leaderUnitId}" is missing from units`); + } + scenario.defeatConditions.forEach((condition) => { + if (!unitIds.has(condition.unitId)) { + errors.push(`${context}: defeat condition references missing unit "${condition.unitId}"`); + } + }); + + scenario.objectives.forEach((objective) => { + if (!objective.id || objectiveIds.has(objective.id)) { + errors.push(`${context}: duplicate or missing objective id "${objective.id}"`); + } + objectiveIds.add(objective.id); + assertNonEmptyString(errors, objective.label, `${context}/${objective.id}: objective label`); + if (!Number.isFinite(objective.rewardGold) || objective.rewardGold < 0) { + errors.push(`${context}/${objective.id}: invalid rewardGold ${objective.rewardGold}`); + } + if (objective.unitId && !unitIds.has(objective.unitId)) { + errors.push(`${context}/${objective.id}: references missing unit "${objective.unitId}"`); + } + if (objective.targetTile && !isInBounds(objective.targetTile, scenario.map)) { + errors.push(`${context}/${objective.id}: target tile ${objective.targetTile.x},${objective.targetTile.y} is outside map`); + } + }); +} + +function validateSortie(errors, scenario, classKeys, context) { + const sortie = scenario.sortie; + if (!sortie) { + return; + } + + const unitIds = new Set(scenario.units.map((unit) => unit.id)); + if (!Number.isInteger(sortie.sortieLimit) || sortie.sortieLimit < 1) { + errors.push(`${context}: invalid sortie limit ${sortie.sortieLimit}`); + } + if ((sortie.requiredUnits?.length ?? 0) > sortie.sortieLimit) { + errors.push(`${context}: requiredUnits count exceeds sortieLimit`); + } + + [...(sortie.requiredUnits ?? []), ...(sortie.excludedUnits ?? [])].forEach((unitId) => { + if (!unitIds.has(unitId)) { + errors.push(`${context}: sortie references missing unit "${unitId}"`); + } + }); + sortie.recommendedUnits.forEach((recommendation) => { + if (!unitIds.has(recommendation.unitId)) { + errors.push(`${context}: recommended unit "${recommendation.unitId}" is missing from units`); + } + assertNonEmptyString(errors, recommendation.reason, `${context}/${recommendation.unitId}: recommendation reason`); + }); + sortie.recommendedClasses.forEach((recommendation) => { + assertNonEmptyString(errors, recommendation.label, `${context}: recommended class label`); + assertNonEmptyString(errors, recommendation.reason, `${context}/${recommendation.label}: recommended class reason`); + recommendation.classKeys.forEach((classKey) => { + if (!classKeys.has(classKey)) { + errors.push(`${context}: recommended class "${recommendation.label}" references unknown classKey "${classKey}"`); + } + }); + }); + + if (sortie.deploymentSlots.length < sortie.sortieLimit) { + errors.push(`${context}: deploymentSlots count is lower than sortieLimit`); + } + const deploymentTiles = new Set(); + sortie.deploymentSlots.forEach((slot) => { + if (!isInBounds(slot, scenario.map)) { + errors.push(`${context}: deployment slot ${slot.x},${slot.y} is outside map`); + return; + } + const tileKey = `${slot.x},${slot.y}`; + if (deploymentTiles.has(tileKey)) { + errors.push(`${context}: duplicate deployment slot ${tileKey}`); + } + deploymentTiles.add(tileKey); + if (slot.unitId && !unitIds.has(slot.unitId)) { + errors.push(`${context}: deployment slot references missing unit "${slot.unitId}"`); + } + }); +} + +function validateRewards(errors, scenario, scenarioIds, context) { + scenario.itemRewards.forEach((reward, index) => { + assertNonEmptyString(errors, reward, `${context}: item reward ${index}`); + }); + + const unlockBattleId = scenario.campaignReward?.unlockBattleId; + if (unlockBattleId && !scenarioIds.has(unlockBattleId)) { + errors.push(`${context}: campaignReward unlockBattleId "${unlockBattleId}" is not a known battle`); + } +} + +function isInBounds(point, map) { + return ( + Number.isInteger(point.x) && + Number.isInteger(point.y) && + point.x >= 0 && + point.y >= 0 && + point.x < map.width && + point.y < map.height + ); +} + +function assertNonEmptyString(errors, value, context) { + if (typeof value !== 'string' || value.trim().length === 0) { + errors.push(`${context} must be a non-empty string`); + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 317e88e..865ea40 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -10,6 +10,7 @@ let browser; try { runCommand('node', ['scripts/verify-campaign-flow-data.mjs']); + runCommand('node', ['scripts/verify-battle-scenario-data.mjs']); mkdirSync(screenshotDir, { recursive: true }); serverProcess = await ensurePreviewServer(targetUrl); diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index b4e3bae..fee2651 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -15774,6 +15774,12 @@ const thirtyFirstBattleAllyPositions: Record = 'wu-yi': { x: 12, y: 38 } }; +const scenarioUnitPositionOverrides: Record = { + 'jing-defense-wei-archer-b': { x: 69, y: 22 }, + 'yiling-fire-ambusher-e': { x: 27, y: 50 }, + 'northern-second-scout-a': { x: 47, y: 72 } +}; + const createThirtyFirstEnemyUnit = ( id: string, name: string, @@ -15785,6 +15791,7 @@ const createThirtyFirstEnemyUnit = ( x: number, y: number ): UnitData => { + const positionOverride = scenarioUnitPositionOverrides[id]; const equipmentByClass: Record = { lord: { weapon: { itemId: 'twin-oath-blades', level: 5, exp: 70 }, @@ -15882,8 +15889,8 @@ const createThirtyFirstEnemyUnit = ( luck: 90 + Math.floor(statLift / 2) }, equipment: equipmentByClass[classKey], - x, - y + x: positionOverride?.x ?? x, + y: positionOverride?.y ?? y }; };