Merge remote-tracking branch 'origin/master' into codex/ddd

# Conflicts:
#	docs/technical/README.md
#	docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
#	docs/technical/SPACETIMEDB_TABLE_CATALOG.md
#	scripts/generate-spacetime-bindings.mjs
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/assets.rs
#	server-rs/crates/api-server/src/big_fish.rs
#	server-rs/crates/api-server/src/custom_world_ai.rs
#	server-rs/crates/api-server/src/llm.rs
#	server-rs/crates/api-server/src/main.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/api-server/src/runtime_profile.rs
#	server-rs/crates/api-server/src/runtime_story/compat/ai.rs
#	server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs
#	server-rs/crates/api-server/src/runtime_story/compat/presentation.rs
#	server-rs/crates/api-server/src/runtime_story/compat/tests.rs
#	server-rs/crates/api-server/src/state.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/module-big-fish/src/lib.rs
#	server-rs/crates/module-custom-world/src/lib.rs
#	server-rs/crates/module-puzzle/src/lib.rs
#	server-rs/crates/module-runtime/src/lib.rs
#	server-rs/crates/spacetime-client/src/big_fish.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/mod.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	server-rs/crates/spacetime-module/src/custom_world/mod.rs
#	server-rs/crates/spacetime-module/src/lib.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	server-rs/crates/spacetime-module/src/puzzle.rs
#	server-rs/crates/spacetime-module/src/runtime/profile.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/aiService.ts
#	src/services/puzzle-runtime/puzzleRuntimeClient.ts
This commit is contained in:
kdletters
2026-05-02 03:35:59 +08:00
513 changed files with 52813 additions and 6013 deletions

View File

