init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,333 @@
import {
buildSchemaSummary,
describeTopAttributes,
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import {
buildCharacterBackstoryPromptContext,
getCharacterPublicBackstorySummary,
getLockedCharacterBackstoryChapters,
} from '../data/characterPresets';
import {
AnimationState,
Character,
CharacterChatTurn,
CustomWorldProfile,
FacingDirection,
StoryMoment,
WorldType,
} from '../types';
import { buildCustomWorldReferenceText } from '../services/customWorld';
import { buildStoryPromptHistory } from '../services/storyHistory';
export interface CharacterChatTargetStatus {
roleLabel?: string | null;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
affinity?: number | null;
}
export interface CharacterChatPromptContext {
playerHp: number;
playerMaxHp: number;
playerMana: number;
playerMaxMana: number;
inBattle: boolean;
playerFacing: FacingDirection;
playerAnimation: AnimationState;
sceneName?: string | null;
sceneDescription?: string | null;
customWorldProfile?: CustomWorldProfile | null;
}
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
只回复这名角色此刻会对玩家说的话。
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
只输出纯文本,共 3 行,每行一条。
不要加编号、项目符号、Markdown 或额外说明。
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
只输出一段简洁文字。
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
function describeWorld(world: WorldType) {
if (world === WorldType.WUXIA) return '边城模板';
if (world === WorldType.XIANXIA) return '灵潮模板';
return '自定义世界';
}
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
return customWorldProfile
? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}`
: null;
}
function describeGender(gender: Character['gender']) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未知';
}
function describeFacing(facing: FacingDirection) {
return facing === 'left' ? '左' : '右';
}
function describeHpBand(ratio: number) {
if (ratio >= 0.95) return '几乎无伤';
if (ratio >= 0.75) return '状态稳健';
if (ratio >= 0.55) return '略有消耗';
if (ratio >= 0.35) return '伤势明显';
if (ratio >= 0.15) return '伤势沉重';
return '濒临极限';
}
function describeManaBand(ratio: number) {
if (ratio >= 0.9) return '充盈';
if (ratio >= 0.7) return '稳定';
if (ratio >= 0.45) return '尚可';
if (ratio >= 0.2) return '偏低';
if (ratio > 0) return '接近枯竭';
return '耗尽';
}
function describeStoryHistory(history: StoryMoment[]) {
const promptHistory = buildStoryPromptHistory(history);
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
return '近期剧情:暂无。';
}
return [
promptHistory.previousSummary
? `更早剧情摘要:\n${promptHistory.previousSummary}`
: '更早剧情摘要:暂无。',
promptHistory.recentOriginalRounds.length > 0
? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds
.map((item, index) => `- 第 ${index + 1} 轮:\n${item}`)
.join('\n')}`
: '最近 3 轮剧情:暂无。',
].join('\n');
}
function describeBackstoryContext(label: string, snippets: string[]) {
const normalized = snippets
.map(snippet => snippet.trim())
.filter(Boolean);
if (normalized.length === 0) {
return [`${label}:暂无公开信息。`];
}
return normalized.map((snippet, index) =>
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index}`}${snippet}`,
);
}
function describeCharacterInfo(
label: string,
character: Character,
world: WorldType,
customWorldProfile?: CustomWorldProfile | null,
options: {
affinity?: number | null;
includeUnlockProgress?: boolean;
} = {},
) {
const schema = resolveAttributeSchema(world, customWorldProfile);
const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile);
const skills = character.skills.length > 0
? character.skills
.map(
skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`,
)
.join(' | ')
: '无';
const backgroundLines = options.affinity == null
? [getCharacterPublicBackstorySummary(character, world)]
: buildCharacterBackstoryPromptContext(character, options.affinity, world);
const nextLockedChapter = options.includeUnlockProgress && options.affinity != null
? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null
: null;
const schemaSummary = buildSchemaSummary(schema)
.map(slot => `${slot.name}${slot.definition}`)
.join(' | ');
const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无';
const attributeDetails = formatAttributeList(attributeProfile, schema)
.map(entry => `${entry.slot.name} ${entry.value}`)
.join(' | ');
return [
`${label}姓名:${character.name}`,
`${label}称号:${character.title}`,
`${label}性别:${describeGender(character.gender ?? 'unknown')}`,
`${label}描述:${character.description}`,
...describeBackstoryContext(`${label}背景`, backgroundLines),
nextLockedChapter
? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser}`
: null,
`${label}性格:${character.personality}`,
`${label}世界属性框架:${schemaSummary}`,
`${label}主要属性:${topAttributes}`,
`${label}属性详情:${attributeDetails}`,
`${label}技能:${skills}`,
].join('\n');
}
function describeChatContext(world: WorldType, context: CharacterChatPromptContext) {
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
return [
`世界:${describeWorld(world)}`,
`玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`,
`场景:${context.sceneName ?? '当前区域'}`,
`场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`,
`玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`,
].join('\n');
}
function describeTargetStatus(status: CharacterChatTargetStatus) {
const hpRatio = status.hp / Math.max(status.maxHp, 1);
const manaRatio = status.mana / Math.max(status.maxMana, 1);
return [
`对方身份:${status.roleLabel ?? '同行角色'}`,
`对方状态:生命 ${status.hp}/${status.maxHp}${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}${describeManaBand(manaRatio)}`,
status.affinity != null ? `当前好感:${status.affinity}` : null,
].filter(Boolean).join('\n');
}
function describeCharacterChatHistory(history: CharacterChatTurn[]) {
if (history.length === 0) {
return '聊天记录:暂无。';
}
return [
'聊天记录:',
...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}${turn.text}`),
].join('\n');
}
export function buildCharacterPanelChatPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
}: {
world: WorldType;
playerCharacter: Character;
targetCharacter: Character;
storyHistory: StoryMoment[];
context: CharacterChatPromptContext;
conversationHistory: CharacterChatTurn[];
conversationSummary: string;
playerMessage: string;
targetStatus: CharacterChatTargetStatus;
}) {
return [
`世界:${describeWorld(world)}`,
describeChatContext(world, context),
describeCustomWorldSection(context.customWorldProfile),
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
affinity: targetStatus.affinity ?? null,
includeUnlockProgress: true,
}),
describeTargetStatus(targetStatus),
describeStoryHistory(storyHistory),
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
describeCharacterChatHistory(conversationHistory),
`玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`,
`现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`,
].filter(Boolean).join('\n\n');
}
export function buildCharacterPanelChatSuggestionPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
targetStatus,
}: {
world: WorldType;
playerCharacter: Character;
targetCharacter: Character;
storyHistory: StoryMoment[];
context: CharacterChatPromptContext;
conversationHistory: CharacterChatTurn[];
conversationSummary: string;
targetStatus: CharacterChatTargetStatus;
}) {
const latestCharacterReply = [...conversationHistory]
.reverse()
.find(turn => turn.speaker === 'character')?.text ?? null;
return [
`世界:${describeWorld(world)}`,
describeChatContext(world, context),
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
affinity: targetStatus.affinity ?? null,
includeUnlockProgress: true,
}),
describeTargetStatus(targetStatus),
describeStoryHistory(storyHistory),
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
describeCharacterChatHistory(conversationHistory),
latestCharacterReply
? `角色刚刚的回复:${latestCharacterReply}`
: `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`,
'生成 3 条可以直接发送的简短玩家回复候选。',
].filter(Boolean).join('\n\n');
}
export function buildCharacterPanelChatSummaryPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
previousSummary,
targetStatus,
}: {
world: WorldType;
playerCharacter: Character;
targetCharacter: Character;
storyHistory: StoryMoment[];
context: CharacterChatPromptContext;
conversationHistory: CharacterChatTurn[];
previousSummary: string;
targetStatus: CharacterChatTargetStatus;
}) {
return [
`世界:${describeWorld(world)}`,
describeChatContext(world, context),
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
affinity: targetStatus.affinity ?? null,
includeUnlockProgress: true,
}),
describeTargetStatus(targetStatus),
describeStoryHistory(storyHistory),
previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。',
describeCharacterChatHistory(conversationHistory),
'请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。',
].filter(Boolean).join('\n\n');
}

View File

@@ -0,0 +1,32 @@
import type {
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldRoleSkill,
} from '../types';
export function buildSkillActionPrompt(params: {
role: Pick<
CustomWorldPlayableNpc | CustomWorldNpc,
| 'name'
| 'title'
| 'role'
| 'description'
| 'backstory'
| 'personality'
| 'motivation'
>;
skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>;
}) {
const { role, skill } = params;
return [
`${role.name}${role.title || role.role}`,
`技能名称:${skill.name}`,
skill.summary ? `技能表现:${skill.summary}` : '',
role.description ? `角色气质:${role.description}` : '',
role.personality ? `性格补充:${role.personality}` : '',
role.motivation ? `动作目标:${role.motivation}` : '',
'横版 RPG 角色技能动作,角色轮廓稳定,动作起手明确,过程连贯,收招干净,镜头稳定。',
]
.filter(Boolean)
.join(' ');
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { buildCustomWorldRoleOutlineBatchPrompt } from './customWorldPrompts';
const framework = {
settingText: '潮雾封锁的边境港城,旧灯塔下藏着失踪船队的线索。',
name: '潮雾港',
subtitle: '旧灯塔仍在雾里亮着',
summary: '玩家需要在港城各方势力间找到失踪船队真相。',
tone: '潮湿、悬疑、克制',
playerGoal: '找回失踪船队并决定港城秩序的走向。',
templateWorldType: 'custom',
compatibilityTemplateWorldType: 'custom',
majorFactions: ['守灯人', '走私船帮'],
coreConflicts: ['旧航道真相', '港城权力交接'],
camp: {
name: '旧灯塔营地',
description: '潮雾里的临时归处。',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
describe('buildCustomWorldRoleOutlineBatchPrompt', () => {
it('requires model-generated visual descriptions for role drafts', () => {
const prompt = buildCustomWorldRoleOutlineBatchPrompt({
framework,
roleType: 'playable',
batchCount: 2,
});
expect(prompt).toContain('"visualDescription"');
expect(prompt).toContain('"actionDescription"');
expect(prompt).toContain('"sceneVisualDescription"');
expect(prompt).toContain('visualDescription 必须跟随本步骤直接生成');
expect(prompt).toContain('不能复制 description');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
/**
* 自定义世界角色资产工坊的“默认描述文本种子”主源。
*
* 这份脚本只负责一件事:
* - 从当前角色对象已有字段里挑出最合适的文本,
* 作为资产工坊输入框的初始默认值
*
* 它不负责:
* - 直接调用 LLM 重新编译默认描述
* - 直接生成图像模型 prompt
* - 直接生成动作模型 prompt
*
* 当前真实调用状态:
* - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件
* - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述”
* 当前直接取这里的本地字段映射
*/
export type PromptDefaultRole = {
name: string;
title: string;
role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
};
export type CustomWorldRolePromptBundle = {
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
};
/**
* 对角色字段做轻量清洗,确保作为输入框默认值时不会带多余空白。
*/
function cleanSeedText(value: string | undefined, maxLength: number) {
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
/**
* 按优先级选择第一条可用文本。
*
* 这里是非常轻量的本地回退逻辑,不做任何“重新创作”或 prompt 扩写。
*/
function pickFirstDescription(
values: Array<string | undefined>,
maxLength: number,
) {
for (const value of values) {
const normalized = cleanSeedText(value, maxLength);
if (normalized) {
return normalized;
}
}
return '';
}
/**
* 资产工坊默认文本映射规则。
*
* 规则分层:
* - visualPromptText: 优先使用角色 visualDescription其次 description
* - animationPromptText: 优先使用 actionDescription其次 combatStyle
* - scenePromptText: 优先使用 sceneVisualDescription其次 backstory
*
* 注意:
* - 返回值只是“输入框默认文案”
* - 正式图像 / 动作模型 prompt 还会在后端继续编译
*/
export function buildDefaultRolePromptBundle(
role: PromptDefaultRole,
): CustomWorldRolePromptBundle {
return {
visualPromptText: pickFirstDescription(
[role.visualDescription, role.description],
220,
),
animationPromptText: pickFirstDescription(
[role.actionDescription, role.combatStyle],
180,
),
scenePromptText: pickFirstDescription(
[role.sceneVisualDescription, role.backstory],
220,
),
};
}

File diff suppressed because it is too large Load Diff