This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -0,0 +1,6 @@
{
"name": "@genarrative/shared",
"private": true,
"version": "0.1.0",
"type": "module"
}

View File

@@ -0,0 +1,157 @@
export type QwenSpriteActionTemplateId =
| 'idle'
| 'run'
| 'attack_slash'
| 'hurt'
| 'die';
export type QwenSpriteActionTemplate = {
id: QwenSpriteActionTemplateId;
label: string;
loop: boolean;
defaultFps: number;
bodyTravel: string;
weaponRule: string;
sequenceLines: [string, string, string, string];
ending: string;
};
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
{
id: 'idle',
label: '待机循环',
loop: true,
defaultFps: 8,
bodyTravel: '原地',
weaponRule: '武器始终在主手,位置稳定',
sequenceLines: [
'1-4 帧:稳定站姿,轻微呼吸起伏',
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
'9-12 帧:呼气回落,重心恢复',
'13-16 帧:逐渐回到与首帧接近的站姿',
],
ending: '第 16 帧自然衔接第 1 帧',
},
{
id: 'run',
label: '奔跑循环',
loop: true,
defaultFps: 12,
bodyTravel: '小幅前移但角色中心基本固定',
weaponRule: '武器始终在主手,不换手',
sequenceLines: [
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
],
ending: '第 16 帧能无缝接回第 1 帧',
},
{
id: 'attack_slash',
label: '横斩攻击',
loop: false,
defaultFps: 12,
bodyTravel: '中幅前探',
weaponRule: '右手持武器,始终右手,不换手',
sequenceLines: [
'1-4 帧:轻微收身蓄力,武器向后收',
'5-8 帧:重心前压,挥击开始',
'9-12 帧:斩击达到最大幅度,动作力量最强',
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
],
ending: '第 16 帧停在收招后稳定姿态',
},
{
id: 'hurt',
label: '受击后仰',
loop: false,
defaultFps: 10,
bodyTravel: '原地或极小后仰',
weaponRule: '武器不要脱手,不要换手',
sequenceLines: [
'1-4 帧:突然受击,头肩后仰',
'5-8 帧:身体失衡最明显',
'9-12 帧:手臂和武器随惯性摆动',
'13-16 帧:逐渐恢复到勉强站稳的姿态',
],
ending: '第 16 帧能接回 idle 或下一个动作',
},
{
id: 'die',
label: '倒地死亡',
loop: false,
defaultFps: 8,
bodyTravel: '明显倒地位移',
weaponRule: '武器不可瞬间消失',
sequenceLines: [
'1-4 帧:受创失衡,重心被打断',
'5-8 帧:身体明显下坠或后仰',
'9-12 帧:倒地过程完成,动作幅度最大',
'13-16 帧:停在清晰的终止姿态',
],
ending: '第 16 帧停在死亡结束姿态,不需要循环',
},
];
const CHIBI_STYLE_TEXT =
'Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
const PIXEL_STYLE_TEXT =
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
const STYLE_REFERENCE_SCOPE_TEXT =
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
const CONCEPT_INTERPRETATION_TEXT =
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
const HUMANLIKE_PRIORITY_TEXT =
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。';
const JELLYFISH_THEME_EXAMPLE_TEXT =
'示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。';
const CONCEPT_HIERARCHY_TEXT =
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
const CHIBI_CHARACTER_TEXT =
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。';
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
return (
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
QWEN_SPRITE_ACTION_TEMPLATES[0]
);
}
export function buildMasterPrompt(characterBrief: string) {
return [
'单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
'画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
CONCEPT_INTERPRETATION_TEXT,
HUMANLIKE_PRIORITY_TEXT,
CONCEPT_HIERARCHY_TEXT,
JELLYFISH_THEME_EXAMPLE_TEXT,
characterBrief.trim(),
]
.filter(Boolean)
.join('\n');
}
export function buildVideoActionPrompt(options: {
actionTemplate: QwenSpriteActionTemplate;
actionDetailText: string;
useChromaKey: boolean;
characterBrief: string;
}) {
return [
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}`,
`角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
CONCEPT_INTERPRETATION_TEXT,
HUMANLIKE_PRIORITY_TEXT,
CONCEPT_HIERARCHY_TEXT,
JELLYFISH_THEME_EXAMPLE_TEXT,
`动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`,
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
: '背景简洁纯净,无复杂场景。',
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
`角色设定:${options.characterBrief.trim()}`,
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
].join(' ');
}

