import { buildCompanionState, PRESET_CHARACTERS, resolveEncounterRecruitCharacter } from '../src/data/characterPresets.ts'; import { activateRosterCompanion, benchActiveCompanion, recruitCompanionToParty } from '../src/data/companionRoster.ts'; import { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts'; import { buildEncounterEntryState, buildEncounterTransitionState, interpolateEncounterTransitionState, } from '../src/data/encounterTransition.ts'; import { applyEquipmentLoadoutToState, buildInitialEquipmentLoadout, createEmptyEquipmentLoadout, getEquipmentBonuses, } from '../src/data/equipmentEffects.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, applyQuestProgressFromHostileNpcDefeat, applyQuestProgressFromNpcTalk, buildQuestForEncounter, findQuestById, markQuestTurnedIn, } from '../src/data/questFlow.ts'; 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 { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts'; import { AnimationState, GameState, WorldType } from '../src/types.ts'; function assert(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message); } } function createBaseState(worldType: WorldType, sceneId?: string): GameState { const playerCharacter = PRESET_CHARACTERS[0]; const currentScenePreset = sceneId ? getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null : getScenePresetsByWorld(worldType)[0] ?? null; return { worldType, customWorldProfile: null, playerCharacter, runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }), currentScene: 'Story', storyHistory: [], characterChats: {}, ambientIdleMode: undefined, animationState: AnimationState.IDLE, currentEncounter: null, npcInteractionActive: false, currentScenePreset, sceneMonsters: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 180, playerMaxHp: 180, playerMana: 100, playerMaxMana: 100, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 180, playerInventory: [], playerEquipment: createEmptyEquipmentLoadout(), npcStates: {}, quests: [], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; } function smokeScenePreviews() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const scene = getScenePresetsByWorld(worldType)[0]; assert(scene, `[preview] missing first scene for ${worldType}`); 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`); const ensured = ensureSceneEncounterPreview(createBaseState(worldType, scene.id)); assert(ensured.currentEncounter || ensured.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`); } } function smokeNpcStories() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const sceneWithNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.length > 0); assert(sceneWithNpc, `[npc] missing npc scene for ${worldType}`); const encounter = { id: sceneWithNpc.npcs[0].id, kind: 'npc' as const, characterId: sceneWithNpc.npcs[0].characterId, npcName: sceneWithNpc.npcs[0].name, npcDescription: sceneWithNpc.npcs[0].description, npcAvatar: sceneWithNpc.npcs[0].avatar, context: sceneWithNpc.npcs[0].role, xMeters: 3.2, }; const playerCharacter = PRESET_CHARACTERS[0]; const npcState = buildInitialNpcState(encounter, worldType); const story = buildNpcEncounterStoryMoment({ encounter, npcState, playerCharacter, playerInventory: [], activeQuests: [], scene: sceneWithNpc, worldType, partySize: 0, }); assert(story.options.length >= 3, `[npc] ${sceneWithNpc.id} npc story returned too few options`); const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar'); assert(battleMonster.hp >= 7 && battleMonster.hp <= 12, `[npc] spar hp for ${encounter.npcName} out of expected range`); } } function smokeTreasureStories() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const sceneWithTreasure = getScenePresetsByWorld(worldType).find(scene => scene.treasureHints.length > 0); assert(sceneWithTreasure, `[treasure] missing treasure scene for ${worldType}`); const state = createBaseState(worldType, sceneWithTreasure.id); const encounter = { id: `treasure-${sceneWithTreasure.id}`, kind: 'treasure' as const, npcName: '前方宝藏', npcDescription: `你在前方发现了${sceneWithTreasure.treasureHints[0]}的痕迹。`, npcAvatar: '/Icons/47_treasure.png', context: '宝藏', xMeters: 3.2, }; const story = buildTreasureEncounterStoryMoment({ state, encounter, }); assert(story.options.length === 3, `[treasure] ${sceneWithTreasure.id} treasure story should provide exactly 3 options`); const inspectReward = resolveTreasureReward(state, encounter, 'inspect'); assert(inspectReward.items.length >= 2, `[treasure] ${sceneWithTreasure.id} inspect reward should contain at least 2 items`); assert(buildTreasureResultText(encounter, 'inspect', inspectReward).includes('收'), `[treasure] ${sceneWithTreasure.id} inspect result text should describe loot`); } } function smokeMonsterCreation() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length > 0); assert(sceneWithMonster, `[monster] missing monster scene for ${worldType}`); const monsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0); assert(monsters.length > 0, `[monster] ${sceneWithMonster.id} failed to create scene monsters`); assert( monsters.length === Math.min(sceneWithMonster.monsterIds.length, 3), `[monster] ${sceneWithMonster.id} should keep the full configured encounter group`, ); const resolvedState = createBaseState(worldType, sceneWithMonster.id); resolvedState.sceneMonsters = monsters; resolvedState.inBattle = true; assert( resolvedState.sceneMonsters.length === monsters.length, `[monster] ${sceneWithMonster.id} multi-enemy battle state lost monsters`, ); } } function smokeRecruitmentData() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const sceneWithCharacterNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.some(npc => npc.characterId)); assert(sceneWithCharacterNpc, `[recruit] missing recruitable character npc scene for ${worldType}`); const recruitableNpc = sceneWithCharacterNpc.npcs.find(npc => npc.characterId)!; const recruitCharacter = resolveEncounterRecruitCharacter({ characterId: recruitableNpc.characterId, context: recruitableNpc.role, npcName: recruitableNpc.name, }); assert(recruitCharacter, `[recruit] failed to resolve recruit character for ${recruitableNpc.id}`); const companionState = buildCompanionState(recruitableNpc.id, recruitCharacter, 60); assert(companionState.hp > 0 && companionState.maxHp >= companionState.hp, `[recruit] invalid hp for ${recruitableNpc.id}`); assert(Object.keys(companionState.skillCooldowns).length === recruitCharacter.skills.length, `[recruit] cooldown map mismatch for ${recruitableNpc.id}`); } } function smokeObserveAndCallOut() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const scene = getScenePresetsByWorld(worldType)[0]; assert(scene, `[idle] missing first scene for ${worldType}`); 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}`); const observeText = buildSceneObserveSignsStoryText(worldType, scene.id); assert(observeText.length > 12, `[idle] observe_signs text too short for ${scene.id}`); } } function smokeInventoryUseLoop() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const playerCharacter = PRESET_CHARACTERS[0]; const inventory = buildInitialPlayerInventory(playerCharacter, worldType); const usableItem = inventory.find(item => isInventoryItemUsable(item)); assert(usableItem, `[inventory] missing usable starter item for ${worldType}`); const effect = resolveInventoryItemUseEffect(usableItem, playerCharacter); assert(effect, `[inventory] failed to resolve use effect for ${usableItem.name}`); assert( effect.hpRestore > 0 || effect.manaRestore > 0 || effect.cooldownReduction > 0, `[inventory] ${usableItem.name} should provide at least one useful effect`, ); } } function smokeEquipmentLoop() { const playerCharacter = PRESET_CHARACTERS[0]; const starterLoadout = buildInitialEquipmentLoadout(playerCharacter); const starterBonuses = getEquipmentBonuses(starterLoadout); assert(starterBonuses.maxHpBonus > 0, '[equipment] starter loadout should provide HP bonus'); assert(starterBonuses.outgoingDamageMultiplier > 1, '[equipment] starter loadout should provide damage bonus'); const baseState = createBaseState(WorldType.WUXIA); const equippedState = applyEquipmentLoadoutToState(baseState, starterLoadout); assert(equippedState.playerMaxHp > baseState.playerMaxHp, '[equipment] applying loadout should increase max HP'); assert(equippedState.playerMaxMana > baseState.playerMaxMana, '[equipment] applying loadout should increase max mana'); } function smokeTradeEconomyLoop() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const sceneWithNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.length > 0); assert(sceneWithNpc, `[trade] missing npc scene for ${worldType}`); const encounter = { id: sceneWithNpc.npcs[0].id, kind: 'npc' as const, characterId: sceneWithNpc.npcs[0].characterId, npcName: sceneWithNpc.npcs[0].name, npcDescription: sceneWithNpc.npcs[0].description, npcAvatar: sceneWithNpc.npcs[0].avatar, context: sceneWithNpc.npcs[0].role, xMeters: 3.2, }; const npcState = buildInitialNpcState(encounter, worldType); const npcItem = npcState.inventory[0]; const playerItem = buildInitialPlayerInventory(PRESET_CHARACTERS[0], worldType)[0]; assert(npcItem, `[trade] missing npc item for ${worldType}`); assert(playerItem, `[trade] missing player item for ${worldType}`); const npcItemValue = getInventoryItemValue(npcItem); const playerItemValue = getInventoryItemValue(playerItem); assert(npcItemValue > 0 && playerItemValue > 0, `[trade] item values should be positive for ${worldType}`); const purchasePrice = getNpcPurchasePrice(npcItem, npcState.affinity); assert(purchasePrice > 0, `[trade] purchase price should be positive for ${worldType}`); const purchaseCheck = checkTradeItem(null, npcItem, npcState.affinity, purchasePrice); assert(purchaseCheck.canPurchase, `[trade] direct purchase should succeed when currency matches price for ${worldType}`); const barterCheck = checkTradeItem(playerItem, npcItem, npcState.affinity, 0); assert(typeof barterCheck.canBarter === 'boolean', `[trade] barter check should return a boolean for ${worldType}`); } } function smokeEncounterTransitionLoop() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length >= 2); assert(sceneWithMonster, `[transition] missing multi-monster scene for ${worldType}`); const finalMonsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0); const finalState = { ...createBaseState(worldType, sceneWithMonster.id), inBattle: true, sceneMonsters: finalMonsters, }; const previewState = { ...finalState, inBattle: false, sceneMonsters: finalMonsters.map((monster, index) => ({ ...monster, xMeters: 12 + (index * 1.8), })), }; const transitionState = buildEncounterTransitionState(finalState, previewState); assert( transitionState.sceneMonsters[1]?.xMeters === previewState.sceneMonsters[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; return monster.xMeters !== startX && monster.xMeters !== endX; }), `[transition] all monsters should interpolate instead of only the first one for ${worldType}`, ); const offscreenState = buildEncounterEntryState(finalState, 18); assert( offscreenState.sceneMonsters.every(monster => monster.xMeters >= 18), `[transition] offscreen entry should place the entire encounter group offscreen for ${worldType}`, ); } } function smokeRosterLoop() { const playerCharacter = PRESET_CHARACTERS[0]; const reserveCharacter = PRESET_CHARACTERS[1]; const recruitCharacter = PRESET_CHARACTERS[2]; const activeCompanion = buildCompanionState('active-npc', playerCharacter, 68); const reserveCompanion = buildCompanionState('reserve-npc', reserveCharacter, 62); const recruitedCompanion = buildCompanionState('new-npc', recruitCharacter, 72); const baseState = { ...createBaseState(WorldType.WUXIA), companions: [activeCompanion], roster: [reserveCompanion], }; const benchedState = benchActiveCompanion(baseState, activeCompanion.npcId); assert(benchedState.companions.length === 0, '[roster] active companion should move off active team'); assert(benchedState.roster.some(companion => companion.npcId === activeCompanion.npcId), '[roster] benched companion should enter reserve roster'); const activatedState = activateRosterCompanion(baseState, reserveCompanion.npcId); assert(activatedState.companions.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] reserve companion should be activatable'); assert(!activatedState.roster.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] activated companion should leave reserve roster'); const swappedState = recruitCompanionToParty( { ...baseState, companions: [activeCompanion, reserveCompanion], roster: [], }, recruitedCompanion, reserveCompanion.npcId, ); assert(swappedState.companions.some(companion => companion.npcId === recruitedCompanion.npcId), '[roster] recruited companion should join active party'); assert(swappedState.roster.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] replaced companion should move to reserve roster'); } function smokeQuestLoop() { for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { const sceneWithNpcAndMonster = getScenePresetsByWorld(worldType).find( scene => scene.npcs.length > 0 && scene.monsterIds.length > 0, ); assert(sceneWithNpcAndMonster, `[quest] missing npc+monster scene for ${worldType}`); const issuer = sceneWithNpcAndMonster.npcs[0]; const quest = buildQuestForEncounter({ issuerNpcId: issuer.id, issuerNpcName: issuer.name, roleText: issuer.role, scene: sceneWithNpcAndMonster, worldType, }); assert(quest, `[quest] failed to build quest for ${sceneWithNpcAndMonster.id}`); const accepted = acceptQuest([], quest); assert(findQuestById(accepted, quest.id)?.status === 'active', `[quest] ${quest.id} should be active after accept`); const afterBattle = applyQuestProgressFromHostileNpcDefeat( accepted, sceneWithNpcAndMonster.id, quest.objective.targetHostileNpcId ? [quest.objective.targetHostileNpcId] : [], ); assert(findQuestById(afterBattle, quest.id)?.status === 'active', `[quest] ${quest.id} should stay active until report back`); const afterReport = applyQuestProgressFromNpcTalk(afterBattle, issuer.id); assert(findQuestById(afterReport, quest.id)?.status === 'ready_to_turn_in', `[quest] ${quest.id} should become reward-ready after reporting back`); const turnedIn = markQuestTurnedIn(afterReport, quest.id); assert(findQuestById(turnedIn, quest.id)?.status === 'turned_in', `[quest] ${quest.id} should turn in successfully`); } } function main() { smokeScenePreviews(); smokeNpcStories(); smokeTreasureStories(); smokeMonsterCreation(); smokeRecruitmentData(); smokeObserveAndCallOut(); smokeInventoryUseLoop(); smokeEquipmentLoop(); smokeTradeEconomyLoop(); smokeEncounterTransitionLoop(); smokeRosterLoop(); smokeQuestLoop(); console.log('Content smoke checks passed.'); } main();