1433 lines
40 KiB
TypeScript
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);
|
|
});
|
|
});
|