Files
Genarrative/server-node/src/modules/story/storyActionRoutes.test.ts
2026-04-19 20:33:18 +08:00

1433 lines
40 KiB
TypeScript

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<T>(
testName: string,
run: (options: { baseUrl: string }) => Promise<T>,
) {
const context = await createAppContext(createTestConfig(testName));
const app = createApp(context);
const server = await new Promise<import('node:http').Server>((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<void>((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<string, unknown> = {}) {
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<string, number>;
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);
});
});