View File

@@ -0,0 +1,158 @@
export type AuthBindingStatus = 'active' | 'pending_bind_phone';
export type AuthLoginMethod = 'password' | 'phone' | 'wechat';
export type AuthUser = {
id: string;
username: string;
displayName: string;
phoneNumberMasked: string | null;
loginMethod: AuthLoginMethod;
bindingStatus: AuthBindingStatus;
wechatBound: boolean;
};
export type AuthEntryRequest = {
username: string;
password: string;
};
export type AuthEntryResponse = {
token: string;
user: AuthUser;
};
export type AuthPhoneSendCodeRequest = {
phone: string;
scene?: 'login' | 'bind_phone' | 'change_phone';
captchaChallengeId?: string;
captchaAnswer?: string;
};
export type AuthPhoneSendCodeResponse = {
ok: true;
cooldownSeconds: number;
expiresInSeconds: number;
providerRequestId: string | null;
};
export type AuthPhoneLoginRequest = {
phone: string;
code: string;
};
export type AuthPhoneLoginResponse = {
token: string;
user: AuthUser;
};
export type AuthMeResponse = {
user: AuthUser | null;
availableLoginMethods: AuthLoginMethod[];
};
export type AuthWechatStartResponse = {
authorizationUrl: string;
};
export type AuthWechatBindPhoneRequest = {
phone: string;
code: string;
};
export type AuthWechatBindPhoneResponse = {
token: string;
user: AuthUser;
};
export type AuthPhoneChangeRequest = {
phone: string;
code: string;
};
export type AuthPhoneChangeResponse = {
user: AuthUser;
};
export type AuthRefreshResponse = {
token: string;
};
export type AuthSessionSummary = {
sessionId: string;
clientType: string;
clientLabel: string;
userAgent: string | null;
ipMasked: string | null;
isCurrent: boolean;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
};
export type AuthSessionsResponse = {
sessions: AuthSessionSummary[];
};
export type AuthLogoutAllResponse = {
ok: true;
};
export type AuthRevokeSessionResponse = {
ok: true;
};
export type AuthAuditLogEventType =
| 'password_login'
| 'phone_login'
| 'wechat_login'
| 'wechat_bind_phone'
| 'change_phone'
| 'captcha_required'
| 'logout'
| 'logout_all'
| 'revoke_session'
| 'risk_block_phone'
| 'risk_block_ip'
| 'risk_unblock_phone'
| 'risk_unblock_ip';
export type AuthAuditLogEntry = {
id: string;
eventType: AuthAuditLogEventType;
title: string;
detail: string;
ipMasked: string | null;
userAgent: string | null;
createdAt: string;
};
export type AuthAuditLogsResponse = {
logs: AuthAuditLogEntry[];
};
export type AuthCaptchaChallenge = {
challengeId: string;
promptText: string;
imageDataUrl: string;
expiresInSeconds: number;
};
export type AuthRiskBlockSummary = {
scopeType: 'phone' | 'ip';
title: string;
detail: string;
expiresAt: string;
remainingSeconds: number;
};
export type AuthRiskBlocksResponse = {
blocks: AuthRiskBlockSummary[];
};
export type AuthLiftRiskBlockResponse = {
ok: true;
};
export type LogoutResponse = {
ok: true;
};

View File

@@ -0,0 +1,3 @@
export type JsonObject = Record<string, unknown>;
export type JsonArray = unknown[];

View File

