96
.idea/editor.xml
generated
96
.idea/editor.xml
generated
@@ -244,101 +244,5 @@
|
|||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IfStdIsConstantEvaluatedCanBeReplaced/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IfStdIsConstantEvaluatedCanBeReplaced/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StdIsConstantEvaluatedWillAlwaysEvaluateToConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StdIsConstantEvaluatedWillAlwaysEvaluateToConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppClangFormat/EnableClangFormatSupport/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_ARGUMENT/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_BINARY_EXPRESSIONS_CHAIN/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_CALLS_CHAIN/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_EXPRESSION/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_EXTENDS_LIST/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_FOR_STMT/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_PARAMETER/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_TYPE_ARGUMENT/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_TYPE_PARAMETER/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTIPLE_DECLARATION/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_TERNARY/@EntryValue" value="ALIGN_ALL" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ANONYMOUS_METHOD_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_CLASS_DEFINITION/@EntryValue" value="1" type="int" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_DECLARATIONS/@EntryValue" value="0" type="int" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_FUNCTION_DECLARATION/@EntryValue" value="1" type="int" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_FUNCTION_DEFINITION/@EntryValue" value="1" type="int" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BREAK_TEMPLATE_DECLARATION/@EntryValue" value="LINE_BREAK" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/CASE_BLOCK_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/CONTINUOUS_LINE_INDENT/@EntryValue" value="Double" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_ACCESS_SPECIFIERS_FROM_CLASS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_CASE_FROM_SWITCH/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_CLASS_MEMBERS_FROM_ACCESS_SPECIFIERS/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_COMMENT/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_SIZE/@EntryValue" value="4" type="int" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_STYLE/@EntryValue" value="Space" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INITIALIZER_BRACES/@EntryValue" value="END_OF_LINE_NO_SPACE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INT_ALIGN_EQ/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INVOCABLE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_BLANK_LINES_IN_CODE/@EntryValue" value="2" type="int" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_BLANK_LINES_IN_DECLARATIONS/@EntryValue" value="2" type="int" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_USER_LINEBREAKS/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/LINE_BREAK_AFTER_COLON_IN_MEMBER_INITIALIZER_LISTS/@EntryValue" value="ON_SINGLE_LINE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/MEMBER_INITIALIZER_LIST_STYLE/@EntryValue" value="DO_NOT_CHANGE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/NAMESPACE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/NAMESPACE_INDENTATION/@EntryValue" value="All" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/OTHER_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_CATCH_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_ELSE_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_NAMESPACE_DEFINITIONS_ON_SAME_LINE/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_WHILE_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SIMPLE_BLOCK_STYLE/@EntryValue" value="DO_NOT_CHANGE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_CAST_EXPRESSION_PARENTHESES/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COLON_IN_BITFIELD_DECLARATOR/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COMMA_IN_TEMPLATE_ARGS/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COMMA_IN_TEMPLATE_PARAMS/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_EXTENDS_COLON/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_FOR_COLON/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_FOR_SEMICOLON/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_DATA_MEMBER/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_DATA_MEMBERS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_METHOD/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_NESTED_DECLARATOR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_DATA_MEMBER/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_DATA_MEMBERS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_METHOD/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_UNARY_OPERATOR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_COLON_IN_BITFIELD_DECLARATOR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_EXTENDS_COLON/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_FOR_COLON/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_FOR_SEMICOLON/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_ABSTRACT_DECL/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_DATA_MEMBER/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_DATA_MEMBERS/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_METHOD/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_ABSTRACT_DECL/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_DATA_MEMBER/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_DATA_MEMBERS/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_METHOD/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_TEMPLATE_ARGS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BETWEEN_CLOSING_ANGLE_BRACKETS_IN_TEMPLATE_ARGS/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_ARRAY_ACCESS_BRACKETS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_CAST_EXPRESSION_PARENTHESES/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_DECLARATION_PARENTHESES/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_BLOCKS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_INITIALIZER_BRACES/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_METHOD_PARENTHESES/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_INITIALIZER_BRACES/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_TEMPLATE_ARGS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPECIAL_ELSE_IF_TREATMENT/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/TAB_WIDTH/@EntryValue" value="4" type="int" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/TYPE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_BINARY_OPSIGN/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_DECLARATION_LPAR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_INVOCATION_LPAR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_ARGUMENTS_STYLE/@EntryValue" value="WRAP_IF_LONG" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_DECLARATION_LPAR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_DECLARATION_RPAR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_INVOCATION_LPAR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_INVOCATION_RPAR/@EntryValue" value="false" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_TERNARY_OPSIGNS/@EntryValue" value="true" type="bool" />
|
|
||||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_PARAMETERS_STYLE/@EntryValue" value="WRAP_IF_LONG" type="string" />
|
|
||||||
<option name="/Default/CodeStyle/EditorConfig/EnableClangFormatSupport/@EntryValue" value="false" type="bool" />
|
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
Binary file not shown.
@@ -156,6 +156,33 @@ export type NpcChatDialogueRequest<
|
|||||||
resultSummary: string;
|
resultSummary: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NpcChatTurnRequest<
|
||||||
|
TCharacter = unknown,
|
||||||
|
TEncounter = unknown,
|
||||||
|
TMonster = unknown,
|
||||||
|
TStoryMoment = unknown,
|
||||||
|
TContext = unknown,
|
||||||
|
TConversationTurn = unknown,
|
||||||
|
TNpcState = unknown,
|
||||||
|
> = {
|
||||||
|
worldType: string;
|
||||||
|
character: TCharacter;
|
||||||
|
encounter: TEncounter;
|
||||||
|
monsters: TMonster[];
|
||||||
|
history: TStoryMoment[];
|
||||||
|
context: TContext;
|
||||||
|
conversationHistory: TConversationTurn[];
|
||||||
|
playerMessage: string;
|
||||||
|
npcState: TNpcState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NpcChatTurnResult = {
|
||||||
|
npcReply: string;
|
||||||
|
affinityDelta: number;
|
||||||
|
affinityText: string;
|
||||||
|
suggestions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type NpcRecruitDialogueRequest<
|
export type NpcRecruitDialogueRequest<
|
||||||
TCharacter = unknown,
|
TCharacter = unknown,
|
||||||
TEncounter = unknown,
|
TEncounter = unknown,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import net from 'node:net';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {spawn} from 'node:child_process';
|
import {spawn} from 'node:child_process';
|
||||||
import {existsSync, readFileSync} from 'node:fs';
|
import {existsSync, readFileSync} from 'node:fs';
|
||||||
import {fileURLToPath} from 'node:url';
|
import {fileURLToPath, pathToFileURL} from 'node:url';
|
||||||
|
|
||||||
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
|
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||||
const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url));
|
const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url));
|
||||||
@@ -13,6 +13,7 @@ const serverTsxCliPath = fileURLToPath(
|
|||||||
const serverTsxLoaderPath = fileURLToPath(
|
const serverTsxLoaderPath = fileURLToPath(
|
||||||
new URL('../server-node/node_modules/tsx/dist/loader.mjs', import.meta.url),
|
new URL('../server-node/node_modules/tsx/dist/loader.mjs', import.meta.url),
|
||||||
);
|
);
|
||||||
|
const serverTsxLoaderUrl = pathToFileURL(serverTsxLoaderPath).href;
|
||||||
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
|
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
|
||||||
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
|
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
|
||||||
const bundledNodePath = fileURLToPath(
|
const bundledNodePath = fileURLToPath(
|
||||||
@@ -304,7 +305,7 @@ function registerChild(name, child, siblingProvider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverProcess = existsSync(serverTsxLoaderPath)
|
const serverProcess = existsSync(serverTsxLoaderPath)
|
||||||
? spawn(runtimeNodePath, ['--watch', '--import', serverTsxLoaderPath, 'src/server.ts'], {
|
? spawn(runtimeNodePath, ['--watch', '--import', serverTsxLoaderUrl, 'src/server.ts'], {
|
||||||
cwd: serverRoot,
|
cwd: serverRoot,
|
||||||
env: mergedEnv,
|
env: mergedEnv,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import type {
|
|||||||
CharacterChatSuggestionsRequest,
|
CharacterChatSuggestionsRequest,
|
||||||
CharacterChatSummaryRequest,
|
CharacterChatSummaryRequest,
|
||||||
NpcChatDialogueRequest,
|
NpcChatDialogueRequest,
|
||||||
|
NpcChatTurnRequest,
|
||||||
NpcRecruitDialogueRequest,
|
NpcRecruitDialogueRequest,
|
||||||
} from '../../../../packages/shared/src/contracts/story.js';
|
} from '../../../../packages/shared/src/contracts/story.js';
|
||||||
|
import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js';
|
||||||
import {
|
import {
|
||||||
buildCharacterPanelChatPrompt,
|
buildCharacterPanelChatPrompt,
|
||||||
buildCharacterPanelChatSuggestionPrompt,
|
buildCharacterPanelChatSuggestionPrompt,
|
||||||
@@ -16,11 +18,52 @@ import {
|
|||||||
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||||
buildNpcRecruitDialoguePrompt,
|
buildNpcRecruitDialoguePrompt,
|
||||||
buildStrictNpcChatDialoguePrompt,
|
buildStrictNpcChatDialoguePrompt,
|
||||||
|
buildNpcChatTurnReplyPrompt,
|
||||||
|
buildNpcChatTurnSuggestionPrompt,
|
||||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||||
|
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT,
|
||||||
|
NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||||
} from './chatPromptBuilders.js';
|
} from './chatPromptBuilders.js';
|
||||||
|
import { prepareEventStreamResponse } from '../../http.js';
|
||||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||||
|
|
||||||
|
function writeSseEvent(
|
||||||
|
response: Response,
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
response.write(`event: ${event}\n`);
|
||||||
|
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecord(value: unknown) {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(value: unknown, fallback = 0) {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeAffinityShift(affinityDelta: number) {
|
||||||
|
if (affinityDelta >= 8) return '态度明显软化了下来。';
|
||||||
|
if (affinityDelta >= 5) return '态度比刚才亲近了一些。';
|
||||||
|
if (affinityDelta > 0) return '对话气氛稍微松动了一点。';
|
||||||
|
if (affinityDelta < 0) return '这轮对话让气氛变得更紧了一些。';
|
||||||
|
return '这轮对话暂时没有带来明显关系变化。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackNpcChatSuggestions(playerMessage: string) {
|
||||||
|
const topic = playerMessage.trim() || '刚才那句话';
|
||||||
|
return [
|
||||||
|
`顺着“${topic}”再追问一句`,
|
||||||
|
'先表明你的判断,再看对方反应',
|
||||||
|
'换个更轻一点的语气继续聊下去',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateCharacterChatSuggestionsFromOrchestrator(
|
export async function generateCharacterChatSuggestionsFromOrchestrator(
|
||||||
llmClient: UpstreamLlmClient,
|
llmClient: UpstreamLlmClient,
|
||||||
payload: CharacterChatSuggestionsRequest,
|
payload: CharacterChatSuggestionsRequest,
|
||||||
@@ -73,6 +116,64 @@ export async function streamNpcChatDialogueFromOrchestrator(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function streamNpcChatTurnFromOrchestrator(
|
||||||
|
llmClient: UpstreamLlmClient,
|
||||||
|
params: {
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
payload: NpcChatTurnRequest;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
prepareEventStreamResponse(params.request, params.response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let streamedReply = '';
|
||||||
|
|
||||||
|
const npcReply = (
|
||||||
|
await llmClient.streamMessageContent({
|
||||||
|
systemPrompt: NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT,
|
||||||
|
userPrompt: buildNpcChatTurnReplyPrompt(params.payload),
|
||||||
|
debugLabel: 'runtime.npc_chat.turn.reply',
|
||||||
|
onUpdate: (text) => {
|
||||||
|
streamedReply = text;
|
||||||
|
writeSseEvent(params.response, 'reply_delta', { text });
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const suggestionText = await llmClient.requestMessageContent({
|
||||||
|
systemPrompt: NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
userPrompt: buildNpcChatTurnSuggestionPrompt(
|
||||||
|
params.payload,
|
||||||
|
npcReply || streamedReply,
|
||||||
|
),
|
||||||
|
debugLabel: 'runtime.npc_chat.turn.suggestions',
|
||||||
|
});
|
||||||
|
|
||||||
|
const suggestions = parseLineListContent(suggestionText, 3);
|
||||||
|
const npcState = readRecord(params.payload.npcState);
|
||||||
|
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||||
|
const affinityDelta = Math.max(2, 6 - chattedCount);
|
||||||
|
|
||||||
|
writeSseEvent(params.response, 'complete', {
|
||||||
|
npcReply: npcReply || streamedReply,
|
||||||
|
affinityDelta,
|
||||||
|
affinityText: describeAffinityShift(affinityDelta),
|
||||||
|
suggestions:
|
||||||
|
suggestions.length === 3
|
||||||
|
? suggestions
|
||||||
|
: buildFallbackNpcChatSuggestions(params.payload.playerMessage),
|
||||||
|
});
|
||||||
|
params.response.write('data: [DONE]\n\n');
|
||||||
|
params.response.end();
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'NPC 单轮聊天流式生成失败';
|
||||||
|
writeSseEvent(params.response, 'error', { message });
|
||||||
|
params.response.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function streamNpcRecruitDialogueFromOrchestrator(
|
export async function streamNpcRecruitDialogueFromOrchestrator(
|
||||||
llmClient: UpstreamLlmClient,
|
llmClient: UpstreamLlmClient,
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CharacterChatSuggestionsRequest,
|
CharacterChatSuggestionsRequest,
|
||||||
CharacterChatSummaryRequest,
|
CharacterChatSummaryRequest,
|
||||||
NpcChatDialogueRequest,
|
NpcChatDialogueRequest,
|
||||||
|
NpcChatTurnRequest,
|
||||||
NpcRecruitDialogueRequest,
|
NpcRecruitDialogueRequest,
|
||||||
} from '../../../../packages/shared/src/contracts/story.js';
|
} from '../../../../packages/shared/src/contracts/story.js';
|
||||||
|
|
||||||
@@ -48,6 +49,16 @@ export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招
|
|||||||
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
|
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
|
||||||
- 最后一行必须由对方明确答应加入队伍。`;
|
- 最后一行必须由对方明确答应加入队伍。`;
|
||||||
|
|
||||||
|
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
|
||||||
|
你只输出这名 NPC 此刻会对玩家说的一轮回复。
|
||||||
|
只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||||
|
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
|
||||||
|
|
||||||
|
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
|
||||||
|
只输出纯文本,共 3 行,每行 1 条。
|
||||||
|
不要加编号、项目符号、Markdown、JSON 或额外说明。
|
||||||
|
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
|
||||||
|
|
||||||
function asRecord(value: unknown): JsonRecord | null {
|
function asRecord(value: unknown): JsonRecord | null {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
? (value as JsonRecord)
|
? (value as JsonRecord)
|
||||||
@@ -143,6 +154,41 @@ function describeConversationHistory(history: unknown) {
|
|||||||
: '聊天记录:暂无。';
|
: '聊天记录:暂无。';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeNpcConversationHistory(history: unknown, npcName: string) {
|
||||||
|
if (!Array.isArray(history) || history.length === 0) {
|
||||||
|
return '当前聊天记录:暂无。';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = history
|
||||||
|
.slice(-10)
|
||||||
|
.map((item) => {
|
||||||
|
const record = asRecord(item);
|
||||||
|
const speaker = readString(record?.speaker);
|
||||||
|
const speakerName = readString(record?.speakerName);
|
||||||
|
const text = readString(record?.text);
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
if (speaker === 'player') {
|
||||||
|
return `- 玩家:${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speaker === 'npc') {
|
||||||
|
return `- ${speakerName ?? npcName}:${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speaker === 'system') {
|
||||||
|
return `- 系统提示:${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `- ${speakerName ?? '同伴'}:${text}`;
|
||||||
|
})
|
||||||
|
.filter((item): item is string => Boolean(item));
|
||||||
|
|
||||||
|
return lines.length > 0
|
||||||
|
? ['当前聊天记录:', ...lines].join('\n')
|
||||||
|
: '当前聊天记录:暂无。';
|
||||||
|
}
|
||||||
|
|
||||||
function describeSceneContext(context: unknown) {
|
function describeSceneContext(context: unknown) {
|
||||||
const record = asRecord(context);
|
const record = asRecord(context);
|
||||||
const sceneName = readString(record?.sceneName) ?? '当前区域';
|
const sceneName = readString(record?.sceneName) ?? '当前区域';
|
||||||
@@ -370,3 +416,40 @@ export function buildNpcRecruitDialoguePrompt(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildNpcChatTurnReplyPrompt(
|
||||||
|
payload: NpcChatTurnRequest,
|
||||||
|
) {
|
||||||
|
const encounter = describeEncounter(payload.encounter);
|
||||||
|
const npcState = asRecord(payload.npcState);
|
||||||
|
const affinity = readNumber(npcState?.affinity, 0);
|
||||||
|
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
buildNpcDialoguePromptBase(payload),
|
||||||
|
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
|
||||||
|
`当前关系值:${affinity}`,
|
||||||
|
`已聊天轮次:${chattedCount}`,
|
||||||
|
`玩家刚刚说:${payload.playerMessage}`,
|
||||||
|
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNpcChatTurnSuggestionPrompt(
|
||||||
|
payload: NpcChatTurnRequest,
|
||||||
|
npcReply: string,
|
||||||
|
) {
|
||||||
|
const encounter = describeEncounter(payload.encounter);
|
||||||
|
|
||||||
|
return [
|
||||||
|
buildNpcDialoguePromptBase(payload),
|
||||||
|
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
|
||||||
|
`玩家刚刚说:${payload.playerMessage}`,
|
||||||
|
`NPC 刚刚回复:${npcReply}`,
|
||||||
|
`请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
CharacterChatSuggestionsRequest,
|
CharacterChatSuggestionsRequest,
|
||||||
CharacterChatSummaryRequest,
|
CharacterChatSummaryRequest,
|
||||||
NpcChatDialogueRequest,
|
NpcChatDialogueRequest,
|
||||||
|
NpcChatTurnRequest,
|
||||||
NpcRecruitDialogueRequest,
|
NpcRecruitDialogueRequest,
|
||||||
} from '../../../packages/shared/src/contracts/story.js';
|
} from '../../../packages/shared/src/contracts/story.js';
|
||||||
import type { AppContext } from '../context.js';
|
import type { AppContext } from '../context.js';
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
generateCharacterChatSummaryFromOrchestrator,
|
generateCharacterChatSummaryFromOrchestrator,
|
||||||
streamCharacterChatReplyFromOrchestrator,
|
streamCharacterChatReplyFromOrchestrator,
|
||||||
streamNpcChatDialogueFromOrchestrator,
|
streamNpcChatDialogueFromOrchestrator,
|
||||||
|
streamNpcChatTurnFromOrchestrator,
|
||||||
streamNpcRecruitDialogueFromOrchestrator,
|
streamNpcRecruitDialogueFromOrchestrator,
|
||||||
} from '../modules/ai/chatOrchestrator.js';
|
} from '../modules/ai/chatOrchestrator.js';
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +58,7 @@ import {
|
|||||||
characterChatSuggestionsRequestSchema,
|
characterChatSuggestionsRequestSchema,
|
||||||
characterChatSummaryRequestSchema,
|
characterChatSummaryRequestSchema,
|
||||||
npcChatDialogueRequestSchema,
|
npcChatDialogueRequestSchema,
|
||||||
|
npcChatTurnRequestSchema,
|
||||||
npcRecruitDialogueRequestSchema,
|
npcRecruitDialogueRequestSchema,
|
||||||
} from '../services/chatService.js';
|
} from '../services/chatService.js';
|
||||||
import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js';
|
import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js';
|
||||||
@@ -671,6 +674,21 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/runtime/chat/npc/turn/stream',
|
||||||
|
routeMeta({ operation: 'runtime.chat.npc.turnStream' }),
|
||||||
|
asyncHandler(async (request, response) => {
|
||||||
|
const payload = npcChatTurnRequestSchema.parse(
|
||||||
|
request.body,
|
||||||
|
) as NpcChatTurnRequest;
|
||||||
|
await streamNpcChatTurnFromOrchestrator(context.llmClient, {
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/runtime/chat/npc/recruit/stream',
|
'/runtime/chat/npc/recruit/stream',
|
||||||
routeMeta({ operation: 'runtime.chat.npc.recruitStream' }),
|
routeMeta({ operation: 'runtime.chat.npc.recruitStream' }),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CharacterChatSuggestionsRequest,
|
CharacterChatSuggestionsRequest,
|
||||||
CharacterChatSummaryRequest,
|
CharacterChatSummaryRequest,
|
||||||
NpcChatDialogueRequest,
|
NpcChatDialogueRequest,
|
||||||
|
NpcChatTurnRequest,
|
||||||
NpcRecruitDialogueRequest,
|
NpcRecruitDialogueRequest,
|
||||||
} from '../../../packages/shared/src/contracts/story.js';
|
} from '../../../packages/shared/src/contracts/story.js';
|
||||||
|
|
||||||
@@ -50,6 +51,12 @@ export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({
|
|||||||
resultSummary: z.string().optional().default(''),
|
resultSummary: z.string().optional().default(''),
|
||||||
}) satisfies z.ZodType<NpcChatDialogueRequest>;
|
}) satisfies z.ZodType<NpcChatDialogueRequest>;
|
||||||
|
|
||||||
|
export const npcChatTurnRequestSchema = baseNpcChatSchema.extend({
|
||||||
|
conversationHistory: z.array(jsonObjectSchema).default([]),
|
||||||
|
playerMessage: z.string().trim().min(1),
|
||||||
|
npcState: jsonObjectSchema,
|
||||||
|
}) satisfies z.ZodType<NpcChatTurnRequest>;
|
||||||
|
|
||||||
export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({
|
export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({
|
||||||
invitationText: z.string().trim().min(1),
|
invitationText: z.string().trim().min(1),
|
||||||
recruitSummary: z.string().optional().default(''),
|
recruitSummary: z.string().optional().default(''),
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ interface AdventurePanelProps {
|
|||||||
canRefreshOptions: boolean;
|
canRefreshOptions: boolean;
|
||||||
onRefreshOptions: () => void;
|
onRefreshOptions: () => void;
|
||||||
onChoice: (option: StoryOption) => void;
|
onChoice: (option: StoryOption) => void;
|
||||||
|
onSubmitNpcChatInput?: (input: string) => boolean;
|
||||||
|
onExitNpcChat?: () => boolean;
|
||||||
onOpenCharacter: () => void;
|
onOpenCharacter: () => void;
|
||||||
onOpenInventory: () => void;
|
onOpenInventory: () => void;
|
||||||
playerCharacter: Character;
|
playerCharacter: Character;
|
||||||
@@ -149,12 +151,22 @@ function getOptionActionTextClass(option: StoryOption) {
|
|||||||
function getDialogueTurnAlignmentClass(
|
function getDialogueTurnAlignmentClass(
|
||||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||||
) {
|
) {
|
||||||
|
if (turn.speaker === 'system') {
|
||||||
|
return 'justify-center';
|
||||||
|
}
|
||||||
|
|
||||||
return turn.speaker === 'player' ? 'justify-end' : 'justify-start';
|
return turn.speaker === 'player' ? 'justify-end' : 'justify-start';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDialogueTurnBubbleClass(
|
function getDialogueTurnBubbleClass(
|
||||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||||
) {
|
) {
|
||||||
|
if (turn.speaker === 'system') {
|
||||||
|
return turn.affinityDelta && turn.affinityDelta > 0
|
||||||
|
? 'border-rose-400/30 bg-rose-500/12 text-rose-50'
|
||||||
|
: 'border-white/12 bg-white/[0.06] text-zinc-100';
|
||||||
|
}
|
||||||
|
|
||||||
if (turn.speaker === 'player') {
|
if (turn.speaker === 'player') {
|
||||||
return 'border-sky-400/20 bg-sky-500/10 text-sky-50';
|
return 'border-sky-400/20 bg-sky-500/10 text-sky-50';
|
||||||
}
|
}
|
||||||
@@ -169,6 +181,10 @@ function getDialogueTurnBubbleClass(
|
|||||||
function getDialogueTurnBubbleShapeClass(
|
function getDialogueTurnBubbleShapeClass(
|
||||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||||
) {
|
) {
|
||||||
|
if (turn.speaker === 'system') {
|
||||||
|
return 'rounded-full';
|
||||||
|
}
|
||||||
|
|
||||||
if (turn.speaker === 'player') {
|
if (turn.speaker === 'player') {
|
||||||
return 'rounded-2xl rounded-br-none';
|
return 'rounded-2xl rounded-br-none';
|
||||||
}
|
}
|
||||||
@@ -183,6 +199,10 @@ function getDialogueTurnBubbleShapeClass(
|
|||||||
function getDialogueTurnLabel(
|
function getDialogueTurnLabel(
|
||||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||||
) {
|
) {
|
||||||
|
if (turn.speaker === 'system') {
|
||||||
|
return turn.affinityDelta && turn.affinityDelta > 0 ? '关系变化' : '系统';
|
||||||
|
}
|
||||||
|
|
||||||
if (turn.speaker === 'player') {
|
if (turn.speaker === 'player') {
|
||||||
return '\u4f60';
|
return '\u4f60';
|
||||||
}
|
}
|
||||||
@@ -597,6 +617,8 @@ export function AdventurePanel({
|
|||||||
canRefreshOptions,
|
canRefreshOptions,
|
||||||
onRefreshOptions,
|
onRefreshOptions,
|
||||||
onChoice,
|
onChoice,
|
||||||
|
onSubmitNpcChatInput,
|
||||||
|
onExitNpcChat,
|
||||||
onOpenCharacter,
|
onOpenCharacter,
|
||||||
onOpenInventory,
|
onOpenInventory,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
@@ -622,6 +644,8 @@ export function AdventurePanel({
|
|||||||
}: AdventurePanelProps) {
|
}: AdventurePanelProps) {
|
||||||
const isDialogueStory = currentStory.displayMode === 'dialogue';
|
const isDialogueStory = currentStory.displayMode === 'dialogue';
|
||||||
const dialogueTurns = currentStory.dialogue ?? [];
|
const dialogueTurns = currentStory.dialogue ?? [];
|
||||||
|
const npcChatState = currentStory.npcChatState ?? null;
|
||||||
|
const isNpcChatMode = Boolean(npcChatState);
|
||||||
const isStoryStreaming = Boolean(currentStory.streaming);
|
const isStoryStreaming = Boolean(currentStory.streaming);
|
||||||
const shouldHideChoiceUi = hideOptions;
|
const shouldHideChoiceUi = hideOptions;
|
||||||
const storyScrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const storyScrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -656,6 +680,7 @@ export function AdventurePanel({
|
|||||||
const [selectedBattleRewardItemId, setSelectedBattleRewardItemId] = useState<
|
const [selectedBattleRewardItemId, setSelectedBattleRewardItemId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [npcChatDraft, setNpcChatDraft] = useState('');
|
||||||
const lastAutoOpenedGoalRef = useRef<string | null>(null);
|
const lastAutoOpenedGoalRef = useRef<string | null>(null);
|
||||||
const lastAutoOpenedPulseRef = useRef<string | null>(null);
|
const lastAutoOpenedPulseRef = useRef<string | null>(null);
|
||||||
const battleReward = battleRewardUi.reward;
|
const battleReward = battleRewardUi.reward;
|
||||||
@@ -734,6 +759,10 @@ export function AdventurePanel({
|
|||||||
setSelectedBattleRewardItemId(null);
|
setSelectedBattleRewardItemId(null);
|
||||||
}, [battleReward]);
|
}, [battleReward]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNpcChatDraft('');
|
||||||
|
}, [npcChatState?.npcId, npcChatState?.turnCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!primaryQuestGoal) {
|
if (!primaryQuestGoal) {
|
||||||
return;
|
return;
|
||||||
@@ -887,6 +916,18 @@ export function AdventurePanel({
|
|||||||
onDismissGoalPulse();
|
onDismissGoalPulse();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitNpcChatDraft = () => {
|
||||||
|
const nextInput = npcChatDraft.trim();
|
||||||
|
if (!nextInput || !onSubmitNpcChatInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitted = onSubmitNpcChatInput(nextInput);
|
||||||
|
if (submitted) {
|
||||||
|
setNpcChatDraft('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ interface GameShellStoryProps {
|
|||||||
canRefreshOptions: boolean;
|
canRefreshOptions: boolean;
|
||||||
handleRefreshOptions: () => void;
|
handleRefreshOptions: () => void;
|
||||||
handleChoice: (option: StoryOption) => void;
|
handleChoice: (option: StoryOption) => void;
|
||||||
|
handleNpcChatInput: (input: string) => boolean;
|
||||||
|
exitNpcChat: () => boolean;
|
||||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||||
npcUi: StoryGenerationNpcUi;
|
npcUi: StoryGenerationNpcUi;
|
||||||
characterChatUi: CharacterChatUi;
|
characterChatUi: CharacterChatUi;
|
||||||
@@ -200,6 +202,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
|||||||
canRefreshOptions,
|
canRefreshOptions,
|
||||||
handleRefreshOptions,
|
handleRefreshOptions,
|
||||||
handleChoice,
|
handleChoice,
|
||||||
|
handleNpcChatInput,
|
||||||
|
exitNpcChat,
|
||||||
handleMapTravelToScene,
|
handleMapTravelToScene,
|
||||||
npcUi,
|
npcUi,
|
||||||
characterChatUi,
|
characterChatUi,
|
||||||
@@ -533,6 +537,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
|||||||
canRefreshOptions={canRefreshOptions}
|
canRefreshOptions={canRefreshOptions}
|
||||||
onRefreshOptions={handleRefreshOptions}
|
onRefreshOptions={handleRefreshOptions}
|
||||||
onChoice={handleSceneTransitionChoice}
|
onChoice={handleSceneTransitionChoice}
|
||||||
|
onSubmitNpcChatInput={handleNpcChatInput}
|
||||||
|
onExitNpcChat={exitNpcChat}
|
||||||
onOpenCharacter={() => openOverlayPanel('character')}
|
onOpenCharacter={() => openOverlayPanel('character')}
|
||||||
onOpenInventory={() => openOverlayPanel('inventory')}
|
onOpenInventory={() => openOverlayPanel('inventory')}
|
||||||
playerCharacter={visibleGameState.playerCharacter}
|
playerCharacter={visibleGameState.playerCharacter}
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export function GameShellMainContent({
|
|||||||
canRefreshOptions,
|
canRefreshOptions,
|
||||||
handleRefreshOptions,
|
handleRefreshOptions,
|
||||||
handleSceneTransitionChoice,
|
handleSceneTransitionChoice,
|
||||||
|
handleNpcChatInput,
|
||||||
|
exitNpcChat,
|
||||||
characterChatUi,
|
characterChatUi,
|
||||||
inventoryUi,
|
inventoryUi,
|
||||||
battleRewardUi,
|
battleRewardUi,
|
||||||
@@ -112,6 +114,8 @@ export function GameShellMainContent({
|
|||||||
canRefreshOptions: boolean;
|
canRefreshOptions: boolean;
|
||||||
handleRefreshOptions: () => void;
|
handleRefreshOptions: () => void;
|
||||||
handleSceneTransitionChoice: (option: StoryOption) => void;
|
handleSceneTransitionChoice: (option: StoryOption) => void;
|
||||||
|
handleNpcChatInput: (input: string) => boolean;
|
||||||
|
exitNpcChat: () => boolean;
|
||||||
characterChatUi: CharacterChatUi;
|
characterChatUi: CharacterChatUi;
|
||||||
inventoryUi: InventoryFlowUi;
|
inventoryUi: InventoryFlowUi;
|
||||||
battleRewardUi: BattleRewardUi;
|
battleRewardUi: BattleRewardUi;
|
||||||
@@ -198,6 +202,8 @@ export function GameShellMainContent({
|
|||||||
canRefreshOptions={canRefreshOptions}
|
canRefreshOptions={canRefreshOptions}
|
||||||
handleRefreshOptions={handleRefreshOptions}
|
handleRefreshOptions={handleRefreshOptions}
|
||||||
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
||||||
|
handleNpcChatInput={handleNpcChatInput}
|
||||||
|
exitNpcChat={exitNpcChat}
|
||||||
characterChatUi={characterChatUi}
|
characterChatUi={characterChatUi}
|
||||||
inventoryUi={inventoryUi}
|
inventoryUi={inventoryUi}
|
||||||
battleRewardUi={battleRewardUi}
|
battleRewardUi={battleRewardUi}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
|||||||
displayedOptions,
|
displayedOptions,
|
||||||
canRefreshOptions,
|
canRefreshOptions,
|
||||||
handleRefreshOptions,
|
handleRefreshOptions,
|
||||||
|
handleNpcChatInput,
|
||||||
|
exitNpcChat,
|
||||||
handleMapTravelToScene,
|
handleMapTravelToScene,
|
||||||
npcUi,
|
npcUi,
|
||||||
characterChatUi,
|
characterChatUi,
|
||||||
@@ -151,6 +153,8 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
|||||||
canRefreshOptions={canRefreshOptions}
|
canRefreshOptions={canRefreshOptions}
|
||||||
handleRefreshOptions={handleRefreshOptions}
|
handleRefreshOptions={handleRefreshOptions}
|
||||||
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
||||||
|
handleNpcChatInput={handleNpcChatInput}
|
||||||
|
exitNpcChat={exitNpcChat}
|
||||||
characterChatUi={characterChatUi}
|
characterChatUi={characterChatUi}
|
||||||
inventoryUi={inventoryUi}
|
inventoryUi={inventoryUi}
|
||||||
battleRewardUi={battleRewardUi}
|
battleRewardUi={battleRewardUi}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export function GameShellStoryPanels({
|
|||||||
canRefreshOptions,
|
canRefreshOptions,
|
||||||
handleRefreshOptions,
|
handleRefreshOptions,
|
||||||
handleSceneTransitionChoice,
|
handleSceneTransitionChoice,
|
||||||
|
handleNpcChatInput,
|
||||||
|
exitNpcChat,
|
||||||
characterChatUi,
|
characterChatUi,
|
||||||
inventoryUi,
|
inventoryUi,
|
||||||
battleRewardUi,
|
battleRewardUi,
|
||||||
@@ -77,6 +79,8 @@ export function GameShellStoryPanels({
|
|||||||
canRefreshOptions: boolean;
|
canRefreshOptions: boolean;
|
||||||
handleRefreshOptions: () => void;
|
handleRefreshOptions: () => void;
|
||||||
handleSceneTransitionChoice: (option: StoryOption) => void;
|
handleSceneTransitionChoice: (option: StoryOption) => void;
|
||||||
|
handleNpcChatInput: (input: string) => boolean;
|
||||||
|
exitNpcChat: () => boolean;
|
||||||
characterChatUi: CharacterChatUi;
|
characterChatUi: CharacterChatUi;
|
||||||
inventoryUi: InventoryFlowUi;
|
inventoryUi: InventoryFlowUi;
|
||||||
battleRewardUi: BattleRewardUi;
|
battleRewardUi: BattleRewardUi;
|
||||||
@@ -181,6 +185,8 @@ export function GameShellStoryPanels({
|
|||||||
canRefreshOptions={canRefreshOptions}
|
canRefreshOptions={canRefreshOptions}
|
||||||
onRefreshOptions={handleRefreshOptions}
|
onRefreshOptions={handleRefreshOptions}
|
||||||
onChoice={handleSceneTransitionChoice}
|
onChoice={handleSceneTransitionChoice}
|
||||||
|
onSubmitNpcChatInput={handleNpcChatInput}
|
||||||
|
onExitNpcChat={exitNpcChat}
|
||||||
onOpenCharacter={() => openOverlayPanel('character')}
|
onOpenCharacter={() => openOverlayPanel('character')}
|
||||||
onOpenInventory={() => openOverlayPanel('inventory')}
|
onOpenInventory={() => openOverlayPanel('inventory')}
|
||||||
playerCharacter={playerCharacter}
|
playerCharacter={playerCharacter}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface GameShellStoryProps {
|
|||||||
canRefreshOptions: boolean;
|
canRefreshOptions: boolean;
|
||||||
handleRefreshOptions: () => void;
|
handleRefreshOptions: () => void;
|
||||||
handleChoice: (option: StoryOption) => void;
|
handleChoice: (option: StoryOption) => void;
|
||||||
|
handleNpcChatInput: (input: string) => boolean;
|
||||||
|
exitNpcChat: () => boolean;
|
||||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||||
npcUi: StoryGenerationNpcUi;
|
npcUi: StoryGenerationNpcUi;
|
||||||
characterChatUi: CharacterChatUi;
|
characterChatUi: CharacterChatUi;
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ import {
|
|||||||
resolveSceneEncounterPreview,
|
resolveSceneEncounterPreview,
|
||||||
} from '../../data/sceneEncounterPreviews';
|
} from '../../data/sceneEncounterPreviews';
|
||||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||||
import { generateNextStep, streamNpcChatDialogue } from '../../services/aiService';
|
import {
|
||||||
|
generateNextStep,
|
||||||
|
streamNpcChatDialogue,
|
||||||
|
streamNpcChatTurn,
|
||||||
|
} from '../../services/aiService';
|
||||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||||
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
||||||
import {
|
import {
|
||||||
@@ -572,6 +576,249 @@ export function createStoryNpcEncounterActions({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildNpcChatTurnOptions = (
|
||||||
|
encounter: Encounter,
|
||||||
|
suggestions: string[],
|
||||||
|
): StoryOption[] =>
|
||||||
|
suggestions.slice(0, 3).map((suggestion) => ({
|
||||||
|
functionId: 'npc_chat',
|
||||||
|
actionText: suggestion,
|
||||||
|
text: suggestion,
|
||||||
|
detailText: '',
|
||||||
|
visuals: {
|
||||||
|
playerAnimation: AnimationState.IDLE,
|
||||||
|
playerMoveMeters: 0,
|
||||||
|
playerOffsetY: 0,
|
||||||
|
playerFacing: 'right',
|
||||||
|
scrollWorld: false,
|
||||||
|
monsterChanges: [],
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
kind: 'npc',
|
||||||
|
npcId: encounter.id ?? encounter.npcName,
|
||||||
|
action: 'chat',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const buildFallbackNpcChatSuggestions = (playerMessage: string) => {
|
||||||
|
const topic = playerMessage.trim() || '刚才那句话';
|
||||||
|
return [
|
||||||
|
`顺着“${topic}”继续追问`,
|
||||||
|
'先表明你的判断,再看对方反应',
|
||||||
|
'换个更轻松的语气把话接下去',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNpcChatStoryMoment = (params: {
|
||||||
|
encounter: Encounter;
|
||||||
|
dialogue: NonNullable<StoryMoment['dialogue']>;
|
||||||
|
options: StoryOption[];
|
||||||
|
streaming: boolean;
|
||||||
|
turnCount: number;
|
||||||
|
}): StoryMoment => ({
|
||||||
|
text: params.dialogue.map((turn) => turn.text).join('\n'),
|
||||||
|
options: params.options,
|
||||||
|
displayMode: 'dialogue',
|
||||||
|
dialogue: params.dialogue,
|
||||||
|
streaming: params.streaming,
|
||||||
|
npcChatState: {
|
||||||
|
npcId: params.encounter.id ?? params.encounter.npcName,
|
||||||
|
npcName: params.encounter.npcName,
|
||||||
|
turnCount: params.turnCount,
|
||||||
|
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNpcChatTurn = async (
|
||||||
|
encounter: Encounter,
|
||||||
|
playerMessage: string,
|
||||||
|
) => {
|
||||||
|
const playerCharacter = gameState.playerCharacter;
|
||||||
|
if (!playerCharacter || !gameState.worldType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const npcState = getResolvedNpcState(gameState, encounter);
|
||||||
|
const currentNpcChatState =
|
||||||
|
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
|
||||||
|
? currentStory.npcChatState
|
||||||
|
: null;
|
||||||
|
const existingDialogue =
|
||||||
|
currentStory?.dialogue && currentNpcChatState
|
||||||
|
? [...currentStory.dialogue]
|
||||||
|
: [];
|
||||||
|
const dialogueWithPlayer = [
|
||||||
|
...existingDialogue,
|
||||||
|
{
|
||||||
|
speaker: 'player' as const,
|
||||||
|
text: playerMessage,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
|
||||||
|
const openingCampContext = buildOpeningCampChatContext(
|
||||||
|
gameState,
|
||||||
|
playerCharacter,
|
||||||
|
encounter,
|
||||||
|
);
|
||||||
|
|
||||||
|
setAiError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
setCurrentStory(
|
||||||
|
buildNpcChatStoryMoment({
|
||||||
|
encounter,
|
||||||
|
dialogue: dialogueWithPlayer,
|
||||||
|
options: [],
|
||||||
|
streaming: true,
|
||||||
|
turnCount: nextTurnCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatTurn = await streamNpcChatTurn(
|
||||||
|
gameState.worldType,
|
||||||
|
playerCharacter,
|
||||||
|
encounter,
|
||||||
|
getStoryGenerationHostileNpcs(gameState),
|
||||||
|
gameState.storyHistory,
|
||||||
|
buildStoryContextFromState(gameState, {
|
||||||
|
lastFunctionId: 'npc_chat',
|
||||||
|
...openingCampContext,
|
||||||
|
encounterNpcStateOverride: npcState,
|
||||||
|
}),
|
||||||
|
existingDialogue,
|
||||||
|
playerMessage,
|
||||||
|
{
|
||||||
|
affinity: npcState.affinity,
|
||||||
|
chattedCount: npcState.chattedCount,
|
||||||
|
recruited: npcState.recruited,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onReplyUpdate: (text) => {
|
||||||
|
setCurrentStory(
|
||||||
|
buildNpcChatStoryMoment({
|
||||||
|
encounter,
|
||||||
|
dialogue: [
|
||||||
|
...dialogueWithPlayer,
|
||||||
|
{
|
||||||
|
speaker: 'npc',
|
||||||
|
speakerName: encounter.npcName,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: [],
|
||||||
|
streaming: true,
|
||||||
|
turnCount: nextTurnCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let nextAffinity = npcState.affinity;
|
||||||
|
const nextState = updateNpcState(
|
||||||
|
gameState,
|
||||||
|
encounter,
|
||||||
|
(currentNpcState) => {
|
||||||
|
nextAffinity = currentNpcState.affinity + chatTurn.affinityDelta;
|
||||||
|
return {
|
||||||
|
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||||
|
affinity: nextAffinity,
|
||||||
|
relationState: buildRelationState(nextAffinity),
|
||||||
|
chattedCount: currentNpcState.chattedCount + 1,
|
||||||
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||||
|
currentNpcState.stanceProfile,
|
||||||
|
'npc_chat',
|
||||||
|
{ affinityGain: chatTurn.affinityDelta },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const finalHistory = appendHistory(
|
||||||
|
gameState,
|
||||||
|
playerMessage,
|
||||||
|
chatTurn.npcReply,
|
||||||
|
);
|
||||||
|
const finalState = {
|
||||||
|
...nextState,
|
||||||
|
quests: applyQuestProgressFromNpcTalk(
|
||||||
|
nextState.quests,
|
||||||
|
encounter.id ?? encounter.npcName,
|
||||||
|
),
|
||||||
|
storyHistory: finalHistory,
|
||||||
|
};
|
||||||
|
setGameState(finalState);
|
||||||
|
|
||||||
|
const affinityTurn =
|
||||||
|
chatTurn.affinityDelta !== 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
speaker: 'system' as const,
|
||||||
|
text:
|
||||||
|
chatTurn.affinityDelta > 0
|
||||||
|
? `${chatTurn.affinityText} 好感 +${chatTurn.affinityDelta}`
|
||||||
|
: chatTurn.affinityText,
|
||||||
|
affinityDelta: chatTurn.affinityDelta,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setCurrentStory(
|
||||||
|
buildNpcChatStoryMoment({
|
||||||
|
encounter,
|
||||||
|
dialogue: [
|
||||||
|
...dialogueWithPlayer,
|
||||||
|
{
|
||||||
|
speaker: 'npc',
|
||||||
|
speakerName: encounter.npcName,
|
||||||
|
text: chatTurn.npcReply,
|
||||||
|
},
|
||||||
|
...affinityTurn,
|
||||||
|
],
|
||||||
|
options: buildNpcChatTurnOptions(
|
||||||
|
encounter,
|
||||||
|
chatTurn.suggestions.length > 0
|
||||||
|
? chatTurn.suggestions
|
||||||
|
: buildFallbackNpcChatSuggestions(playerMessage),
|
||||||
|
),
|
||||||
|
streaming: false,
|
||||||
|
turnCount: nextTurnCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stream npc chat turn:', error);
|
||||||
|
setAiError(
|
||||||
|
error instanceof Error ? error.message : 'NPC 聊天续写失败',
|
||||||
|
);
|
||||||
|
setCurrentStory(
|
||||||
|
buildNpcChatStoryMoment({
|
||||||
|
encounter,
|
||||||
|
dialogue: dialogueWithPlayer,
|
||||||
|
options: buildNpcChatTurnOptions(
|
||||||
|
encounter,
|
||||||
|
buildFallbackNpcChatSuggestions(playerMessage),
|
||||||
|
),
|
||||||
|
streaming: false,
|
||||||
|
turnCount: nextTurnCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitNpcChat = () => {
|
||||||
|
const playerCharacter = gameState.playerCharacter;
|
||||||
|
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiError(null);
|
||||||
|
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
|
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
|
||||||
const playerCharacter = gameState.playerCharacter;
|
const playerCharacter = gameState.playerCharacter;
|
||||||
if (!playerCharacter) return false;
|
if (!playerCharacter) return false;
|
||||||
@@ -824,57 +1071,7 @@ export function createStoryNpcEncounterActions({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case 'chat': {
|
case 'chat': {
|
||||||
const chatOutcome = getChatAffinityOutcome({
|
void handleNpcChatTurn(encounter, option.actionText);
|
||||||
playerCharacter,
|
|
||||||
encounter,
|
|
||||||
npcState,
|
|
||||||
actionText: option.actionText,
|
|
||||||
worldType: gameState.worldType,
|
|
||||||
customWorldProfile: gameState.customWorldProfile,
|
|
||||||
});
|
|
||||||
const affinityGain = chatOutcome.affinityGain;
|
|
||||||
const attributeSummary = chatOutcome.summary;
|
|
||||||
let nextAffinity = npcState.affinity;
|
|
||||||
const nextState = updateNpcState(
|
|
||||||
gameState,
|
|
||||||
encounter,
|
|
||||||
(currentNpcState) => {
|
|
||||||
nextAffinity = currentNpcState.affinity + affinityGain;
|
|
||||||
return {
|
|
||||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
|
||||||
affinity: nextAffinity,
|
|
||||||
relationState: buildRelationState(nextAffinity),
|
|
||||||
chattedCount: currentNpcState.chattedCount + 1,
|
|
||||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
||||||
currentNpcState.stanceProfile,
|
|
||||||
'npc_chat',
|
|
||||||
{ affinityGain },
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
void commitNpcChatState(
|
|
||||||
nextState,
|
|
||||||
playerCharacter,
|
|
||||||
encounter,
|
|
||||||
option.actionText,
|
|
||||||
npcState.recruited
|
|
||||||
? buildCampCompanionChatResultText(
|
|
||||||
encounter,
|
|
||||||
affinityGain,
|
|
||||||
nextAffinity,
|
|
||||||
)
|
|
||||||
: buildNpcChatResultText(
|
|
||||||
encounter,
|
|
||||||
affinityGain,
|
|
||||||
nextAffinity,
|
|
||||||
attributeSummary,
|
|
||||||
),
|
|
||||||
option.functionId,
|
|
||||||
{
|
|
||||||
contextNpcStateOverride: npcState,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case 'quest_accept': {
|
case 'quest_accept': {
|
||||||
@@ -1029,5 +1226,7 @@ export function createStoryNpcEncounterActions({
|
|||||||
enterNpcInteraction,
|
enterNpcInteraction,
|
||||||
handleNpcInteraction,
|
handleNpcInteraction,
|
||||||
finalizeNpcBattleResult,
|
finalizeNpcBattleResult,
|
||||||
|
handleNpcChatTurn,
|
||||||
|
exitNpcChat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export function useStoryFlowCoordinator({
|
|||||||
npcUi,
|
npcUi,
|
||||||
inventoryUi,
|
inventoryUi,
|
||||||
clearStoryInteractionUi,
|
clearStoryInteractionUi,
|
||||||
|
handleNpcChatInput,
|
||||||
|
exitNpcChat,
|
||||||
} = useStoryInteractionCoordinator({
|
} = useStoryInteractionCoordinator({
|
||||||
gameState,
|
gameState,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -186,5 +188,7 @@ export function useStoryFlowCoordinator({
|
|||||||
goalUi,
|
goalUi,
|
||||||
npcUi,
|
npcUi,
|
||||||
inventoryUi,
|
inventoryUi,
|
||||||
|
handleNpcChatInput,
|
||||||
|
exitNpcChat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,11 +101,16 @@ export function useStoryInteractionCoordinator({
|
|||||||
const npcInteractionFlow = useStoryNpcInteractionFlow(
|
const npcInteractionFlow = useStoryNpcInteractionFlow(
|
||||||
interactionConfig.npcInteractionFlow,
|
interactionConfig.npcInteractionFlow,
|
||||||
);
|
);
|
||||||
const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } =
|
const {
|
||||||
createStoryNpcEncounterActions({
|
enterNpcInteraction,
|
||||||
...interactionConfig.npcEncounterActions,
|
handleNpcInteraction,
|
||||||
npcInteractionFlow,
|
finalizeNpcBattleResult,
|
||||||
});
|
handleNpcChatTurn,
|
||||||
|
exitNpcChat,
|
||||||
|
} = createStoryNpcEncounterActions({
|
||||||
|
...interactionConfig.npcEncounterActions,
|
||||||
|
npcInteractionFlow,
|
||||||
|
});
|
||||||
const choiceRuntimeController: Parameters<
|
const choiceRuntimeController: Parameters<
|
||||||
typeof useStoryChoiceCoordinator
|
typeof useStoryChoiceCoordinator
|
||||||
>[0]['runtimeController'] = {
|
>[0]['runtimeController'] = {
|
||||||
@@ -199,5 +204,15 @@ export function useStoryInteractionCoordinator({
|
|||||||
npcUi: npcInteractionFlow.npcUi,
|
npcUi: npcInteractionFlow.npcUi,
|
||||||
inventoryUi,
|
inventoryUi,
|
||||||
clearStoryInteractionUi,
|
clearStoryInteractionUi,
|
||||||
|
handleNpcChatInput: (input: string) => {
|
||||||
|
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
|
||||||
|
if (!encounter || encounter.kind !== 'npc') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleNpcChatTurn(encounter, input);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
exitNpcChat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export function useGameShellRuntime(): GameShellProps {
|
|||||||
canRefreshOptions: storyFlow.canRefreshOptions,
|
canRefreshOptions: storyFlow.canRefreshOptions,
|
||||||
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
||||||
handleChoice: storyFlow.handleChoice,
|
handleChoice: storyFlow.handleChoice,
|
||||||
|
handleNpcChatInput: storyFlow.handleNpcChatInput,
|
||||||
|
exitNpcChat: storyFlow.exitNpcChat,
|
||||||
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
||||||
npcUi: storyFlow.npcUi,
|
npcUi: storyFlow.npcUi,
|
||||||
characterChatUi: storyFlow.characterChatUi,
|
characterChatUi: storyFlow.characterChatUi,
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export function useStoryGeneration({
|
|||||||
goalUi,
|
goalUi,
|
||||||
npcUi,
|
npcUi,
|
||||||
inventoryUi,
|
inventoryUi,
|
||||||
|
handleNpcChatInput,
|
||||||
|
exitNpcChat,
|
||||||
} = useStoryFlowCoordinator({
|
} = useStoryFlowCoordinator({
|
||||||
gameState,
|
gameState,
|
||||||
setGameState,
|
setGameState,
|
||||||
@@ -141,5 +143,7 @@ export function useStoryGeneration({
|
|||||||
npcUi,
|
npcUi,
|
||||||
characterChatUi,
|
characterChatUi,
|
||||||
inventoryUi,
|
inventoryUi,
|
||||||
|
handleNpcChatInput,
|
||||||
|
exitNpcChat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import type {
|
|||||||
CharacterChatSuggestionsRequest,
|
CharacterChatSuggestionsRequest,
|
||||||
CharacterChatSummaryRequest,
|
CharacterChatSummaryRequest,
|
||||||
NpcChatDialogueRequest,
|
NpcChatDialogueRequest,
|
||||||
|
NpcChatTurnRequest,
|
||||||
|
NpcChatTurnResult,
|
||||||
NpcRecruitDialogueRequest,
|
NpcRecruitDialogueRequest,
|
||||||
PlainTextResponse,
|
PlainTextResponse,
|
||||||
} from '../../packages/shared/src/contracts/story';
|
} from '../../packages/shared/src/contracts/story';
|
||||||
@@ -156,6 +158,37 @@ async function requestPlainTextStream(
|
|||||||
return accumulatedText.trim();
|
return accumulatedText.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ParsedSseEvent = {
|
||||||
|
event: string | null;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
|
||||||
|
let eventName: string | null = null;
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
eventName = line.slice(6).trim() || null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: eventName,
|
||||||
|
data: dataLines.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateInitialStory(
|
export async function generateInitialStory(
|
||||||
world: WorldType,
|
world: WorldType,
|
||||||
character: Character,
|
character: Character,
|
||||||
@@ -893,6 +926,109 @@ export async function streamNpcChatDialogue(
|
|||||||
return dialogue.trim();
|
return dialogue.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function streamNpcChatTurn(
|
||||||
|
world: WorldType,
|
||||||
|
character: Character,
|
||||||
|
encounter: Encounter,
|
||||||
|
monsters: SceneHostileNpc[],
|
||||||
|
history: StoryMoment[],
|
||||||
|
context: StoryGenerationContext,
|
||||||
|
conversationHistory: StoryMoment['dialogue'],
|
||||||
|
playerMessage: string,
|
||||||
|
npcState: Record<string, unknown>,
|
||||||
|
options: {
|
||||||
|
onReplyUpdate?: (text: string) => void;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const payload = {
|
||||||
|
worldType: world,
|
||||||
|
character,
|
||||||
|
encounter,
|
||||||
|
monsters,
|
||||||
|
history,
|
||||||
|
context,
|
||||||
|
conversationHistory: conversationHistory ?? [],
|
||||||
|
playerMessage,
|
||||||
|
npcState,
|
||||||
|
} satisfies NpcChatTurnRequest;
|
||||||
|
|
||||||
|
const response = await fetchWithApiAuth(`${RUNTIME_API_BASE}/chat/npc/turn/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseText = await response.text();
|
||||||
|
throw new Error(parseApiErrorMessage(responseText, 'NPC 聊天续写失败'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('streaming response body is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
let accumulatedReply = '';
|
||||||
|
let completedResult: NpcChatTurnResult | null = null;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
while (buffer.includes('\n\n')) {
|
||||||
|
const boundary = buffer.indexOf('\n\n');
|
||||||
|
const eventBlock = buffer.slice(0, boundary);
|
||||||
|
buffer = buffer.slice(boundary + 2);
|
||||||
|
|
||||||
|
const parsedEvent = parseSseEventBlock(eventBlock);
|
||||||
|
if (!parsedEvent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedEvent.data === '[DONE]') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedEvent.event === 'reply_delta') {
|
||||||
|
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
|
||||||
|
const nextText =
|
||||||
|
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
||||||
|
accumulatedReply = nextText;
|
||||||
|
options.onReplyUpdate?.(accumulatedReply);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedEvent.event === 'complete') {
|
||||||
|
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
|
||||||
|
accumulatedReply = completedResult.npcReply;
|
||||||
|
options.onReplyUpdate?.(accumulatedReply);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedEvent.event === 'error') {
|
||||||
|
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
|
||||||
|
throw new Error(
|
||||||
|
typeof payloadRecord.message === 'string'
|
||||||
|
? payloadRecord.message
|
||||||
|
: 'NPC 聊天续写失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!completedResult) {
|
||||||
|
throw new Error('NPC 聊天续写结果为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
return completedResult;
|
||||||
|
}
|
||||||
|
|
||||||
export async function streamNpcRecruitDialogue(
|
export async function streamNpcRecruitDialogue(
|
||||||
world: WorldType,
|
world: WorldType,
|
||||||
character: Character,
|
character: Character,
|
||||||
|
|||||||
@@ -101,9 +101,17 @@ export interface QuestLogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StoryDialogueTurn {
|
export interface StoryDialogueTurn {
|
||||||
speaker: 'player' | 'npc' | 'companion';
|
speaker: 'player' | 'npc' | 'companion' | 'system';
|
||||||
speakerName?: string;
|
speakerName?: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
affinityDelta?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryNpcChatState {
|
||||||
|
npcId: string;
|
||||||
|
npcName: string;
|
||||||
|
turnCount: number;
|
||||||
|
customInputPlaceholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CharacterChatTurn {
|
export interface CharacterChatTurn {
|
||||||
@@ -127,6 +135,7 @@ export interface StoryMoment {
|
|||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
deferredOptions?: StoryOption[];
|
deferredOptions?: StoryOption[];
|
||||||
historyRole?: StoryHistoryRole;
|
historyRole?: StoryHistoryRole;
|
||||||
|
npcChatState?: StoryNpcChatState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StoryOptionInteraction =
|
export type StoryOptionInteraction =
|
||||||
|
|||||||
Reference in New Issue
Block a user