@@ -1,11 +1,11 @@
import {type Dispatch, type SetStateAction,useState} from 'react';
import { type Dispatch, type SetStateAction, useState } from 'react';
import {
generateCharacterPanelChatSuggestions,
generateCharacterPanelChatSummary,
streamCharacterPanelChatReply,
} from '../../services/aiService';
import type {StoryGenerationContext} from '../../services/aiTypes';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type {
Character,
CharacterChatRecord,
@@ -47,12 +47,17 @@ export interface CharacterChatUi {
sendDraft: () => void;
}
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
return state.characterChats[characterId] ?? {
history: [],
summary: '',
updatedAt: null,
};
export function getCharacterChatRecord(
state: GameState,
characterId: string,
): CharacterChatRecord {
return (
state.characterChats[characterId] ?? {
history: [],
summary: '',
updatedAt: null,
}
);
}
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
@@ -66,7 +71,10 @@ export function buildLocalCharacterChatSummary(
) {
const latestTurns = history
.slice(-4)
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
.map(
(turn) =>
`${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`,
)
.join(' ');
const currentSummary = latestTurns
@@ -111,7 +119,9 @@ type CharacterChatTargetStatus = {
affinity?: number | null;
};
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
function buildTargetStatus(
target: CharacterChatTarget,
): CharacterChatTargetStatus {
return {
roleLabel: target.roleLabel,
hp: target.hp,
@@ -129,9 +139,13 @@ export function useCharacterChatFlow({
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
buildStoryContextFromState: (
state: GameState,
extras?: { currentStory?: null },
) => StoryGenerationContext;
}) {
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
const [characterChatModal, setCharacterChatModal] =
useState<CharacterChatModalState | null>(null);
const loadCharacterChatSuggestions = async (
target: CharacterChatTarget,
@@ -139,7 +153,7 @@ export function useCharacterChatFlow({
summary: string,
) => {
if (!gameState.worldType || !gameState.playerCharacter) {
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -151,7 +165,7 @@ export function useCharacterChatFlow({
return;
}
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -172,7 +186,7 @@ export function useCharacterChatFlow({
buildTargetStatus(target),
);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -183,7 +197,7 @@ export function useCharacterChatFlow({
);
} catch (error) {
console.error('Failed to generate character chat suggestions:', error);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -213,7 +227,11 @@ export function useCharacterChatFlow({
};
const sendCharacterChatDraft = async () => {
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
if (
!characterChatModal ||
!gameState.worldType ||
!gameState.playerCharacter
) {
return;
}
@@ -223,7 +241,10 @@ export function useCharacterChatFlow({
}
const target = characterChatModal.target;
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
const existingRecord = getCharacterChatRecord(
gameState,
target.character.id,
);
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
const nextMessages = trimCharacterChatHistory([
...baseMessages,
@@ -233,12 +254,12 @@ export function useCharacterChatFlow({
},
]);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
draft: '',
messages: [...nextMessages, {speaker: 'character', text: ''}],
messages: [...nextMessages, { speaker: 'character', text: '' }],
suggestions: [],
isSending: true,
isLoadingSuggestions: true,
@@ -261,12 +282,12 @@ export function useCharacterChatFlow({
draft,
buildTargetStatus(target),
{
onUpdate: text => {
setCharacterChatModal(current =>
onUpdate: (text) => {
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
messages: [...nextMessages, {speaker: 'character', text}],
messages: [...nextMessages, { speaker: 'character', text }],
}
: current,
);
@@ -275,7 +296,7 @@ export function useCharacterChatFlow({
);
} catch (error) {
console.error('Failed to stream character panel chat reply:', error);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -283,10 +304,12 @@ export function useCharacterChatFlow({
messages: baseMessages,
isSending: false,
isLoadingSuggestions: false,
error: error instanceof Error ? error.message : '未知智能生成错误',
suggestions: current.suggestions.length > 0
? current.suggestions
: buildLocalCharacterChatSuggestions(target.character),
error:
error instanceof Error ? error.message : '未知智能生成错误',
suggestions:
current.suggestions.length > 0
? current.suggestions
: buildLocalCharacterChatSuggestions(target.character),
}
: current,
);
@@ -315,7 +338,11 @@ export function useCharacterChatFlow({
);
} catch (error) {
console.error('Failed to summarize character chat:', error);
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
nextSummary = buildLocalCharacterChatSummary(
target.character,
finalMessages,
existingRecord.summary,
);
}
const nextRecord: CharacterChatRecord = {
@@ -324,10 +351,10 @@ export function useCharacterChatFlow({
updatedAt: new Date().toISOString(),
};
setGameState(current =>
setGameState((current) =>
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -346,8 +373,14 @@ export function useCharacterChatFlow({
modal: characterChatModal,
openChat: openCharacterChat,
closeChat: () => setCharacterChatModal(null),
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
setDraft: (value: string) =>
setCharacterChatModal((current) =>
current ? { ...current, draft: value } : current,
),
useSuggestion: (value: string) =>
setCharacterChatModal((current) =>
current ? { ...current, draft: value } : current,
),
refreshSuggestions: () => {
if (!characterChatModal) {
return;

View File

@@ -315,6 +315,20 @@ describe('createStoryChoiceActions', () => {
treasureHints: [],
npcs: [],
},
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'scene-bridge',
chapterId: 'chapter-bridge',
currentActId: 'act-2',
currentActIndex: 1,
completedActIds: ['act-1'],
visitedActIds: ['act-1', 'act-2'],
},
},
},
};
const setCurrentStory = vi.fn();
@@ -369,11 +383,13 @@ describe('createStoryChoiceActions', () => {
currentScenePreset: expect.objectContaining({
id: 'scene-bridge',
}),
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
currentActId: 'act-2',
}),
}),
}),
);
expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty(
'storyEngineMemory',
);
expect(setCurrentStory).toHaveBeenCalledWith({
...currentStory,
options: deferredOptions,

View File

@@ -1,7 +1,4 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
@@ -25,7 +22,12 @@ import {
} from './storyChoiceRuntime';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
type RuntimeStatsIncrements = Partial<
Pick<
GameState['runtimeStats'],
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
>
>;
type BuildFallbackStoryForState = (
state: GameState,
@@ -63,6 +65,7 @@ type BuildStoryContextFromState = (
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
currentStory?: StoryMoment | null;
},
) => StoryGenerationContext;
@@ -115,7 +118,11 @@ export function createStoryChoiceActions({
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
character: Character,
) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
@@ -127,14 +134,24 @@ export function createStoryChoiceActions({
buildStoryFromResponse: BuildStoryFromResponse;
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: GenerateStoryForState;
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getAvailableOptionsForState: (
state: GameState,
character: Character,
) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene?: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
getCampCompanionTravelScene?: (
state: GameState,
character: Character,
) => GameState['currentScenePreset'] | null;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: (
@@ -149,8 +166,12 @@ export function createStoryChoiceActions({
) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption?: (option: StoryOption) => boolean;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter?: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isNpcEncounter?: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName?: string;
turnVisualMs: number;
@@ -160,7 +181,10 @@ export function createStoryChoiceActions({
if (!gameState.worldType || !character || isLoading) return;
if (option.disabled) return;
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
if (
currentStory?.deferredOptions?.length &&
isContinueAdventureOption(option)
) {
if (currentStory.deferredRuntimeState) {
setGameState({
...gameState,
@@ -174,6 +198,9 @@ export function createStoryChoiceActions({
currentScenePreset:
currentStory.deferredRuntimeState.currentScenePreset ??
gameState.currentScenePreset,
storyEngineMemory:
currentStory.deferredRuntimeState.storyEngineMemory ??
gameState.storyEngineMemory,
});
}
setCurrentStory({
@@ -209,9 +236,9 @@ export function createStoryChoiceActions({
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isRegularNpcEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
option.functionId === npcPreviewTalkFunctionId &&
isRegularNpcEncounter(gameState.currentEncounter) &&
!gameState.npcInteractionActive
) {
setAiError(null);
enterNpcInteraction(gameState.currentEncounter, option.actionText);

View File

@@ -175,7 +175,7 @@ function createState(overrides: Partial<GameState> = {}): GameState {
function createSceneActProfile(
primaryNpcId = 'npc-rival',
actCount = 1,
actCount = 2,
): NonNullable<GameState['customWorldProfile']> {
const acts = Array.from({ length: actCount }, (_, index) => ({
id: `scene-bridge-act-${index + 1}`,
@@ -847,6 +847,79 @@ describe('npcEncounterActions', () => {
]);
});
it('streams a model-driven npc-initiated opening after first contact was resolved', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '又见面了。桥口的风比刚才更乱,我先把你漏掉的那句话补上。',
suggestions: ['你先说我漏掉了什么'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: 8,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
}),
currentStory: {
text: '断桥客重新抬眼,像是准备先开口。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '继续和断桥客搭话')).toBe(true);
await flushAsyncWork();
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ id: 'npc-rival' }),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'',
expect.objectContaining({
affinity: 8,
chattedCount: 1,
}),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '又见面了。桥口的风比刚才更乱,我先把你漏掉的那句话补上。',
},
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '你先说我漏掉了什么',
}),
]);
});
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 1,
@@ -960,6 +1033,7 @@ describe('npcEncounterActions', () => {
it('sends a closing chat turn after exiting npc chat and keeps the dialogue panel until continue', async () => {
const gameState = createState({
customWorldProfile: createSceneActProfile(),
storyHistory: [
{
text: '你先试探了对方的态度。',
@@ -1026,11 +1100,17 @@ describe('npcEncounterActions', () => {
forceExitAfterTurn: true,
functionOptions: expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_help' }),
expect.objectContaining({ functionId: 'npc_fight' }),
]),
}),
}),
);
const exitChatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(exitChatDirective.functionOptions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_fight' }),
]),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
@@ -1051,11 +1131,35 @@ describe('npcEncounterActions', () => {
functionId: 'story_continue_adventure',
}),
]);
expect(
lastStory.deferredRuntimeState?.storyEngineMemory?.currentSceneActState,
).toEqual(
expect.objectContaining({
currentActId: 'scene-bridge-act-2',
completedActIds: expect.arrayContaining(['scene-bridge-act-1']),
}),
);
expect(lastStory.deferredOptions).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
interaction: expect.objectContaining({
npcId: 'npc-rival',
action: 'chat',
}),
}),
expect.objectContaining({
functionId: 'npc_chat',
interaction: expect.objectContaining({
npcId: 'npc-rival',
action: 'chat',
}),
}),
]);
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
});
it('feeds current story non-chat function options into npc chat context', async () => {
it('feeds allowed positive-affinity function options into npc chat context', async () => {
const gameState = createState({
storyHistory: [
{
@@ -1149,14 +1253,17 @@ describe('npcEncounterActions', () => {
functionId: 'npc_help',
actionText: '借你的人脉把线索铺开',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '现在就把这笔旧账打清',
}),
]),
}),
}),
);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective.functionOptions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_fight' }),
]),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.options).toEqual(
@@ -1235,6 +1342,163 @@ describe('npcEncounterActions', () => {
);
});
it('keeps negative-affinity chat function context empty so only chat choices appear mid-dialogue', async () => {
const gameState = createState({
customWorldProfile: createSceneActProfile(),
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
});
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '关系暂未变化',
npcReply: '你只剩这一句话的机会。',
suggestions: ['我只问最后一句'],
functionSuggestions: [
{
functionId: 'npc_help',
actionText: '让你帮我一次',
},
],
});
const actions = createNpcEncounterActions({
gameState,
currentStory: {
text: '断桥客没有放下戒备。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '断桥客', text: '别再靠近。' },
],
options: [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
]),
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '我只问最后一句。'),
).resolves.toBe(true);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective.functionOptions).toEqual([]);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '我只问最后一句',
}),
]);
});
it('sends hostile termination mode for any negative-affinity npc chat turn', async () => {
const gameState = createState({
customWorldProfile: null,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
});
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '关系暂未变化',
npcReply: '话到这里就够了。',
suggestions: [],
chatDirective: {
forceExit: true,
terminationReason: 'hostile_breakoff',
},
});
const actions = createNpcEncounterActions({
gameState,
currentStory: {
text: '断桥客没有放下戒备。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '断桥客', text: '别再靠近。' },
],
options: [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '我只问最后一句。'),
).resolves.toBe(true);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective).toMatchObject({
turnLimit: null,
remainingTurns: null,
limitReason: 'negative_affinity',
terminationMode: 'hostile_model',
isHostileChat: true,
functionOptions: [],
});
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect.objectContaining({ functionId: 'npc_fight' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
]);
});
it('lets the current act primary npc enter limited chat even with negative affinity', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
@@ -1287,7 +1551,7 @@ describe('npcEncounterActions', () => {
).toBe(false);
});
it('streams npc-initiated opening when negative affinity chat starts from interaction options', async () => {
it('streams npc-initiated opening when resolved negative affinity chat starts from interaction options', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
@@ -1308,7 +1572,7 @@ describe('npcEncounterActions', () => {
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
firstMeaningfulContactResolved: true,
},
},
}),
@@ -1375,6 +1639,86 @@ describe('npcEncounterActions', () => {
]);
});
it('lets the model terminate a hostile npc-initiated opening immediately', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '离桥口远一点。再往前,我就不听你解释了。',
suggestions: [],
functionSuggestions: [],
chatDirective: {
forceExit: true,
closingMode: 'foreshadow_close',
terminationReason: 'hostile_breakoff',
},
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
customWorldProfile: createSceneActProfile(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
},
},
}),
currentStory: {
text: '断桥客仍挡在桥口。',
options: [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
},
});
expect(
actions.handleNpcInteraction(
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
),
).toBe(true);
await flushAsyncWork();
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '离桥口远一点。再往前,我就不听你解释了。',
},
]);
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_fight',
actionText: '战斗',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
]);
});
it('lets player exit hostile chat and offers fight plus scene escape routes instead of continuing adventure', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,

View File

@@ -1,20 +1,13 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
import {
getCharacterById,
} from '../../data/characterPresets';
import { getCharacterById } from '../../data/characterPresets';
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalIntroText,
} from '../../data/functionCatalog';
import {
buildNpcTradeTransactionActionText,
} from '../../data/npcInteractions';
import { buildNpcTradeTransactionActionText } from '../../data/npcInteractions';
import { streamNpcRecruitDialogue } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type {
@@ -52,6 +45,7 @@ type StoryNpcInteractionRuntime = {
state: GameState,
extras?: {
lastFunctionId?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;
@@ -67,11 +61,16 @@ type StoryNpcInteractionRuntime = {
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getTypewriterDelay: (char: string) => number;
};
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
function buildOfflineRecruitDialogue(
encounter: Encounter,
releasedCompanionName?: string | null,
) {
const releaseLine = releasedCompanionName
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
@@ -92,10 +91,11 @@ function normalizeRecruitDialogue(
const rawLines = dialogueText
.replace(/\r/g, '')
.split('\n')
.map(line => line.trim())
.map((line) => line.trim())
.filter(Boolean);
const refusalPattern = /|||||||||||/u;
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
const refusalPattern =
/|||||||||||/u;
const sanitizedLines = rawLines.filter((line) => !refusalPattern.test(line));
const npcPrefix = `${encounter.npcName}`;
const playerPrefix = '你:';
const releaseLine = releasedCompanionName
@@ -108,14 +108,17 @@ function normalizeRecruitDialogue(
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
];
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
const workingLines =
sanitizedLines.length > 0
? sanitizedLines.slice(0, 5)
: defaultLines.slice(0, 3);
if (!workingLines.some((line) => line.startsWith(playerPrefix))) {
const firstDefaultLine = defaultLines[0];
if (firstDefaultLine) {
workingLines.unshift(firstDefaultLine);
}
}
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
if (!workingLines.some((line) => line.startsWith(npcPrefix))) {
const secondDefaultLine = defaultLines[1];
if (secondDefaultLine) {
workingLines.push(secondDefaultLine);
@@ -125,9 +128,9 @@ function normalizeRecruitDialogue(
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
const lastWorkingLine = workingLines[workingLines.length - 1];
if (
workingLines.length === 0
|| !lastWorkingLine?.startsWith(npcPrefix)
|| refusalPattern.test(lastWorkingLine)
workingLines.length === 0 ||
!lastWorkingLine?.startsWith(npcPrefix) ||
refusalPattern.test(lastWorkingLine)
) {
workingLines.push(acceptanceLine);
} else {
@@ -158,11 +161,16 @@ export function useStoryNpcInteractionFlow({
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
getNpcEncounterKey: (encounter: Encounter) => string;
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
getResolvedNpcState: (
state: GameState,
encounter: Encounter,
) => GameState['npcStates'][string];
updateNpcState: (
state: GameState,
encounter: Encounter,
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
updater: (
npcState: GameState['npcStates'][string],
) => GameState['npcStates'][string],
) => GameState;
cloneInventoryItemForOwner: (
item: InventoryItem,
@@ -173,7 +181,9 @@ export function useStoryNpcInteractionFlow({
}) {
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(
null,
);
const resolveRecruitmentOnServer = async (params: {
encounter: Encounter;
@@ -221,7 +231,10 @@ export function useStoryNpcInteractionFlow({
runtime.setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc recruit action on the server:', error);
console.error(
'Failed to resolve npc recruit action on the server:',
error,
);
runtime.setAiError(
error instanceof Error ? error.message : 'NPC 招募执行失败',
);
@@ -245,9 +258,11 @@ export function useStoryNpcInteractionFlow({
const releasedCompanionName = releasedNpcId
? (() => {
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
const releasedCompanion = gameState.companions.find(
(item) => item.npcId === releasedNpcId,
);
return releasedCompanion?.characterId
? getCharacterById(releasedCompanion.characterId)?.name ?? null
? (getCharacterById(releasedCompanion.characterId)?.name ?? null)
: null;
})()
: null;
@@ -261,7 +276,9 @@ export function useStoryNpcInteractionFlow({
setRecruitModal(null);
runtime.setAiError(null);
runtime.setIsLoading(true);
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
);
let dialogueText = '';
let streamedTargetText = '';
@@ -269,20 +286,32 @@ export function useStoryNpcInteractionFlow({
let streamCompleted = false;
const typewriterPromise = (async () => {
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
while (
!streamCompleted ||
displayedText.length < streamedTargetText.length
) {
if (displayedText.length >= streamedTargetText.length) {
await new Promise(resolve => window.setTimeout(resolve, 40));
await new Promise((resolve) => window.setTimeout(resolve, 40));
continue;
}
const nextChar = streamedTargetText[displayedText.length];
if (!nextChar) {
await new Promise(resolve => window.setTimeout(resolve, 40));
await new Promise((resolve) => window.setTimeout(resolve, 40));
continue;
}
displayedText += nextChar;
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true));
await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)));
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(
encounter.npcName,
displayedText,
[],
true,
),
);
await new Promise((resolve) =>
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
);
}
})();
@@ -294,12 +323,13 @@ export function useStoryNpcInteractionFlow({
runtime.getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
runtime.buildStoryContextFromState(provisionalState, {
currentStory: runtime.currentStory,
lastFunctionId: 'npc_recruit',
}),
actionText,
recruitPromptSummary,
{
onUpdate: text => {
onUpdate: (text) => {
streamedTargetText = text;
},
},
@@ -311,17 +341,30 @@ export function useStoryNpcInteractionFlow({
streamCompleted = true;
await typewriterPromise;
console.error('Failed to stream recruit dialogue:', error);
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
dialogueText =
displayedText ||
buildOfflineRecruitDialogue(encounter, releasedCompanionName);
runtime.setAiError(
error instanceof Error ? error.message : '未知智能生成错误',
);
}
const finalDialogueText = normalizeRecruitDialogue(
encounter,
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
dialogueText ||
displayedText ||
buildOfflineRecruitDialogue(encounter, releasedCompanionName),
releasedCompanionName,
);
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
await new Promise(resolve => window.setTimeout(resolve, 260));
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(
encounter.npcName,
finalDialogueText,
[],
false,
),
);
await new Promise((resolve) => window.setTimeout(resolve, 260));
await resolveRecruitmentOnServer({
encounter,
actionText,
@@ -334,8 +377,8 @@ export function useStoryNpcInteractionFlow({
mode: 'buy' | 'sell',
): RuntimeNpcTradeItemView[] =>
mode === 'buy'
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
? (gameState.runtimeNpcInteraction?.trade.buyItems ?? [])
: (gameState.runtimeNpcInteraction?.trade.sellItems ?? []);
const findRuntimeTradeItem = (modal: TradeModalState) => {
const itemId =
@@ -360,27 +403,25 @@ export function useStoryNpcInteractionFlow({
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
setTradeModal(
{
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId:
gameState.runtimeNpcInteraction?.trade.buyItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
gameState.runtimeNpcInteraction?.trade.sellItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
},
);
setTradeModal({
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId:
gameState.runtimeNpcInteraction?.trade.buyItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
gameState.runtimeNpcInteraction?.trade.sellItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
});
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
@@ -390,17 +431,13 @@ export function useStoryNpcInteractionFlow({
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
null;
setGiftModal(
buildNpcGiftModalState(
encounter,
actionText,
selectedItemId,
),
);
setGiftModal(buildNpcGiftModalState(encounter, actionText, selectedItemId));
};
const openRecruitModal = (encounter: Encounter, actionText: string) => {
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
setRecruitModal(
buildNpcRecruitModalState(gameState, encounter, actionText),
);
};
const clearNpcInteractionUi = () => {
@@ -448,7 +485,10 @@ export function useStoryNpcInteractionFlow({
runtime.setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc runtime action on the server:', error);
console.error(
'Failed to resolve npc runtime action on the server:',
error,
);
runtime.setAiError(
error instanceof Error ? error.message : 'NPC 交互执行失败',
);
@@ -514,50 +554,62 @@ export function useStoryNpcInteractionFlow({
tradeModal,
giftModal,
recruitModal,
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
if (!current) return current;
return {
...current,
mode,
selectedNpcItemId:
current.selectedNpcItemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
current.selectedPlayerItemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
};
}),
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
return {
...current,
selectedNpcItemId: itemId,
selectedQuantity: 1,
};
}),
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
return {
...current,
selectedPlayerItemId: itemId,
selectedQuantity: 1,
};
}),
setTradeQuantity: (quantity: number) => setTradeModal(current => current
? {
setTradeMode: (mode: 'buy' | 'sell') =>
setTradeModal((current) => {
if (!current) return current;
return {
...current,
selectedQuantity: normalizeTradeQuantity(quantity),
}
: current),
mode,
selectedNpcItemId:
current.selectedNpcItemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
current.selectedPlayerItemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
};
}),
selectTradeNpcItem: (itemId: string) =>
setTradeModal((current) => {
if (!current) return current;
return {
...current,
selectedNpcItemId: itemId,
selectedQuantity: 1,
};
}),
selectTradePlayerItem: (itemId: string) =>
setTradeModal((current) => {
if (!current) return current;
return {
...current,
selectedPlayerItemId: itemId,
selectedQuantity: 1,
};
}),
setTradeQuantity: (quantity: number) =>
setTradeModal((current) =>
current
? {
...current,
selectedQuantity: normalizeTradeQuantity(quantity),
}
: current,
),
closeTradeModal: () => setTradeModal(null),
confirmTrade,
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
selectGiftItem: (itemId: string) =>
setGiftModal((current) =>
current ? { ...current, selectedItemId: itemId } : current,
),
closeGiftModal: () => setGiftModal(null),
confirmGift,
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
selectRecruitRelease: (npcId: string) =>
setRecruitModal((current) =>
current ? { ...current, selectedReleaseNpcId: npcId } : current,
),
closeRecruitModal: () => setRecruitModal(null),
confirmRecruit: () => {
if (!recruitModal) return;

View File

@@ -55,6 +55,7 @@ type BuildStoryContextFromState = (
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
currentStory?: StoryMoment | null;
},
) => StoryGenerationContext;
@@ -161,7 +162,10 @@ export async function runLocalStoryChoiceContinuation(params: {
params.option,
params.character,
);
if (resolvedChoice.optionKind === 'battle' || resolvedChoice.optionKind === 'escape') {
if (
resolvedChoice.optionKind === 'battle' ||
resolvedChoice.optionKind === 'escape'
) {
throw new Error(
`战斗与逃脱动作必须由后端结算,禁止进入本地 continuation${params.option.functionId}`,
);
@@ -194,6 +198,7 @@ export async function runLocalStoryChoiceContinuation(params: {
observeSignsRequested:
params.option.functionId === 'idle_observe_signs',
recentActionResult: combatResolutionContextText,
currentStory: params.currentStory,
}),
projectedAvailableOptions
? { availableOptions: projectedAvailableOptions }
@@ -239,11 +244,11 @@ export async function runLocalStoryChoiceContinuation(params: {
lastObserveSignsSceneId:
params.option.functionId === 'idle_observe_signs'
? (afterSequence.currentScenePreset?.id ?? null)
: afterSequence.lastObserveSignsSceneId ?? null,
: (afterSequence.lastObserveSignsSceneId ?? null),
lastObserveSignsReport:
params.option.functionId === 'idle_observe_signs'
? response.storyText
: afterSequence.lastObserveSignsReport ?? null,
: (afterSequence.lastObserveSignsReport ?? null),
storyHistory: nextHistory,
},
{},

View File

@@ -22,6 +22,7 @@ export type ChoiceRuntimeController = {
recentActionResult?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState, type StoryMoment } from '../../types';
import { buildStoryContextFromState } from './storyContextBuilder';
function createState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: 'WUXIA',
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 3,
runtimeMode: 'play',
runtimePersistenceDisabled: false,
playerHp: 30,
playerMaxHp: 40,
playerMana: 12,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
animationState: AnimationState.IDLE,
playerSkillCooldowns: {},
currentScenePreset: {
id: 'forest-trail',
name: '林间小径',
description: '风声穿过树梢。',
},
...overrides,
} as GameState;
}
describe('storyContextBuilder', () => {
it('keeps normal play context lightweight', () => {
const context = buildStoryContextFromState(createState());
expect(context.runtimeSessionId).toBe('runtime-main');
expect(context.runtimeSnapshot).toBeUndefined();
});
it('attaches transient snapshot for disabled persistence runtime', () => {
const state = createState({
runtimeSessionId: 'runtime-preview',
runtimePersistenceDisabled: true,
});
const currentStory: StoryMoment = {
text: '断桥客站在风口,等你先开口。',
options: [],
};
const context = buildStoryContextFromState(state, { currentStory });
expect(context.runtimeSnapshot).toEqual({
bottomTab: 'adventure',
gameState: state,
currentStory,
});
});
});

View File

@@ -1,5 +1,5 @@
import type { StoryGenerationContext } from '../../services/aiTypes';
import type { GameState } from '../../types';
import type { GameState, StoryMoment } from '../../types';
export type StoryContextBuilderExtras = {
pendingSceneEncounter?: boolean;
@@ -9,8 +9,17 @@ export type StoryContextBuilderExtras = {
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
currentStory?: StoryMoment | null;
};
function shouldAttachTransientRuntimeSnapshot(state: GameState) {
return (
state.runtimePersistenceDisabled === true ||
state.runtimeMode === 'preview' ||
state.runtimeMode === 'test'
);
}
/**
* 运行时 story prompt context 的正式投影已经迁到 server-rs。
* 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。
@@ -23,6 +32,15 @@ export function buildStoryContextFromState(
runtimeSessionId: state.runtimeSessionId ?? null,
storySessionId: state.storySessionId ?? null,
runtimeActionVersion: state.runtimeActionVersion,
runtimeSnapshot: shouldAttachTransientRuntimeSnapshot(state)
? {
// 中文注释:禁存运行态不会写入正式 runtime_snapshot
// 聊天/续写请求需要携带本地临时快照供 server-rs 投影上下文。
bottomTab: 'adventure',
gameState: state,
currentStory: extras.currentStory ?? null,
}
: undefined,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,

View File

@@ -24,6 +24,7 @@ type StoryInteractionCoordinatorParams = {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;

View File

@@ -18,6 +18,7 @@ type BuildStoryContextFromState = (
state: GameState,
extras?: {
lastFunctionId?: string | null;
currentStory?: StoryMoment | null;
},
) => StoryGenerationContext;
@@ -163,8 +164,11 @@ export async function generateStoryForStateWithCoordinator(params: {
const context = params.choice
? params.buildStoryContextFromState(params.state, {
lastFunctionId: params.lastFunctionId,
currentStory: params.currentStory,
})
: params.buildStoryContextFromState(params.state);
: params.buildStoryContextFromState(params.state, {
currentStory: params.currentStory,
});
const response = params.choice
? await params.requestNextStep(
worldType,

View File

@@ -17,14 +17,18 @@ import {
applyQuestProgressFromSpar,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import { getScenePresetById } from '../../data/scenePresets';
import { resolveFunctionOption } from '../../data/stateFunctions';
import { streamNpcChatTurn } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
advanceSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveSceneActProgression,
resolveLimitedPrimaryNpcChatState,
} from '../../services/customWorldSceneActRuntime';
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
Character,
Encounter,
@@ -63,6 +67,7 @@ type BuildStoryContextExtras = {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
@@ -259,7 +264,9 @@ export function createStoryNpcEncounterActions({
},
});
const buildPendingQuestOfferOptions = (encounter: Encounter): StoryOption[] => [
const buildPendingQuestOfferOptions = (
encounter: Encounter,
): StoryOption[] => [
buildNpcChatQuestOfferOption(
encounter,
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.view,
@@ -336,8 +343,7 @@ export function createStoryNpcEncounterActions({
? `你们刚结束一场切磋,${params.resultText}`
: `你刚赢下这场交锋,${params.resultText}`,
logLines,
battleOutcome:
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
battleOutcome: params.battleMode === 'spar' ? 'spar_complete' : 'victory',
};
};
@@ -353,7 +359,10 @@ export function createStoryNpcEncounterActions({
return false;
}
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
const reopenedNpcState = getResolvedNpcState(
params.nextState,
params.encounter,
);
const baseStory = buildNpcStory(
params.nextState,
playerCharacter,
@@ -365,7 +374,10 @@ export function createStoryNpcEncounterActions({
);
const fallbackChatOption =
baseChatOptions[0] ??
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
buildNpcChatOption(
params.encounter,
`继续和${params.encounter.npcName}对话`,
);
const combatContext = buildNpcBattleChatCombatContext({
battleMode: params.battleMode,
resultText: params.resultText,
@@ -381,6 +393,9 @@ export function createStoryNpcEncounterActions({
}),
params.encounter,
playerCharacter,
{
sourceState: params.nextState,
},
);
setCurrentStory(
@@ -487,7 +502,9 @@ export function createStoryNpcEncounterActions({
);
const restoredEncounter =
state.sparReturnEncounter ??
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
(state.currentEncounter?.kind === 'npc'
? state.currentEncounter
: null) ??
activeBattleHostiles[0]?.encounter ??
({
id: battleNpcId,
@@ -734,12 +751,32 @@ export function createStoryNpcEncounterActions({
const buildNpcChatFunctionOptionCatalog = (
encounter: Encounter,
playerCharacter: Character,
) =>
buildPostNpcChatOptionCatalog(encounter, playerCharacter)
sourceState: GameState = gameState,
) => {
const npcState = getResolvedNpcState(sourceState, encounter);
if (npcState.affinity < 0) {
return [];
}
const allowedFunctionIds = new Set([
'npc_help',
'npc_trade',
'npc_gift',
'npc_quest_accept',
'npc_quest_turn_in',
'npc_recruit',
'npc_chat_quest_offer_view',
'npc_chat_quest_offer_replace',
'npc_chat_quest_offer_abandon',
]);
return buildPostNpcChatOptionCatalog(encounter, playerCharacter)
.filter((option) => option.functionId !== 'battle_escape_breakout')
.filter((option) => !isNpcChatOptionForEncounter(option, encounter))
.filter((option) => option.interaction?.kind === 'npc')
.filter((option) => allowedFunctionIds.has(option.functionId))
.map(cloneNpcChatFunctionOption);
};
const toNpcChatDirectiveWithFunctionOptions = (
directive: NpcChatDirective,
@@ -747,23 +784,38 @@ export function createStoryNpcEncounterActions({
playerCharacter: Character,
options?: {
forcePlayerExit?: boolean;
sourceState?: GameState;
},
): NpcChatDirective => {
const sourceState = options?.sourceState ?? gameState;
const npcState = getResolvedNpcState(sourceState, encounter);
const functionOptions = buildNpcChatFunctionOptionCatalog(
encounter,
playerCharacter,
sourceState,
).map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
detailText: option.detailText ?? null,
action: option.interaction?.kind === 'npc' ? option.interaction.action : null,
action:
option.interaction?.kind === 'npc' ? option.interaction.action : null,
}));
const isHostileChat =
// 中文注释:只要当前 NPC 仍是负好感,本轮聊天就必须交给模型判断是否主动中止,不能只依赖场景幕 directive。
const shouldForceHostileModelChat =
npcState.affinity < 0 ||
directive?.limitReason === 'negative_affinity' ||
directive?.isHostileChat === true ||
directive?.terminationMode === 'hostile_model';
const isHostileChat =
shouldForceHostileModelChat || encounter.hostile === true;
return {
...(directive ?? {}),
turnLimit: directive?.turnLimit ?? null,
remainingTurns: directive?.remainingTurns ?? null,
limitReason:
directive?.limitReason ??
(npcState.affinity < 0 ? 'negative_affinity' : null),
terminationMode: isHostileChat ? 'hostile_model' : 'none',
isHostileChat,
terminationReason: options?.forcePlayerExit
@@ -883,10 +935,9 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
playerCharacter: Character,
) => {
const resolvedStateOptions =
collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const resolvedStateOptions = collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const currentStoryOptions = currentStory?.options ?? [];
const currentChatOptions = currentStoryOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
@@ -978,14 +1029,57 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
playerCharacter: Character,
) => {
const travelOptions = buildSceneConnectionTravelOptions(gameState);
const sceneActProgression = resolveSceneActProgression({
profile: gameState.customWorldProfile,
sceneId: gameState.currentScenePreset?.id ?? null,
storyEngineMemory: gameState.storyEngineMemory,
});
const nextSceneActState = sceneActProgression
? advanceSceneActRuntimeState({
progress: sceneActProgression,
})
: null;
if (nextSceneActState) {
const nextStoryEngineMemory = {
...normalizeStoryEngineMemoryState(gameState.storyEngineMemory),
currentSceneActState: nextSceneActState,
};
const nextState: GameState = {
...ensureSceneEncounterPreview({
...gameState,
storyEngineMemory: nextStoryEngineMemory,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
}),
};
const nextOptions =
getAvailableOptionsForState(nextState, playerCharacter) ??
buildSceneConnectionTravelOptions(nextState);
const nextActEntryOptions = nextOptions.filter(
(option) =>
option.functionId === 'npc_preview_talk' ||
option.functionId === 'npc_chat',
);
return {
deferredRuntimeState: {
currentScenePreset: gameState.currentScenePreset,
storyEngineMemory: nextStoryEngineMemory,
},
options:
nextActEntryOptions.length > 0 ? nextActEntryOptions : nextOptions,
};
}
return {
deferredRuntimeState: null,
options:
travelOptions.length > 0
? travelOptions
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
options: buildSceneConnectionTravelOptions(gameState),
};
};
@@ -1035,23 +1129,6 @@ export function createStoryNpcEncounterActions({
)
: [];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
affinity: number,
) => {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (
character: Character,
actionText = '逃跑',
@@ -1105,7 +1182,9 @@ export function createStoryNpcEncounterActions({
};
};
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
const buildHostileNpcEscapeOptions = (
character: Character,
): StoryOption[] => {
const currentScene = gameState.currentScenePreset;
const worldType = gameState.worldType;
const options: StoryOption[] = [];
@@ -1120,34 +1199,24 @@ export function createStoryNpcEncounterActions({
seenSceneIds.add(connection.sceneId);
const targetScene = getScenePresetById(worldType, connection.sceneId);
const targetSceneName =
targetScene?.name ??
connection.summary?.trim() ??
connection.sceneId;
targetScene?.name ?? connection.summary?.trim() ?? connection.sceneId;
options.push(
buildHostileNpcEscapeOption(
character,
`逃往${targetSceneName}`,
{
targetSceneId: connection.sceneId,
escapeTargetSceneId: connection.sceneId,
escapeEntry: 'from_left',
},
),
buildHostileNpcEscapeOption(character, `逃往${targetSceneName}`, {
targetSceneId: connection.sceneId,
escapeTargetSceneId: connection.sceneId,
escapeEntry: 'from_left',
}),
);
}
options.push(
buildHostileNpcEscapeOption(
character,
'逃回当前场景起点',
{
targetSceneId: currentScene.id,
escapeTargetSceneId: currentScene.id,
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
},
),
buildHostileNpcEscapeOption(character, '逃回当前场景起点', {
targetSceneId: currentScene.id,
escapeTargetSceneId: currentScene.id,
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
}),
);
}
@@ -1176,31 +1245,6 @@ export function createStoryNpcEncounterActions({
},
});
const buildHostileNpcStoryMoment = (
encounter: Encounter,
character: Character,
affinity: number,
): StoryMoment => {
const declarationText = buildHostileNpcDeclarationText(encounter, affinity);
return {
text: declarationText,
options: [
buildHostileNpcFightOption(encounter),
...buildHostileNpcEscapeOptions(character),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: declarationText,
},
],
streaming: false,
};
};
const shouldUseHostileNpcChatClosureOptions = (
directive: NpcChatDirective,
affinity: number,
@@ -1326,6 +1370,7 @@ export function createStoryNpcEncounterActions({
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
currentStory,
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
@@ -1366,17 +1411,62 @@ export function createStoryNpcEncounterActions({
throw new Error('NPC 主动开场结果为空');
}
const resolvedOpeningDirective = {
sceneActId: resolvedChatDirective?.sceneActId ?? null,
turnLimit:
chatTurn.chatDirective?.turnLimit ??
resolvedChatDirective?.turnLimit ??
null,
remainingTurns:
chatTurn.chatDirective?.remainingTurns ??
resolvedChatDirective?.remainingTurns ??
null,
limitReason: resolvedChatDirective?.limitReason ?? null,
terminationMode: resolvedChatDirective?.terminationMode ?? null,
terminationReason:
chatTurn.chatDirective?.terminationReason ??
resolvedChatDirective?.terminationReason ??
null,
isHostileChat: resolvedChatDirective?.isHostileChat ?? false,
closingMode:
chatTurn.chatDirective?.closingMode ??
resolvedChatDirective?.closingMode ??
'free',
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
resolvedChatDirective?.forceExitAfterTurn ??
false,
} satisfies NonNullable<NpcChatDirective>;
const openingDialogue = [
...existingDialogue,
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
];
if (resolvedOpeningDirective.forceExitAfterTurn) {
setCurrentStory({
text: openingDialogue.map((turn) => turn.text).join('\n'),
options: buildNpcChatClosureOptions(
encounter,
playerCharacter,
resolvedOpeningDirective,
npcState.affinity,
),
displayMode: 'dialogue',
dialogue: openingDialogue,
streaming: false,
deferredOptions: undefined,
deferredRuntimeState: undefined,
});
return true;
}
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
],
dialogue: openingDialogue,
options: buildNpcChatMixedTurnOptions(
encounter,
playerCharacter,
@@ -1387,7 +1477,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: 0,
chatDirective: resolvedChatDirective,
chatDirective: resolvedOpeningDirective,
openingSource: 'npc_initiated',
}),
);
@@ -1484,6 +1574,7 @@ export function createStoryNpcEncounterActions({
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
currentStory,
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
@@ -1594,17 +1685,17 @@ export function createStoryNpcEncounterActions({
chatDirective.remainingTurns ??
null,
limitReason: chatDirective.limitReason ?? null,
terminationMode: chatDirective.terminationMode ?? null,
terminationReason:
terminationMode: chatDirective.terminationMode ?? null,
terminationReason:
chatTurn.chatDirective?.terminationReason ??
chatDirective.terminationReason ??
null,
isHostileChat: chatDirective.isHostileChat ?? false,
closingMode:
chatTurn.chatDirective?.closingMode ??
chatDirective.closingMode ??
'free',
forceExitAfterTurn:
isHostileChat: chatDirective.isHostileChat ?? false,
closingMode:
chatTurn.chatDirective?.closingMode ??
chatDirective.closingMode ??
'free',
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
chatDirective.forceExitAfterTurn ??
false,
@@ -1615,9 +1706,7 @@ export function createStoryNpcEncounterActions({
const pendingQuestIntroText =
chatTurn.pendingQuestOffer?.introText?.trim() || '';
if (shouldForceExitAfterTurn) {
const closingDialogue = [
...nextDialogue,
];
const closingDialogue = [...nextDialogue];
const shouldUseHostileClosureOptions =
shouldUseHostileNpcChatClosureOptions(
resolvedChatDirective,
@@ -1729,6 +1818,15 @@ export function createStoryNpcEncounterActions({
);
const nextState = {
...gameState,
...(progressionResult.deferredRuntimeState?.storyEngineMemory
? {
storyEngineMemory:
progressionResult.deferredRuntimeState.storyEngineMemory,
}
: {}),
currentScenePreset:
progressionResult.deferredRuntimeState?.currentScenePreset ??
gameState.currentScenePreset,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
@@ -1736,8 +1834,6 @@ export function createStoryNpcEncounterActions({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentScenePreset: gameState.currentScenePreset,
storyEngineMemory: gameState.storyEngineMemory,
};
setGameState(nextState);
@@ -1756,13 +1852,9 @@ export function createStoryNpcEncounterActions({
return false;
}
void handleNpcChatTurn(
encounter,
`我先结束这轮交谈,继续往前走。`,
{
forcePlayerExit: true,
},
);
void handleNpcChatTurn(encounter, `我先结束这轮交谈,继续往前走。`, {
forcePlayerExit: true,
});
return true;
};
@@ -1814,33 +1906,14 @@ export function createStoryNpcEncounterActions({
},
} satisfies StoryOption);
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
}
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
setCurrentStory(
buildHostileNpcStoryMoment(
encounter,
playerCharacter,
npcState.affinity,
),
);
return true;
}
return enterNpcChat(
// 中文注释:每次从 NPC 入口新开聊天,都必须由模型生成 NPC 首句;首遇标记只用于关系结算,不再决定谁先开口。
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
};
const resolveServerNpcStoryAction = async (params: {
@@ -2091,17 +2164,14 @@ export function createStoryNpcEncounterActions({
nextTurnCount: 0,
});
if (!npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
resolvedOption,
[],
limitedChatDirective,
);
return true;
}
return enterNpcChat(encounter, resolvedOption);
// 中文注释:不在已有聊天里时,点击聊天入口也重新走 NPC 模型首句,避免回到玩家先选话题的旧分支。
void startNpcInitiatedOpening(
encounter,
resolvedOption,
[],
limitedChatDirective,
);
return true;
}
case 'quest_accept': {
void resolveServerNpcStoryAction({