@@ -0,0 +1,124 @@
import type { JsonObject } from './common';
export const SAVE_SNAPSHOT_VERSION = 2;
export const DEFAULT_MUSIC_VOLUME = 0.42;
export type SavedGameSnapshot<
TGameState = unknown,
TBottomTab extends string = string,
TCurrentStory = unknown,
> = {
version: number;
savedAt: string;
gameState: TGameState;
bottomTab: TBottomTab;
currentStory: TCurrentStory | null;
};
export type SavedGameSnapshotInput<
TGameState = unknown,
TBottomTab extends string = string,
TCurrentStory = unknown,
> = Omit<
SavedGameSnapshot<TGameState, TBottomTab, TCurrentStory>,
'savedAt' | 'version'
> & {
savedAt?: string;
};
export type RuntimeSettings = {
musicVolume: number;
};
export type BasicOkResult = {
ok: true;
};
export type CustomWorldProfileRecord = JsonObject & {
id?: string;
};
export type CustomWorldLibraryResponse<
TProfile = CustomWorldProfileRecord,
> = {
profiles: TProfile[];
};
export const CUSTOM_WORLD_GENERATION_MODES = ['fast', 'full'] as const;
export type CustomWorldGenerationMode =
(typeof CUSTOM_WORLD_GENERATION_MODES)[number];
export const CUSTOM_WORLD_SESSION_STATUSES = [
'clarifying',
'ready_to_generate',
'generating',
'completed',
'generation_error',
] as const;
export type CustomWorldSessionStatus =
(typeof CUSTOM_WORLD_SESSION_STATUSES)[number];
export type CustomWorldQuestion = {
id: string;
label: string;
question: string;
answer?: string;
};
export type CustomWorldGenerationStep = {
id: string;
label: string;
detail: string;
completed: number;
total: number;
status: 'pending' | 'active' | 'completed';
};
export type CustomWorldGenerationProgress = {
phaseId: string;
phaseLabel: string;
phaseDetail: string;
batchLabel?: string;
overallProgress: number;
completedWeight: number;
totalWeight: number;
elapsedMs: number;
estimatedRemainingMs: number | null;
activeStepIndex: number;
steps: CustomWorldGenerationStep[];
};
export type GenerateCustomWorldProfileOptions = {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
};
export type GenerateCustomWorldProfileInput = {
settingText: string;
creatorIntent?: JsonObject | null;
generationMode?: CustomWorldGenerationMode;
};
export type CreateCustomWorldSessionRequest = {
settingText: string;
creatorIntent?: JsonObject | null;
generationMode: CustomWorldGenerationMode;
};
export type AnswerCustomWorldSessionQuestionRequest = {
questionId: string;
answer: string;
};
export type CustomWorldSessionSummary = {
sessionId: string;
status: CustomWorldSessionStatus;
questions: CustomWorldQuestion[];
};
export type CustomWorldSessionRecord = CustomWorldSessionSummary & {
settingText: string;
generationMode: CustomWorldGenerationMode;
result?: JsonObject;
lastError?: string;
};

View File

