import assert from 'node:assert/strict'; import fs from 'node:fs'; import type { AddressInfo } from 'node:net'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { createApp } from '../../app.ts'; import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js'; import type { AppConfig } from '../../config.ts'; import { createAppContext } from '../../server.ts'; import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.ts'; import { httpRequest, type TestRequestInit } from '../../testHttp.ts'; import { applyQuestSignal } from '../quest/questProgressionService.ts'; function createTestConfig(testName: string): AppConfig { const tempRoot = fs.mkdtempSync( path.join(os.tmpdir(), `genarrative-story-actions-${testName}-`), ); return { nodeEnv: 'test', projectRoot: tempRoot, publicDir: path.join(tempRoot, 'public'), logsDir: path.join(tempRoot, 'logs'), dataDir: path.join(tempRoot, 'data'), rawEnv: {}, databaseUrl: `pg-mem://genarrative-story-actions-${testName}`, serverAddr: ':0', logLevel: 'silent', editorApiEnabled: true, assetsApiEnabled: true, jwtSecret: 'test-secret', jwtExpiresIn: '7d', jwtIssuer: 'genarrative-story-actions-test', llm: { baseUrl: 'https://example.invalid', apiKey: '', model: 'test-model', }, dashScope: { baseUrl: 'https://example.invalid', apiKey: '', imageModel: 'test-image-model', requestTimeoutMs: 1000, }, smsAuth: { enabled: true, provider: 'mock', endpoint: 'dypnsapi.aliyuncs.com', accessKeyId: '', accessKeySecret: '', signName: 'Test Sign', templateCode: '100001', templateParamKey: 'code', countryCode: '86', schemeName: '', codeLength: 6, codeType: 1, validTimeSeconds: 300, intervalSeconds: 60, duplicatePolicy: 1, caseAuthPolicy: 1, returnVerifyCode: false, mockVerifyCode: '123456', maxSendPerPhonePerDay: 20, maxSendPerIpPerHour: 30, maxVerifyFailuresPerPhonePerHour: 12, maxVerifyFailuresPerIpPerHour: 24, captchaTtlSeconds: 180, captchaTriggerVerifyFailuresPerPhone: 3, captchaTriggerVerifyFailuresPerIp: 5, blockPhoneFailureThreshold: 6, blockIpFailureThreshold: 10, blockPhoneDurationMinutes: 30, blockIpDurationMinutes: 30, }, wechatAuth: { enabled: true, provider: 'mock', appId: '', appSecret: '', authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', callbackPath: '/api/auth/wechat/callback', defaultRedirectPath: '/', mockUserId: 'mock_wechat_user', mockUnionId: 'mock_wechat_union', mockDisplayName: '微信旅人', mockAvatarUrl: '', }, authSession: { refreshCookieName: 'genarrative_refresh_session', refreshSessionTtlDays: 30, refreshCookieSecure: false, refreshCookieSameSite: 'Lax', refreshCookiePath: '/api/auth', }, }; } async function withTestServer( testName: string, run: (options: { baseUrl: string }) => Promise, ) { const context = await createAppContext(createTestConfig(testName)); const app = createApp(context); const server = await new Promise((resolve) => { const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); }); try { const address = server.address() as AddressInfo; return await run({ baseUrl: `http://127.0.0.1:${address.port}`, }); } finally { await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); await context.db.close(); } } async function authEntry(baseUrl: string, username: string, password: string) { const response = await httpRequest(`${baseUrl}/api/auth/entry`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username, password, }), }); const payload = (await response.json()) as { token: string; }; assert.equal(response.status, 200); assert.ok(payload.token); return payload; } function withBearer(token: string, init: TestRequestInit = {}) { return { ...init, headers: { ...(init.headers ?? {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, } satisfies TestRequestInit; } async function putSnapshot(baseUrl: string, token: string, gameState: unknown) { const response = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, withBearer(token, { method: 'PUT', body: JSON.stringify({ gameState, bottomTab: 'adventure', currentStory: { text: '初始化剧情', options: [], }, }), }), ); assert.equal(response.status, 200); } function requirePlayerCharacter() { return createTestPlayerCharacter(); } function createTask6GameState(overrides: Record = {}) { return { worldType: 'WUXIA', playerCharacter: requirePlayerCharacter(), runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, currentScene: 'test-scene', storyHistory: [], characterChats: {}, animationState: 'idle', currentEncounter: null, npcInteractionActive: false, currentScenePreset: null, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 32, playerMaxHp: 40, playerMana: 9, playerMaxMana: 16, playerSkillCooldowns: { slash: 2, }, activeBuildBuffs: [], activeCombatEffects: [], playerCurrency: 90, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: {}, quests: [], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, ...overrides, }; } const QUEST_BATTLE_SCENE = { id: 'quest-bridge', name: '断桥口', description: '桥口被匪首和刀痕压得极紧。', npcs: [ { id: 'npc_bandit_01', name: '断桥匪首', description: '手提短刀的拦路匪徒', avatar: '匪', role: '敌对角色', monsterPresetId: 'npc_bandit_01', initialAffinity: -40, hostile: true, }, ], treasureHints: [], }; const QUEST_TREASURE_SCENE = { id: 'quest-ruins', name: '残碑古道', description: '路旁散着断碑和旧匣。', npcs: [], treasureHints: ['残匣', '旧印'], }; test('runtime story actions resolve npc chat on the server and persist updated affinity', async () => { await withTestServer('npc-chat', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_npc_chat', 'secret123'); await putSnapshot(baseUrl, entry.token, { worldType: 'WUXIA', storyHistory: [], currentEncounter: { kind: 'npc', id: 'npc_merchant_01', npcName: '沈七', npcDescription: '腰间挂着药囊的行商', context: '受伤行商', }, npcInteractionActive: true, sceneHostileNpcs: [], inBattle: false, playerHp: 31, playerMaxHp: 40, playerMana: 9, playerMaxMana: 16, npcStates: { npc_merchant_01: { affinity: 46, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, companions: [], currentNpcBattleMode: null, currentNpcBattleOutcome: null, }); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'npc_chat', }, }), }), ); const payload = (await response.json()) as { serverVersion: number; viewModel: { encounter: { affinity: number; } | null; availableOptions: Array<{ functionId: string; }>; }; presentation: { storyText: string; }; patches: Array<{ type: string; }>; }; assert.equal(response.status, 200); assert.equal(payload.serverVersion, 1); assert.equal(payload.viewModel.encounter?.affinity, 52); assert.match(payload.presentation.storyText, /沈七/u); assert.ok( payload.viewModel.availableOptions.some( (option) => option.functionId === 'npc_help', ), ); assert.ok( payload.patches.some((patch) => patch.type === 'npc_affinity_changed'), ); const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { headers: { Authorization: `Bearer ${entry.token}`, }, }); const snapshotPayload = (await snapshotResponse.json()) as { gameState: { runtimeActionVersion: number; npcStates: { npc_merchant_01: { affinity: number; }; }; }; currentStory: { text: string; }; }; assert.equal(snapshotResponse.status, 200); assert.equal(snapshotPayload.gameState.runtimeActionVersion, 1); assert.equal(snapshotPayload.gameState.npcStates.npc_merchant_01.affinity, 52); assert.match(snapshotPayload.currentStory.text, /沈七/u); }); }); test('runtime story actions resolve combat finishers on the server and collapse the battle state', async () => { await withTestServer('combat-finisher', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123'); await putSnapshot( baseUrl, entry.token, createTask6GameState({ currentEncounter: { kind: 'npc', id: 'npc_bandit_01', npcName: '断桥匪首', npcDescription: '手提短刀的拦路匪徒', context: '桥口劫匪', hostile: true, }, npcInteractionActive: false, sceneHostileNpcs: [ { id: 'npc_bandit_01', name: '断桥匪首', hp: 12, maxHp: 28, description: '桥口劫匪', }, ], inBattle: true, playerHp: 42, playerMaxHp: 50, playerMana: 20, playerMaxMana: 20, playerSkillCooldowns: {}, npcStates: { npc_bandit_01: { affinity: -12, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, currentNpcBattleMode: 'fight', currentNpcBattleOutcome: null, }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'battle_finisher_window', }, }), }), ); const payload = (await response.json()) as { viewModel: { encounter: null; status: { inBattle: boolean; currentNpcBattleOutcome: string | null; }; availableOptions: Array<{ functionId: string; }>; }; presentation: { battle: { outcome: string; damageDealt: number; } | null; }; }; assert.equal(response.status, 200); assert.equal(payload.viewModel.encounter, null); assert.equal(payload.viewModel.status.inBattle, false); assert.equal(payload.viewModel.status.currentNpcBattleOutcome, 'fight_victory'); assert.equal(payload.presentation.battle?.outcome, 'victory'); assert.ok((payload.presentation.battle?.damageDealt ?? 0) >= 12); assert.ok( payload.viewModel.availableOptions.some( (option) => option.functionId === 'idle_observe_signs', ), ); const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { headers: { Authorization: `Bearer ${entry.token}`, }, }); const snapshotPayload = (await snapshotResponse.json()) as { gameState: { inBattle: boolean; currentEncounter: unknown; sceneHostileNpcs: unknown[]; currentNpcBattleOutcome: string | null; }; }; assert.equal(snapshotResponse.status, 200); assert.equal(snapshotPayload.gameState.inBattle, false); assert.equal(snapshotPayload.gameState.currentEncounter, null); assert.deepEqual(snapshotPayload.gameState.sceneHostileNpcs, []); assert.equal(snapshotPayload.gameState.currentNpcBattleOutcome, 'fight_victory'); }); }); test('runtime story state exposes the single-action combat option pool with runtime payload metadata', async () => { await withTestServer('combat-state-options', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_combat_state', 'secret123'); const playerCharacter = { ...requirePlayerCharacter(), skills: [ { id: 'slash', name: '试锋斩', animation: 'attack', damage: 18, manaCost: 4, cooldownTurns: 2, range: 1, style: 'steady', }, { id: 'wind-step', name: '断风步', animation: 'attack', damage: 12, manaCost: 2, cooldownTurns: 0, range: 1, style: 'steady', }, ], }; await putSnapshot( baseUrl, entry.token, createTask6GameState({ playerCharacter, currentEncounter: { kind: 'npc', id: 'npc_bandit_01', npcName: '断桥匪首', npcDescription: '手提短刀的拦路匪徒', context: '桥口劫匪', hostile: true, }, npcInteractionActive: false, sceneHostileNpcs: [ { id: 'npc_bandit_01', name: '断桥匪首', hp: 36, maxHp: 36, description: '桥口劫匪', }, ], inBattle: true, playerMana: 6, playerMaxMana: 16, playerSkillCooldowns: { slash: 2, 'wind-step': 0, }, playerInventory: [ { id: 'focus-tonic', category: '消耗品', name: '凝神灵液', quantity: 1, rarity: 'rare', tags: ['mana'], useProfile: { manaRestore: 6, }, }, ], npcStates: { npc_bandit_01: { affinity: -12, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, currentNpcBattleMode: 'fight', }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/state/runtime-main`, { headers: { Authorization: `Bearer ${entry.token}`, }, }, ); const payload = (await response.json()) as { viewModel: { status: { inBattle: boolean; }; availableOptions: Array<{ functionId: string; actionText: string; payload?: { skillId?: string; itemId?: string; }; disabled?: boolean; reason?: string; }>; }; }; assert.equal(response.status, 200); assert.equal(payload.viewModel.status.inBattle, true); assert.deepEqual( payload.viewModel.availableOptions.map((option) => option.functionId), [ 'battle_attack_basic', 'battle_recover_breath', 'inventory_use', 'battle_use_skill', 'battle_use_skill', 'battle_escape_breakout', ], ); const itemOption = payload.viewModel.availableOptions[2]; assert.equal(itemOption?.functionId, 'inventory_use'); assert.equal(itemOption?.payload?.itemId, 'focus-tonic'); assert.equal(itemOption?.disabled, undefined); const slashOption = payload.viewModel.availableOptions[3]; assert.equal(slashOption?.actionText, '试锋斩'); assert.equal(slashOption?.payload?.skillId, 'slash'); assert.equal(slashOption?.disabled, true); assert.match(slashOption?.reason ?? '', /冷却中/u); const windStepOption = payload.viewModel.availableOptions[4]; assert.equal(windStepOption?.actionText, '断风步'); assert.equal(windStepOption?.payload?.skillId, 'wind-step'); assert.equal(windStepOption?.disabled, undefined); }); }); test('runtime story actions resolve battle_use_skill as a single ongoing combat turn', async () => { await withTestServer('combat-use-skill', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_combat_use_skill', 'secret123'); const playerCharacter = { ...requirePlayerCharacter(), skills: [ { id: 'slash', name: '试锋斩', animation: 'attack', damage: 18, manaCost: 4, cooldownTurns: 2, range: 1, style: 'steady', buildBuffs: [ { id: 'slash:buff', sourceType: 'skill', sourceId: 'slash', name: '试锋余势', tags: ['快剑'], durationTurns: 2, }, ], }, ], }; await putSnapshot( baseUrl, entry.token, createTask6GameState({ playerCharacter, currentEncounter: { kind: 'npc', id: 'npc_bandit_01', npcName: '断桥匪首', npcDescription: '手提短刀的拦路匪徒', context: '桥口劫匪', hostile: true, }, npcInteractionActive: false, sceneHostileNpcs: [ { id: 'npc_bandit_01', name: '断桥匪首', hp: 80, maxHp: 80, description: '桥口劫匪', }, ], inBattle: true, playerHp: 32, playerMaxHp: 40, playerMana: 9, playerMaxMana: 16, playerSkillCooldowns: {}, activeBuildBuffs: [], npcStates: { npc_bandit_01: { affinity: -12, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, currentNpcBattleMode: 'fight', }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'battle_use_skill', payload: { skillId: 'slash', }, }, }), }), ); const payload = (await response.json()) as { serverVersion: number; viewModel: { player: { mana: number; }; status: { inBattle: boolean; }; availableOptions: Array<{ functionId: string; actionText: string; payload?: { skillId?: string; }; disabled?: boolean; reason?: string; }>; }; presentation: { resultText: string; storyText: string; battle: { outcome: string; damageDealt: number; } | null; }; snapshot: { gameState: { playerMana: number; playerSkillCooldowns: Record; activeBuildBuffs: Array<{ id: string; }>; }; }; patches: Array<{ type: string; functionId?: string; }>; }; assert.equal(response.status, 200); assert.equal(payload.serverVersion, 1); assert.equal(payload.presentation.battle?.outcome, 'ongoing'); assert.ok((payload.presentation.battle?.damageDealt ?? 0) > 0); assert.equal(payload.presentation.storyText, payload.presentation.resultText); assert.match(payload.presentation.storyText, /试锋斩/u); assert.equal(payload.viewModel.status.inBattle, true); assert.equal(payload.viewModel.player.mana, 5); assert.equal(payload.snapshot.gameState.playerMana, 5); assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 2); assert.equal(payload.snapshot.gameState.activeBuildBuffs[0]?.id, 'slash:buff'); assert.ok( payload.patches.some( (patch) => patch.type === 'battle_resolved' && patch.functionId === 'battle_use_skill', ), ); const skillOption = payload.viewModel.availableOptions.find( (option) => option.functionId === 'battle_use_skill' && option.payload?.skillId === 'slash', ); assert.ok(skillOption); assert.equal(skillOption.actionText, '试锋斩'); assert.equal(skillOption.disabled, true); assert.match(skillOption.reason ?? '', /冷却中/u); }); }); test('runtime story actions resolve inventory_use and persist updated resources', async () => { await withTestServer('task6-inventory-use', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123'); await putSnapshot( baseUrl, entry.token, createTask6GameState({ playerInventory: [ { id: 'focus-tonic', category: '消耗品', name: '凝神灵液', quantity: 1, rarity: 'rare', tags: ['healing', 'mana'], useProfile: { hpRestore: 12, manaRestore: 6, cooldownReduction: 1, buildBuffs: [ { id: 'focus-tonic:buff', sourceType: 'item', sourceId: 'focus-tonic', name: '凝神增益', tags: ['快剑'], durationTurns: 2, }, ], }, }, ], }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'inventory_use', payload: { itemId: 'focus-tonic', }, }, }), }), ); const payload = (await response.json()) as { serverVersion: number; viewModel: { player: { hp: number; mana: number; }; }; presentation: { storyText: string; toast: string | null; }; snapshot: { gameState: { runtimeStats: { itemsUsed: number; }; playerInventory: unknown[]; }; }; }; assert.equal(response.status, 200); assert.equal(payload.serverVersion, 1); assert.equal(payload.viewModel.player.hp, 44); assert.equal(payload.viewModel.player.mana, 15); assert.match(payload.presentation.storyText, /凝神灵液/u); assert.match(payload.presentation.toast ?? '', /Build/u); assert.equal(payload.snapshot.gameState.runtimeStats.itemsUsed, 1); assert.deepEqual(payload.snapshot.gameState.playerInventory, []); }); }); test('runtime story actions resolve equipment_equip and persist updated loadout', async () => { await withTestServer('task6-equipment-equip', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_task6_equip', 'secret123'); await putSnapshot( baseUrl, entry.token, createTask6GameState({ playerInventory: [ { id: 'ward-mail', category: '护甲', name: '镇岳甲', quantity: 1, rarity: 'rare', tags: ['armor', '守御', '护体'], equipmentSlotId: 'armor', statProfile: { maxHpBonus: 24, outgoingDamageBonus: 0.04, incomingDamageMultiplier: 0.92, }, buildProfile: { role: '守御', tags: ['守御', '护体'], synergy: ['守御', '护体'], forgeRank: 0, }, }, ], }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'equipment_equip', payload: { itemId: 'ward-mail', }, }, }), }), ); const payload = (await response.json()) as { viewModel: { player: { maxHp: number; }; }; presentation: { storyText: string; }; snapshot: { gameState: { playerInventory: unknown[]; playerEquipment: { armor: { id: string; name: string; } | null; }; }; }; }; assert.equal(response.status, 200); assert.ok(payload.viewModel.player.maxHp > 40); assert.match(payload.presentation.storyText, /镇岳甲/u); assert.equal(payload.snapshot.gameState.playerInventory.length, 0); assert.equal(payload.snapshot.gameState.playerEquipment.armor?.id, 'ward-mail'); assert.equal(payload.snapshot.gameState.playerEquipment.armor?.name, '镇岳甲'); }); }); test('runtime story actions resolve npc_trade buy transactions on the server', async () => { await withTestServer('task6-trade-buy', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_task6_trade_buy', 'secret123'); await putSnapshot( baseUrl, entry.token, createTask6GameState({ currentEncounter: { kind: 'npc', id: 'npc_merchant_02', npcName: '梁伯', npcDescription: '携带杂货箱的老人', context: '沿街商贩', characterId: 'merchant-test', }, npcInteractionActive: true, playerCurrency: 90, npcStates: { npc_merchant_02: { affinity: 58, chattedCount: 1, helpUsed: false, giftsGiven: 0, inventory: [ { id: 'merchant-essence', category: '消耗品', name: '回气散', quantity: 3, rarity: 'uncommon', tags: ['mana'], }, ], recruited: false, }, }, }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'npc_trade', payload: { mode: 'buy', itemId: 'merchant-essence', quantity: 2, }, }, }), }), ); const payload = (await response.json()) as { presentation: { storyText: string; }; snapshot: { gameState: { playerCurrency: number; playerInventory: Array<{ name: string; quantity: number }>; npcStates: { npc_merchant_02: { inventory: Array<{ id: string; quantity: number }>; }; }; }; }; }; assert.equal(response.status, 200); assert.match(payload.presentation.storyText, /回气散/u); assert.ok(payload.snapshot.gameState.playerCurrency < 90); assert.equal(payload.snapshot.gameState.playerInventory[0]?.name, '回气散'); assert.equal(payload.snapshot.gameState.playerInventory[0]?.quantity, 2); assert.equal( payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0]?.quantity, 1, ); }); }); test('runtime story actions resolve npc_gift and persist affinity changes', async () => { await withTestServer('task6-gift', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_task6_gift', 'secret123'); await putSnapshot( baseUrl, entry.token, createTask6GameState({ currentEncounter: { kind: 'npc', id: 'npc_merchant_03', npcName: '沈娘', npcDescription: '对药性很敏感的行脚商', context: '药商', characterId: 'merchant-gift', }, npcInteractionActive: true, playerInventory: [ { id: 'gift-herb', category: '材料', name: '暖息草', quantity: 1, rarity: 'rare', tags: ['material', 'mana'], }, ], npcStates: { npc_merchant_03: { affinity: 22, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'npc_gift', payload: { itemId: 'gift-herb', }, }, }), }), ); const payload = (await response.json()) as { snapshot: { gameState: { playerInventory: unknown[]; npcStates: { npc_merchant_03: { affinity: number; giftsGiven: number; }; }; }; }; patches: Array<{ type: string }>; }; assert.equal(response.status, 200); assert.equal(payload.snapshot.gameState.playerInventory.length, 0); assert.ok(payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22); assert.equal(payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven, 1); assert.ok(payload.patches.some((patch) => patch.type === 'npc_affinity_changed')); }); }); test('runtime story actions resolve npc_quest_accept and persist accepted quests', async () => { await withTestServer('task6-quest-accept', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_task6_quest_accept', 'secret123'); await putSnapshot( baseUrl, entry.token, createTask6GameState({ currentEncounter: { kind: 'npc', id: 'npc_scout_01', npcName: '巡路人', npcDescription: '熟悉桥口风向的探子', context: '巡路人', characterId: 'scout-quest', }, currentScenePreset: QUEST_BATTLE_SCENE, npcInteractionActive: true, npcStates: { npc_scout_01: { affinity: 16, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'npc_quest_accept', }, }), }), ); const payload = (await response.json()) as { snapshot: { gameState: { quests: Array<{ issuerNpcId: string; status: string }>; runtimeStats: { questsAccepted: number; }; }; }; presentation: { storyText: string; }; }; assert.equal(response.status, 200); assert.equal(payload.snapshot.gameState.quests.length, 1); assert.equal(payload.snapshot.gameState.quests[0]?.issuerNpcId, 'npc_scout_01'); assert.equal(payload.snapshot.gameState.quests[0]?.status, 'active'); assert.equal(payload.snapshot.gameState.runtimeStats.questsAccepted, 1); assert.match(payload.presentation.storyText, /正式把委托交到了你手上/u); }); }); test('runtime story actions progress quests from combat victories and npc turn-ins', async () => { await withTestServer('task6-quest-progress-turnin', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_qp_turnin', 'secret123'); const quest = buildQuestForEncounter({ issuerNpcId: 'npc_bandit_01', issuerNpcName: '断桥匪首', roleText: '桥口劫匪', scene: QUEST_BATTLE_SCENE, worldType: 'WUXIA', currentQuests: [], }); assert.ok(quest); await putSnapshot(baseUrl, entry.token, { ...createTask6GameState({ currentEncounter: { kind: 'npc', id: 'npc_bandit_01', npcName: '断桥匪首', npcDescription: '手提短刀的拦路匪徒', context: '桥口劫匪', characterId: 'bandit-quest', hostile: true, }, currentScenePreset: QUEST_BATTLE_SCENE, sceneHostileNpcs: [ { id: 'npc_bandit_01', name: '断桥匪首', hp: 8, maxHp: 28, description: '桥口劫匪', }, ], inBattle: true, playerMana: 20, playerMaxMana: 20, currentNpcBattleMode: 'fight', npcInteractionActive: false, quests: [quest], npcStates: { npc_bandit_01: { affinity: -12, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, }), }); const battleResponse = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'battle_finisher_window', }, }), }), ); const battlePayload = (await battleResponse.json()) as { snapshot: { gameState: { quests: Array<{ status: string; objective: { kind: string }; }>; }; }; }; assert.equal(battleResponse.status, 200); assert.equal(battlePayload.snapshot.gameState.quests[0]?.status, 'active'); assert.equal( battlePayload.snapshot.gameState.quests[0]?.objective.kind, 'talk_to_npc', ); const afterHostile = applyQuestSignal([quest], { kind: 'hostile_npc_defeated', sceneId: QUEST_BATTLE_SCENE.id, hostileNpcId: 'npc_bandit_01', }).nextQuests[0]; assert.ok(afterHostile); const readyQuest = applyQuestSignal([afterHostile], { kind: 'npc_talk_completed', npcId: 'npc_bandit_01', }).nextQuests[0]; assert.ok(readyQuest); await putSnapshot( baseUrl, entry.token, createTask6GameState({ currentEncounter: { kind: 'npc', id: 'npc_bandit_01', npcName: '断桥匪首', npcDescription: '手提短刀的拦路匪徒', context: '桥口劫匪', characterId: 'bandit-quest', }, currentScenePreset: QUEST_BATTLE_SCENE, npcInteractionActive: true, playerCurrency: 12, quests: [readyQuest], npcStates: { npc_bandit_01: { affinity: 6, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, }), ); const turnInResponse = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'npc_quest_turn_in', payload: { questId: readyQuest.id, }, }, }), }), ); const turnInPayload = (await turnInResponse.json()) as { snapshot: { gameState: { quests: Array<{ status: string }>; playerCurrency: number; playerInventory: Array<{ name: string }>; npcStates: { npc_bandit_01: { affinity: number; }; }; }; }; }; assert.equal(turnInResponse.status, 200); assert.equal(turnInPayload.snapshot.gameState.quests[0]?.status, 'turned_in'); assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12); assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0); assert.ok(turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6); }); }); test('runtime story actions resolve treasure_inspect and advance treasure quests on the server', async () => { await withTestServer('task6-treasure', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_task6_treasure', 'secret123'); const quest = buildQuestForEncounter({ issuerNpcId: 'npc_researcher_01', issuerNpcName: '碑下学人', roleText: '考据学人', scene: QUEST_TREASURE_SCENE, worldType: 'WUXIA', currentQuests: [], }); assert.ok(quest); await putSnapshot( baseUrl, entry.token, createTask6GameState({ currentEncounter: { kind: 'treasure', id: 'treasure-stone-box', npcName: '残匣', npcDescription: '匣盖和断碑之间卡着旧印。', context: '残碑古道', }, currentScenePreset: QUEST_TREASURE_SCENE, quests: [quest], }), ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, withBearer(entry.token, { method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: 0, action: { type: 'story_choice', functionId: 'treasure_inspect', }, }), }), ); const payload = (await response.json()) as { snapshot: { gameState: { currentEncounter: unknown; playerInventory: Array<{ name: string }>; quests: Array<{ objective: { kind: string } }>; }; }; presentation: { storyText: string; }; }; assert.equal(response.status, 200); assert.equal(payload.snapshot.gameState.currentEncounter, null); assert.ok(payload.snapshot.gameState.playerInventory.length > 0); assert.equal( payload.snapshot.gameState.quests[0]?.objective.kind, 'talk_to_npc', ); assert.match(payload.presentation.storyText, /仔细检查了\s*残匣/u); }); });