275 lines
11 KiB
JavaScript
275 lines
11 KiB
JavaScript
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 { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
|
const { unitClasses } = await server.ssrLoadModule('/src/game/data/battleRules.ts');
|
|
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
|
|
|
|
const errors = [];
|
|
const recruitTemplates = Array.isArray(campaignRecruitUnits) ? campaignRecruitUnits : [];
|
|
const recruitTemplateIds = new Set(recruitTemplates.map((unit) => unit.id));
|
|
const classKeys = new Set(Object.keys(unitClasses));
|
|
const itemKeys = new Set(Object.keys(itemCatalog));
|
|
const recruitRewardRefs = [];
|
|
|
|
validateRecruitTemplates(errors, recruitTemplates, classKeys, itemKeys, itemCatalog, equipmentSlots);
|
|
|
|
const scenarioEntries = Object.entries(battleScenarios);
|
|
|
|
scenarioEntries.forEach(([battleId, scenario]) => {
|
|
const sameBattleAllies = new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id));
|
|
const rewardRecruits = scenario.campaignReward?.recruits ?? [];
|
|
|
|
rewardRecruits.forEach((unitId, index) => {
|
|
recruitRewardRefs.push({ battleId, unitId });
|
|
if (typeof unitId !== 'string' || unitId.trim().length === 0) {
|
|
errors.push(`${battleId}: campaignReward.recruits[${index}] must be a non-empty string`);
|
|
return;
|
|
}
|
|
if (!sameBattleAllies.has(unitId) && !recruitTemplateIds.has(unitId)) {
|
|
errors.push(
|
|
`${battleId}: recruit "${unitId}" is not an ally in this battle and has no campaignRecruitUnits template`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
validateReachability(errors, recruitTemplates, recruitRewardRefs);
|
|
validateUniqueRecruitRewards(errors, recruitRewardRefs);
|
|
validateSortieRecruitAvailability(errors, scenarioEntries);
|
|
validateEarlyJianYongChoice(errors, battleScenarios);
|
|
|
|
if (errors.length) {
|
|
console.error(`Campaign recruit data verification failed with ${errors.length} issue(s):`);
|
|
errors.slice(0, 100).forEach((error) => console.error(`- ${error}`));
|
|
if (errors.length > 100) {
|
|
console.error(`- ...and ${errors.length - 100} more`);
|
|
}
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(
|
|
`Verified ${recruitTemplates.length} campaign recruit unit templates and ${recruitRewardRefs.length} recruit rewards.`
|
|
);
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
function validateRecruitTemplates(errors, recruitTemplates, classKeys, itemKeys, itemCatalog, equipmentSlots) {
|
|
const ids = new Set();
|
|
|
|
recruitTemplates.forEach((unit, index) => {
|
|
const context = unit?.id ? `campaignRecruitUnits/${unit.id}` : `campaignRecruitUnits[${index}]`;
|
|
if (!unit || typeof unit !== 'object') {
|
|
errors.push(`${context}: must be a unit object`);
|
|
return;
|
|
}
|
|
|
|
assertNonEmptyString(errors, unit.id, `${context}: id`);
|
|
assertNonEmptyString(errors, unit.name, `${context}: name`);
|
|
assertNonEmptyString(errors, unit.className, `${context}: className`);
|
|
|
|
if (ids.has(unit.id)) {
|
|
errors.push(`${context}: duplicate recruit id "${unit.id}"`);
|
|
}
|
|
ids.add(unit.id);
|
|
|
|
if (unit.faction !== 'ally') {
|
|
errors.push(`${context}: campaign recruit faction must be ally, got "${unit.faction}"`);
|
|
}
|
|
if (!classKeys.has(unit.classKey)) {
|
|
errors.push(`${context}: unknown classKey "${unit.classKey}"`);
|
|
}
|
|
validateCharacterProgress(errors, unit, context);
|
|
if (!Number.isInteger(unit.hp) || !Number.isInteger(unit.maxHp) || unit.hp < 1 || unit.maxHp < 1 || unit.hp > unit.maxHp) {
|
|
errors.push(`${context}: invalid hp ${unit.hp}/${unit.maxHp}`);
|
|
}
|
|
if (!Number.isInteger(unit.attack) || unit.attack < 0) {
|
|
errors.push(`${context}: invalid attack ${unit.attack}`);
|
|
}
|
|
if (!Number.isInteger(unit.move) || unit.move < 1) {
|
|
errors.push(`${context}: invalid move ${unit.move}`);
|
|
}
|
|
if (!Number.isInteger(unit.x) || !Number.isInteger(unit.y)) {
|
|
errors.push(`${context}: invalid fallback position ${unit.x},${unit.y}`);
|
|
}
|
|
|
|
validateStats(errors, unit.stats, context);
|
|
validateEquipment(errors, unit.equipment, itemKeys, itemCatalog, equipmentSlots, context);
|
|
});
|
|
}
|
|
|
|
function validateStats(errors, stats, context) {
|
|
const requiredStats = ['might', 'intelligence', 'leadership', 'agility', 'luck'];
|
|
if (!stats || typeof stats !== 'object') {
|
|
errors.push(`${context}: missing stats`);
|
|
return;
|
|
}
|
|
|
|
requiredStats.forEach((statKey) => {
|
|
const value = stats[statKey];
|
|
if (!Number.isInteger(value) || value < 1 || value > 120) {
|
|
errors.push(`${context}: invalid stats.${statKey} ${value}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateCharacterProgress(errors, unit, context) {
|
|
if (!Number.isInteger(unit.level) || unit.level < 1) {
|
|
errors.push(`${context}: invalid level ${unit.level}`);
|
|
}
|
|
if (!Number.isInteger(unit.exp) || unit.exp < 0) {
|
|
errors.push(`${context}: invalid exp ${unit.exp}`);
|
|
} else if (Number.isInteger(unit.level) && unit.level < 99 && unit.exp >= 100) {
|
|
errors.push(`${context}: exp ${unit.exp} should be below 100 before max level`);
|
|
} else if (Number.isInteger(unit.level) && unit.level >= 99 && unit.exp > 100) {
|
|
errors.push(`${context}: max level exp ${unit.exp} should be at most 100`);
|
|
}
|
|
}
|
|
|
|
function validateEquipment(errors, equipment, itemKeys, itemCatalog, equipmentSlots, context) {
|
|
if (!equipment || typeof equipment !== 'object') {
|
|
errors.push(`${context}: missing equipment`);
|
|
return;
|
|
}
|
|
|
|
equipmentSlots.forEach((slot) => {
|
|
const state = equipment[slot];
|
|
if (!state) {
|
|
errors.push(`${context}: missing ${slot} equipment`);
|
|
return;
|
|
}
|
|
|
|
if (!itemKeys.has(state.itemId)) {
|
|
errors.push(`${context}: unknown ${slot} item "${state.itemId}"`);
|
|
} else if (itemCatalog[state.itemId].slot !== slot) {
|
|
errors.push(`${context}: ${slot} item "${state.itemId}" belongs to ${itemCatalog[state.itemId].slot}`);
|
|
}
|
|
if (!Number.isInteger(state.level) || state.level < 1 || state.level > 9) {
|
|
errors.push(`${context}: invalid ${slot} level ${state.level}`);
|
|
}
|
|
if (!Number.isInteger(state.exp) || state.exp < 0) {
|
|
errors.push(`${context}: invalid ${slot} exp ${state.exp}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateReachability(errors, recruitTemplates, recruitRewardRefs) {
|
|
const rewardedRecruitIds = new Set(recruitRewardRefs.map(({ unitId }) => unitId));
|
|
recruitTemplates.forEach((unit) => {
|
|
if (!rewardedRecruitIds.has(unit.id)) {
|
|
errors.push(`campaignRecruitUnits/${unit.id}: recruit template is never awarded by campaignReward.recruits`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateUniqueRecruitRewards(errors, recruitRewardRefs) {
|
|
const firstBattleByRecruitId = new Map();
|
|
recruitRewardRefs.forEach(({ battleId, unitId }) => {
|
|
if (firstBattleByRecruitId.has(unitId)) {
|
|
errors.push(`${battleId}: recruit "${unitId}" was already awarded by ${firstBattleByRecruitId.get(unitId)}`);
|
|
return;
|
|
}
|
|
firstBattleByRecruitId.set(unitId, battleId);
|
|
});
|
|
}
|
|
|
|
function validateSortieRecruitAvailability(errors, scenarioEntries) {
|
|
const firstBattleAllies = scenarioEntries[0]?.[1]?.units
|
|
?.filter((unit) => unit.faction === 'ally')
|
|
.map((unit) => unit.id) ?? [];
|
|
const availableUnitIds = new Set(firstBattleAllies);
|
|
|
|
scenarioEntries.forEach(([battleId, scenario]) => {
|
|
const unavailableRefs = new Set();
|
|
const sortie = scenario.sortie;
|
|
|
|
(sortie?.requiredUnits ?? []).forEach((unitId) => {
|
|
if (!availableUnitIds.has(unitId)) {
|
|
unavailableRefs.add(`requiredUnits:${unitId}`);
|
|
}
|
|
});
|
|
(sortie?.recommendedUnits ?? []).forEach((recommendation) => {
|
|
if (!availableUnitIds.has(recommendation.unitId)) {
|
|
unavailableRefs.add(`recommendedUnits:${recommendation.unitId}`);
|
|
}
|
|
});
|
|
(sortie?.deploymentSlots ?? []).forEach((slot) => {
|
|
if (slot.unitId && !availableUnitIds.has(slot.unitId)) {
|
|
unavailableRefs.add(`deploymentSlots:${slot.unitId}`);
|
|
}
|
|
});
|
|
|
|
if (unavailableRefs.size > 0) {
|
|
errors.push(`${battleId}: sortie references unit(s) before recruit availability: ${Array.from(unavailableRefs).join(', ')}`);
|
|
}
|
|
|
|
(scenario.campaignReward?.recruits ?? []).forEach((unitId) => availableUnitIds.add(unitId));
|
|
});
|
|
}
|
|
|
|
function validateEarlyJianYongChoice(errors, battleScenarios) {
|
|
const firstBattleId = 'first-battle-zhuo-commandery';
|
|
const earlyChoiceBattleIds = [
|
|
'second-battle-yellow-turban-pursuit',
|
|
'third-battle-guangzong-road',
|
|
'fourth-battle-guangzong-camp',
|
|
'fifth-battle-sishui-vanguard',
|
|
'sixth-battle-jieqiao-relief',
|
|
'seventh-battle-xuzhou-rescue'
|
|
];
|
|
const foundingTrio = ['liu-bei', 'guan-yu', 'zhang-fei'];
|
|
const earlyRoster = [...foundingTrio, 'jian-yong'];
|
|
|
|
const firstScenario = battleScenarios[firstBattleId];
|
|
if (!firstScenario?.campaignReward?.recruits?.includes('jian-yong')) {
|
|
errors.push(`${firstBattleId}: first victory must recruit jian-yong`);
|
|
}
|
|
const firstVictoryCopy = firstScenario?.victoryPages?.map((page) => `${page.text ?? ''} ${page.cutscene?.title ?? ''} ${page.cutscene?.subtitle ?? ''}`).join(' ') ?? '';
|
|
if (!firstVictoryCopy.includes('간옹') || !firstVictoryCopy.includes('최대 3명')) {
|
|
errors.push(`${firstBattleId}: victory story must explain Jian Yong's arrival and the four-officer/three-slot choice`);
|
|
}
|
|
|
|
earlyChoiceBattleIds.forEach((battleId) => {
|
|
const scenario = battleScenarios[battleId];
|
|
if (!scenario) {
|
|
errors.push(`${battleId}: missing early-choice battle scenario`);
|
|
return;
|
|
}
|
|
const allyIds = new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id));
|
|
earlyRoster.forEach((unitId) => {
|
|
if (!allyIds.has(unitId)) {
|
|
errors.push(`${battleId}: early 4-of-3 roster is missing ${unitId}`);
|
|
}
|
|
});
|
|
if (scenario.sortie?.sortieLimit !== 3) {
|
|
errors.push(`${battleId}: early formation must keep sortieLimit 3, got ${scenario.sortie?.sortieLimit}`);
|
|
}
|
|
const recommendedIds = new Set((scenario.sortie?.recommendedUnits ?? []).map((entry) => entry.unitId));
|
|
if (recommendedIds.size !== foundingTrio.length || foundingTrio.some((unitId) => !recommendedIds.has(unitId))) {
|
|
errors.push(`${battleId}: recommended formation must remain the founding trio`);
|
|
}
|
|
if (!scenario.bonds.some((bond) => bond.id === 'liu-bei__jian-yong')) {
|
|
errors.push(`${battleId}: missing liu-bei__jian-yong resonance`);
|
|
}
|
|
});
|
|
|
|
const seventhRecruits = battleScenarios['seventh-battle-xuzhou-rescue']?.campaignReward?.recruits ?? [];
|
|
if (seventhRecruits.includes('jian-yong') || !seventhRecruits.includes('mi-zhu')) {
|
|
errors.push('seventh-battle-xuzhou-rescue: must recruit only the new Xuzhou officer Mi Zhu, not Jian Yong again');
|
|
}
|
|
}
|
|
|
|
function assertNonEmptyString(errors, value, context) {
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
errors.push(`${context} must be a non-empty string`);
|
|
}
|
|
}
|