@@ -0,0 +1,408 @@
import type { JsonObject } from './common';
import type { SavedGameSnapshot } from './runtime';
export const QUEST_NARRATIVE_TYPES = [
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
] as const;
export type SharedQuestNarrativeType =
(typeof QUEST_NARRATIVE_TYPES)[number];
export const QUEST_OBJECTIVE_KINDS = [
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
] as const;
export type SharedQuestObjectiveKind =
(typeof QUEST_OBJECTIVE_KINDS)[number];
export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const;
export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number];
export const QUEST_INTIMACY_LEVELS = [
'transactional',
'cooperative',
'trust_based',
] as const;
export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number];
export const QUEST_REWARD_THEMES = [
'currency',
'resource',
'relationship',
'intel',
'rare_item',
] as const;
export type SharedQuestRewardTheme =
(typeof QUEST_REWARD_THEMES)[number];
export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [
'heal',
'mana',
'cooldown',
'guard',
'damage',
] as const;
export type SharedRuntimeItemFunctionalBias =
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
export const RUNTIME_ITEM_TONE_VALUES = [
'grim',
'mysterious',
'martial',
'ritual',
'survival',
] as const;
export type SharedRuntimeItemTone =
(typeof RUNTIME_ITEM_TONE_VALUES)[number];
export type StoryRequestOptionsPayload = {
availableOptions?: JsonObject[];
optionCatalog?: JsonObject[];
};
export type StoryRequestPayload<TWorldType extends string = string> = {
worldType: TWorldType;
character: JsonObject;
monsters?: JsonObject[];
history?: JsonObject[];
choice?: string;
context: JsonObject;
requestOptions?: StoryRequestOptionsPayload;
};
export type PlainTextPromptRequest = {
systemPrompt: string;
userPrompt: string;
};
export type PlainTextResponse = {
text: string;
};
export type CharacterChatReplyRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
conversationSummary: string;
playerMessage: string;
targetStatus: TTargetStatus;
};
export type CharacterChatSuggestionsRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
conversationSummary: string;
targetStatus: TTargetStatus;
};
export type CharacterChatSummaryRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
previousSummary: string;
targetStatus: TTargetStatus;
};
export type NpcChatDialogueRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
> = {
worldType: string;
character: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
topic: string;
resultSummary: string;
};
export type NpcRecruitDialogueRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
> = {
worldType: string;
character: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
invitationText: string;
recruitSummary: string;
};
export type RuntimeItemIntentRequest<
TContext = JsonObject,
TPlan = JsonObject,
> = {
context: TContext;
plans: TPlan[];
};
export type RuntimeItemIntentResponse<TIntent = JsonObject> = {
intents: TIntent[];
};
export type QuestGenerationRequest<
TState = JsonObject,
TEncounter = JsonObject,
> = {
state: TState;
encounter: TEncounter;
};
export type RuntimeAction<
TType extends string = string,
TPayload = JsonObject,
> = {
type: TType;
functionId?: string;
targetId?: string;
payload?: TPayload;
};
export type RuntimeActionRequest<
TAction extends RuntimeAction = RuntimeAction,
> = {
sessionId: string;
clientVersion?: number;
action: TAction;
};
export type RuntimeActionResponse<
TViewModel = JsonObject,
TPresentation = JsonObject,
TPatch = JsonObject,
> = {
sessionId: string;
serverVersion: number;
viewModel: TViewModel;
presentation: TPresentation;
patches: TPatch[];
};
export const TASK5_RUNTIME_FUNCTION_IDS = [
'story_continue_adventure',
'story_opening_camp_dialogue',
'camp_travel_home_scene',
'idle_call_out',
'idle_explore_forward',
'idle_observe_signs',
'idle_rest_focus',
'idle_travel_next_scene',
'battle_all_in_crush',
'battle_escape_breakout',
'battle_feint_step',
'battle_finisher_window',
'battle_guard_break',
'battle_probe_pressure',
'battle_recover_breath',
'npc_chat',
'npc_fight',
'npc_help',
'npc_leave',
'npc_preview_talk',
'npc_recruit',
'npc_spar',
] as const;
export type Task5RuntimeFunctionId =
(typeof TASK5_RUNTIME_FUNCTION_IDS)[number];
export const TASK6_RUNTIME_FUNCTION_IDS = [
'equipment_equip',
'equipment_unequip',
'forge_craft',
'forge_dismantle',
'forge_reforge',
'inventory_use',
'npc_gift',
'npc_quest_accept',
'npc_quest_turn_in',
'npc_trade',
'treasure_inspect',
'treasure_leave',
'treasure_secure',
] as const;
export type Task6RuntimeFunctionId =
(typeof TASK6_RUNTIME_FUNCTION_IDS)[number];
export const SERVER_RUNTIME_FUNCTION_IDS = [
...TASK5_RUNTIME_FUNCTION_IDS,
...TASK6_RUNTIME_FUNCTION_IDS,
] as const;
export type ServerRuntimeFunctionId =
(typeof SERVER_RUNTIME_FUNCTION_IDS)[number];
export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const;
export type Task5RuntimeOptionScope =
(typeof TASK5_RUNTIME_OPTION_SCOPES)[number];
export type RuntimeStoryChoicePayload = JsonObject & {
optionText?: string;
note?: string;
};
export type RuntimeStoryChoiceAction = RuntimeAction<
'story_choice',
RuntimeStoryChoicePayload
> & {
functionId: string;
targetId?: string;
};
export type RuntimeStoryOptionView = {
functionId: string;
actionText: string;
detailText?: string;
scope: Task5RuntimeOptionScope;
disabled?: boolean;
reason?: string;
};
export type RuntimeStoryPlayerViewModel = {
hp: number;
maxHp: number;
mana: number;
maxMana: number;
};
export type RuntimeStoryCompanionViewModel = {
npcId: string;
characterId?: string;
joinedAtAffinity: number;
};
export type RuntimeStoryEncounterViewModel = {
id: string;
kind: 'npc' | 'treasure';
npcName: string;
hostile: boolean;
affinity?: number;
recruited?: boolean;
interactionActive: boolean;
battleMode?: 'fight' | 'spar' | null;
};
export type RuntimeStoryStatusViewModel = {
inBattle: boolean;
npcInteractionActive: boolean;
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
export type RuntimeBattlePresentation = {
targetId?: string;
targetName?: string;
damageDealt?: number;
damageTaken?: number;
outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
};
export type RuntimeStoryViewModel = {
player: RuntimeStoryPlayerViewModel;
encounter: RuntimeStoryEncounterViewModel | null;
companions: RuntimeStoryCompanionViewModel[];
availableOptions: RuntimeStoryOptionView[];
status: RuntimeStoryStatusViewModel;
};
export type RuntimeStoryPresentation = {
actionText: string;
resultText: string;
storyText: string;
options: RuntimeStoryOptionView[];
toast?: string | null;
battle?: RuntimeBattlePresentation | null;
};
export type RuntimeStoryPatch =
| {
type: 'story_history_append';
actionText: string;
resultText: string;
}
| {
type: 'npc_affinity_changed';
npcId: string;
previousAffinity: number;
nextAffinity: number;
}
| {
type: 'battle_resolved';
functionId: string;
targetId?: string;
damageDealt?: number;
damageTaken?: number;
outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
}
| {
type: 'status_changed';
inBattle: boolean;
npcInteractionActive: boolean;
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
}
| {
type: 'encounter_changed';
encounterId: string | null;
};
export type RuntimeStoryActionRequest =
RuntimeActionRequest<RuntimeStoryChoiceAction>;
export type RuntimeStoryActionResponse<
TSnapshotGameState = JsonObject,
TSnapshotCurrentStory = JsonObject,
> = RuntimeActionResponse<
RuntimeStoryViewModel,
RuntimeStoryPresentation,
RuntimeStoryPatch
> & {
snapshot: SavedGameSnapshot<
TSnapshotGameState,
string,
TSnapshotCurrentStory
>;
};

199
packages/shared/src/http.ts Normal file
View File

@@ -0,0 +1,199 @@
export const API_VERSION = '2026-04-08';
export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
export const API_RESPONSE_ENVELOPE_VERSION = 'v1';
export type ApiErrorCode =
| 'BAD_REQUEST'
| 'INVALID_REQUEST'
| 'VALIDATION_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'UPSTREAM_ERROR'
| 'INTERNAL_SERVER_ERROR'
| 'bad_request'
| 'validation_error'
| 'unauthorized'
| 'forbidden'
| 'not_found'
| 'conflict'
| 'upstream_error'
| 'internal_error'
| (string & {});
export type ApiErrorPayload = {
code: ApiErrorCode;
message: string;
details?: Record<string, unknown> | null;
};
export type ApiMeta = {
apiVersion: string;
requestId?: string;
routeVersion?: string;
operation?: string | null;
latencyMs?: number;
timestamp?: string;
};
export type ApiSuccessResponse<T> = {
ok: true;
data: T;
error: null;
meta: ApiMeta;
};
export type ApiErrorResponse = {
ok: false;
data: null;
error: ApiErrorPayload;
meta: ApiMeta;
};
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function buildApiMeta(meta: Partial<ApiMeta> = {}): ApiMeta {
return {
apiVersion: meta.apiVersion ?? API_VERSION,
requestId:
typeof meta.requestId === 'string' && meta.requestId.trim()
? meta.requestId.trim()
: undefined,
routeVersion:
typeof meta.routeVersion === 'string' && meta.routeVersion.trim()
? meta.routeVersion.trim()
: undefined,
operation:
typeof meta.operation === 'string' && meta.operation.trim()
? meta.operation.trim()
: meta.operation === null
? null
: undefined,
latencyMs:
typeof meta.latencyMs === 'number' && Number.isFinite(meta.latencyMs)
? meta.latencyMs
: undefined,
timestamp:
typeof meta.timestamp === 'string' && meta.timestamp.trim()
? meta.timestamp.trim()
: undefined,
};
}
export function createApiSuccess<T>(
data: T,
meta: Partial<ApiMeta> = {},
): ApiSuccessResponse<T> {
return {
ok: true,
data,
error: null,
meta: buildApiMeta(meta),
};
}
export function createApiError(
error: ApiErrorPayload,
meta: Partial<ApiMeta> = {},
): ApiErrorResponse {
return {
ok: false,
data: null,
error: {
code: error.code,
message: error.message,
details: error.details ?? null,
},
meta: buildApiMeta(meta),
};
}
export function isApiResponse<T>(value: unknown): value is ApiResponse<T> {
if (!isRecord(value) || typeof value.ok !== 'boolean' || !('meta' in value)) {
return false;
}
if (!isRecord(value.meta) || typeof value.meta.apiVersion !== 'string') {
return false;
}
if (value.ok) {
return 'data' in value && value.error === null;
}
return (
value.data === null &&
isRecord(value.error) &&
typeof value.error.code === 'string' &&
typeof value.error.message === 'string'
);
}
export function unwrapApiResponse<T>(value: ApiResponse<T> | T): T {
if (!isApiResponse<T>(value)) {
return value as T;
}
if (value.ok) {
return value.data;
}
throw new Error(value.error.message || '请求失败');
}
export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
if (!rawText.trim()) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(rawText) as
| ApiErrorResponse
| {
error?: {
message?: string;
code?: string;
};
message?: string;
code?: string;
};
if (
typeof parsed.error?.message === 'string' &&
parsed.error.message.trim()
) {
return parsed.error.message.trim();
}
const topLevelMessage =
'message' in parsed && typeof parsed.message === 'string'
? parsed.message.trim()
: '';
if (topLevelMessage) {
return topLevelMessage;
}
const errorCode =
typeof parsed.error?.code === 'string' && parsed.error.code.trim()
? parsed.error.code.trim()
: 'code' in parsed &&
typeof parsed.code === 'string' &&
parsed.code.trim()
? parsed.code.trim()
: '';
if (errorCode) {
return `${fallbackMessage}${errorCode}`;
}
} catch {
// Ignore malformed json responses.
}
return rawText.trim() || fallbackMessage;
}

