Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

@@ -41,6 +41,8 @@ const EXCLUDED_PREFIXES = [
'.codex-logs/',
'.git/',
'dist/',
'dist_check/',
'dist_check_monster_position/',
'media/',
'node_modules/',
'public/Icons/',

View File

@@ -12,8 +12,8 @@ import {
createEmptyEquipmentLoadout,
getEquipmentBonuses,
} from '../src/data/equipmentEffects.ts';
import { createSceneHostileNpcsFromIds } from '../src/data/hostileNpcs.ts';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../src/data/inventoryEffects.ts';
import { createSceneMonstersFromIds } from '../src/data/monsters.ts';
import { buildInitialNpcState, buildInitialPlayerInventory, buildNpcEncounterStoryMoment, checkTradeItem, createNpcBattleMonster } from '../src/data/npcInteractions.ts';
import {
acceptQuest,
@@ -26,7 +26,7 @@ import {
import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts';
import { createSceneCallOutEncounter, createSceneEncounterPreview, ensureSceneEncounterPreview } from '../src/data/sceneEncounterPreviews.ts';
import { buildSceneObserveSignsStoryText } from '../src/data/sceneObservation.ts';
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts';
import { AnimationState, GameState, WorldType } from '../src/types.ts';
@@ -55,7 +55,7 @@ function createBaseState(worldType: WorldType, sceneId?: string): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -92,10 +92,10 @@ function smokeScenePreviews() {
const preview = createSceneEncounterPreview(createBaseState(worldType, scene.id));
assert(preview.currentEncounter?.kind !== 'treasure', `[preview] treasure encounter should be disabled for ${worldType}`);
assert(preview.currentEncounter || preview.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
assert(preview.currentEncounter || preview.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
const ensured = ensureSceneEncounterPreview(createBaseState(worldType, scene.id));
assert(ensured.currentEncounter || ensured.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`);
assert(ensured.currentEncounter || ensured.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`);
}
}
@@ -163,20 +163,21 @@ function smokeTreasureStories() {
function smokeMonsterCreation() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length > 0);
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length > 0);
assert(sceneWithMonster, `[monster] missing monster scene for ${worldType}`);
const monsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0);
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
const monsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
assert(monsters.length > 0, `[monster] ${sceneWithMonster.id} failed to create scene monsters`);
assert(
monsters.length === Math.min(sceneWithMonster.monsterIds.length, 3),
monsters.length === Math.min(hostileNpcPresetIds.length, 3),
`[monster] ${sceneWithMonster.id} should keep the full configured encounter group`,
);
const resolvedState = createBaseState(worldType, sceneWithMonster.id);
resolvedState.sceneMonsters = monsters;
resolvedState.sceneHostileNpcs = monsters;
resolvedState.inBattle = true;
assert(
resolvedState.sceneMonsters.length === monsters.length,
resolvedState.sceneHostileNpcs.length === monsters.length,
`[monster] ${sceneWithMonster.id} multi-enemy battle state lost monsters`,
);
}
@@ -206,7 +207,7 @@ function smokeObserveAndCallOut() {
const baseState = createBaseState(worldType, scene.id);
const callOutResult = createSceneCallOutEncounter(baseState);
assert(callOutResult.currentEncounter?.kind !== 'treasure', `[idle] treasure call_out should be disabled for ${worldType}`);
assert(callOutResult.currentEncounter || callOutResult.sceneMonsters.length > 0 || scene.monsterIds.length === 0, `[idle] call_out failed for ${scene.id}`);
assert(callOutResult.currentEncounter || callOutResult.sceneHostileNpcs.length > 0 || getSceneHostileNpcPresetIds(scene).length === 0, `[idle] call_out failed for ${scene.id}`);
const observeText = buildSceneObserveSignsStoryText(worldType, scene.id);
assert(observeText.length > 12, `[idle] observe_signs text too short for ${scene.id}`);
@@ -281,19 +282,20 @@ function smokeTradeEconomyLoop() {
function smokeEncounterTransitionLoop() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length >= 2);
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length >= 2);
assert(sceneWithMonster, `[transition] missing multi-monster scene for ${worldType}`);
const finalMonsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0);
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
const finalMonsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
const finalState = {
...createBaseState(worldType, sceneWithMonster.id),
inBattle: true,
sceneMonsters: finalMonsters,
sceneHostileNpcs: finalMonsters,
};
const previewState = {
...finalState,
inBattle: false,
sceneMonsters: finalMonsters.map((monster, index) => ({
sceneHostileNpcs: finalMonsters.map((monster, index) => ({
...monster,
xMeters: 12 + (index * 1.8),
})),
@@ -301,15 +303,15 @@ function smokeEncounterTransitionLoop() {
const transitionState = buildEncounterTransitionState(finalState, previewState);
assert(
transitionState.sceneMonsters[1]?.xMeters === previewState.sceneMonsters[1]?.xMeters,
transitionState.sceneHostileNpcs[1]?.xMeters === previewState.sceneHostileNpcs[1]?.xMeters,
`[transition] second monster should keep its preview x during transition for ${worldType}`,
);
const halfwayState = interpolateEncounterTransitionState(transitionState, finalState, 0.5);
assert(
halfwayState.sceneMonsters.every((monster, index) => {
const startX = transitionState.sceneMonsters[index]?.xMeters ?? monster.xMeters;
const endX = finalState.sceneMonsters[index]?.xMeters ?? monster.xMeters;
halfwayState.sceneHostileNpcs.every((monster, index) => {
const startX = transitionState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
const endX = finalState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
return monster.xMeters !== startX && monster.xMeters !== endX;
}),
`[transition] all monsters should interpolate instead of only the first one for ${worldType}`,
@@ -317,7 +319,7 @@ function smokeEncounterTransitionLoop() {
const offscreenState = buildEncounterEntryState(finalState, 18);
assert(
offscreenState.sceneMonsters.every(monster => monster.xMeters >= 18),
offscreenState.sceneHostileNpcs.every(monster => monster.xMeters >= 18),
`[transition] offscreen entry should place the entire encounter group offscreen for ${worldType}`,
);
}
@@ -361,7 +363,7 @@ function smokeRosterLoop() {
function smokeQuestLoop() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithNpcAndMonster = getScenePresetsByWorld(worldType).find(
scene => scene.npcs.length > 0 && scene.monsterIds.length > 0,
scene => scene.npcs.length > 0 && getSceneHostileNpcPresetIds(scene).length > 0,
);
assert(sceneWithNpcAndMonster, `[quest] missing npc+monster scene for ${worldType}`);

View File

@@ -1,6 +1,6 @@
import { getCharacterHomeSceneId, getCharacterNpcSceneIds, PRESET_CHARACTERS } from '../src/data/characterPresets.ts';
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
import { WorldType } from '../src/types.ts';
@@ -32,7 +32,7 @@ function validateScenes(errors: string[]) {
}
});
scene.monsterIds.forEach(monsterId => {
getSceneHostileNpcPresetIds(scene).forEach(monsterId => {
if (!monsterIdSet.has(monsterId)) {
addError(errors, `[scene] ${scene.id} references unknown monster "${monsterId}" in ${worldType}`);
}

View File

@@ -87,12 +87,12 @@ function validateMonsterOverrides(errors: string[]) {
const overrides = readJsonFile<Record<string, unknown>>('src/data/monsterOverrides.json');
if (!expectPlainObject(errors, 'monsterOverrides', overrides)) return;
const monsterIds = new Set(
const hostilePresetIds = new Set(
[...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id),
);
Object.entries(overrides).forEach(([monsterId, override]) => {
if (!monsterIds.has(monsterId)) {
if (!hostilePresetIds.has(monsterId)) {
errors.push(`[override] monsterOverrides contains unknown monster id "${monsterId}"`);
return;
}
@@ -109,10 +109,6 @@ function validateSceneOverrides(errors: string[]) {
const sceneIds = new Set(
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
);
const monsterIds = new Set(
[...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id),
);
Object.entries(overrides).forEach(([sceneId, override]) => {
if (!sceneIds.has(sceneId)) {
errors.push(`[override] sceneOverrides contains unknown scene id "${sceneId}"`);
@@ -134,13 +130,6 @@ function validateSceneOverrides(errors: string[]) {
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid connectedSceneIds`);
}
}
const overrideMonsterIds = override.monsterIds;
if (overrideMonsterIds !== undefined) {
if (!Array.isArray(overrideMonsterIds) || overrideMonsterIds.some(id => typeof id !== 'string' || !monsterIds.has(id))) {
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid monsterIds`);
}
}
});
}