feat: add playable inter-battle city stays
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,12 @@ try {
|
||||
} = await server.ssrLoadModule('/src/game/data/cityStays.ts');
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const { itemCatalog } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
|
||||
const {
|
||||
cityStayExplorationProfiles,
|
||||
getCityStayExplorationProfile
|
||||
} = await server.ssrLoadModule('/src/game/data/cityStayExploration.ts');
|
||||
const { unitBaseSheetAssetInfo } = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
|
||||
const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
|
||||
validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog);
|
||||
validateLookupHelpers(
|
||||
@@ -26,13 +32,21 @@ try {
|
||||
findCityStayAfterBattle,
|
||||
findCityStayBeforeBattle
|
||||
);
|
||||
validateExplorationProfiles(
|
||||
cityStayDefinitions,
|
||||
cityStayExplorationProfiles,
|
||||
getCityStayExplorationProfile,
|
||||
unitBaseSheetAssetInfo,
|
||||
musicTracks,
|
||||
ambienceTracks
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`City stay data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Verified ${cityStayDefinitions.length} city stays, ${cityStayDefinitions.length * 3} common equipment offers, battle links, information visits, resonance dialogues, and typed lookup helpers.`
|
||||
`Verified ${cityStayDefinitions.length} city stays, ${cityStayDefinitions.length * 3} common equipment offers, battle links, direct-exploration profiles, NPC sprites, soundscapes, information visits, resonance dialogues, and typed lookup helpers.`
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
@@ -228,6 +242,210 @@ function validateLookupHelpers(
|
||||
});
|
||||
}
|
||||
|
||||
function validateExplorationProfiles(
|
||||
definitions,
|
||||
profiles,
|
||||
getProfile,
|
||||
unitBaseSheetAssetInfo,
|
||||
musicTracks,
|
||||
ambienceTracks
|
||||
) {
|
||||
if (!profiles || typeof profiles !== 'object') {
|
||||
errors.push('cityStayExplorationProfiles must be an object');
|
||||
return;
|
||||
}
|
||||
|
||||
const actualIds = Object.keys(profiles);
|
||||
if (JSON.stringify(actualIds) !== JSON.stringify(expectedCityStayIds)) {
|
||||
errors.push(
|
||||
`exploration profile ids/order must be ${expectedCityStayIds.join(', ')} (found ${actualIds.join(', ')})`
|
||||
);
|
||||
}
|
||||
|
||||
const knownDirections = new Set(['north', 'east', 'south', 'west']);
|
||||
const knownTerrains = new Set([
|
||||
'earth',
|
||||
'stone',
|
||||
'wet',
|
||||
'wood',
|
||||
'plain',
|
||||
'road',
|
||||
'forest',
|
||||
'hill',
|
||||
'village',
|
||||
'fort',
|
||||
'camp',
|
||||
'river',
|
||||
'cliff',
|
||||
'bridge',
|
||||
'water',
|
||||
'marsh',
|
||||
'swamp'
|
||||
]);
|
||||
const seenActorIds = new Set();
|
||||
const seenBuildingIds = new Set();
|
||||
|
||||
definitions.forEach((definition) => {
|
||||
const profile = profiles[definition.id];
|
||||
const context = `cityStayExplorationProfiles.${definition.id}`;
|
||||
if (!profile || typeof profile !== 'object') {
|
||||
errors.push(`${context}: profile is missing`);
|
||||
return;
|
||||
}
|
||||
if (profile.id !== definition.id) {
|
||||
errors.push(`${context}: id "${profile.id}" does not match city stay "${definition.id}"`);
|
||||
}
|
||||
if (getProfile(definition.id) !== profile) {
|
||||
errors.push(`${context}: getCityStayExplorationProfile did not return the canonical profile`);
|
||||
}
|
||||
|
||||
['heading', 'subtitle', 'landmarkLabel', 'musicKey', 'ambienceKey'].forEach((field) => {
|
||||
assertNonEmptyString(profile[field], `${context}.${field}`);
|
||||
});
|
||||
['groundColor', 'grassColor', 'roadColor', 'roadHighlightColor', 'accentColor'].forEach((field) => {
|
||||
if (!Number.isInteger(profile[field]) || profile[field] < 0 || profile[field] > 0xffffff) {
|
||||
errors.push(`${context}.${field} must be a 24-bit color`);
|
||||
}
|
||||
});
|
||||
if (!musicTracks[profile.musicKey]) {
|
||||
errors.push(`${context}: unknown musicKey "${profile.musicKey}"`);
|
||||
}
|
||||
if (!ambienceTracks[profile.ambienceKey]) {
|
||||
errors.push(`${context}: unknown ambienceKey "${profile.ambienceKey}"`);
|
||||
}
|
||||
if (!knownTerrains.has(profile.movementTerrain)) {
|
||||
errors.push(`${context}: unsupported movementTerrain "${profile.movementTerrain}"`);
|
||||
}
|
||||
|
||||
validateExplorationPoint(profile.playerStart, `${context}.playerStart`, {
|
||||
minX: 67,
|
||||
maxX: 1427,
|
||||
minY: 133,
|
||||
maxY: 897
|
||||
});
|
||||
if (!knownDirections.has(profile.playerStart?.direction)) {
|
||||
errors.push(`${context}.playerStart.direction must be north/east/south/west`);
|
||||
}
|
||||
|
||||
if (profile.exit?.id !== 'camp-exit') {
|
||||
errors.push(`${context}.exit.id must be "camp-exit"`);
|
||||
}
|
||||
assertNonEmptyString(profile.exit?.name, `${context}.exit.name`);
|
||||
validateExplorationPoint(profile.exit, `${context}.exit`, {
|
||||
minX: 42,
|
||||
maxX: 1452,
|
||||
minY: 108,
|
||||
maxY: 922
|
||||
});
|
||||
const startToExit = distanceBetween(profile.playerStart, profile.exit);
|
||||
if (startToExit <= 164) {
|
||||
errors.push(
|
||||
`${context}: player must start outside the exit prompt radius (distance ${Math.round(startToExit)})`
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(profile.actors) || profile.actors.length !== 3) {
|
||||
errors.push(`${context}.actors must contain exactly 3 entries`);
|
||||
} else {
|
||||
const kinds = profile.actors.map((actor) => actor.kind).sort();
|
||||
if (JSON.stringify(kinds) !== JSON.stringify(['dialogue', 'information', 'market'])) {
|
||||
errors.push(`${context}.actors must contain one information, market, and dialogue actor`);
|
||||
}
|
||||
profile.actors.forEach((actor, actorIndex) => {
|
||||
const actorContext = `${context}.actors[${actorIndex}]`;
|
||||
assertUniqueNonEmptyString(actor?.id, seenActorIds, `${actorContext}.id`);
|
||||
assertNonEmptyString(actor?.name, `${actorContext}.name`);
|
||||
assertNonEmptyString(actor?.role, `${actorContext}.role`);
|
||||
assertNonEmptyString(actor?.textureKey, `${actorContext}.textureKey`);
|
||||
validateExplorationPoint(actor, actorContext, {
|
||||
minX: 42,
|
||||
maxX: 1452,
|
||||
minY: 108,
|
||||
maxY: 922
|
||||
});
|
||||
if (!knownDirections.has(actor?.direction)) {
|
||||
errors.push(`${actorContext}.direction must be north/east/south/west`);
|
||||
}
|
||||
if (!unitBaseSheetAssetInfo(actor?.textureKey)) {
|
||||
errors.push(`${actorContext}: unknown unit texture "${actor?.textureKey}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!unitBaseSheetAssetInfo('unit-liu-bei')) {
|
||||
errors.push(`${context}: player texture "unit-liu-bei" is missing`);
|
||||
}
|
||||
if (!Array.isArray(profile.buildings) || profile.buildings.length !== 3) {
|
||||
errors.push(`${context}.buildings must contain exactly 3 entries`);
|
||||
} else {
|
||||
profile.buildings.forEach((building, buildingIndex) => {
|
||||
const buildingContext = `${context}.buildings[${buildingIndex}]`;
|
||||
assertUniqueNonEmptyString(building?.id, seenBuildingIds, `${buildingContext}.id`);
|
||||
assertNonEmptyString(building?.label, `${buildingContext}.label`);
|
||||
['x', 'y', 'width', 'height'].forEach((field) => {
|
||||
if (!Number.isFinite(building?.[field])) {
|
||||
errors.push(`${buildingContext}.${field} must be a finite number`);
|
||||
}
|
||||
});
|
||||
if (
|
||||
building?.x < 0 ||
|
||||
building?.y < 92 ||
|
||||
building?.width <= 0 ||
|
||||
building?.height <= 0 ||
|
||||
building?.x + building?.width > 1495 ||
|
||||
building?.y + building?.height > 958
|
||||
) {
|
||||
errors.push(`${buildingContext} must fit inside the city map`);
|
||||
}
|
||||
['wallColor', 'roofColor'].forEach((field) => {
|
||||
if (!Number.isInteger(building?.[field]) || building[field] < 0 || building[field] > 0xffffff) {
|
||||
errors.push(`${buildingContext}.${field} must be a 24-bit color`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const points = [
|
||||
profile.playerStart,
|
||||
profile.exit,
|
||||
...(Array.isArray(profile.actors) ? profile.actors : [])
|
||||
];
|
||||
for (let left = 0; left < points.length; left += 1) {
|
||||
for (let right = left + 1; right < points.length; right += 1) {
|
||||
if (distanceBetween(points[left], points[right]) < 46) {
|
||||
errors.push(`${context}: exploration points ${left} and ${right} overlap`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateExplorationPoint(point, context, bounds) {
|
||||
if (!point || typeof point !== 'object') {
|
||||
errors.push(`${context} is missing`);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
||||
errors.push(`${context} must use finite x/y coordinates`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
point.x < bounds.minX ||
|
||||
point.x > bounds.maxX ||
|
||||
point.y < bounds.minY ||
|
||||
point.y > bounds.maxY
|
||||
) {
|
||||
errors.push(`${context} (${point.x}, ${point.y}) lies outside the playable map`);
|
||||
}
|
||||
}
|
||||
|
||||
function distanceBetween(left, right) {
|
||||
if (!left || !right || !Number.isFinite(left.x) || !Number.isFinite(left.y) || !Number.isFinite(right.x) || !Number.isFinite(right.y)) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return Math.hypot(left.x - right.x, left.y - right.y);
|
||||
}
|
||||
|
||||
function assertUniqueNonEmptyString(value, seen, context) {
|
||||
assertNonEmptyString(value, context);
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user