View File

@@ -0,0 +1,8 @@
export * from './assets/qwenSprite';
export * from './contracts/auth';
export * from './contracts/common';
export * from './contracts/runtime';
export * from './contracts/story';
export * from './http';
export * from './llm/narrativeLanguage';
export * from './llm/parsers';

View File

@@ -0,0 +1,66 @@
const CJK_CHAR_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/gu;
const LATIN_WORD_PATTERN = /[A-Za-z][A-Za-z'-]{1,}/g;
const LATIN_FRAGMENT_PATTERN =
/[A-Za-z][A-Za-z0-9'"()\-,:;!?/]*(?:\s+[A-Za-z0-9'"()\-,:;!?/]+)+/gu;
const SAFE_LATIN_TOKENS = new Set([
'act',
'ai',
'boss',
'cd',
'hp',
'json',
'llm',
'mp',
'npc',
'qa',
'rpg',
]);
function getCjkCharCount(text: string) {
return text.match(CJK_CHAR_PATTERN)?.length ?? 0;
}
function getSignificantLatinWords(text: string) {
return (text.match(LATIN_WORD_PATTERN) ?? [])
.map((word) => word.toLowerCase())
.filter((word) => word.length >= 4 && !SAFE_LATIN_TOKENS.has(word));
}
export function hasMixedNarrativeLanguage(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return false;
}
const cjkCharCount = getCjkCharCount(trimmed);
const latinSentenceFragments = (trimmed.match(LATIN_FRAGMENT_PATTERN) ?? [])
.map((fragment) => fragment.trim())
.filter((fragment) => fragment.split(/\s+/u).length >= 2);
const significantLatinWords = getSignificantLatinWords(trimmed);
if (latinSentenceFragments.length > 0) {
return true;
}
if (cjkCharCount > 0 && significantLatinWords.length >= 2) {
return true;
}
return cjkCharCount === 0 && significantLatinWords.length >= 3;
}
export function sanitizePromptNarrativeText(
text: string | null | undefined,
fallback: string | null = null,
) {
if (typeof text !== 'string') {
return fallback;
}
const trimmed = text.trim();
if (!trimmed) {
return fallback;
}
return hasMixedNarrativeLanguage(trimmed) ? fallback : trimmed;
}

View File

@@ -0,0 +1,28 @@
export function parseJsonResponseText(text: string) {
const trimmed = text.trim();
if (!trimmed) {
throw new Error('LLM returned an empty response.');
}
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
if (fencedMatch?.[1]) {
return JSON.parse(fencedMatch[1].trim());
}
const firstBrace = trimmed.indexOf('{');
const lastBrace = trimmed.lastIndexOf('}');
if (firstBrace >= 0 && lastBrace > firstBrace) {
return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
}
return JSON.parse(trimmed);
}
export function parseLineListContent(text: string, maxItems = 3) {
return text
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim().replace(/^[-*\d.)\s]+/u, '').trim())
.filter(Boolean)
.slice(0, maxItems);
}