diff --git a/.codex-logs/dev.err b/.codex-logs/dev.err deleted file mode 100644 index e69de29b..00000000 diff --git a/.codex-logs/dev.out b/.codex-logs/dev.out deleted file mode 100644 index e69de29b..00000000 diff --git a/.codex-logs/restore-backups/2026-03-29/customWorld.ts.broken b/.codex-logs/restore-backups/2026-03-29/customWorld.ts.broken deleted file mode 100644 index 720e55ad..00000000 --- a/.codex-logs/restore-backups/2026-03-29/customWorld.ts.broken +++ /dev/null @@ -1,470 +0,0 @@ -import { - CustomWorldItem, - CustomWorldLandmark, - CustomWorldNpc, - CustomWorldPlayableNpc, - CustomWorldProfile, - ItemRarity, - WorldType, -} from '../types'; - -const CUSTOM_WORLD_RARITIES: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary']; - -export const CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT = `浣犳槸鍍忕礌鍔ㄤ綔 RPG 鐨勪笘鐣岃璁捐甯堛€備綘鍙兘杈撳嚭 JSON 瀵硅薄锛屼笉鑳借緭鍑鸿В閲娿€丮arkdown 鎴栦唬鐮佸潡銆? - -杈撳嚭鏍煎紡蹇呴』涓ユ牸绗﹀悎锛? -{ - "name": "涓栫晫鍚嶇О", - "subtitle": "涓栫晫鍓爣棰?, - "summary": "80瀛椾互鍐呯殑涓栫晫姒傝堪", - "tone": "涓栫晫姘涘洿涓庡熀璋?, - "playerGoal": "鐜╁杩涘叆杩欎釜涓栫晫鍚庣殑鏍稿績鐩爣", - "playableNpcs": [ - { - "name": "濮撳悕", - "title": "韬唤鎴栫О鍙?, - "description": "40瀛椾互鍐呯畝浠?, - "backstory": "瑙掕壊鑳屾櫙", - "personality": "鎬ф牸鍏抽敭璇?, - "combatStyle": "鎴樻枟椋庢牸", - "tags": ["鏍囩1", "鏍囩2"] - } - ], - "storyNpcs": [ - { - "name": "濮撳悕", - "role": "韬唤鎴栬亴涓?, - "description": "40瀛椾互鍐呯畝浠?, - "motivation": "瑙掕壊鍔ㄦ満", - "relationshipHooks": ["鍙笌鐜╁浜掑姩鐨勫垏鍏ョ偣1", "鍒囧叆鐐?"] - } - ], - "items": [ - { - "name": "鐗╁搧鍚?, - "category": "姝﹀櫒|鎶ょ敳|楗板搧|娑堣€楀搧|鏉愭枡|绋€鏈夊搧|涓撳睘鐗?, - "rarity": "common|uncommon|rare|epic|legendary", - "description": "鐗╁搧鎻忚堪", - "tags": ["鏍囩1", "鏍囩2"] - } - ], - "landmarks": [ - { - "name": "鍦版爣鍚?, - "description": "鍦版爣鎻忚堪" - } - ] -} - -纭€ц鍒欙細 -- 鎵€鏈夋枃鏈繀椤绘槸涓枃銆? -- 涓栫晫鍚嶇О銆丯PC 鍚嶇О銆佺墿鍝佸悕绉拌鍏蜂綋锛屼笉瑕佷娇鐢ㄢ€滆鑹蹭竴鈥濃€淣PC 涓€鈥濃€滅鍣ㄤ竴鍙封€濊繖绫诲崰浣嶈瘝銆? -- 涓嶈濂楃敤鍥哄畾鐨勨€滄渚犲簳绋库€濇垨鈥滀粰渚犲簳绋库€濇潵鍐欙紝鎵€鏈夎鑹层€佸娍鍔涖€佸湴鏍囥€佺墿鍝侀兘蹇呴』鐩存帴浠庣帺瀹剁粰鍑虹殑涓栫晫璁惧畾涓帹瀵笺€? -- playableNpcs 蹇呴』杈撳嚭 5 鍚嶅畬鏁磋鑹诧紱storyNpcs 杈撳嚭 8 鍒?12 鍚嶁€滄牳蹇?NPC 鏍锋湰鈥濓紱items 杈撳嚭 12 鍒?20 浠垛€滃叧閿墿鍝佹牱鏈€濓紱landmarks 杈撳嚭 4 鍒?6 涓湴鏍囥€? -- playableNpcs 蹇呴』閫傚悎琚帺瀹跺€熺敤鎴栨敼鍐欐垚涓昏璁惧畾锛屽洜姝よ儗鏅€佹€ф牸銆佹垬鏂楅鏍奸兘瑕佸畬鏁淬€? -- storyNpcs 蹇呴』瑕嗙洊涓嶅悓绀句細瑙掕壊锛屼笉鑳藉叏鏄垬鏂楀瀷浜虹墿銆? -- items 蹇呴』鍚屾椂鍖呭惈甯哥敤鐗╄祫涓庡叿鏈夊墽鎯呮剰鍛崇殑鍏抽敭鐗┿€? -- 绯荤粺浼氬湪浣犺緭鍑哄悗锛屽熀浜庢湰鍦扮礌鏉愬簱缁х画鎵╁睍鍒板畬鏁寸殑 30+ NPC 涓?1000+ 鐗╁搧妗f锛屾墍浠ヤ綘瑕佷紭鍏堝啓鈥滄渶鏍稿績銆佹渶鏈夎鲸璇嗗害鈥濈殑瑙掕壊鍜屽叧閿墿鍝侀鏋躲€? -- 涓嶈寮曠敤鐜板疄涓栫晫鍝佺墝銆両P 鎴栫幇鎴愪綔鍝佽鑹层€俙; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter(item => item && typeof item === 'object') as Array> - : []; -} - -function normalizeTags(value: unknown, fallbackTags: string[] = []) { - const tags = Array.isArray(value) - ? value.map(item => toText(item)).filter(Boolean) - : []; - return [...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean))].slice(0, 5); -} - -function normalizeWorldType(value: unknown, sourceText: string) { - const worldType = toText(value).toUpperCase(); - if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) { - return worldType; - } - return inferWorldTypeFromSetting(sourceText); -} - -function normalizeRarity(value: unknown, fallback: ItemRarity = 'rare'): ItemRarity { - const rarity = toText(value).toLowerCase() as ItemRarity; - return CUSTOM_WORLD_RARITIES.includes(rarity) ? rarity : fallback; -} - -function slugify(value: string) { - const ascii = value - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') - .replace(/^-+|-+$/g, ''); - - if (ascii) { - return ascii.slice(0, 24); - } - - return 'entry'; -} - -function createEntryId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function inferWorldTypeFromSetting(settingText: string) { - if (/[浠欑伒淇湡椋炲崌鐏佃剦瀹楅棬娉曞櫒澶╁绉樺閬撻鏄熻垷]/u.test(settingText)) { - return WorldType.XIANXIA; - } - return WorldType.WUXIA; -} - -function buildSeedPhrase(settingText: string, fallback: string) { - const compact = settingText.replace(/\s+/g, '').trim(); - return compact ? compact.slice(0, 10) : fallback; -} - -function buildWorldName(settingText: string, worldType: WorldType) { - const seed = buildSeedPhrase(settingText, worldType === WorldType.XIANXIA ? '鐏垫疆' : '姹熸箹'); - const suffix = worldType === WorldType.XIANXIA ? '鐣? : '褰?; - return `${seed}${suffix}`; -} - -function buildFallbackPlayableNpcs(settingText: string, worldType: WorldType): CustomWorldPlayableNpc[] { - const seed = buildSeedPhrase(settingText, worldType === WorldType.XIANXIA ? '浜戞捣' : '姹熸箹'); - - if (worldType === WorldType.XIANXIA) { - return [ - { - id: 'playable-su-yunhui-1', - name: '鑻忎簯寰?, - title: `${seed}宸$晫浣縛, - description: '鐔熸倝鐏佃剦寮傚姩涓庢棫绂佸埗鐥曡抗锛屽杽浜庤拷绱㈠け鎺ф簮澶淬€?, - backstory: `鑻忎簯寰婃浘濂夊懡宸℃煡${seed}闄勮繎鐨勭澧冭缂濓紝鍗村湪涓€娆″け鎺т簨鏁呬腑澶卞幓浜嗗悓闂ㄤ笌鏃ц亴浣嶏紝濡備粖鍙兂鏌ユ竻鐪熸鐨勫箷鍚庢搷鐩樿€呫€俙, - personality: '鍐烽潤銆佸厠鍒躲€佽拷鏍圭┒搴?, - combatStyle: '浠ョ伒鍒冧笌鐭拻鍘嬪埗鎴樺眬锛屾搮闀垮厛鎵嬪皝閿佷笌杩藉嚮', - tags: ['杩借抗', '绂佸埗', '鐏靛垉'], - }, - { - id: 'playable-wen-xinglan-2', - name: '闂绘槦婢?, - title: '鏄熻垷娈嬭埅瀹?, - description: '鎿呴暱鍦ㄩ珮绌轰笌闄╁湴琛屽姩锛岀粡楠屾潵鑷极闀胯€屽け璐ョ殑杩滆埅銆?, - backstory: `闂绘槦婢滄浘鏄┛琛岃鍩熺殑鏄熻垷寮曡埅浜猴紝鍦?{seed}涓婄┖鐨勪竴娆″潬鑸悗娴佽惤姝ょ晫锛屽彧鍓╀竴寮犳畫缂鸿埅鍥句笌涓€韬笉鑲唲鐏殑鎵у康銆俙, - personality: '鏁忛攼銆佸菇榛樸€佹垝蹇冨緢閲?, - combatStyle: '鍋忓悜鏈哄姩杩滅▼锛屼範鎯竟绉诲姩杈瑰鎵捐嚧鍛借搴?, - tags: ['鏄熻垷', '杩滅▼', '鏈哄姩'], - }, - { - id: 'playable-bai-luochen-3', - name: '鐧借惤灏?, - title: '娈嬪嵎绗︿慨', - description: '闈犳畫缂哄彜鍗疯嚜淇垚鏈紝瀵圭闂讳笌鏃у彶鏈夊紓甯告墽鐫€銆?, - backstory: `鐧借惤灏樺嚭韬钩鍑★紝鍗村洜鍋跺緱涓€鍗锋潵鑷?{seed}娣卞鐨勬畫鍗疯€岃笍涓婁慨琛屼箣璺€備负浜嗗紕娓呮畫鍗蜂负浣曞弽澶嶆寚鍚戝悓涓€澶勭伨鍘勶紝浠栦富鍔ㄨ蛋杩涢鏆翠腑蹇冦€俙, - personality: '娓╁拰銆佺粏鑷淬€佹剰蹇楅〗寮?, - combatStyle: '浠ョ绠撱€佹硶闃靛拰鎺у満瑙侀暱锛岄噸瑙嗗噯澶囦笌鑺傚', - tags: ['绗︿慨', '鎺у満', '鍙ゅ嵎'], - }, - ]; - } - - return [ - { - id: 'playable-shen-tingyu-1', - name: '娌堝惉闆?, - title: `${seed}娓镐緺`, - description: '鎿呴暱浠庣悍涔辩嚎绱㈤噷鏁寸悊鐪熺浉锛屼篃鑳藉湪涔卞眬閲屽厛鍑烘墜绋充綇鍦洪潰銆?, - backstory: `娌堝惉闆ㄦ浘鏇夸汉鎶ら€佷竴浠戒笌${seed}鐩稿叧鐨勫瘑淇★紝鍗村湪閫斾腑閬汉鐏彛浼忓嚮銆傝嚜閭d互鍚庯紝浠栦笉鍐嶆浛璋佸崠鍛斤紝鍙拷鏌ラ偅灏佷俊鍒板簳瑙﹀姩浜嗚皝鐨勫埄鐩娿€俙, - personality: '娌夌潃銆佹灉鏂€侀噸瑙嗘壙璇?, - combatStyle: '鍒€鍓戝吋淇紝鑳藉揩閫熷垏鍏ユ闈㈠眬鍔垮苟缁存寔鍘嬪埗', - tags: ['娓镐緺', '杩芥煡', '鍒€鍓?], - }, - { - id: 'playable-pei-jinghong-2', - name: '瑁存儕楦?, - title: '澶卞娍鍓戜緧', - description: '鍑鸿韩鏃у娍鍔涙牳蹇冿紝瑙佽繃鏉冨娍濡備綍鎶婁汉鎺ㄥ悜缁濊矾銆?, - backstory: `瑁存儕楦挎湰鏄浛鏃т富鎵у墤鐨勪汉锛岀洿鍒颁竴鍦哄洿鐚庢妸浠栦笌${seed}鏈夊叧鐨勭瀵嗕竴骞舵嫋鍏ヨ妗堛€傚浠婁粬鍙兂鍦ㄥ悇鏂瑰娍鍔涘弽搴旇繃鏉ヤ箣鍓嶅厛涓€姝ユ嬁鍒扮湡鐩搞€俙, - personality: '楂樺偛銆佹晱閿愩€佷笉杞讳俊浜?, - combatStyle: '浠ュ揩鍓戜笌韬硶瑙侀暱锛屾搮闀跨偣鏉€涓庡帇鑺傚', - tags: ['蹇墤', '鏃ф', '娼滆'], - }, - { - id: 'playable-gu-cangfeng-3', - name: '椤捐棌閿?, - title: '闀栧眬鏆楀崼', - description: '甯稿勾娓歌蛋鐏拌壊鍦板甫锛岀煡閬撴€庢牱鍦ㄥ嵄闄╅潬杩戝墠鍏堢湅鍑洪鍚戙€?, - backstory: `椤捐棌閿嬫浘璐熻矗鎶ら€佺┛瓒?{seed}鍦板甫鐨勯噸瑕佷汉鐗╋紝鍚庢潵鏁存敮闃熶紞鍙墿浠栦竴浜烘椿鐫€鍥炴潵銆傞偅娆℃儴璐ヨ浠栧啀涓嶈偗杞绘槗杞韩绂诲紑浠讳綍鍙枒鐥曡抗銆俙, - personality: '璋ㄦ厧銆佽€愬績銆佹瀬鏈夐煣鎬?, - combatStyle: '鍋忓悜涓窛绂昏瘯鎺笌绮惧噯鐖嗗彂锛屽厛鐪嬬牬鍐嶄笅鎵?, - tags: ['鏆楀崼', '浼忓嚮', '闀栧眬'], - }, - ]; -} - -function buildFallbackStoryNpcs(settingText: string, worldType: WorldType): CustomWorldNpc[] { - const seed = buildSeedPhrase(settingText, worldType === WorldType.XIANXIA ? '浜戞捣' : '姹熸箹'); - - const base: Array<[string, string, string, string]> = worldType === WorldType.XIANXIA - ? [ - ['椤鹃棶娓?, '鐏垫笭绠′簨', `璐熻矗鐪嬪畧${seed}闄勮繎鐨勭伒娓狅紝鏈€鍏堝療瑙夌伒娼紓鍙樸€俙, '鎯充繚浣忕伒娓犱笌闄勮繎鏉戣惤锛屼笉鎰夸笂灞傛妸鐏惧彉缁х画鍘嬩笅鍘?], - ['绁濇媯闇?, '鑽渻鎵т簨', '鏀舵不杩囪澶氬洜绉樺寮傚彉鍙椾激鐨勪汉锛屽寮傚彉瑙勫緥鏈夎嚜宸辩殑鍒ゆ柇銆?, '鎯虫壘鍒拌浼よ€呭弽澶嶆伓鍖栫殑鏍瑰洜'], - ['榻愮収澶?, '纰戝綍鍙镐功', '瀹堢潃涓€鎵瑰彜纰戞嫇鏈紝璁板緱璁稿琚埢鎰忔姽鎺夌殑鏃у彶銆?, '鎯虫妸琚殣鍘荤殑鏃ф閲嶆柊鎷煎畬鏁?], - ['鍙舵矇鑸?, '鏁d慨鍟嗚穿', '寰€鏉ヤ簬鍚勫绉樺杈圭紭锛屼粈涔堝嵄闄╅兘鍋氳繃涓€鐐逛拱鍗栥€?, '鎯宠秮灞€鍔挎湭瀹氬厛璧屼竴鎶婂ぇ鐨?], - ['瀹佹櫄姹?, '瑙傛槦濂充慨', '閫氳繃澶╄薄涓庤鐣屽洖鍝嶆帹婕旂伨鍘勭殑璧板悜銆?, '鎯抽樆姝㈡煇涓嵆灏嗗彂鐢熺殑澶╄薄鑺傜偣澶辨帶'], - ] - : [ - ['涔斿畧鎴?, '娓″彛鎺屾煖', `鍦?{seed}涓€甯︾粡钀ユ秷鎭拰璐ц矾锛岃杩囪澶氫笉璇ュ嚭鐜扮殑浜恒€俙, '鎯充繚浣忚嚜宸辩殑鍟嗚矾锛屼篃鎯虫懜娓呰皝鍦ㄦ殫涓埅鏂揣婧?], - ['璐洪潚妲?, '灞遍棬澶栭棬寮熷瓙', '甯稿勾瀹堝湪灞遍亾涓庡墠鍝紝瀵规潵寰€鍔垮姏鏈€涓烘晱鎰熴€?, '鎯宠瘉鏄庤嚜宸变笉鏄闂ㄦ淳闅忔椂鍙互涓㈡帀鐨勬瀛?], - ['鏌充笁濞?, '鑽摵鎺屾煖', '鏀舵不杩囪澶氭睙婀栦汉锛岀煡閬撲粬浠粠鍝噷鏉ャ€佸張鍦ㄨ翰浠€涔堛€?, '鎯充繚浣忚嵂閾哄拰搴囨姢杩囩殑浜?], - ['鍞愰椈绛?, '鏃у啗涔﹀悘', '閫€褰瑰悗浠嶄繚鐣欑潃鍐涗腑鏁寸悊鎯呮姤鐨勪範鎯€?, '鎯虫妸涓€妗╁啗涓棫妗堟煡涓槑鐧?], - ['娓╃収涓?, '閾稿潑鍖犲笀', '璁ゅ緱鍏靛櫒缂哄彛涓庝激鐥曡儗鍚庨殣钘忕殑浜ら攱杞ㄨ抗銆?, '鎯虫壘鍒伴偅鎶婃浘姣佹帀甯堥棬蹇冭鐨勫叺鍣?], - ]; - - return base.map(([name, role, description, motivation], index) => ({ - id: createEntryId('story-npc', name, index), - name, - role, - description, - motivation, - relationshipHooks: worldType === WorldType.XIANXIA - ? ['鎰挎剰鐢ㄦ儏鎶ヤ氦鎹㈠府鍔?, '瀵圭鍒朵笌寮傝薄鏈夌嫭鐗圭悊瑙?] - : ['鎵嬮噷鎻$潃鏃ф绾跨储', '瀵瑰悇鏂瑰娍鍔涚殑鍙嶅簲鏋佷负鏁忔劅'], - })); -} - -function buildFallbackItems(settingText: string, worldType: WorldType): CustomWorldItem[] { - const seed = buildSeedPhrase(settingText, worldType === WorldType.XIANXIA ? '鐏垫疆' : '椋庝簯'); - - const base: Array<[string, string, ItemRarity, string, string[]]> = worldType === WorldType.XIANXIA - ? [ - ['寮曠伒绗?, '娑堣€楀搧', 'uncommon', `鑳界煭鏆傜ǔ瀹?{seed}闄勮繎绱婁贡鐏垫皵鐨勭伒绗︺€俙, ['绗︾畵', '鐏垫皵']], - ['鏄熺爞缃楃洏', '楗板搧', 'rare', '鍙湪绉樺涓庤鐣屽洖鍝嶄腑杈ㄨ鏂瑰悜銆?, ['瀵昏矾', '鏄熻薄']], - ['鏂櫣鐏靛垉', '姝﹀櫒', 'epic', '鏇捐鏌愪綅宸$晫浣夸娇鐢ㄧ殑娈嬬己鐏靛垉锛屼粛鏈夐攱鑺掋€?, ['鐏靛垉', '鎴樻枟']], - ['浜戦珦鎶ゅ績闀?, '鎶ょ敳', 'rare', '鑳藉墛寮辩伒鍘嬪啿鍑荤殑鎶ゅ績娉曞櫒銆?, ['闃叉姢', '鐏靛帇']], - ['鏃у煙鎷撶鐗?, '绋€鏈夊搧', 'rare', '璁拌浇鐫€琚姽鍘绘棫鍙茬殑娈嬬墖銆?, ['绉橀椈', '鏃у彶']], - ['鐣岄殭绉嶇伀', '涓撳睘鐗?, 'legendary', '涓庡ぇ瑙勬ā寮傚彉鏍规簮鏈夊叧鐨勫叧閿紩瀛愩€?, ['鍓ф儏鍏抽敭', '寮傚彉']], - ] - : [ - ['鍥為鏁?, '娑堣€楀搧', 'uncommon', `甯稿湪${seed}鍦板甫娴侀€氱殑澶栦激鑽紝瑙佹晥寰堝揩銆俙, ['鐤椾激', '鑽墿']], - ['鏂汗浠ょ墝', '绋€鏈夊搧', 'rare', '鏉ヨ嚜鏃у娍鍔涚殑浠ょ墝纰庣墖锛屽彲璇佹槑鏌愪簺浜烘浘缁忕殑韬唤銆?, ['鏃ф', '淇$墿']], - ['娌夐攱鐭垁', '姝﹀櫒', 'rare', '渚夸簬杩戣韩鎼忔潃涓庢殫涓嚭鎵嬬殑鐭垁銆?, ['杩戞垬', '娼滆']], - ['榛戦碁鎶よ噦', '鎶ょ敳', 'rare', '鑳芥壙鍙楄繛缁啿鍑荤殑鎶よ噦锛岄€傚悎纭纭幃鏉€銆?, ['闃插尽', '纭垬']], - ['澶滆鐏專', '楗板搧', 'uncommon', '鍙湪闆惧涓庢殫宸蜂腑淇濇寔寰急绋冲畾鍏夋簮銆?, ['鎺㈢储', '澶滆']], - ['瑾撶棔娈嬪墤', '涓撳睘鐗?, 'legendary', '涓€鏌勫拰琛€妗堟簮澶寸揣瀵嗙浉杩炵殑娈嬪墤銆?, ['鍓ф儏鍏抽敭', '瀹垮懡']], - ]; - - return base.map(([name, category, rarity, description, tags], index) => ({ - id: createEntryId('item', name, index), - name, - category, - rarity: normalizeRarity(rarity, 'rare'), - description, - tags: normalizeTags(tags, []), - })); -} - -function buildFallbackLandmarks(settingText: string, worldType: WorldType): CustomWorldLandmark[] { - const seed = buildSeedPhrase(settingText, worldType === WorldType.XIANXIA ? '鐏垫疆' : '椋庝簯'); - - const base = worldType === WorldType.XIANXIA - ? [ - ['瑁傜晫浜戦棬', `浼犺█鑳芥槧鍑?{seed}鐪熸婧愬ご鐨勯珮绌洪棬闃欍€俙, '楂?], - ['娌夋槦鑽渻', '澶栬〃瀹侀潤銆佸疄闄呰寮傚寲鐏垫皵鎸佺画渚垫煋鐨勮嵂鍦冦€?, '涓?], - ['鍙ょ瑙傛槦鍙?, '淇濆瓨鐫€鏃ф椂浠h娴嬭褰曠殑鏂楂樺彴銆?, '涓?], - ['鏃犵伅鐣屼簳', '鎹瓒婇潬杩戜簳鍙o紝瓒婅兘鍚鏉ヨ嚜瑁傜晫娣卞鐨勫洖鍝嶃€?, '鏋侀珮'], - ] - : [ - ['鏂ˉ鏃ч┛', `鍚勬柟浜洪┈閮戒細鍦?{seed}杈圭紭鐭殏鍋滅暀鐨勮鍐层€俙, '涓?], - ['閿侀灞遍棬', '琛ㄩ潰娌夊瘋銆佸疄闄呮殫娴佹瀬澶氱殑鏃ч棬娲惧墠绾裤€?, '楂?], - ['鍐烽搧閾稿潑', '鏃㈣兘琛ョ粰鍏靛櫒锛屼篃钘忕潃涓嶅皯琚帺鍩嬬殑鏃х棔杩广€?, '涓?], - ['琛€鐥曞瘑寤?, '璁稿鍏抽敭浜虹墿閮芥浘浠庤繖閲岃繘鍑哄嵈涓嶆効鐣欎笅鍚嶅瓧銆?, '鏋侀珮'], - ]; - - return base.map(([name, description, dangerLevel], index) => ({ - id: createEntryId('landmark', name, index), - name, - description, - dangerLevel, - })); -} - -export function buildFallbackCustomWorldProfile(settingText: string): CustomWorldProfile { - const templateWorldType = inferWorldTypeFromSetting(settingText); - const worldName = buildWorldName(settingText, templateWorldType); - const subtitle = templateWorldType === WorldType.XIANXIA ? '鐏垫疆鏈畾' : '椋庝簯鏆楁秾'; - const summary = settingText.trim() - ? `杩欎釜涓栫晫鍥寸粫鈥?{settingText.trim().slice(0, 28)}鈥濆睍寮€锛岃〃闈㈢З搴忓皻瀛橈紝鏆楀湴閲屽嵈宸叉湁浜哄紑濮嬩簤澶哄け鎺х殑鍏抽敭鍔涢噺銆俙 - : templateWorldType === WorldType.XIANXIA - ? '鐏垫疆姝e湪钄撳欢锛岀澧冧笌鏃х鍒剁浉缁уけ琛★紝鍚勬柟閮藉湪鎶㈠厛瀵绘壘鐪熸婧愬ご銆? - : '姹熸箹涓庢棫鍔垮姏涔嬮棿鐨勫钩琛℃鍦ㄧ牬瑁傦紝涓€妗╂棫妗堥噸鏂扮壍鍔ㄤ簡鎵€鏈変汉鐨勭珛鍦恒€?; - - return { - id: `custom-world-${Date.now().toString(36)}-${slugify(worldName)}`, - settingText: settingText.trim(), - name: worldName, - subtitle, - summary, - tone: templateWorldType === WorldType.XIANXIA ? '绌虹伒銆佸嵄闄┿€佸眰灞傞€掕繘鐨勭伨鍙樻劅' : '绱у紶銆佸厠鍒躲€佸甫鐫€鏃ф闃村奖鐨勬睙婀栧帇杩劅', - playerGoal: templateWorldType === WorldType.XIANXIA - ? '鏌ユ竻鐏垫疆澶辨帶婧愬ご锛屽苟鍦ㄥ悇澶т粰闂ㄥ弽搴旇繃鏉ヤ箣鍓嶆姠鍏堟嬁鍒板叧閿嚎绱€? - : '寰潃鏃ф鐣欎笅鐨勭棔杩硅拷鏌ュ箷鍚庝箣浜猴紝骞跺畧浣忎粛鍊煎緱鐩镐俊鐨勪汉涓庤矾銆?, - templateWorldType, - playableNpcs: buildFallbackPlayableNpcs(settingText, templateWorldType), - storyNpcs: buildFallbackStoryNpcs(settingText, templateWorldType), - items: buildFallbackItems(settingText, templateWorldType), - landmarks: buildFallbackLandmarks(settingText, templateWorldType), - }; -} - -function normalizePlayableNpcList(value: unknown, fallback: CustomWorldPlayableNpc[]) { - const entries = toRecordArray(value).map((item, index) => { - const name = toText(item.name) || fallback[index]?.name || `鍙壆婕旇鑹?{index + 1}`; - return { - id: createEntryId('playable-npc', name, index), - name, - title: toText(item.title) || fallback[index]?.title || '琛岃€?, - description: toText(item.description) || fallback[index]?.description || `${name}鎷ユ湁瓒冲椴滄槑鐨勮韩浠戒笌琛屽姩鐞嗙敱銆俙, - backstory: toText(item.backstory) || fallback[index]?.backstory || `${name}鑳屽悗钘忕潃灏氭湭琚畬鍏ㄦ彮寮€鐨勬棫浜嬨€俙, - personality: toText(item.personality) || fallback[index]?.personality || '鍐烽潤銆佸潥闊с€佹効鎰忚鍔?, - combatStyle: toText(item.combatStyle) || fallback[index]?.combatStyle || '鎿呴暱姝i潰鎺ㄨ繘涓庝复鍦哄簲鍙?, - tags: normalizeTags(item.tags, fallback[index]?.tags ?? ['涓昏鍊欓€?]), - } satisfies CustomWorldPlayableNpc; - }); - - return entries.length > 0 ? entries.slice(0, 5) : fallback; -} - -function normalizeStoryNpcList(value: unknown, fallback: CustomWorldNpc[]) { - const entries = toRecordArray(value).map((item, index) => { - const name = toText(item.name) || fallback[index]?.name || `涓栫晫NPC${index + 1}`; - return { - id: createEntryId('story-npc', name, index), - name, - role: toText(item.role) || fallback[index]?.role || '鎯呮姤涓棿浜?, - description: toText(item.description) || fallback[index]?.description || `${name}涓庡綋鍓嶄笘鐣屼富绾跨籂钁涘緢娣便€俙, - motivation: toText(item.motivation) || fallback[index]?.motivation || '鎯充粠娣蜂贡閲屼繚浣忚嚜宸辨渶鐪嬮噸鐨勪笢瑗?, - relationshipHooks: normalizeTags(item.relationshipHooks, fallback[index]?.relationshipHooks ?? ['鎺屾彙鍏抽敭淇℃伅']), - } satisfies CustomWorldNpc; - }); - - return entries.length > 0 ? entries.slice(0, 12) : fallback; -} - -function normalizeItemList(value: unknown, fallback: CustomWorldItem[]) { - const entries = toRecordArray(value).map((item, index) => { - const name = toText(item.name) || fallback[index]?.name || `鍏抽敭鐗╁搧${index + 1}`; - return { - id: createEntryId('item', name, index), - name, - category: toText(item.category) || fallback[index]?.category || '绋€鏈夊搧', - rarity: normalizeRarity(item.rarity, fallback[index]?.rarity ?? 'rare'), - description: toText(item.description) || fallback[index]?.description || `${name}涓庡綋鍓嶄笘鐣岀殑灞€鍔垮彉鍖栧瓨鍦ㄨ仈绯汇€俙, - tags: normalizeTags(item.tags, fallback[index]?.tags ?? ['涓栫晫鐗╁搧']), - } satisfies CustomWorldItem; - }); - - return entries.length > 0 ? entries.slice(0, 20) : fallback; -} - -function normalizeLandmarkList(value: unknown, fallback: CustomWorldLandmark[]) { - const entries = toRecordArray(value).map((item, index) => { - const name = toText(item.name) || fallback[index]?.name || `鍏抽敭鍦版爣${index + 1}`; - return { - id: createEntryId('landmark', name, index), - name, - description: toText(item.description) || fallback[index]?.description || `${name}瀵瑰悗缁啋闄╁叿鏈夋槑纭奖鍝嶃€俙, - dangerLevel: toText(item.dangerLevel) || fallback[index]?.dangerLevel || '中', - } satisfies CustomWorldLandmark; - }); - - return entries.length > 0 ? entries.slice(0, 6) : fallback; -} - -export function normalizeCustomWorldProfile(raw: unknown, settingText: string): CustomWorldProfile { - const fallback = buildFallbackCustomWorldProfile(settingText); - if (!raw || typeof raw !== 'object') { - return fallback; - } - - const item = raw as Record; - const worldSignalText = [ - settingText, - toText(item.subtitle), - toText(item.summary), - toText(item.tone), - toText(item.playerGoal), - ].join(' '); - const templateWorldType = normalizeWorldType(item.templateWorldType, worldSignalText); - const normalizedFallback = templateWorldType === fallback.templateWorldType - ? fallback - : buildFallbackCustomWorldProfile(settingText); - - const name = toText(item.name) || normalizedFallback.name; - - return { - id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`, - settingText: settingText.trim(), - name, - subtitle: toText(item.subtitle) || normalizedFallback.subtitle, - summary: toText(item.summary) || normalizedFallback.summary, - tone: toText(item.tone) || normalizedFallback.tone, - playerGoal: toText(item.playerGoal) || normalizedFallback.playerGoal, - templateWorldType, - playableNpcs: normalizePlayableNpcList(item.playableNpcs, normalizedFallback.playableNpcs), - storyNpcs: normalizeStoryNpcList(item.storyNpcs, normalizedFallback.storyNpcs), - items: normalizeItemList(item.items, normalizedFallback.items), - landmarks: normalizeLandmarkList(item.landmarks, normalizedFallback.landmarks), - }; -} - -export function buildCustomWorldGenerationPrompt(settingText: string) { - return [ - '璇峰熀浜庝互涓嬬帺瀹惰緭鍏ョ殑涓栫晫璁惧畾锛岀敓鎴愪竴涓€傚悎鍍忕礌鍔ㄤ綔 RPG 鐨勫畬鏁磋嚜瀹氫箟涓栫晫妗f銆?, - '鐜╁杈撳叆锛?, - settingText.trim(), - '', - '棰濆瑕佹眰锛?, - '- 涓嶈浠ヤ换浣曠幇鎴愮殑姝︿緺銆佷粰渚犳垨鍏朵粬鍥哄畾妯℃澘涓哄簳绋匡紝蹇呴』鐩存帴浠庤繖娈典笘鐣岃瀹氭帹瀵间笘鐣岀粨鏋勩€?, - '- 涓栫晫蹇呴』鑳芥壙杞介暱绾垮啋闄┿€佹垬鏂椼€佹帰绱€佷氦鏄撲笌瑙掕壊鍏崇郴鍙戝睍銆?, - '- playableNpcs 瑕佺洿鎺ョ粰鍑?5 鍚嶅彲鎵紨瑙掕壊锛屾瘡鍚嶉兘瑕佹湁娓呮櫚韬唤銆佽儗鏅€佹€ф牸涓庢垬鏂楅鏍笺€?, - '- storyNpcs 鍙渶鎻愪緵 8 鍒?12 鍚嶆渶閲嶈鐨勬牳蹇?NPC 鏍锋湰锛屼絾瑕佽鐩栨儏鎶ャ€佹敮鎻淬€佷氦鏄撱€佸娍鍔涖€佸湴鏂硅璇佽€呯瓑涓嶅悓鍔熻兘銆?, - '- items 鍙渶鎻愪緵 12 鍒?20 浠舵渶鍏抽敭鐨勭墿鍝佹牱鏈紝鍏朵腑鑷冲皯鍖呭惈 1 涓墽鎯呭叧閿墿銆? 涓父鐢ㄦ秷鑰楁垨鎺㈢储鐗┿€? 涓澶囧悜鐗╁搧銆?, - '- landmarks 瑕佽兘鏀拺鍚庣画鍦板浘銆佸啋闄╁紑鍦哄拰鍓ф儏鎺ㄨ繘銆?, - '- 浣犵殑杈撳嚭浼氳绯荤粺杩涗竴姝ユ墿灞曚负瀹屾暣鐨勫ぇ鍨嬩笘鐣屾。妗堬紝鎵€浠ヨ浼樺厛淇濊瘉楠ㄦ灦椴滄槑銆佸彲鎵╁睍锛岃€屼笉鏄┓涓俱€?, - ].join('\n'); -} - -export function buildCustomWorldReferenceText(profile: CustomWorldProfile) { - const playableNpcText = profile.playableNpcs - .slice(0, 3) - .map(npc => `- ${npc.name} / ${npc.title}锛?{npc.description}锛涜儗鏅細${npc.backstory}锛涢鏍硷細${npc.combatStyle}`) - .join('\n'); - const storyNpcText = profile.storyNpcs - .slice(0, 5) - .map(npc => `- ${npc.name} / ${npc.role}锛?{npc.description}锛涘姩鏈猴細${npc.motivation}`) - .join('\n'); - const itemText = profile.items - .slice(0, 6) - .map(item => `- ${item.name} / ${item.category} / ${item.rarity}锛?{item.description}`) - .join('\n'); - const landmarkText = profile.landmarks - .slice(0, 4) - .map(landmark => `- ${landmark.name}锛?{landmark.description}`) - .join('\n'); - - return [ - `鑷畾涔変笘鐣岋細${profile.name}`, - `鍓爣棰橈細${profile.subtitle}`, - `鐜╁鍘熷璁惧畾锛?{profile.settingText}`, - `涓栫晫姒傝堪锛?{profile.summary}`, - `涓栫晫鍩鸿皟锛?{profile.tone}`, - `鐜╁鏍稿績鐩爣锛?{profile.playerGoal}`, - `鍙壆婕?NPC 妗f锛歕n${playableNpcText || '- 鏆傛棤'}`, - `鏅€?NPC 妗f锛歕n${storyNpcText || '- 鏆傛棤'}`, - `鍏抽敭鐗╁搧妗f锛歕n${itemText || '- 鏆傛棤'}`, - `鍏抽敭鍦版爣妗f锛歕n${landmarkText || '- 鏆傛棤'}`, - ].join('\n'); -} diff --git a/.codex-logs/restore-backups/2026-03-29/customWorldBuilder.ts.broken b/.codex-logs/restore-backups/2026-03-29/customWorldBuilder.ts.broken deleted file mode 100644 index 8bacaee1..00000000 --- a/.codex-logs/restore-backups/2026-03-29/customWorldBuilder.ts.broken +++ /dev/null @@ -1,683 +0,0 @@ -import { - CustomWorldItem, - CustomWorldLandmark, - CustomWorldNpc, - CustomWorldPlayableNpc, - CustomWorldProfile, - ItemCatalogEntry, - ItemRarity, - WorldTemplateType, - WorldType, -} from '../types'; -import { - buildFallbackCustomWorldProfile, - normalizeCustomWorldProfile, -} from './customWorld'; -import { - buildThemedItemDescription, - buildThemedItemName, - inferCustomItemMechanics, -} from './customWorldPresentation'; -import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags'; -import { getDefaultCustomWorldNpcImage, getDefaultCustomWorldSceneImage } from '../data/customWorldVisuals'; -import { resolveCustomWorldAnchorWorldType } from './customWorldTheme'; - -const MIN_PLAYABLE_NPC_COUNT = 5; -const MIN_STORY_NPC_COUNT = 30; -const MIN_ITEM_COUNT = 1000; -const MIN_LANDMARK_COUNT = 6; -const FEATURED_ITEM_COUNT = 24; -type ExpandedPlayableNpc = CustomWorldPlayableNpc & { templateCharacterId: string }; -const PLAYABLE_TEMPLATE_CHARACTER_IDS = [ - 'sword-princess', - 'archer-hero', - 'girl-hero', - 'punch-hero', - 'fighter-4', -] as const; - -const NAME_SURNAMES = [ - '娌?, - '闄?, - '椤?, - '鑻?, - '娓?, - '瑁?, - '绁?, - '闂?, - '璋?, - '钀?, - '瀹?, - '姹?, - '绉?, - '鐧?, - '鍙?, - '宕?, -] as const; - -const NAME_GIVEN_PREFIXES = [ - '浜?, - '鏄?, - '闈?, - '鐜?, - '鎯?, - '鏅?, - '钀?, - '闇?, - '鐓?, - '娌?, - '娓?, - '闀?, - '鏄?, - '宀?, - '闇?, - '鍚?, -] as const; - -const NAME_GIVEN_SUFFIXES = [ - '鑸?, - '宀?, - '琛?, - '灏?, - '姝?, - '妫?, - '婢?, - '鏈?, - '閲?, - '宸?, - '鐮?, - '闇?, - '鐟?, - '婢?, - '瀵?, - '瀹?, -] as const; - -const PLAYABLE_FILL_TEMPLATES: Record< - WorldTemplateType, - Array> -> = { - [WorldType.WUXIA]: [ - { - title: '椋庨洦鍓戝', - description: '鎿呴暱蹇墤绐佽繘涓庣牬鍔胯繛娈碉紝涔犳儻鍏堣瘯鎺㈠啀涓€鍑诲畾灞€銆?, - backstory: '鏃╁勾鏇夸汉鎶ら€佸瘑淇★紝鍗村湪鍗婇€斿嵎鍏ユ棫妗堬紝鍚庢潵绱㈡€ч『鐫€閭f潯琛€绾夸竴璺拷鍒颁簡濡備粖鐨勪笘鐣屼腑蹇冦€?, - personality: '鍏嬪埗銆佹晱閿愩€佸畞鍙厛鐪嬫竻灞€鍔垮啀鍔ㄦ墜', - combatStyle: '杞昏韩杩戞垬涓庡揩鍓戝帇鍒跺苟閲嶏紝鎿呴暱鎶撳彇鐮寸唤鍚庤繛缁拷鍑?, - tags: ['蹇墤', '杩芥煡', '绐佽繘'], - }, - { - title: '娓稿巻鍒€淇?, - description: '鎿呴暱涓窛绂诲帇杩笌鍒囩嚎鎹綅锛屽杽浜庡湪娣锋垬閲岀ǔ浣忚妭濂忋€?, - backstory: '鏇捐窡闅忓晢闃熷拰闀栧眬璺戦亶杈瑰湴锛岀煡閬撹繖鐗囨睙婀栨瘡涓€鏉¤涓嶅緱鍏夌殑鎹㈣揣璺紝涔熺煡閬撹皝浼氬湪涔卞眬閲屽厛闇插嚭鐮寸唤銆?, - personality: '娌夌潃銆佺洿鎺ャ€佸鎵胯鐪嬪緱鏋侀噸', - combatStyle: '鍒€鍔垮帤閲嶄絾璧锋墜鏋佸揩锛屾搮闀块€氳繃姝ユ硶鍜岃妭濂忛€煎嚭瀵规墜澶辫', - tags: ['鍒€淇?, '闀栬矾', '鍘嬪埗'], - }, - { - title: '鏈哄叧琛岃€?, - description: '绮句簬鎷嗚В鏈哄叧涓庡竷缃櫡闃憋紝閫傚悎鎺㈢储鍗遍櫓鍖哄煙涓庣牬瑙i仐杩广€?, - backstory: '鍑鸿韩鍖犻棬鏃佹敮锛屽師鏈彧璐熻矗淇ˉ搴熸棫鏈哄叧锛岀洿鍒版湁浜烘妸鍙よ€佺鍒堕噸鏂版嫋鍥炴睙婀栵紝浠栦篃琚揩鍐嶆鎵у尃鍏ュ眬銆?, - personality: '鑰愬績銆佽皑鎱庛€佸寮傚父缁嗚妭杩囩洰涓嶅繕', - combatStyle: '渚濋潬鏈哄叧銆佺储鍏蜂笌鐭叺鍗忓悓浣滄垬锛屾搮闀挎帶鍦轰笌鐮撮樀', - tags: ['鏈哄叧', '鐮撮樀', '鎺㈢储'], - }, - { - title: '鑽簮澶栦紶寮熷瓙', - description: '鐔熸倝鍖荤悊銆佹瘨鐞嗕笌閲庡閲囬泦锛岃兘鍦ㄩ櫓澧冧腑缁存寔闃熶紞缁埅銆?, - backstory: '鍘熸湰鍙兂瀹堢潃鑽簮涓庢棫鏂瑰瓙瀹夌ǔ搴︽棩锛屽嵈鍥犱笘鐣屾殫娴佺壍鍑轰簡琚笀闂ㄥ埢鎰忓煁鎺夌殑寰€浜嬨€?, - personality: '娓╁拰銆佺粏鑷淬€侀瀛愰噷鏋佹湁闊ф€?, - combatStyle: '鍋忓悜涓窛绂荤壍鍒朵笌鐘舵€佸帇杩紝閲嶈娑堣€楀拰鑺傚鎺у埗', - tags: ['鍖荤悊', '缁埅', '鐘舵€?], - }, - { - title: '榛戝競淇′娇', - description: '璧板緱蹇紝璁板緱鐗紝鎿呴暱鍦ㄥ鏂瑰娍鍔涘す缂濋噷甯﹀洖鍏抽敭鎯呮姤銆?, - backstory: '闀挎湡鏇夸笉鍚屼汉浼犱俊鎹㈣矾锛岀煡閬撳摢涓€鍙ョ湡璇濊浠€涔堟椂鍊欒锛屼篃鐭ラ亾鍝簺鍚嶅瓧涓€鏃︽彁璧峰氨浼氭浜恒€?, - personality: '鏈虹伒銆佽瑙夈€佽璇濇€讳細鐣欏崐鍒嗕綑鍦?, - combatStyle: '寮鸿皟鏈哄姩銆佹姇鎺蜂笌杩戣韩鐖嗗彂锛岄€傚悎娓稿嚮鍜屾挙绂?, - tags: ['鎯呮姤', '鏈哄姩', '娓稿嚮'], - }, - ], - [WorldType.XIANXIA]: [ - { - title: '宸$晫鐏典娇', - description: '鐔熸倝瑁傞殭銆佹硶闃典笌鐏垫疆鍙樺姩锛岄€傚悎鎵挎媴涓栫晫寮傚彉涓荤嚎銆?, - backstory: '鏇惧彈鍛藉贰鏌ョ伒娼竟鐣岋紝鍗村湪涓€娆″け鎺т簨浠跺悗澶卞幓浜嗘棫浣嶄笌鏃у悓琚嶏紝鍙墿杩芥煡婧愬ご杩欎竴鏉¤矾銆?, - personality: '鍐烽潤銆佸厠鍒躲€佽拷鏍圭┒搴?, - combatStyle: '鐏靛垉涓庣煭鍜掔粨鍚堬紝鎿呴暱鍏堟墜灏侀攣鍜屾寔缁帇鍒?, - tags: ['宸$晫', '瑁傞殭', '灏侀攣'], - }, - { - title: '鏄熻垷鍧犱慨', - description: '鎿呴暱杩滅▼鐗靛埗涓庨珮鏈哄姩浣嶇Щ锛屽楂樼┖涓庡紓鍩熼仐杩规瀬鏈夌粡楠屻€?, - backstory: '鏇剧粡鏄繙鑸慨澹紝鍚庢潵鍦ㄤ笘鐣岃竟鐣屽潬鑸熷け搴忥紝鍙兘闈犳畫鍥惧拰璁板繂閲嶆柊鎷煎嚭鍥炲幓鐨勮矾銆?, - personality: '鏁忔嵎銆佽鎯曘€佸伓灏斾細鐢ㄧ帺绗戞帺楗版墽蹇?, - combatStyle: '鍋忛噸杩滅▼鎶曞皠鍜岄珮閫熻吘鎸紝浠ユ媺鎵拰鍘嬭揩瑙侀暱', - tags: ['鏄熻垷', '鏈哄姩', '杩滅▼'], - }, - { - title: '娈嬪嵎绗︿慨', - description: '鎿呴暱闃靛浘銆佺鍜掍笌灞€閮ㄦ帶鍦猴紝閫傚悎澶勭悊绉樺涓庡鏉傞伃閬囥€?, - backstory: '灏戝勾鏃跺伓寰楁畫鍗凤紝鑷琚嵎鍏ヤ竴杩炰覆涓嶈鍏紑鐨勬棫鍙蹭笌绂佷护锛岃秺鏌ヨ秺鍙戠幇杩欎笘鐣屽苟涓嶅畬鏁淬€?, - personality: '娓╁拰銆佷笓娉ㄣ€佸鐪熺浉鏈夎繎涔庢墽鎷楃殑鑰愬績', - combatStyle: '浠ョ闃点€佹嫎鏉熷拰鎸佺画鐗靛埗瑙侀暱锛岃绌跺噯澶囦笌鑺傚', - tags: ['绗︿慨', '闃靛浘', '鎺у満'], - }, - { - title: '鐏垫灞卞', - description: '鐔熸倝鑽潗銆佺伒妞嶄笌绂佸湴杈圭紭鐢熸€侊紝鑳藉湪闄╁湴涓寔缁淮鎸佽ˉ缁欍€?, - backstory: '鍘熸湰鍙槸鏇垮北闂ㄧ収鐪嬬伒鐢颁笌鑽胺锛屽悗鏉ュ嵈鍙戠幇鐏垫寮傚父涓庝笘鐣岃鐥曟潵鑷悓涓€鏍硅剦缁溿€?, - personality: '娌夐潤銆佸杽瀵熴€佸績杞絾涓嶈蒋寮?, - combatStyle: '鍒╃敤鐏垫銆侀闆句笌鐭叺鍗忎綔锛屽己璋冪壍鍒朵笌缁埅', - tags: ['鐏垫', '缁埅', '鑽胺'], - }, - { - title: '鐏伐鍣ㄤ慨', - description: '绮句簬鍣ㄧ墿鏀归€犮€佹硶鍣ㄦ嫾鎺ヤ笌涓村満鐖嗗彂锛屾搮闀跨牬瑙eけ鎺ф満鍏炽€?, - backstory: '鏇惧湪鍣ㄥ潑搴曞眰鍋氭渶鑴忔渶绱殑娲伙紝鐩村埌鏈変汉灏嗘棫鏃朵唬鐨勫嵄闄╁櫒姊伴噸鏂板敜閱掞紝浠栦篃琚揩绔欏埌鍙板墠銆?, - personality: '鐩寸巼銆佸浐鎵с€侀潰瀵瑰鏉傛満鍏虫椂鍙嶈€屾渶鍐烽潤', - combatStyle: '浠ユ硶鍣ㄧ垎鍙戝拰涓窛绂昏桨鍑讳负涓伙紝鍏奸【鎷嗚В鍜屾姢鎸?, - tags: ['鍣ㄤ慨', '娉曞櫒', '鐖嗗彂'], - }, - ], -}; - -const STORY_NPC_TEMPLATES: Record< - WorldTemplateType, - Array<{ - role: string; - description: string; - motivation: string; - relationshipHooks: [string, string]; - }> -> = { - [WorldType.WUXIA]: [ - { role: '鑼舵ゼ鎺屾煖', description: '鎺屾彙寰€鏉ユ秷鎭笌鍩庝腑椋庡悜銆?, motivation: '鎯充繚浣忚嚜宸辩殑娑堟伅缃戜笌鏉ュ線瀹㈣矾銆?, relationshipHooks: ['鑳戒氦鎹㈠競浜曟秷鎭?, '瀵瑰娍鍔涢鍚戞瀬鏁忔劅'] }, - { role: '娓″彛鑸瑰', description: '闀挎湡寰€杩旀按璺紝鐭ラ亾璋佸湪澶滈噷鍋锋浮銆?, motivation: '鎯虫煡娓呮煇娆″け韪鑳屽悗鐨勪拱鍗栫嚎銆?, relationshipHooks: ['鑳芥彁渚涢殣钘忚埅绾?, '鎰夸负鐪熺浉鏆傛椂鍐掗櫓'] }, - { role: '榛戝競鐗欎汉', description: '娓歌蛋浜庡悇鏂圭伆鑹蹭氦鏄撲箣闂达紝鎿呴暱鎾悎涓庨殣鍖裤€?, motivation: '鎯冲湪涔卞眬涓繚浣忚嚜宸辩殑绛圭爜涓庢€у懡銆?, relationshipHooks: ['鎺屾彙绋€鏈夌墿璧勫幓鍚?, '鐭ラ亾璋佸湪鏆楀湴閲屾敹浜?] }, - { role: '鏃ф涔﹀悘', description: '淇濈琚埢鎰忓帇涓嬬殑鏃ф。涓庡彛渚涖€?, motivation: '鎯虫妸琚姽鎺夌殑妗堝嵎閲嶆柊鎷煎畬鏁淬€?, relationshipHooks: ['鍙彁渚涙鍗风嚎绱?, '浼氳瘯鎺㈢帺瀹剁珛鍦?] }, - { role: '灞遍棬澶栭棬寮熷瓙', description: '瀹堝湪闂ㄧ涓庡北閬撻棿锛屾渶鍏堝療瑙変笉瀵瑰姴鐨勪汉銆?, motivation: '鎯宠瘉鏄庤嚜宸变笉鏄殢鏃跺彲琚壓鐗茬殑寮冨瓙銆?, relationshipHooks: ['浜嗚В灞遍棬鍔ㄦ€?, '鎰夸氦鎹㈠唴閮ㄨ闂?] }, - { role: '鑽摵閮庝腑', description: '甯稿勾鏁戞不姹熸箹瀹紝瑙佽繃澶涓嶈鍑虹幇鐨勪激鍙c€?, motivation: '鎯虫壘鍑鸿浼よ€呭弽澶嶆伓鍖栫殑鏍规簮銆?, relationshipHooks: ['鑳芥彁渚涚枟浼ゆ敮鎻?, '鐔熸倝杩戞湡浼ゅ娍寮傚父'] }, - { role: '宸″煄鎹曞揩', description: '鍚嶄箟涓婄淮鎸佺З搴忥紝瀹炲垯澶瑰湪澶氭柟涔嬮棿銆?, motivation: '鎯冲湪瀹橀潰涓庢睙婀栦箣闂翠繚浣忓簳绾裤€?, relationshipHooks: ['鐭ラ亾閫氱級涓庢悳鏌ュ姩鍚?, '鑳戒复鏃跺帇浣忛夯鐑?] }, - { role: '鐭垮満鎶婂ご', description: '鎵嬮噷鏈変汉鎵嬩笌杩涘嚭璁板綍锛岃杩囧お澶氱瀵嗚杞︺€?, motivation: '鎯抽樆姝㈡煇鎵瑰嵄闄╃熆鏂欑户缁娴併€?, relationshipHooks: ['鎺屾彙鐭胯剦鍏ュ彛', '鐭ラ亾璋佸湪澶滈噷杩愯揣'] }, - { role: '鏃у啗鍋忓皢', description: '閫€涓嬫潵鍚庝粛淇濈暀鍐涗腑鏁撮】鎯呮姤鐨勪範鎯€?, motivation: '鎯虫煡娓呮棫鍐涜В浣撳墠鐨勬渶鍚庝竴绾稿啗浠ゃ€?, relationshipHooks: ['鑳芥彁渚涙垬鏈缓璁?, '鐭ラ亾鏃у啗鍒嗚鍘熷洜'] }, - { role: '閾佸尃閾哄尃甯?, description: '浠庡叺鍒冪己鍙i噷灏辫兘鐪嬪嚭涓€鍦烘垬鏂楀彂鐢熶簡浠€涔堛€?, motivation: '鎯虫壘鍒版瘉鎺夊笀鎵夸箣鍒冪殑浜恒€?, relationshipHooks: ['鑳借鲸璁ゆ鍣ㄥ嚭澶?, '鎳傚緱淇ˉ涓庨噸閾?] }, - ], - [WorldType.XIANXIA]: [ - { role: '澶栭棬鎵т簨', description: '缁存寔瀹楅棬澶栧洿绉╁簭锛屼篃鏈€鍏堟帴瑙﹀紓甯搞€?, motivation: '鎯抽樆姝笂灞傜户缁妸鐏惧彉褰撴垚鍐呴儴鏈哄瘑銆?, relationshipHooks: ['鑳藉崗璋冨畻闂ㄦ祦绋?, '鐭ラ亾璋佽鍒绘剰鍘嬩笅'] }, - { role: '瑙傛槦淇+', description: '璐熻矗鎺ㄦ紨澶╄薄涓庣伒娼妭鐐癸紝璁板綍涓栫晫澶辫 銆?, motivation: '鎯抽伩鍏嶄笅涓€娆″ぇ瑙勬ā寮傝薄澶辨帶銆?, relationshipHooks: ['鍙В璇诲ぉ璞″彉鍖?, '鎺屾彙鏃舵満涓庤妭鐐?] }, - { role: '鐐煎櫒鍖犲笀', description: '鎿呴暱淇ˉ娉曞櫒涓庡彜鏃ц缃紝瀵瑰け鎺у櫒鍏峰緢鏁忔劅銆?, motivation: '鎯虫壘鍥炶浜虹洍璧扮殑鍏抽敭鍣ㄦ牳銆?, relationshipHooks: ['鑳借鲸璁ゆ硶鍣ㄦ潵婧?, '鑳戒慨澶嶇牬鎹熸満鍏?] }, - { role: '鐏垫鍖讳慨', description: '闀挎湡澶勭悊鐏垫疆涓庡紓鍙樺甫鏉ョ殑鍒涗激銆?, motivation: '鎯虫壘鍑鸿鐏垫鍜屼慨澹悓鏃跺け鎺х殑鐥呯伓銆?, relationshipHooks: ['鑳芥彁渚涜嵂鐞嗘敮鎻?, '鎺屾彙绂佸湴鐢熸€佸彉鍖?] }, - { role: '绉樺鍚戝', description: '甯稿勾鍦ㄧ澧冭竟缂樿鐢熸椿锛岀煡閬撳摢浜涜矾鑳芥椿鐫€鍥炴潵銆?, motivation: '鎯冲湪灞€鍔垮け鎺у墠鎹炲嚭浠嶅洶鍦ㄩ噷闈㈢殑浜恒€?, relationshipHooks: ['鐔熸倝鍗遍櫓璺嚎', '鐭ラ亾鍝被浜哄嚭涓嶆潵'] }, - { role: '鏁d慨鍟嗘梾', description: '鏉ュ線澶氬煙涔嬮棿锛屾秷鎭潅浣嗚寖鍥村箍銆?, motivation: '鎯宠秮鍔夸繚浣忚嚜宸辩殑鑸嚎涓庤揣婧愩€?, relationshipHooks: ['鎺屾彙寮傚煙浜ゆ槗鎯呮姤', '鑳藉甫鏉ョ█缂虹墿璧?] }, - { role: '瀹堥樀浜?, description: '璐熻矗缁存寔杈圭晫娉曢樀锛屾槸鏈€鏃╁惉瑙佸紓鍝嶇殑浜轰箣涓€銆?, motivation: '鎯冲湪娉曢樀瀹屽叏宕╂簝鍓嶆壘鍒颁唬鏇挎柟妗堛€?, relationshipHooks: ['浜嗚В娉曢樀寮辩偣', '鑳借В閲婅缂濇墿寮犺抗璞?] }, - { role: '鐏佃垷鑸垫墜', description: '鏉ュ線浜戞捣鍜岃闅欎箣闂达紝瀵归珮绌洪€氳矾鏋佺啛銆?, motivation: '鎯虫煡鏄庢煇娆″潬鑸熶簨鏁呯┒绔熸槸璋佸姩鎵嬨€?, relationshipHooks: ['鑳藉甫璺┛瓒婂嵄闄╃┖鍩?, '鐭ラ亾璋佹帶鍒惰埅璺?] }, - { role: '鏃у彶鎶勫綍鑰?, description: '淇濈暀浜嗚澶氳鍒犳敼杩囩殑鏃ф椂浠h杩般€?, motivation: '鎯虫妸鏂鍘嗗彶閲嶆柊鎷兼帴鎴愬畬鏁磋剦缁溿€?, relationshipHooks: ['鎺屾彙鍙ゆ棫璁板綍', '鑳藉府鍔╂牎瀵逛紶璇寸湡鍋?] }, - { role: '宸$晫鍓戜慨', description: '甯搁┗瑁傜晫鍓嶇嚎锛屽寮傜晫鐢熺伒鍜屾尝鍔ㄩ兘鏈夌粡楠屻€?, motivation: '鎯虫煡娓呭墠绾垮薄灞″け瀹堢殑鐪熸鍘熷洜銆?, relationshipHooks: ['鑳芥彁渚涘墠绾胯闂?, '鎿呴暱澶勭悊楂樺帇鍐茬獊'] }, - ], -}; - -const LANDMARK_TEMPLATES: Record< - WorldTemplateType, - Array<{ - name: string; - description: string; - }> -> = { - [WorldType.WUXIA]: [ - { name: '鏂ˉ鏃ч┛', description: '澶氭柟鍔垮姏閮戒細鍦ㄦ鐭殏鍋滅暀锛屾秷鎭笌璐х墿娴佸悜鏋佷贡銆? }, - { name: '閿侀灞遍棬', description: '琛ㄩ潰娌夊瘋锛屽疄鍒欓棬绂佷笌鏆楁祦骞跺瓨锛岄€傚悎浣滀负鍔垮姏鍐茬獊鐒︾偣銆? }, - { name: '鍐烽搧鐭垮満', description: '鐭挎枡銆佸叺鍒冧笌鏃ф绾跨储閮藉湪姝や氦姹囷紝澶滈噷鏇村嵄闄┿€? }, - { name: '琛€鐥曞瘑宸?, description: '璁稿鍏抽敭浜虹墿閮芥浘浠庤繖閲岃繘鍑猴紝鍗存病浜烘効鎰忕暀涓嬪悕瀛椼€? }, - { name: '闆炬爤娓″彛', description: '鐧藉ぉ鍍忔甯告浮鍙o紝澶滈噷鍒欐祦閫氭渶瑙佷笉寰楀厜鐨勪笢瑗裤€? }, - { name: '鍙ょ鍓嶅涵', description: '浼犻椈涓庢棫瑾撴湁鍏崇殑鐭崇绔嬪湪杩欓噷锛屽父寮曟潵涓嶅悓绔嬪満鐨勪汉銆? }, - ], - [WorldType.XIANXIA]: [ - { name: '瑁傜晫浜戦棬', description: '楂樼┖闂ㄩ槞浼氭槧鍑虹伒娼簮澶达紝涔熸槸瑙傚療寮傝薄鐨勬渶濂戒綅缃箣涓€銆? }, - { name: '娌夋槦鑽胺', description: '鐪嬩技闈欒哀锛屽疄鍒欒寮傚父鐏垫皵鎸佺画渚佃殌锛岄€傚悎灞曞紑璋冩煡涓庢晳娌荤嚎銆? }, - { name: '鍙ら櫒瑙傛槦鍙?, description: '淇濆瓨鐫€鏃ф椂浠f帹婕旇褰曪紝鑳界壍鍑鸿鎶瑰幓鐨勬棫鍙层€? }, - { name: '鏃犵伅鐣屼簳', description: '瓒婃帴杩戜簳鍙h秺鑳藉惉瑙佹潵鑷鐣屾繁澶勭殑鍥炲搷锛屾槸楂樺嵄鏍稿績鍖恒€? }, - { name: '娴垷娈嬫腐', description: '澶变簨鐏佃垷涓庨粦甯備氦鏄撻兘鍦ㄦ鐣欎笅鐥曡抗锛岄€傚悎灞曞紑璧勬簮绾裤€? }, - { name: '鐏扮儸鍣ㄥ潑', description: '娉曞櫒纰庣墖涓庣鍒舵畫楠稿湪姝ゅ爢绉紝甯告湁鍖犱慨涓庡娍鍔涗簤澶恒€? }, - ], -}; - -const PROCEDURAL_ITEM_TEMPLATES: Record< - WorldTemplateType, - Array<{ - category: string; - rarity: ItemRarity; - prefix: string; - noun: string; - description: string; - tags: string[]; - }> -> = { - [WorldType.WUXIA]: [ - { category: '姝﹀櫒', rarity: 'rare', prefix: '鏂?, noun: '閿嬪垉', description: '閫傚悎姹熸箹杩戞垬涓庢棫妗堣拷鏌ョ嚎鐨勫鐢ㄥ叺鍣ㄣ€?, tags: ['weapon', '姹熸箹'] }, - { category: '鎶ょ敳', rarity: 'uncommon', prefix: '琛?, noun: '鏃呯敳', description: '閫傚悎闀跨嚎濂旇蛋涓庡闂存綔琛岀殑杞昏銆?, tags: ['armor', '鎺㈢储'] }, - { category: '娑堣€楀搧', rarity: 'common', prefix: '鍥?, noun: '椋庤嵂', description: '鐢ㄤ簬涓存椂璋冩暣鐘舵€佷笌缁х画鎺㈢储銆?, tags: ['healing', '琛ョ粰'] }, - { category: '鏉愭枡', rarity: 'common', prefix: '鍐?, noun: '閾佹枡', description: '鍙敤浜庨噸閾稿叺鍒冩垨鍏呭綋浜ゆ槗绱犳潗銆?, tags: ['material', '閿婚€?] }, - { category: '绋€鏈夊搧', rarity: 'epic', prefix: '鏃?, noun: '妗堟畫椤?, description: '涓庝笘鐣屼富绾垮叧绯荤揣瀵嗙殑绋€鏈夌嚎绱㈢墿銆?, tags: ['rare', '绾跨储'] }, - ], - [WorldType.XIANXIA]: [ - { category: '姝﹀櫒', rarity: 'rare', prefix: '瑁?, noun: '鐏靛垉', description: '閫傚悎鍓嶇嚎宸$晫涓庤闅欎綔鎴樼殑娉曞叺銆?, tags: ['weapon', '鐏靛垉'] }, - { category: '鎶ょ敳', rarity: 'uncommon', prefix: '浜?, noun: '绾硅。', description: '鍏奸【鏈哄姩涓庣伒鍘嬮槻鎶ょ殑鎶よ韩娉曡。銆?, tags: ['armor', '娉曡。'] }, - { category: '娑堣€楀搧', rarity: 'common', prefix: '鍥?, noun: '鐏甸湶', description: '鐢ㄤ簬鎭㈠姘旇銆佹硶鍔涙垨绋冲畾鐏垫疆渚佃殌銆?, tags: ['mana', '琛ョ粰'] }, - { category: '鏉愭枡', rarity: 'common', prefix: '娌?, noun: '鏄熺爞', description: '甯歌浜庡櫒淇笌闃典慨鐨勫熀纭€鏉愭枡銆?, tags: ['material', '鍣ㄤ慨'] }, - { category: '绋€鏈夊搧', rarity: 'epic', prefix: '鍙?, noun: '鍩熸畫鐗?, description: '璁板綍鏃ф椂浠h竟鐣屼笌绂佸埗淇℃伅鐨勫叧閿畫鐗囥€?, tags: ['rare', '鏃у彶'] }, - ], -}; - -export interface CustomWorldBuilderOptions { - itemCatalogEntries?: ItemCatalogEntry[]; -} - -export function buildExpandedCustomWorldProfile( - raw: unknown, - settingText: string, - options: CustomWorldBuilderOptions = {}, -): CustomWorldProfile { - const normalizedProfile = normalizeCustomWorldProfile(raw, settingText); - const fallbackProfile = buildFallbackCustomWorldProfile(settingText); - const baseProfile = normalizedProfile ?? fallbackProfile; - - const playableNpcs = expandPlayableNpcs(baseProfile); - const storyNpcs = expandStoryNpcs(baseProfile); - const landmarks = expandLandmarks(baseProfile); - const items: CustomWorldItem[] = []; - - void options; - - return { - ...baseProfile, - playableNpcs, - storyNpcs, - items, - landmarks, - }; -} - -function expandPlayableNpcs(profile: CustomWorldProfile) { - const uniqueBase: ExpandedPlayableNpc[] = dedupeByName(profile.playableNpcs).map((npc, index) => ({ - ...npc, - templateCharacterId: npc.templateCharacterId ?? PLAYABLE_TEMPLATE_CHARACTER_IDS[index], - tags: normalizeTags(npc.tags), - })); - const next: ExpandedPlayableNpc[] = [...uniqueBase]; - let templateIndex = next.length; - let safety = 0; - - while (next.length < MIN_PLAYABLE_NPC_COUNT && safety < 40) { - const filler = buildPlayableFallbackNpc(profile, templateIndex); - if (!next.some(item => item.name === filler.name)) { - next.push(filler); - } - templateIndex += 1; - safety += 1; - } - - return next - .slice(0, MIN_PLAYABLE_NPC_COUNT) - .map((npc, index) => ({ - ...npc, - id: createEntryId('playable-npc', npc.name, index), - templateCharacterId: PLAYABLE_TEMPLATE_CHARACTER_IDS[index], - tags: mergeCustomWorldPlayableNpcTags(profile, npc, { - templateCharacterId: PLAYABLE_TEMPLATE_CHARACTER_IDS[index], - maxCount: 5, - }), - })); -} - -function expandStoryNpcs(profile: CustomWorldProfile) { - const uniqueBase = dedupeByName(profile.storyNpcs).map((npc, index) => ({ - ...npc, - id: createEntryId('story-npc', npc.name, index), - relationshipHooks: normalizeHooks(npc.relationshipHooks), - })); - const next = [...uniqueBase]; - let templateIndex = 0; - let safety = 0; - - while (next.length < MIN_STORY_NPC_COUNT && safety < 240) { - const filler = buildStoryNpcFallback(profile, next.length, templateIndex); - if (!next.some(item => item.name === filler.name)) { - next.push(filler); - } - templateIndex += 1; - safety += 1; - } - - return next - .slice(0, MIN_STORY_NPC_COUNT) - .map((npc, index) => ({ - ...npc, - id: createEntryId('story-npc', npc.name, index), - relationshipHooks: normalizeHooks(npc.relationshipHooks), - imageSrc: npc.imageSrc || getDefaultCustomWorldNpcImage(profile.id || profile.name, index), - })); -} - -function expandLandmarks(profile: CustomWorldProfile) { - const uniqueBase = dedupeByName(profile.landmarks); - const next = [...uniqueBase]; - let templateIndex = 0; - let safety = 0; - - while (next.length < MIN_LANDMARK_COUNT && safety < 40) { - const filler = buildLandmarkFallback(profile, next.length, templateIndex); - if (!next.some(item => item.name === filler.name)) { - next.push(filler); - } - templateIndex += 1; - safety += 1; - } - - return next - .slice(0, Math.max(uniqueBase.length, MIN_LANDMARK_COUNT)) - .map((landmark, index) => ({ - ...landmark, - id: createEntryId('landmark', landmark.name, index), - description: clampText(landmark.description, 72), - imageSrc: landmark.imageSrc || getDefaultCustomWorldSceneImage( - profile.id || profile.name, - index, - resolveCustomWorldAnchorWorldType(profile), - ), - })); -} - -function expandItems(profile: CustomWorldProfile, itemCatalogEntries: ItemCatalogEntry[]) { - const featuredSeeds = dedupeByName(profile.items).slice(0, FEATURED_ITEM_COUNT); - const rankedCatalogEntries = rankCatalogEntries(itemCatalogEntries, resolveCustomWorldAnchorWorldType(profile)); - const usedCatalogIds = new Set(); - - const featuredItems = featuredSeeds.map((item, index) => { - const matchedCatalog = matchCatalogEntry(item, rankedCatalogEntries, usedCatalogIds); - if (matchedCatalog) { - usedCatalogIds.add(matchedCatalog.id); - } - return buildFeaturedItem(item, matchedCatalog, index); - }); - - const catalogItems = rankedCatalogEntries - .filter(entry => !usedCatalogIds.has(entry.id)) - .slice(0, Math.max(0, MIN_ITEM_COUNT - featuredItems.length)) - .map((entry, index) => buildCatalogBackedItem(entry, profile, featuredItems.length + index)); - - const nextItems = dedupeByName([...featuredItems, ...catalogItems]); - let fillerIndex = 0; - - while (nextItems.length < MIN_ITEM_COUNT) { - const filler = buildProceduralItem(profile, nextItems.length, fillerIndex); - if (!nextItems.some(item => item.name === filler.name)) { - nextItems.push(filler); - } - fillerIndex += 1; - } - - return nextItems - .slice(0, MIN_ITEM_COUNT) - .map((item, index) => ({ - ...item, - id: createEntryId('item', item.name, index), - description: clampText(item.description, 72), - tags: normalizeTags(item.tags), - })); -} - -function buildPlayableFallbackNpc(profile: CustomWorldProfile, index: number): ExpandedPlayableNpc { - const anchorWorldType = resolveCustomWorldAnchorWorldType(profile); - const templates = PLAYABLE_FILL_TEMPLATES[anchorWorldType]; - const template = templates[index % templates.length]; - const name = buildGeneratedName(profile, index); - - return { - id: createEntryId('playable-npc', name, index), - name, - title: template.title, - description: template.description, - backstory: `${name}${template.backstory} 浠栬繘鍏?{profile.name}鐨勭悊鐢憋紝鏈€缁堥兘鎸囧悜鈥?{profile.playerGoal}鈥濄€俙, - personality: template.personality, - combatStyle: template.combatStyle, - tags: normalizeTags(template.tags, [anchorWorldType === WorldType.XIANXIA ? '浠欏煙' : '姹熸箹']), - templateCharacterId: PLAYABLE_TEMPLATE_CHARACTER_IDS[index % PLAYABLE_TEMPLATE_CHARACTER_IDS.length], - }; -} - -function buildStoryNpcFallback(profile: CustomWorldProfile, index: number, templateIndex: number): CustomWorldNpc { - const templates = STORY_NPC_TEMPLATES[resolveCustomWorldAnchorWorldType(profile)]; - const template = templates[templateIndex % templates.length]; - const name = buildGeneratedName(profile, index + templateIndex + 7); - - return { - id: createEntryId('story-npc', name, index), - name, - role: template.role, - description: `${template.description} 涓庘€?{profile.name}鈥濆綋鍓嶅眬鍔垮叧绯诲瘑鍒囥€俙, - motivation: `${template.motivation} 鐪间笅鏈€鍏冲績鐨勶紝鏄€?{profile.playerGoal}鈥濅細鎶婂眬闈㈡帹鍚戝摢閲屻€俙, - relationshipHooks: normalizeHooks([ - template.relationshipHooks[0], - template.relationshipHooks[1], - ]), - }; -} - -function buildLandmarkFallback(profile: CustomWorldProfile, index: number, templateIndex: number): CustomWorldLandmark { - const templates = LANDMARK_TEMPLATES[resolveCustomWorldAnchorWorldType(profile)]; - const template = templates[templateIndex % templates.length]; - const suffix = index >= templates.length ? `绗?{index - templates.length + 2}鍖篳 : ''; - const name = suffix ? `${template.name}${suffix}` : template.name; - - return { - id: createEntryId('landmark', name, index), - name, - description: `${template.description} 杩欓噷涓庘€?{profile.playerGoal}鈥濇湁鐩存帴鑱旂郴銆俙, - dangerLevel: template.dangerLevel, - }; -} - -function buildFeaturedItem( - item: CustomWorldItem, - matchedCatalog: ItemCatalogEntry | null, - index: number, -): CustomWorldItem { - const mechanics = inferCustomItemMechanics( - item.category, - item.rarity, - normalizeTags(item.tags, matchedCatalog?.tags ?? []), - matchedCatalog?.id ?? item.id ?? `${item.name}-${index}`, - ); - return { - id: createEntryId('item', item.name, index), - name: clampText(item.name, 24), - category: item.category, - rarity: item.rarity, - description: clampText(item.description, 72), - tags: normalizeTags(item.tags, matchedCatalog?.tags ?? []), - iconSrc: matchedCatalog?.iconSrc, - sourcePath: matchedCatalog?.sourcePath, - origin: 'generated', - equipmentSlotId: mechanics.equipmentSlotId ?? item.equipmentSlotId ?? null, - statProfile: mechanics.statProfile ?? item.statProfile ?? null, - useProfile: mechanics.useProfile ?? item.useProfile ?? null, - value: item.value ?? mechanics.value, - }; -} - -function buildCatalogBackedItem( - entry: ItemCatalogEntry, - profile: CustomWorldProfile, - index: number, -): CustomWorldItem { - const worldProfile = entry.worldProfiles?.[resolveCustomWorldAnchorWorldType(profile)]; - const themedName = buildThemedItemName( - profile, - entry.category, - entry.id, - index, - ); - const themedDescription = buildThemedItemDescription( - profile, - entry.category, - entry.rarity, - entry.id, - ); - - return { - id: createEntryId('item', themedName, index), - name: clampText(themedName, 24), - category: entry.category, - rarity: entry.rarity, - description: clampText(themedDescription || worldProfile?.description || entry.description, 72), - tags: normalizeTags(entry.tags, ['绱犳潗搴?]), - iconSrc: entry.iconSrc, - sourcePath: entry.sourcePath, - origin: 'catalog', - equipmentSlotId: entry.equipmentSlotId ?? null, - statProfile: entry.statProfile ?? null, - useProfile: entry.useProfile ?? null, - value: entry.value, - }; -} - -function buildProceduralItem( - profile: CustomWorldProfile, - index: number, - templateIndex: number, -): CustomWorldItem { - const anchorWorldType = resolveCustomWorldAnchorWorldType(profile); - const template = PROCEDURAL_ITEM_TEMPLATES[anchorWorldType][ - templateIndex % PROCEDURAL_ITEM_TEMPLATES[anchorWorldType].length - ]; - const name = buildThemedItemName(profile, template.category, `${template.prefix}-${template.noun}`, index); - const mechanics = inferCustomItemMechanics( - template.category, - template.rarity, - normalizeTags(template.tags), - `${template.prefix}-${template.noun}-${index}`, - ); - - return { - id: createEntryId('item', name, index), - name, - category: template.category, - rarity: template.rarity, - description: buildThemedItemDescription(profile, template.category, template.rarity, `${template.prefix}-${template.noun}-${index}`), - tags: normalizeTags(template.tags, [anchorWorldType === WorldType.XIANXIA ? '浠欏煙' : '姹熸箹']), - origin: 'generated', - equipmentSlotId: mechanics.equipmentSlotId ?? null, - statProfile: mechanics.statProfile ?? null, - useProfile: mechanics.useProfile ?? null, - value: mechanics.value, - }; -} - -function rankCatalogEntries(entries: ItemCatalogEntry[], worldType: WorldType) { - const affinity = worldType === WorldType.XIANXIA ? 'xianxia' : 'wuxia'; - const preferred = entries.filter(entry => entry.worldAffinity === affinity); - const neutral = entries.filter(entry => entry.worldAffinity === 'neutral'); - const fallback = entries.filter(entry => entry.worldAffinity !== affinity && entry.worldAffinity !== 'neutral'); - return dedupeById([...preferred, ...neutral, ...fallback]); -} - -function matchCatalogEntry( - seedItem: CustomWorldItem, - entries: ItemCatalogEntry[], - usedCatalogIds: Set, -) { - const normalizedCategory = seedItem.category.trim(); - const normalizedTags = new Set(seedItem.tags.map(tag => tag.toLowerCase())); - - const directMatch = entries.find(entry => - !usedCatalogIds.has(entry.id) - && entry.category === normalizedCategory, - ); - if (directMatch) { - return directMatch; - } - - return entries.find(entry => - !usedCatalogIds.has(entry.id) - && entry.tags.some(tag => normalizedTags.has(tag.toLowerCase())), - ) ?? null; -} - -function buildGeneratedName(profile: Pick, index: number) { - const seed = hashText(`${profile.name}:${profile.settingText}:${index}`); - const surname = NAME_SURNAMES[seed % NAME_SURNAMES.length]; - const prefix = NAME_GIVEN_PREFIXES[(seed >>> 3) % NAME_GIVEN_PREFIXES.length]; - const suffix = NAME_GIVEN_SUFFIXES[(seed >>> 5) % NAME_GIVEN_SUFFIXES.length]; - return `${surname}${prefix}${suffix}`; -} - -function normalizeTags(tags: string[], fallbackTags: string[] = []) { - return [...new Set([...tags, ...fallbackTags].map(tag => tag.trim()).filter(Boolean))].slice(0, 5); -} - -function normalizeHooks(hooks: string[]) { - const normalized = [...new Set(hooks.map(hook => hook.trim()).filter(Boolean))]; - if (normalized.length >= 2) { - return normalized.slice(0, 3); - } - - return [...normalized, '鎺屾彙鍙户缁繁鎸栫殑鍏抽敭绾跨储', '浼氭牴鎹帺瀹剁珛鍦烘敼鍙樺悎浣滄繁搴?].slice(0, 3); -} - -function clampText(value: string, maxLength: number) { - const normalized = value.trim().replace(/\s+/g, ' '); - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}鈥; -} - -function dedupeByName(items: T[]) { - const seen = new Set(); - return items.filter(item => { - const key = item.name.trim(); - if (!key || seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); -} - -function dedupeById(items: T[]) { - const seen = new Set(); - return items.filter(item => { - if (!item.id || seen.has(item.id)) { - return false; - } - seen.add(item.id); - return true; - }); -} - -function createEntryId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function slugify(value: string) { - const ascii = value - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') - .replace(/^-+|-+$/g, ''); - - if (ascii) { - return ascii.slice(0, 24); - } - - return 'entry'; -} - -function hashText(value: string) { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) >>> 0; - } - return hash; -} diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0da9c2d2..9c860439 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -60,7 +60,13 @@ module.exports = { 'media', '.codex-logs', '*.log', - 'npc-editor-dom.html', + '.preview.*', + 'temp-build-goal-check/**', + 'tmp_*', + 'tmp/**', + 'npc-editor-*', + 'temp-write-check.txt', + '**/__pycache__/**', '*.timestamp-*.mjs', ], rules: { diff --git a/.gitignore b/.gitignore index 2b9317b9..3c487a54 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,15 @@ dist/ coverage/ .DS_Store *.log +/.codex-logs/ +.preview.* +tmp_* +tmp/ +npc-editor-* +temp-write-check.txt +temp-build-goal-check/ +**/__pycache__/ +*.py[cod] /public/generated-custom-world-scenes temp*build*/ /server-node/dist/ diff --git a/.prettierignore b/.prettierignore index 512c6b44..4592c0b6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,5 +5,10 @@ node_modules public/Icons media *.log -npc-editor-dom.html -npc-editor-console.log +.preview.* +tmp_* +tmp/ +temp-build-goal-check/ +npc-editor-* +temp-write-check.txt +**/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index ddd2492e..5cb0b911 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ - 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。 - 禁止将功能说明描述类的文本默认写入UI界面中。 - prd文档中每个模块的描述要落地设计到可以精准编码到位,不能出现需求落地漂移。 +- 点击按钮弹出独立的面板的设计不要实现成在当前面板下面显示内容。 ## 文档图谱 diff --git a/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md index e8291b8c..d06ee069 100644 --- a/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md +++ b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md @@ -124,7 +124,7 @@ 工具链: -- `scripts/dev-server/localApiPlugins.ts`:`1504` 行 +- `scripts/dev-server/*.ts`:已于 `2026-04-19` 删除,旧 Vite 本地 API 链路不再保留实现代码 #### 影响 @@ -145,27 +145,27 @@ --- -### P1-2:继续收口 editor / assets 工具链边界 +### P1-2:继续收口 editor / assets 工具链边界(旧链路已删除) 这项的重要性正在上升。 #### 证据 - `docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md` 已说明 editor/assets API 已经迁到 `server-node`,方向是对的。 -- 但当前仓库里仍保留一个 `1504` 行的 `scripts/dev-server/localApiPlugins.ts`。 +- `scripts/dev-server/*.ts` 旧 Vite 本地 API 实现代码已于 `2026-04-19` 删除,仓库里不再保留并行实现。 - 目录 `temp-build-goal-check/` 当前包含 `15099` 个文件,已经开始干扰 lint 和本地开发信号。 - 相关日志里还出现了大量指向 `temp-build-goal-check` 的页面 reload 与 `ENOENT` 噪音。 #### 影响 -- 旧工具链虽然“不再是主入口”,但它们还在继续占据认知空间和仓库噪音预算。 -- 新旧 editor/assets 路径长期并存,会导致维护者很难快速判断哪条链才是正式路径。 +- editor/assets 正式入口已经收口到 `server-node`,这部分双链路问题已解除。 +- 当前更大的噪音来源已经转移到临时构建目录、检查目录和历史日志残留。 #### 当前建议 -1. 明确把旧 Vite 插件链标记为迁移参考,避免继续被误用。 +1. 保持 `scripts/dev-server/README.md` 作为迁移结果标记,不要恢复旧 Vite `/api/*` 本地插件链。 2. 将临时构建目录、检查目录、导出目录统一移出主工程扫描面。 -3. 对 editor/assets 正式入口补一份“唯一推荐入口”文档或 README 更新,减少后续回流。 +3. 继续以 `server-node/src/modules/editor/**`、`server-node/src/modules/assets/**` 与 `src/editor/shared/editorApiClient.ts` 作为唯一推荐入口,减少后续回流。 --- diff --git a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md index b6a5ac7f..58a6ce54 100644 --- a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md +++ b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md @@ -1,6 +1,91 @@ # 工程清理与后端边界审计(2026-04-19) -更新时间:`2026-04-19` +更新时间:`2026-04-20` + +## 0.1 执行回填(2026-04-19) + +本文审计项 `3.2` 与 `4.4` 已于 `2026-04-19` 当日完成首轮处置: + +1. 已删除 `scripts/dev-server/localApiPlugins.ts` +2. 已删除 `scripts/dev-server/characterAssetStudioPlugins.ts` +3. 已删除 `scripts/dev-server/qwenSpriteSheetToolPlugins.ts` +4. `scripts/dev-server/` 目录仅保留迁移说明,不再保留旧 Vite 本地 API 实现代码 +5. 当前正式入口统一为 `scripts/dev-node.mjs + vite proxy + server-node/src/modules/**` + +本文其余段落保留为本次审计时的原始问题快照,用于解释为什么要做这轮删除。 + +## 0.2 执行回填(2026-04-19,仓库噪音产物) + +本文审计项 `3.1` 已于 `2026-04-19` 当日完成首轮处置: + +1. 已从版本库删除以下根目录历史扫描/截图产物: + - `npc-editor-dom.html` + - `npc-editor-shot.png` + - `temp-write-check.txt` + - `tmp_character_presets_scan.txt` + - `tmp_jsx_text_scan.txt` + - `tmp_runtime_text_scan.txt` + - `tmp_text_candidates.txt` + - `tmp_text_candidates_refined.txt` + - `tmp_visible_props_scan.txt` + - `tmp_volc_seedance_doc.html` +2. 已从版本库删除 `scripts/__pycache__/generate-build-tag-similarity.cpython-313.pyc`。 +3. 已清理本地工作区中的 `.codex-*.log`、`.preview.*`、`npc-editor-console.log` 与 `temp-build-goal-check/`,清理前对应体量约为: + - 根目录噪音文件 `60` 个,约 `49.94 MB` + - `temp-build-goal-check/` 共 `15620` 个条目,约 `158.85 MB` +4. 已补齐 `.gitignore`、`.prettierignore` 与 `.eslintrc.cjs` 的忽略口径,显式覆盖 `tmp_*`、`tmp/`、`npc-editor-*`、`temp-write-check.txt`、`temp-build-goal-check/`、`__pycache__/`。 +5. `scripts/dev-server/localApiPlugins.ts` 之外的后端边界收口项不在本轮噪音清理范围内,后续继续按本文第二至第四阶段推进。 + +## 0.3 执行回填(2026-04-19,运行时边界第一轮收口) + +本文审计项 `4.1` 与 `5.1` 已于 `2026-04-19` 当日完成一轮工程收口: + +1. `RuntimeStoryOptionView` 现在由后端直接附带 `interaction` 元数据。 +2. `server-node/src/modules/story/runtimeSession.ts` 已成为 runtime option interaction 的唯一构建位置。 +3. `src/services/runtimeStoryService.ts` 不再根据 `currentEncounter + functionId` 在前端本地重建一份 interaction 映射。 +4. `/api/custom-world/scene-image` 已补齐服务端 prompt 兜底组装能力,允许前端只提交 `profile + landmark + userPrompt` 上下文。 +5. `src/services/aiService.ts` 的场景图 SDK 已改为直接调用后端接口,不再为了该链路动态加载 `src/services/ai.ts`。 + +## 0.4 执行回填(2026-04-19,自定义世界后端边界第二轮收口) + +本文审计项 `5.2` 与“第三阶段第 4 条:清理 `server-node -> src/**` 的反向依赖”已于 `2026-04-19` 当日完成第二轮工程收口: + +1. `server-node/src/modules/custom-world/` 已新增服务端自持 runtime 模块,承接: + - `creator intent` 归一化 + - `anchorPack / lockState` 推导 + - custom world framework/profile compile 与 normalize +2. `server-node/src/modules/ai/customWorldOrchestrator.ts` 与 `server-node/src/services/customWorldAgentFoundationDraftService.ts` 已不再运行时依赖: + - `src/services/customWorld.js` + - `src/services/customWorldBuilder.js` + - `src/services/customWorldCreatorIntent.js` + - `src/types.js` +3. `server-node/src/prompts/customWorldPrompts.ts` 已成为后端自持的 custom world prompt source,`scene image` 与 `foundation draft` 相关 builder 不再从前端 `src/prompts/customWorldPrompts.ts` 反向 import。 +4. 本轮只迁移 prompt source 位置,没有改动任何 custom world 提示词正文,也没有改动功能需求。 + +## 0.5 执行回填(2026-04-20,NPC 待接委托正式接取收口) + +本文审计项 `5.3` 已于 `2026-04-20` 完成一轮补充收口: + +1. `src/hooks/story/npcEncounterActions.ts` 中“聊天里的待接委托正式接取”已不再由前端本地直接写入: + - `quests` + - `runtimeStats.questsAccepted` + - `npcChatState.pendingQuestOffer` +2. `server-node/src/modules/quest/questStoryActionService.ts` 现在会优先读取服务端快照里已保存的 `pendingQuestOffer.quest`,按当前聊天态中已经展示给玩家的那份委托完成正式接取。 +3. `server-node/src/modules/story/storyActionService.ts` 已补齐待接委托接取后的聊天态投影: + - 保留 NPC 对话展示模式 + - 清空 `pendingQuestOffer` + - 回到既有的三条自由追问建议 +4. 本轮没有新增任何 runtime functionId,也没有改动任务生成提示词或任务需求,只是把既有“接任务”正式结算权收回到后端。 + +## 0.6 执行回填(2026-04-20,NPC 聊天任务草案与浏览器 LLM fallback 收口) + +本文审计项 `5.1` 与 `5.3` 已于 `2026-04-20` 完成一轮补充收口: + +1. `server-node/src/modules/ai/chatOrchestrator.ts` 现在会基于 `NPC chat turn` 的运行时上下文,在后端判断是否触发 `pendingQuestOffer`,并把 quest draft 与引导文案一并回填给前端。 +2. `src/hooks/story/npcEncounterActions.ts` 不再在 NPC 单轮聊天完成后本地调用 `generateQuestForNpcEncounter(...)` 再决定是否挂出待接委托。 +3. `src/services/questDirector.ts` 浏览器端在后端失败时不再退回本地 LLM 生成 quest draft,而是直接走 deterministic fallback compile。 +4. `src/services/runtimeItemAiDirector.ts` 浏览器端在后端失败时不再退回本地 LLM 生成 runtime item intent,而是直接返回 deterministic fallback intents。 +5. 本轮仍未改动任何业务提示词正文,也没有改动 quest / runtime item 的需求能力面,只是继续清理浏览器里的正式 AI orchestration 残留。 ## 0. 审计目标 @@ -71,11 +156,11 @@ ### 证据 -| 项目 | 当前证据 | 判断 | -| --- | --- | --- | -| 根目录日志/临时文件 | 根目录命中 `60` 个 `.codex-*.log`、`.preview.*`、`tmp_*`、`npc-editor-*`、`temp-write-check.txt`,合计约 `52.36 MB` | 已经不是偶发临时文件,而是长期堆积的开发残留 | -| `temp-build-goal-check/` | 当前包含 `15099` 个文件,合计约 `166.56 MB` | 大体量检查产物,应该移出主工程视野 | -| Python 缓存 | 当前存在 `scripts/__pycache__/` | 纯缓存产物,不应长期留在仓库工作区中 | +| 项目 | 当前证据 | 判断 | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| 根目录日志/临时文件 | 根目录命中 `60` 个 `.codex-*.log`、`.preview.*`、`tmp_*`、`npc-editor-*`、`temp-write-check.txt`,合计约 `52.36 MB` | 已经不是偶发临时文件,而是长期堆积的开发残留 | +| `temp-build-goal-check/` | 当前包含 `15099` 个文件,合计约 `166.56 MB` | 大体量检查产物,应该移出主工程视野 | +| Python 缓存 | 当前存在 `scripts/__pycache__/` | 纯缓存产物,不应长期留在仓库工作区中 | ### 影响 @@ -123,20 +208,20 @@ ### 高置信度无入口/仅测试引用清单 -| 模块 | 证据 | 判断 | -| --- | --- | --- | -| `src/components/GameShell.tsx` | 文件体量 `761` 行;当前 `src/App.tsx` 只接入 `components/game-shell/GameShellRuntime.tsx`;仓库内无其它 import | 旧版壳层残留 | -| `src/components/custom-world-home/CustomWorldCreationHub.tsx` | 仅被 `CustomWorldCreationHub.test.tsx` 和 `CustomWorldCreationHub.interaction.test.tsx` 引用;`src/routing/appRoutes.tsx` 只有 `game` 和 `qwen-sprite-tool` 两条路由 | 已做出 UI,但未进入正式入口 | -| `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` | 当前无运行时引用 | 同属未接线入口壳层 | -| `src/components/custom-world-agent/*` 中 `9` 个子模块 | 当前合计约 `826` 行;典型文件包括 `CustomWorldAgentLauncherModal.tsx`、`CustomWorldAgentDraftDrawer.tsx`、`CustomWorldAgentLockBar.tsx`、`CustomWorldAgentQuickActions.tsx`、`CustomWorldAgentSummaryPanel.tsx`;部分文件完全无引用,部分仅被测试引用 | 处于“做了一部分 UI,但未进入主链”的孤岛状态 | -| `src/hooks/story/storyBootstrap.ts` | `250` 行,仓库内只定义不消费 | 已被新流程替代的可能性高 | -| `src/hooks/useEquipmentFlow.ts` / `useForgeFlow.ts` / `useInventoryFlow.ts` | 合计约 `393` 行,当前无运行时引用 | 旧流转层残留 | -| `src/editor/shared/cloneValue.ts` / `EditorEmptyState.tsx` / `EditorSelectionCard.tsx` / `useJsonSave.ts` | 当前无运行时引用 | editor 旧共享层碎片 | -| `src/services/customWorldPresentation.stub.ts` | 当前无引用,且文件本身就是 stub | 高置信度占位残留 | -| `src/services/typewriter.ts` | 当前无引用,仅提供一个 `getTypewriterDelay` | 已被其它链路内联实现替代 | -| `src/data/buildTagSimilarity.generated.ts` | 当前 `823` 行,仅能被生成脚本自身检索到,没有消费方 | 生成产物未接入任何业务链路 | -| `src/data/customWorldCharacterLoadout.stub.ts` | 当前无引用,且实现只返回空数组 | 占位残留 | -| `src/components/DeveloperTeamModal.tsx` / `src/components/LazySkillEffectPreview.tsx` | 当前无运行时引用 | 小体量零散孤岛 | +| 模块 | 证据 | 判断 | +| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `src/components/GameShell.tsx` | 文件体量 `761` 行;当前 `src/App.tsx` 只接入 `components/game-shell/GameShellRuntime.tsx`;仓库内无其它 import | 旧版壳层残留 | +| `src/components/custom-world-home/CustomWorldCreationHub.tsx` | 仅被 `CustomWorldCreationHub.test.tsx` 和 `CustomWorldCreationHub.interaction.test.tsx` 引用;`src/routing/appRoutes.tsx` 只有 `game` 和 `qwen-sprite-tool` 两条路由 | 已做出 UI,但未进入正式入口 | +| `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` | 当前无运行时引用 | 同属未接线入口壳层 | +| `src/components/custom-world-agent/*` 中 `9` 个子模块 | 当前合计约 `826` 行;典型文件包括 `CustomWorldAgentLauncherModal.tsx`、`CustomWorldAgentDraftDrawer.tsx`、`CustomWorldAgentLockBar.tsx`、`CustomWorldAgentQuickActions.tsx`、`CustomWorldAgentSummaryPanel.tsx`;部分文件完全无引用,部分仅被测试引用 | 处于“做了一部分 UI,但未进入主链”的孤岛状态 | +| `src/hooks/story/storyBootstrap.ts` | `250` 行,仓库内只定义不消费 | 已被新流程替代的可能性高 | +| `src/hooks/useEquipmentFlow.ts` / `useForgeFlow.ts` / `useInventoryFlow.ts` | 合计约 `393` 行,当前无运行时引用 | 旧流转层残留 | +| `src/editor/shared/cloneValue.ts` / `EditorEmptyState.tsx` / `EditorSelectionCard.tsx` / `useJsonSave.ts` | 当前无运行时引用 | editor 旧共享层碎片 | +| `src/services/customWorldPresentation.stub.ts` | 当前无引用,且文件本身就是 stub | 高置信度占位残留 | +| `src/services/typewriter.ts` | 当前无引用,仅提供一个 `getTypewriterDelay` | 已被其它链路内联实现替代 | +| `src/data/buildTagSimilarity.generated.ts` | 当前 `823` 行,仅能被生成脚本自身检索到,没有消费方 | 生成产物未接入任何业务链路 | +| `src/data/customWorldCharacterLoadout.stub.ts` | 当前无引用,且实现只返回空数组 | 占位残留 | +| `src/components/DeveloperTeamModal.tsx` / `src/components/LazySkillEffectPreview.tsx` | 当前无运行时引用 | 小体量零散孤岛 | ### 判断 @@ -521,4 +606,3 @@ 6. `vite.config.ts` 7. `.eslintrc.cjs` 8. `git grep` 对关键模块引用、后端跨层 import、localStorage、旧 dev 插件入口的扫描结果 - diff --git a/docs/design/NPC_HIGH_AFFINITY_CHAT_QUEST_OFFER_FLOW_2026-04-19.md b/docs/design/NPC_HIGH_AFFINITY_CHAT_QUEST_OFFER_FLOW_2026-04-19.md new file mode 100644 index 00000000..ee05ab5a --- /dev/null +++ b/docs/design/NPC_HIGH_AFFINITY_CHAT_QUEST_OFFER_FLOW_2026-04-19.md @@ -0,0 +1,316 @@ +# 高好感角色聊天内委托触发与领取流程设计 + +更新时间:`2026-04-19` + +## 0. 目标 + +这次迭代解决的是一个很具体的体验断层: + +1. 当前角色委托主要还是从 NPC 互动菜单里直接出现,缺少“先聊上 1-2 轮,再顺着上下文自然托付任务”的过渡。 +2. 聊天态虽然已经有多轮对话、自定义输入和选项建议,但没有“临时任务 offer”这一层中间状态。 +3. 现有任务详情面板已经能看任务、领奖励,但还不能承接“任务尚未正式入日志,只是对方刚提出委托”的场景。 + +目标不是新造一套聊天任务系统,而是把现有: + +- `npc_chat` 多轮聊天流 +- `generateQuestForNpcEncounter(...)` 任务生成链 +- `QuestLogEntry` 任务日志结构 +- `AdventurePanelOverlays` 任务详情面板 + +串成一条更自然的“聊天内委托”链路。 + +一句话目标: + +**当玩家与好感度大于 0 的角色聊天时,先寒暄 1-2 轮,再由角色顺着上下文提出委托;玩家可查看、换任务、放弃任务,确认领取后任务才正式进入日志,并恢复自由聊天。** + +## 1. 这次不做什么 + +为了避免系统边界漂移,这次明确不做下面这些事: + +1. 不重写任务生成器。 + - “任务”和“更换任务”都必须复用现有 `generateQuestForNpcEncounter(...)` 链路。 + - 也就是说,仍然走现在的 `evaluateQuestOpportunity -> AI / fallback quest intent -> compileQuestIntentToQuest`。 + +2. 不把聊天态任务 offer 直接视为已接任务。 + - 对方刚把委托提出来时,它还只是 `pending offer`,不应立即写进 `gameState.quests`。 + - 只有玩家点击“领取任务”后,才正式调用现有任务接取写入逻辑。 + +3. 不在 UI 默认堆说明文字。 + - 聊天态只切换三项操作: + - `查看任务` + - `更换任务` + - `放弃任务` + - 不额外在主界面堆功能说明。 + +4. 不改成必须走服务端聊天。 + - 当前多轮 NPC 聊天仍沿用前端本地的 `handleNpcChatTurn(...)` 流程。 + - 这次只是在聊天流程里插入任务 offer 状态。 + +## 2. 核心流程 + +## 2.1 触发条件 + +只有同时满足下面条件时,聊天中才允许提出委托: + +1. 当前遭遇是角色型 NPC。 + - `encounter.characterId` 存在。 + +2. 当前好感度大于 `0`。 + +3. 当前角色没有未结清任务。 + - 复用 `getQuestForIssuer(...)` 判断。 + +4. 当前聊天里还没有待处理的任务 offer。 + +5. 已经完成前置寒暄轮次。 + - 默认要求先完成 `1-2` 轮自然聊天。 + - 建议规则: + - `affinity >= 30` 时,完成 `1` 轮后即可进入委托时机。 + - `0 < affinity < 30` 时,完成 `2` 轮后再进入委托时机。 + +## 2.2 聊天轮次切换 + +正常聊天时: + +- 保持现有三条 `npc_chat` 建议选项 +- 保持自定义输入可用 + +当触发委托时: + +1. 先正常生成本轮 NPC 回复。 +2. 随后调用现有 `generateQuestForNpcEncounter(...)` 生成一份 `pending quest offer`。 +3. 在当前轮次追加一段 NPC 委托台词。 +4. 把当前轮次选项替换成: + - 第一项:`查看任务` + - 第二项:`更换任务` + - 第三项:`放弃任务` +5. 临时隐藏自定义输入。 + +这意味着聊天态此时进入一个短暂的“任务处理态”,直到玩家: + +- 查看并领取 +- 更换 +- 放弃 + +其中任一分支结算完成后,再恢复自由聊天。 + +## 2.3 查看任务 + +点击 `查看任务` 时: + +1. 不立即写入任务日志。 +2. 直接复用现有任务详情弹层展示 `pending quest offer` 的详情。 +3. 任务详情面板在这类任务上新增主按钮: + - `领取任务` + +这一步的关键是: + +**查看任务只是看,不等于接。** + +## 2.4 领取任务 + +点击 `领取任务` 时: + +1. 使用当前 `pending quest offer` 的 `QuestLogEntry`,调用现有任务接取写入逻辑,把任务正式写入 `gameState.quests`。 +2. 同时把这轮动作写回聊天: + - 追加玩家一句明确接受委托的话。 + - 可追加一条简短 NPC 确认回应,或直接用现有结果文案转成对话语义。 +3. 更新 `storyHistory`,确保后续聊天上下文知道“这份委托已经接下”。 +4. 清空 `pending quest offer`。 +5. 恢复正常 `npc_chat` 建议选项与自定义输入。 + +## 2.5 更换任务 + +点击 `更换任务` 时: + +1. 必须再次调用现有 `generateQuestForNpcEncounter(...)`。 +2. 旧的 `pending quest offer` 被新的覆盖。 +3. 当前聊天追加一条“对方换了个委托”的回应。 +4. 仍然维持任务处理态: + - 继续显示 + - `查看任务` + - `更换任务` + - `放弃任务` + - 自定义输入仍隐藏 + +这里的关键约束是: + +**更换任务不是本地改标题或改描述,而是重新走现有任务生成链。** + +## 2.6 放弃任务 + +点击 `放弃任务` 时: + +1. 直接丢弃当前 `pending quest offer`。 +2. 在对话里补一条“玩家暂时不接”的回应。 +3. 恢复自由聊天: + - 再次显示正常 `npc_chat` 建议 + - 恢复自定义输入 + +放弃这里只作用于“待领取委托”,不会影响已经入日志的正式任务。 + +## 3. 数据与状态设计 + +## 3.1 聊天态新增待领取任务状态 + +建议把这次临时状态挂在 `StoryNpcChatState` 上,而不是直接写入 `GameState.quests`。 + +```ts +interface StoryNpcQuestOfferState { + quest: QuestLogEntry; +} + +interface StoryNpcChatState { + npcId: string; + npcName: string; + turnCount: number; + customInputPlaceholder?: string; + pendingQuestOffer?: StoryNpcQuestOfferState | null; +} +``` + +这样有 3 个好处: + +1. 任务 offer 只属于当前聊天上下文,不污染正式任务日志。 +2. AdventurePanel 可以直接从 `currentStory.npcChatState` 判断是否进入任务处理态。 +3. 任务详情面板可以直接读取这份 `QuestLogEntry` 展示,而不用再造一套展示结构。 + +## 3.2 任务处理态的选项表达 + +建议不要把“查看 / 更换 / 放弃”接进服务端 runtime action。 + +原因是: + +1. `查看任务` 只是 UI 行为,不需要服务端结算。 +2. `更换任务` 和 `放弃任务` 都是当前聊天态内部状态流转。 +3. 这三项更适合作为本地聊天态专用选项,由 `AdventurePanel + npcEncounterActions` 协同处理。 + +建议做成 3 个本地专用 `StoryOption`: + +```ts +{ + functionId: 'npc_chat_quest_offer_view', + actionText: '查看任务', + runtimePayload: { npcChatQuestOfferAction: 'view' } +} +``` + +其余两个同理: + +- `replace` +- `abandon` + +## 3.3 接取后的正式写入 + +正式领取后才进入任务日志: + +```ts +nextState = { + ...state, + quests: acceptQuest(state.quests, pendingQuest.quest), + runtimeStats: incrementGameRuntimeStats(state.runtimeStats, { + questsAccepted: 1, + }), +} +``` + +也就是说: + +- `pending offer` 不计入 `questsAccepted` +- 真正点击 `领取任务` 才计数 + +## 4. UI 落点 + +## 4.1 聊天面板 + +`AdventurePanel` 中增加一个判断: + +1. `currentStory.npcChatState?.pendingQuestOffer` 存在时: + - 只显示三项任务处理选项 + - 隐藏自定义输入 + +2. 不存在时: + - 保持现有聊天输入与 `npc_chat` 建议 + +## 4.2 任务详情弹层 + +`AdventurePanelOverlays` 里的任务详情弹层继续复用,但要区分两种任务来源: + +1. 已在任务日志中的正式任务 + - 保持现有逻辑 + - 完成后仍显示 `领取奖励` + +2. 聊天里的 `pending quest offer` + - 底部显示 `领取任务` + - 不显示 `领取奖励` + +点击 `领取任务` 后: + +- 关闭详情弹层 +- 回到聊天界面 +- 当前聊天追加“我愿意接下”这一步 + +## 5. 代码改动建议 + +建议落地在这些文件: + +1. `src/types/story.ts` + - 扩展 `StoryNpcChatState` + +2. `src/hooks/story/npcEncounterActions.ts` + - 增加聊天内任务 offer 触发判断 + - 接入 `generateQuestForNpcEncounter(...)` + - 增加 + - 更换任务 + - 放弃任务 + - 领取任务 + 对应的本地状态流转 + +3. `src/hooks/story/useStoryInteractionCoordinator.ts` + - 向上暴露聊天内任务 offer 的操作方法 + +4. `src/hooks/useStoryGeneration.ts` + - 把聊天内任务 offer UI 能力透传给面板 + +5. `src/components/AdventurePanel.tsx` + - 聊天态隐藏 / 恢复输入 + - 拦截 `查看任务 / 更换任务 / 放弃任务` + - 让 pending quest 也能进入任务详情弹层 + +6. `src/components/adventure-panel/AdventurePanelOverlays.tsx` + - 为 pending quest 增加 `领取任务` 按钮 + +7. `src/components/AdventurePanel.test.tsx` + - 补聊天态输入隐藏测试 + +8. `src/hooks/story/npcEncounterActions.test.ts` + - 补任务 offer 触发 / 更换 / 接取测试 + +## 6. 验收标准 + +做到以下几点,才算这次需求成立: + +1. 与好感度大于 `0` 的角色聊天时,不会一上来立刻塞任务,前 `1-2` 轮先正常寒暄。 +2. 达到委托时机后,系统会调用现有 `generateQuestForNpcEncounter(...)` 生成一份待领取任务。 +3. 当前聊天轮次会出现一段明确的委托台词。 +4. 这一轮聊天选项会切成: + - `查看任务` + - `更换任务` + - `放弃任务` +5. 任务处理态下,自定义输入会被临时隐藏。 +6. 点击 `查看任务` 会弹出现有任务详情面板。 +7. 点击 `领取任务` 后,任务才正式进入任务日志,并在对话里体现“玩家愿意接下”。 +8. 领取完成后,聊天会恢复正常输入与自由继续对话。 +9. 点击 `更换任务` 时,必须重新调用现有任务生成链,而不是本地改文案。 + +## 7. 一句话收束 + +这次要做的,不是“让聊天里多一个任务按钮”,而是把: + +- 高好感聊天 +- 上下文化任务生成 +- 临时任务 offer +- 任务详情查看 +- 正式领取后回流聊天 + +整合成一个更自然的叙事交接过程。 diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md index 4230ada9..372f9e19 100644 --- a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -134,6 +134,17 @@ - 公开作品广场 - 本地浏览历史 +公开作品广场前端请求约束: + +- `listCustomWorldGallery` +- `getCustomWorldGalleryDetail` + +这两类公开请求必须走“公开只读请求”通道: + +- 不主动附带 `Authorization` +- 不因本地 access token 失效去触发 `/api/auth/refresh` +- refresh cookie 缺失、refresh 失败、账号状态过期时,不能把首页公开作品广场一起拖成错误态 + 未登录时不读取: - 自定义世界库 diff --git a/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md index 8d5cf0ed..eb6f214f 100644 --- a/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md +++ b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md @@ -45,6 +45,8 @@ - 大圆角卡片 - 半透明玻璃质感 - 平台正文与功能信息统一使用 `Inter + Noto Serif SC` +- 左上角品牌区允许使用专用像素字标组件或直接使用 `Fusion Pixel` 文本,但仅限品牌 logo,不向正文、按钮、标签扩散 +- 品牌 logo 只能复用游戏现有 `Fusion Pixel`,不允许再引入第二套像素字体文件 主题基准: @@ -57,6 +59,10 @@ ### 3.2 排版 - 平台层正文、按钮、说明、功能标签统一使用非像素字体 +- 左上角 `叙世 / GENARRATIVE` 品牌字标允许单独做成像素化 logo +- `GENARRATIVE` 与 `叙世` 都优先直接使用游戏内同款 `Fusion Pixel` +- 品牌字标默认保持正常像素字观感,禁止再叠双层粗阴影或手动加粗到影响识别 +- 品牌字标直接使用字体文件内原字形,不额外做运行时描字、轮廓拼字或伪粗体处理 - 主标题保留明显层级,但不再做像素描边效果 - 微型标签维持高字距英文/中文短标签,用来保留产品感和秩序感 @@ -69,8 +75,12 @@ - 弹窗:沿用登录页的圆角浮层和半透明遮罩,不再使用像素弹窗边框 - 桌面壳层:首页允许增加顶部工具栏、左侧导航轨、中央内容舞台与右侧趋势面板的组合 - 登录页、绑定手机号、账户弹窗、平台详情、创作生成页、结果页、编辑弹窗都必须共享同一套平台主题 token,禁止再各自写一套独立旧色板 +- 创作中心、Agent 工作台、草稿详情抽屉、资产工坊、启动弹窗、生成弹窗这类二三级平台面板必须显式挂载平台主题壳层或平台 remap 容器,禁止直接在局部面板里写死旧深色 modal 底和旧输入框底色 - 平台“我的”页中的“设置”入口必须打开真正的设置面板;账号信息、设备管理、安全状态属于设置面板中的分区,不允许再把账号信息弹层直接充当设置页 - 设置面板必须支持平台亮色 / 暗色主题切换,并复用同一套平台 token 驱动登录页、首页、详情页与二三级面板 +- 首页移动端底部 Tab 与桌面侧边导航的图标底座、图标颜色、文字状态必须全部由平台 token 驱动;暗色主题下不得出现过浅底座和错误文字色,亮色主题下不得残留旧灰蓝 inactive 状态 +- 首页、存档页、作品详情这类平台主导航与局部 Tab 的 active fill、active shadow、icon shell fill 必须全部来自主题 token;暗色主题禁止继续复用亮色主题的粉橘高光、白色 active 底座 +- “我的”页账号主卡必须跟随平台亮 / 暗主题联动,不允许继续写死浅色渐变卡面与 `slate` 系按钮 ## 4. 交互与布局约束 @@ -84,17 +94,19 @@ ## 5. 实现约束 - 平台态从 `fusion-pixel-app` 中隔离,避免被全局像素字体覆盖 +- 品牌区禁止新增额外像素字体包;平台层只允许保留现有 `public/fusion-pixel.ttf` 这一份像素字体资源 - 平台态背景不再使用 `/UI/Background_fill.png` - 新样式优先沉淀为平台专用 class / theme token,避免把游戏内像素 class 改坏 - 平台默认挂载亮色主题 class,旧紫蓝方案保留为暗色主题 class - 亮色主题需要补齐统一的 overlay、progress track、status pill token,登录弹层与二三级功能面板禁止继续沿用旧深色遮罩与紫蓝强调残留 +- 平台态中仍保留旧 Tailwind 深色类的历史组件,必须通过平台 remap 容器或平台专用 class 统一收口,不能放任 `bg-[#111318]`、`bg-black/*`、`bg-white/*` 这类旧类在亮色主题下直接裸露 - 编辑弹窗保留业务结构与表单逻辑,只替换壳层样式 ## 6. 验收标准 达到以下结果才算完成: -1. 平台首页、详情、登录、绑定手机号、账户弹窗、创作入口、创作结果页不再出现像素字体 +1. 除左上角品牌像素字标外,平台首页、详情、登录、绑定手机号、账户弹窗、创作入口、创作结果页不再出现像素字体 2. 平台层按钮、面板、关闭按钮、底部 tab 不再依赖像素 UI 素材 3. 平台默认展示亮色主题,暗色主题保留为独立主题方案 4. 平台层二三级面板、表单、状态卡、弹窗与登录体系不再残留旧金橙 / 青蓝 / 深黑混搭方案 diff --git a/docs/design/README.md b/docs/design/README.md index c3106211..232f9698 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -13,6 +13,7 @@ - [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。 - [EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md](./EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md):配装构筑与合成/锻造闭环设计。 - [COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md](./COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md):角色首遇感、关系分层解锁、私聊系统设计。 +- [NPC_HIGH_AFFINITY_CHAT_QUEST_OFFER_FLOW_2026-04-19.md](./NPC_HIGH_AFFINITY_CHAT_QUEST_OFFER_FLOW_2026-04-19.md):高好感角色在聊天内自然提出委托,并支持查看、更换、放弃、领取的流程设计。 - [SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md](./SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md):把每个场景收束成章节单元,并在首进场景时开启章节任务的设计稿。 - [SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md](./SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md):对标仙剑、博德之门、黑神话,分析单场景章节的体验缺口,并给出 AI 原生补强方案。 - [npc-conversation-situation-draft.md](./npc-conversation-situation-draft.md):NPC 对话阶段和情景注入草案。 @@ -27,6 +28,7 @@ - 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。 - 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。 - 做角色关系、同伴互动、对话表现时,先看后两份。 +- 做“高好感聊天里如何顺着上下文自然抛出委托、并让任务在聊天内领取”的需求时,优先看新增的聊天委托流程设计稿。 - 做剧情引擎章节化、场景闭环、章节任务接入时,优先看新增的场景章节设计稿。 - 做“单章节体验还缺什么、该补哪种情感 / 抉择 / 试炼模块”时,优先看新增的章节对标补强设计稿。 - 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。 diff --git a/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md b/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md index 2686302d..b9c9b559 100644 --- a/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md +++ b/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md @@ -498,7 +498,7 @@ type GeneratedCharacterAnimationAsset = { - `src/components/CharacterAnimator.tsx` - `src/types/characters.ts` - `src/data/characterOverrides.json` -- `scripts/dev-server/localApiPlugins.ts` +- `server-node/src/modules/assets/**` 建议新增: diff --git a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md index b73d82f8..3058f963 100644 --- a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md +++ b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md @@ -1,6 +1,6 @@ # AI 原生自定义世界创作页面 PRD -更新时间:`2026-04-13` +更新时间:`2026-04-20` ## 0. 文档目的 @@ -314,9 +314,11 @@ UI 主标题建议: 按优先级取: -1. `draftProfile.camp.imageSrc` -2. `draftProfile` 中可解析的营地图 -3. 角色主图或默认创作占位图 +1. `draftProfile.cover.imageSrc`,当 `sourceType` 为 `uploaded / generated` +2. `draftProfile.camp.imageSrc` 作为默认封面底图 +3. 默认封面底图上叠加 `draftProfile.cover.characterRoleIds` 对应的角色主形象 +4. 若未显式指定角色,则按 `playableNpcs` 顺序取前 `3` 个有主图的角色 +5. 若开局场景图为空,则回退到第一张场景图;再不行才回退到首个角色主图或默认占位图 ### 草稿卡片主操作 @@ -358,9 +360,78 @@ UI 主标题建议: 按优先级取: -1. 营地图 -2. 第一可扮演角色立绘 -3. 默认已发布作品占位图 +1. `CustomWorldProfile.cover.imageSrc`,当 `sourceType` 为 `uploaded / generated` +2. 开局场景图作为默认封面底图 +3. 默认封面底图上叠加 `cover.characterRoleIds` 指定的角色主形象 +4. 若未显式指定角色,则按 `playableNpcs` 顺序取前 `3` 个有主图的角色 +5. 若默认底图不可用,再回退到第一可扮演角色立绘或默认占位图 + +## 7.3 作品封面属性 + +作品必须新增显式封面属性,作为作者可编辑的作品资产,而不再只靠“卡片展示时临时猜一张图”。 + +建议字段: + +```ts +type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated'; + +interface CustomWorldCoverProfile { + sourceType: CustomWorldCoverSourceType; + imageSrc?: string | null; + characterRoleIds?: string[]; +} +``` + +字段含义: + +1. `sourceType = default` + - 表示继续使用系统默认封面布局 + - `imageSrc` 不作为最终封面图使用 + - 底图固定取“开局场景图” + - 前景角色取 `characterRoleIds` + +2. `sourceType = uploaded` + - 表示作者直接上传了一张最终封面 + - 卡片与详情页直接显示 `imageSrc` + - 不再叠加默认角色前景 + +3. `sourceType = generated` + - 表示作者通过 AI 生成了一张最终封面 + - 卡片与详情页直接显示 `imageSrc` + - 不再叠加默认角色前景 + +## 7.4 默认封面布局 + +默认封面布局不是单纯“取开局场景图”,而是: + +```text +开局场景图 ++ 前景主角色主形象 2~3 个 ++ 用于列表卡片和作品详情的统一封面预览 +``` + +明确规则: + +1. 默认封面底图固定优先取 `camp.imageSrc` +2. 默认前景角色固定从 `playableNpcs` 中取前 `3` 个有主图的角色 +3. 若作者在 `cover.characterRoleIds` 中显式指定角色,则优先按指定顺序展示 +4. 前端只负责把后端给出的“底图 + 角色主图列表”渲染成封面,不在前端做封面规则推理 +5. 已上传或已生成的最终封面,直接作为成品图显示,不再做默认布局叠加 + +## 7.5 作者操作 + +作者在作品编辑态至少支持 4 个动作: + +1. `使用默认封面` +2. `上传封面` +3. `AI 生成封面` +4. `重置为默认` + +约束: + +1. 上传和 AI 生成都必须把最终图片落到后端资产目录,前端不能长期持有 Data URL 作为作品封面 +2. 重置为默认后,`sourceType` 回到 `default` +3. 草稿与已发布作品都读取同一份封面属性,不允许出现“草稿页是一个封面、发布后又自动换另一张”的漂移 ### 已发布卡片主操作 @@ -395,6 +466,8 @@ interface CustomWorldWorkSummary { subtitle: string; summary: string; coverImageSrc?: string | null; + coverRenderMode?: 'image' | 'scene_with_roles'; + coverCharacterImageSrcs?: string[]; updatedAt: string; publishedAt?: string | null; stage?: string | null; @@ -447,6 +520,25 @@ interface CustomWorldWorkSummary { 仅已发布作品为 `true` +### `coverRenderMode / coverCharacterImageSrcs` + +用于支撑默认封面布局。 + +规则: + +1. 当作品封面为上传或 AI 生成成图时: + - `coverRenderMode = image` + - `coverCharacterImageSrcs = []` + +2. 当作品封面为默认布局时: + - `coverRenderMode = scene_with_roles` + - `coverImageSrc = 开局场景图` + - `coverCharacterImageSrcs = 需要叠加的角色主图列表` + +一句话: + +**后端负责告诉前端“这张封面该怎么画”,前端只负责把它画出来。** + --- ## 9. 后端接口设计 @@ -700,8 +792,9 @@ type SelectionStage = 1. 新建作品区位于首屏 2. tabs 横向可滚 -3. 作品卡优先单列 +3. 平台“创作”页中的“我的创作”列表在移动端至少双列展示,不能继续沿用横向滚动卡片的固定宽度 4. 不使用桌面化大表格 +5. 双列卡片必须采用紧凑栅格布局,标题、状态、时间允许换行或截断,但不能横向溢出或出现参差错位 ## 12.2 页面保持清爽 diff --git a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md new file mode 100644 index 00000000..fc7b75c4 --- /dev/null +++ b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md @@ -0,0 +1,728 @@ +# AI 原生场景多幕配置与 NPC 相遇聊天流程 PRD + +更新时间:`2026-04-20` + +## 0. 文档目的 + +这份 PRD 用于把下面几条已经存在但还没真正接成一条产品主链的设计,收束成一次可直接编码的迭代: + +- `docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md` +- `docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md` +- `docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md` +- `docs/design/NPC_HIGH_AFFINITY_CHAT_QUEST_OFFER_FLOW_2026-04-19.md` +- `docs/design/SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md` + +本次要解决的不是再新建一套场景系统或聊天系统,而是把现有: + +1. 创作工作区 +2. 场景章节闭环 +3. NPC 多轮聊天 +4. 场景背景资产 +5. 好感度关系流 + +接成一条新的稳定流程: + +**每个场景由创作者在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。** + +这份文档必须能直接指导后续创作工具和游戏流程改造,避免需求落地漂移。 + +--- + +## 1. 一句话定义 + +把当前“一个场景只有一层平铺内容”的创作与运行方式,升级成“一个场景内有多幕推进、每幕有独立视觉和主角色相遇规则”的章节内流程。 + +--- + +## 2. 本次目标 + +本次迭代必须同时满足以下目标: + +1. 创作者可以在现有创作页面中为每个场景章节配置多幕内容。 +2. 每一幕都必须绑定一张正式背景图。 +3. 每一幕都可以配置玩家会遇到哪些 NPC,并且保留顺序。 +4. 每一幕配置的第一个 NPC 必须被系统认定为该幕主角色。 +5. 运行时进入某一幕时,背景图和可遇 NPC 必须随幕切换。 +6. 当前幕主角色的聊天轮数必须按好感度裁决,而不是继续完全沿用统一规则。 +7. 好感度大于 `0` 的主角色,在相遇后进入无限轮聊天态,直到玩家主动退出。 +8. 好感度小于 `0` 的主角色,在相遇后最多只允许聊天 `5` 轮,第 `5` 轮必须输出一段为后续剧情开展铺垫的收束回应。 +9. 前端继续只负责展示,幕切换、聊天限制、幕进度与数据裁决全部由 Express 后端负责。 +10. 默认复用现有创作页面、草稿抽屉、详情弹层、场景章节和聊天流程,不新开独立系统或新页面。 + +--- + +## 3. 明确不做 + +本次明确不做下面这些事: + +1. 不新建独立的“场景编辑器”页面。 +2. 不把幕推进逻辑放到前端本地计算。 +3. 不让创作者直接编辑底层运行时 `ChapterState` 或聊天状态对象。 +4. 不做多 NPC 并行聊天。 +5. 不做每一幕的复杂分支树可视化编辑器。 +6. 不把“规则说明文案”默认堆到创作页或游戏 UI 面板里。 +7. 不把“点击配置”实现成在当前卡片下面继续展开大段内容。 +8. 不重写现有高好感委托链路,只在本次规则下明确它什么时候还能触发。 + +--- + +## 4. 现状判断 + +## 4.1 创作工具侧现状 + +当前仓库已经具备下面这些基础: + +1. `packages/shared/src/contracts/customWorldAgent.ts` + - 已存在 `scene_chapter` 草稿卡 kind。 + +2. `server-node/src/services/customWorldAgentDraftCompiler.ts` + - 已经能编译世界、第一幕、线程、势力、角色、地点等草稿卡。 + +3. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` + - 已有草稿抽屉,但还没有把 `scene_chapter` 正式纳入抽屉分组。 + +4. 现有场景背景图生成与发布链已存在。 + +但当前仍有 4 个缺口: + +1. 场景章节没有“幕”这一层结构化对象。 +2. 背景图是场景级资产,不是幕级资产。 +3. NPC 与场景的关系主要还是地点级归属,不是幕级相遇编排。 +4. 创作者无法在创作页里明确控制“这一幕谁先出场、谁是主角色”。 + +## 4.2 游戏运行侧现状 + +当前运行时已经具备下面这些基础: + +1. `src/data/questFlow.ts` + - 已有 `scene chapter quest` 与 `buildSceneChapterId(...)`。 + +2. `src/services/storyEngine/chapterDirector.ts` + - 已能按场景章节输出 `ChapterState`。 + +3. `src/hooks/story/npcEncounterActions.ts` + - 已有 `npc_chat` 多轮聊天、`turnCount`、`pendingQuestOffer` 等状态。 + +4. `packages/shared/src/contracts/story.ts` + - 已有 `NpcChatTurnRequest` / `NpcChatTurnResult` 契约。 + +但当前仍有 5 个问题: + +1. 场景内部仍偏单层推进,缺少“第几幕”的明确状态。 +2. 场景背景不会随幕切换。 +3. 场景可遇 NPC 不会随幕切换。 +4. 主角色没有从配置顺序直接编译成运行时规则。 +5. 负好感主角色聊天仍没有“最多 5 轮且第 5 轮收束铺垫”的规则。 + +一句话总结: + +**现在我们有场景章节,也有聊天系统,但还没有“场景多幕蓝图”这一层把创作配置、背景资产、NPC 相遇顺序和聊天规则真正串起来。** + +--- + +## 5. 核心决策 + +## 5.1 场景章节与场景幕的关系 + +本次新增一个明确约束: + +- `场景章节` 仍然是场景级闭环容器 +- `场景幕` 是场景章节内部的有序分段 + +关系定义如下: + +| 层级 | 作用 | +| --- | --- | +| `scene chapter` | 表示这一整个场景在剧情上的一章 | +| `scene act` | 表示这章内部的第几幕、当前视觉和当前相遇主体 | + +每个场景章节必须至少有 `2` 幕,最多 `5` 幕。 + +## 5.2 多幕数量与章节阶段映射 + +为了不引入第二套完全独立的运行时章节体系,本次规定场景幕按数量映射到现有 `ChapterState.stage`: + +| 幕数 | 编译规则 | +| --- | --- | +| `2` 幕 | 幕 1=`opening + expansion`,幕 2=`turning_point + climax + aftermath` | +| `3` 幕 | 幕 1=`opening`,幕 2=`expansion + turning_point`,幕 3=`climax + aftermath` | +| `4` 幕 | 幕 1=`opening`,幕 2=`expansion`,幕 3=`turning_point`,幕 4=`climax + aftermath` | +| `5` 幕 | 与 `opening / expansion / turning_point / climax / aftermath` 一一对应 | + +这意味着: + +1. 创作者在工具里编辑的是“第几幕”。 +2. 运行时仍然只认现有章节阶段枚举。 +3. `chapterDirector` 可以继续复用,只是数据来源从“纯 quest 推导”升级成“quest + 幕蓝图联合推导”。 + +## 5.3 主角色定义 + +每一幕配置的 `encounterNpcIds` 必须是有序数组。 + +规则固定为: + +1. `encounterNpcIds[0]` 就是当前幕主角色。 +2. 运行时会把它编译成 `primaryNpcId`。 +3. 主角色承担该幕默认的首次相遇、聊天轮数裁决和幕推进优先级。 +4. 其余 NPC 视为辅助相遇角色,不直接承担本次“好感度聊天轮数规则”。 + +--- + +## 6. 数据结构要求 + +## 6.1 创作草稿层新增结构 + +建议在现有 `CustomWorldFoundationDraftProfile` 之上新增下面两层: + +```ts +type SceneActAdvanceRule = + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + +interface CustomWorldFoundationDraftSceneAct { + id: string; + title: string; + summary: string; + stageCoverage: Array< + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath' + >; + backgroundImageSrc?: string | null; + backgroundAssetId?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + linkedThreadIds: string[]; + actGoal: string; + transitionHook: string; + advanceRule: SceneActAdvanceRule; +} + +interface CustomWorldFoundationDraftSceneChapter { + id: string; + sceneId: string; + sceneName: string; + title: string; + summary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + acts: CustomWorldFoundationDraftSceneAct[]; +} +``` + +硬要求: + +1. `primaryNpcId` 必须等于 `encounterNpcIds[0]`,不允许单独填写成别的角色。 +2. 每幕必须至少有 `1` 个 NPC。 +3. 每幕必须有 `backgroundImageSrc` 或 `backgroundAssetId`。 +4. `advanceRule` 由系统按幕位置默认编译,第一版不要求创作者手改。 + +## 6.2 发布到运行时的蓝图结构 + +创作草稿在发布时必须进一步编译成运行时蓝图: + +```ts +interface SceneActBlueprint { + id: string; + sceneId: string; + title: string; + stageCoverage: Array< + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath' + >; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + advanceRule: + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + actGoal: string; + transitionHook: string; +} + +interface SceneChapterBlueprint { + id: string; + sceneId: string; + title: string; + summary: string; + acts: SceneActBlueprint[]; +} +``` + +建议把它挂入 `CustomWorldProfile` 的新字段中: + +```ts +sceneChapterBlueprints?: SceneChapterBlueprint[] | null; +``` + +原因: + +1. 现有 `landmarks` 只足够表达地点,不足够表达幕顺序。 +2. 现有 `ChapterState` 是运行时状态,不适合直接兼做创作者蓝图。 +3. 独立蓝图层更适合后端编译和发布校验。 + +## 6.3 聊天状态扩展 + +建议在现有 `StoryNpcChatState` 上新增有限聊天需要的状态: + +```ts +interface StoryNpcChatState { + npcId: string; + npcName: string; + turnCount: number; + customInputPlaceholder?: string; + pendingQuestOffer?: { + quest: QuestLogEntry; + } | null; + sceneActId?: string | null; + turnLimit?: number | null; + remainingTurns?: number | null; + limitReason?: 'negative_affinity' | null; + forceExitAfterTurn?: boolean; +} +``` + +要求: + +1. 正常无限聊天时,`turnLimit` 和 `remainingTurns` 为 `null`。 +2. 负好感主角色聊天时,`turnLimit=5`。 +3. 第 `5` 轮结束后,`forceExitAfterTurn=true`,由后端明确告知前端结束当前聊天态。 + +## 6.4 NPC 聊天返回契约扩展 + +建议扩展 `NpcChatTurnResult`: + +```ts +type NpcChatTurnResult = { + npcReply: string; + affinityDelta: number; + affinityText: string; + suggestions: string[]; + chatDirective?: { + turnLimit?: number | null; + remainingTurns?: number | null; + forceExit?: boolean; + closingMode?: 'free' | 'foreshadow_close'; + }; +}; +``` + +这部分必须由后端给出,不允许前端自己猜。 + +--- + +## 7. 创作工具需求 + +## 7.1 入口与承载方式 + +本次必须继续复用现有: + +1. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` +2. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx` +3. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx` + +不新建独立页面。 + +新增规则: + +1. 草稿抽屉必须正式支持 `scene_chapter` 分组。 +2. `scene_chapter` 分组应位于 `chapter` 后、`thread` 前。 +3. 点开 `scene_chapter` 草稿卡后,进入现有详情弹层和编辑面板体系。 +4. 创作页面卡片摘要后续可增加 `sceneChapterCount`,但第一版不是阻塞项。 + +## 7.2 场景章节卡展示要求 + +每张 `scene_chapter` 草稿卡至少展示: + +1. 场景名称 +2. 章节标题 +3. 幕数量 +4. 已就绪背景图数量 +5. 关联 NPC 数量 +6. 关联线程数量 +7. 当前风险数 + +详情页必须至少展示: + +1. 场景摘要 +2. 幕结构总览 +3. 每幕的背景缩略图 +4. 每幕的主角色 +5. 每幕的辅助 NPC +6. 每幕目标 +7. 每幕过渡钩子 + +## 7.3 幕编辑交互 + +每个场景章节卡的编辑区必须支持下面这些操作: + +1. 新增幕 +2. 删除幕 +3. 调整幕顺序 +4. 编辑幕标题 +5. 编辑幕摘要 +6. 绑定幕背景图 +7. 配置幕相遇 NPC 顺序 +8. 编辑幕目标 +9. 编辑幕过渡钩子 + +交互要求: + +1. 幕列表在桌面端纵向堆叠,在移动端同样保持纵向,不做复杂双列。 +2. 每幕是独立卡片,不把所有字段一次性铺满。 +3. 点击“配置背景图”时必须打开独立面板或独立弹层,不允许在当前卡片下方内联展开。 +4. 点击“配置相遇 NPC”时必须打开独立面板或独立弹层,不允许在当前卡片下方内联展开。 +5. 默认不展示大段规则说明文字。 + +## 7.4 幕背景图配置 + +背景图配置必须复用现有场景图资产链,而不是另造上传体系。 + +要求如下: + +1. 一幕只绑定一张正式背景图。 +2. 可从已生成场景图中选择,也可调用现有场景图生成链生成。 +3. 幕背景图和场景总背景图不是同一个概念,允许不同幕使用不同图。 +4. 发布前如果存在未绑定背景图的幕,必须阻止发布。 +5. 幕切换时运行时优先使用幕背景图,而不是地点默认图。 + +## 7.5 幕相遇 NPC 配置 + +NPC 配置面板必须支持: + +1. 从当前世界的 `playableNpcs + storyNpcs` 中选择角色 +2. 只展示与当前场景相关的优先推荐角色 +3. 支持排序 +4. 第一位角色明确标记为“主角色” +5. 允许同一角色出现在多个不同幕 + +硬约束: + +1. 每幕至少 `1` 名 NPC。 +2. 第一位 NPC 不能为空。 +3. 不允许把不存在于当前世界角色池中的 id 写入幕配置。 +4. 若主角色未与当前场景或线程建立任何关联,给出发布警告。 + +## 7.6 创作校验 + +`CustomWorldQualityFinding` 至少新增下面这些检查项: + +1. `scene_chapter_missing_act` +2. `scene_act_missing_background` +3. `scene_act_missing_primary_npc` +4. `scene_act_missing_encounter_npc` +5. `scene_act_primary_npc_not_first` +6. `scene_act_unlinked_thread` +7. `scene_act_unpublished_background` + +发布阻断项: + +1. 幕数小于 `2` +2. 任意一幕没有背景图 +3. 任意一幕没有 NPC +4. 任意一幕的第一 NPC 为空 + +--- + +## 8. 游戏流程需求 + +## 8.1 幕运行时状态 + +运行时必须为每个场景章节维护独立幕进度: + +```ts +interface SceneActRuntimeState { + sceneId: string; + chapterId: string; + currentActId: string; + currentActIndex: number; + completedActIds: string[]; + visitedActIds: string[]; +} +``` + +建议挂入当前 story engine memory 中,和现有 `openedSceneChapterIds` 并存。 + +## 8.2 进入场景时的流程 + +当玩家进入一个有 `SceneChapterBlueprint` 的场景时: + +1. 后端定位当前场景对应的 `scene chapter blueprint` +2. 如果该场景首次进入,则激活第 `1` 幕 +3. 如果该场景未完成且已有幕进度,则恢复到当前未完成幕 +4. 把当前幕的背景图写入前端展示模型 +5. 把当前幕的 `encounterNpcIds` 作为本幕优先相遇池 +6. 把当前幕的 `stageCoverage` 交给 `chapterDirector` 参与裁决,并结合 quest 进度输出单一 `ChapterState.stage` + +## 8.3 幕推进规则 + +第一版不要求创作者手填推进条件,而是由系统按幕位置默认编译: + +1. 第 `1` 幕默认 `after_primary_contact` + - 玩家与主角色发生首次有效接触后可进入下一幕判定 + +2. 中间幕默认 `after_active_step_complete` + - 当前场景章节任务 active step 完成后进入下一幕判定 + +3. 最后一幕默认 `after_chapter_resolution` + - 当前场景章节任务完成或进入可收束状态后结束本场景章节 + +要求: + +1. 幕推进由后端统一裁决。 +2. 前端只接收“幕已切换”的结果,不自行判断。 +3. 幕切换后必须触发背景切换与相遇池更新。 + +## 8.4 幕切换表现 + +游戏前台在幕切换时必须至少做到: + +1. 显示当前幕标题 +2. 更新背景图 +3. 更新当前可遇 NPC +4. 给出一条轻量系统提示,说明进入了新一幕 + +注意: + +1. 不新建独立页面。 +2. 不弹全屏说明面板。 +3. 移动端优先保证幕标题与背景切换不遮挡底部操作区。 + +--- + +## 9. NPC 相遇与聊天规则 + +## 9.1 规则适用范围 + +本次新增的“按好感度控制聊天轮数”规则,只对**当前幕主角色**生效。 + +也就是说: + +1. 当前幕 `primaryNpcId` 命中的角色,使用本次新规则。 +2. 当前幕其他辅助 NPC,第一版继续沿用现有 `npc_chat` 通用流程。 +3. 辅助 NPC 的聊天不直接推进幕进度,除非后端另有章节 step 裁决。 + +## 9.2 主角色好感度大于 0 + +当当前幕主角色对玩家的当前好感度 `> 0` 时: + +1. 玩家与其相遇后可以进入聊天态。 +2. 聊天轮数无限制。 +3. 继续沿用现有: + - `3` 个续聊建议项 + - `1` 个自定义输入框 + - 主动退出聊天 +4. 只要满足 `docs/design/NPC_HIGH_AFFINITY_CHAT_QUEST_OFFER_FLOW_2026-04-19.md` 中的条件,仍然允许在聊天内抛出委托。 + +## 9.3 主角色好感度等于 0 + +为了避免编码边界歧义,本 PRD 先明确: + +1. `affinity = 0` 视为中立档,不归入负好感限制分支。 +2. 中立档允许进入正常多轮聊天。 +3. 中立档不自动享受“高好感委托时机”。 + +这意味着: + +- `> 0`:无限聊,且可进入高好感委托逻辑 +- `= 0`:无限聊,但不进入高好感委托逻辑 +- `< 0`:最多 `5` 轮 + +## 9.4 主角色好感度小于 0 + +当当前幕主角色对玩家的当前好感度 `< 0` 时,必须进入 `limited hostile chat mode`: + +1. 允许进入聊天,但最多 `5` 轮。 +2. 聊天状态中必须显示剩余轮数。 +3. 第 `1~4` 轮仍然走正常“玩家一句 -> NPC 一句 -> 建议项刷新”的基本结构。 +4. 第 `5` 轮不是普通续聊,而是强制收束轮。 +5. 第 `5` 轮必须输出一段带方向的收束回应,为后续剧情开展铺垫。 +6. 第 `5` 轮结束后: + - 自定义输入框隐藏 + - 当前聊天态结束 + - 恢复普通冒险态或进入后续 action 选择 + +## 9.5 第 5 轮的“铺垫”定义 + +“为开展铺垫”在本次 PRD 中必须被明确解释为: + +**NPC 在第 5 轮必须抛出一个明确的后续方向,不能只用一句敌意台词把对话硬截断。** + +可接受的铺垫结果包括: + +1. 抛出新的威胁或最后通牒 +2. 指向某个地点、人物或线索 +3. 把矛盾推向对峙、交易、追踪或战斗 +4. 暗示自己下一步行动去向 +5. 给玩家一个必须接住的悬念或条件 + +不可接受的结果: + +1. 纯重复敌意表达 +2. 没有任何新方向的信息 +3. 第 5 轮结束后界面直接空掉,没有后续承接 + +## 9.6 对当前负好感拦截逻辑的调整 + +当前若主角色属于本幕 `primaryNpcId`,则需要覆盖现有“负好感直接不给持续聊天”的逻辑。 + +新规则如下: + +1. 如果它是当前幕主角色,即使当前好感度 `< 0`,也允许进入有限聊天态。 +2. 只有在完成第 `5` 轮铺垫收束后,才切回普通探索/对峙流程。 +3. 如果该 NPC 不是当前幕主角色,仍可沿用现有负好感拦截逻辑。 + +--- + +## 10. 前端表现要求 + +## 10.1 创作页 + +创作页必须保持清爽,不默认塞规则说明。 + +必须做到: + +1. `scene_chapter` 卡片可见 +2. 幕列表可编辑 +3. 背景图选择和 NPC 选择都走独立面板 +4. 移动端仍能完成幕排序、背景选择、NPC 排序 + +## 10.2 游戏主面板 + +Adventure 主面板在本次迭代中至少增加下面这些表现: + +1. 当前幕标题或幕序号标签 +2. 当前幕背景图切换 +3. 主角色负好感聊天时的“剩余轮数”轻量提示 +4. 第 5 轮结束后的过渡系统消息 + +禁止: + +1. 默认展示大段规则介绍 +2. 把幕配置说明直接写进玩家面板 +3. 为了展示幕切换而新建独立剧情页面 + +--- + +## 11. 前后端职责边界 + +## 11.1 前端职责 + +前端只负责: + +1. 渲染 `scene_chapter` 草稿卡与幕编辑 UI +2. 发起背景图配置和 NPC 配置请求 +3. 渲染当前幕背景和幕标题 +4. 渲染负好感聊天剩余轮数 +5. 根据后端返回切换幕、退出聊天、展示后续 options + +前端不负责: + +1. 计算主角色是谁 +2. 计算好感度轮数限制 +3. 判定什么时候切幕 +4. 决定第 5 轮要输出什么铺垫 +5. 本地拼接下一幕 encounter 池 + +## 11.2 后端职责 + +后端必须负责: + +1. 把创作页幕配置编译成运行时蓝图 +2. 校验每幕背景与 NPC 配置完整性 +3. 维护 `SceneActRuntimeState` +4. 进入场景时确定当前幕 +5. 输出当前幕背景与 encounter 池 +6. 裁决主角色聊天轮数限制 +7. 在第 `5` 轮生成铺垫式收束回应 +8. 在满足条件时推进到下一幕 + +--- + +## 12. 影响模块 + +本 PRD 落地时,至少会影响下面这些模块: + +1. `packages/shared/src/contracts/customWorldAgent.ts` + - 新增场景多幕草稿结构 + +2. `src/types/customWorld.ts` + - 新增发布态 `sceneChapterBlueprints` + +3. `server-node/src/services/customWorldAgentDraftCompiler.ts` + - 编译 `scene_chapter` 草稿卡 + +4. `server-node/src/services/customWorldAgentDraftEditService.ts` + - 支持场景幕的增删改排序 + +5. `server-node/src/services/customWorldAgentQualityService.ts` + - 增加幕背景和幕 NPC 校验 + +6. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` + - 展示 `scene_chapter` 分组 + +7. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx` + - 展示幕详情 + +8. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx` + - 新增幕编辑 UI + +9. `src/data/questFlow.ts` + - 让 scene chapter quest 感知当前幕 + +10. `src/services/storyEngine/chapterDirector.ts` + - 用当前幕映射章节阶段和摘要 + +11. `src/hooks/story/npcEncounterActions.ts` + - 新增主角色有限聊天与第 5 轮收束逻辑 + +12. `packages/shared/src/contracts/story.ts` + - 扩展 `NpcChatTurnResult` + +13. `src/services/aiService.ts` + - 透传有限聊天新字段 + +14. `server-node/src/modules/ai/chatOrchestrator.ts` + - 生成第 `5` 轮铺垫式收束结果 + +--- + +## 13. 验收标准 + +当下面这些结果都成立时,视为本次 PRD 已被正确落地: + +1. 创作者可以在现有创作工作区中创建并编辑 `scene_chapter`。 +2. 每个场景章节都可以配置 `2~5` 幕。 +3. 每一幕都可以绑定独立背景图。 +4. 每一幕都可以配置有序 NPC 列表,第一位自动成为主角色。 +5. 发布时缺少幕背景或幕 NPC 会被明确拦截。 +6. 玩家进入场景后,当前幕背景图能正确显示。 +7. 当前幕可遇 NPC 会按幕配置切换。 +8. 当前幕主角色好感度 `> 0` 时可以无限续聊。 +9. 当前幕主角色好感度 `< 0` 时最多只聊 `5` 轮。 +10. 第 `5` 轮结束后一定会出现为后续剧情开展铺垫的收束结果,而不是直接硬断。 +11. 高好感委托链仍只在正好感聊天中触发。 +12. 桌面端和移动端都能完成幕配置与幕切换使用。 + +--- + +## 14. 本稿默认假定 + +为了避免下一步编码时再出现语义歧义,这份 PRD 先明确采用下面两条默认假定: + +1. `affinity = 0` 先按中立档处理:允许无限聊,但不进入高好感委托分支。 +2. “第 5 轮为开展铺垫”先解释为“为后续剧情推进、对峙、追踪、交易或战斗制造明确下一跳”,而不是限定为必须开战。 + +如果后续你希望把: + +- `affinity = 0` 改成也只聊 `5` 轮 +- 第 `5` 轮明确收束到“开战前摇” + +可以在下一版实现文档中单独收紧,不影响本稿主结构。 diff --git a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md index 68cfae4a..383070cb 100644 --- a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md @@ -1,6 +1,6 @@ # “我的”Tab 设置与账号安全 PRD -更新时间:`2026-04-16` +更新时间:`2026-04-19` ## 0. 目标 @@ -64,6 +64,13 @@ 4. 更换手机号 5. 账号操作记录 +交互层级要求补充为: + +1. 设置首页只展示分区入口与危险操作,不在首页内联展开具体详情 +2. 点击任一分区入口后,必须进入独立二级面板 +3. 二级面板负责单一任务,不允许把详情继续堆在入口列表下面 +4. 更换手机号属于独立操作面板,不允许在账号概况面板内直接展开表单 + 底部保留两个危险操作按钮: 1. 退出登录 @@ -84,6 +91,12 @@ 这里只看信息,不做大编辑动作。 +标题约束: + +- 设置首页标题固定表达“设置”或“设置与账号安全” +- 设置首页标题区域不展示手机号,也不允许把手机号当作主标题替代昵称 +- 手机号只允许出现在账号概况信息项中,以脱敏值展示 + ## 4.2 当前安全状态 展示当前账号命中的风控保护: @@ -188,8 +201,11 @@ 1. 设置继续采用当前账号弹窗基础形态即可 2. 移动端优先底部弹层,桌面端可居中弹窗 -3. 更换手机号区域默认折叠 -4. 危险操作按钮与普通按钮必须明显区分 +3. 设置首页只保留分区入口,不直接承载分区详情内容 +4. 分区详情必须通过独立子面板承载,移动端优先使用全宽底部子弹层,桌面端使用覆盖在设置首页之上的居中子面板 +5. 更换手机号必须通过独立操作面板完成,不再使用当前面板内联展开表单 +6. 危险操作按钮与普通按钮必须明显区分 +7. 设置首页标题处禁止展示手机号、脱敏手机号或手机号形态的 displayName --- diff --git a/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md b/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md index 342844bd..3f1e0961 100644 --- a/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md +++ b/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md @@ -110,6 +110,14 @@ - 最后游玩时间 - 游戏信息 +### 3.3.1 移动端卡片布局约束 + +- 移动端列表卡片中的封面只能作为独立缩略图或弱化背景层使用,不能直接占满整张卡片并压在正文信息下方。 +- 标题、时间、摘要所在的信息区必须保持 `min-width: 0` 的可收缩布局,长标题不能把正文挤出屏幕外。 +- 世界名称最多展示 2 行,游戏信息最多展示 3 行,超出后截断,不允许横向溢出。 +- 时间标签、状态标签在窄屏下必须允许换行或独立成行,不能为了保持单行导致卡片内容错位。 +- 列表卡片缩略图区域比例固定,文本区与缩略图区在移动端需要保持稳定对齐,避免出现上下参差和视觉歪斜。 + 其中“游戏信息”优先级如下: 1. `continueGameDigest` diff --git a/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md b/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md new file mode 100644 index 00000000..0dbd947d --- /dev/null +++ b/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md @@ -0,0 +1,185 @@ +# 业务提示词清单(2026-04-19) + +## 1. 目标 + +这份清单用于回答两个问题: + +- 目前业务里到底有哪些提示词还在被使用。 +- 哪些提示词已经收口到独立目录,哪些仍散落在前后端与工具链里。 + +本次统计范围: + +- `server-node/src/**` +- `src/**` +- `packages/shared/src/**` + +本次“提示词”统计口径包含: + +- system prompt +- user prompt builder +- repair prompt +- negative prompt +- 图像 / 动画生成 prompt +- 编辑器里会直接喂给模型的默认 prompt 种子 + +本次不计入: + +- 单纯转发 prompt 的接口入参校验 +- 普通剧情文案、UI 文案、剧情预设文本 +- 纯测试断言文件 + +## 2. 当前结论 + +截至 2026-04-19 本轮收口完成后,业务 prompt 主源已经集中到 3 个目录: + +1. `server-node/src/prompts/` +2. `src/prompts/` +3. `packages/shared/src/prompts/` + +当前业务模块、路由、服务层里的旧 prompt 文件大多已经退化成两类: + +- prompt 调用方 +- 薄 re-export 兼容层 + +目前没有再发现“正式业务 prompt 正文仍长期内联在主流程文件里”的大块散点;剩余位于非 prompt 目录的相关文件,主要是兼容层、测试文件或普通调用方。 + +## 3. 当前 Prompt 目录清单 + +### 3.1 后端 + +| 文件 | 业务域 | 关键导出 | +| --- | --- | --- | +| `server-node/src/prompts/storyPromptBuilders.ts` | 主剧情推进 | `SYSTEM_PROMPT`、`buildUserPrompt` | +| `server-node/src/prompts/storyOrchestratorPrompts.ts` | 剧情语言修复 | `STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT`、`buildStoryLanguageRepairPrompt` | +| `server-node/src/prompts/chatPromptBuilders.ts` | 角色私聊 / NPC 对话 / 招募 | `CHARACTER_PANEL_CHAT_*`、`NPC_CHAT_*`、多个 `build*Prompt` | +| `server-node/src/prompts/questPrompts.ts` | 任务意图 | `QUEST_INTENT_SYSTEM_PROMPT`、`buildQuestIntentPrompt` | +| `server-node/src/prompts/runtimeItemPrompts.ts` | 运行时物品意图 | `RUNTIME_ITEM_INTENT_SYSTEM_PROMPT`、`buildRuntimeItemIntentPromptText` | +| `server-node/src/prompts/customWorldOrchestratorPrompts.ts` | 自定义世界主编排 | `CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT`、`CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT`、`buildCustomWorldProfilePrompt`、`buildCustomWorldProfileRepairPrompt` | +| `server-node/src/prompts/customWorldAgentPrompts.ts` | 世界草稿增补 | `FOUNDATION_JSON_ONLY_SYSTEM_PROMPT`、`FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT`、多个扩展 prompt | +| `server-node/src/prompts/customWorldEntityPrompts.ts` | 世界编辑器实体生成 | `CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT`、`buildPlayablePrompt`、`buildStoryPrompt`、`buildLandmarkPrompt` | +| `server-node/src/prompts/customWorldSceneNpcPrompts.ts` | 世界编辑器场景 NPC | `CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT`、`buildCustomWorldSceneNpcPrompt` | +| `server-node/src/prompts/eightAnchorPrompts.ts` | 八锚点共创 | `BASE_SYSTEM_PROMPT`、`GLOBAL_HARD_RULES`、`MODE_RULES`、`USER_SIGNAL_RULES`、`buildPromptDynamicStateInferencePrompt`、`buildEightAnchorSingleTurnPrompt` | +| `server-node/src/prompts/characterAssetPrompts.ts` | 角色形象 / 动作资产生成 | `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT`、`buildFallbackCharacterPromptBundle`、`buildCharacterPromptBundleUserPrompt`、`buildNpcVisualPrompt`、`buildNpcAnimationPrompt`、`buildArkCharacterAnimationPrompt` | + +### 3.2 前端 + +| 文件 | 业务域 | 关键导出 | +| --- | --- | --- | +| `src/prompts/storyPromptBuilders.ts` | 剧情推进 | `SYSTEM_PROMPT`、`buildUserPrompt` | +| `src/prompts/characterChatPrompts.ts` | 角色面板私聊 | `CHARACTER_PANEL_CHAT_*`、多个 `build*Prompt` | +| `src/prompts/questPrompts.ts` | 前端任务意图兜底 | `QUEST_INTENT_SYSTEM_PROMPT`、`buildQuestIntentPrompt` | +| `src/prompts/runtimeItemPrompts.ts` | 前端物品意图兜底 | `RUNTIME_ITEM_INTENT_SYSTEM_PROMPT`、`buildRuntimeItemIntentPrompt` | +| `src/prompts/customWorldPrompts.ts` | 自定义世界分阶段生成 + 场景背景图 | 多个 `buildCustomWorld*Prompt`、`DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT` | +| `src/prompts/customWorldOrchestratorPrompts.ts` | 世界 JSON 修复 / JSON only | `CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT`、`CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT` | +| `src/prompts/storyOrchestratorPrompts.ts` | 剧情中文修复 | `STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT` | +| `src/prompts/customWorldRolePromptDefaults.ts` | 角色资产工作台默认词 | `buildDefaultRolePromptBundle` | +| `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器技能动作词 | `buildSkillActionPrompt` | +| `src/prompts/qwenSpriteSheetToolPrompts.ts` | Qwen 精灵图工具 prompt 模型 | 主 prompt / sheet prompt / repair prompt / negative prompt 系列 | + +### 3.3 共享层 + +| 文件 | 业务域 | 关键导出 | +| --- | --- | --- | +| `packages/shared/src/prompts/qwenSprite.ts` | 共享像素角色主 prompt 模板 | `QWEN_SPRITE_ACTION_TEMPLATES`、`buildMasterPrompt`、`buildVideoActionPrompt`、`getActionTemplateById` | + +## 4. 兼容层与调用层 + +为了避免一次性打断旧引用,当前保留了若干兼容层: + +- `src/services/prompt.ts` +- `src/services/characterChatPrompt.ts` +- `src/services/questPrompt.ts` +- `src/services/runtimeItemAiPrompt.ts` +- `server-node/src/services/eightAnchorPromptBuilder.ts` +- `src/tools/qwenSpriteSheetToolModel.ts` +- `src/components/asset-studio/customWorldRolePromptDefaults.ts` +- `packages/shared/src/assets/qwenSprite.ts` + +这些文件当前职责是: + +- 维持旧路径可用 +- re-export 到新的 prompt 目录 + +它们不再是 prompt 正文主源。 + +## 5. AI 角色形象生成当前来源 + +这部分是你点名要求补齐的重点,现在已经收口为: + +| 文件 | 角色 | 当前定位 | +| --- | --- | --- | +| `server-node/src/prompts/characterAssetPrompts.ts` | 正式角色资产生成 prompt | 后端角色主图、动作试片、角色场景词主源 | +| `packages/shared/src/prompts/qwenSprite.ts` | 共享角色主 prompt 模板 | 共享给后端资产链使用的基础模板 | +| `src/prompts/qwenSpriteSheetToolPrompts.ts` | 工具链 prompt 模型 | Qwen 精灵图工具主词、分镜词、修帧词、负面词 | +| `src/prompts/customWorldRolePromptDefaults.ts` | 工作台默认词种子 | 角色视觉词、动画词、场景词默认值 | +| `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器动作词 | 技能动作描述 prompt builder | + +当前调用关系: + +- `server-node/src/modules/assets/characterAssetRoutes.ts` 调用 `server-node/src/prompts/characterAssetPrompts.ts` +- `src/tools/QwenSpriteSheetTool.tsx` 通过兼容层消费 `src/prompts/qwenSpriteSheetToolPrompts.ts` +- `src/components/CustomWorldRoleAssetStudioModal.tsx` 通过兼容层消费 `src/prompts/customWorldRolePromptDefaults.ts` +- `src/components/CustomWorldEntityEditorModal.tsx` 直接调用 `src/prompts/customWorldEntityActionPrompts.ts` + +## 6. AI 场景背景生成当前来源 + +场景背景图 prompt 现在已经从业务流程文件里抽出,统一主源是: + +| 文件 | 角色 | 当前定位 | +| --- | --- | --- | +| `src/prompts/customWorldPrompts.ts` | 场景背景图 prompt 主源 | `buildCustomWorldSceneImagePrompt`、`DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT` | +| `src/services/ai.ts` | 前端编排调用方 | 组装请求并调用同一份 prompt builder | +| `server-node/src/services/sceneImageService.ts` | 后端执行器调用方 | 在服务端用同一份 prompt builder 生成 prompt,再请求上游模型 | + +这条链的关键变化是: + +- 不再让 `src/services/customWorld.ts` 承担场景图 prompt 正文主源 +- 前后端场景图生成改为共用 `src/prompts/customWorldPrompts.ts` + +## 7. 本轮完成的原散点收口 + +本轮已经完成的原散点包括: + +- `server-node/src/modules/assets/characterAssetRoutes.ts` 中的角色资产 prompt +- `server-node/src/services/eightAnchorPromptBuilder.ts` 中的八锚点 prompt +- `src/services/customWorld.ts` 中的自定义世界分阶段 prompt 与场景背景图 prompt +- `src/services/ai.ts` 中的世界修复 / 语言修复 / JSON only system prompt +- `src/services/prompt.ts`、`characterChatPrompt.ts`、`questPrompt.ts`、`runtimeItemAiPrompt.ts` 这批前端 prompt 脚本 +- `src/tools/qwenSpriteSheetToolModel.ts`、`src/components/asset-studio/customWorldRolePromptDefaults.ts`、`src/components/CustomWorldEntityEditorModal.tsx` 里的工具 / 编辑器 prompt 散点 + +## 8. 当前仍在非 Prompt 目录中的相关文件 + +仍在非 prompt 目录中的相关文件,当前主要是: + +- 调用方 +- 兼容层 +- 测试 + +因此现在的工程状态已经从“散点查找”变成“目录集中 + 兼容过渡”。 + +## 9. 验证结果 + +本轮收口后已验证: + +- `npm run check:encoding` +- `npm --prefix server-node run build` +- `npm run build` +- `npm run server-node:test` + +结果: + +- 编码检查通过 +- 前端构建通过 +- 后端构建通过 +- `server-node` 测试 143 项全部通过 + +## 10. 本次盘点后的判断 + +截至 2026-04-19,本仓库的业务 prompt 已经基本完成目录化管理。 + +当前更准确的结论是: + +- 后端正式业务 prompt 主源集中在 `server-node/src/prompts/` +- 前端与编辑器 prompt 主源集中在 `src/prompts/` +- 共享资产 prompt 主源集中在 `packages/shared/src/prompts/` +- 旧服务路径、旧工具路径仍保留为兼容层,但不再承担 prompt 正文维护职责 diff --git a/docs/reference/README.md b/docs/reference/README.md index 1693def1..b8e4dae8 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -2,6 +2,7 @@ ## 当前入口 +- [BUSINESS_PROMPT_INVENTORY_2026-04-19.md](./BUSINESS_PROMPT_INVENTORY_2026-04-19.md):业务中现存提示词的总清单,覆盖后端主链、前端遗留、自定义世界、角色形象生成、场景背景生成与工具链 prompt。 - [FUNCTION_SCRIPT_CATALOG_2026-04-04.md](./FUNCTION_SCRIPT_CATALOG_2026-04-04.md):Function 独立脚本目录与分类速查。 - [TASK_GENERATION_TRACE_2026-04-08.md](./TASK_GENERATION_TRACE_2026-04-08.md):任务描述、达成条件与奖励生成链路梳理。 - [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md):自定义世界当前仍依赖哪些模板世界设定的清单。 diff --git a/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md b/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md index d49f6645..f295840f 100644 --- a/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md +++ b/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md @@ -854,7 +854,8 @@ generatedAnimationOverrides?: Partial>; ## 10.3 本地 API 层 -当前项目已经有 `scripts/dev-server/localApiPlugins.ts` 这种本地 API 插件机制,应该复用。 +自 `2026-04-19` 起,旧 `scripts/dev-server/localApiPlugins.ts` 已从仓库删除。 +当前角色视觉与动画相关 `/api/*` 能力应统一落到 `server-node/src/modules/assets/**` 与 `server-node/src/modules/ai/**`,不再复用旧 Vite 本地插件链路。 建议新增: diff --git a/docs/technical/ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md b/docs/technical/ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md index 6338940c..5018f569 100644 --- a/docs/technical/ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md +++ b/docs/technical/ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md @@ -82,7 +82,8 @@ ### 2.3 本地 API 插件里已经有 DashScope 接入样板 -`scripts/dev-server/localApiPlugins.ts` 里已经接了自定义世界场景图: +本文撰写时,旧 `scripts/dev-server/localApiPlugins.ts` 里已经接了自定义世界场景图。 +截至 `2026-04-19`,该文件已从仓库删除,对应样板能力应改为参考 `server-node/src/modules/assets/**` 与 `server-node/src/modules/ai/**`: - 默认 DashScope base URL 已经存在 - 已经有异步任务创建、轮询、下载、落盘、写 manifest 的完整样板 @@ -480,7 +481,7 @@ - 最少改 UI - 最快复用当前 `CharacterAssetPanel` -- 最容易复用 `localApiPlugins.ts` 里现有 DashScope 异步任务模式 +- 最容易复用现已迁入 `server-node` 的 DashScope 异步任务模式 ## 9.2 第二轮 @@ -641,5 +642,6 @@ - `src/components/preset-editor/characterAssetStudioPersistence.ts` - `src/routing/appRoutes.tsx` - `src/services/ai.ts` -- `scripts/dev-server/localApiPlugins.ts` +- `server-node/src/modules/assets/**` +- `server-node/src/modules/ai/**` - `docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md` diff --git a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md index 3bcbbbf4..cd2ba7e6 100644 --- a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md +++ b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md @@ -73,12 +73,12 @@ ## 5. 旧工具链隔离状态 -`scripts/dev-server/**` 中的旧 Vite 本地插件已经不再由 `vite.config.ts` 注入,也不再作为当前开发入口使用。 +自 `2026-04-19` 起,`scripts/dev-server/**` 中的旧 Vite 本地插件实现代码已经从仓库删除,也不再作为当前开发入口使用。 -旧文件保留用途: +当前保留状态: -- 作为历史迁移参考。 -- 对照旧 DashScope 调用与文件写入逻辑。 +- `scripts/dev-server/` 目录只保留迁移说明 README。 +- 旧链路的历史背景由 `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md` 等审计文档承接。 新增编辑器或资产能力时,应优先写入: @@ -103,4 +103,4 @@ - 前端编辑器组件已通过统一 SDK 或资源 ID 访问编辑器 API。 - Vite 已代理 `/api/editor` 与 `/api/assets` 到 Node 后端。 - 写接口已经有环境门禁。 -- 旧 Vite 本地插件不再是当前工具链入口。 +- 旧 Vite 本地插件代码已删除,不再保留并行实现。 diff --git a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md index d078eb90..7616c90e 100644 --- a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md +++ b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md @@ -16,7 +16,7 @@ 当前不再使用: -- Vite 本地 API 插件 `scripts/dev-server/` 作为当前接口入口 +- 已删除的旧 Vite 本地 API 插件链路 `scripts/dev-server/*.ts` ## 2. 技术栈 @@ -162,6 +162,11 @@ JWT 现状: - `POST /api/runtime/items/runtime-intent` - `POST /api/runtime/quests/generate` +补充说明(`2026-04-19`): + +- `POST /api/custom-world/scene-image` 现在支持前端仅提交 `profile + landmark + userPrompt` 上下文,由后端统一补齐场景图 prompt 与默认 negative prompt。 +- runtime story option 的 `interaction` 元数据现在由后端随 option 一并返回,前端不再本地按 `functionId` 重建 NPC / treasure 交互语义。 + 编辑器工具: - `GET /api/editor/catalog/items` @@ -198,7 +203,11 @@ Story: Custom World: -- Node 后端直接复用前端现有多阶段生成编排 +- Node 后端当前已自持 `server-node/src/modules/custom-world/**` 运行时模块 +- 已承接 `creator intent` 归一化、`anchorPack / lockState` 推导、framework normalize、runtime profile compile +- `customWorldOrchestrator` 与 `customWorldAgentFoundationDraftService` 已不再运行时 import 前端 `src/services/customWorld*.ts` 与 `src/types.js` +- `server-node/src/prompts/customWorldPrompts.ts` 已承接 foundation draft 与 scene image 使用的 custom world prompt source +- 上述 prompt 迁移只改变源码归属位置,没有改动提示词正文 - 当前保留 `session + answers + SSE progress/result/error` 协议 - 前端已支持接收真实阶段进度对象 @@ -238,4 +247,4 @@ Vite 当前只负责代理,不再提供本地 API 插件。 全部转发到 Node 后端。 -旧 `scripts/dev-server/**` 文件仅保留为迁移参考,不再由 `vite.config.ts` 注入。 +`scripts/dev-server/` 目录现仅保留 README 作为迁移说明,旧本地 API 实现代码已于 `2026-04-19` 删除。 diff --git a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md index e3580928..ec9ced16 100644 --- a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md +++ b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md @@ -2,12 +2,17 @@ ## 1. 这次调整解决什么问题 -此前后端提示词分散在多个业务模块里: +此前提示词分散在多个后端、前端和工具文件里: - `server-node/src/modules/ai/**` - `server-node/src/modules/quest/**` - `server-node/src/modules/runtime-item/**` - `server-node/src/services/customWorld*.ts` +- `server-node/src/services/eightAnchorPromptBuilder.ts` +- `server-node/src/modules/assets/characterAssetRoutes.ts` +- `src/services/**` +- `src/tools/qwenSpriteSheetToolModel.ts` +- `src/components/**` 问题主要有三类: @@ -15,23 +20,40 @@ 2. 同一类 prompt 缺少集中入口,排查系统 prompt / user prompt / repair prompt 成本高。 3. 老桥接层、测试和新业务链路同时依赖时,迁移成本高,容易出现导出断裂。 -这次收口目标不是“重写全部 AI 链路”,而是先把当前后端主链 prompt 收到单独目录,业务模块退化成“准备上下文 + 调用 prompt 脚本”。 +这次收口目标不是“重写全部 AI 链路”,而是把当前正式业务 prompt 主源收到独立目录,业务模块退化成“准备上下文 + 调用 prompt 脚本”。 ## 2. 新目录 -本轮新增目录: +本轮落地后的目录: ```text +packages/shared/src/prompts/ +└─ qwenSprite.ts + server-node/src/prompts/ +├─ characterAssetPrompts.ts ├─ chatPromptBuilders.ts ├─ customWorldAgentPrompts.ts ├─ customWorldEntityPrompts.ts ├─ customWorldOrchestratorPrompts.ts ├─ customWorldSceneNpcPrompts.ts +├─ eightAnchorPrompts.ts ├─ questPrompts.ts ├─ runtimeItemPrompts.ts ├─ storyOrchestratorPrompts.ts └─ storyPromptBuilders.ts + +src/prompts/ +├─ characterChatPrompts.ts +├─ customWorldEntityActionPrompts.ts +├─ customWorldOrchestratorPrompts.ts +├─ customWorldPrompts.ts +├─ customWorldRolePromptDefaults.ts +├─ questPrompts.ts +├─ qwenSpriteSheetToolPrompts.ts +├─ runtimeItemPrompts.ts +├─ storyOrchestratorPrompts.ts +└─ storyPromptBuilders.ts ``` 当前职责划分: @@ -54,6 +76,20 @@ server-node/src/prompts/ - 世界编辑器角色 / 场景实体生成 prompt - `customWorldSceneNpcPrompts.ts` - 世界编辑器场景 NPC 生成 prompt +- `characterAssetPrompts.ts` + - 角色主图 / 动作试片 / 角色关联场景 prompt +- `eightAnchorPrompts.ts` + - 八锚点状态推断、模式规则与正式单轮共创 prompt +- `src/prompts/customWorldPrompts.ts` + - 自定义世界分阶段生成 prompt 与场景背景图 prompt +- `src/prompts/qwenSpriteSheetToolPrompts.ts` + - 精灵图工具主词 / 分镜词 / 修帧词 / 负面词 +- `src/prompts/customWorldRolePromptDefaults.ts` + - 角色资产工作台默认 prompt 种子 +- `src/prompts/customWorldEntityActionPrompts.ts` + - 编辑器技能动作 prompt +- `packages/shared/src/prompts/qwenSprite.ts` + - 共享资产层的基础角色 prompt 模板 ## 3. 落地规则 @@ -82,12 +118,18 @@ server-node/src/prompts/ ### 3.3 兼容层保留旧导出 -本轮对已有纯 prompt builder 文件采取了兼容迁移: +本轮对已有纯 prompt builder 文件采取了兼容迁移,旧路径保留为薄 re-export: - `server-node/src/modules/ai/chatPromptBuilders.ts` - `server-node/src/modules/ai/storyPromptBuilders.ts` - -旧路径现在作为薄 re-export 保留,避免测试、桥接层和旧引用一次性全部断掉。 +- `server-node/src/services/eightAnchorPromptBuilder.ts` +- `src/services/prompt.ts` +- `src/services/characterChatPrompt.ts` +- `src/services/questPrompt.ts` +- `src/services/runtimeItemAiPrompt.ts` +- `src/tools/qwenSpriteSheetToolModel.ts` +- `src/components/asset-studio/customWorldRolePromptDefaults.ts` +- `packages/shared/src/assets/qwenSprite.ts` 对于 `runtimeQuestModule.ts`、`runtimeItemModule.ts` 这类被桥接层直接引用的模块,本轮保留原导出名,通过 re-export 指向新 prompt 文件,保证兼容性。 @@ -95,11 +137,12 @@ server-node/src/prompts/ 新增提示词时按下面顺序处理: -1. 先判断是否属于已有领域。 -2. 如果属于已有领域,优先补到对应 `server-node/src/prompts/*.ts`。 -3. 如果是新领域,再新增一个独立 prompt 脚本文件。 -4. 业务模块只传入已经整理好的上下文字段,不在模块内部继续拼长文本。 -5. 至少补一条该 prompt 的调用链测试或现有测试断言。 +1. 先判断属于后端、前端/编辑器还是共享工具层。 +2. 后端正式业务优先补到 `server-node/src/prompts/*.ts`。 +3. 前端/编辑器 prompt 优先补到 `src/prompts/*.ts`。 +4. 可复用的共享资产 prompt 优先补到 `packages/shared/src/prompts/*.ts`。 +5. 业务模块只传入已经整理好的上下文字段,不在模块内部继续拼长文本。 +6. 至少补一条该 prompt 的调用链测试或现有测试断言。 建议命名: @@ -108,7 +151,7 @@ server-node/src/prompts/ - user prompt:`buildXXXPrompt` - 纯文本装配:`buildXXXPromptText` -## 5. 本轮范围与剩余事项 +## 5. 本轮范围与当前状态 本轮已经收口: @@ -119,16 +162,17 @@ server-node/src/prompts/ - Custom World 主编排 - Custom World Agent 草稿增补 - Custom World 编辑器角色 / 场景 / 场景 NPC 生成 +- Character Asset +- Eight Anchor +- Scene Image +- 前端剧情 / 私聊 / 任务 / 物品 prompt 兼容层 +- 编辑器与工具链 prompt 种子 -本轮尚未完全收口的内联 prompt 聚集区: +当前状态: -- `server-node/src/modules/assets/characterAssetRoutes.ts` -- `server-node/src/services/eightAnchorPromptBuilder.ts` - -这两块后续继续沿用同一规则: - -- 先抽出 prompt 文本与 builder -- 再让业务文件只保留参数整理与调用 +- 正式业务 prompt 主源已经集中到 prompt 目录。 +- 旧 `services/`、`tools/`、`components/` 下保留的相关文件主要是兼容层或调用方。 +- 当前没有再发现需要优先继续抽离的大块业务 prompt 正文。 ## 6. 验证方式 @@ -138,6 +182,9 @@ server-node/src/prompts/ - `npm run server-node:test` - `npm --prefix server-node run build` -说明: +本轮实测结果: -- 当前仓库里 `server-node/src/db.test.ts` 仍有一条与新增迁移版本号相关的既有失败,不属于本次 prompt 目录改造引入的问题。 +- `npm run check:encoding` 通过 +- `npm --prefix server-node run build` 通过 +- `npm run build` 通过 +- `npm run server-node:test` 143 项全部通过 diff --git a/docs/technical/README.md b/docs/technical/README.md index 62477f5f..a6c5531b 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 - [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 - [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md):Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。 diff --git a/docs/technical/REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md b/docs/technical/REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md new file mode 100644 index 00000000..ed1a68af --- /dev/null +++ b/docs/technical/REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md @@ -0,0 +1,53 @@ +# 仓库噪音清理基线(2026-04-19) + +更新时间:`2026-04-19` + +## 1. 背景 + +本次清理落实以下审计结论: + +- `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md` + +目标只覆盖审计文档推荐执行顺序中的第一阶段,也就是先把仓库噪音产物和本地检查残留从主工程面清掉,不在这一轮同时推进前后端边界迁移或无入口模块归档。 + +## 2. 本次已执行的清理 + +本轮已从仓库中移除以下高置信度噪音产物: + +- 根目录的 `.codex-*` 日志、`.preview.*` 输出、`tmp_*` 扫描文本与 HTML、`npc-editor-*` 截图和调试文件、`temp-write-check.txt` +- 根目录 `temp-build-goal-check/` 大体量检查产物目录 +- `scripts/__pycache__/` Python 缓存目录与 `.pyc` 文件 +- `.codex-logs/` 中遗留的旧日志与 restore backup 文件 + +这些文件都不属于正式运行时代码、PRD、设计稿或生产资源,继续保留只会污染根目录视野、拖慢扫描和 review,并增加误判成本。 + +## 3. 规则闭合 + +为避免相同问题反复回流,本次同步补齐了两层规则: + +1. `.gitignore` + - 新增 `/.codex-logs/` + - 新增 `*.py[cod]` + - 保持 `.preview.*`、`tmp_*`、`tmp/`、`npc-editor-*`、`temp-write-check.txt`、`temp-build-goal-check/`、`**/__pycache__/` 为忽略项 +2. `.eslintrc.cjs` + - 保持 `temp-build-goal-check/**` + - 保持 `.preview.*`、`tmp_*`、`tmp/**`、`npc-editor-*`、`temp-write-check.txt` + - 保持 `**/__pycache__/**` + +这样 Git 与 ESLint 对临时产物的忽略口径保持一致,不会再出现某个目录虽然被 Git 忽略,但仍被 lint 扫进去的情况。 + +## 4. 后续约束 + +- 以后所有临时扫描结果、调试截图、HTML 导出、一次性日志,不要直接落在仓库根目录。 +- 如需保留本地临时产物,统一放到本地忽略目录,例如 `tmp/` 或 `.codex-logs/`。 +- `temp-build-goal-check/` 仍视为本地检查产物,不作为主工程目录的一部分长期保留。 +- Python 脚本执行后产生的 `__pycache__` 不应进入仓库。 + +## 5. 本轮未处理范围 + +以下内容仍按审计原计划留待后续阶段处理: + +- `scripts/dev-server/localApiPlugins.ts` 及旧 Vite 本地 API 链路的归档 +- 无运行时入口或仅测试引用的孤岛模块处置 +- 前后端双份真相收口与 `server-node -> src/**` 反向依赖治理 +- 巨型热点文件拆分 diff --git a/docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md b/docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md new file mode 100644 index 00000000..61d45654 --- /dev/null +++ b/docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md @@ -0,0 +1,174 @@ +# Runtime Story 后端边界迁移记录(2026-04-19) + +更新时间:`2026-04-20` + +## 1. 本轮目标 + +本轮只处理 `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md` 中已经明确、且可以无需求漂移落地的两类问题: + +1. `RuntimeStoryOptionView` 的 `interaction` 语义不能再由前端根据 `functionId + currentEncounter` 本地重建。 +2. `src/hooks/story/npcEncounterActions.ts` 中 `help / leave / fight / spar / quest_turn_in` 等运行时动作不能继续在浏览器里保留旧本地结算分支。 + +本轮**没有**做以下事情: + +1. 没有修改任何功能需求。 +2. 没有修改任何业务提示词。 +3. 没有扩展新的动作能力面。 +4. 没有处理 custom-world 领域规则从 `server-node -> src/services/**` 反向 import 的剩余问题。 + +## 2. 已落地收口 + +### 2.1 runtime option interaction 改为后端唯一构建 + +已完成: + +1. `server-node/src/modules/story/runtimeSession.ts` + - `buildOptionView(...)` 直接输出 `interaction` + - `buildAvailableOptions(...)` 直接返回带 `interaction` 的 runtime option + - 删除前一版补丁式附加逻辑,避免后续再出现“双份映射” +2. `server-node/src/modules/story/storyActionService.ts` + - story option view model 直接透传 `option.interaction` +3. `src/services/runtimeStoryService.ts` + - 前端不再根据 `currentEncounter` 重建一份 `interaction` + - 只消费服务端返回的 `option.interaction` + +这意味着: + +1. `npc_chat / npc_help / npc_fight / npc_leave / npc_trade / npc_gift / npc_quest_*` +2. `treasure_secure / treasure_inspect / treasure_leave` + +这些交互语义以后都以后端 runtime session 为准。 + +### 2.2 npcEncounterActions 收缩为前端壳层 + +已完成: + +1. `src/hooks/story/npcEncounterActions.ts` + - `help` + - `leave` + - `fight` + - `spar` + - `quest_accept` + - `quest_turn_in` + + 以上动作已统一改为走 `resolveServerRuntimeChoice(...)` +2. 删除本轮迁移后已无消费方的本地 helper / import / 常量残留,避免误以为本地仍承担这部分结算职责。 + +当前前端在这条链路上只保留: + +1. 选项点击分发 +2. trade / gift / recruit 的 UI modal 打开 +3. NPC 对话壳层与流式展示 +4. 服务端返回结果的状态落地与 story 刷新 + +### 2.3 待接委托正式接取改为后端收口 + +已完成: + +1. `server-node/src/modules/quest/questStoryActionService.ts` + - `npc_quest_accept` 会优先读取服务端快照里 `currentStory.npcChatState.pendingQuestOffer.quest` + - 如果当前聊天态里已经存在待接委托,就按这份已展示给玩家的委托正式接取,不再在后端临时重建另一份任务 +2. `server-node/src/modules/story/storyActionService.ts` + - `npc_quest_accept` 在存在待接委托聊天态时,会继续输出 `displayMode: 'dialogue'` 的 current story + - 会清空 `pendingQuestOffer` + - 会恢复既有的三条自由追问建议,避免接任务后 UI 退回普通 story 文本态 +3. `src/hooks/story/npcEncounterActions.ts` + - `acceptPendingNpcQuestOffer()` 不再本地直接修改 `quests / runtimeStats / storyHistory` + - 现在只负责触发 `resolveServerRuntimeChoice(...)`,由后端统一落地正式接取结果 + +这意味着: + +1. “NPC 聊天里弹出的待接委托”不再是一份只存在于前端内存里的临时结果 +2. 前端不会再一边展示待接委托、一边本地把正式 quest log 写出来 +3. 正式接取后的 quest 真相源、聊天态投影与快照持久化统一以后端为准 + +### 2.4 NPC 聊天待接委托生成与浏览器 LLM fallback 继续后移 + +已完成: + +1. `server-node/src/modules/ai/chatOrchestrator.ts` + - `streamNpcChatTurnFromOrchestrator(...)` 现在会基于前端提交的 `questOfferContext` + - 由后端判断是否应该生成 `pendingQuestOffer` + - quest draft 与引导文案 `introText` 由服务端一并回填到 `complete` 事件 +2. `src/services/aiService.ts` + - `streamNpcChatTurn(...)` 已支持把 `questOfferContext` 送入后端 +3. `src/hooks/story/npcEncounterActions.ts` + - NPC 单轮聊天结束后不再本地调用 `generateQuestForNpcEncounter(...)` + - 前端改为只消费服务端回填的 `pendingQuestOffer + introText` +4. `src/services/questDirector.ts` + - 浏览器端 quest draft 失败时,不再退回本地 LLM 调用 + - 当前改为直接走 deterministic fallback quest compile +5. `src/services/runtimeItemAiDirector.ts` + - 浏览器端 runtime item intent 失败时,不再退回本地 LLM 调用 + - 当前改为直接返回 deterministic fallback intents + +这意味着: + +1. NPC 聊天里“是否触发待接委托”的判定不再由前端依据 `turnCount / affinity / quests` 本地重跑 +2. 浏览器不再保留 quest draft 与 runtime item intent 的正式 LLM fallback orchestration +3. 前端在这两条链路上继续退化为服务端结果消费层 + +## 3. 本轮涉及文件 + +代码: + +1. `server-node/src/modules/story/runtimeSession.ts` +2. `server-node/src/modules/story/storyActionService.ts` +3. `server-node/src/modules/story/storyActionRoutes.test.ts` +4. `src/services/runtimeStoryService.ts` +5. `src/services/runtimeStoryService.test.ts` +6. `src/hooks/story/npcEncounterActions.ts` +7. `src/hooks/story/npcEncounterActions.test.ts` +8. `server-node/src/modules/quest/questStoryActionService.ts` +9. `server-node/src/modules/ai/chatOrchestrator.ts` +10. `server-node/src/modules/ai/orchestrator.test.ts` +11. `src/services/aiService.ts` +12. `src/services/questDirector.ts` +13. `src/services/runtimeItemAiDirector.ts` + +说明: + +1. 本轮没有改 prompt 文本。 +2. 本轮没有新增或删减 runtime functionId。 +3. 本轮只是把既有交互语义和动作结算职责收回到后端。 + +## 4. 验证结果 + +已执行并通过: + +1. `npm run check:encoding` +2. `npx vitest run src/services/runtimeStoryService.test.ts src/hooks/story/npcEncounterActions.test.ts` +3. `npx tsx --test server-node/src/modules/story/runtimeSession.test.ts` + +新增的防回退验证包括: + +1. 前端 `runtimeStoryService` 会保留并消费服务端返回的 `interaction` +2. `npcEncounterActions` 的 `help / leave / fight / spar` 明确走服务端 resolver +3. `npc_quest_turn_in` 会把 `questId` 原样透传到后端 +4. 后端 `runtimeSession` 明确直接构建并透传 NPC option interaction +5. 待接委托正式接取会复用快照中的 pending quest,而不是在接取瞬间重新造一份 quest +6. 待接委托接取后仍保持 NPC 聊天展示态,并清空 `pendingQuestOffer` +7. NPC chat turn 的 pending quest offer 现在由后端直接产出,前端不再本地二次决定 +8. 浏览器端 quest / runtime item 已不再保留直接调用本地 LLM 的 fallback orchestration + +补充说明: + +1. `server-node/src/modules/story/storyActionRoutes.test.ts` 已补充路由级 interaction 断言。 +2. `2026-04-20` 的补充收口改为以 `storyActionRoutes.test.ts` 直接覆盖待接委托正式接取链路,验证快照输入与快照输出是否都以后端为准。 +3. 本轮没有改 prompt 文本,也没有新增函数能力面,只是继续把现有 runtime story 结算权后移。 + +## 5. 暂未处理的后续边界项 + +这轮故意没有继续扩大的点: + +1. `server-node/src/modules/ai/customWorldOrchestrator.ts` 仍直接依赖 `src/services/customWorld*.ts` +2. `server-node/src/services/customWorldAgentFoundationDraftService.ts` 仍直接依赖 `src/services/customWorld*.ts` 与 `src/types.ts` +3. `pending quest offer` 的 replace / abandon 仍由前端 UI 壳层协调 +4. 浏览历史、本地快照真相源、custom-world 领域规则共享化仍需后续阶段继续推进 + +原因: + +1. 这些点会牵涉 shared contract 抽取与较大范围重组 +2. 若与本轮 runtime story 收口混做,容易把“边界迁移”误做成“需求改造” + +因此本轮把范围锁在 runtime story 主链,优先交付一条可验证、可继续扩展的后端边界基线。 diff --git a/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md b/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md index 4703b585..ef0f502c 100644 --- a/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md +++ b/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md @@ -42,7 +42,8 @@ ### 2.2 当前 API 层还只是开发期能力 -这些接口现在主要由 [scripts/dev-server/localApiPlugins.ts](/E:/Repos/Genarrative/scripts/dev-server/localApiPlugins.ts) 挂在 Vite dev/preview 服务器里。 +本文撰写时,这些接口主要由旧 `scripts/dev-server/localApiPlugins.ts` 挂在 Vite dev/preview 服务器里。 +截至 `2026-04-19`,这条旧链路已经从仓库删除,当前统一由 `server-node` 承接。 这在本地开发阶段很方便,但生产环境存在几个明显问题: @@ -500,7 +501,14 @@ flowchart LR ## 11.1 第一优先级:把 Vite 里的 API 能力抽出来 -当前 [scripts/dev-server/localApiPlugins.ts](/E:/Repos/Genarrative/scripts/dev-server/localApiPlugins.ts) 里的能力,建议分三类迁移: +该迁移建议已完成,原 `scripts/dev-server/localApiPlugins.ts` 已于 `2026-04-19` 删除。 +对应能力现统一收口到以下正式模块: + +- `server-node/src/modules/editor/**` +- `server-node/src/modules/assets/**` +- `server-node/src/modules/ai/**` + +原旧链路中的能力,可按以下三类理解其迁移归属: ### A. 运行时代理接口 diff --git a/npc-editor-dom.html b/npc-editor-dom.html deleted file mode 100644 index e69de29b..00000000 diff --git a/npc-editor-shot.png b/npc-editor-shot.png deleted file mode 100644 index 82bff63b..00000000 Binary files a/npc-editor-shot.png and /dev/null differ diff --git a/packages/shared/src/assets/qwenSprite.ts b/packages/shared/src/assets/qwenSprite.ts index f53da868..67957218 100644 --- a/packages/shared/src/assets/qwenSprite.ts +++ b/packages/shared/src/assets/qwenSprite.ts @@ -1,146 +1 @@ -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 BODY_RATIO_TEXT = - '横版像素动作角色体型,头身比优先控制在 3 到 4 头身,头部只允许略大于写实比例,保留清楚的头、躯干、双臂和双腿轮廓,不要退化成软萌 Q版大头贴或儿童绘本比例。'; -const PIXEL_STYLE_TEXT = - '明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,身体始终朝右,适合横版动作 sprite 资产。'; - -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 动画。', - `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, - `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, - `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, - `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感,便于后续连续动作生成。`, - '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。', - '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', - '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。', - characterBrief.trim(), - ] - .filter(Boolean) - .join('\n'); -} - -export function buildVideoActionPrompt(options: { - actionTemplate: QwenSpriteActionTemplate; - actionDetailText: string; - useChromaKey: boolean; - characterBrief: string; -}) { - return [ - `单人全身角色动作视频,动作英文名是 ${options.actionTemplate.id}。`, - `角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`, - `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, - `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, - `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, - `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`, - `动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`, - options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' - : '背景简洁纯净,无复杂场景。', - `动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`, - `角色设定:${options.characterBrief.trim()}`, - '目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。', - ].join(' '); -} +export * from '../prompts/qwenSprite.js'; diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts index 203826a2..2ba99892 100644 --- a/packages/shared/src/contracts/customWorldAgent.ts +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -68,6 +68,8 @@ export interface CustomWorldWorkSummary { subtitle: string; summary: string; coverImageSrc?: string | null; + coverRenderMode?: 'image' | 'scene_with_roles'; + coverCharacterImageSrcs?: string[]; updatedAt: string; publishedAt?: string | null; stage?: string | null; diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts index b915471a..4d9cb352 100644 --- a/packages/shared/src/contracts/story.ts +++ b/packages/shared/src/contracts/story.ts @@ -9,8 +9,7 @@ export const QUEST_NARRATIVE_TYPES = [ 'relationship', 'trial', ] as const; -export type SharedQuestNarrativeType = - (typeof QUEST_NARRATIVE_TYPES)[number]; +export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; export const QUEST_OBJECTIVE_KINDS = [ 'defeat_hostile_npc', @@ -20,8 +19,7 @@ export const QUEST_OBJECTIVE_KINDS = [ 'reach_scene', 'deliver_item', ] as const; -export type SharedQuestObjectiveKind = - (typeof QUEST_OBJECTIVE_KINDS)[number]; +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]; @@ -40,8 +38,7 @@ export const QUEST_REWARD_THEMES = [ 'intel', 'rare_item', ] as const; -export type SharedQuestRewardTheme = - (typeof QUEST_REWARD_THEMES)[number]; +export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number]; export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [ 'heal', @@ -60,8 +57,7 @@ export const RUNTIME_ITEM_TONE_VALUES = [ 'ritual', 'survival', ] as const; -export type SharedRuntimeItemTone = - (typeof RUNTIME_ITEM_TONE_VALUES)[number]; +export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number]; export type StoryRequestOptionsPayload = { availableOptions?: JsonObject[]; @@ -164,6 +160,8 @@ export type NpcChatTurnRequest< TContext = unknown, TConversationTurn = unknown, TNpcState = unknown, + TQuestOfferState = unknown, + TQuestOfferEncounter = unknown, > = { worldType: string; character?: TCharacter; @@ -176,13 +174,24 @@ export type NpcChatTurnRequest< dialogue?: TConversationTurn[]; playerMessage: string; npcState: TNpcState; + questOfferContext?: { + state: TQuestOfferState; + encounter: TQuestOfferEncounter; + turnCount: number; + } | null; }; -export type NpcChatTurnResult = { +export type NpcChatPendingQuestOffer = { + quest: TQuest; + introText?: string; +}; + +export type NpcChatTurnResult = { npcReply: string; affinityDelta: number; affinityText: string; suggestions: string[]; + pendingQuestOffer?: NpcChatPendingQuestOffer | null; }; export type NpcRecruitDialogueRequest< @@ -315,6 +324,28 @@ export type RuntimeStoryChoicePayload = JsonObject & { note?: string; }; +export type RuntimeStoryOptionInteraction = + | { + kind: 'npc'; + npcId: string; + action: + | 'chat' + | 'help' + | 'fight' + | 'leave' + | 'recruit' + | 'spar' + | 'trade' + | 'gift' + | 'quest_accept' + | 'quest_turn_in'; + questId?: string; + } + | { + kind: 'treasure'; + action: 'inspect' | 'leave' | 'secure'; + }; + export type RuntimeStoryChoiceAction = RuntimeAction< 'story_choice', RuntimeStoryChoicePayload @@ -328,6 +359,7 @@ export type RuntimeStoryOptionView = { actionText: string; detailText?: string; scope: Task5RuntimeOptionScope; + interaction?: RuntimeStoryOptionInteraction; payload?: RuntimeStoryChoicePayload; disabled?: boolean; reason?: string; diff --git a/packages/shared/src/prompts/qwenSprite.ts b/packages/shared/src/prompts/qwenSprite.ts new file mode 100644 index 00000000..f53da868 --- /dev/null +++ b/packages/shared/src/prompts/qwenSprite.ts @@ -0,0 +1,146 @@ +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 BODY_RATIO_TEXT = + '横版像素动作角色体型,头身比优先控制在 3 到 4 头身,头部只允许略大于写实比例,保留清楚的头、躯干、双臂和双腿轮廓,不要退化成软萌 Q版大头贴或儿童绘本比例。'; +const PIXEL_STYLE_TEXT = + '明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,身体始终朝右,适合横版动作 sprite 资产。'; + +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 动画。', + `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, + `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, + `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, + `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感,便于后续连续动作生成。`, + '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。', + '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', + '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。', + characterBrief.trim(), + ] + .filter(Boolean) + .join('\n'); +} + +export function buildVideoActionPrompt(options: { + actionTemplate: QwenSpriteActionTemplate; + actionDetailText: string; + useChromaKey: boolean; + characterBrief: string; +}) { + return [ + `单人全身角色动作视频,动作英文名是 ${options.actionTemplate.id}。`, + `角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`, + `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, + `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, + `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, + `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`, + `动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`, + options.useChromaKey + ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' + : '背景简洁纯净,无复杂场景。', + `动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`, + `角色设定:${options.characterBrief.trim()}`, + '目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。', + ].join(' '); +} diff --git a/scripts/__pycache__/generate-build-tag-similarity.cpython-313.pyc b/scripts/__pycache__/generate-build-tag-similarity.cpython-313.pyc deleted file mode 100644 index 3699158a..00000000 Binary files a/scripts/__pycache__/generate-build-tag-similarity.cpython-313.pyc and /dev/null differ diff --git a/scripts/dev-server/README.md b/scripts/dev-server/README.md index dc13462f..847a17d1 100644 --- a/scripts/dev-server/README.md +++ b/scripts/dev-server/README.md @@ -1,11 +1,14 @@ -# 旧 Vite 本地 API 插件 +# 已移除的旧 Vite 本地 API 链路 -`scripts/dev-server/**` 已不再是当前开发入口。 +自 `2026-04-19` 起,`scripts/dev-server/**` 下的旧本地 API 实现代码已经从仓库删除。 -当前编辑器与资产接口已经迁移到: +当前正式开发入口统一为: +- `node scripts/dev-node.mjs` - `server-node/src/modules/editor/**` - `server-node/src/modules/assets/**` - `src/editor/shared/editorApiClient.ts` -这些文件仅保留为迁移参考,不要在这里继续新增 `/api/*` 编辑器写盘或资产生成接口。 +该目录只保留本说明文件,作为迁移结果标记。 + +不要在仓库中恢复或新增旧式 Vite `/api/*` 本地插件链路。 diff --git a/scripts/dev-server/characterAssetStudioPlugins.ts b/scripts/dev-server/characterAssetStudioPlugins.ts deleted file mode 100644 index 36711a0a..00000000 --- a/scripts/dev-server/characterAssetStudioPlugins.ts +++ /dev/null @@ -1,2165 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import http, { - type IncomingMessage, - type RequestOptions, - type ServerResponse, -} from 'node:http'; -import https from 'node:https'; -import path from 'node:path'; - -import { loadEnv, type Plugin } from 'vite'; - -import { - buildMasterPrompt, - buildVideoActionPrompt, - getActionTemplateById, -} from '../../src/tools/qwenSpriteSheetToolModel'; - -const CHARACTER_VISUAL_GENERATE_PATH = '/api/character-visual/generate'; -const CHARACTER_VISUAL_JOBS_PATH = '/api/character-visual/jobs/'; -const CHARACTER_ANIMATION_GENERATE_PATH = '/api/animation/generate'; -const CHARACTER_ANIMATION_JOBS_PATH = '/api/animation/jobs/'; -const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = '/api/animation/import-video'; -const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/animation/templates'; -const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; -const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro'; -const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash'; -const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v'; -const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move'; -const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500; -const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000; -const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000; -const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000; - -const BUILT_IN_MOTION_TEMPLATES = [ - { - id: 'idle_loop', - label: '待机循环', - animation: 'idle', - promptSuffix: '保持呼吸感和轻微重心起伏。', - notes: '适合方案三的默认待机模板。', - }, - { - id: 'run_side', - label: '奔跑侧移', - animation: 'run', - promptSuffix: '保持平稳横向移动,脚步连续。', - notes: '适合横版角色的标准奔跑模板。', - }, - { - id: 'attack_slash', - label: '横斩攻击', - animation: 'attack', - promptSuffix: '短促前踏后横斩,收招干净。', - notes: '适合近战角色的基础攻击模板。', - }, - { - id: 'hurt_back', - label: '受击后仰', - animation: 'hurt', - promptSuffix: '身体后仰,重心短暂失衡后稳住。', - notes: '适合方案三的受击模板。', - }, - { - id: 'die_fall', - label: '倒地死亡', - animation: 'die', - promptSuffix: '失衡倒地,动作完整结束。', - notes: '适合终结动作模板。', - }, -] as const; - -type RequestResponse = { - statusCode: number; - headers: Record; - body: Buffer; -}; - -type DecodedMediaPayload = { - buffer: Buffer; - mimeType: string; - extension: string; -}; - -function readJsonBody(req: IncomingMessage) { - return new Promise>((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => { - try { - const raw = Buffer.concat(chunks).toString('utf8') || '{}'; - resolve(JSON.parse(raw)); - } catch (error) { - reject(error); - } - }); - req.on('error', reject); - }); -} - -function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function isRecordValue(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.every((item) => typeof item === 'string' && item.trim().length > 0) - ); -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function normalizeDashScopeBaseUrl(value: string) { - return value.replace(/\/$/u, ''); -} - -function resolveRuntimeEnv( - rootDir: string, - mode: string, - env: Record, -) { - return { - ...env, - ...loadEnv(mode, rootDir, ''), - }; -} - -function extractApiErrorMessage(responseText: string, fallbackMessage: string) { - if (!responseText.trim()) { - return fallbackMessage; - } - - try { - const parsed = JSON.parse(responseText) as { - code?: string; - message?: string; - error?: { message?: string }; - }; - if ( - typeof parsed.error?.message === 'string' && - parsed.error.message.trim() - ) { - return parsed.error.message; - } - if (typeof parsed.message === 'string' && parsed.message.trim()) { - return parsed.message; - } - if (typeof parsed.code === 'string' && parsed.code.trim()) { - return `${fallbackMessage} (${parsed.code})`; - } - } catch { - // Fall through. - } - - return responseText; -} - -function sanitizePathSegment(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-_]+/gu, '-') - .replace(/-+/gu, '-') - .replace(/^-|-$/gu, ''); - - return normalized || 'asset'; -} - -function createTimestampId(prefix: string) { - return `${prefix}-${Date.now()}`; -} - -function getJobRecordPath( - rootDir: string, - kind: 'visual' | 'animation', - taskId: string, -) { - return path.resolve( - rootDir, - 'public', - 'generated-character-drafts', - '_jobs', - kind, - `${sanitizePathSegment(taskId)}.json`, - ); -} - -async function writeJobRecord( - rootDir: string, - kind: 'visual' | 'animation', - taskId: string, - payload: Record, -) { - const filePath = getJobRecordPath(rootDir, kind, taskId); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); -} - -async function readJobRecord( - rootDir: string, - kind: 'visual' | 'animation', - taskId: string, -) { - const filePath = getJobRecordPath(rootDir, kind, taskId); - const raw = await readFile(filePath, 'utf8'); - return JSON.parse(raw) as Record; -} - -function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { - const matched = /^data:([^;]+);base64,(.+)$/u.exec(dataUrl); - if (!matched) { - throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); - } - - const mimeType = matched[1]; - const base64Payload = matched[2]; - const extension = (() => { - switch (mimeType) { - case 'image/jpeg': - return 'jpg'; - case 'image/png': - return 'png'; - case 'image/webp': - return 'webp'; - case 'video/mp4': - return 'mp4'; - case 'video/quicktime': - return 'mov'; - case 'video/x-msvideo': - return 'avi'; - default: - return mimeType.split('/')[1] ?? 'bin'; - } - })(); - - return { - buffer: Buffer.from(base64Payload, 'base64'), - mimeType, - extension, - }; -} - -async function resolveMediaSourcePayload( - rootDir: string, - source: string, -): Promise { - const dataUrlMatch = /^data:/u.test(source); - if (dataUrlMatch) { - return decodeMediaDataUrl(source); - } - - if (!source.startsWith('/')) { - throw new Error('媒体来源必须是 Data URL 或 public 目录下的 URL。'); - } - - const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); - const absolutePath = path.resolve( - rootDir, - 'public', - ...normalizedSource.split('/'), - ); - const publicRoot = path.resolve(rootDir, 'public'); - if (!absolutePath.startsWith(publicRoot)) { - throw new Error('媒体来源路径越界。'); - } - - const buffer = await readFile(absolutePath); - const extension = path - .extname(absolutePath) - .replace(/^\./u, '') - .toLowerCase(); - const mimeType = (() => { - switch (extension) { - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'png': - return 'image/png'; - case 'webp': - return 'image/webp'; - case 'mp4': - return 'video/mp4'; - case 'mov': - return 'video/quicktime'; - case 'avi': - return 'video/x-msvideo'; - default: - return 'application/octet-stream'; - } - })(); - - return { - buffer, - mimeType, - extension: extension || 'bin', - }; -} - -async function resolveMediaSourceAsDataUrl( - rootDir: string, - source: string, -) { - if (/^data:/u.test(source)) { - return source; - } - - const payload = await resolveMediaSourcePayload(rootDir, source); - return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; -} - -function requestResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - body?: Buffer | string; - } = {}, -) { - return new Promise((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const payload = - typeof options.body === 'string' - ? Buffer.from(options.body) - : options.body; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: { - ...(options.headers ?? {}), - ...(payload ? { 'Content-Length': String(payload.byteLength) } : {}), - }, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - body: Buffer.concat(chunks), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - if (payload) { - request.write(payload); - } - request.end(); - }); -} - -function getRequestPathname(req: IncomingMessage) { - return new URL(req.url || '/', 'http://localhost').pathname; -} - -function requestTextResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - body?: Buffer | string; - } = {}, -) { - return requestResponse(urlString, options).then((response) => ({ - ...response, - bodyText: response.body.toString('utf8'), - })); -} - -function requestBinaryResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - } = {}, -) { - return requestResponse(urlString, options); -} - -function proxyJsonRequest( - urlString: string, - apiKey: string, - body: Record, - extraHeaders: Record = {}, -) { - return requestTextResponse(urlString, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - ...extraHeaders, - }, - body: JSON.stringify(body), - }); -} - -function buildMultipartBody( - fields: Array<{ name: string; value: string }>, - file: { - fieldName: string; - fileName: string; - contentType: string; - buffer: Buffer; - }, -) { - const boundary = `----GenarrativeBoundary${Date.now().toString(16)}`; - const chunks: Buffer[] = []; - - fields.forEach((field) => { - chunks.push( - Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`, - ), - ); - }); - - chunks.push( - Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, - ), - ); - chunks.push(file.buffer); - chunks.push(Buffer.from(`\r\n--${boundary}--\r\n`)); - - return { - boundary, - body: Buffer.concat(chunks), - }; -} - -async function uploadFileToDashScope( - baseUrl: string, - apiKey: string, - model: string, - fileName: string, - payload: DecodedMediaPayload, -) { - const policyResponse = await requestTextResponse( - `${baseUrl}/uploads?action=getPolicy&model=${encodeURIComponent(model)}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - ); - - if (policyResponse.statusCode < 200 || policyResponse.statusCode >= 300) { - throw new Error( - extractApiErrorMessage( - policyResponse.bodyText, - '获取阿里云临时上传策略失败。', - ), - ); - } - - const policyResponsePayload = JSON.parse(policyResponse.bodyText) as { - data?: { - upload_host?: string; - upload_dir?: string; - policy?: string; - signature?: string; - oss_access_key_id?: string; - x_oss_object_acl?: string; - x_oss_content_type?: string; - x_oss_forbid_overwrite?: string; - 'x-oss-object-acl'?: string; - 'x-oss-content-type'?: string; - 'x-oss-forbid-overwrite'?: string; - }; - }; - const policyPayload = policyResponsePayload.data ?? {}; - - if ( - !policyPayload.upload_host || - !policyPayload.upload_dir || - !policyPayload.policy || - !policyPayload.signature || - !policyPayload.oss_access_key_id - ) { - throw new Error('阿里云临时上传策略返回不完整。'); - } - - const objectKey = `${policyPayload.upload_dir.replace(/\/+$/u, '')}/${sanitizePathSegment(fileName)}.${payload.extension}`; - const multipart = buildMultipartBody( - [ - { name: 'key', value: objectKey }, - { name: 'OSSAccessKeyId', value: policyPayload.oss_access_key_id }, - { name: 'policy', value: policyPayload.policy }, - { name: 'Signature', value: policyPayload.signature }, - { name: 'success_action_status', value: '200' }, - ...(policyPayload.x_oss_object_acl || policyPayload['x-oss-object-acl'] - ? [ - { - name: 'x-oss-object-acl', - value: - policyPayload.x_oss_object_acl || - policyPayload['x-oss-object-acl'] || - '', - }, - ] - : []), - ...(policyPayload.x_oss_forbid_overwrite || - policyPayload['x-oss-forbid-overwrite'] - ? [ - { - name: 'x-oss-forbid-overwrite', - value: - policyPayload.x_oss_forbid_overwrite || - policyPayload['x-oss-forbid-overwrite'] || - '', - }, - ] - : []), - ...(policyPayload.x_oss_content_type || - policyPayload['x-oss-content-type'] - ? [ - { - name: 'x-oss-content-type', - value: - policyPayload.x_oss_content_type || - policyPayload['x-oss-content-type'] || - '', - }, - ] - : []), - ], - { - fieldName: 'file', - fileName, - contentType: payload.mimeType, - buffer: payload.buffer, - }, - ); - - const uploadResponse = await requestTextResponse(policyPayload.upload_host, { - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`, - }, - body: multipart.body, - }); - - if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) { - throw new Error( - extractApiErrorMessage(uploadResponse.bodyText, '上传媒体文件失败。'), - ); - } - - return `oss://${objectKey}`; -} - -async function waitForDashScopeTask( - baseUrl: string, - apiKey: string, - taskId: string, - options: { - timeoutMs: number; - intervalMs: number; - }, -) { - const deadline = Date.now() + options.timeoutMs; - - while (Date.now() < deadline) { - const response = await requestTextResponse(`${baseUrl}/tasks/${taskId}`, { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error( - extractApiErrorMessage( - response.bodyText, - `查询任务失败(${response.statusCode})。`, - ), - ); - } - - const parsed = JSON.parse(response.bodyText) as Record; - const output = isRecordValue(parsed.output) ? parsed.output : null; - const taskStatus = - output && typeof output.task_status === 'string' - ? output.task_status - : ''; - - if (taskStatus === 'SUCCEEDED') { - return parsed; - } - - if (taskStatus === 'FAILED' || taskStatus === 'CANCELED') { - throw new Error( - extractApiErrorMessage(response.bodyText, '任务执行失败。'), - ); - } - - if (taskStatus === 'UNKNOWN') { - throw new Error('任务状态未知,可能已过期。'); - } - - await sleep(options.intervalMs); - } - - throw new Error('任务执行超时,请稍后重试。'); -} - -function findFirstStringByKey( - value: unknown, - targetKey: string, -): string | null { - if (Array.isArray(value)) { - for (const item of value) { - const candidate = findFirstStringByKey(item, targetKey); - if (candidate) { - return candidate; - } - } - return null; - } - - if (!isRecordValue(value)) { - return null; - } - - const directValue = value[targetKey]; - if (typeof directValue === 'string' && directValue.trim()) { - return directValue.trim(); - } - - for (const nestedValue of Object.values(value)) { - const candidate = findFirstStringByKey(nestedValue, targetKey); - if (candidate) { - return candidate; - } - } - - return null; -} - -function collectStringsByKey( - value: unknown, - targetKey: string, - results: string[], -) { - if (Array.isArray(value)) { - value.forEach((item) => collectStringsByKey(item, targetKey, results)); - return; - } - - if (!isRecordValue(value)) { - return; - } - - const directValue = value[targetKey]; - if (typeof directValue === 'string' && directValue.trim()) { - results.push(directValue.trim()); - } - - Object.values(value).forEach((nestedValue) => - collectStringsByKey(nestedValue, targetKey, results), - ); -} - -function extractTaskId(payload: Record) { - return findFirstStringByKey(payload, 'task_id') ?? ''; -} - -function extractVideoUrl(payload: Record) { - return ( - findFirstStringByKey(payload, 'video_url') ?? - findFirstStringByKey(payload, 'url') ?? - '' - ); -} - -function extractImageUrls(payload: Record) { - const urls: string[] = []; - collectStringsByKey(payload, 'image', urls); - collectStringsByKey(payload, 'url', urls); - return [...new Set(urls)]; -} - -function buildNpcVisualPrompt( - promptText: string, - characterBriefText = '', -) { - const mergedBrief = [characterBriefText.trim(), promptText.trim()] - .filter(Boolean) - .join('\n'); - - return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。'); -} - -function buildImageSequencePrompt( - animation: string, - promptText: string, - frameCount: number, - useChromaKey: boolean, -) { - return [ - `同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`, - '固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。', - '帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。', - useChromaKey - ? '纯绿色背景,无地面装饰,方便后期抠像。' - : '背景尽量纯净,避免复杂场景。', - promptText.trim(), - ] - .filter(Boolean) - .join(' '); -} - -function buildNpcAnimationPrompt(options: { - animation: string; - promptText: string; - useChromaKey: boolean; - characterBriefText?: string; - actionTemplateId?: string; -}) { - if (options.actionTemplateId) { - return buildVideoActionPrompt({ - actionTemplate: getActionTemplateById( - options.actionTemplateId as Parameters[0], - ), - actionDetailText: options.promptText, - useChromaKey: options.useChromaKey, - characterBrief: - options.characterBriefText?.trim() || `${options.animation} 动作角色`, - }); - } - - return [ - `单人 NPC 全身动作视频,动作主题是 ${options.animation}。`, - '角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', - '动作连贯,避免服装、发型、面部、武器随机漂移。', - options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' - : '背景简洁纯净,无复杂场景。', - options.characterBriefText?.trim() - ? `角色设定:${options.characterBriefText.trim()}` - : '', - options.promptText.trim(), - ] - .filter(Boolean) - .join(' '); -} - -async function writeDraftBinaryFile( - rootDir: string, - relativePath: string, - buffer: Buffer, -) { - const absolutePath = path.resolve( - rootDir, - 'public', - ...relativePath.split('/'), - ); - await mkdir(path.dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, buffer); - return `/${relativePath}`; -} - -async function handleGenerateCharacterVisuals( - rootDir: string, - mode: string, - env: Record, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); - const baseUrl = normalizeDashScopeBaseUrl( - runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - const timeoutMs = Number( - runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || - DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, - ); - - if (!apiKey) { - sendJson(res, 500, { - error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色主形象。' }, - }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' - ? body.characterId.trim() - : 'character'; - const sourceMode = - typeof body.sourceMode === 'string' ? body.sourceMode.trim() : ''; - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const characterBriefText = - typeof body.characterBriefText === 'string' - ? body.characterBriefText.trim() - : ''; - const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) - ? body.referenceImageDataUrls.slice(0, 4) - : []; - const candidateCountRaw = - typeof body.candidateCount === 'number' ? body.candidateCount : 3; - const candidateCount = Math.max( - 1, - Math.min(4, Math.round(candidateCountRaw)), - ); - const model = - typeof body.imageModel === 'string' && body.imageModel.trim() - ? body.imageModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || - runtimeEnv.DASHSCOPE_IMAGE_MODEL || - DEFAULT_CHARACTER_VISUAL_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1024*1536'; - - if (sourceMode === 'image-to-image' && referenceImageDataUrls.length === 0) { - sendJson(res, 400, { - error: { message: '图生主形象至少需要一张参考图。' }, - }); - return; - } - - if (!promptText && !characterBriefText && sourceMode === 'text-to-image') { - sendJson(res, 400, { - error: { message: '文生主形象需要填写角色设定。' }, - }); - return; - } - - let activeTaskId = ''; - let activePrompt = ''; - try { - const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText); - activePrompt = finalPrompt; - const content = [ - { text: finalPrompt }, - ...referenceImageDataUrls.map((image) => ({ image })), - ]; - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image-generation/generation`, - apiKey, - { - model, - input: { - messages: [ - { - role: 'user', - content, - }, - ], - }, - parameters: { - n: candidateCount, - size, - prompt_extend: true, - watermark: false, - }, - }, - { - 'X-DashScope-Async': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建角色主形象任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('角色主形象任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'visual', taskId, { - taskId, - kind: 'visual', - status: 'running', - characterId, - model, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, - }); - const imageUrls = extractImageUrls(taskResult).slice(0, candidateCount); - - if (imageUrls.length === 0) { - throw new Error('角色主形象生成成功,但没有返回可下载图片。'); - } - - const jobId = createTimestampId('visual-draft'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'visual', - jobId, - ); - const drafts = await Promise.all( - imageUrls.map(async (imageUrl, index) => { - const imageResponse = await requestBinaryResponse(imageUrl); - - if (imageResponse.statusCode < 200 || imageResponse.statusCode >= 300) { - throw new Error( - `下载主形象候选失败(${imageResponse.statusCode})。`, - ); - } - - const fileName = `candidate-${String(index + 1).padStart(2, '0')}.png`; - const imageSrc = await writeDraftBinaryFile( - rootDir, - path.posix.join(draftRelativeDir, fileName), - imageResponse.body, - ); - - return { - id: `candidate-${index + 1}`, - label: `候选 ${index + 1}`, - imageSrc, - width: 1024, - height: 1536, - }; - }), - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model, - prompt: finalPrompt, - sourceMode, - createdAt: new Date().toISOString(), - imageUrls, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'visual', taskId, { - taskId, - kind: 'visual', - status: 'completed', - characterId, - model, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - drafts, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - model, - prompt: finalPrompt, - drafts, - }); - } catch (error) { - if (activeTaskId) { - await writeJobRecord(rootDir, 'visual', activeTaskId, { - taskId: activeTaskId, - kind: 'visual', - status: 'failed', - characterId, - model, - prompt: activePrompt, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - errorMessage: error instanceof Error ? error.message : '生成角色主形象失败。', - }); - } - sendJson(res, 500, { - error: { - message: - error instanceof Error ? error.message : '生成角色主形象候选失败。', - }, - }); - } -} - -async function handleGenerateCharacterAnimation( - rootDir: string, - mode: string, - env: Record, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); - const baseUrl = normalizeDashScopeBaseUrl( - runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - const timeoutMs = Number( - runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || - runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || - DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - ); - - if (!apiKey) { - sendJson(res, 500, { - error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色动作。' }, - }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' - ? body.characterId.trim() - : 'character'; - const strategy = - typeof body.strategy === 'string' ? body.strategy.trim() : ''; - const animation = - typeof body.animation === 'string' ? body.animation.trim() : 'idle'; - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const characterBriefText = - typeof body.characterBriefText === 'string' - ? body.characterBriefText.trim() - : ''; - const actionTemplateId = - typeof body.actionTemplateId === 'string' - ? body.actionTemplateId.trim() - : ''; - const visualSource = - typeof body.visualSource === 'string' ? body.visualSource.trim() : ''; - const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) - ? body.referenceImageDataUrls.slice(0, 6) - : []; - const referenceVideoDataUrls = isStringArray(body.referenceVideoDataUrls) - ? body.referenceVideoDataUrls.slice(0, 2) - : []; - const lastFrameImageDataUrl = - typeof body.lastFrameImageDataUrl === 'string' && - body.lastFrameImageDataUrl.trim() - ? body.lastFrameImageDataUrl.trim() - : ''; - const frameCount = - typeof body.frameCount === 'number' && Number.isFinite(body.frameCount) - ? Math.max(2, Math.min(16, Math.round(body.frameCount))) - : 8; - const requestedDurationSeconds = - typeof body.durationSeconds === 'number' && - Number.isFinite(body.durationSeconds) - ? Math.max(1, Math.min(8, Math.round(body.durationSeconds))) - : 4; - const useChromaKey = body.useChromaKey !== false; - const resolution = - typeof body.resolution === 'string' && body.resolution.trim() - ? body.resolution.trim() - : '720P'; - const imageSequenceModel = - typeof body.imageSequenceModel === 'string' && - body.imageSequenceModel.trim() - ? body.imageSequenceModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL || - runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || - DEFAULT_CHARACTER_VISUAL_MODEL; - const videoModel = - typeof body.videoModel === 'string' && body.videoModel.trim() - ? body.videoModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || - DEFAULT_CHARACTER_VIDEO_MODEL; - const durationSeconds = - videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds; - const normalizedResolution = - videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution; - const referenceVideoModel = - typeof body.referenceVideoModel === 'string' && - body.referenceVideoModel.trim() - ? body.referenceVideoModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL || - DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL; - const motionTransferModel = - typeof body.motionTransferModel === 'string' && - body.motionTransferModel.trim() - ? body.motionTransferModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL || - DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL; - - if (!visualSource) { - sendJson(res, 400, { - error: { message: '请先准备主形象,再生成动作。' }, - }); - return; - } - - let activeTaskId = ''; - let activePrompt = ''; - let activeModel = ''; - try { - if (strategy === 'image-sequence') { - const finalPrompt = buildImageSequencePrompt( - animation, - promptText, - frameCount, - useChromaKey, - ); - activePrompt = finalPrompt; - activeModel = imageSequenceModel; - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image-generation/generation`, - apiKey, - { - model: imageSequenceModel, - input: { - messages: [ - { - role: 'user', - content: [ - { text: finalPrompt }, - { image: visualSource }, - ...referenceImageDataUrls.map((image) => ({ image })), - ], - }, - ], - }, - parameters: { - n: frameCount, - size: '768*1024', - enable_sequential: true, - prompt_extend: true, - watermark: false, - }, - }, - { - 'X-DashScope-Async': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建动作序列帧任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('动作序列帧任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'running', - characterId, - animation, - strategy, - model: imageSequenceModel, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, - }); - const imageUrls = extractImageUrls(taskResult).slice(0, frameCount); - - if (imageUrls.length === 0) { - throw new Error('动作序列帧生成成功,但没有返回图片。'); - } - - const jobId = createTimestampId('animation-seq'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - jobId, - ); - const imageSources = await Promise.all( - imageUrls.map(async (imageUrl, index) => { - const imageResponse = await requestBinaryResponse(imageUrl); - - if ( - imageResponse.statusCode < 200 || - imageResponse.statusCode >= 300 - ) { - throw new Error(`下载动作帧失败(${imageResponse.statusCode})。`); - } - - return writeDraftBinaryFile( - rootDir, - path.posix.join( - draftRelativeDir, - `frame-${String(index + 1).padStart(2, '0')}.png`, - ), - imageResponse.body, - ); - }), - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model: imageSequenceModel, - strategy, - animation, - prompt: finalPrompt, - createdAt: new Date().toISOString(), - imageUrls, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'completed', - characterId, - animation, - strategy, - model: imageSequenceModel, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - imageSources, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - strategy: 'image-sequence', - model: imageSequenceModel, - prompt: finalPrompt, - imageSources, - }); - return; - } - - if (strategy === 'image-to-video') { - const finalPrompt = buildNpcAnimationPrompt({ - animation, - promptText, - useChromaKey, - characterBriefText, - actionTemplateId, - }); - activePrompt = finalPrompt; - activeModel = videoModel; - const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash'; - const visualInputRef = isKf2vFlash - ? await resolveMediaSourceAsDataUrl(rootDir, visualSource) - : await uploadFileToDashScope( - baseUrl, - apiKey, - videoModel, - `${characterId}-${animation}-visual`, - await resolveMediaSourcePayload(rootDir, visualSource), - ); - const lastFrameRef = lastFrameImageDataUrl - ? isKf2vFlash - ? await resolveMediaSourceAsDataUrl(rootDir, lastFrameImageDataUrl) - : await uploadFileToDashScope( - baseUrl, - apiKey, - videoModel, - `${characterId}-${animation}-last-frame`, - await resolveMediaSourcePayload( - rootDir, - lastFrameImageDataUrl, - ), - ) - : ''; - const inputPayload = - isKf2vFlash - ? { - prompt: finalPrompt, - first_frame_url: visualInputRef, - ...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}), - } - : { - prompt: finalPrompt, - media: [ - { type: 'first_frame', url: visualInputRef }, - ...(lastFrameRef - ? [{ type: 'last_frame', url: lastFrameRef }] - : []), - ], - }; - const videoSynthesisEndpoint = isKf2vFlash - ? `${baseUrl}/services/aigc/image2video/video-synthesis` - : `${baseUrl}/services/aigc/video-generation/video-synthesis`; - - const createTaskResponse = await proxyJsonRequest( - videoSynthesisEndpoint, - apiKey, - { - model: videoModel, - input: inputPayload, - parameters: { - duration: durationSeconds, - resolution: normalizedResolution, - ...(isKf2vFlash ? { prompt_extend: true, watermark: false } : {}), - }, - }, - { - 'X-DashScope-Async': 'enable', - 'X-DashScope-OssResourceResolve': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建图生视频任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('图生视频任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'running', - characterId, - animation, - strategy, - model: videoModel, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, - }); - const videoUrl = extractVideoUrl(taskResult); - - if (!videoUrl) { - throw new Error('图生视频成功,但没有返回视频链接。'); - } - - const videoResponse = await requestBinaryResponse(videoUrl); - if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { - throw new Error(`下载图生视频失败(${videoResponse.statusCode})。`); - } - - const jobId = createTimestampId('animation-video'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - jobId, - ); - const previewVideoPath = await writeDraftBinaryFile( - rootDir, - path.posix.join(draftRelativeDir, 'preview.mp4'), - videoResponse.body, - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model: videoModel, - strategy, - animation, - prompt: finalPrompt, - createdAt: new Date().toISOString(), - videoUrl, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'completed', - characterId, - animation, - strategy, - model: videoModel, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - previewVideoPath, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - strategy: 'image-to-video', - model: videoModel, - prompt: finalPrompt, - previewVideoPath, - }); - return; - } - - const modelForVisualUpload = - strategy === 'reference-to-video' - ? referenceVideoModel - : strategy === 'motion-transfer' - ? motionTransferModel - : videoModel; - const visualUrl = await uploadFileToDashScope( - baseUrl, - apiKey, - modelForVisualUpload, - `${characterId}-${animation}-visual`, - await resolveMediaSourcePayload(rootDir, visualSource), - ); - - if (strategy === 'motion-transfer') { - if (referenceVideoDataUrls.length === 0) { - sendJson(res, 400, { - error: { message: '动作模板驱动至少需要一段参考视频。' }, - }); - return; - } - - const finalPrompt = buildNpcAnimationPrompt({ - animation, - promptText, - useChromaKey, - characterBriefText, - }); - activePrompt = finalPrompt; - activeModel = motionTransferModel; - const referenceVideoUrl = await uploadFileToDashScope( - baseUrl, - apiKey, - motionTransferModel, - `${characterId}-${animation}-reference-video`, - await resolveMediaSourcePayload(rootDir, referenceVideoDataUrls[0]), - ); - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image2video/video-synthesis`, - apiKey, - { - model: motionTransferModel, - input: { - prompt: finalPrompt, - image_url: visualUrl, - video_url: referenceVideoUrl, - watermark: false, - }, - parameters: { - mode: 'wan-std', - }, - }, - { - 'X-DashScope-Async': 'enable', - 'X-DashScope-OssResourceResolve': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建动作模板迁移任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('动作模板迁移任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'running', - characterId, - animation, - strategy, - model: motionTransferModel, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, - }); - const videoUrl = extractVideoUrl(taskResult); - - if (!videoUrl) { - throw new Error('动作模板迁移成功,但没有返回视频链接。'); - } - - const videoResponse = await requestBinaryResponse(videoUrl); - if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { - throw new Error( - `下载动作模板视频失败(${videoResponse.statusCode})。`, - ); - } - - const jobId = createTimestampId('animation-motion'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - jobId, - ); - const previewVideoPath = await writeDraftBinaryFile( - rootDir, - path.posix.join(draftRelativeDir, 'preview.mp4'), - videoResponse.body, - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model: motionTransferModel, - strategy, - animation, - prompt: finalPrompt, - createdAt: new Date().toISOString(), - videoUrl, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'completed', - characterId, - animation, - strategy, - model: motionTransferModel, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - previewVideoPath, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - strategy: 'motion-transfer', - model: motionTransferModel, - prompt: finalPrompt, - previewVideoPath, - }); - return; - } - - if (strategy === 'reference-to-video') { - const uploadedReferenceUrls = await Promise.all([ - ...referenceImageDataUrls.map(async (source, index) => - uploadFileToDashScope( - baseUrl, - apiKey, - referenceVideoModel, - `${characterId}-${animation}-reference-image-${index + 1}`, - await resolveMediaSourcePayload(rootDir, source), - ), - ), - ...referenceVideoDataUrls.map(async (source, index) => - uploadFileToDashScope( - baseUrl, - apiKey, - referenceVideoModel, - `${characterId}-${animation}-reference-video-${index + 1}`, - await resolveMediaSourcePayload(rootDir, source), - ), - ), - ]); - - if (uploadedReferenceUrls.length === 0) { - sendJson(res, 400, { - error: { message: '参考生视频至少需要一张参考图或一段参考视频。' }, - }); - return; - } - - const finalPrompt = buildNpcAnimationPrompt({ - animation, - promptText, - useChromaKey, - characterBriefText, - }); - activePrompt = finalPrompt; - activeModel = referenceVideoModel; - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/video-generation/video-synthesis`, - apiKey, - { - model: referenceVideoModel, - input: { - prompt: finalPrompt, - reference_urls: [visualUrl, ...uploadedReferenceUrls], - }, - parameters: { - duration: durationSeconds, - resolution, - prompt_optimizer: true, - }, - }, - { - 'X-DashScope-Async': 'enable', - 'X-DashScope-OssResourceResolve': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建参考生视频任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('参考生视频任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'running', - characterId, - animation, - strategy, - model: referenceVideoModel, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, - }); - const videoUrl = extractVideoUrl(taskResult); - - if (!videoUrl) { - throw new Error('参考生视频成功,但没有返回视频链接。'); - } - - const videoResponse = await requestBinaryResponse(videoUrl); - if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { - throw new Error(`下载参考生视频失败(${videoResponse.statusCode})。`); - } - - const jobId = createTimestampId('animation-reference'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - jobId, - ); - const previewVideoPath = await writeDraftBinaryFile( - rootDir, - path.posix.join(draftRelativeDir, 'preview.mp4'), - videoResponse.body, - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model: referenceVideoModel, - strategy, - animation, - prompt: finalPrompt, - createdAt: new Date().toISOString(), - videoUrl, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'completed', - characterId, - animation, - strategy, - model: referenceVideoModel, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - previewVideoPath, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - strategy: 'reference-to-video', - model: referenceVideoModel, - prompt: finalPrompt, - previewVideoPath, - }); - return; - } - - sendJson(res, 400, { - error: { message: `不支持的动作生成策略:${strategy || 'unknown'}` }, - }); - } catch (error) { - if (activeTaskId) { - await writeJobRecord(rootDir, 'animation', activeTaskId, { - taskId: activeTaskId, - kind: 'animation', - status: 'failed', - characterId, - animation, - strategy, - model: activeModel, - prompt: activePrompt, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - errorMessage: error instanceof Error ? error.message : '生成角色动作失败。', - }); - } - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '生成角色动作失败。', - }, - }); - } -} - -async function handleReadCharacterJobStatus( - rootDir: string, - req: IncomingMessage, - res: ServerResponse, - kind: 'visual' | 'animation', -) { - if (req.method !== 'GET') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - const pathname = getRequestPathname(req); - const prefix = - kind === 'visual' ? CHARACTER_VISUAL_JOBS_PATH : CHARACTER_ANIMATION_JOBS_PATH; - const taskId = decodeURIComponent(pathname.slice(prefix.length)).trim(); - - if (!taskId) { - sendJson(res, 400, { error: { message: 'taskId is required.' } }); - return; - } - - try { - const record = await readJobRecord(rootDir, kind, taskId); - sendJson(res, 200, record); - } catch (error) { - sendJson(res, 404, { - error: { - message: - error instanceof Error - ? error.message - : '未找到对应的任务记录。', - }, - }); - } -} - -async function handleImportCharacterAnimationVideo( - rootDir: string, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' - ? body.characterId.trim() - : 'character'; - const animation = - typeof body.animation === 'string' ? body.animation.trim() : 'clip'; - const videoSource = - typeof body.videoSource === 'string' ? body.videoSource.trim() : ''; - const sourceLabel = - typeof body.sourceLabel === 'string' && body.sourceLabel.trim() - ? body.sourceLabel.trim() - : 'imported-video'; - - if (!videoSource) { - sendJson(res, 400, { error: { message: 'videoSource is required.' } }); - return; - } - - try { - const payload = await resolveMediaSourcePayload(rootDir, videoSource); - const draftId = createTimestampId('animation-import'); - const relativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - draftId, - ); - const fileName = `${sanitizePathSegment(sourceLabel)}.${payload.extension}`; - const importedVideoPath = await writeDraftBinaryFile( - rootDir, - path.posix.join(relativeDir, fileName), - payload.buffer, - ); - - await writeFile( - path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'import.json'), - JSON.stringify( - { - characterId, - animation, - sourceLabel, - importedVideoPath, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - sendJson(res, 200, { - ok: true, - importedVideoPath, - draftId, - saveMessage: '参考视频已导入到本地草稿目录。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error ? error.message : '导入动作视频失败。', - }, - }); - } -} - -function handleListAnimationTemplates( - _rootDir: string, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'GET') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - sendJson(res, 200, { - ok: true, - templates: BUILT_IN_MOTION_TEMPLATES, - }); -} - -export function createCharacterAssetStudioPlugins( - rootDir: string, - mode: string, - env: Record, -): Plugin[] { - const visualHandler = (req: IncomingMessage, res: ServerResponse) => - void handleGenerateCharacterVisuals(rootDir, mode, env, req, res); - const animationHandler = (req: IncomingMessage, res: ServerResponse) => - void handleGenerateCharacterAnimation(rootDir, mode, env, req, res); - const visualJobHandler = (req: IncomingMessage, res: ServerResponse) => - void handleReadCharacterJobStatus(rootDir, req, res, 'visual'); - const animationJobHandler = (req: IncomingMessage, res: ServerResponse) => - void handleReadCharacterJobStatus(rootDir, req, res, 'animation'); - const importVideoHandler = (req: IncomingMessage, res: ServerResponse) => - void handleImportCharacterAnimationVideo(rootDir, req, res); - const templateListHandler = (req: IncomingMessage, res: ServerResponse) => - void handleListAnimationTemplates(rootDir, req, res); - - return [ - { - name: 'character-visual-generate', - configureServer(server) { - server.middlewares.use(CHARACTER_VISUAL_GENERATE_PATH, visualHandler); - }, - configurePreviewServer(server) { - server.middlewares.use(CHARACTER_VISUAL_GENERATE_PATH, visualHandler); - }, - }, - { - name: 'character-visual-job-status', - configureServer(server) { - server.middlewares.use(CHARACTER_VISUAL_JOBS_PATH, visualJobHandler); - }, - configurePreviewServer(server) { - server.middlewares.use(CHARACTER_VISUAL_JOBS_PATH, visualJobHandler); - }, - }, - { - name: 'character-animation-generate', - configureServer(server) { - server.middlewares.use( - CHARACTER_ANIMATION_GENERATE_PATH, - animationHandler, - ); - }, - configurePreviewServer(server) { - server.middlewares.use( - CHARACTER_ANIMATION_GENERATE_PATH, - animationHandler, - ); - }, - }, - { - name: 'character-animation-job-status', - configureServer(server) { - server.middlewares.use(CHARACTER_ANIMATION_JOBS_PATH, animationJobHandler); - }, - configurePreviewServer(server) { - server.middlewares.use(CHARACTER_ANIMATION_JOBS_PATH, animationJobHandler); - }, - }, - { - name: 'character-animation-import-video', - configureServer(server) { - server.middlewares.use( - CHARACTER_ANIMATION_IMPORT_VIDEO_PATH, - importVideoHandler, - ); - }, - configurePreviewServer(server) { - server.middlewares.use( - CHARACTER_ANIMATION_IMPORT_VIDEO_PATH, - importVideoHandler, - ); - }, - }, - { - name: 'character-animation-templates', - configureServer(server) { - server.middlewares.use( - CHARACTER_ANIMATION_TEMPLATES_PATH, - templateListHandler, - ); - }, - configurePreviewServer(server) { - server.middlewares.use( - CHARACTER_ANIMATION_TEMPLATES_PATH, - templateListHandler, - ); - }, - }, - ]; -} diff --git a/scripts/dev-server/localApiPlugins.ts b/scripts/dev-server/localApiPlugins.ts deleted file mode 100644 index cc5e3e33..00000000 --- a/scripts/dev-server/localApiPlugins.ts +++ /dev/null @@ -1,1663 +0,0 @@ -import { webcrypto } from 'node:crypto'; -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; -import http, { - type IncomingMessage, - type RequestOptions, - type ServerResponse, -} from 'node:http'; -import https from 'node:https'; -import path from 'node:path'; - -import { loadEnv, type Plugin } from 'vite'; - -import { createCharacterAssetStudioPlugins } from './characterAssetStudioPlugins'; -import { createQwenSpriteSheetToolPlugins } from './qwenSpriteSheetToolPlugins'; - -const LLM_PROXY_PATH = '/api/llm/chat/completions'; -const ITEM_CATALOG_PATH = '/api/item-catalog'; -const ITEM_OVERRIDES_PATH = '/api/item-overrides'; -const NPC_VISUAL_OVERRIDES_PATH = '/api/npc-visual-overrides'; -const NPC_LAYOUT_CONFIG_PATH = '/api/npc-layout-config'; -const CHARACTER_OVERRIDES_PATH = '/api/character-overrides'; -const MONSTER_OVERRIDES_PATH = '/api/monster-overrides'; -const SCENE_OVERRIDES_PATH = '/api/scene-overrides'; -const SCENE_NPC_OVERRIDES_PATH = '/api/scene-npc-overrides'; -const STATE_FUNCTION_OVERRIDES_PATH = '/api/state-function-overrides'; -const CHARACTER_VISUAL_PUBLISH_PATH = '/api/character-visual/publish'; -const CHARACTER_ANIMATION_PUBLISH_PATH = '/api/animation/publish'; -const CUSTOM_WORLD_SCENE_IMAGE_PATH = '/api/custom-world/scene-image'; -const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; -const DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL = 'wan2.7-image'; -const DASHSCOPE_TASK_POLL_INTERVAL_MS = 2000; -const DASHSCOPE_TASK_TIMEOUT_MS = 150000; - -if ( - !globalThis.crypto || - typeof globalThis.crypto.getRandomValues !== 'function' -) { - Object.defineProperty(globalThis, 'crypto', { - value: webcrypto, - configurable: true, - }); -} - -function readJsonBody(req: IncomingMessage) { - return new Promise>((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => { - try { - const raw = Buffer.concat(chunks).toString('utf8') || '{}'; - resolve(JSON.parse(raw)); - } catch (error) { - reject(error); - } - }); - req.on('error', reject); - }); -} - -function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function isRecordValue(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function hashText(value: string) { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) >>> 0; - } - return hash >>> 0; -} - -function buildAssetPathSegment(value: string, fallback: string) { - const sanitized = sanitizePathSegment(value); - const suffix = hashText(value || fallback) - .toString(36) - .slice(0, 6); - return `${sanitized || fallback}-${suffix}`; -} - -function normalizeDashScopeBaseUrl(value: string) { - return value.replace(/\/$/u, ''); -} - -function resolveRuntimeEnv( - rootDir: string, - mode: string, - env: Record, -) { - return { - ...env, - ...loadEnv(mode, rootDir, ''), - }; -} - -function extractApiErrorMessage(responseText: string, fallbackMessage: string) { - if (!responseText.trim()) { - return fallbackMessage; - } - - try { - const parsed = JSON.parse(responseText) as { - code?: string; - message?: string; - error?: { message?: string }; - }; - if ( - typeof parsed.error?.message === 'string' && - parsed.error.message.trim() - ) { - return parsed.error.message; - } - if (typeof parsed.message === 'string' && parsed.message.trim()) { - return parsed.message; - } - if (typeof parsed.code === 'string' && parsed.code.trim()) { - return `${fallbackMessage} (${parsed.code})`; - } - } catch { - // Fall through to the raw response text below. - } - - return responseText; -} - -function requestTextResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - bodyText?: string; - } = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - bodyText: string; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const payload = options.bodyText; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: { - ...(options.headers ?? {}), - ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), - }, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - bodyText: Buffer.concat(chunks).toString('utf8'), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - if (payload) { - request.write(payload); - } - request.end(); - }); -} - -function requestBinaryResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - } = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - body: Buffer; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: options.headers ?? {}, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - body: Buffer.concat(chunks), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - request.end(); - }); -} - -function normalizeUpstreamBaseUrl(value: string) { - return value.replace(/\/chat\/completions\/?$/u, '').replace(/\/$/u, ''); -} - -function proxyJsonRequest( - urlString: string, - apiKey: string, - body: Record, - extraHeaders: Record = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - bodyText: string; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const payload = JSON.stringify(body); - const options: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload), - ...extraHeaders, - }, - }; - - const upstreamReq = transport.request(options, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - bodyText: Buffer.concat(chunks).toString('utf8'), - }); - }); - }); - - upstreamReq.on('error', reject); - upstreamReq.write(payload); - upstreamReq.end(); - }); -} - -function proxyStreamingRequest( - urlString: string, - apiKey: string, - body: Record, - res: ServerResponse, -) { - return new Promise((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const payload = JSON.stringify(body); - const options: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload), - }, - }; - - const upstreamReq = transport.request(options, (upstreamRes) => { - res.statusCode = upstreamRes.statusCode ?? 502; - res.setHeader( - 'Content-Type', - String( - upstreamRes.headers['content-type'] || - 'text/event-stream; charset=utf-8', - ), - ); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - - upstreamRes.on('data', (chunk) => { - res.write(chunk); - }); - upstreamRes.on('end', () => { - res.end(); - resolve(); - }); - upstreamRes.on('error', (error) => { - if (!res.writableEnded) { - res.end(); - } - reject(error); - }); - }); - - upstreamReq.on('error', reject); - res.on('close', () => { - upstreamReq.destroy(); - }); - upstreamReq.write(payload); - upstreamReq.end(); - }); -} - -function createLlmProxyPlugin( - rootDir: string, - mode: string, - env: Record, -): Plugin { - const handler = async (req: IncomingMessage, res: ServerResponse) => { - const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); - const upstreamBaseUrl = normalizeUpstreamBaseUrl( - runtimeEnv.VITE_LLM_BASE_URL || - runtimeEnv.LLM_BASE_URL || - 'https://ark.cn-beijing.volces.com/api/v3', - ); - const apiKey = - runtimeEnv.LLM_API_KEY || - runtimeEnv.ARK_API_KEY || - runtimeEnv.VITE_LLM_API_KEY || - ''; - - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - if (!apiKey) { - sendJson(res, 500, { - error: { message: 'Missing LLM API key on server' }, - }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - try { - if (body.stream === true) { - await proxyStreamingRequest( - `${upstreamBaseUrl}/chat/completions`, - apiKey, - body, - res, - ); - return; - } - - const upstreamResponse = await proxyJsonRequest( - `${upstreamBaseUrl}/chat/completions`, - apiKey, - body, - ); - res.statusCode = upstreamResponse.statusCode; - res.setHeader( - 'Content-Type', - String( - upstreamResponse.headers['content-type'] || - 'application/json; charset=utf-8', - ), - ); - res.end(upstreamResponse.bodyText); - } catch (error) { - sendJson(res, 502, { - error: { - message: - error instanceof Error ? error.message : 'LLM proxy request failed', - }, - }); - } - }; - - return { - name: 'local-llm-proxy', - configureServer(server) { - server.middlewares.use(LLM_PROXY_PATH, handler); - }, - configurePreviewServer(server) { - server.middlewares.use(LLM_PROXY_PATH, handler); - }, - }; -} - -async function handleJsonFileRead( - filePath: string, - res: ServerResponse, - readErrorMessage: string, -) { - try { - const content = await readFile(filePath, 'utf8'); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(content); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : readErrorMessage, - }, - }); - } -} - -async function readJsonObjectFile(filePath: string) { - try { - const content = await readFile(filePath, 'utf8'); - const parsed = JSON.parse(content); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {}; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return {}; - } - throw error; - } -} - -async function writeJsonObjectFile( - filePath: string, - payload: Record, -) { - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); -} - -function sanitizePathSegment(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-_]+/gu, '-') - .replace(/-+/gu, '-') - .replace(/^-|-$/gu, ''); - - return normalized || 'asset'; -} - -function createTimestampId(prefix: string) { - return `${prefix}-${Date.now()}`; -} - -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.every((item) => typeof item === 'string' && item.trim().length > 0) - ); -} - -async function resolveAssetSourcePayload( - rootDir: string, - source: string, - fallbackMessage: string, -) { - const dataUrlMatch = - /^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/u.exec(source); - if (dataUrlMatch) { - const mimeType = dataUrlMatch[1]; - const base64Payload = dataUrlMatch[2]; - return { - buffer: Buffer.from(base64Payload, 'base64'), - extension: - mimeType === 'image/jpeg' - ? 'jpg' - : mimeType === 'image/webp' - ? 'webp' - : 'png', - }; - } - - if (!source.startsWith('/')) { - throw new Error(fallbackMessage); - } - - const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); - const absolutePath = path.resolve( - rootDir, - 'public', - ...normalizedSource.split('/'), - ); - const publicRoot = path.resolve(rootDir, 'public'); - - if (!absolutePath.startsWith(publicRoot)) { - throw new Error('Asset source points outside the public directory.'); - } - - const buffer = await readFile(absolutePath); - const extension = path - .extname(absolutePath) - .replace(/^\./u, '') - .toLowerCase(); - if (!extension) { - throw new Error(fallbackMessage); - } - - return { - buffer, - extension, - }; -} - -async function resolveAssetSourceAsDataUrl( - rootDir: string, - source: string, - fallbackMessage: string, -) { - if (/^data:image\/[^;]+;base64,/u.test(source)) { - return source; - } - - const payload = await resolveAssetSourcePayload( - rootDir, - source, - fallbackMessage, - ); - const mimeType = (() => { - switch (payload.extension) { - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'webp': - return 'image/webp'; - default: - return 'image/png'; - } - })(); - - return `data:${mimeType};base64,${payload.buffer.toString('base64')}`; -} - -function resolveDashScopeSceneImageModel(model: string) { - if (/^wan2\.7-image(?:-pro)?$/u.test(model)) { - return model; - } - - return DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL; -} - -function resolveImageExtension( - contentTypeHeader: string | string[] | undefined, - sourceUrl: string, -) { - const contentType = Array.isArray(contentTypeHeader) - ? (contentTypeHeader[0] ?? '') - : (contentTypeHeader ?? ''); - if (/image\/jpeg/u.test(contentType)) { - return 'jpg'; - } - if (/image\/png/u.test(contentType)) { - return 'png'; - } - - try { - const extension = path.posix - .extname(new URL(sourceUrl).pathname) - .toLowerCase(); - if (extension === '.jpg' || extension === '.jpeg') { - return 'jpg'; - } - if (extension === '.png') { - return 'png'; - } - } catch { - // Ignore malformed URLs and fall back to png below. - } - - return 'png'; -} - -async function waitForDashScopeTask( - baseUrl: string, - apiKey: string, - taskId: string, - timeoutMs = DASHSCOPE_TASK_TIMEOUT_MS, -) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const response = await requestTextResponse(`${baseUrl}/tasks/${taskId}`, { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error( - extractApiErrorMessage( - response.bodyText, - `查询场景图片生成任务失败(${response.statusCode})。`, - ), - ); - } - - let parsed: Record | null = null; - try { - const candidate = JSON.parse(response.bodyText); - parsed = isRecordValue(candidate) ? candidate : null; - } catch { - parsed = null; - } - - if (!parsed) { - throw new Error('场景图片生成任务返回了无法解析的结果。'); - } - - const output = isRecordValue(parsed.output) ? parsed.output : null; - const taskStatus = - output && typeof output.task_status === 'string' - ? output.task_status - : ''; - - if (taskStatus === 'SUCCEEDED') { - return parsed; - } - - if (taskStatus === 'FAILED') { - throw new Error( - extractApiErrorMessage(response.bodyText, '场景图片生成任务失败。'), - ); - } - - if (taskStatus === 'UNKNOWN') { - throw new Error('场景图片生成任务状态未知,请重新发起生成。'); - } - - await sleep(DASHSCOPE_TASK_POLL_INTERVAL_MS); - } - - throw new Error('场景图片生成超时,请稍后重试。'); -} - -function getDashScopeImageUrl(taskResponse: Record) { - const output = isRecordValue(taskResponse.output) - ? taskResponse.output - : null; - const results = output && Array.isArray(output.results) ? output.results : []; - - for (const entry of results) { - if (!isRecordValue(entry)) { - continue; - } - - if (typeof entry.url === 'string' && entry.url.trim()) { - return { - url: entry.url.trim(), - actualPrompt: - typeof entry.actual_prompt === 'string' && entry.actual_prompt.trim() - ? entry.actual_prompt.trim() - : undefined, - }; - } - } - - const choices = output && Array.isArray(output.choices) ? output.choices : []; - for (const choice of choices) { - if (!isRecordValue(choice)) { - continue; - } - - const message = isRecordValue(choice.message) ? choice.message : null; - const content = - message && Array.isArray(message.content) ? message.content : []; - - for (const entry of content) { - if (!isRecordValue(entry)) { - continue; - } - - const imageUrl = - typeof entry.image === 'string' && entry.image.trim() - ? entry.image.trim() - : typeof entry.url === 'string' && entry.url.trim() - ? entry.url.trim() - : ''; - - if (imageUrl) { - return { - url: imageUrl, - actualPrompt: - typeof entry.actual_prompt === 'string' && - entry.actual_prompt.trim() - ? entry.actual_prompt.trim() - : typeof entry.revised_prompt === 'string' && - entry.revised_prompt.trim() - ? entry.revised_prompt.trim() - : undefined, - }; - } - } - } - - throw new Error('场景图片生成成功,但没有返回可下载的图片地址。'); -} - -type PublishedVisualManifest = { - id: string; - characterId: string; - sourceMode: string; - promptText?: string; - masterImagePath: string; - previewImagePaths: string[]; - width: number; - height: number; - facing: 'right'; - locked: boolean; -}; - -type PublishedAnimationManifest = { - id: string; - animationSetId: string; - characterId: string; - visualAssetId: string; - action: string; - frameCount: number; - fps: number; - loop: boolean; - frameWidth: number; - frameHeight: number; - previewVideoPath?: string; - framePaths: string[]; -}; - -function createJsonFileEditorPlugin({ - name, - routePath, - filePath, - invalidPayloadMessage, - readErrorMessage, - saveErrorMessage, -}: { - name: string; - routePath: string; - filePath: string; - invalidPayloadMessage: string; - readErrorMessage: string; - saveErrorMessage: string; -}): Plugin { - const readOnlyHandler = async (req: IncomingMessage, res: ServerResponse) => { - if (req.method !== 'GET') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - await handleJsonFileRead(filePath, res, readErrorMessage); - }; - - const readWriteHandler = async ( - req: IncomingMessage, - res: ServerResponse, - ) => { - if (req.method === 'GET') { - await handleJsonFileRead(filePath, res, readErrorMessage); - return; - } - - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - if (!body || typeof body !== 'object' || Array.isArray(body)) { - sendJson(res, 400, { error: { message: invalidPayloadMessage } }); - return; - } - - try { - await writeFile(filePath, JSON.stringify(body, null, 2) + '\n', 'utf8'); - sendJson(res, 200, { ok: true }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : saveErrorMessage, - }, - }); - } - }; - - return { - name, - configureServer(server) { - server.middlewares.use(routePath, readWriteHandler); - }, - configurePreviewServer(server) { - server.middlewares.use(routePath, readOnlyHandler); - }, - }; -} - -function createNpcVisualOverridePlugin(rootDir: string): Plugin { - return createJsonFileEditorPlugin({ - name: 'npc-visual-overrides', - routePath: NPC_VISUAL_OVERRIDES_PATH, - filePath: path.resolve(rootDir, 'src/data/npcVisualOverrides.json'), - invalidPayloadMessage: 'Override payload must be an object map', - readErrorMessage: 'Failed to read override file', - saveErrorMessage: 'Failed to save override file', - }); -} - -function createNpcLayoutConfigPlugin(rootDir: string): Plugin { - return createJsonFileEditorPlugin({ - name: 'npc-layout-config', - routePath: NPC_LAYOUT_CONFIG_PATH, - filePath: path.resolve(rootDir, 'src/data/npcLayoutConfig.json'), - invalidPayloadMessage: 'Layout payload must be an object', - readErrorMessage: 'Failed to read layout file', - saveErrorMessage: 'Failed to save layout file', - }); -} - -function createCharacterOverridesPlugin(rootDir: string): Plugin { - return createJsonFileEditorPlugin({ - name: 'character-overrides', - routePath: CHARACTER_OVERRIDES_PATH, - filePath: path.resolve(rootDir, 'src/data/characterOverrides.json'), - invalidPayloadMessage: 'Character override payload must be an object map', - readErrorMessage: 'Failed to read character override file', - saveErrorMessage: 'Failed to save character override file', - }); -} - -function createMonsterOverridesPlugin(rootDir: string): Plugin { - return createJsonFileEditorPlugin({ - name: 'monster-overrides', - routePath: MONSTER_OVERRIDES_PATH, - filePath: path.resolve(rootDir, 'src/data/monsterOverrides.json'), - invalidPayloadMessage: 'Monster override payload must be an object map', - readErrorMessage: 'Failed to read monster override file', - saveErrorMessage: 'Failed to save monster override file', - }); -} - -function createSceneOverridesPlugin(rootDir: string): Plugin { - return createJsonFileEditorPlugin({ - name: 'scene-overrides', - routePath: SCENE_OVERRIDES_PATH, - filePath: path.resolve(rootDir, 'src/data/sceneOverrides.json'), - invalidPayloadMessage: 'Scene override payload must be an object map', - readErrorMessage: 'Failed to read scene override file', - saveErrorMessage: 'Failed to save scene override file', - }); -} - -function createSceneNpcOverridesPlugin(rootDir: string): Plugin { - return createJsonFileEditorPlugin({ - name: 'scene-npc-overrides', - routePath: SCENE_NPC_OVERRIDES_PATH, - filePath: path.resolve(rootDir, 'src/data/sceneNpcOverrides.json'), - invalidPayloadMessage: 'Scene NPC override payload must be an object map', - readErrorMessage: 'Failed to read scene NPC override file', - saveErrorMessage: 'Failed to save scene NPC override file', - }); -} - -function createStateFunctionOverridesPlugin(rootDir: string): Plugin { - return createJsonFileEditorPlugin({ - name: 'state-function-overrides', - routePath: STATE_FUNCTION_OVERRIDES_PATH, - filePath: path.resolve(rootDir, 'src/data/stateFunctionOverrides.json'), - invalidPayloadMessage: - 'State function override payload must be an object map', - readErrorMessage: 'Failed to read state function override file', - saveErrorMessage: 'Failed to save state function override file', - }); -} - -function createCustomWorldSceneImagePlugin( - rootDir: string, - mode: string, - env: Record, -): Plugin { - const handler = async (req: IncomingMessage, res: ServerResponse) => { - const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); - const baseUrl = normalizeDashScopeBaseUrl( - runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - const defaultModel = - runtimeEnv.DASHSCOPE_IMAGE_MODEL || DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL; - const taskTimeoutMs = Number( - runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || - runtimeEnv.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS || - DASHSCOPE_TASK_TIMEOUT_MS, - ); - - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - if (!apiKey) { - sendJson(res, 500, { - error: { message: 'Missing DASHSCOPE_API_KEY on server' }, - }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1280*720'; - const requestedModel = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : defaultModel; - const model = resolveDashScopeSceneImageModel(requestedModel); - const worldName = - typeof body.worldName === 'string' ? body.worldName.trim() : ''; - const profileId = - typeof body.profileId === 'string' ? body.profileId.trim() : ''; - const landmarkName = - typeof body.landmarkName === 'string' ? body.landmarkName.trim() : ''; - const landmarkId = - typeof body.landmarkId === 'string' ? body.landmarkId.trim() : ''; - const referenceImageSrc = - typeof body.referenceImageSrc === 'string' - ? body.referenceImageSrc.trim() - : ''; - - if (!prompt) { - sendJson(res, 400, { error: { message: 'prompt is required.' } }); - return; - } - - if (!landmarkName && !landmarkId) { - sendJson(res, 400, { - error: { message: 'landmarkName or landmarkId is required.' }, - }); - return; - } - - try { - const messageContent: Array<{ image: string } | { text: string }> = []; - if (referenceImageSrc) { - messageContent.push({ - image: await resolveAssetSourceAsDataUrl( - rootDir, - referenceImageSrc, - '参考图必须来自 public 目录或使用 Data URL。', - ), - }); - } - messageContent.push({ text: prompt }); - - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image-generation/generation`, - apiKey, - { - model, - input: { - messages: [ - { - role: 'user', - content: messageContent, - }, - ], - }, - parameters: { - n: 1, - size, - prompt_extend: true, - watermark: false, - ...(negativePrompt ? { negative_prompt: negativePrompt } : {}), - }, - }, - { - 'X-DashScope-Async': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建场景图片生成任务失败。', - ), - }, - }); - return; - } - - let taskPayload: Record | null = null; - try { - const candidate = JSON.parse(createTaskResponse.bodyText); - taskPayload = isRecordValue(candidate) ? candidate : null; - } catch { - taskPayload = null; - } - - const output = - taskPayload && isRecordValue(taskPayload.output) - ? taskPayload.output - : null; - const taskId = - output && typeof output.task_id === 'string' - ? output.task_id.trim() - : ''; - - if (!taskId) { - throw new Error('场景图片生成任务未返回 task_id。'); - } - - const taskResponse = await waitForDashScopeTask( - baseUrl, - apiKey, - taskId, - Number.isFinite(taskTimeoutMs) && taskTimeoutMs > 0 - ? taskTimeoutMs - : DASHSCOPE_TASK_TIMEOUT_MS, - ); - const imageResult = getDashScopeImageUrl(taskResponse); - const imageResponse = await requestBinaryResponse(imageResult.url); - - if (imageResponse.statusCode < 200 || imageResponse.statusCode >= 300) { - throw new Error(`下载生成图片失败(${imageResponse.statusCode})。`); - } - - const assetId = createTimestampId('custom-scene'); - const worldSegment = buildAssetPathSegment( - profileId || worldName || 'custom-world', - 'world', - ); - const landmarkSegment = buildAssetPathSegment( - landmarkId || landmarkName || 'landmark', - 'landmark', - ); - const relativeDir = path.posix.join( - 'generated-custom-world-scenes', - worldSegment, - landmarkSegment, - assetId, - ); - const outputDir = path.resolve( - rootDir, - 'public', - ...relativeDir.split('/'), - ); - const extension = resolveImageExtension( - imageResponse.headers['content-type'], - imageResult.url, - ); - const fileName = `scene.${extension}`; - const imageSrc = `/${path.posix.join(relativeDir, fileName)}`; - - await mkdir(outputDir, { recursive: true }); - await writeFile(path.join(outputDir, fileName), imageResponse.body); - await writeFile( - path.join(outputDir, 'manifest.json'), - JSON.stringify( - { - assetId, - taskId, - model, - size, - prompt, - negativePrompt, - referenceImageSrc: referenceImageSrc || undefined, - actualPrompt: imageResult.actualPrompt, - remoteUrl: imageResult.url, - imageSrc, - worldName, - landmarkName, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - sendJson(res, 200, { - ok: true, - imageSrc, - assetId, - taskId, - model, - size, - prompt, - actualPrompt: imageResult.actualPrompt, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error ? error.message : '场景图片生成失败。', - }, - }); - } - }; - - return { - name: 'custom-world-scene-image', - configureServer(server) { - server.middlewares.use(CUSTOM_WORLD_SCENE_IMAGE_PATH, handler); - }, - configurePreviewServer(server) { - server.middlewares.use(CUSTOM_WORLD_SCENE_IMAGE_PATH, handler); - }, - }; -} - -async function collectPngAssetPaths( - rootDir: string, - relativeDir = 'Icons', -): Promise { - const entries = await readdir(rootDir, { withFileTypes: true }); - const collected: string[] = []; - - for (const entry of entries) { - const absolutePath = path.join(rootDir, entry.name); - const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/'); - - if (entry.isDirectory()) { - collected.push( - ...(await collectPngAssetPaths(absolutePath, relativePath)), - ); - continue; - } - - if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) { - collected.push(relativePath); - } - } - - return collected.sort((left, right) => left.localeCompare(right)); -} - -function createItemCatalogPlugin(rootDir: string): Plugin { - let cachedAssetPaths: string[] | null = null; - - const handler = async (req: IncomingMessage, res: ServerResponse) => { - if (req.method !== 'GET') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - try { - if (!cachedAssetPaths) { - cachedAssetPaths = await collectPngAssetPaths( - path.resolve(rootDir, 'public/Icons'), - ); - } - - sendJson(res, 200, { - assetPaths: cachedAssetPaths, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error - ? error.message - : 'Failed to read item catalog assets', - }, - }); - } - }; - - return { - name: 'item-catalog', - configureServer(server) { - server.middlewares.use(ITEM_CATALOG_PATH, handler); - }, - configurePreviewServer(server) { - server.middlewares.use(ITEM_CATALOG_PATH, handler); - }, - }; -} - -function createItemOverridesPlugin(rootDir: string): Plugin { - return createJsonFileEditorPlugin({ - name: 'item-overrides', - routePath: ITEM_OVERRIDES_PATH, - filePath: path.resolve(rootDir, 'src/data/itemOverrides.json'), - invalidPayloadMessage: 'Item override payload must be an object map', - readErrorMessage: 'Failed to read item override file', - saveErrorMessage: 'Failed to save item override file', - }); -} - -function createCharacterVisualPublishPlugin(rootDir: string): Plugin { - const characterOverridesFilePath = path.resolve( - rootDir, - 'src/data/characterOverrides.json', - ); - - const handler = async (req: IncomingMessage, res: ServerResponse) => { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' ? body.characterId.trim() : ''; - const sourceMode = - typeof body.sourceMode === 'string' ? body.sourceMode.trim() : 'upload'; - const promptText = - typeof body.promptText === 'string' && body.promptText.trim() - ? body.promptText.trim() - : undefined; - const selectedPreviewSource = - typeof body.selectedPreviewSource === 'string' - ? body.selectedPreviewSource - : ''; - const previewSources = isStringArray(body.previewSources) - ? body.previewSources - : []; - const width = - typeof body.width === 'number' && Number.isFinite(body.width) - ? body.width - : 1024; - const height = - typeof body.height === 'number' && Number.isFinite(body.height) - ? body.height - : 1536; - const updateCharacterOverride = body.updateCharacterOverride !== false; - - if (!characterId) { - sendJson(res, 400, { error: { message: 'characterId is required.' } }); - return; - } - - if (!selectedPreviewSource) { - sendJson(res, 400, { - error: { message: 'selectedPreviewSource is required.' }, - }); - return; - } - - try { - const assetId = createTimestampId('visual'); - const visualDir = path.resolve( - rootDir, - 'public/generated-characters', - sanitizePathSegment(characterId), - 'visual', - assetId, - ); - await mkdir(visualDir, { recursive: true }); - - const masterPayload = await resolveAssetSourcePayload( - rootDir, - selectedPreviewSource, - 'Unsupported image payload. Expected PNG/JPEG/WEBP data URL or public asset path.', - ); - const masterFileName = `master.${masterPayload.extension}`; - const masterFilePath = path.join(visualDir, masterFileName); - await writeFile(masterFilePath, masterPayload.buffer); - - const previewImagePaths: string[] = []; - for (let index = 0; index < previewSources.length; index += 1) { - const previewPayload = await resolveAssetSourcePayload( - rootDir, - previewSources[index] ?? '', - 'Unsupported image payload. Expected PNG/JPEG/WEBP data URL or public asset path.', - ); - const previewFileName = `preview-${index + 1}.${previewPayload.extension}`; - await writeFile( - path.join(visualDir, previewFileName), - previewPayload.buffer, - ); - previewImagePaths.push( - `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${previewFileName}`, - ); - } - - const masterImagePath = `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${masterFileName}`; - const manifest: PublishedVisualManifest = { - id: assetId, - characterId, - sourceMode, - promptText, - masterImagePath, - previewImagePaths, - width, - height, - facing: 'right', - locked: true, - }; - - await writeFile( - path.join(visualDir, 'visual-manifest.json'), - JSON.stringify(manifest, null, 2) + '\n', - 'utf8', - ); - - let overrideMap: Record = {}; - if (updateCharacterOverride) { - overrideMap = await readJsonObjectFile(characterOverridesFilePath); - const existingOverride = overrideMap[characterId]; - const nextOverride = - existingOverride && - typeof existingOverride === 'object' && - !Array.isArray(existingOverride) - ? { ...(existingOverride as Record) } - : {}; - nextOverride.generatedVisualAssetId = assetId; - nextOverride.portrait = masterImagePath; - overrideMap[characterId] = nextOverride; - await writeJsonObjectFile(characterOverridesFilePath, overrideMap); - } - - sendJson(res, 200, { - ok: true, - assetId, - portraitPath: masterImagePath, - overrideMap, - saveMessage: updateCharacterOverride - ? '主形象已发布到 public/generated-characters,并更新角色覆盖。' - : '主形象已保存到 public/generated-characters,可直接写回当前自定义世界角色。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error - ? error.message - : 'Failed to publish character visual asset', - }, - }); - } - }; - - return { - name: 'character-visual-publish', - configureServer(server) { - server.middlewares.use(CHARACTER_VISUAL_PUBLISH_PATH, handler); - }, - configurePreviewServer(server) { - server.middlewares.use(CHARACTER_VISUAL_PUBLISH_PATH, handler); - }, - }; -} - -function createCharacterAnimationPublishPlugin(rootDir: string): Plugin { - const characterOverridesFilePath = path.resolve( - rootDir, - 'src/data/characterOverrides.json', - ); - - const handler = async (req: IncomingMessage, res: ServerResponse) => { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' ? body.characterId.trim() : ''; - const visualAssetId = - typeof body.visualAssetId === 'string' ? body.visualAssetId.trim() : ''; - const animations = - body.animations && - typeof body.animations === 'object' && - !Array.isArray(body.animations) - ? (body.animations as Record) - : null; - const updateCharacterOverride = body.updateCharacterOverride !== false; - - if (!characterId) { - sendJson(res, 400, { error: { message: 'characterId is required.' } }); - return; - } - - if (!visualAssetId) { - sendJson(res, 400, { error: { message: 'visualAssetId is required.' } }); - return; - } - - if (!animations || Object.keys(animations).length === 0) { - sendJson(res, 400, { error: { message: 'animations is required.' } }); - return; - } - - try { - const animationSetId = createTimestampId('animation-set'); - const baseAnimationDir = path.resolve( - rootDir, - 'public/generated-animations', - sanitizePathSegment(characterId), - animationSetId, - ); - await mkdir(baseAnimationDir, { recursive: true }); - - const actionManifests: PublishedAnimationManifest[] = []; - const nextAnimationMap: Record> = {}; - - for (const [action, rawAnimation] of Object.entries(animations)) { - if ( - !rawAnimation || - typeof rawAnimation !== 'object' || - Array.isArray(rawAnimation) - ) { - continue; - } - - const typedAnimation = rawAnimation as { - framesDataUrls?: unknown; - fps?: unknown; - loop?: unknown; - frameWidth?: unknown; - frameHeight?: unknown; - previewVideoPath?: unknown; - }; - const framesDataUrls = isStringArray(typedAnimation.framesDataUrls) - ? typedAnimation.framesDataUrls - : []; - if (framesDataUrls.length === 0) { - continue; - } - - const fps = - typeof typedAnimation.fps === 'number' && - Number.isFinite(typedAnimation.fps) - ? typedAnimation.fps - : 8; - const loop = typedAnimation.loop === true; - const frameWidth = - typeof typedAnimation.frameWidth === 'number' && - Number.isFinite(typedAnimation.frameWidth) - ? typedAnimation.frameWidth - : 192; - const frameHeight = - typeof typedAnimation.frameHeight === 'number' && - Number.isFinite(typedAnimation.frameHeight) - ? typedAnimation.frameHeight - : 256; - const actionKey = sanitizePathSegment(action); - const actionDir = path.join(baseAnimationDir, actionKey); - await mkdir(actionDir, { recursive: true }); - - const framePaths: string[] = []; - for (let index = 0; index < framesDataUrls.length; index += 1) { - const framePayload = await resolveAssetSourcePayload( - rootDir, - framesDataUrls[index] ?? '', - 'Unsupported frame payload. Expected PNG/JPEG/WEBP data URL or public asset path.', - ); - const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`; - await writeFile( - path.join(actionDir, frameFileName), - framePayload.buffer, - ); - framePaths.push( - `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}/${frameFileName}`, - ); - } - - const basePath = `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}`; - const previewVideoPath = - typeof typedAnimation.previewVideoPath === 'string' && - typedAnimation.previewVideoPath.trim() - ? typedAnimation.previewVideoPath.trim() - : undefined; - const manifest: PublishedAnimationManifest = { - id: `${animationSetId}-${actionKey}`, - animationSetId, - characterId, - visualAssetId, - action, - frameCount: framePaths.length, - fps, - loop, - frameWidth, - frameHeight, - previewVideoPath, - framePaths, - }; - - await writeFile( - path.join(actionDir, 'manifest.json'), - JSON.stringify(manifest, null, 2) + '\n', - 'utf8', - ); - - actionManifests.push(manifest); - nextAnimationMap[action] = { - folder: action, - prefix: 'frame', - frames: framePaths.length, - startFrame: 1, - extension: 'png', - basePath, - }; - } - - await writeFile( - path.join(baseAnimationDir, 'manifest.json'), - JSON.stringify( - { - animationSetId, - characterId, - visualAssetId, - actions: actionManifests, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - let overrideMap: Record = {}; - if (updateCharacterOverride) { - overrideMap = await readJsonObjectFile(characterOverridesFilePath); - const existingOverride = overrideMap[characterId]; - const nextOverride = - existingOverride && - typeof existingOverride === 'object' && - !Array.isArray(existingOverride) - ? { ...(existingOverride as Record) } - : {}; - const existingAnimationMap = - nextOverride.animationMap && - typeof nextOverride.animationMap === 'object' && - !Array.isArray(nextOverride.animationMap) - ? (nextOverride.animationMap as Record) - : {}; - nextOverride.generatedAnimationSetId = animationSetId; - nextOverride.generatedVisualAssetId = visualAssetId; - nextOverride.animationMap = { - ...existingAnimationMap, - ...nextAnimationMap, - }; - overrideMap[characterId] = nextOverride; - await writeJsonObjectFile(characterOverridesFilePath, overrideMap); - } - - sendJson(res, 200, { - ok: true, - animationSetId, - overrideMap, - animationMap: nextAnimationMap, - saveMessage: updateCharacterOverride - ? '基础动作资源已发布到 public/generated-animations,并更新角色覆盖。' - : '基础动作资源已保存到 public/generated-animations,可直接写回当前自定义世界角色。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error - ? error.message - : 'Failed to publish character animation asset', - }, - }); - } - }; - - return { - name: 'character-animation-publish', - configureServer(server) { - server.middlewares.use(CHARACTER_ANIMATION_PUBLISH_PATH, handler); - }, - configurePreviewServer(server) { - server.middlewares.use(CHARACTER_ANIMATION_PUBLISH_PATH, handler); - }, - }; -} - -export function createLocalApiPlugins( - rootDir: string, - mode: string, - env: Record, -): Plugin[] { - return [ - ...createCharacterAssetStudioPlugins(rootDir, mode, env), - ...createQwenSpriteSheetToolPlugins(rootDir, mode, env), - createLlmProxyPlugin(rootDir, mode, env), - createCustomWorldSceneImagePlugin(rootDir, mode, env), - createItemCatalogPlugin(rootDir), - createItemOverridesPlugin(rootDir), - createNpcVisualOverridePlugin(rootDir), - createNpcLayoutConfigPlugin(rootDir), - createCharacterOverridesPlugin(rootDir), - createMonsterOverridesPlugin(rootDir), - createSceneOverridesPlugin(rootDir), - createSceneNpcOverridesPlugin(rootDir), - createStateFunctionOverridesPlugin(rootDir), - createCharacterVisualPublishPlugin(rootDir), - createCharacterAnimationPublishPlugin(rootDir), - ]; -} diff --git a/scripts/dev-server/qwenSpriteSheetToolPlugins.ts b/scripts/dev-server/qwenSpriteSheetToolPlugins.ts deleted file mode 100644 index 48508235..00000000 --- a/scripts/dev-server/qwenSpriteSheetToolPlugins.ts +++ /dev/null @@ -1,902 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import http, { - type IncomingMessage, - type RequestOptions, - type ServerResponse, -} from 'node:http'; -import https from 'node:https'; -import path from 'node:path'; - -import { loadEnv, type Plugin } from 'vite'; - -const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/qwen-sprite/master'; -const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/qwen-sprite/sheet'; -const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/qwen-sprite/frame-repair'; -const QWEN_SPRITE_SAVE_PATH = '/api/qwen-sprite/save'; -const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; -const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0'; - -function readJsonBody(req: IncomingMessage) { - return new Promise>((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => { - try { - const raw = - Buffer.concat(chunks) - .toString('utf8') - .replace(/^\uFEFF/u, '') || '{}'; - resolve(JSON.parse(raw)); - } catch (error) { - reject(error); - } - }); - req.on('error', reject); - }); -} - -function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function isRecordValue(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.every((item) => typeof item === 'string' && item.trim().length > 0) - ); -} - -function resolveRuntimeEnv( - rootDir: string, - mode: string, - env: Record, -) { - return { - ...env, - ...loadEnv(mode, rootDir, ''), - }; -} - -function normalizeDashScopeBaseUrl(value: string) { - return value.replace(/\/$/u, ''); -} - -function extractApiErrorMessage(responseText: string, fallbackMessage: string) { - if (!responseText.trim()) { - return fallbackMessage; - } - - try { - const parsed = JSON.parse(responseText) as { - code?: string; - message?: string; - error?: { message?: string }; - }; - if ( - typeof parsed.error?.message === 'string' && - parsed.error.message.trim() - ) { - return parsed.error.message; - } - if (typeof parsed.message === 'string' && parsed.message.trim()) { - return parsed.message; - } - if (typeof parsed.code === 'string' && parsed.code.trim()) { - return `${fallbackMessage} (${parsed.code})`; - } - } catch { - // Fall through to raw text. - } - - return responseText; -} - -function sanitizePathSegment(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-_]+/gu, '-') - .replace(/-+/gu, '-') - .replace(/^-|-$/gu, ''); - - return normalized || 'asset'; -} - -function createTimestampId(prefix: string) { - return `${prefix}-${Date.now()}`; -} - -function requestTextResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - bodyText?: string; - } = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - bodyText: string; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const payload = options.bodyText; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: { - ...(options.headers ?? {}), - ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), - }, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - bodyText: Buffer.concat(chunks).toString('utf8'), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - if (payload) { - request.write(payload); - } - request.end(); - }); -} - -function requestBinaryResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - } = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - body: Buffer; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: options.headers ?? {}, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - body: Buffer.concat(chunks), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - request.end(); - }); -} - -function proxyJsonRequest( - urlString: string, - apiKey: string, - body: Record, -) { - return requestTextResponse(urlString, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - bodyText: JSON.stringify(body), - }); -} - -function collectStringsByKey( - value: unknown, - targetKey: string, - results: string[], -) { - if (Array.isArray(value)) { - value.forEach((item) => collectStringsByKey(item, targetKey, results)); - return; - } - - if (!isRecordValue(value)) { - return; - } - - const directValue = value[targetKey]; - if (typeof directValue === 'string' && directValue.trim()) { - results.push(directValue.trim()); - } - - Object.values(value).forEach((nestedValue) => - collectStringsByKey(nestedValue, targetKey, results), - ); -} - -function extractImageUrls(payload: Record) { - const results: string[] = []; - collectStringsByKey(payload.output, 'image', results); - collectStringsByKey(payload.output, 'url', results); - return [...new Set(results)]; -} - -function parseDataUrl(source: string) { - const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); - if (!matched) { - return null; - } - - const mimeType = matched[1]; - const base64Payload = matched[2]; - const extension = (() => { - switch (mimeType) { - case 'image/jpeg': - return 'jpg'; - case 'image/webp': - return 'webp'; - default: - return 'png'; - } - })(); - - return { - buffer: Buffer.from(base64Payload, 'base64'), - extension, - }; -} - -async function resolveImageSourcePayload(rootDir: string, source: string) { - const parsedDataUrl = parseDataUrl(source); - if (parsedDataUrl) { - return parsedDataUrl; - } - - if (!source.startsWith('/')) { - throw new Error('图像来源必须是 Data URL 或 public 目录 URL。'); - } - - const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); - const absolutePath = path.resolve( - rootDir, - 'public', - ...normalizedSource.split('/'), - ); - const publicRoot = path.resolve(rootDir, 'public'); - - if (!absolutePath.startsWith(publicRoot)) { - throw new Error('图像来源路径越界。'); - } - - const buffer = await readFile(absolutePath); - const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png'; - - return { - buffer, - extension, - }; -} - -async function resolveImageSourceAsDataUrl(rootDir: string, source: string) { - if (/^data:image\/[^;]+;base64,/u.test(source)) { - return source; - } - - const payload = await resolveImageSourcePayload(rootDir, source); - const mimeType = (() => { - switch (payload.extension) { - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'webp': - return 'image/webp'; - default: - return 'image/png'; - } - })(); - - return `data:${mimeType};base64,${payload.buffer.toString('base64')}`; -} - -async function writeDraftImageFile( - rootDir: string, - relativePath: string, - buffer: Buffer, -) { - const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/')); - await mkdir(path.dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, buffer); - return `/${relativePath}`; -} - -async function generateQwenImages( - rootDir: string, - mode: string, - env: Record, - input: { - kind: 'master' | 'sheet' | 'repair'; - promptText: string; - negativePrompt: string; - model: string; - size: string; - promptExtend: boolean; - seed?: number; - candidateCount: number; - referenceImages: string[]; - }, -) { - const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); - const baseUrl = normalizeDashScopeBaseUrl( - runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - - if (!apiKey) { - throw new Error('服务端缺少 DASHSCOPE_API_KEY,无法调用 Qwen-Image。'); - } - - const content = [ - ...(await Promise.all( - input.referenceImages - .slice(0, 3) - .map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })), - )), - { text: input.promptText }, - ]; - - const requestPayload: Record = { - model: input.model || DEFAULT_QWEN_IMAGE_MODEL, - input: { - messages: [ - { - role: 'user', - content, - }, - ], - }, - parameters: { - n: Math.max(1, Math.min(6, input.candidateCount)), - negative_prompt: input.negativePrompt, - prompt_extend: input.promptExtend, - watermark: false, - size: input.size, - ...(typeof input.seed === 'number' && Number.isFinite(input.seed) - ? { seed: input.seed } - : {}), - }, - }; - - const response = await proxyJsonRequest( - `${baseUrl}/services/aigc/multimodal-generation/generation`, - apiKey, - requestPayload, - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error( - extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'), - ); - } - - const parsed = JSON.parse(response.bodyText) as Record; - const imageUrls = extractImageUrls(parsed); - - if (imageUrls.length === 0) { - throw new Error('Qwen-Image 未返回可下载的图片结果。'); - } - - const draftId = createTimestampId(`qwen-${input.kind}`); - const relativeDir = path.posix.join( - 'generated-qwen-sprites', - '_drafts', - input.kind, - draftId, - ); - - const drafts = await Promise.all( - imageUrls.map(async (imageUrl, index) => { - const binaryResponse = await requestBinaryResponse(imageUrl); - if ( - binaryResponse.statusCode < 200 || - binaryResponse.statusCode >= 300 - ) { - throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`); - } - - const imageSrc = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`), - binaryResponse.body, - ); - - return { - id: `${draftId}-${index + 1}`, - label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`, - imageSrc, - remoteUrl: imageUrl, - }; - }), - ); - - await writeFile( - path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'), - JSON.stringify( - { - draftId, - kind: input.kind, - model: input.model, - size: input.size, - promptText: input.promptText, - negativePrompt: input.negativePrompt, - promptExtend: input.promptExtend, - seed: input.seed, - candidateCount: input.candidateCount, - referenceImageCount: input.referenceImages.length, - drafts, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - return { - draftId, - drafts, - model: input.model, - size: input.size, - promptText: input.promptText, - negativePrompt: input.negativePrompt, - }; -} - -async function handleGenerateMaster( - rootDir: string, - mode: string, - env: Record, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1024*1024'; - const promptExtend = body.promptExtend !== false; - const candidateCount = - typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount) - ? body.candidateCount - : 1; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - try { - const result = await generateQwenImages(rootDir, mode, env, { - kind: 'master', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '生成主图失败。', - }, - }); - } -} - -async function handleGenerateSheet( - rootDir: string, - mode: string, - env: Record, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1024*1024'; - const promptExtend = body.promptExtend !== false; - const candidateCount = - typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount) - ? body.candidateCount - : 1; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - try { - const result = await generateQwenImages(rootDir, mode, env, { - kind: 'sheet', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '生成精灵表失败。', - }, - }); - } -} - -async function handleRepairFrame( - rootDir: string, - mode: string, - env: Record, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '512*512'; - const promptExtend = body.promptExtend !== false; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - if (referenceImages.length === 0) { - sendJson(res, 400, { - error: { message: '至少需要一张参考图来修复帧。' }, - }); - return; - } - - try { - const result = await generateQwenImages(rootDir, mode, env, { - kind: 'repair', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount: 1, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - repairedFrame: result.drafts[0] ?? null, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '修帧失败。', - }, - }); - } -} - -async function handleSaveAsset( - rootDir: string, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const assetKey = - typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : ''; - const actionKey = - typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : ''; - const masterSource = - typeof body.masterSource === 'string' ? body.masterSource.trim() : ''; - const sheetSource = - typeof body.sheetSource === 'string' ? body.sheetSource.trim() : ''; - const framesDataUrls = isStringArray(body.framesDataUrls) - ? body.framesDataUrls - : []; - const metadata = isRecordValue(body.metadata) ? body.metadata : {}; - const prompts = isRecordValue(body.prompts) ? body.prompts : {}; - - if (!assetKey) { - sendJson(res, 400, { error: { message: 'assetKey is required.' } }); - return; - } - - if (!actionKey) { - sendJson(res, 400, { error: { message: 'actionKey is required.' } }); - return; - } - - if (!sheetSource) { - sendJson(res, 400, { error: { message: 'sheetSource is required.' } }); - return; - } - - try { - const assetId = createTimestampId('qwen-sprite'); - const relativeDir = path.posix.join( - 'generated-qwen-sprites', - assetKey, - actionKey, - assetId, - ); - const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/')); - await mkdir(path.join(absoluteDir, 'frames'), { recursive: true }); - - let masterImagePath: string | null = null; - if (masterSource) { - const payload = await resolveImageSourcePayload(rootDir, masterSource); - masterImagePath = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `master.${payload.extension}`), - payload.buffer, - ); - } - - const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource); - const sheetImagePath = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`), - sheetPayload.buffer, - ); - - const framePaths: string[] = []; - for (let index = 0; index < framesDataUrls.length; index += 1) { - const framePayload = await resolveImageSourcePayload( - rootDir, - framesDataUrls[index] ?? '', - ); - const framePath = await writeDraftImageFile( - rootDir, - path.posix.join( - relativeDir, - 'frames', - `frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`, - ), - framePayload.buffer, - ); - framePaths.push(framePath); - } - - await writeFile( - path.join(absoluteDir, 'metadata.json'), - JSON.stringify( - { - assetId, - assetKey, - actionKey, - masterImagePath, - sheetImagePath, - framePaths, - metadata, - prompts, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - sendJson(res, 200, { - ok: true, - assetId, - assetDir: `/${relativeDir}`, - masterImagePath, - sheetImagePath, - framePaths, - saveMessage: '已保存到 public/generated-qwen-sprites。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '保存精灵表资产失败。', - }, - }); - } -} - -export function createQwenSpriteSheetToolPlugins( - rootDir: string, - mode: string, - env: Record, -): Plugin[] { - const masterHandler = (req: IncomingMessage, res: ServerResponse) => - void handleGenerateMaster(rootDir, mode, env, req, res); - const sheetHandler = (req: IncomingMessage, res: ServerResponse) => - void handleGenerateSheet(rootDir, mode, env, req, res); - const repairHandler = (req: IncomingMessage, res: ServerResponse) => - void handleRepairFrame(rootDir, mode, env, req, res); - const saveHandler = (req: IncomingMessage, res: ServerResponse) => - void handleSaveAsset(rootDir, req, res); - - return [ - { - name: 'qwen-sprite-master-generate', - configureServer(server) { - server.middlewares.use(QWEN_SPRITE_MASTER_GENERATE_PATH, masterHandler); - }, - configurePreviewServer(server) { - server.middlewares.use(QWEN_SPRITE_MASTER_GENERATE_PATH, masterHandler); - }, - }, - { - name: 'qwen-sprite-sheet-generate', - configureServer(server) { - server.middlewares.use(QWEN_SPRITE_SHEET_GENERATE_PATH, sheetHandler); - }, - configurePreviewServer(server) { - server.middlewares.use(QWEN_SPRITE_SHEET_GENERATE_PATH, sheetHandler); - }, - }, - { - name: 'qwen-sprite-frame-repair', - configureServer(server) { - server.middlewares.use(QWEN_SPRITE_FRAME_REPAIR_PATH, repairHandler); - }, - configurePreviewServer(server) { - server.middlewares.use(QWEN_SPRITE_FRAME_REPAIR_PATH, repairHandler); - }, - }, - { - name: 'qwen-sprite-save', - configureServer(server) { - server.middlewares.use(QWEN_SPRITE_SAVE_PATH, saveHandler); - }, - configurePreviewServer(server) { - server.middlewares.use(QWEN_SPRITE_SAVE_PATH, saveHandler); - }, - }, - ]; -} diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index 1fa33bdb..10e77951 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -1802,10 +1802,17 @@ test('runtime persistence is isolated by user', async () => { method: 'PUT', body: JSON.stringify({ musicVolume: 0.25, + platformTheme: 'dark', }), }), ); assert.equal(settingsResponse.status, 200); + const settingsPayload = (await settingsResponse.json()) as { + musicVolume: number; + platformTheme: 'light' | 'dark'; + }; + assert.equal(settingsPayload.musicVolume, 0.25); + assert.equal(settingsPayload.platformTheme, 'dark'); const libraryResponse = await httpRequest( `${baseUrl}/api/runtime/custom-world-library/world-a`, @@ -1854,8 +1861,10 @@ test('runtime persistence is isolated by user', async () => { }); const userBSettingsPayload = (await userBSettings.json()) as { musicVolume: number; + platformTheme: 'light' | 'dark'; }; assert.equal(userBSettingsPayload.musicVolume, 0.42); + assert.equal(userBSettingsPayload.platformTheme, 'light'); const userBLibrary = await httpRequest( `${baseUrl}/api/runtime/custom-world-library`, diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts index 5685065f..b04d16be 100644 --- a/server-node/src/db.test.ts +++ b/server-node/src/db.test.ts @@ -112,6 +112,9 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () = '20260414_010_custom_world_gallery_metadata', '20260416_011_profile_dashboard_tables', '20260416_012_user_browse_history', + '20260417_013_custom_world_profile_soft_delete', + '20260419_014_profile_save_archives', + '20260419_015_runtime_settings_platform_theme', ], ); @@ -130,6 +133,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () = 'custom_world_sessions', 'profile_dashboard_state', 'profile_played_worlds', + 'profile_save_archives', 'profile_wallet_ledger', 'save_snapshots', 'runtime_settings', @@ -149,6 +153,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () = 'custom_world_sessions', 'profile_dashboard_state', 'profile_played_worlds', + 'profile_save_archives', 'profile_wallet_ledger', 'runtime_settings', 'save_snapshots', diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts index 5ef2ce8b..1eb7a2f0 100644 --- a/server-node/src/modules/ai/chatOrchestrator.ts +++ b/server-node/src/modules/ai/chatOrchestrator.ts @@ -5,28 +5,34 @@ import type { CharacterChatSuggestionsRequest, CharacterChatSummaryRequest, NpcChatDialogueRequest, + type NpcChatPendingQuestOffer, NpcChatTurnRequest, NpcRecruitDialogueRequest, } from '../../../../packages/shared/src/contracts/story.js'; import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js'; +import { prepareEventStreamResponse } from '../../http.js'; +import type { UpstreamLlmClient } from '../../services/llmClient.js'; +import { generateQuestForNpcEncounter } from '../../services/questService.js'; +import { + applyQuestSignal, + getQuestForIssuer, +} from '../quest/questProgressionService.js'; import { buildCharacterPanelChatPrompt, buildCharacterPanelChatSuggestionPrompt, buildCharacterPanelChatSummaryPrompt, + buildNpcChatTurnReplyPrompt, + buildNpcChatTurnSuggestionPrompt, + buildNpcRecruitDialoguePrompt, + buildStrictNpcChatDialoguePrompt, CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, - buildNpcRecruitDialoguePrompt, - buildStrictNpcChatDialoguePrompt, - buildNpcChatTurnReplyPrompt, - buildNpcChatTurnSuggestionPrompt, NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT, NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, } from './chatPromptBuilders.js'; -import { prepareEventStreamResponse } from '../../http.js'; -import type { UpstreamLlmClient } from '../../services/llmClient.js'; function writeSseEvent( response: Response, @@ -47,6 +53,14 @@ function readNumber(value: unknown, fallback = 0) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function readArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + function countKeywordMatches(text: string, keywords: string[]) { return keywords.reduce( (count, keyword) => (text.includes(keyword) ? count + 1 : count), @@ -135,6 +149,98 @@ function buildFallbackNpcChatSuggestions(playerMessage: string) { ]; } +function buildQuestOfferDialogueText( + npcName: string, + quest: Record, +) { + const summaryText = + readString(quest.summary) || readString(quest.description); + + return `${npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${ + summaryText + ? `如果你愿意,我想把这件事正式交给你:${summaryText}` + : '如果你愿意,我想把眼前这件事正式交给你。' + }`; +} + +async function maybeBuildPendingNpcQuestOffer( + llmClient: UpstreamLlmClient, + payload: NpcChatTurnRequest, + affinityDelta: number, +): Promise { + const questOfferContext = readRecord(payload.questOfferContext); + const state = readRecord(questOfferContext?.state); + const encounter = readRecord(questOfferContext?.encounter); + if (!state || !encounter) { + return null; + } + + const npcId = readString(encounter.id) || readString(encounter.npcName); + const npcName = readString(encounter.npcName); + const characterId = readString(encounter.characterId); + if (!npcId || !npcName || !characterId) { + return null; + } + + const turnCount = Math.max( + 0, + Math.round(readNumber(questOfferContext?.turnCount, 0)), + ); + const npcStates = readRecord(state.npcStates) ?? {}; + const currentNpcState = readRecord(npcStates[npcId]) ?? {}; + const currentQuests = readArray(state.quests) as Parameters< + typeof getQuestForIssuer + >[0]; + const questsAfterTalk = applyQuestSignal(currentQuests, { + kind: 'npc_talk_completed', + npcId, + }).nextQuests; + const nextAffinity = readNumber(currentNpcState.affinity, 0) + affinityDelta; + const warmupTurns = nextAffinity >= 30 ? 1 : 2; + + if (nextAffinity <= 0 || turnCount < warmupTurns) { + return null; + } + + if (getQuestForIssuer(questsAfterTalk, npcId)) { + return null; + } + + const nextState = { + ...state, + quests: questsAfterTalk, + npcStates: { + ...npcStates, + [npcId]: { + ...currentNpcState, + affinity: nextAffinity, + chattedCount: Math.max( + 0, + Math.round(readNumber(currentNpcState.chattedCount, 0)), + ) + 1, + firstMeaningfulContactResolved: true, + }, + }, + }; + + const quest = await generateQuestForNpcEncounter(llmClient, { + state: nextState as never, + encounter: encounter as never, + }); + + if (!quest || typeof quest !== 'object') { + return null; + } + + return { + quest, + introText: buildQuestOfferDialogueText( + npcName, + quest as Record, + ), + }; +} + export async function generateCharacterChatSuggestionsFromOrchestrator( llmClient: UpstreamLlmClient, payload: CharacterChatSuggestionsRequest, @@ -229,6 +335,11 @@ export async function streamNpcChatTurnFromOrchestrator( npcReply: npcReply || streamedReply, chattedCount, }); + const pendingQuestOffer = await maybeBuildPendingNpcQuestOffer( + llmClient, + params.payload, + affinityDelta, + ); writeSseEvent(params.response, 'complete', { npcReply: npcReply || streamedReply, @@ -238,6 +349,7 @@ export async function streamNpcChatTurnFromOrchestrator( suggestions.length === 3 ? suggestions : buildFallbackNpcChatSuggestions(params.payload.playerMessage), + pendingQuestOffer, }); params.response.write('data: [DONE]\n\n'); params.response.end(); diff --git a/server-node/src/modules/ai/customWorldOrchestrator.ts b/server-node/src/modules/ai/customWorldOrchestrator.ts index b1a33fae..8294e443 100644 --- a/server-node/src/modules/ai/customWorldOrchestrator.ts +++ b/server-node/src/modules/ai/customWorldOrchestrator.ts @@ -5,23 +5,23 @@ import type { } from '../../../../packages/shared/src/contracts/runtime.js'; import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js'; import { + buildCompiledCustomWorldProfile, MIN_CUSTOM_WORLD_LANDMARK_COUNT, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, MIN_CUSTOM_WORLD_STORY_NPC_COUNT, validateGeneratedCustomWorldProfile, -} from '../../../../src/services/customWorld.js'; -import { buildExpandedCustomWorldProfile } from '../../../../src/services/customWorldBuilder.js'; +} from '../custom-world/runtimeProfile.js'; import { buildCustomWorldAnchorPackFromIntent, buildCustomWorldCreatorIntentGenerationText, deriveCustomWorldLockStateFromIntent, hasMeaningfulCustomWorldCreatorIntent, normalizeCustomWorldCreatorIntent, -} from '../../../../src/services/customWorldCreatorIntent.js'; +} from '../custom-world/creatorIntentRuntime.js'; import type { CustomWorldCreatorIntent, CustomWorldProfile, -} from '../../../../src/types.js'; +} from '../custom-world/runtimeTypes.js'; import { buildCustomWorldProfilePrompt, buildCustomWorldProfileRepairPrompt, @@ -396,7 +396,7 @@ export async function generateCustomWorldProfileFromOrchestrator( reporter.complete('llm-profile', '模型已返回世界档案,开始系统归一与运行时编译。'); reporter.begin('normalize', '正在把模型 JSON 归一为可运行的自定义世界结构。'); - const expandedProfile = buildExpandedCustomWorldProfile( + const expandedProfile = buildCompiledCustomWorldProfile( { ...(rawProfile as GeneratedProfile), settingText, diff --git a/server-node/src/modules/ai/orchestrator.test.ts b/server-node/src/modules/ai/orchestrator.test.ts index bd31340c..943ee5f8 100644 --- a/server-node/src/modules/ai/orchestrator.test.ts +++ b/server-node/src/modules/ai/orchestrator.test.ts @@ -3,10 +3,12 @@ import test from 'node:test'; import type { CharacterChatSuggestionsRequest, + NpcChatTurnRequest, } from '../../../../packages/shared/src/contracts/story.js'; import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; import { generateCharacterChatSuggestionsFromOrchestrator, + streamNpcChatTurnFromOrchestrator, } from './chatOrchestrator.js'; import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js'; import { @@ -195,6 +197,179 @@ test('chat orchestrator builds character suggestion prompts on the server side', assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u')); }); +test('chat orchestrator returns pending npc quest offers from the server side', async () => { + const encounter = { + kind: 'npc', + id: 'npc_scout_01', + npcName: '巡路人', + npcDescription: '熟悉桥口风向的探子', + context: '巡路人', + characterId: 'scout-quest', + } as const; + const requestPayload = { + worldType: TEST_WORLD, + character: createTestCharacter(), + player: createTestCharacter(), + encounter, + monsters: [], + history: [], + context: createStoryContext(), + conversationHistory: [ + { speaker: 'player', text: '你像是还有别的话想说。' }, + { speaker: 'npc', text: '这地方最近确实不太平。' }, + ], + dialogue: [ + { speaker: 'player', text: '你像是还有别的话想说。' }, + { speaker: 'npc', text: '这地方最近确实不太平。' }, + ], + playerMessage: '如果你愿意,我可以继续追下去。', + npcState: { + affinity: 28, + chattedCount: 1, + }, + questOfferContext: { + turnCount: 2, + encounter, + state: { + worldType: TEST_WORLD, + currentScenePreset: { + id: 'quest-bridge', + name: '断桥口', + description: '桥口被风和旧账压得很紧。', + npcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hostile: true, + monsterPresetId: 'npc_bandit_01', + }, + ], + treasureHints: [], + }, + currentEncounter: encounter, + storyHistory: [], + customWorldProfile: null, + storyEngineMemory: null, + playerCharacter: createTestCharacter(), + playerHp: 32, + playerMaxHp: 40, + playerMana: 12, + playerMaxMana: 16, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + companions: [], + roster: [], + quests: [], + npcStates: { + npc_scout_01: { + affinity: 28, + chattedCount: 1, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + }, + }, + } satisfies NpcChatTurnRequest; + const responseChunks: string[] = []; + let requestCount = 0; + const llmClient = { + streamMessageContent: async ({ + onUpdate, + }: { + onUpdate?: (text: string) => void; + }) => { + const reply = '如果你真愿意追查,我这里确实有件事想托给你。'; + onUpdate?.(reply); + return reply; + }, + requestMessageContent: async () => { + requestCount += 1; + if (requestCount === 1) { + return '这件事最早是从什么时候开始的\n桥口最近到底少了什么人\n你想让我先盯哪条线'; + } + + return JSON.stringify({ + intent: { + title: '断桥巡线', + description: '巡路人希望你去断桥口查清最近被人故意压下的风声。', + summary: '去断桥口查清最近被人故意压下的风声。', + narrativeType: 'investigation', + dramaticNeed: '有人在桥口刻意遮掩痕迹。', + issuerGoal: '确认是谁在桥口截断消息。', + playerHook: '你可以顺着桥口的异常把事情继续追下去。', + worldReason: '桥口异动会影响接下来整段路的安全。', + recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'], + urgency: 'medium', + intimacy: 'cooperative', + rewardTheme: 'currency', + followupHooks: ['巡路人还藏着半句没说完的话。'], + }, + }); + }, + } as const; + const request = { + method: 'POST', + originalUrl: '/api/runtime/chat/npc/turn/stream', + requestId: 'test-request', + requestStartedAt: Date.now(), + header: () => '', + on: () => request, + } as never; + const response = { + locals: {}, + statusCode: 200, + setHeader: () => undefined, + status(code: number) { + this.statusCode = code; + return this; + }, + write(chunk: string) { + responseChunks.push(chunk); + return true; + }, + end(chunk?: string) { + if (chunk) { + responseChunks.push(chunk); + } + return this; + }, + } as never; + + await streamNpcChatTurnFromOrchestrator(llmClient as never, { + request, + response, + payload: requestPayload, + }); + + const eventText = responseChunks.join(''); + const completeBlock = eventText + .split('\n\n') + .find((block) => block.includes('event: complete')); + assert.ok(completeBlock); + const completeLine = completeBlock + ?.split('\n') + .find((line) => line.startsWith('data:')); + assert.ok(completeLine); + const payload = JSON.parse(completeLine!.slice(5).trim()) as { + pendingQuestOffer?: { + quest?: { + issuerNpcId?: string; + }; + introText?: string; + } | null; + }; + + assert.equal(payload.pendingQuestOffer?.quest?.issuerNpcId, 'npc_scout_01'); + assert.match(payload.pendingQuestOffer?.introText ?? '', /正式交给你/u); +}); + test('custom world orchestrator requests LLM content before compiling the profile', async () => { const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = []; const storyNpcNames = Array.from( diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index 91f0387b..bb0d125b 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -17,14 +17,22 @@ import { import { PNG } from 'pngjs'; import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js'; -import { - buildMasterPrompt, - buildVideoActionPrompt, - getActionTemplateById, -} from '../../../../packages/shared/src/assets/qwenSprite.js'; import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js'; import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js'; import type { AppConfig } from '../../config.js'; +import { + buildArkCharacterAnimationPrompt, + buildCharacterPromptBundleUserPrompt, + buildFallbackCharacterPromptBundle, + buildFallbackModerationSafeAnimationPrompt, + buildImageSequencePrompt, + buildNpcAnimationPrompt, + buildNpcVisualNegativePrompt, + buildNpcVisualPrompt, + CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT, + type CharacterPromptBundle, + sanitizeCharacterPromptBundle, +} from '../../prompts/characterAssetPrompts.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH = @@ -56,24 +64,6 @@ const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000; const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000; const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000; const ARK_VIDEO_TASK_POLL_INTERVAL_MS = 5000; -const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。 -你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。 -你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。 -输出格式必须严格为: -{ - "visualPromptText": "角色主图提示词", - "animationPromptText": "角色动作提示词", - "scenePromptText": "角色关联场景提示词" -} - -硬性约束: -- 所有字段都必须是自然中文。 -- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。 -- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。 -- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。 -- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。 -- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。 -- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`; const BUILT_IN_MOTION_TEMPLATES = [ { @@ -142,14 +132,6 @@ function applyChromaKeyToMediaPayload(payload: DecodedMediaPayload) { } satisfies DecodedMediaPayload; } -type CharacterPromptBundle = { - visualPromptText: string; - animationPromptText: string; - scenePromptText: string; - source: 'llm' | 'fallback'; - model: string | null; -}; - type CharacterAssetWorkflowCacheRecord = { characterId: string; visualPromptText: string; @@ -306,128 +288,6 @@ function clampPromptSeedText(value: unknown, maxLength: number) { return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength); } -function buildFallbackCharacterPromptBundle(params: { - characterName: string; - roleKind: string; - roleTitle: string; - roleLabel: string; - description: string; - backstory: string; - personality: string; - motivation: string; - combatStyle: string; - tags: string[]; -}) { - const roleAnchor = - [params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') || - (params.roleKind === 'playable' ? '可扮演角色' : '场景角色'); - const characterAnchor = params.characterName || '该角色'; - const descriptionAnchor = - params.description || params.backstory || params.personality || '气质鲜明'; - const combatAnchor = - params.combatStyle || params.motivation || '动作发力清晰'; - const tagAnchor = - params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : ''; - - return { - visualPromptText: [ - `${characterAnchor},${roleAnchor}。`, - '单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。', - `外观气质围绕:${descriptionAnchor}。`, - combatAnchor ? `战斗识别点:${combatAnchor}。` : '', - tagAnchor, - '背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。', - ] - .filter(Boolean) - .join(' '), - animationPromptText: [ - `${characterAnchor}的核心动作试片。`, - '保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。', - combatAnchor ? `动作气质参考:${combatAnchor}。` : '', - params.personality ? `角色气质补充:${params.personality}。` : '', - '发力起手明确,过程干净,收招利落,避免漂移和变形。', - ] - .filter(Boolean) - .join(' '), - scenePromptText: [ - `${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`, - '16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。', - `场景叙事气质围绕:${descriptionAnchor}。`, - params.backstory ? `背景线索可参考:${params.backstory}。` : '', - params.motivation - ? `环境中可埋入与当前目标相关的暗示:${params.motivation}。` - : '', - '整体风格克制统一,适合剧情探索与战斗底图。', - ] - .filter(Boolean) - .join(' '), - source: 'fallback' as const, - model: null, - }; -} - -function sanitizePromptBundleValue( - value: unknown, - fallback: string, - maxLength: number, -) { - const normalized = clampPromptSeedText(value, maxLength); - return normalized || fallback; -} - -function sanitizeCharacterPromptBundle( - value: unknown, - fallback: CharacterPromptBundle, - model: string, -) { - const record = isRecordValue(value) ? value : {}; - - return { - visualPromptText: sanitizePromptBundleValue( - record.visualPromptText, - fallback.visualPromptText, - 280, - ), - animationPromptText: sanitizePromptBundleValue( - record.animationPromptText, - fallback.animationPromptText, - 280, - ), - scenePromptText: sanitizePromptBundleValue( - record.scenePromptText, - fallback.scenePromptText, - 320, - ), - source: 'llm' as const, - model: model.trim() || null, - }; -} - -function sanitizeAnimationPromptText(value: string, maxLength: number) { - return value - .replace(/\s+/gu, ' ') - .replace(/血浆|喷血|鲜血|断肢|斩首|裸体|裸露|色情|性交/gu, '') - .replace(/死亡|死去|击杀/gu, '倒地结束') - .replace(/受击|受伤/gu, '失衡') - .replace(/砍杀|斩击/gu, '挥击') - .trim() - .slice(0, maxLength); -} - -function buildCompactAnimationCharacterBrief(value: string) { - const normalized = sanitizeAnimationPromptText(value, 160); - if (!normalized) { - return ''; - } - - return normalized - .split(/[/|\n,,。;;]+/u) - .map((item) => item.trim()) - .filter(Boolean) - .slice(0, 4) - .join(','); -} - function isInappropriateContentMessage(value: string) { return /finappropriate-content|inappropriate content|不适当内容|违规内容/iu.test( value, @@ -489,42 +349,6 @@ async function proxyJsonRequestWithPromptFallback(params: { }; } -function buildCharacterPromptBundleUserPrompt(params: { - roleKind: string; - characterBriefText: string; - characterName: string; - roleTitle: string; - roleLabel: string; - description: string; - backstory: string; - personality: string; - motivation: string; - combatStyle: string; - tags: string[]; -}) { - return [ - '请根据下面的角色卡摘要,编译一组默认资产提示词。', - '提示词用于当前项目的角色主图、动作试片和角色关联场景背景。', - '请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。', - '', - `角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`, - params.characterName ? `角色名称:${params.characterName}` : '', - params.roleTitle ? `角色头衔:${params.roleTitle}` : '', - params.roleLabel ? `世界身份:${params.roleLabel}` : '', - params.description ? `角色描述:${params.description}` : '', - params.backstory ? `角色背景:${params.backstory}` : '', - params.personality ? `角色性格:${params.personality}` : '', - params.motivation ? `角色动机:${params.motivation}` : '', - params.combatStyle ? `战斗风格:${params.combatStyle}` : '', - params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '', - '', - '角色卡全文:', - params.characterBriefText, - ] - .filter(Boolean) - .join('\n'); -} - function createTimestampId(prefix: string) { return `${prefix}-${Date.now()}`; } @@ -1211,187 +1035,6 @@ function extractImageUrls(payload: Record) { return [...new Set(urls)]; } -function buildNpcVisualPrompt(promptText: string, characterBriefText = '') { - const mergedBrief = [characterBriefText.trim(), promptText.trim()] - .filter(Boolean) - .join('\n'); - - return buildMasterPrompt( - mergedBrief || '自定义世界角色,服装完整,姿态自然。', - ); -} - -function buildNpcVisualNegativePrompt() { - return [ - '正面视角', - '左朝向', - '完全 90 度纯右视图', - '镜头透视', - '半身像', - '脚被裁切', - '头顶被裁切', - '多角色', - '复杂背景', - '建筑场景', - '漂浮物', - '烟雾环境', - '武器消失', - '武器换手', - '额外手臂', - '额外腿', - '服装变化', - '脸部变化', - '模糊', - '运动模糊', - '文字', - '水印', - 'UI 元素', - '软萌 Q版大头贴', - '儿童绘本风', - '厚涂插画感', - '低对比柔边', - ].join(','); -} - -function buildImageSequencePrompt( - animation: string, - promptText: string, - frameCount: number, - useChromaKey: boolean, -) { - return [ - `同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`, - '固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。', - '帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。', - useChromaKey - ? '纯绿色背景,无地面装饰,方便后期抠像。' - : '背景尽量纯净,避免复杂场景。', - promptText.trim(), - ] - .filter(Boolean) - .join(' '); -} - -function buildNpcAnimationPrompt(options: { - animation: string; - promptText: string; - useChromaKey: boolean; - loop: boolean; - characterBriefText?: string; - actionTemplateId?: string; -}) { - const characterBrief = buildCompactAnimationCharacterBrief( - options.characterBriefText ?? '', - ); - const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); - const loopRule = options.loop - ? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。' - : options.animation === 'die' - ? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。' - : '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。'; - - if (options.actionTemplateId) { - return [ - buildVideoActionPrompt({ - actionTemplate: getActionTemplateById( - options.actionTemplateId as Parameters< - typeof getActionTemplateById - >[0], - ), - actionDetailText, - useChromaKey: options.useChromaKey, - characterBrief: characterBrief || `${options.animation} 动作角色`, - }), - loopRule, - ] - .filter(Boolean) - .join(' '); - } - - return [ - `单人 NPC 全身动作视频,动作主题是 ${options.animation}。`, - '角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', - '动作连贯,避免服装、发型、面部、武器随机漂移。', - options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' - : '背景简洁纯净,无复杂场景。', - characterBrief ? `角色设定:${characterBrief}` : '', - actionDetailText, - loopRule, - ] - .filter(Boolean) - .join(' '); -} - -function buildArkCharacterAnimationPrompt(options: { - animation: string; - promptText: string; - useChromaKey: boolean; - loop: boolean; - characterBriefText?: string; - actionTemplateId?: string; -}) { - const normalizedAnimationName = - options.animation.trim().replace(/\s+/gu, '_') || 'idle'; - const characterBrief = buildCompactAnimationCharacterBrief( - options.characterBriefText ?? '', - ); - const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); - const frameRule = options.loop - ? '首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。' - : '首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。'; - - if (options.actionTemplateId) { - return [ - buildVideoActionPrompt({ - actionTemplate: getActionTemplateById( - options.actionTemplateId as Parameters[0], - ), - actionDetailText, - useChromaKey: options.useChromaKey, - characterBrief: characterBrief || `${normalizedAnimationName} action role`, - }), - `动作英文名:${normalizedAnimationName}。`, - frameRule, - ] - .filter(Boolean) - .join(' '); - } - - return [ - `单人 NPC 全身动作视频,动作英文名是 ${normalizedAnimationName}。`, - '角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', - '动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。', - options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' - : '背景简洁纯净,无复杂场景。', - characterBrief ? `角色设定:${characterBrief}` : '', - actionDetailText ? `动作细节:${actionDetailText}` : '', - frameRule, - ] - .filter(Boolean) - .join(' '); -} - -function buildFallbackModerationSafeAnimationPrompt(options: { - animation: string; - loop: boolean; - useChromaKey: boolean; -}) { - return [ - `单人全身角色动作视频,动作主题是 ${options.animation}。`, - '角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。', - options.loop - ? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。' - : '非循环动作首尾回到角色标准站姿,中段完成动作变化。', - options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素。' - : '背景简洁纯净。', - ] - .filter(Boolean) - .join(' '); -} - function getLowestSupportedVideoResolution(model: string, fallback: string) { switch (model) { case 'wan2.6-i2v-flash': diff --git a/server-node/src/modules/custom-world/creatorIntentRuntime.ts b/server-node/src/modules/custom-world/creatorIntentRuntime.ts new file mode 100644 index 00000000..86806f5a --- /dev/null +++ b/server-node/src/modules/custom-world/creatorIntentRuntime.ts @@ -0,0 +1,528 @@ +import type { + ActorAnchor, + CreatorCharacterSeed, + CreatorFactionSeed, + CreatorLandmarkSeed, + CustomWorldAnchorPack, + CustomWorldCreatorIntent, + CustomWorldLockState, + LandmarkAnchor, +} from './runtimeTypes.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function toStringArray(value: unknown, maxCount = 8) { + if (!Array.isArray(value)) { + return []; + } + + return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( + 0, + maxCount, + ); +} + +function slugify(value: string) { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || 'entry'; +} + +function createSeedId(prefix: string, label: string, index: number) { + return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; +} + +function clampText(value: string, maxLength: number) { + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) { + return ''; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +function normalizeCreatorFactionSeed( + value: unknown, + index: number, +): CreatorFactionSeed | null { + if (!value || typeof value !== 'object') { + return null; + } + + const item = value as Record; + const name = toText(item.name); + const publicGoal = toText(item.publicGoal); + const tension = toText(item.tension); + const notes = toText(item.notes); + + if (!name && !publicGoal && !tension && !notes) { + return null; + } + + return { + id: + toText(item.id) || + createSeedId('creator-faction', name || publicGoal, index), + name, + publicGoal, + tension, + notes, + locked: Boolean(item.locked), + }; +} + +function normalizeCreatorCharacterSeed( + value: unknown, + index: number, +): CreatorCharacterSeed | null { + if (!value || typeof value !== 'object') { + return null; + } + + const item = value as Record; + const name = toText(item.name); + const role = toText(item.role); + const publicMask = toText(item.publicMask); + const hiddenHook = toText(item.hiddenHook); + const relationToPlayer = toText(item.relationToPlayer); + const notes = toText(item.notes); + + if ( + !name && + !role && + !publicMask && + !hiddenHook && + !relationToPlayer && + !notes + ) { + return null; + } + + return { + id: + toText(item.id) || + createSeedId('creator-character', name || role || publicMask, index), + name, + role, + publicMask, + hiddenHook, + relationToPlayer, + notes, + locked: Boolean(item.locked), + }; +} + +function normalizeCreatorLandmarkSeed( + value: unknown, + index: number, +): CreatorLandmarkSeed | null { + if (!value || typeof value !== 'object') { + return null; + } + + const item = value as Record; + const name = toText(item.name); + const purpose = toText(item.purpose); + const mood = toText(item.mood); + const secret = toText(item.secret); + + if (!name && !purpose && !mood && !secret) { + return null; + } + + return { + id: + toText(item.id) || + createSeedId('creator-landmark', name || purpose || mood, index), + name, + purpose, + mood, + secret, + locked: Boolean(item.locked), + }; +} + +function normalizeAnchorArray( + value: unknown, + normalizer: (value: unknown, index: number) => T | null, + maxCount: number, +) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item, index) => normalizer(item, index)) + .filter((item): item is T => Boolean(item)) + .slice(0, maxCount); +} + +export function normalizeCustomWorldCreatorIntent( + value: unknown, + fallbackMode: CustomWorldCreatorIntent['sourceMode'] = 'freeform', +): CustomWorldCreatorIntent | null { + if (!value || typeof value !== 'object') { + return null; + } + + const item = value as Record; + const sourceMode = + item.sourceMode === 'card' || item.sourceMode === 'freeform' + ? item.sourceMode + : fallbackMode; + const rawSettingText = toText(item.rawSettingText); + const worldHook = toText(item.worldHook); + const playerPremise = toText(item.playerPremise); + const openingSituation = toText(item.openingSituation); + const themeKeywords = toStringArray(item.themeKeywords, 8); + const toneDirectives = toStringArray(item.toneDirectives, 8); + const coreConflicts = toStringArray(item.coreConflicts, 6); + const iconicElements = toStringArray(item.iconicElements, 8); + const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8); + const keyFactions = normalizeAnchorArray( + item.keyFactions, + normalizeCreatorFactionSeed, + 6, + ); + const keyCharacters = normalizeAnchorArray( + item.keyCharacters, + normalizeCreatorCharacterSeed, + 8, + ); + const keyLandmarks = normalizeAnchorArray( + item.keyLandmarks, + normalizeCreatorLandmarkSeed, + 8, + ); + + if ( + !rawSettingText && + !worldHook && + themeKeywords.length === 0 && + toneDirectives.length === 0 && + !playerPremise && + !openingSituation && + coreConflicts.length === 0 && + keyFactions.length === 0 && + keyCharacters.length === 0 && + keyLandmarks.length === 0 && + iconicElements.length === 0 && + forbiddenDirectives.length === 0 + ) { + return null; + } + + return { + sourceMode, + rawSettingText, + worldHook, + themeKeywords, + toneDirectives, + playerPremise, + openingSituation, + coreConflicts, + keyFactions, + keyCharacters, + keyLandmarks, + iconicElements, + forbiddenDirectives, + }; +} + +export function normalizeCustomWorldLockState( + value: unknown, +): CustomWorldLockState { + if (!value || typeof value !== 'object') { + return { + worldLockedFields: [], + lockedCharacterIds: [], + lockedLandmarkIds: [], + lockedConflictIds: [], + lockedFactionIds: [], + }; + } + + const item = value as Record; + return { + worldLockedFields: toStringArray(item.worldLockedFields, 12), + lockedCharacterIds: toStringArray(item.lockedCharacterIds, 20), + lockedLandmarkIds: toStringArray(item.lockedLandmarkIds, 20), + lockedConflictIds: toStringArray(item.lockedConflictIds, 20), + lockedFactionIds: toStringArray(item.lockedFactionIds, 20), + }; +} + +export function deriveCustomWorldLockStateFromIntent( + intent: CustomWorldCreatorIntent | null | undefined, +): CustomWorldLockState { + return { + worldLockedFields: [], + lockedCharacterIds: + intent?.keyCharacters + .filter((entry) => entry.locked) + .map((entry) => entry.id) ?? [], + lockedLandmarkIds: + intent?.keyLandmarks + .filter((entry) => entry.locked) + .map((entry) => entry.id) ?? [], + lockedConflictIds: [], + lockedFactionIds: + intent?.keyFactions + .filter((entry) => entry.locked) + .map((entry) => entry.id) ?? [], + }; +} + +export function hasMeaningfulCustomWorldCreatorIntent( + intent: CustomWorldCreatorIntent | null | undefined, +) { + return Boolean( + intent && + (intent.rawSettingText || + intent.worldHook || + intent.themeKeywords.length > 0 || + intent.toneDirectives.length > 0 || + intent.playerPremise || + intent.openingSituation || + intent.coreConflicts.length > 0 || + intent.keyFactions.length > 0 || + intent.keyCharacters.length > 0 || + intent.keyLandmarks.length > 0 || + intent.iconicElements.length > 0 || + intent.forbiddenDirectives.length > 0), + ); +} + +function buildAnchorLine(label: string, content: string) { + return content ? `${label}:${content}` : ''; +} + +function buildCustomWorldCreatorIntentDisplayText( + intent: CustomWorldCreatorIntent | null | undefined, +) { + if (!hasMeaningfulCustomWorldCreatorIntent(intent)) { + return ''; + } + + const lines = [ + intent?.worldHook ? `世界一句话:${intent.worldHook}` : '', + intent?.rawSettingText ? `补充设定:${intent.rawSettingText}` : '', + buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''), + buildAnchorLine('世界气质', intent?.toneDirectives.join('、') || ''), + buildAnchorLine('玩家是谁', intent?.playerPremise || ''), + buildAnchorLine('开局处境', intent?.openingSituation || ''), + buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''), + buildAnchorLine( + '关键势力', + intent?.keyFactions + .map((entry) => + [entry.name, entry.publicGoal, entry.tension] + .filter(Boolean) + .join(' / '), + ) + .filter(Boolean) + .join(';') || '', + ), + buildAnchorLine( + '关键角色', + intent?.keyCharacters + .map((entry) => + [ + entry.name, + entry.role, + entry.publicMask, + entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '', + ] + .filter(Boolean) + .join(' / '), + ) + .filter(Boolean) + .join(';') || '', + ), + buildAnchorLine( + '关键地点', + intent?.keyLandmarks + .map((entry) => + [entry.name, entry.purpose, entry.mood].filter(Boolean).join(' / '), + ) + .filter(Boolean) + .join(';') || '', + ), + buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''), + buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''), + ].filter(Boolean); + + return lines.join('\n'); +} + +export function buildCustomWorldCreatorIntentGenerationText( + intent: CustomWorldCreatorIntent | null | undefined, +) { + if (!hasMeaningfulCustomWorldCreatorIntent(intent)) { + return ''; + } + + const sections = [ + buildAnchorLine('世界核心命题', intent?.worldHook || ''), + buildAnchorLine('补充设定原文', intent?.rawSettingText || ''), + buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''), + buildAnchorLine('情绪与气质', intent?.toneDirectives.join('、') || ''), + buildAnchorLine('玩家身份', intent?.playerPremise || ''), + buildAnchorLine('开局处境', intent?.openingSituation || ''), + buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''), + buildAnchorLine( + '关键势力锚点', + intent?.keyFactions + .map((entry) => + [ + entry.name, + entry.publicGoal ? `目标 ${entry.publicGoal}` : '', + entry.tension ? `张力 ${entry.tension}` : '', + entry.notes ? `补充 ${entry.notes}` : '', + ] + .filter(Boolean) + .join(';'), + ) + .filter(Boolean) + .join('\n') || '', + ), + buildAnchorLine( + '关键角色锚点', + intent?.keyCharacters + .map((entry) => + [ + entry.name, + entry.role ? `身份 ${entry.role}` : '', + entry.publicMask ? `表面 ${entry.publicMask}` : '', + entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '', + entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '', + entry.notes ? `补充 ${entry.notes}` : '', + ] + .filter(Boolean) + .join(';'), + ) + .filter(Boolean) + .join('\n') || '', + ), + buildAnchorLine( + '关键地点锚点', + intent?.keyLandmarks + .map((entry) => + [ + entry.name, + entry.purpose ? `作用 ${entry.purpose}` : '', + entry.mood ? `氛围 ${entry.mood}` : '', + entry.secret ? `秘密 ${entry.secret}` : '', + ] + .filter(Boolean) + .join(';'), + ) + .filter(Boolean) + .join('\n') || '', + ), + buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''), + buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''), + ].filter(Boolean); + + return sections.join('\n\n'); +} + +function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor { + const summary = clampText( + [ + entry.role, + entry.publicMask, + entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '', + entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '', + ] + .filter(Boolean) + .join(';'), + 72, + ); + + return { + id: entry.id, + name: entry.name || '未命名关键角色', + summary, + }; +} + +function buildLandmarkAnchorSummary( + entry: CreatorLandmarkSeed, +): LandmarkAnchor { + const summary = clampText( + [entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : ''] + .filter(Boolean) + .join(';'), + 72, + ); + + return { + id: entry.id, + name: entry.name || '未命名关键地点', + summary, + }; +} + +export function buildCustomWorldAnchorPackFromIntent( + intent: CustomWorldCreatorIntent | null | undefined, +): CustomWorldAnchorPack | null { + if (!hasMeaningfulCustomWorldCreatorIntent(intent)) { + return null; + } + + const lockedAnchorIds = [ + ...(intent?.keyCharacters + .filter((entry) => entry.locked) + .map((entry) => entry.id) ?? []), + ...(intent?.keyLandmarks + .filter((entry) => entry.locked) + .map((entry) => entry.id) ?? []), + ...(intent?.keyFactions + .filter((entry) => entry.locked) + .map((entry) => entry.id) ?? []), + ]; + + return { + worldSummary: clampText( + intent?.worldHook || intent?.rawSettingText || '', + 96, + ), + creatorIntentSummary: clampText( + buildCustomWorldCreatorIntentDisplayText(intent), + 240, + ), + lockedAnchorIds, + keyConflictSummaries: + intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [], + keyFactionSummaries: + intent?.keyFactions.map((entry) => + clampText( + [entry.name, entry.publicGoal, entry.tension] + .filter(Boolean) + .join(';'), + 72, + ), + ) ?? [], + keyCharacterAnchors: + intent?.keyCharacters.map((entry) => + buildCharacterAnchorSummary(entry), + ) ?? [], + keyLandmarkAnchors: + intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? + [], + motifDirectives: [ + ...(intent?.themeKeywords ?? []), + ...(intent?.toneDirectives ?? []), + ...(intent?.iconicElements ?? []), + ].slice(0, 12), + }; +} diff --git a/server-node/src/modules/custom-world/runtimeProfile.test.ts b/server-node/src/modules/custom-world/runtimeProfile.test.ts new file mode 100644 index 00000000..d39bf690 --- /dev/null +++ b/server-node/src/modules/custom-world/runtimeProfile.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildCompiledCustomWorldProfile, + validateGeneratedCustomWorldProfile, +} from './runtimeProfile.js'; + +function createPlayableNpc(index: number) { + return { + name: `角色${index + 1}`, + title: `称号${index + 1}`, + role: `身份${index + 1}`, + description: `角色描述${index + 1}`, + backstory: `角色背景${index + 1}`, + personality: `角色性格${index + 1}`, + motivation: `角色动机${index + 1}`, + combatStyle: `战斗风格${index + 1}`, + initialAffinity: 18, + relationshipHooks: [`接触点${index + 1}`], + tags: [`标签${index + 1}`], + }; +} + +function createStoryNpc(index: number) { + return { + name: `场景角色${index + 1}`, + title: `头衔${index + 1}`, + role: `职责${index + 1}`, + description: `场景角色描述${index + 1}`, + backstory: `场景角色背景${index + 1}`, + personality: `场景角色性格${index + 1}`, + motivation: `场景角色动机${index + 1}`, + combatStyle: `场景角色战斗风格${index + 1}`, + initialAffinity: index % 4 === 0 ? -10 : 6, + relationshipHooks: [`关系${index + 1}`], + tags: [`线索${index + 1}`], + }; +} + +function createLandmark(index: number, storyNpcNames: string[]) { + return { + name: `场景${index + 1}`, + description: `场景描述${index + 1}`, + dangerLevel: 'medium', + sceneNpcNames: storyNpcNames, + connections: [ + { + targetLandmarkName: `场景${((index + 1) % 10) + 1}`, + relativePosition: 'forward', + summary: '沿主路前行', + }, + { + targetLandmarkName: `场景${((index + 9) % 10) + 1}`, + relativePosition: 'back', + summary: '回身可返', + }, + ], + }; +} + +test('buildCompiledCustomWorldProfile preserves runtime-critical generated fields on the server', () => { + const storyNpcs = Array.from({ length: 25 }, (_, index) => + createStoryNpc(index), + ); + const profile = buildCompiledCustomWorldProfile( + { + id: 'generated-world', + name: '测试世界', + subtitle: '副标题', + summary: '概述', + tone: '紧张、潮湿', + playerGoal: '先站稳,再查明真相', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '沉船商盟'], + coreConflicts: ['航道解释权正在争夺'], + creatorIntent: { + sourceMode: 'card', + rawSettingText: '', + worldHook: '一个被潮雾反复切开的边境世界。', + themeKeywords: ['潮雾', '边境'], + toneDirectives: ['紧张', '潮湿'], + playerPremise: '玩家是前巡夜人。', + openingSituation: '刚进城就卷入旧案。', + coreConflicts: ['旧案名单再次出现'], + keyFactions: [], + keyCharacters: [ + { + id: 'creator-character-1', + name: '沈砺', + role: '灰炬向导', + publicMask: '看起来只是个带路人', + hiddenHook: '一直在查旧撤离线', + relationToPlayer: '会先怀疑玩家身份', + notes: '', + locked: true, + }, + ], + keyLandmarks: [], + iconicElements: ['裂潮灯塔'], + forbiddenDirectives: ['不要出现现代枪械'], + }, + playableNpcs: Array.from({ length: 5 }, (_, index) => + createPlayableNpc(index), + ), + storyNpcs, + landmarks: Array.from({ length: 10 }, (_, index) => + createLandmark(index, [ + storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`, + storyNpcs[(index + 1) % storyNpcs.length]?.name ?? + `场景角色${index + 2}`, + storyNpcs[(index + 2) % storyNpcs.length]?.name ?? + `场景角色${index + 3}`, + ]), + ), + }, + '一个被潮雾反复切开的边境世界。', + ); + + assert.equal(profile.playableNpcs.length, 5); + assert.equal(profile.storyNpcs.length, 25); + assert.equal(profile.landmarks.length, 10); + assert.equal(profile.playableNpcs[0]?.templateCharacterId, 'sword-princess'); + assert.ok(profile.playableNpcs[0]?.attributeProfile); + assert.ok(profile.storyNpcs[0]?.attributeProfile); + assert.equal(profile.scenarioPackId, 'scenario-pack:测试世界'); + assert.equal(profile.campaignPackId, 'campaign-pack:测试世界'); + assert.equal(profile.creatorIntent?.keyCharacters[0]?.name, '沈砺'); + assert.ok(profile.anchorPack?.lockedAnchorIds.includes('creator-character-1')); + assert.ok(profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3)); + + validateGeneratedCustomWorldProfile(profile); +}); diff --git a/server-node/src/modules/custom-world/runtimeProfile.ts b/server-node/src/modules/custom-world/runtimeProfile.ts new file mode 100644 index 00000000..8f71ada9 --- /dev/null +++ b/server-node/src/modules/custom-world/runtimeProfile.ts @@ -0,0 +1,1734 @@ +import { + buildCustomWorldAnchorPackFromIntent, + deriveCustomWorldLockStateFromIntent, + normalizeCustomWorldCreatorIntent, + normalizeCustomWorldLockState, +} from './creatorIntentRuntime.js'; +import type { + AttributeVector, + CharacterBackstoryChapter, + CharacterBackstoryRevealConfig, + CustomWorldCampScene, + CustomWorldCoverProfile, + CustomWorldCoverSourceType, + CustomWorldGenerationFramework, + CustomWorldGenerationLandmarkOutline, + CustomWorldGenerationRoleBatchType, + CustomWorldGenerationRoleOutline, + CustomWorldItem, + CustomWorldNpc, + CustomWorldPlayableNpc, + CustomWorldProfile, + CustomWorldRoleInitialItem, + CustomWorldRoleProfile, + CustomWorldRoleSkill, + RoleAttributeProfile, + WorldAttributeSchema, + WorldAttributeSlot, + WorldType, +} from './runtimeTypes.js'; + +export type { + CustomWorldGenerationFramework, + CustomWorldGenerationLandmarkOutline, + CustomWorldGenerationRoleBatchStage, + CustomWorldGenerationRoleBatchType, + CustomWorldGenerationRoleOutline, +} from './runtimeTypes.js'; + +const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const; +const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60; + +const MIN_CUSTOM_WORLD_AFFINITY = -40; +const MAX_CUSTOM_WORLD_AFFINITY = 90; +const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18; +const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6; +const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3; +const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3; +const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ + '表层来意', + '旧事裂痕', + '隐藏执念', + '最终底牌', +] as const; +const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [ + '武器', + '护甲', + '饰品', + '消耗品', + '材料', + '稀有品', + '专属物品', + '专属物', +] as const; +const CUSTOM_WORLD_RARITIES = [ + 'common', + 'uncommon', + 'rare', + 'epic', + 'legendary', +] as const; +const PLAYABLE_TEMPLATE_CHARACTER_IDS = [ + 'sword-princess', + 'archer-hero', + 'girl-hero', + 'punch-hero', + 'fighter-4', +] as const; +const WORLD_ATTRIBUTE_SLOT_IDS = [ + 'axis_a', + 'axis_b', + 'axis_c', + 'axis_d', + 'axis_e', + 'axis_f', +] as const; + +export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5; +export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30; +export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; +export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max( + 0, + MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, +); + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function toFiniteInteger(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) + ? Math.round(value) + : undefined; +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? (value.filter((item) => item && typeof item === 'object') as Array< + Record + >) + : []; +} + +function toStringArray(value: unknown, nestedKey?: string) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => { + if (typeof item === 'string') { + return item.trim(); + } + if (nestedKey && item && typeof item === 'object') { + return toText((item as Record)[nestedKey]); + } + return ''; + }) + .filter(Boolean); +} + +function normalizeTags(value: unknown, fallbackTags: string[] = []) { + const tags = Array.isArray(value) + ? value.map((item) => toText(item)).filter(Boolean) + : []; + return [ + ...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)), + ].slice(0, 5); +} + +function clampText(value: string, maxLength: number) { + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) { + return ''; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +function slugify(value: string) { + const ascii = value + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return ascii ? ascii.slice(0, 24) : 'entry'; +} + +function createEntryId(prefix: string, label: string, index: number) { + return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; +} + +function clampCustomWorldAffinity(value: number) { + return Math.max( + MIN_CUSTOM_WORLD_AFFINITY, + Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)), + ); +} + +function normalizeInitialAffinity(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) + ? clampCustomWorldAffinity(value) + : fallback; +} + +function normalizeRarity( + value: unknown, + fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare', +) { + const rarity = toText(value).toLowerCase(); + return CUSTOM_WORLD_RARITIES.includes( + rarity as (typeof CUSTOM_WORLD_RARITIES)[number], + ) + ? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number]) + : fallback; +} + +function normalizeRoleItemCategory(value: unknown, fallback = '材料') { + const category = toText(value); + if ( + (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category) + ) { + return category === '专属物' ? '专属物品' : category; + } + if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器'; + if (/甲|护|盾|衣|袍/u.test(category)) return '护甲'; + if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品'; + if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品'; + if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料'; + if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品'; + if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品'; + return fallback; +} + +function splitNarrativeSentences(text: string) { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return []; + } + const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu); + return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean); +} + +type CustomWorldRoleFallbackSource = Pick< + CustomWorldRoleProfile, + | 'name' + | 'title' + | 'role' + | 'description' + | 'backstory' + | 'personality' + | 'motivation' + | 'combatStyle' + | 'relationshipHooks' + | 'tags' +>; + +function buildFallbackBackstoryReveal( + source: CustomWorldRoleFallbackSource, +): CharacterBackstoryRevealConfig { + const normalizedBackstory = + source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`; + const backstorySentences = splitNarrativeSentences(normalizedBackstory); + const backstoryLead = backstorySentences[0] ?? normalizedBackstory; + const backstoryDetail = + backstorySentences.slice(0, 2).join('') || normalizedBackstory; + const publicSummary = + source.description.trim() || clampText(normalizedBackstory, 42); + const fallbackContents = [ + source.description.trim() || backstoryLead, + backstoryDetail, + source.motivation.trim() + ? `${source.name}真正挂念的,是:${source.motivation.trim()}` + : `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`, + source.personality.trim() + ? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}` + : `${source.name}仍把最深的筹码藏在过去之中。`, + ]; + + return { + publicSummary, + privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, + chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( + (affinityRequired, index) => + ({ + id: createEntryId( + 'backstory-chapter', + `${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`, + index, + ), + title: + CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? + `背景片段${index + 1}`, + affinityRequired, + teaser: clampText( + fallbackContents[index] ?? normalizedBackstory, + 22, + ), + content: clampText( + fallbackContents[index] ?? normalizedBackstory, + 72, + ), + contextSnippet: clampText( + `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, + 48, + ), + }) satisfies CharacterBackstoryChapter, + ), + }; +} + +function normalizeBackstoryReveal( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const fallback = buildFallbackBackstoryReveal(fallbackSource); + if (!value || typeof value !== 'object') { + return fallback; + } + + const item = value as Record; + const rawChapters = toRecordArray(item.chapters); + + return { + publicSummary: toText(item.publicSummary) || fallback.publicSummary, + privateChatUnlockAffinity: + typeof item.privateChatUnlockAffinity === 'number' && + Number.isFinite(item.privateChatUnlockAffinity) + ? clampCustomWorldAffinity(item.privateChatUnlockAffinity) + : fallback.privateChatUnlockAffinity, + chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( + (defaultAffinity, index) => { + const fallbackChapter = fallback.chapters[index]; + const rawChapter = rawChapters[index]; + return { + id: + (rawChapter && toText(rawChapter.id)) || + fallbackChapter?.id || + `backstory-chapter-${index + 1}`, + title: + (rawChapter && toText(rawChapter.title)) || + fallbackChapter?.title || + `背景片段${index + 1}`, + affinityRequired: + fallbackChapter?.affinityRequired ?? defaultAffinity, + teaser: + (rawChapter && toText(rawChapter.teaser)) || + fallbackChapter?.teaser || + '', + content: + (rawChapter && toText(rawChapter.content)) || + fallbackChapter?.content || + '', + contextSnippet: + (rawChapter && toText(rawChapter.contextSnippet)) || + fallbackChapter?.contextSnippet || + '', + } satisfies CharacterBackstoryChapter; + }, + ), + } satisfies CharacterBackstoryRevealConfig; +} + +function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { + const skillNameSeed = source.title || source.role || source.name || '角色'; + const skillSummarySeed = + source.combatStyle || source.description || `${source.name}善于把握局势。`; + const motivationSeed = + source.motivation || source.personality || source.backstory; + + return [ + { + id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0), + name: `${skillNameSeed}起手`, + summary: clampText(skillSummarySeed, 36), + style: '起手压制', + }, + { + id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1), + name: `${skillNameSeed}变招`, + summary: clampText( + source.personality || `${source.name}习惯在试探中寻找破绽。`, + 36, + ), + style: '机动周旋', + }, + { + id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2), + name: `${skillNameSeed}底牌`, + summary: clampText( + motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`, + 36, + ), + style: '爆发终结', + }, + ] satisfies CustomWorldRoleSkill[]; +} + +function normalizeRoleSkillList( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const normalized = toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + const summary = toText(item.summary) || toText(item.description); + const style = toText(item.style) || toText(item.category) || '常用'; + + return { + id: createEntryId('role-skill', name || style, index), + name, + summary, + style, + } satisfies CustomWorldRoleSkill; + }) + .filter((entry) => entry.name) + .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT); + + return normalized.length > 0 + ? normalized + : buildFallbackRoleSkills(fallbackSource); +} + +function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { + const itemNameSeed = source.title || source.role || source.name || '角色'; + return [ + { + id: createEntryId('role-item', `${itemNameSeed}-1`, 0), + name: `${itemNameSeed}常备武具`, + category: '武器', + quantity: 1, + rarity: 'rare', + description: clampText( + source.combatStyle || `${source.name}随身携带的主要作战物件。`, + 36, + ), + tags: normalizeTags(source.tags, ['战斗', '随身']), + }, + { + id: createEntryId('role-item', `${itemNameSeed}-2`, 1), + name: `${itemNameSeed}补给包`, + category: '消耗品', + quantity: 2, + rarity: 'uncommon', + description: clampText( + source.personality || `${source.name}为了长期行动准备的基础补给。`, + 36, + ), + tags: normalizeTags(source.relationshipHooks, ['补给', '行动']), + }, + { + id: createEntryId('role-item', `${itemNameSeed}-3`, 2), + name: `${itemNameSeed}私人物件`, + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: clampText( + source.backstory || + source.motivation || + `${source.name}不愿随意交出的信物。`, + 36, + ), + tags: normalizeTags( + [...source.tags, ...source.relationshipHooks], + ['信物', '线索'], + ), + }, + ] satisfies CustomWorldRoleInitialItem[]; +} + +function normalizeRoleInitialItemList( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const normalized = toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + return { + id: createEntryId('role-item', name, index), + name, + category: normalizeRoleItemCategory(item.category), + quantity: + typeof item.quantity === 'number' && Number.isFinite(item.quantity) + ? Math.max(1, Math.min(99, Math.round(item.quantity))) + : 1, + rarity: normalizeRarity(item.rarity, 'rare'), + description: toText(item.description), + tags: normalizeTags(item.tags), + } satisfies CustomWorldRoleInitialItem; + }) + .filter((entry) => entry.name) + .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT); + + return normalized.length > 0 + ? normalized + : buildFallbackRoleInitialItems(fallbackSource); +} + +function inferWorldTypeFromSetting(settingText: string): WorldType { + return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText) + ? 'XIANXIA' + : 'WUXIA'; +} + +function normalizeWorldType(value: unknown, sourceText: string): WorldType { + const worldType = toText(value).toUpperCase(); + if (worldType === 'WUXIA' || worldType === 'XIANXIA') { + return worldType; + } + return inferWorldTypeFromSetting(sourceText); +} + +function buildSeedPhrase(settingText: string, fallback: string) { + const compact = settingText.replace(/\s+/g, '').trim(); + return compact ? compact.slice(0, 10) : fallback; +} + +function buildWorldName(settingText: string, worldType: WorldType) { + const seed = buildSeedPhrase(settingText, '新旅'); + const suffix = worldType === 'XIANXIA' ? '境' : '域'; + return `${seed}${suffix}`; +} + +function detectCustomWorldThemeMode(profile: { + settingText: string; + summary: string; + tone: string; + playerGoal: string; +}) { + const source = [ + profile.settingText, + profile.summary, + profile.tone, + profile.playerGoal, + ].join(' '); + + if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; + if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; + if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; + if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; + if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; + return 'mythic'; +} + +function sanitizeCampSeed(name: string) { + const normalized = name.trim().replace(/\s+/g, ''); + if (!normalized) { + return ''; + } + + const stripped = normalized.replace( + /(世界|江湖|边城|仙洲|仙境|灵境|界|录|域|境)$/u, + '', + ); + const seed = stripped || normalized; + + return seed.slice(0, Math.min(seed.length, 4)); +} + +function buildFallbackCampName(profile: { + name: string; + summary: string; + tone: string; + playerGoal: string; + settingText: string; +}) { + const seed = sanitizeCampSeed(profile.name) || '归途'; + const themeMode = detectCustomWorldThemeMode(profile); + + const suffixByMode = { + mythic: '归舍', + martial: '归舍', + arcane: '栖居', + machina: '整备居', + tide: '潮居', + rift: '界隙居所', + } as const; + + return `${seed}${suffixByMode[themeMode]}`; +} + +function buildFallbackCustomWorldCampScene(profile: { + name: string; + summary: string; + tone: string; + playerGoal: string; + settingText: string; +}): CustomWorldCampScene { + const fallbackName = buildFallbackCampName(profile); + const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗'; + const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索'; + const themeMode = detectCustomWorldThemeMode(profile); + + const descriptionByMode = { + mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`, + martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`, + arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`, + machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`, + tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`, + rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`, + } as const; + + return { + name: fallbackName, + description: descriptionByMode[themeMode], + dangerLevel: 'low', + }; +} + +function buildTemplateWorldAttributeSchema(worldType: Exclude) { + const common = { + schemaVersion: 1, + generatedFrom: + worldType === 'XIANXIA' + ? { + worldType: 'XIANXIA' as const, + worldName: '仙侠', + settingSummary: '灵潮、宗门、禁制、秘境与道途交织。', + tone: '空灵、危险、带着灾变与大道压迫。', + conflictCore: '在裂变与因果之间稳住自我与道途。', + } + : { + worldType: 'WUXIA' as const, + worldName: '武侠', + settingSummary: '江湖、门派、旧案与人情纠葛并存。', + tone: '克制、紧张、讲究局势与心气。', + conflictCore: '在人情、威压与旧案之间立住自身。', + }, + }; + + if (worldType === 'XIANXIA') { + return { + id: 'schema:xianxia:v1', + worldId: 'XIANXIA', + schemaName: '灵界六轴', + ...common, + slots: [ + { + slotId: 'axis_a', + name: '道骨', + definition: '承载道压与高强度冲击的底子。', + positiveSignals: ['承压', '根基稳', '扛得住'], + negativeSignals: ['根基浅', '易溃', '承载不足'], + combatUseText: '扛住灵压、正面承受高强度对撞。', + socialUseText: '让人感到根基扎实,值得托付重事。', + explorationUseText: '承受秘境、禁制与裂隙带来的压迫。', + }, + { + slotId: 'axis_b', + name: '灵行', + definition: '位移、御空、转场、抢占天时地利的能力。', + positiveSignals: ['位移', '御空', '机动'], + negativeSignals: ['迟滞', '失位', '转场慢'], + combatUseText: '抢位、御空、快速重整战场位置。', + socialUseText: '反应轻快,擅长顺势接住局面的变化。', + explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。', + }, + { + slotId: 'axis_c', + name: '识海', + definition: '解析禁制、洞察因果、识破虚实的能力。', + positiveSignals: ['洞察', '解构', '看破'], + negativeSignals: ['迷失', '误判', '看不清'], + combatUseText: '识破术理、找出因果节点与破绽。', + socialUseText: '更容易辨认真话、虚言与隐藏动机。', + explorationUseText: '解读阵纹、禁制、旧史与环境异象。', + }, + { + slotId: 'axis_d', + name: '劫纹', + definition: '在高危变化中强行推进、改写局势的能力。', + positiveSignals: ['强推', '决断', '逆转'], + negativeSignals: ['畏缩', '迟疑', '不敢碰变局'], + combatUseText: '在高压窗口里压上去,逼出变化与突破。', + socialUseText: '在关键谈判中拍板,推动他人表态。', + explorationUseText: '面对异变与风险时敢于推进关键节点。', + }, + { + slotId: 'axis_e', + name: '心契', + definition: '与他者、器物、灵兽、誓约建立共鸣的能力。', + positiveSignals: ['共鸣', '结契', '安抚'], + negativeSignals: ['隔阂', '生硬', '难以共振'], + combatUseText: '与器物、灵兽、同伴形成协同与共鸣。', + socialUseText: '建立信任、誓约与更深层的关系连结。', + explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。', + }, + { + slotId: 'axis_f', + name: '玄息', + definition: '循环灵息、稳住心神、让自身持续在线的能力。', + positiveSignals: ['稳态', '回转', '续航'], + negativeSignals: ['紊乱', '枯竭', '失衡'], + combatUseText: '维持灵息循环、拖住长线压力与消耗。', + socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。', + explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。', + }, + ] satisfies WorldAttributeSlot[], + } satisfies WorldAttributeSchema; + } + + return { + id: 'schema:wuxia:v1', + worldId: 'WUXIA', + schemaVersion: 1, + schemaName: '江湖六脉', + generatedFrom: common.generatedFrom, + slots: [ + { + slotId: 'axis_a', + name: '骨势', + definition: '扛压、顶冲、硬吃风险也不退的势头。', + positiveSignals: ['扛压', '硬桥硬马', '稳住正面'], + negativeSignals: ['虚浮', '怯退', '一碰就散'], + combatUseText: '顶住正面压力、换伤不退、撑住阵线。', + socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。', + explorationUseText: '穿越险路、硬顶机关、承受高压环境。', + }, + { + slotId: 'axis_b', + name: '身法', + definition: '腾挪、抢位、换线、把握出手节奏的能力。', + positiveSignals: ['快', '轻灵', '抢位'], + negativeSignals: ['迟缓', '失位', '笨重'], + combatUseText: '切线换位、闪转腾挪、争夺先手。', + socialUseText: '应变快,擅长观察气口并顺势接话。', + explorationUseText: '攀越、潜入、追踪与复杂地形穿行。', + }, + { + slotId: 'axis_c', + name: '眼脉', + definition: '看破破绽、拆招、识局、看穿人心的能力。', + positiveSignals: ['识局', '洞察', '拆招'], + negativeSignals: ['迟钝', '误判', '看不透'], + combatUseText: '抓破绽、拆套路、找出最该切入的位置。', + socialUseText: '判断弦外之音、试探真假、识别来意。', + explorationUseText: '识破机关、辨认痕迹、看懂异状。', + }, + { + slotId: 'axis_d', + name: '心焰', + definition: '决断、压迫、胆气、在局面中立住自身意志的能力。', + positiveSignals: ['胆气', '决断', '压迫'], + negativeSignals: ['犹疑', '软弱', '易被动摇'], + combatUseText: '逼迫对手、强行推进、在关键时刻拍板。', + socialUseText: '立威、定调、在谈判里压住场子。', + explorationUseText: '在未知风险前保持决断,不被局势拖死。', + }, + { + slotId: 'axis_e', + name: '尘缘', + definition: '与人事、情面、承诺、牵引关系打交道的能力。', + positiveSignals: ['通人情', '会安抚', '懂交换'], + negativeSignals: ['生硬', '失礼', '不近人情'], + combatUseText: '借势协同、读懂同伴与对手的关系脉络。', + socialUseText: '安抚、求助、结盟、维系承诺与信任。', + explorationUseText: '从传闻、人脉和地方关系里打开线索。', + }, + { + slotId: 'axis_f', + name: '玄息', + definition: '调息、稳态、久战、把自身维持在可用状态的能力。', + positiveSignals: ['稳', '续战', '调息'], + negativeSignals: ['紊乱', '易崩', '续不上'], + combatUseText: '续战、回气、稳住节奏与状态。', + socialUseText: '遇事不乱,语气和姿态都更沉稳可信。', + explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。', + }, + ] satisfies WorldAttributeSlot[], + } satisfies WorldAttributeSchema; +} + +function generateWorldAttributeSchema(input: { + worldName: string; + settingText: string; + summary: string; + tone: string; + playerGoal: string; +}) { + const inferredWorldType = inferWorldTypeFromSetting(input.settingText); + const template = buildTemplateWorldAttributeSchema( + inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA', + ); + + return { + ...template, + id: `schema:custom:${slugify(input.worldName)}`, + worldId: `custom:${input.worldName}`, + generatedFrom: { + worldType: 'CUSTOM', + worldName: input.worldName, + settingSummary: input.summary, + tone: input.tone, + conflictCore: input.playerGoal, + }, + } satisfies WorldAttributeSchema; +} + +function normalizeAttributeValues( + values: AttributeVector, + slotIds: readonly string[], + targetTotal = 360, +) { + const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0)); + const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0); + const normalized = + rawTotal > 0 + ? positiveValues.map((value) => (value / rawTotal) * targetTotal) + : slotIds.map(() => targetTotal / Math.max(slotIds.length, 1)); + const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value)))); + return Object.fromEntries( + slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]), + ) as AttributeVector; +} + +function ensureRoleAttributeProfile( + profile: Partial | null | undefined, + schema: WorldAttributeSchema, + fallbackValues: AttributeVector, +): RoleAttributeProfile { + const slotIds = schema.slots.map((slot) => slot.slotId); + const values = normalizeAttributeValues( + { + ...fallbackValues, + ...(profile?.values ?? {}), + }, + slotIds, + ); + const sortedSlots = [...schema.slots] + .map((slot) => ({ + slot, + value: values[slot.slotId] ?? 0, + })) + .sort((left, right) => right.value - left.value); + + return { + schemaId: profile?.schemaId ?? schema.id, + values, + topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name), + hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined, + evidence: + profile?.evidence?.length + ? [...profile.evidence] + : sortedSlots.slice(0, 3).map((entry) => ({ + slotId: entry.slot.slotId, + reason: `${entry.slot.name}在当前画像中最突出。`, + })), + }; +} + +const AXIS_KEYWORD_RULES: Array<{ + slotId: string; + patterns: RegExp[]; + weight: number; +}> = [ + { slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16 }, + { slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16 }, + { slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16 }, + { slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16 }, + { slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16 }, + { slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16 }, +]; + +function buildDefaultAxisVector( + overrides: Partial>, +) { + return WORLD_ATTRIBUTE_SLOT_IDS.reduce((result, slotId) => { + result[slotId] = overrides[slotId] ?? 0; + return result; + }, {}); +} + +function buildRoleAttributeProfileFromTexts(params: { + entityId: string; + schema: WorldAttributeSchema; + textBlocks: Array; +}) { + const sourceText = params.textBlocks.filter(Boolean).join(' '); + const seed = buildDefaultAxisVector({ + axis_a: 58, + axis_b: 58, + axis_c: 58, + axis_d: 58, + axis_e: 58, + axis_f: 58, + }); + + AXIS_KEYWORD_RULES.forEach((rule) => { + const matches = rule.patterns.reduce( + (count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), + 0, + ); + if (matches <= 0) { + return; + } + seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches; + }); + + return ensureRoleAttributeProfile( + { + schemaId: params.schema.id, + }, + params.schema, + seed, + ); +} + +function buildCustomWorldPlayableNpcAttributeProfile( + npc: CustomWorldPlayableNpc, + schema: WorldAttributeSchema, +) { + return buildRoleAttributeProfileFromTexts({ + entityId: npc.id, + schema, + textBlocks: [ + npc.title, + npc.role, + npc.description, + npc.backstory, + npc.personality, + npc.motivation, + npc.combatStyle, + ...(npc.relationshipHooks ?? []), + ...(npc.tags ?? []), + ], + }); +} + +function buildCustomWorldStoryNpcAttributeProfile( + npc: CustomWorldNpc, + schema: WorldAttributeSchema, +) { + return buildRoleAttributeProfileFromTexts({ + entityId: npc.id, + schema, + textBlocks: [ + npc.title, + npc.role, + npc.description, + npc.backstory, + npc.personality, + npc.motivation, + npc.combatStyle, + ...(npc.relationshipHooks ?? []), + ...(npc.tags ?? []), + ], + }); +} + +function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile { + const templateWorldType = inferWorldTypeFromSetting(settingText); + const name = buildWorldName(settingText, templateWorldType); + const subtitle = '前路未明'; + const summary = settingText.trim() + ? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。` + : '一个仍待展开的独立世界正在成形。'; + const tone = '未知、紧绷、仍在展开'; + const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事'; + const camp = buildFallbackCustomWorldCampScene({ + name, + summary, + tone, + playerGoal, + settingText: settingText.trim(), + }); + + return { + id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`, + settingText: settingText.trim(), + name, + subtitle, + summary, + tone, + playerGoal, + cover: buildDefaultCustomWorldCover([]), + templateWorldType, + compatibilityTemplateWorldType: templateWorldType, + majorFactions: [], + coreConflicts: [summary], + attributeSchema: generateWorldAttributeSchema({ + worldName: name, + settingText: settingText.trim(), + summary, + tone, + playerGoal, + }), + playableNpcs: [], + storyNpcs: [], + items: [], + camp, + landmarks: [], + themePack: null, + storyGraph: null, + creatorIntent: null, + anchorPack: null, + lockState: normalizeCustomWorldLockState(null), + generationMode: 'full', + generationStatus: 'complete', + ownedSettingLayers: null, + scenarioPackId: null, + campaignPackId: null, + }; +} + +function normalizeRoleOutlineList( + value: unknown, + options: { + titleFallback: string; + defaultAffinity: number; + maxCount?: number; + }, +) { + const normalized = toRecordArray(value) + .map((item) => { + const name = toText(item.name); + const title = + toText(item.title) || toText(item.role) || options.titleFallback; + const role = toText(item.role) || title; + const relationshipHooks = normalizeTags( + item.relationshipHooks, + normalizeTags(item.tags), + ); + + return { + name, + title, + role, + description: + toText(item.description) || + clampText(`${name || title}在世界中以${role}身份活动。`, 36), + visualDescription: toText(item.visualDescription) || undefined, + actionDescription: toText(item.actionDescription) || undefined, + sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, + initialAffinity: normalizeInitialAffinity( + item.initialAffinity, + options.defaultAffinity, + ), + relationshipHooks, + tags: normalizeTags(item.tags, relationshipHooks), + } satisfies CustomWorldGenerationRoleOutline; + }) + .filter((entry) => entry.name); + + return typeof options.maxCount === 'number' + ? normalized.slice(0, options.maxCount) + : normalized; +} + +function normalizeCampOutline( + value: unknown, + fallbackProfile: { + name: string; + summary: string; + tone: string; + playerGoal: string; + settingText: string; + }, +) { + const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); + const item = + value && typeof value === 'object' + ? (value as Record) + : {}; + + return { + name: toText(item.name) || fallback.name, + description: toText(item.description) || fallback.description, + dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, + }; +} + +function normalizeLandmarkOutlineList(value: unknown) { + return toRecordArray(value) + .map((item) => { + const name = toText(item.name); + return { + name, + description: + toText(item.description) || + clampText(`${name}暗藏新的局势变化。`, 40), + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || 'medium', + sceneNpcNames: [ + ...toStringArray(item.sceneNpcNames), + ...toStringArray(item.npcs, 'name'), + ...toStringArray(item.sceneNpcs, 'name'), + ...toStringArray(item.npcNames), + ], + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || + toText(connection.position) || + 'forward', + summary: + toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkName), + } satisfies CustomWorldGenerationLandmarkOutline; + }) + .filter((entry) => entry.name) + .slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT); +} + +export function normalizeCustomWorldGenerationRoleOutlineBatch( + raw: unknown, + roleType: CustomWorldGenerationRoleBatchType, +) { + const item = + raw && typeof raw === 'object' ? (raw as Record) : {}; + const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; + + return normalizeRoleOutlineList(item[key], { + titleFallback: '未定称号', + defaultAffinity: + roleType === 'playable' + ? DEFAULT_PLAYABLE_INITIAL_AFFINITY + : DEFAULT_STORY_NPC_INITIAL_AFFINITY, + }); +} + +export function normalizeCustomWorldGenerationLandmarkOutlineBatch( + raw: unknown, +) { + const item = + raw && typeof raw === 'object' ? (raw as Record) : {}; + return normalizeLandmarkOutlineList(item.landmarks); +} + +export function normalizeCustomWorldGenerationFramework( + raw: unknown, + settingText: string, +): CustomWorldGenerationFramework { + const fallback = buildBaseCustomWorldProfile(settingText); + if (!raw || typeof raw !== 'object') { + return { + settingText: fallback.settingText, + name: fallback.name, + subtitle: fallback.subtitle, + summary: fallback.summary, + tone: fallback.tone, + playerGoal: fallback.playerGoal, + templateWorldType: fallback.templateWorldType, + compatibilityTemplateWorldType: + fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType, + majorFactions: [], + coreConflicts: [fallback.summary], + camp: { + name: fallback.camp?.name ?? '归舍', + description: fallback.camp?.description ?? '', + dangerLevel: fallback.camp?.dangerLevel ?? 'low', + }, + playableNpcs: [], + storyNpcs: [], + landmarks: [], + }; + } + + const item = raw as Record; + const worldSignalText = [ + settingText, + toText(item.subtitle), + toText(item.summary), + toText(item.tone), + toText(item.playerGoal), + ].join(' '); + const templateWorldType = normalizeWorldType( + item.templateWorldType, + worldSignalText, + ); + const name = + toText(item.name) || buildWorldName(settingText, templateWorldType); + + return { + settingText: settingText.trim(), + name, + subtitle: toText(item.subtitle) || fallback.subtitle, + summary: toText(item.summary) || fallback.summary, + tone: toText(item.tone) || fallback.tone, + playerGoal: toText(item.playerGoal) || fallback.playerGoal, + templateWorldType, + compatibilityTemplateWorldType: templateWorldType, + majorFactions: normalizeTags(item.majorFactions, []), + coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]), + camp: normalizeCampOutline(item.camp, { + name, + summary: toText(item.summary) || fallback.summary, + tone: toText(item.tone) || fallback.tone, + playerGoal: toText(item.playerGoal) || fallback.playerGoal, + settingText: settingText.trim(), + }), + playableNpcs: normalizeRoleOutlineList(item.playableNpcs, { + titleFallback: '未定称号', + defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, + maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, + }), + storyNpcs: normalizeRoleOutlineList(item.storyNpcs, { + titleFallback: '未定称号', + defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, + maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT, + }), + landmarks: normalizeLandmarkOutlineList(item.landmarks), + }; +} + +export function buildCustomWorldRawProfileFromFramework( + framework: CustomWorldGenerationFramework, +) { + return { + name: framework.name, + subtitle: framework.subtitle, + summary: framework.summary, + tone: framework.tone, + playerGoal: framework.playerGoal, + templateWorldType: framework.templateWorldType, + compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType, + majorFactions: framework.majorFactions, + coreConflicts: framework.coreConflicts, + camp: { + name: framework.camp.name, + description: framework.camp.description, + dangerLevel: framework.camp.dangerLevel, + }, + playableNpcs: framework.playableNpcs.map((npc) => ({ + name: npc.name, + title: npc.title, + role: npc.role, + description: npc.description, + visualDescription: npc.visualDescription, + actionDescription: npc.actionDescription, + sceneVisualDescription: npc.sceneVisualDescription, + initialAffinity: npc.initialAffinity, + relationshipHooks: [...npc.relationshipHooks], + tags: [...npc.tags], + })), + storyNpcs: framework.storyNpcs.map((npc) => ({ + name: npc.name, + title: npc.title, + role: npc.role, + description: npc.description, + visualDescription: npc.visualDescription, + actionDescription: npc.actionDescription, + sceneVisualDescription: npc.sceneVisualDescription, + initialAffinity: npc.initialAffinity, + relationshipHooks: [...npc.relationshipHooks], + tags: [...npc.tags], + })), + landmarks: framework.landmarks.map((landmark) => ({ + name: landmark.name, + description: landmark.description, + visualDescription: landmark.visualDescription, + dangerLevel: landmark.dangerLevel, + sceneNpcNames: [...landmark.sceneNpcNames], + connections: landmark.connections.map((connection) => ({ + targetLandmarkName: connection.targetLandmarkName, + relativePosition: connection.relativePosition, + summary: connection.summary, + })), + })), + }; +} + +function normalizeRoleProfile( + item: Record, + index: number, + options: { + idPrefix: 'playable-npc' | 'story-npc'; + titleFallback: string; + defaultAffinity: number; + }, +) { + const name = toText(item.name); + const title = + toText(item.title) || toText(item.role) || options.titleFallback; + const role = toText(item.role) || title; + const relationshipHooks = normalizeTags( + item.relationshipHooks, + normalizeTags(item.tags), + ); + const normalizedRole = { + id: toText(item.id) || createEntryId(options.idPrefix, name, index), + name, + title, + role, + description: toText(item.description), + visualDescription: toText(item.visualDescription) || undefined, + actionDescription: toText(item.actionDescription) || undefined, + sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, + backstory: toText(item.backstory), + personality: toText(item.personality), + motivation: toText(item.motivation) || toText(item.description), + combatStyle: toText(item.combatStyle), + initialAffinity: normalizeInitialAffinity( + item.initialAffinity, + options.defaultAffinity, + ), + relationshipHooks, + tags: normalizeTags(item.tags, relationshipHooks), + }; + + return { + ...normalizedRole, + backstoryReveal: normalizeBackstoryReveal( + item.backstoryReveal, + normalizedRole, + ), + skills: normalizeRoleSkillList(item.skills, normalizedRole), + initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole), + imageSrc: toText(item.imageSrc) || undefined, + generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined, + generatedAnimationSetId: + toText(item.generatedAnimationSetId) || undefined, + animationMap: + item.animationMap && typeof item.animationMap === 'object' + ? (item.animationMap as Record) + : undefined, + narrativeProfile: + item.narrativeProfile && typeof item.narrativeProfile === 'object' + ? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile']) + : null, + }; +} + +function normalizePlayableNpcList(value: unknown) { + return toRecordArray(value) + .map((item, index) => ({ + ...normalizeRoleProfile(item, index, { + idPrefix: 'playable-npc', + titleFallback: '未定称号', + defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, + }), + templateCharacterId: toText(item.templateCharacterId) || undefined, + })) + .filter((entry) => entry.name) + .slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT); +} + +function normalizeStoryNpcList(value: unknown) { + return toRecordArray(value) + .map( + (item, index) => + ({ + ...normalizeRoleProfile(item, index, { + idPrefix: 'story-npc', + titleFallback: '未定称号', + defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, + }), + visual: + item.visual && typeof item.visual === 'object' + ? (item.visual as Record) + : undefined, + }) satisfies CustomWorldNpc, + ) + .filter((entry) => entry.name); +} + +function normalizeCustomWorldCoverCharacterRoleIds( + value: unknown, + playableNpcs: Array>, +) { + const availableIds = new Set( + playableNpcs.map((entry) => entry.id.trim()).filter(Boolean), + ); + const selectedIds = Array.isArray(value) + ? [ + ...new Set( + value + .map((entry) => toText(entry)) + .filter((entry) => entry && availableIds.has(entry)), + ), + ].slice(0, 3) + : []; + + if (selectedIds.length > 0) { + return selectedIds; + } + + return playableNpcs + .map((entry) => entry.id.trim()) + .filter(Boolean) + .slice(0, 3); +} + +function buildDefaultCustomWorldCover( + playableNpcs: Array>, +): CustomWorldCoverProfile { + return { + sourceType: 'default' as const, + imageSrc: null, + characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds( + undefined, + playableNpcs, + ), + }; +} + +function normalizeCustomWorldCover( + value: unknown, + playableNpcs: Array>, +): CustomWorldCoverProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return buildDefaultCustomWorldCover(playableNpcs); + } + + const item = value as Record; + const sourceType: CustomWorldCoverSourceType = + item.sourceType === 'uploaded' || item.sourceType === 'generated' + ? item.sourceType + : 'default'; + const imageSrc = toText(item.imageSrc) || null; + + if (sourceType !== 'default' && imageSrc) { + return { + sourceType, + imageSrc, + characterRoleIds: [], + }; + } + + return buildDefaultCustomWorldCover(playableNpcs); +} + +function normalizeItemList(value: unknown) { + return toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + const category = toText(item.category); + return { + id: toText(item.id) || createEntryId('item', name, index), + name, + category, + rarity: normalizeRarity(item.rarity, 'rare'), + description: toText(item.description), + tags: normalizeTags(item.tags), + } satisfies CustomWorldItem; + }) + .filter((entry) => entry.name && entry.category); +} + +function normalizeLandmarks(params: { + landmarks: Array>; + storyNpcs: CustomWorldNpc[]; +}) { + const storyNpcIdByName = new Map( + params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const), + ); + const landmarkEntries = params.landmarks + .map((item, index) => ({ + id: toText(item.id) || createEntryId('landmark', toText(item.name), index), + name: toText(item.name), + description: toText(item.description), + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || 'medium', + imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + sceneNpcNames: [ + ...toStringArray(item.sceneNpcNames), + ...toStringArray(item.npcs, 'name'), + ...toStringArray(item.sceneNpcs, 'name'), + ...toStringArray(item.npcNames), + ], + connections: toRecordArray(item.connections).map((connection) => ({ + targetLandmarkId: toText(connection.targetLandmarkId), + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || toText(connection.position), + summary: toText(connection.summary) || toText(connection.description), + })), + })) + .filter((entry) => entry.name); + + const landmarkIdByName = new Map( + landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const), + ); + + return landmarkEntries.map((landmark) => { + const resolvedSceneNpcIds = [ + ...new Set( + [ + ...landmark.sceneNpcIds, + ...landmark.sceneNpcNames + .map((name) => storyNpcIdByName.get(name.trim()) ?? '') + .filter(Boolean), + ].filter(Boolean), + ), + ]; + + return { + id: landmark.id, + name: landmark.name, + description: landmark.description, + visualDescription: landmark.visualDescription, + dangerLevel: landmark.dangerLevel, + imageSrc: landmark.imageSrc, + sceneNpcIds: resolvedSceneNpcIds, + connections: landmark.connections + .map((connection) => ({ + targetLandmarkId: + connection.targetLandmarkId || + landmarkIdByName.get(connection.targetLandmarkName.trim()) || + '', + relativePosition: connection.relativePosition || 'forward', + summary: connection.summary, + })) + .filter((connection) => connection.targetLandmarkId), + }; + }); +} + +function normalizeCampScene( + value: unknown, + fallbackProfile: { + name: string; + summary: string; + tone: string; + playerGoal: string; + settingText: string; + }, +): CustomWorldCampScene { + const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); + const item = + value && typeof value === 'object' + ? (value as Record) + : {}; + + return { + name: toText(item.name) || fallback.name, + description: toText(item.description) || fallback.description, + dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, + imageSrc: toText(item.imageSrc) || undefined, + }; +} + +export function normalizeCustomWorldProfile( + raw: unknown, + settingText: string, +): CustomWorldProfile { + const fallback = buildBaseCustomWorldProfile(settingText); + if (!raw || typeof raw !== 'object') { + return fallback; + } + + const item = raw as Record; + const worldSignalText = [ + settingText, + toText(item.subtitle), + toText(item.summary), + toText(item.tone), + toText(item.playerGoal), + ].join(' '); + const templateWorldType = normalizeWorldType( + item.templateWorldType, + worldSignalText, + ); + const name = + toText(item.name) || buildWorldName(settingText, templateWorldType); + const summary = toText(item.summary) || fallback.summary; + const tone = toText(item.tone) || fallback.tone; + const playerGoal = toText(item.playerGoal) || fallback.playerGoal; + const generatedAttributeSchema = generateWorldAttributeSchema({ + worldName: name, + settingText: settingText.trim(), + summary, + tone, + playerGoal, + }); + const playableNpcs = normalizePlayableNpcList(item.playableNpcs); + const storyNpcs = normalizeStoryNpcList(item.storyNpcs); + const landmarkDrafts = toRecordArray(item.landmarks); + const camp = normalizeCampScene(item.camp, { + name, + summary, + tone, + playerGoal, + settingText: settingText.trim(), + }); + const creatorIntent = normalizeCustomWorldCreatorIntent(item.creatorIntent); + + return { + id: + toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`, + settingText: settingText.trim(), + name, + subtitle: toText(item.subtitle) || fallback.subtitle, + summary, + tone, + playerGoal, + cover: normalizeCustomWorldCover(item.cover, playableNpcs), + templateWorldType, + compatibilityTemplateWorldType: templateWorldType, + majorFactions: normalizeTags(item.majorFactions, []), + coreConflicts: normalizeTags(item.coreConflicts, [summary]), + attributeSchema: + item.attributeSchema && typeof item.attributeSchema === 'object' + ? generatedAttributeSchema + : generatedAttributeSchema, + playableNpcs, + storyNpcs, + items: normalizeItemList(item.items), + camp, + landmarks: normalizeLandmarks({ + landmarks: landmarkDrafts, + storyNpcs, + }), + themePack: + item.themePack && typeof item.themePack === 'object' + ? (item.themePack as CustomWorldProfile['themePack']) + : null, + storyGraph: + item.storyGraph && typeof item.storyGraph === 'object' + ? (item.storyGraph as CustomWorldProfile['storyGraph']) + : null, + anchorContent: + item.anchorContent && typeof item.anchorContent === 'object' + ? (item.anchorContent as Record) + : null, + creatorIntent, + anchorPack: + item.anchorPack && typeof item.anchorPack === 'object' + ? (item.anchorPack as CustomWorldProfile['anchorPack']) + : buildCustomWorldAnchorPackFromIntent(creatorIntent), + lockState: + item.lockState && typeof item.lockState === 'object' + ? normalizeCustomWorldLockState(item.lockState) + : deriveCustomWorldLockStateFromIntent(creatorIntent), + generationMode: + item.generationMode === 'fast' || item.generationMode === 'full' + ? item.generationMode + : fallback.generationMode, + generationStatus: + item.generationStatus === 'key_only' || item.generationStatus === 'complete' + ? item.generationStatus + : fallback.generationStatus, + ownedSettingLayers: + item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object' + ? (item.ownedSettingLayers as Record) + : null, + knowledgeFacts: + Array.isArray(item.knowledgeFacts) + ? (item.knowledgeFacts as Array>) + : null, + threadContracts: + Array.isArray(item.threadContracts) + ? (item.threadContracts as Array>) + : null, + scenarioPackId: toText(item.scenarioPackId) || null, + campaignPackId: toText(item.campaignPackId) || null, + }; +} + +function pickCyclic(items: readonly T[], index: number, label: string): T { + const item = items[index % items.length]; + if (item === undefined) { + throw new Error(`Missing ${label}`); + } + return item; +} + +export function buildCompiledCustomWorldProfile( + raw: unknown, + settingText: string, +): CustomWorldProfile { + const profile = normalizeCustomWorldProfile(raw, settingText); + const playableNpcs = profile.playableNpcs.map((npc, index) => { + const templateCharacterId = + npc.templateCharacterId ?? + pickCyclic( + PLAYABLE_TEMPLATE_CHARACTER_IDS, + index, + 'playable template character id', + ); + + return { + ...npc, + templateCharacterId, + attributeProfile: + npc.attributeProfile ?? + buildCustomWorldPlayableNpcAttributeProfile( + { + ...npc, + templateCharacterId, + }, + profile.attributeSchema, + ), + }; + }); + + const storyNpcs = profile.storyNpcs.map((npc) => ({ + ...npc, + attributeProfile: + npc.attributeProfile ?? + buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema), + })); + + return { + ...profile, + playableNpcs, + storyNpcs, + scenarioPackId: + profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`, + campaignPackId: + profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`, + }; +} + +function countUniqueNames(items: Array<{ name: string }>) { + return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size; +} + +export function validateGeneratedCustomWorldProfile( + profile: CustomWorldProfile, +) { + const playableCount = countUniqueNames(profile.playableNpcs); + const landmarkCount = countUniqueNames(profile.landmarks); + + if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) { + throw new Error( + `自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`, + ); + } + + if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) { + throw new Error( + `自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`, + ); + } + + const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id)); + const validLandmarkIds = new Set( + profile.landmarks.map((landmark) => landmark.id), + ); + + profile.landmarks.forEach((landmark) => { + const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)]; + if (uniqueSceneNpcIds.length < 3) { + throw new Error( + `场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`, + ); + } + if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) { + throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`); + } + if (landmark.connections.length === 0) { + throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`); + } + if ( + landmark.connections.some( + (connection) => + connection.targetLandmarkId === landmark.id || + !validLandmarkIds.has(connection.targetLandmarkId), + ) + ) { + throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`); + } + }); +} diff --git a/server-node/src/modules/custom-world/runtimeTypes.ts b/server-node/src/modules/custom-world/runtimeTypes.ts new file mode 100644 index 00000000..77e2e117 --- /dev/null +++ b/server-node/src/modules/custom-world/runtimeTypes.ts @@ -0,0 +1,389 @@ +export type WorldType = 'WUXIA' | 'XIANXIA' | 'CUSTOM'; + +export type CustomWorldGenerationMode = 'fast' | 'full'; +export type CustomWorldGenerationStatus = 'key_only' | 'complete'; +export type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated'; + +export interface CustomWorldCoverProfile { + sourceType: CustomWorldCoverSourceType; + imageSrc?: string | null; + characterRoleIds?: string[]; +} + +export interface CreatorFactionSeed { + id: string; + name: string; + publicGoal: string; + tension: string; + notes: string; + locked?: boolean; +} + +export interface CreatorCharacterSeed { + id: string; + name: string; + role: string; + publicMask: string; + hiddenHook: string; + relationToPlayer: string; + notes: string; + locked?: boolean; +} + +export interface CreatorLandmarkSeed { + id: string; + name: string; + purpose: string; + mood: string; + secret: string; + locked?: boolean; +} + +export interface ActorAnchor { + id: string; + name: string; + summary: string; +} + +export interface LandmarkAnchor { + id: string; + name: string; + summary: string; +} + +export interface CustomWorldCreatorIntent { + sourceMode: 'freeform' | 'card'; + rawSettingText: string; + worldHook: string; + themeKeywords: string[]; + toneDirectives: string[]; + playerPremise: string; + openingSituation: string; + coreConflicts: string[]; + keyFactions: CreatorFactionSeed[]; + keyCharacters: CreatorCharacterSeed[]; + keyLandmarks: CreatorLandmarkSeed[]; + iconicElements: string[]; + forbiddenDirectives: string[]; +} + +export interface CustomWorldAnchorPack { + worldSummary: string; + creatorIntentSummary: string; + lockedAnchorIds: string[]; + keyConflictSummaries: string[]; + keyFactionSummaries: string[]; + keyCharacterAnchors: ActorAnchor[]; + keyLandmarkAnchors: LandmarkAnchor[]; + motifDirectives: string[]; +} + +export interface CustomWorldLockState { + worldLockedFields: string[]; + lockedCharacterIds: string[]; + lockedLandmarkIds: string[]; + lockedConflictIds: string[]; + lockedFactionIds: string[]; +} + +export interface WorldAttributeSlot { + slotId: string; + name: string; + definition: string; + positiveSignals: string[]; + negativeSignals: string[]; + combatUseText: string; + socialUseText: string; + explorationUseText: string; +} + +export interface WorldAttributeSchema { + id: string; + worldId: string; + schemaVersion: number; + schemaName: string; + generatedFrom: { + worldType: WorldType; + worldName: string; + settingSummary: string; + tone: string; + conflictCore: string; + }; + slots: WorldAttributeSlot[]; +} + +export type AttributeVector = Record; + +export interface RoleAttributeEvidence { + slotId: string; + reason: string; +} + +export interface RoleAttributeProfile { + schemaId: string; + values: AttributeVector; + topTraits: string[]; + hiddenTraits?: string[]; + evidence: RoleAttributeEvidence[]; +} + +export interface CharacterBackstoryChapter { + id: string; + title: string; + affinityRequired: number; + teaser: string; + content: string; + contextSnippet: string; +} + +export interface CharacterBackstoryRevealConfig { + publicSummary: string; + privateChatUnlockAffinity: number; + chapters: CharacterBackstoryChapter[]; +} + +export interface ActorNarrativeProfile { + publicMask: string; + firstContactMask: string; + visibleLine: string; + hiddenLine: string; + contradiction: string; + debtOrBurden: string; + taboo: string; + immediatePressure: string; + relatedThreadIds: string[]; + relatedScarIds: string[]; + reactionHooks: string[]; +} + +export interface CustomWorldRoleSkill { + id: string; + name: string; + summary: string; + style: string; + actionPromptText?: string; + actionPreviewConfig?: Record; +} + +export interface CustomWorldRoleInitialItem { + id: string; + name: string; + category: string; + quantity: number; + rarity: string; + description: string; + tags: string[]; +} + +export interface CustomWorldRoleProfile { + id: string; + name: string; + title: string; + role: string; + description: string; + visualDescription?: string; + actionDescription?: string; + sceneVisualDescription?: string; + backstory: string; + personality: string; + motivation: string; + combatStyle: string; + initialAffinity: number; + relationshipHooks: string[]; + tags: string[]; + backstoryReveal: CharacterBackstoryRevealConfig; + skills: CustomWorldRoleSkill[]; + initialItems: CustomWorldRoleInitialItem[]; + imageSrc?: string; + generatedVisualAssetId?: string; + generatedAnimationSetId?: string; + animationMap?: Record; + attributeProfile?: RoleAttributeProfile; + narrativeProfile?: ActorNarrativeProfile | null; +} + +export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile { + templateCharacterId?: string; +} + +export interface CustomWorldNpc extends CustomWorldRoleProfile { + visual?: Record; +} + +export interface CustomWorldItem { + id: string; + name: string; + category: string; + rarity: string; + description: string; + tags: string[]; +} + +export interface CustomWorldSceneConnection { + targetLandmarkId: string; + relativePosition: string; + summary: string; +} + +export interface CustomWorldCampScene { + name: string; + description: string; + dangerLevel: string; + imageSrc?: string; +} + +export interface CustomWorldLandmark { + id: string; + name: string; + description: string; + visualDescription?: string; + dangerLevel: string; + imageSrc?: string; + sceneNpcIds: string[]; + connections: CustomWorldSceneConnection[]; + narrativeResidues?: + | Array<{ + summary?: string; + changeHint?: string; + hiddenTruth?: string; + }> + | null; +} + +export interface ThemePack { + id: string; + displayName: string; + toneRange: string[]; + institutionLexicon: string[]; + tabooLexicon: string[]; + artifactClasses: string[]; + actorArchetypes: string[]; + conflictForms: string[]; + clueForms: string[]; + namingPatterns: string[]; + revealStyles: string[]; +} + +export interface StoryThread { + id: string; + title: string; + visibility: 'visible' | 'hidden'; + summary: string; + conflictType: string; + stakes: string; + involvedFactionIds: string[]; + involvedActorIds: string[]; + relatedLocationIds: string[]; +} + +export interface StoryScar { + id: string; + title: string; + pastEvent: string; + publicResidue: string; + hiddenTruth: string; + relatedActorIds: string[]; + relatedLocationIds: string[]; +} + +export interface StoryMotif { + id: string; + label: string; + semanticRole: string; + lexicalHints: string[]; +} + +export interface WorldStoryGraph { + visibleThreads: StoryThread[]; + hiddenThreads: StoryThread[]; + scars: StoryScar[]; + motifs: StoryMotif[]; +} + +export interface CustomWorldProfile { + id: string; + settingText: string; + name: string; + subtitle: string; + summary: string; + tone: string; + playerGoal: string; + cover?: CustomWorldCoverProfile | null; + templateWorldType: WorldType; + compatibilityTemplateWorldType?: WorldType | null; + majorFactions: string[]; + coreConflicts: string[]; + attributeSchema: WorldAttributeSchema; + playableNpcs: CustomWorldPlayableNpc[]; + storyNpcs: CustomWorldNpc[]; + items: CustomWorldItem[]; + camp?: CustomWorldCampScene | null; + landmarks: CustomWorldLandmark[]; + themePack?: ThemePack | null; + storyGraph?: WorldStoryGraph | null; + knowledgeFacts?: Array> | null; + threadContracts?: Array> | null; + anchorContent?: Record | null; + creatorIntent?: CustomWorldCreatorIntent | null; + anchorPack?: CustomWorldAnchorPack | null; + lockState?: CustomWorldLockState | null; + ownedSettingLayers?: Record | null; + generationMode?: CustomWorldGenerationMode | null; + generationStatus?: CustomWorldGenerationStatus | null; + scenarioPackId?: string | null; + campaignPackId?: string | null; +} + +export interface CustomWorldGenerationRoleOutline { + name: string; + title: string; + role: string; + description: string; + visualDescription?: string; + actionDescription?: string; + sceneVisualDescription?: string; + initialAffinity: number; + relationshipHooks: string[]; + tags: string[]; +} + +export interface CustomWorldGenerationLandmarkConnectionOutline { + targetLandmarkName: string; + relativePosition: string; + summary: string; +} + +export interface CustomWorldGenerationLandmarkOutline { + name: string; + description: string; + visualDescription?: string; + dangerLevel: string; + sceneNpcNames: string[]; + connections: CustomWorldGenerationLandmarkConnectionOutline[]; +} + +export interface CustomWorldGenerationCampOutline { + name: string; + description: string; + dangerLevel: string; +} + +export interface CustomWorldGenerationFramework { + settingText: string; + name: string; + subtitle: string; + summary: string; + tone: string; + playerGoal: string; + templateWorldType: WorldType; + compatibilityTemplateWorldType: WorldType; + majorFactions: string[]; + coreConflicts: string[]; + camp: CustomWorldGenerationCampOutline; + playableNpcs: CustomWorldGenerationRoleOutline[]; + storyNpcs: CustomWorldGenerationRoleOutline[]; + landmarks: CustomWorldGenerationLandmarkOutline[]; +} + +export type CustomWorldGenerationRoleBatchType = 'playable' | 'story'; +export type CustomWorldGenerationRoleBatchStage = 'narrative' | 'dossier'; diff --git a/server-node/src/modules/quest/questStoryActionService.ts b/server-node/src/modules/quest/questStoryActionService.ts index 23e1f447..f8b23a7a 100644 --- a/server-node/src/modules/quest/questStoryActionService.ts +++ b/server-node/src/modules/quest/questStoryActionService.ts @@ -37,6 +37,7 @@ type QuestStoryResolution = { type JsonRecord = Record; type RuntimeGameState = Parameters[0]; +type RuntimeQuestLogEntry = NonNullable>; type RuntimeNpcState = Parameters< typeof markNpcFirstMeaningfulContactResolved >[0]; @@ -83,11 +84,56 @@ function readString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : ''; } +function isObject(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function readQuestId(request: RuntimeStoryActionRequest) { const payload = readPayload(request); return readString(payload.questId) || readString(request.action.targetId); } +function readPendingQuestOffer( + currentStory: unknown, + npcKey: string, +): RuntimeQuestLogEntry | null { + if (!isObject(currentStory)) { + return null; + } + + const npcChatState = isObject(currentStory.npcChatState) + ? currentStory.npcChatState + : null; + const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer) + ? npcChatState.pendingQuestOffer + : null; + const quest = isObject(pendingQuestOffer?.quest) + ? pendingQuestOffer.quest + : null; + + if (!quest) { + return null; + } + + const pendingNpcId = readString(npcChatState?.npcId); + const questId = readString(quest.id); + const issuerNpcId = readString(quest.issuerNpcId); + + if (!questId) { + return null; + } + + if (pendingNpcId && pendingNpcId !== npcKey) { + return null; + } + + if (issuerNpcId && issuerNpcId !== npcKey) { + return null; + } + + return quest as RuntimeQuestLogEntry; +} + function ensureEncounterQuestContext(session: RuntimeSession) { const state = session.rawGameState as unknown as RuntimeGameState; const encounter = getNpcEncounter(session, state); @@ -111,6 +157,7 @@ function ensureEncounterQuestContext(session: RuntimeSession) { function resolveQuestAcceptAction( session: RuntimeSession, + currentStory?: unknown, ): QuestStoryResolution { const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); const quests = Array.isArray(state.quests) ? state.quests : []; @@ -119,18 +166,20 @@ function resolveQuestAcceptAction( throw conflict('当前角色已经有未结清的委托。'); } - const quest = buildQuestForEncounter({ - issuerNpcId: npcKey, - issuerNpcName: encounter.npcName, - roleText: encounter.context, - scene: state.currentScenePreset, - worldType: state.worldType, - currentQuests: quests.map((item) => ({ - id: item.id, - issuerNpcId: item.issuerNpcId, - status: item.status, - })), - }); + const quest = + readPendingQuestOffer(currentStory, npcKey) ?? + buildQuestForEncounter({ + issuerNpcId: npcKey, + issuerNpcName: encounter.npcName, + roleText: encounter.context, + scene: state.currentScenePreset, + worldType: state.worldType, + currentQuests: quests.map((item) => ({ + id: item.id, + issuerNpcId: item.issuerNpcId, + status: item.status, + })), + }); if (!quest) { throw conflict('当前场景缺少可落地的委托抓手,暂时无法接取任务。'); } @@ -228,10 +277,13 @@ export function isSupportedQuestStoryFunctionId(functionId: string) { export function resolveQuestStoryAction( session: RuntimeSession, request: RuntimeStoryActionRequest, + options: { + currentStory?: unknown; + } = {}, ): QuestStoryResolution { switch (request.action.functionId) { case 'npc_quest_accept': - return resolveQuestAcceptAction(session); + return resolveQuestAcceptAction(session, options.currentStory); case 'npc_quest_turn_in': return resolveQuestTurnInAction(session, request); default: diff --git a/server-node/src/modules/story/runtimeSession.test.ts b/server-node/src/modules/story/runtimeSession.test.ts new file mode 100644 index 00000000..8ca7b522 --- /dev/null +++ b/server-node/src/modules/story/runtimeSession.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildAvailableOptions, + buildLegacyCurrentStory, + loadRuntimeSession, +} from './runtimeSession.ts'; + +function createNpcSnapshot() { + return { + version: 2, + savedAt: '2026-04-19T00:00:00.000Z', + bottomTab: 'adventure', + currentStory: null, + gameState: { + worldType: 'WUXIA', + storyHistory: [], + currentEncounter: { + kind: 'npc', + id: 'npc_merchant_01', + npcName: '沈七', + npcDescription: '腰间挂着药囊的行商', + context: '受伤行商', + }, + npcInteractionActive: true, + sceneHostileNpcs: [], + inBattle: false, + playerHp: 31, + playerMaxHp: 40, + playerMana: 9, + playerMaxMana: 16, + npcStates: { + npc_merchant_01: { + affinity: 46, + chattedCount: 1, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + companions: [], + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + quests: [], + playerInventory: [], + }, + }; +} + +test('buildAvailableOptions attaches npc interaction metadata from the server runtime session', () => { + const session = loadRuntimeSession( + createNpcSnapshot() as Parameters[0], + 'runtime-main', + ); + + const options = buildAvailableOptions(session); + + assert.deepEqual( + options.find((option) => option.functionId === 'npc_chat')?.interaction, + { + kind: 'npc', + npcId: 'npc_merchant_01', + action: 'chat', + }, + ); + assert.deepEqual( + options.find((option) => option.functionId === 'npc_help')?.interaction, + { + kind: 'npc', + npcId: 'npc_merchant_01', + action: 'help', + }, + ); +}); + +test('buildLegacyCurrentStory preserves runtime interaction metadata on projected options', () => { + const session = loadRuntimeSession( + createNpcSnapshot() as Parameters[0], + 'runtime-main', + ); + const options = buildAvailableOptions(session); + + const currentStory = buildLegacyCurrentStory('服务端已经生成了当前故事。', options); + + assert.deepEqual( + currentStory.options.find((option) => option.functionId === 'npc_leave') + ?.interaction, + { + kind: 'npc', + npcId: 'npc_merchant_01', + action: 'leave', + }, + ); +}); diff --git a/server-node/src/modules/story/runtimeSession.ts b/server-node/src/modules/story/runtimeSession.ts index 816086cc..15d09f8c 100644 --- a/server-node/src/modules/story/runtimeSession.ts +++ b/server-node/src/modules/story/runtimeSession.ts @@ -1,6 +1,7 @@ import type { - RuntimeStoryEncounterViewModel, RuntimeStoryChoicePayload, + RuntimeStoryEncounterViewModel, + RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryViewModel, Task5RuntimeOptionScope, @@ -180,8 +181,7 @@ const TASK6_RUNTIME_FUNCTION_ID_SET = new Set( TASK6_RUNTIME_FUNCTION_IDS, ); -export const TASK6_DEFERRED_FUNCTION_IDS = new Set([ -]); +export const TASK6_DEFERRED_FUNCTION_IDS = new Set([]); const FUNCTION_DEFINITIONS: Record = { story_continue_adventure: { @@ -406,13 +406,16 @@ function normalizeNpcState(value: unknown): RuntimeNpcState { ? cloneJson(rawState.stanceProfile) : null, revealedFacts: readArray(rawState.revealedFacts).filter( - (item): item is string => typeof item === 'string' && item.trim().length > 0, + (item): item is string => + typeof item === 'string' && item.trim().length > 0, ), knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter( - (item): item is string => typeof item === 'string' && item.trim().length > 0, + (item): item is string => + typeof item === 'string' && item.trim().length > 0, ), seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter( - (item): item is string => typeof item === 'string' && item.trim().length > 0, + (item): item is string => + typeof item === 'string' && item.trim().length > 0, ), }; } @@ -456,7 +459,10 @@ function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null { } const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1))); - const hp = Math.max(0, Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp)))); + const hp = Math.max( + 0, + Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))), + ); return { id, @@ -489,7 +495,10 @@ function normalizeNpcStates(value: unknown) { const rawStates = isObject(value) ? value : {}; return Object.fromEntries( - Object.entries(rawStates).map(([key, state]) => [key, normalizeNpcState(state)]), + Object.entries(rawStates).map(([key, state]) => [ + key, + normalizeNpcState(state), + ]), ) as Record; } @@ -672,13 +681,14 @@ function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) { } function buildBattleDisabledOption(params: { + session: RuntimeSession; functionId: string; actionText?: string; detailText?: string; reason: string; payload?: RuntimeStoryChoicePayload; }) { - return buildOptionView(params.functionId, { + return buildOptionView(params.session, params.functionId, { actionText: params.actionText, detailText: params.detailText, payload: params.payload, @@ -687,6 +697,44 @@ function buildBattleDisabledOption(params: { }); } +function buildOptionInteraction( + session: RuntimeSession, + functionId: string, +): RuntimeStoryOptionInteraction | undefined { + const encounter = session.currentEncounter; + + if (encounter?.kind === 'npc') { + const npcId = getEncounterKey(encounter); + const npcActionMap: Record = { + npc_chat: { kind: 'npc', npcId, action: 'chat' }, + npc_fight: { kind: 'npc', npcId, action: 'fight' }, + npc_help: { kind: 'npc', npcId, action: 'help' }, + npc_leave: { kind: 'npc', npcId, action: 'leave' }, + npc_preview_talk: { kind: 'npc', npcId, action: 'chat' }, + npc_recruit: { kind: 'npc', npcId, action: 'recruit' }, + npc_spar: { kind: 'npc', npcId, action: 'spar' }, + npc_trade: { kind: 'npc', npcId, action: 'trade' }, + npc_gift: { kind: 'npc', npcId, action: 'gift' }, + npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' }, + npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' }, + }; + + return npcActionMap[functionId]; + } + + if (encounter?.kind === 'treasure') { + const treasureActionMap: Record = { + treasure_secure: { kind: 'treasure', action: 'secure' }, + treasure_inspect: { kind: 'treasure', action: 'inspect' }, + treasure_leave: { kind: 'treasure', action: 'leave' }, + }; + + return treasureActionMap[functionId]; + } + + return undefined; +} + function buildBattleItemSummary( effect: NonNullable>, ) { @@ -711,44 +759,47 @@ function pickPreferredBattleItem(session: RuntimeSession) { const cooldowns = getPlayerSkillCooldowns(session); const hasCoolingSkill = Object.values(cooldowns).some((turns) => turns > 0); const playerHpRatio = session.playerHp / Math.max(session.playerMaxHp, 1); - const playerManaRatio = session.playerMana / Math.max(session.playerMaxMana, 1); + const playerManaRatio = + session.playerMana / Math.max(session.playerMaxMana, 1); - return getBattleInventoryItems(session) - .filter((item) => item.quantity > 0 && isInventoryItemUsable(item)) - .map((item) => { - const effect = resolveInventoryItemUseEffect(item, character); - if (!effect) { - return null; - } + return ( + getBattleInventoryItems(session) + .filter((item) => item.quantity > 0 && isInventoryItemUsable(item)) + .map((item) => { + const effect = resolveInventoryItemUseEffect(item, character); + if (!effect) { + return null; + } - const score = - effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) + - effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) + - effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) + - effect.buildBuffs.length * 8; + const score = + effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) + + effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) + + effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) + + effect.buildBuffs.length * 8; - return { - item, - effect, - score, - }; - }) - .filter( - ( - candidate, - ): candidate is { - item: RuntimeBattleInventoryItem; - effect: NonNullable>; - score: number; - } => Boolean(candidate), - ) - .sort( - (left, right) => - right.score - left.score || - right.effect.hpRestore - left.effect.hpRestore || - right.effect.manaRestore - left.effect.manaRestore || - left.item.name.localeCompare(right.item.name, 'zh-CN'), - )[0] ?? null; + return { + item, + effect, + score, + }; + }) + .filter( + ( + candidate, + ): candidate is { + item: RuntimeBattleInventoryItem; + effect: NonNullable>; + score: number; + } => Boolean(candidate), + ) + .sort( + (left, right) => + right.score - left.score || + right.effect.hpRestore - left.effect.hpRestore || + right.effect.manaRestore - left.effect.manaRestore || + left.item.name.localeCompare(right.item.name, 'zh-CN'), + )[0] ?? null + ); } function buildBattleSkillOptions(session: RuntimeSession) { @@ -762,7 +813,9 @@ function buildBattleSkillOptions(session: RuntimeSession) { return character.skills.map((skill) => { const remainingCooldown = cooldowns[skill.id] ?? 0; const damage = resolvePlayerOutgoingDamageResult( - session.rawGameState as Parameters[0], + session.rawGameState as Parameters< + typeof resolvePlayerOutgoingDamageResult + >[0], character, skill.damage, 1, @@ -776,6 +829,7 @@ function buildBattleSkillOptions(session: RuntimeSession) { if (remainingCooldown > 0) { return buildBattleDisabledOption({ + session, functionId: 'battle_use_skill', actionText: skill.name, detailText, @@ -786,6 +840,7 @@ function buildBattleSkillOptions(session: RuntimeSession) { if (skill.manaCost > session.playerMana) { return buildBattleDisabledOption({ + session, functionId: 'battle_use_skill', actionText: skill.name, detailText, @@ -794,7 +849,7 @@ function buildBattleSkillOptions(session: RuntimeSession) { }); } - return buildOptionView('battle_use_skill', { + return buildOptionView(session, 'battle_use_skill', { actionText: skill.name, detailText, payload: { skillId: skill.id }, @@ -807,7 +862,9 @@ function buildBattleActionOptions(session: RuntimeSession) { const itemCandidate = pickPreferredBattleItem(session); const basicAttackDamage = character ? resolvePlayerOutgoingDamageResult( - session.rawGameState as Parameters[0], + session.rawGameState as Parameters< + typeof resolvePlayerOutgoingDamageResult + >[0], character, buildBasicAttackBaseDamage(character), 1, @@ -816,30 +873,31 @@ function buildBattleActionOptions(session: RuntimeSession) { : 0; return [ - buildOptionView('battle_attack_basic', { + buildOptionView(session, 'battle_attack_basic', { detailText: basicAttackDamage > 0 ? `不耗蓝 / 伤害 ${basicAttackDamage}` : '不耗蓝的基础攻击', }), - buildOptionView('battle_recover_breath', { + buildOptionView(session, 'battle_recover_breath', { actionText: '恢复', detailText: '回血 12 / 回蓝 9 / 冷却 -1', }), itemCandidate - ? buildOptionView('inventory_use', { + ? buildOptionView(session, 'inventory_use', { actionText: `使用物品:${itemCandidate.item.name}`, detailText: buildBattleItemSummary(itemCandidate.effect), payload: { itemId: itemCandidate.item.id }, }) : buildBattleDisabledOption({ + session, functionId: 'inventory_use', actionText: '使用物品', detailText: '当前没有可直接结算的战斗消耗品', reason: '暂无可用物品', }), ...buildBattleSkillOptions(session), - buildOptionView('battle_escape_breakout'), + buildOptionView(session, 'battle_escape_breakout'), ] satisfies RuntimeStoryOptionView[]; } @@ -875,7 +933,10 @@ export function loadRuntimeSession( sceneHostileNpcs, inBattle, playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))), - playerMaxHp: Math.max(1, Math.round(readNumber(rawGameState.playerMaxHp, 1))), + playerMaxHp: Math.max( + 1, + Math.round(readNumber(rawGameState.playerMaxHp, 1)), + ), playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))), playerMaxMana: Math.max( 1, @@ -953,6 +1014,7 @@ export function setEncounterNpcState( } function buildOptionView( + session: RuntimeSession, functionId: string, overrides: Partial = {}, ): RuntimeStoryOptionView { @@ -963,6 +1025,7 @@ function buildOptionView( actionText: functionId, detailText: '', scope: 'story', + interaction: buildOptionInteraction(session, functionId), ...overrides, }; } @@ -972,6 +1035,7 @@ function buildOptionView( actionText: definition.actionText, detailText: definition.detailText, scope: definition.scope, + interaction: buildOptionInteraction(session, functionId), ...overrides, }; } @@ -1033,38 +1097,42 @@ export function buildAvailableOptions(session: RuntimeSession) { const npcState = getEncounterNpcState(session); if (session.currentEncounter.hostile) { return [ - buildOptionView('npc_fight'), - buildOptionView('npc_leave'), + buildOptionView(session, 'npc_fight'), + buildOptionView(session, 'npc_leave'), ]; } if (!session.npcInteractionActive) { return [ - buildOptionView('npc_preview_talk'), - buildOptionView('npc_fight'), - buildOptionView('npc_leave'), + buildOptionView(session, 'npc_preview_talk'), + buildOptionView(session, 'npc_fight'), + buildOptionView(session, 'npc_leave'), ]; } const activeQuest = getActiveEncounterQuest(session); const options = [ - buildOptionView('npc_chat'), - buildOptionView('npc_help', npcState?.helpUsed - ? { - disabled: true, - reason: '当前 NPC 的一次性援手已经用完了。', - } - : {}), - buildOptionView('npc_spar'), - buildOptionView('npc_fight'), + buildOptionView(session, 'npc_chat'), + buildOptionView( + session, + 'npc_help', + npcState?.helpUsed + ? { + disabled: true, + reason: '当前 NPC 的一次性援手已经用完了。', + } + : {}, + ), + buildOptionView(session, 'npc_spar'), + buildOptionView(session, 'npc_fight'), ]; if ((npcState?.inventory?.length ?? 0) > 0) { - options.push(buildOptionView('npc_trade')); + options.push(buildOptionView(session, 'npc_trade')); } if (hasGiftablePlayerInventory(session)) { - options.push(buildOptionView('npc_gift')); + options.push(buildOptionView(session, 'npc_gift')); } if ( @@ -1072,14 +1140,15 @@ export function buildAvailableOptions(session: RuntimeSession) { (activeQuest.status === 'completed' || activeQuest.status === 'ready_to_turn_in') ) { - options.push(buildOptionView('npc_quest_turn_in')); + options.push(buildOptionView(session, 'npc_quest_turn_in')); } else if (!activeQuest) { - options.push(buildOptionView('npc_quest_accept')); + options.push(buildOptionView(session, 'npc_quest_accept')); } if (npcState && !npcState.recruited && npcState.affinity >= 60) { options.push( buildOptionView( + session, 'npc_recruit', session.companions.length >= MAX_TASK5_COMPANIONS ? { @@ -1091,15 +1160,15 @@ export function buildAvailableOptions(session: RuntimeSession) { ); } - options.push(buildOptionView('npc_leave')); + options.push(buildOptionView(session, 'npc_leave')); return options; } if (session.currentEncounter?.kind === 'treasure') { return [ - buildOptionView('treasure_secure'), - buildOptionView('treasure_inspect'), - buildOptionView('treasure_leave'), + buildOptionView(session, 'treasure_secure'), + buildOptionView(session, 'treasure_inspect'), + buildOptionView(session, 'treasure_leave'), ]; } @@ -1110,7 +1179,7 @@ export function buildAvailableOptions(session: RuntimeSession) { 'idle_explore_forward', 'idle_travel_next_scene', 'story_continue_adventure', - ].map((functionId) => buildOptionView(functionId)); + ].map((functionId) => buildOptionView(session, functionId)); } function buildEncounterViewModel( @@ -1189,6 +1258,7 @@ export function buildLegacyCurrentStory( text: option.actionText, detailText: option.detailText, priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1, + interaction: option.interaction, runtimePayload: option.payload, disabled: option.disabled, disabledReason: option.reason, @@ -1221,8 +1291,10 @@ export function syncRawGameState(session: RuntimeSession) { session.rawGameState.npcStates = cloneJson(session.npcStates); session.rawGameState.companions = cloneJson(session.companions); session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode; - session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome; - session.rawGameState.currentBattleNpcId = session.currentEncounter?.id ?? null; + session.rawGameState.currentNpcBattleOutcome = + session.currentNpcBattleOutcome; + session.rawGameState.currentBattleNpcId = + session.currentEncounter?.id ?? null; session.rawGameState.activeCombatEffects = []; session.rawGameState.playerActionMode = 'idle'; session.rawGameState.scrollWorld = false; diff --git a/server-node/src/modules/story/storyActionRoutes.test.ts b/server-node/src/modules/story/storyActionRoutes.test.ts index 0d0c84e2..82a8f441 100644 --- a/server-node/src/modules/story/storyActionRoutes.test.ts +++ b/server-node/src/modules/story/storyActionRoutes.test.ts @@ -160,7 +160,15 @@ function withBearer(token: string, init: TestRequestInit = {}) { } satisfies TestRequestInit; } -async function putSnapshot(baseUrl: string, token: string, gameState: unknown) { +async function putSnapshot( + baseUrl: string, + token: string, + gameState: unknown, + currentStory: unknown = { + text: '初始化剧情', + options: [], + }, +) { const response = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, withBearer(token, { @@ -168,10 +176,7 @@ async function putSnapshot(baseUrl: string, token: string, gameState: unknown) { body: JSON.stringify({ gameState, bottomTab: 'adventure', - currentStory: { - text: '初始化剧情', - options: [], - }, + currentStory, }), }), ); @@ -240,6 +245,73 @@ function createTask6GameState(overrides: Record = {}) { }; } +function createPendingQuestOfferCurrentStory(quest: Record) { + return { + text: '巡路人终于把真正的委托说了出来。', + options: [ + { + functionId: 'npc_chat_quest_offer_view', + actionText: '查看任务', + text: '查看任务', + detailText: '', + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + { + functionId: 'npc_chat_quest_offer_replace', + actionText: '更换任务', + text: '更换任务', + detailText: '', + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + { + functionId: 'npc_chat_quest_offer_abandon', + actionText: '放弃任务', + text: '放弃任务', + detailText: '', + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + ], + displayMode: 'dialogue', + dialogue: [ + { + speaker: 'npc', + speakerName: '巡路人', + text: '这件事我只想托给你。', + }, + ], + npcChatState: { + npcId: 'npc_scout_01', + npcName: '巡路人', + turnCount: 2, + customInputPlaceholder: '输入你想对 TA 说的话', + pendingQuestOffer: { + quest, + }, + }, + }; +} + const QUEST_BATTLE_SCENE = { id: 'quest-bridge', name: '断桥口', @@ -325,6 +397,11 @@ test('runtime story actions resolve npc chat on the server and persist updated a } | null; availableOptions: Array<{ functionId: string; + interaction?: { + kind: string; + npcId?: string; + action?: string; + }; }>; }; presentation: { @@ -344,15 +421,28 @@ test('runtime story actions resolve npc chat on the server and persist updated a (option) => option.functionId === 'npc_help', ), ); + assert.deepEqual( + payload.viewModel.availableOptions.find( + (option) => option.functionId === 'npc_help', + )?.interaction, + { + kind: 'npc', + npcId: 'npc_merchant_01', + action: 'help', + }, + ); assert.ok( payload.patches.some((patch) => patch.type === 'npc_affinity_changed'), ); - const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { - headers: { - Authorization: `Bearer ${entry.token}`, + const snapshotResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, }, - }); + ); const snapshotPayload = (await snapshotResponse.json()) as { gameState: { runtimeActionVersion: number; @@ -369,14 +459,95 @@ test('runtime story actions resolve npc chat on the server and persist updated a assert.equal(snapshotResponse.status, 200); assert.equal(snapshotPayload.gameState.runtimeActionVersion, 1); - assert.equal(snapshotPayload.gameState.npcStates.npc_merchant_01.affinity, 52); + assert.equal( + snapshotPayload.gameState.npcStates.npc_merchant_01.affinity, + 52, + ); assert.match(snapshotPayload.currentStory.text, /沈七/u); }); }); +test('runtime story state exposes npc interaction metadata directly from the server option builder', async () => { + await withTestServer('npc-state-options', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_npc_state', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_merchant_01', + npcName: '沈七', + npcDescription: '腰间挂着药囊的行商', + context: '受伤行商', + }, + npcInteractionActive: true, + npcStates: { + npc_merchant_01: { + affinity: 46, + chattedCount: 1, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/state/runtime-main`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const payload = (await response.json()) as { + viewModel: { + availableOptions: Array<{ + functionId: string; + interaction?: { + kind: string; + npcId?: string; + action?: string; + }; + }>; + }; + }; + + assert.equal(response.status, 200); + assert.deepEqual( + payload.viewModel.availableOptions.find( + (option) => option.functionId === 'npc_chat', + )?.interaction, + { + kind: 'npc', + npcId: 'npc_merchant_01', + action: 'chat', + }, + ); + assert.deepEqual( + payload.viewModel.availableOptions.find( + (option) => option.functionId === 'npc_help', + )?.interaction, + { + kind: 'npc', + npcId: 'npc_merchant_01', + action: 'help', + }, + ); + }); +}); + test('runtime story actions resolve combat finishers on the server and collapse the battle state', async () => { await withTestServer('combat-finisher', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123'); + const entry = await authEntry( + baseUrl, + 'story_combat_finisher', + 'secret123', + ); await putSnapshot( baseUrl, @@ -457,7 +628,10 @@ test('runtime story actions resolve combat finishers on the server and collapse assert.equal(response.status, 200); assert.equal(payload.viewModel.encounter, null); assert.equal(payload.viewModel.status.inBattle, false); - assert.equal(payload.viewModel.status.currentNpcBattleOutcome, 'fight_victory'); + assert.equal( + payload.viewModel.status.currentNpcBattleOutcome, + 'fight_victory', + ); assert.equal(payload.presentation.battle?.outcome, 'victory'); assert.ok((payload.presentation.battle?.damageDealt ?? 0) >= 12); assert.ok( @@ -466,11 +640,14 @@ test('runtime story actions resolve combat finishers on the server and collapse ), ); - const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { - headers: { - Authorization: `Bearer ${entry.token}`, + const snapshotResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, }, - }); + ); const snapshotPayload = (await snapshotResponse.json()) as { gameState: { inBattle: boolean; @@ -484,7 +661,10 @@ test('runtime story actions resolve combat finishers on the server and collapse assert.equal(snapshotPayload.gameState.inBattle, false); assert.equal(snapshotPayload.gameState.currentEncounter, null); assert.deepEqual(snapshotPayload.gameState.sceneHostileNpcs, []); - assert.equal(snapshotPayload.gameState.currentNpcBattleOutcome, 'fight_victory'); + assert.equal( + snapshotPayload.gameState.currentNpcBattleOutcome, + 'fight_victory', + ); }); }); @@ -634,7 +814,11 @@ test('runtime story state exposes the single-action combat option pool with runt test('runtime story actions resolve battle_use_skill as a single ongoing combat turn', async () => { await withTestServer('combat-use-skill', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_combat_use_skill', 'secret123'); + const entry = await authEntry( + baseUrl, + 'story_combat_use_skill', + 'secret123', + ); const playerCharacter = { ...requirePlayerCharacter(), skills: [ @@ -768,13 +952,19 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat assert.equal(payload.serverVersion, 1); assert.equal(payload.presentation.battle?.outcome, 'ongoing'); assert.ok((payload.presentation.battle?.damageDealt ?? 0) > 0); - assert.equal(payload.presentation.storyText, payload.presentation.resultText); + assert.equal( + payload.presentation.storyText, + payload.presentation.resultText, + ); assert.match(payload.presentation.storyText, /试锋斩/u); assert.equal(payload.viewModel.status.inBattle, true); assert.equal(payload.viewModel.player.mana, 5); assert.equal(payload.snapshot.gameState.playerMana, 5); assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 2); - assert.equal(payload.snapshot.gameState.activeBuildBuffs[0]?.id, 'slash:buff'); + assert.equal( + payload.snapshot.gameState.activeBuildBuffs[0]?.id, + 'slash:buff', + ); assert.ok( payload.patches.some( (patch) => @@ -797,7 +987,11 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat test('runtime story actions resolve inventory_use and persist updated resources', async () => { await withTestServer('task6-inventory-use', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123'); + const entry = await authEntry( + baseUrl, + 'story_task6_inventory', + 'secret123', + ); await putSnapshot( baseUrl, @@ -957,14 +1151,24 @@ test('runtime story actions resolve equipment_equip and persist updated loadout' assert.ok(payload.viewModel.player.maxHp > 40); assert.match(payload.presentation.storyText, /镇岳甲/u); assert.equal(payload.snapshot.gameState.playerInventory.length, 0); - assert.equal(payload.snapshot.gameState.playerEquipment.armor?.id, 'ward-mail'); - assert.equal(payload.snapshot.gameState.playerEquipment.armor?.name, '镇岳甲'); + assert.equal( + payload.snapshot.gameState.playerEquipment.armor?.id, + 'ward-mail', + ); + assert.equal( + payload.snapshot.gameState.playerEquipment.armor?.name, + '镇岳甲', + ); }); }); test('runtime story actions resolve npc_trade buy transactions on the server', async () => { await withTestServer('task6-trade-buy', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_task6_trade_buy', 'secret123'); + const entry = await authEntry( + baseUrl, + 'story_task6_trade_buy', + 'secret123', + ); await putSnapshot( baseUrl, @@ -1044,7 +1248,8 @@ test('runtime story actions resolve npc_trade buy transactions on the server', a assert.equal(payload.snapshot.gameState.playerInventory[0]?.name, '回气散'); assert.equal(payload.snapshot.gameState.playerInventory[0]?.quantity, 2); assert.equal( - payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0]?.quantity, + payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0] + ?.quantity, 1, ); }); @@ -1124,15 +1329,26 @@ test('runtime story actions resolve npc_gift and persist affinity changes', asyn assert.equal(response.status, 200); assert.equal(payload.snapshot.gameState.playerInventory.length, 0); - assert.ok(payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22); - assert.equal(payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven, 1); - assert.ok(payload.patches.some((patch) => patch.type === 'npc_affinity_changed')); + assert.ok( + payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22, + ); + assert.equal( + payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven, + 1, + ); + assert.ok( + payload.patches.some((patch) => patch.type === 'npc_affinity_changed'), + ); }); }); test('runtime story actions resolve npc_quest_accept and persist accepted quests', async () => { await withTestServer('task6-quest-accept', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_task6_quest_accept', 'secret123'); + const entry = await authEntry( + baseUrl, + 'story_task6_quest_accept', + 'secret123', + ); await putSnapshot( baseUrl, @@ -1191,13 +1407,129 @@ test('runtime story actions resolve npc_quest_accept and persist accepted quests assert.equal(response.status, 200); assert.equal(payload.snapshot.gameState.quests.length, 1); - assert.equal(payload.snapshot.gameState.quests[0]?.issuerNpcId, 'npc_scout_01'); + assert.equal( + payload.snapshot.gameState.quests[0]?.issuerNpcId, + 'npc_scout_01', + ); assert.equal(payload.snapshot.gameState.quests[0]?.status, 'active'); assert.equal(payload.snapshot.gameState.runtimeStats.questsAccepted, 1); assert.match(payload.presentation.storyText, /正式把委托交到了你手上/u); }); }); +test('runtime story actions accept pending npc quest offers from saved chat state', async () => { + await withTestServer('task6-quest-accept-pending-offer', async ({ baseUrl }) => { + const entry = await authEntry( + baseUrl, + 'story_q_accept_pending', + 'secret123', + ); + const seededQuest = buildQuestForEncounter({ + issuerNpcId: 'npc_scout_01', + issuerNpcName: '巡路人', + roleText: '巡路人', + scene: QUEST_BATTLE_SCENE, + worldType: 'WUXIA', + currentQuests: [], + }); + assert.ok(seededQuest); + const pendingQuest = { + ...seededQuest, + id: 'quest-pending-offer', + }; + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_scout_01', + npcName: '巡路人', + npcDescription: '熟悉桥口风向的探子', + context: '巡路人', + characterId: 'scout-quest', + }, + currentScenePreset: QUEST_BATTLE_SCENE, + npcInteractionActive: true, + npcStates: { + npc_scout_01: { + affinity: 16, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + }), + createPendingQuestOfferCurrentStory(pendingQuest), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_quest_accept', + }, + }), + }), + ); + const payload = (await response.json()) as { + snapshot: { + gameState: { + quests: Array<{ id: string; issuerNpcId: string; status: string }>; + }; + currentStory: { + displayMode?: string; + options?: Array<{ actionText?: string }>; + dialogue?: Array<{ speaker?: string; text?: string }>; + npcChatState?: { + pendingQuestOffer?: unknown; + }; + }; + }; + }; + + assert.equal(response.status, 200); + assert.equal(payload.snapshot.gameState.quests.length, 1); + assert.equal( + payload.snapshot.gameState.quests[0]?.id, + 'quest-pending-offer', + ); + assert.equal( + payload.snapshot.gameState.quests[0]?.issuerNpcId, + 'npc_scout_01', + ); + assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue'); + assert.equal( + payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null, + null, + ); + assert.deepEqual( + payload.snapshot.currentStory.options?.map((option) => option.actionText), + [ + '这件事里你最担心哪一步', + '我回来时你最想先知道什么', + '除了这份委托,你还想提醒我什么', + ], + ); + assert.equal( + payload.snapshot.currentStory.dialogue?.at(-2)?.text, + '这件事我愿意接下,你把关键要点交给我。', + ); + assert.match( + payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '', + /那就拜托你了。/u, + ); + }); +}); + test('runtime story actions progress quests from combat victories and npc turn-ins', async () => { await withTestServer('task6-quest-progress-turnin', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_qp_turnin', 'secret123'); @@ -1357,10 +1689,15 @@ test('runtime story actions progress quests from combat victories and npc turn-i }; assert.equal(turnInResponse.status, 200); - assert.equal(turnInPayload.snapshot.gameState.quests[0]?.status, 'turned_in'); + assert.equal( + turnInPayload.snapshot.gameState.quests[0]?.status, + 'turned_in', + ); assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12); assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0); - assert.ok(turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6); + assert.ok( + turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6, + ); }); }); diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/story/storyActionService.ts index 1cb41455..c12c731a 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/story/storyActionService.ts @@ -14,16 +14,17 @@ import { } from '../ai/chatPromptBuilders.js'; import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js'; import { resolveCombatAction } from '../combat/combatResolutionService.js'; -import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventoryStoryActionService.js'; +import { + isSupportedInventoryStoryFunctionId, + resolveInventoryStoryAction, +} from '../inventory/inventoryStoryActionService.js'; import { ensureNpcInventorySessionState, isSupportedNpcInventoryStoryFunctionId, resolveNpcInventoryStoryAction, } from '../inventory/npcInventoryStoryActionService.js'; import { resolveNpcInteraction } from '../npc/npcInteractionService.js'; -import { - applyQuestSignalsForResolvedAction, -} from '../quest/questRuntimeSignalService.js'; +import { applyQuestSignalsForResolvedAction } from '../quest/questRuntimeSignalService.js'; import { isSupportedQuestStoryFunctionId, resolveQuestStoryAction, @@ -92,7 +93,10 @@ const DEFAULT_STORY_OPTION_VISUALS = { monsterChanges: [], } as const; -function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) { +function resolveActionText( + defaultText: string, + request: RuntimeStoryActionRequest, +) { const payload = request.action.payload; const optionText = payload && typeof payload.optionText === 'string' @@ -114,54 +118,14 @@ function readArray(value: unknown) { return Array.isArray(value) ? value : []; } -function buildStoryOptionInteraction( - session: RuntimeSession, - option: RuntimeStoryOptionView, -) { - const encounter = session.currentEncounter; - - if (encounter?.kind === 'npc') { - const npcId = encounter.id || encounter.npcName; - const npcActionMap: Record = { - npc_chat: { kind: 'npc', npcId, action: 'chat' }, - npc_help: { kind: 'npc', npcId, action: 'help' }, - npc_fight: { kind: 'npc', npcId, action: 'fight' }, - npc_leave: { kind: 'npc', npcId, action: 'leave' }, - npc_recruit: { kind: 'npc', npcId, action: 'recruit' }, - npc_spar: { kind: 'npc', npcId, action: 'spar' }, - npc_trade: { kind: 'npc', npcId, action: 'trade' }, - npc_gift: { kind: 'npc', npcId, action: 'gift' }, - npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' }, - npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' }, - }; - - return npcActionMap[option.functionId]; - } - - if (encounter?.kind === 'treasure') { - const treasureActionMap: Record = { - treasure_secure: { kind: 'treasure', action: 'secure' }, - treasure_inspect: { kind: 'treasure', action: 'inspect' }, - treasure_leave: { kind: 'treasure', action: 'leave' }, - }; - - return treasureActionMap[option.functionId]; - } - - return undefined; -} - -function buildStoryOptionFromRuntimeOption( - session: RuntimeSession, - option: RuntimeStoryOptionView, -) { +function buildStoryOptionFromRuntimeOption(option: RuntimeStoryOptionView) { return { functionId: option.functionId, actionText: option.actionText, text: option.actionText, detailText: option.detailText, visuals: DEFAULT_STORY_OPTION_VISUALS, - interaction: buildStoryOptionInteraction(session, option), + interaction: option.interaction, runtimePayload: option.payload, disabled: option.disabled, disabledReason: option.reason, @@ -169,10 +133,10 @@ function buildStoryOptionFromRuntimeOption( } function buildStoryOptionsFromRuntimeOptions( - session: RuntimeSession, + _session: RuntimeSession, options: RuntimeStoryOptionView[], ) { - return options.map((option) => buildStoryOptionFromRuntimeOption(session, option)); + return options.map((option) => buildStoryOptionFromRuntimeOption(option)); } function escapeRegExp(value: string) { @@ -284,10 +248,9 @@ function buildDialogueCurrentStory(params: { }) { return { text: params.text, - options: buildStoryOptionsFromRuntimeOptions( - params.session, - [CONTINUE_ADVENTURE_OPTION], - ), + options: buildStoryOptionsFromRuntimeOptions(params.session, [ + CONTINUE_ADVENTURE_OPTION, + ]), displayMode: 'dialogue', dialogue: parseDialogueTurns(params.text, params.npcName), streaming: false, @@ -298,7 +261,150 @@ function buildDialogueCurrentStory(params: { } satisfies JsonRecord; } -function buildStoryPromptContext(session: RuntimeSession, extras: JsonRecord = {}) { +function readDialogueTurns(currentStory: unknown) { + if (!isObject(currentStory) || !Array.isArray(currentStory.dialogue)) { + return []; + } + + return currentStory.dialogue.filter(isObject).map((turn) => ({ ...turn })); +} + +function readPendingQuestOfferContext(params: { + currentStory: unknown; + encounterId: string; +}) { + if (!isObject(params.currentStory)) { + return null; + } + + const npcChatState = isObject(params.currentStory.npcChatState) + ? params.currentStory.npcChatState + : null; + const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer) + ? npcChatState.pendingQuestOffer + : null; + const quest = isObject(pendingQuestOffer?.quest) + ? pendingQuestOffer.quest + : null; + + if (!quest) { + return null; + } + + const npcId = readString(npcChatState?.npcId); + if (npcId && npcId !== params.encounterId) { + return null; + } + + return { + dialogue: readDialogueTurns(params.currentStory), + turnCount: + typeof npcChatState?.turnCount === 'number' && + Number.isFinite(npcChatState.turnCount) + ? Math.max(0, Math.round(npcChatState.turnCount)) + : 0, + customInputPlaceholder: + readString(npcChatState?.customInputPlaceholder) || + '输入你想对 TA 说的话', + quest, + }; +} + +function buildNpcChatSuggestionOption( + encounter: RuntimeSession['currentEncounter'] & { kind: 'npc' }, + actionText: string, +) { + return buildStoryOptionFromRuntimeOption({ + functionId: 'npc_chat', + actionText, + detailText: '', + scope: 'npc', + interaction: { + kind: 'npc', + npcId: encounter.id, + action: 'chat', + }, + }); +} + +function buildQuestAcceptedNpcReplyText(quest: JsonRecord) { + const activeStepId = readString(quest.activeStepId); + const activeStep = readArray(quest.steps) + .filter(isObject) + .find((step) => readString(step.id) === activeStepId); + const revealText = readString(activeStep?.revealText); + const summary = readString(quest.summary); + + if (revealText) { + return `那就拜托你了。${revealText}`; + } + + return `那就拜托你了。${summary || '这份委托的关键要点我已经交给你。'}`; +} + +function buildQuestAcceptedSuggestionOptions( + encounter: RuntimeSession['currentEncounter'] & { kind: 'npc' }, +) { + return [ + '这件事里你最担心哪一步', + '我回来时你最想先知道什么', + '除了这份委托,你还想提醒我什么', + ].map((actionText) => buildNpcChatSuggestionOption(encounter, actionText)); +} + +function buildPendingQuestAcceptedCurrentStory(params: { + session: RuntimeSession; + currentStory: unknown; +}) { + const encounter = params.session.currentEncounter; + if (!encounter || encounter.kind !== 'npc') { + return null; + } + + const pendingOffer = readPendingQuestOfferContext({ + currentStory: params.currentStory, + encounterId: encounter.id, + }); + if (!pendingOffer) { + return null; + } + + const dialogue = [ + ...pendingOffer.dialogue, + { + speaker: 'player', + text: '这件事我愿意接下,你把关键要点交给我。', + }, + { + speaker: 'npc', + speakerName: encounter.npcName, + text: buildQuestAcceptedNpcReplyText(pendingOffer.quest), + }, + ]; + + return { + text: dialogue + .map((turn) => readString(turn.text)) + .filter(Boolean) + .join('\n'), + options: buildQuestAcceptedSuggestionOptions(encounter), + displayMode: 'dialogue', + dialogue, + streaming: false, + npcChatState: { + npcId: encounter.id, + npcName: encounter.npcName, + turnCount: pendingOffer.turnCount, + customInputPlaceholder: pendingOffer.customInputPlaceholder, + pendingQuestOffer: null, + }, + } satisfies JsonRecord; +} + +function buildStoryPromptContext( + session: RuntimeSession, + extras: JsonRecord = {}, +) { const scenePreset = isObject(session.rawGameState.currentScenePreset) ? session.rawGameState.currentScenePreset : null; @@ -409,11 +515,19 @@ function buildOpeningCampChatContext(session: RuntimeSession) { for (let index = 0; index < session.storyHistory.length - 1; index += 1) { const entry = session.storyHistory[index]; - if (!entry || entry.historyRole !== 'action' || entry.text !== openingActionText) { + if ( + !entry || + entry.historyRole !== 'action' || + entry.text !== openingActionText + ) { continue; } - for (let nextIndex = index + 1; nextIndex < session.storyHistory.length; nextIndex += 1) { + for ( + let nextIndex = index + 1; + nextIndex < session.storyHistory.length; + nextIndex += 1 + ) { const nextEntry = session.storyHistory[nextIndex]; if (!nextEntry) { continue; @@ -517,7 +631,12 @@ async function generateNpcDialoguePayload(params: { const playerCharacter = isObject(params.session.rawGameState.playerCharacter) ? params.session.rawGameState.playerCharacter : null; - if (!encounter || encounter.kind !== 'npc' || !playerCharacter || !params.session.worldType) { + if ( + !encounter || + encounter.kind !== 'npc' || + !playerCharacter || + !params.session.worldType + ) { return null; } @@ -533,7 +652,9 @@ async function generateNpcDialoguePayload(params: { worldType: params.session.worldType, character: playerCharacter, encounter: params.session.rawGameState.currentEncounter ?? {}, - monsters: readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject), + monsters: readArray( + params.session.rawGameState.sceneHostileNpcs, + ).filter(isObject), history, context: buildStoryPromptContext(params.session, { ...buildOpeningCampChatContext(params.session), @@ -637,7 +758,8 @@ function resolveStoryFlowAction( case 'story_continue_adventure': return { actionText: '继续推进冒险', - resultText: '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。', + resultText: + '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。', patches: [normalizeStatusPatch(session)], }; case 'story_opening_camp_dialogue': { @@ -669,7 +791,8 @@ function resolveStoryFlowAction( return { actionText: '交换开场判断', - resultText: '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。', + resultText: + '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。', patches: [normalizeStatusPatch(session)], }; } @@ -677,7 +800,8 @@ function resolveStoryFlowAction( clearEncounterState(session); return { actionText: '返回营地', - resultText: '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。', + resultText: + '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。', patches: [ normalizeStatusPatch(session), { @@ -689,13 +813,15 @@ function resolveStoryFlowAction( case 'idle_call_out': return { actionText: '主动出声试探', - resultText: '你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。', + resultText: + '你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。', patches: [normalizeStatusPatch(session)], }; case 'idle_explore_forward': return { actionText: '继续向前探索', - resultText: '你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。', + resultText: + '你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。', patches: [normalizeStatusPatch(session)], }; case 'idle_observe_signs': @@ -706,7 +832,10 @@ function resolveStoryFlowAction( }; case 'idle_rest_focus': session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 8); - session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 6); + session.playerMana = Math.min( + session.playerMaxMana, + session.playerMana + 6, + ); return { actionText: '原地调息', resultText: '你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。', @@ -804,7 +933,9 @@ export async function resolveRuntimeStoryAction(params: { } else if (isSupportedNpcInventoryStoryFunctionId(functionId)) { resolution = resolveNpcInventoryStoryAction(session, params.request); } else if (isSupportedQuestStoryFunctionId(functionId)) { - resolution = resolveQuestStoryAction(session, params.request); + resolution = resolveQuestStoryAction(session, params.request, { + currentStory: hydratedSnapshot.currentStory, + }); } else if (isSupportedTreasureStoryFunctionId(functionId)) { resolution = resolveTreasureStoryAction(session, params.request); } else if (isStoryFunctionId(functionId)) { @@ -836,9 +967,21 @@ export async function resolveRuntimeStoryAction(params: { syncRawGameState(session); ensureNpcInventorySessionState(session); let options = buildAvailableOptions(session); - let savedCurrentStory: JsonRecord = buildLegacyCurrentStory(storyText, options); + let savedCurrentStory: JsonRecord = buildLegacyCurrentStory( + storyText, + options, + ); + const pendingQuestAcceptedCurrentStory = + functionId === 'npc_quest_accept' + ? buildPendingQuestAcceptedCurrentStory({ + session, + currentStory: hydratedSnapshot.currentStory, + }) + : null; - if ( + if (pendingQuestAcceptedCurrentStory) { + savedCurrentStory = pendingQuestAcceptedCurrentStory; + } else if ( params.llmClient && (functionId === 'npc_chat' || functionId === 'story_opening_camp_dialogue') ) { diff --git a/server-node/src/prompts/characterAssetPrompts.ts b/server-node/src/prompts/characterAssetPrompts.ts new file mode 100644 index 00000000..0d173694 --- /dev/null +++ b/server-node/src/prompts/characterAssetPrompts.ts @@ -0,0 +1,379 @@ +import { + buildMasterPrompt, + buildVideoActionPrompt, + getActionTemplateById, +} from '../../../packages/shared/src/prompts/qwenSprite.js'; + +function clampPromptSeedText(value: unknown, maxLength: number) { + if (typeof value !== 'string') { + return ''; + } + + return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength); +} + +export const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。 +你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。 +你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。 +输出格式必须严格为: +{ + "visualPromptText": "角色主图提示词", + "animationPromptText": "角色动作提示词", + "scenePromptText": "角色关联场景提示词" +} + +硬性约束: +- 所有字段都必须是自然中文。 +- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。 +- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。 +- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。 +- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。 +- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。 +- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`; + +export type CharacterPromptBundle = { + visualPromptText: string; + animationPromptText: string; + scenePromptText: string; + source: 'llm' | 'fallback'; + model: string | null; +}; + +export function buildFallbackCharacterPromptBundle(params: { + characterName: string; + roleKind: string; + roleTitle: string; + roleLabel: string; + description: string; + backstory: string; + personality: string; + motivation: string; + combatStyle: string; + tags: string[]; +}) { + const roleAnchor = + [params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') || + (params.roleKind === 'playable' ? '可扮演角色' : '场景角色'); + const characterAnchor = params.characterName || '该角色'; + const descriptionAnchor = + params.description || params.backstory || params.personality || '气质鲜明'; + const combatAnchor = + params.combatStyle || params.motivation || '动作发力清晰'; + const tagAnchor = + params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : ''; + + return { + visualPromptText: [ + `${characterAnchor},${roleAnchor}。`, + '单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。', + `外观气质围绕:${descriptionAnchor}。`, + combatAnchor ? `战斗识别点:${combatAnchor}。` : '', + tagAnchor, + '背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。', + ] + .filter(Boolean) + .join(' '), + animationPromptText: [ + `${characterAnchor}的核心动作试片。`, + '保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。', + combatAnchor ? `动作气质参考:${combatAnchor}。` : '', + params.personality ? `角色气质补充:${params.personality}。` : '', + '发力起手明确,过程干净,收招利落,避免漂移和变形。', + ] + .filter(Boolean) + .join(' '), + scenePromptText: [ + `${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`, + '16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。', + `场景叙事气质围绕:${descriptionAnchor}。`, + params.backstory ? `背景线索可参考:${params.backstory}。` : '', + params.motivation + ? `环境中可埋入与当前目标相关的暗示:${params.motivation}。` + : '', + '整体风格克制统一,适合剧情探索与战斗底图。', + ] + .filter(Boolean) + .join(' '), + source: 'fallback' as const, + model: null, + }; +} + +function sanitizePromptBundleValue( + value: unknown, + fallback: string, + maxLength: number, +) { + const normalized = clampPromptSeedText(value, maxLength); + return normalized || fallback; +} + +export function sanitizeCharacterPromptBundle( + value: unknown, + fallback: CharacterPromptBundle, + model: string, +) { + const record = isRecordValue(value) ? value : {}; + + return { + visualPromptText: sanitizePromptBundleValue( + record.visualPromptText, + fallback.visualPromptText, + 280, + ), + animationPromptText: sanitizePromptBundleValue( + record.animationPromptText, + fallback.animationPromptText, + 280, + ), + scenePromptText: sanitizePromptBundleValue( + record.scenePromptText, + fallback.scenePromptText, + 320, + ), + source: 'llm' as const, + model: model.trim() || null, + }; +} + +function sanitizeAnimationPromptText(value: string, maxLength: number) { + return value + .replace(/\s+/gu, ' ') + .replace(/血浆|喷血|鲜血|断肢|斩首|裸体|裸露|色情|性交/gu, '') + .replace(/死亡|死去|击杀/gu, '倒地结束') + .replace(/受击|受伤/gu, '失衡') + .replace(/砍杀|斩击/gu, '挥击') + .trim() + .slice(0, maxLength); +} + +function buildCompactAnimationCharacterBrief(value: string) { + const normalized = sanitizeAnimationPromptText(value, 160); + if (!normalized) { + return ''; + } + + return normalized + .split(/[/|\n,,。;;]+/u) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 4) + .join(','); +} + +export function buildCharacterPromptBundleUserPrompt(params: { + roleKind: string; + characterBriefText: string; + characterName: string; + roleTitle: string; + roleLabel: string; + description: string; + backstory: string; + personality: string; + motivation: string; + combatStyle: string; + tags: string[]; +}) { + return [ + '请根据下面的角色卡摘要,编译一组默认资产提示词。', + '提示词用于当前项目的角色主图、动作试片和角色关联场景背景。', + '请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。', + '', + `角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`, + params.characterName ? `角色名称:${params.characterName}` : '', + params.roleTitle ? `角色头衔:${params.roleTitle}` : '', + params.roleLabel ? `世界身份:${params.roleLabel}` : '', + params.description ? `角色描述:${params.description}` : '', + params.backstory ? `角色背景:${params.backstory}` : '', + params.personality ? `角色性格:${params.personality}` : '', + params.motivation ? `角色动机:${params.motivation}` : '', + params.combatStyle ? `战斗风格:${params.combatStyle}` : '', + params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '', + '', + '角色卡全文:', + params.characterBriefText, + ] + .filter(Boolean) + .join('\n'); +} + +export function buildNpcVisualPrompt(promptText: string, characterBriefText = '') { + const mergedBrief = [characterBriefText.trim(), promptText.trim()] + .filter(Boolean) + .join('\n'); + + return buildMasterPrompt( + mergedBrief || '自定义世界角色,服装完整,姿态自然。', + ); +} + +export function buildNpcVisualNegativePrompt() { + return [ + '正面视角', + '左朝向', + '完全 90 度纯右视图', + '镜头透视', + '半身像', + '脚被裁切', + '头顶被裁切', + '多角色', + '复杂背景', + '建筑场景', + '漂浮物', + '烟雾环境', + '武器消失', + '武器换手', + '额外手臂', + '额外腿', + '服装变化', + '脸部变化', + '模糊', + '运动模糊', + '文字', + '水印', + 'UI 元素', + '软萌 Q版大头贴', + '儿童绘本风', + '厚涂插画感', + '低对比柔边', + ].join(','); +} + +export function buildImageSequencePrompt( + animation: string, + promptText: string, + frameCount: number, + useChromaKey: boolean, +) { + return [ + `同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`, + '固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。', + '帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。', + useChromaKey + ? '纯绿色背景,无地面装饰,方便后期抠像。' + : '背景尽量纯净,避免复杂场景。', + promptText.trim(), + ] + .filter(Boolean) + .join(' '); +} + +export function buildNpcAnimationPrompt(options: { + animation: string; + promptText: string; + useChromaKey: boolean; + loop: boolean; + characterBriefText?: string; + actionTemplateId?: string; +}) { + const characterBrief = buildCompactAnimationCharacterBrief( + options.characterBriefText ?? '', + ); + const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); + const loopRule = options.loop + ? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。' + : options.animation === 'die' + ? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。' + : '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。'; + + if (options.actionTemplateId) { + return [ + buildVideoActionPrompt({ + actionTemplate: getActionTemplateById( + options.actionTemplateId as Parameters< + typeof getActionTemplateById + >[0], + ), + actionDetailText, + useChromaKey: options.useChromaKey, + characterBrief: characterBrief || `${options.animation} 动作角色`, + }), + loopRule, + ] + .filter(Boolean) + .join(' '); + } + + return [ + `单人 NPC 全身动作视频,动作主题是 ${options.animation}。`, + '角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', + '动作连贯,避免服装、发型、面部、武器随机漂移。', + options.useChromaKey + ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' + : '背景简洁纯净,无复杂场景。', + characterBrief ? `角色设定:${characterBrief}` : '', + actionDetailText, + loopRule, + ] + .filter(Boolean) + .join(' '); +} + +export function buildArkCharacterAnimationPrompt(options: { + animation: string; + promptText: string; + useChromaKey: boolean; + loop: boolean; + characterBriefText?: string; + actionTemplateId?: string; +}) { + const normalizedAnimationName = + options.animation.trim().replace(/\s+/gu, '_') || 'idle'; + const characterBrief = buildCompactAnimationCharacterBrief( + options.characterBriefText ?? '', + ); + const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); + const frameRule = options.loop + ? '首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。' + : '首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。'; + + if (options.actionTemplateId) { + return [ + buildVideoActionPrompt({ + actionTemplate: getActionTemplateById( + options.actionTemplateId as Parameters[0], + ), + actionDetailText, + useChromaKey: options.useChromaKey, + characterBrief: characterBrief || `${normalizedAnimationName} action role`, + }), + `动作英文名:${normalizedAnimationName}。`, + frameRule, + ] + .filter(Boolean) + .join(' '); + } + + return [ + `单人 NPC 全身动作视频,动作英文名是 ${normalizedAnimationName}。`, + '角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', + '动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。', + options.useChromaKey + ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' + : '背景简洁纯净,无复杂场景。', + characterBrief ? `角色设定:${characterBrief}` : '', + actionDetailText ? `动作细节:${actionDetailText}` : '', + frameRule, + ] + .filter(Boolean) + .join(' '); +} + +export function buildFallbackModerationSafeAnimationPrompt(options: { + animation: string; + loop: boolean; + useChromaKey: boolean; +}) { + return [ + `单人全身角色动作视频,动作主题是 ${options.animation}。`, + '角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。', + options.loop + ? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。' + : '非循环动作首尾回到角色标准站姿,中段完成动作变化。', + options.useChromaKey + ? '背景为纯绿色绿幕,无其他人物和场景元素。' + : '背景简洁纯净。', + ] + .filter(Boolean) + .join(' '); +} diff --git a/server-node/src/prompts/customWorldPrompts.ts b/server-node/src/prompts/customWorldPrompts.ts new file mode 100644 index 00000000..d1d4eb29 --- /dev/null +++ b/server-node/src/prompts/customWorldPrompts.ts @@ -0,0 +1,645 @@ +import type { + CustomWorldGenerationFramework, + CustomWorldGenerationLandmarkOutline, + CustomWorldGenerationRoleBatchStage, + CustomWorldGenerationRoleBatchType, + CustomWorldGenerationRoleOutline, + CustomWorldLandmark, + CustomWorldProfile, +} from '../modules/custom-world/runtimeTypes.js'; + +const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; +const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES = [15, 30, 60, 90] as const; +const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS = [ + 'forward', + 'back', + 'left', + 'right', + 'north', + 'south', + 'east', + 'west', + 'up', + 'down', + 'inside', + 'outside', + 'portal', +] as const; + +function buildFrameworkSummaryText( + framework: CustomWorldGenerationFramework, + options: { + maxLandmarks?: number; + } = {}, +) { + const maxLandmarks = options.maxLandmarks ?? MIN_CUSTOM_WORLD_LANDMARK_COUNT; + const landmarkText = framework.landmarks + .slice(0, maxLandmarks) + .map( + (landmark) => + `${landmark.name}(${landmark.dangerLevel},${landmark.description})`, + ) + .join('、'); + + return [ + `世界:${framework.name}`, + `副标题:${framework.subtitle}`, + `世界概述:${framework.summary}`, + `世界基调:${framework.tone}`, + `玩家核心目标:${framework.playerGoal}`, + framework.majorFactions.length > 0 + ? `主要势力:${framework.majorFactions.join('、')}` + : '', + framework.coreConflicts.length > 0 + ? `核心冲突:${framework.coreConflicts.join('、')}` + : '', + `开局归处:${framework.camp.name}(${framework.camp.description})`, + landmarkText ? `关键场景:${landmarkText}` : '', + ] + .filter(Boolean) + .join('\n'); +} + +function buildLandmarkAppearanceLookup( + framework: CustomWorldGenerationFramework, +) { + const lookup = new Map(); + + framework.landmarks.forEach((landmark) => { + landmark.sceneNpcNames.forEach((npcName) => { + const key = npcName.trim(); + if (!key) { + return; + } + const current = lookup.get(key) ?? []; + if (!current.includes(landmark.name)) { + current.push(landmark.name); + } + lookup.set(key, current); + }); + }); + + return lookup; +} + +function buildRoleOutlinePromptLines( + roleBatch: CustomWorldGenerationRoleOutline[], + options: { + framework: CustomWorldGenerationFramework; + roleType: CustomWorldGenerationRoleBatchType; + }, +) { + const appearanceLookup = + options.roleType === 'story' + ? buildLandmarkAppearanceLookup(options.framework) + : new Map(); + + return roleBatch + .map((role) => { + const appearanceText = + options.roleType === 'story' + ? (appearanceLookup.get(role.name)?.join('、') ?? '未指定') + : ''; + return [ + `- ${role.name} / ${role.title}`, + `身份:${role.role}`, + `框架描述:${role.description}`, + `预设好感:${role.initialAffinity}`, + role.relationshipHooks.length > 0 + ? `关系切入口:${role.relationshipHooks.join('、')}` + : '', + role.tags.length > 0 ? `标签:${role.tags.join('、')}` : '', + appearanceText ? `出现场景:${appearanceText}` : '', + ] + .filter(Boolean) + .join(';'); + }) + .join('\n'); +} + +export function buildCustomWorldFrameworkPrompt(settingText: string) { + return [ + '请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。', + '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', + '这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。', + '玩家设定:', + settingText.trim(), + '', + '输出 JSON 模板:', + '{', + ' "name": "世界名称",', + ' "subtitle": "世界副标题",', + ' "summary": "世界概述",', + ' "tone": "世界基调",', + ' "playerGoal": "玩家核心目标",', + ' "templateWorldType": "WUXIA|XIANXIA",', + ' "majorFactions": ["势力甲", "势力乙"],', + ' "coreConflicts": ["冲突甲", "冲突乙"],', + ' "camp": {', + ' "name": "开局归处名称",', + ' "description": "这是玩家进入世界后的第一处落脚点描述",', + ' "dangerLevel": "low|medium|high|extreme"', + ' }', + '}', + '', + '要求:', + '- 所有生成文本都必须使用中文。', + '- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。', + '- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。', + '- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。', + '- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。', + '- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。', + '- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。', + '- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。', + '- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。', + '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', + ].join('\n'); +} + +export function buildCustomWorldFrameworkJsonRepairPrompt( + responseText: string, +) { + return [ + '下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', + '请只输出修复后的 JSON 对象。', + '顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。', + '不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。', + 'majorFactions 与 coreConflicts 必须是字符串数组。', + 'camp 必须是对象,且包含:name、description、dangerLevel。', + '原始文本:', + responseText.trim(), + ].join('\n'); +} + +export function buildCustomWorldRoleOutlineBatchPrompt(params: { + framework: CustomWorldGenerationFramework; + roleType: CustomWorldGenerationRoleBatchType; + batchCount: number; + forbiddenNames?: string[]; +}) { + const { framework, roleType, batchCount, forbiddenNames = [] } = params; + const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; + const label = roleType === 'playable' ? '可扮演角色' : '场景角色'; + + return [ + `请根据下面的世界核心信息,生成一批${label}框架名单。`, + '后续我会继续补全人物档案,所以这一步每个角色只保留最少字段。', + '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', + '世界核心信息:', + buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), + forbiddenNames.length > 0 + ? `这些名字已经生成,禁止重复:${forbiddenNames.join('、')}` + : '', + '', + '输出 JSON 模板:', + '{', + ` "${key}": [`, + ' {', + ' "name": "角色名称",', + ' "title": "称号",', + ' "role": "身份",', + ' "description": "极简定位描述",', + ' "initialAffinity": 18,', + ' "relationshipHooks": ["一个关系切入口"],', + ' "tags": ["标签1", "标签2"]', + ' }', + ' ]', + '}', + '', + '要求:', + `- 必须生成恰好 ${batchCount} 个${label}。`, + '- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。', + '- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。', + '- 只保留:name、title、role、description、initialAffinity、relationshipHooks、tags。', + '- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。', + '- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。', + '- initialAffinity 必须是 -40 到 90 的整数。', + roleType === 'playable' + ? '- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。' + : '- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。', + '- 所有生成文本都必须使用中文。', + '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', + ] + .filter(Boolean) + .join('\n'); +} + +export function buildCustomWorldRoleOutlineBatchJsonRepairPrompt(params: { + responseText: string; + roleType: CustomWorldGenerationRoleBatchType; + expectedCount: number; + forbiddenNames?: string[]; +}) { + const { responseText, roleType, expectedCount, forbiddenNames = [] } = params; + const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; + + return [ + `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, + '请只输出修复后的 JSON 对象。', + `顶层必须只包含一个 ${key} 数组。`, + `必须保留恰好 ${expectedCount} 个角色对象。`, + forbiddenNames.length > 0 + ? `禁止使用这些重复名:${forbiddenNames.join('、')}。` + : '', + '每个角色只包含:name、title、role、description、initialAffinity、relationshipHooks、tags。', + '如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。', + '不要输出 backstory、skills、landmarks 或任何其他字段。', + '原始文本:', + responseText.trim(), + ] + .filter(Boolean) + .join('\n'); +} + +export function buildCustomWorldLandmarkSeedBatchPrompt(params: { + framework: CustomWorldGenerationFramework; + batchCount: number; + forbiddenNames?: string[]; +}) { + const { framework, batchCount, forbiddenNames = [] } = params; + + return [ + '请根据下面的世界核心信息,生成一批场景地标骨架。', + '后续我会继续补全场景角色分布和连接关系,所以这一步只保留最少字段。', + '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', + '世界核心信息:', + buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), + forbiddenNames.length > 0 + ? `这些场景名已经生成,禁止重复:${forbiddenNames.join('、')}` + : '', + '', + '输出 JSON 模板:', + '{', + ' "landmarks": [', + ' {', + ' "name": "场景名称",', + ' "description": "极简场景描述",', + ' "dangerLevel": "low|medium|high|extreme"', + ' }', + ' ]', + '}', + '', + '要求:', + `- 必须生成恰好 ${batchCount} 个 landmarks。`, + '- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。', + '- 这一步只保留:name、description、dangerLevel。', + '- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。', + '- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。', + '- description 控制在 8 到 18 个汉字内。', + '- 所有生成文本都必须使用中文。', + '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', + ] + .filter(Boolean) + .join('\n'); +} + +export function buildCustomWorldLandmarkSeedBatchJsonRepairPrompt(params: { + responseText: string; + expectedCount: number; + forbiddenNames?: string[]; +}) { + const { responseText, expectedCount, forbiddenNames = [] } = params; + + return [ + '下面这段文本本应是自定义世界场景地标骨架批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', + '请只输出修复后的 JSON 对象。', + '顶层必须只包含一个 landmarks 数组。', + `必须保留恰好 ${expectedCount} 个地标对象。`, + forbiddenNames.length > 0 + ? `禁止使用这些重复场景名:${forbiddenNames.join('、')}。` + : '', + '每个地标只包含:name、description、dangerLevel。', + '不要输出 sceneNpcNames、connections 或其他字段。', + '原始文本:', + responseText.trim(), + ] + .filter(Boolean) + .join('\n'); +} + +export function buildCustomWorldLandmarkNetworkBatchPrompt(params: { + framework: CustomWorldGenerationFramework; + landmarkBatch: CustomWorldGenerationLandmarkOutline[]; + storyNpcs: CustomWorldGenerationRoleOutline[]; +}) { + const { framework, landmarkBatch, storyNpcs } = params; + const relativePositionValues = + CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.join('|'); + const allLandmarkNames = framework.landmarks.map((landmark) => landmark.name); + const storyNpcNames = storyNpcs.map((npc) => npc.name); + + return [ + '请根据下面的世界信息,为这一批场景补全“出现场景角色”和“地图连接关系”。', + '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', + '世界核心信息:', + buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), + `全部场景名:${allLandmarkNames.join('、')}`, + `可用场景角色名:${storyNpcNames.join('、')}`, + '本批次场景骨架:', + landmarkBatch + .map( + (landmark) => + `- ${landmark.name} / 危险度:${landmark.dangerLevel} / 描述:${landmark.description}`, + ) + .join('\n'), + '', + '输出 JSON 模板:', + '{', + ' "landmarks": [', + ' {', + ' "name": "场景名称",', + ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', + ' "connections": [', + ' {', + ' "targetLandmarkName": "其他场景名称",', + ` "relativePosition": "${CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS[0] ?? 'forward'}",`, + ' "summary": "极简通路说明"', + ' }', + ' ]', + ' }', + ' ]', + '}', + '', + '要求:', + `- 只输出这批 ${landmarkBatch.length} 个场景,不要输出其他场景。`, + '- 这是一个完全独立的自定义世界;summary 不要带入“武侠”“仙侠”等现成世界名称。', + '- 名称必须与本批次场景骨架完全一致,不得改名。', + '- 每个场景必须提供恰好 3 个唯一 sceneNpcNames,且只能从可用场景角色名里选择。', + `- 每个场景必须提供恰好 2 条 connections;relativePosition 只能使用:${relativePositionValues}。`, + '- targetLandmarkName 必须来自全部场景名,且不能连接自己,两个目标场景不能重复。', + '- summary 控制在 4 到 10 个汉字内。', + '- 不要输出 description、dangerLevel、backstory 或其他字段。', + '- 所有生成文本都必须使用中文。', + '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', + ].join('\n'); +} + +export function buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt(params: { + responseText: string; + expectedNames: string[]; +}) { + const { responseText, expectedNames } = params; + + return [ + '下面这段文本本应是自定义世界场景连接补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', + '请只输出修复后的 JSON 对象。', + '顶层必须只包含一个 landmarks 数组。', + `landmarks 里只能保留这些场景名:${expectedNames.join('、')}。`, + '每个场景对象只包含:name、sceneNpcNames、connections。', + 'connections 里的每个对象必须包含:targetLandmarkName、relativePosition、summary。', + '不要输出 description、dangerLevel 或其他字段。', + '原始文本:', + responseText.trim(), + ].join('\n'); +} + +export function buildCustomWorldRoleBatchPrompt(params: { + framework: CustomWorldGenerationFramework; + roleType: CustomWorldGenerationRoleBatchType; + roleBatch: CustomWorldGenerationRoleOutline[]; + stage: CustomWorldGenerationRoleBatchStage; +}) { + const { framework, roleType, roleBatch, stage } = params; + const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; + const label = roleType === 'playable' ? '可扮演角色' : '场景角色'; + const roleOutlineText = buildRoleOutlinePromptLines(roleBatch, { + framework, + roleType, + }); + + if (stage === 'narrative') { + return [ + `请根据下面的世界框架,补全这一批${label}的叙事基础设定。`, + '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', + '玩家原始设定:', + framework.settingText, + '', + '世界框架摘要:', + buildFrameworkSummaryText(framework, { maxLandmarks: 8 }), + '', + `本批次需要补全的${label}(名称必须原样保留):`, + roleOutlineText, + '', + '输出 JSON 模板:', + '{', + ` "${key}": [`, + ' {', + ' "name": "角色名称",', + ' "backstory": "背景经历",', + ' "personality": "性格特点",', + ' "motivation": "当前动机",', + ' "combatStyle": "战斗风格"', + ' }', + ' ]', + '}', + '', + '要求:', + `- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`, + '- 这是一个完全独立的自定义世界;不要在角色背景、性格、动机或战斗风格里直接写“武侠世界”“仙侠世界”等现成世界名。', + `- ${key} 的数量必须与本批次名单完全一致。`, + '- 名称必须与批次名单完全一致,不得增删改名。', + '- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。', + '- 必须严格沿用框架中的 title、role、description、initialAffinity、relationshipHooks、tags 所表达的角色定位,不要改名,不要改阵营。', + '- backstory 必须写出角色和当前世界的具体关系,至少落到一个势力、一个地点、一个正在发生的局势变化,不要只写抽象气质或泛泛成长史。', + '- personality 不能只写单个形容词,要体现角色在这个世界里的处事习惯、应对压力的方式和与人相处的锋面。', + '- motivation 必须是“此刻正在推动角色行动”的现实目标,而不是空泛理想;它要和玩家目标、核心冲突或开局处境形成直接拉扯。', + '- combatStyle 要体现角色为什么会这样战斗,它最好能反映其身份、经历、所属势力或长期栖身的场景环境。', + roleType === 'story' + ? '- 怪物型场景角色要在 backstory 或 combatStyle 中直接写出怪物特征、栖息环境或攻击方式。' + : '- 可扮演角色要体现成长空间、协作价值和明确的入队理由。', + '- 所有生成文本都必须使用中文。', + '- 每个字符串尽量简洁但不能空泛:backstory/personality/motivation/combatStyle 控制在 18 到 56 个汉字内。', + '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', + ].join('\n'); + } + + return [ + `请根据下面的世界框架,补全这一批${label}的背景章节、技能和初始物品。`, + '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', + '玩家原始设定:', + framework.settingText, + '', + '世界框架摘要:', + buildFrameworkSummaryText(framework, { maxLandmarks: 8 }), + '', + `本批次需要补全的${label}(名称必须原样保留):`, + roleOutlineText, + '', + '输出 JSON 模板:', + '{', + ` "${key}": [`, + ' {', + ' "name": "角色名称",', + ' "backstoryReveal": {', + ' "publicSummary": "公开可见的背景摘要",', + ' "chapters": [', + ` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, + ` { "id": "scar", "title": "旧事裂痕", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, + ` { "id": "hidden", "title": "隐藏执念", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, + ` { "id": "final", "title": "最终底牌", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" }`, + ' ]', + ' },', + ' "skills": [', + ' { "name": "技能1", "summary": "技能说明", "style": "起手压制" },', + ' { "name": "技能2", "summary": "技能说明", "style": "机动周旋" },', + ' { "name": "技能3", "summary": "技能说明", "style": "爆发终结" }', + ' ],', + ' "initialItems": [', + ' { "name": "初始物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] },', + ' { "name": "初始物品2", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "物品说明", "tags": ["标签1", "标签2"] },', + ' { "name": "初始物品3", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] }', + ' ]', + ' }', + ' ]', + '}', + '', + '要求:', + `- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`, + '- 这是一个完全独立的自定义世界;不要在公开背景、技能名、物品名或说明里直接带入“武侠”“仙侠”等现成世界名。', + `- ${key} 的数量必须与本批次名单完全一致。`, + '- 名称必须与批次名单完全一致,不得增删改名。', + '- 这一阶段只补全 backstoryReveal、skills、initialItems,不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。', + '- 背景章节、技能和初始物品必须严格围绕框架中的角色定位来写,不要改变阵营、身份或出现场景。', + '- backstoryReveal 的 4 章必须形成明显递进:第 1 章写表层来意与第一印象,第 2 章写旧伤或代价,第 3 章写角色真正隐瞒的线索,第 4 章写最终底牌或不可回避的真相。', + '- 每一章都必须紧贴当前世界设定,至少落到具体势力、地点、事件、制度、禁忌或关系链中的一项,不要写成可套用到任何世界的空泛心情。', + '- teaser 必须像“继续相处后能戳到的钩子”,content 必须像“真正解锁后得到的新信息”,contextSnippet 必须可直接被后续剧情复用,三者不要只是同一句话改写。', + '- skills 不只是职业标签,要体现角色的个人经历、所属阵营、地理环境或禁忌系统影响,尽量写出这个世界独有的招式语感。', + '- initialItems 不只是常规装备清单,至少要有一件能反映角色背景、关系或任务压力的私人物件。', + `- backstoryReveal.chapters 必须恰好 4 章,affinityRequired 固定使用 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`, + '- 每个角色必须提供恰好 3 个 skills 和恰好 3 个 initialItems。', + '- initialItems.category 只能使用:武器、护甲、饰品、消耗品、材料、稀有品、专属物品。', + roleType === 'story' + ? '- 怪物型角色仍然放进 storyNpcs,并在 role、description、backstory、combatStyle、tags、backstoryReveal、skills 或 initialItems 中明确写出怪物特征、栖息环境、攻击方式或异形外观。' + : '- 可扮演角色要保持明确的成长空间、协作价值和入队理由。', + '- 所有生成文本都必须使用中文。', + '- 每个字符串尽量简洁但要有信息量:backstoryReveal.publicSummary 控制在 14 到 36 个汉字内,backstoryReveal.teaser 控制在 12 到 28 个汉字内,backstoryReveal.content 控制在 20 到 64 个汉字内,contextSnippet 控制在 12 到 36 个汉字内,skills.summary 和 initialItems.description 控制在 12 到 32 个汉字内。', + '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', + ].join('\n'); +} + +export function buildCustomWorldRoleBatchJsonRepairPrompt(params: { + responseText: string; + roleType: CustomWorldGenerationRoleBatchType; + expectedNames: string[]; + stage: CustomWorldGenerationRoleBatchStage; +}) { + const { responseText, roleType, expectedNames, stage } = params; + const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; + + if (stage === 'narrative') { + return [ + `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}叙事设定批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, + '请只输出修复后的 JSON 对象。', + `顶层必须只包含一个 ${key} 数组。`, + `这个数组里只能保留这些角色名:${expectedNames.join('、')}。`, + '名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。', + '每个角色都必须包含:name、backstory、personality、motivation、combatStyle。', + '如果缺少字段:字符串补空字符串。', + '不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。', + '原始文本:', + responseText.trim(), + ].join('\n'); + } + + return [ + `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, + '请只输出修复后的 JSON 对象。', + `顶层必须只包含一个 ${key} 数组。`, + `这个数组里只能保留这些角色名:${expectedNames.join('、')}。`, + '名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。', + '每个角色都必须包含:name、backstoryReveal、skills、initialItems。', + `backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`, + 'skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。', + '不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。', + '原始文本:', + responseText.trim(), + ].join('\n'); +} + +function clampSceneImageText(value: string, maxLength: number) { + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) { + return ''; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +function describeDangerLevel(dangerLevel: string) { + const normalized = dangerLevel.trim().toLowerCase(); + if (normalized === 'low' || normalized === '低') + return '气氛相对平静,但暗藏细节张力'; + if (normalized === 'medium' || normalized === '中') + return '带有明确的探索压力与潜在威胁'; + if (normalized === 'high' || normalized === '高') + return '危险感强烈,空间中有明显压迫感'; + if (normalized === 'extreme' || normalized === '极高') + return '极端危险,环境本身就像会吞没闯入者'; + return dangerLevel.trim() + ? `危险氛围:${dangerLevel.trim()}` + : '危险气质保持克制但不可忽视'; +} + +export const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT = [ + '文字', + '水印', + 'logo', + 'UI界面', + '对话框', + '边框', + '人物近景特写', + '多人合照', + '模糊', + '低清晰度', + '畸形建筑', + '现代车辆', + '监控摄像头', +].join(','); + +export function buildCustomWorldSceneImagePrompt( + profile: Pick< + CustomWorldProfile, + 'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText' + >, + landmark: Pick, + userPrompt = '', + options: { + hasReferenceImage?: boolean; + } = {}, +) { + const worldName = clampSceneImageText(profile.name, 18) || '未命名世界'; + const worldSubtitle = clampSceneImageText(profile.subtitle, 18); + const worldTone = clampSceneImageText(profile.tone, 48); + const worldGoal = clampSceneImageText(profile.playerGoal, 48); + const worldSummary = clampSceneImageText(profile.summary, 72); + const worldSetting = clampSceneImageText(profile.settingText, 72); + const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景'; + const landmarkDescription = clampSceneImageText(landmark.description, 96); + const requestedVisual = clampSceneImageText(userPrompt, 120); + const dangerMood = describeDangerLevel(landmark.dangerLevel); + + return [ + '为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。', + '画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。', + '下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。', + '下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。', + '下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。', + options.hasReferenceImage + ? '已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。' + : '', + `世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`, + worldSetting ? `玩家设定:${worldSetting}。` : '', + worldSummary ? `世界概述:${worldSummary}。` : '', + worldTone ? `整体基调:${worldTone}。` : '', + worldGoal ? `玩家目标关联:${worldGoal}。` : '', + `场景名称:${landmarkName}。`, + landmarkDescription ? `场景描述:${landmarkDescription}。` : '', + requestedVisual ? `本次想要生成的画面内容:${requestedVisual}。` : '', + `${dangerMood}。`, + '不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。', + ] + .filter(Boolean) + .join(''); +} diff --git a/server-node/src/prompts/eightAnchorPrompts.ts b/server-node/src/prompts/eightAnchorPrompts.ts new file mode 100644 index 00000000..777d7666 --- /dev/null +++ b/server-node/src/prompts/eightAnchorPrompts.ts @@ -0,0 +1,784 @@ +import type { + EightAnchorContent, + HiddenLineValue, + IconicElementValue, + KeyRelationshipValue, + ThemeBoundaryValue, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + createEmptyEightAnchorContent, + normalizeEightAnchorContent, +} from '../services/eightAnchorCompatibilityService.js'; + +export type PromptUserInputSignal = + | 'rich' + | 'normal' + | 'sparse' + | 'correction' + | 'delegate'; + +export type PromptDriftRisk = 'low' | 'medium' | 'high'; + +export type PromptConversationMode = + | 'bootstrap' + | 'expand' + | 'compress' + | 'repair_direction' + | 'force_complete' + | 'closing'; + +export type PromptDynamicState = { + currentTurn: number; + progressPercent: number; + userInputSignal: PromptUserInputSignal; + driftRisk: PromptDriftRisk; + quickFillRequested: boolean; + conversationMode: PromptConversationMode; + judgementSummary: string; +}; + +export type PromptDynamicStateInference = { + userInputSignal?: unknown; + driftRisk?: unknown; + conversationMode?: unknown; + judgementSummary?: unknown; +}; + +const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。 + +你正在和用户一起共创一个游戏世界。每一轮你都必须读取: +1. 当前完整设定结构 +2. 用户聊天记录 + +然后输出: +1. 一版新的完整设定结构 +2. 当前 progress 百分比 +3. 一段直接回复用户的话 + +你必须把“新的完整设定结构”视为下一轮的唯一有效版本。 +你的输出会直接覆盖上一版设定结构。 + +你不是在做局部 patch。 +你不是在做解释报告。 +你不是在给开发者写分析。 +你是在同时完成: +1. 世界设定更新 +2. 当前推进程度判断 +3. 对用户的共创回复`; + +const GLOBAL_HARD_RULES = `全局硬约束: + +1. 必须输出完整的设定结构,而不是只输出变化部分。 +2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。 +3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。 +4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。 +5. progressPercent 最低为 0,不允许为负数。 +6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。 +7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。 +8. replyText 不要写成长篇策划文,不要展开大段世界观百科。 +9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。 +10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。 +11. 你输出的 JSON 必须可以被直接解析。 +12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。`; + +const MODE_RULES: Record = { + bootstrap: `当前模式:bootstrap + +目标: +1. 先把世界的基本方向抓住 +2. 不要一次塞太多新设定 +3. 回复要降低用户开口压力 + +本轮行为要求: +1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索 +2. 如果用户信息很少,不要强行把整套结构一次补满 +3. replyText 要像共创搭档,而不是像审问 +4. 默认只推进一个最关键的问题方向 +5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步 +6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题 +7. 不要把问题问得像表单采集,不要一口气追问多个维度 + +用户体验要求: +1. 让用户觉得“现在很容易继续往下说” +2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉 +3. replyText 最好短、稳、可接话 +4. 如果用户信息很少,也不要显得冷淡或机械`, + expand: `当前模式:expand + +目标: +1. 在保持现有方向的前提下,把设定结构逐步补全 +2. 尽量让一轮输入覆盖多个关键维度 + +本轮行为要求: +1. 继续保留上一版里仍成立的设定 +2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段 +3. replyText 要明确体现“你已经理解了哪些内容” +4. 不要突然大幅改写已经成形的世界 +5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步 +6. 可以适度替用户整理,但不要把回复写成总结报告 +7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感 + +用户体验要求: +1. 让用户感到“我刚说的内容都被接住了” +2. 回复里可以带一点顺势整理感,但不要太像会议纪要 +3. 不要无视用户刚提供的高价值细节 +4. 不要让用户觉得系统在自顾自重写世界`, + compress: `当前模式:compress + +目标: +1. 开始收束当前设定 +2. 减少无效发散 +3. 让 progress 更接近可进入下一阶段 + +本轮行为要求: +1. 新的设定结构优先保留稳定内容,不要无端重写 +2. 对用户本轮输入做高密度吸收 +3. replyText 要更聚焦,不要绕圈 +4. 默认只推进当前最影响 completion 的一步 +5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支 +6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist +7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问 + +用户体验要求: +1. 让用户感觉世界正在变得更稳,而不是越来越散 +2. 让推进感更明确,但不要显得催促 +3. 回复语气应更笃定一些,减少反复横跳 +4. 不要把用户刚补进来的细节又冲淡掉`, + repair_direction: `当前模式:repair_direction + +目标: +1. 处理用户对既有设定的修正 +2. 避免世界方向飘散或自相矛盾 + +本轮行为要求: +1. 如果用户明确改口,新的设定结构必须体现修正后的方向 +2. 对已经不再成立的旧设定,不要机械保留 +3. progressPercent 可以停滞,也可以小幅回落,但不能为负 +4. replyText 要承认用户的修正,并顺着修正后的方向继续聊 +5. 先处理“改掉什么”,再决定“往哪里继续推” +6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向 +7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境 + +用户体验要求: +1. 让用户感到“我刚刚的纠偏真的生效了” +2. 不要和用户辩论旧方案为什么也行 +3. 不要表现出对修正的不情愿 +4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`, + force_complete: `当前模式:force_complete + +目标: +1. 基于当前方向直接补齐剩余设定 +2. 生成一版尽量完整、可进入下一阶段的设定结构 +3. 结束当前收集阶段 + +本轮行为要求: +1. 尽量保留已经形成的世界方向 +2. 对明显缺失的关键维度进行合理补全 +3. 不要继续拉长聊天,不要再追问用户 +4. progressPercent 直接输出为 100 +5. replyText 要自然引导用户点击“生成游戏设定草稿” +6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突 +7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经 +8. replyText 更像阶段完成提示,不再像继续采集信息的对话 + +用户体验要求: +1. 让用户感到“系统已经帮我把能补的补好了” +2. 不要在这一步突然冒出很多陌生设定把用户吓出戏 +3. 回复要有完成感,但不要太官话 +4. 清楚告诉用户下一步可以做什么`, + closing: `当前模式:closing + +目标: +1. 尽量形成一版可用的设定底子 +2. 不再继续发散新世界观 + +本轮行为要求: +1. 优先收束,而不是扩写 +2. 不要大改已经成形的核心设定 +3. progressPercent 接近完成时,replyText 要更像确认与推进 +4. 如果用户没有大改方向,尽量让下一版内容更稳定 +5. 可以轻微补足缺口,但不要再大开新支线 +6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感 +7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题 + +用户体验要求: +1. 让用户感觉作品已经快成了,而不是还在无穷试探 +2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探 +3. 保持留白感,不要把所有东西都一次说死 +4. 让用户自然过渡到下一阶段,而不是突然被切断对话`, +}; + +const USER_SIGNAL_RULES: Record = { + rich: `本轮用户输入信息密度高。 +请尽量从这一轮里提取多个锚点,不要只更新单一方向。 +如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`, + normal: `本轮用户输入为正常补充。 +请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`, + sparse: `本轮用户输入较少或较虚。 +请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。 +replyText 要让用户容易继续往下说。`, + correction: `本轮用户在修正或推翻旧设定。 +请优先吸收修正,不要机械复读旧版本。 +新的完整设定结构必须以修正后的方向为准。`, + delegate: `本轮用户把部分决定权交给你。 +你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。 +新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`, +}; + +const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。 + +这表示用户接受你基于当前方向自动补完剩余设定。 + +本轮要求: +1. 不要再继续提问 +2. 直接输出一版尽量完整的设定结构 +3. progressPercent 直接输出为 100 +4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`; + +const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。 +你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。 + +你必须综合以下信息判断: +1. 当前轮次 currentTurn +2. 当前完成度 progressPercent +3. 用户是否要求自动补全 quickFillRequested +4. 当前完整设定结构 +5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息 + +你需要输出 4 个字段: +1. userInputSignal:只能是 rich / normal / sparse / correction / delegate +2. driftRisk:只能是 low / medium / high +3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing +4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么 + +请按下面的语义判断。 + +一、userInputSignal 定义 +1. rich +- 用户这一轮给了多条可直接落地的有效信息 +- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个 +- 正式生成时应优先高密度吸收,不要只更新一个点 + +2. normal +- 用户在顺着当前方向做正常补充 +- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统 +- 正式生成时应稳定推进并自然接住用户内容 + +3. sparse +- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实 +- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达 +- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问 +- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题 + +4. correction +- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定 +- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction +- correction 的优先级高于 rich 和 normal + +5. delegate +- 用户把部分决定权交给系统 +- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案” +- delegate 关注的是授权关系,不只是信息多寡 + +二、driftRisk 定义 +1. low +- 当前轮输入与已有方向基本一致 +- 没有明显改口或冲突 + +2. medium +- 当前轮带来一定方向变化或扩张 +- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散 + +3. high +- 用户明确纠偏、改口、替换方向,或最近多轮反复修正 +- 这时最重要的是防止旧方向重新回流到正式生成结果里 + +三、conversationMode 选择原则 +1. bootstrap +- 适用于前期、信息少、核心方向未稳定 +- replyText 更适合低压力确认和单点启发 + +2. expand +- 适用于方向已成形,正在顺着现有路线继续补充 +- replyText 更适合总结已接住的内容并往前推一步 + +3. compress +- 适用于中后段,已有骨架,需要开始收束 +- replyText 更适合聚焦最关键缺口,而不是继续开支线 + +4. repair_direction +- 适用于用户正在纠偏 +- replyText 更适合先承认修正,再沿修正后的方向继续推进 + +5. force_complete +- 适用于用户明确要求自动补全 +- replyText 不再提问,而应给出完成感和下一步引导 + +6. closing +- 适用于接近完成但并非强制一键补全 +- replyText 更像确认与收束,而不是前期式探索 + +四、优先级规则 +1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete +2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction +3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate +4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择 + +五、关于 replyText 风格的专门判断要求 +1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问 +2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多 +3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈 +4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进 +5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法 + +六、关于 replyText 用语的硬约束 +1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词 +2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点 +3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户 +4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构 +5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语 + +七、关于 judgementSummary 的写法 +1. 必须简洁,不要写成长篇分析 +2. 必须直接服务于下一轮正式生成 +3. 最好同时包含两层信息: +- 为什么这么判断 +- 正式生成时最该优先做什么,或最该避免什么 + +八、硬性约束 +1. 只能输出 JSON,不能输出解释、代码块或额外说明 +2. 不能发明上下文里不存在的设定事实 +3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定” +4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态 +5. judgementSummary 必须是中文 +6. 输出值必须严格落在给定枚举中`; + +const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字: +{ + "userInputSignal": "normal", + "driftRisk": "low", + "conversationMode": "expand", + "judgementSummary": "" +}`; + +const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字: +{ + "replyText": "", + "progressPercent": 0, + "nextAnchorContent": { + "worldPromise": { + "hook": "", + "differentiator": "", + "desiredExperience": "" + }, + "playerFantasy": { + "playerRole": "", + "corePursuit": "", + "fearOfLoss": "" + }, + "themeBoundary": { + "toneKeywords": [], + "aestheticDirectives": [], + "forbiddenDirectives": [] + }, + "playerEntryPoint": { + "openingIdentity": "", + "openingProblem": "", + "entryMotivation": "" + }, + "coreConflict": { + "surfaceConflicts": [], + "hiddenCrisis": "", + "firstTouchedConflict": "" + }, + "keyRelationships": [ + { + "pairs": "", + "relationshipType": "", + "secretOrCost": "" + } + ], + "hiddenLines": { + "hiddenTruths": [], + "misdirectionHints": [], + "revealPacing": "" + }, + "iconicElements": { + "iconicMotifs": [], + "institutionsOrArtifacts": [], + "hardRules": [] + } + } +}`; + +function toJson(value: unknown) { + return JSON.stringify(value, null, 2); +} + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function getLatestUserText( + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, +) { + return ( + [...chatHistory] + .reverse() + .find((entry) => entry.role === 'user' && entry.content.trim())?.content ?? + '' + ); +} + +function includesAny(text: string, patterns: RegExp[]) { + return patterns.some((pattern) => pattern.test(text)); +} + +function isPromptUserInputSignal( + value: unknown, +): value is PromptUserInputSignal { + return ( + value === 'rich' || + value === 'normal' || + value === 'sparse' || + value === 'correction' || + value === 'delegate' + ); +} + +function isPromptDriftRisk(value: unknown): value is PromptDriftRisk { + return value === 'low' || value === 'medium' || value === 'high'; +} + +function isPromptConversationMode( + value: unknown, +): value is PromptConversationMode { + return ( + value === 'bootstrap' || + value === 'expand' || + value === 'compress' || + value === 'repair_direction' || + value === 'force_complete' || + value === 'closing' + ); +} + +export function detectUserInputSignal( + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, +): PromptUserInputSignal { + const latestUserText = getLatestUserText(chatHistory).trim(); + + if (!latestUserText) { + return 'sparse'; + } + + if (includesAny(latestUserText, [/(不是|改成|改为|换成|重来|推翻|修正)/u])) { + return 'correction'; + } + + if (includesAny(latestUserText, [/(你帮我想|你来定|你决定|你补完)/u])) { + return 'delegate'; + } + + const segments = latestUserText + .split(/[。!?;\n]/u) + .map((item) => item.trim()) + .filter(Boolean); + + if (latestUserText.length <= 10 || segments.length <= 1) { + return 'sparse'; + } + + if (segments.length >= 3 || latestUserText.length >= 60) { + return 'rich'; + } + + return 'normal'; +} + +function summarizeDynamicState( + state: Pick< + PromptDynamicState, + 'userInputSignal' | 'driftRisk' | 'conversationMode' + >, +) { + return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`; +} + +function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) { + return Boolean( + value && + (value.toneKeywords.length > 0 || + value.aestheticDirectives.length > 0 || + value.forbiddenDirectives.length > 0), + ); +} + +function isRelationshipsFilled(value: KeyRelationshipValue[]) { + return value.length > 0; +} + +function isHiddenLinesFilled(value: HiddenLineValue | null) { + return Boolean( + value && + (value.hiddenTruths.length > 0 || + value.misdirectionHints.length > 0 || + value.revealPacing), + ); +} + +function isIconicElementsFilled(value: IconicElementValue | null) { + return Boolean( + value && + (value.iconicMotifs.length > 0 || + value.institutionsOrArtifacts.length > 0 || + value.hardRules.length > 0), + ); +} + +export function detectDriftRisk(params: { + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; + anchorContent: EightAnchorContent; + progressPercent: number; +}) { + const latestUserText = getLatestUserText(params.chatHistory).trim(); + const recentUserMessages = params.chatHistory + .filter((entry) => entry.role === 'user') + .slice(-3) + .map((entry) => entry.content.trim()) + .filter(Boolean); + + const correctionCount = recentUserMessages.filter((entry) => + /(不是|改成|改为|换成|推翻|重来|修正)/u.test(entry), + ).length; + + if ( + correctionCount >= 2 || + (params.progressPercent >= 65 && + /(不是|改成|改为|换成|重来|推翻)/u.test(latestUserText)) + ) { + return 'high' as const; + } + + const normalizedContent = normalizeEightAnchorContent(params.anchorContent); + const filledCount = [ + Boolean(normalizedContent.worldPromise), + Boolean(normalizedContent.playerFantasy), + isThemeBoundaryFilled(normalizedContent.themeBoundary), + Boolean(normalizedContent.playerEntryPoint), + Boolean(normalizedContent.coreConflict), + isRelationshipsFilled(normalizedContent.keyRelationships), + isHiddenLinesFilled(normalizedContent.hiddenLines), + isIconicElementsFilled(normalizedContent.iconicElements), + ].filter(Boolean).length; + + if (filledCount >= 3 && latestUserText.length >= 40) { + return 'medium' as const; + } + + return 'low' as const; +} + +export function pickConversationMode(params: { + currentTurn: number; + progressPercent: number; + userInputSignal: PromptUserInputSignal; + driftRisk: PromptDriftRisk; + quickFillRequested: boolean; +}) { + if (params.quickFillRequested) { + return 'force_complete' as const; + } + + if ( + params.userInputSignal === 'correction' || + params.driftRisk === 'high' + ) { + return 'repair_direction' as const; + } + + if (params.progressPercent >= 85 || params.currentTurn >= 15) { + return 'closing' as const; + } + + if (params.currentTurn > 10 || params.progressPercent >= 65) { + return 'compress' as const; + } + + if (params.currentTurn <= 10 && params.progressPercent < 65) { + return 'expand' as const; + } + + return 'bootstrap' as const; +} + +function buildRuleBasedPromptDynamicState(input: { + currentTurn: number; + progressPercent: number; + quickFillRequested: boolean; + currentAnchorContent: EightAnchorContent; + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; +}): PromptDynamicState { + const userInputSignal = detectUserInputSignal(input.chatHistory); + const driftRisk = detectDriftRisk({ + chatHistory: input.chatHistory, + anchorContent: input.currentAnchorContent, + progressPercent: input.progressPercent, + }); + + const conversationMode = pickConversationMode({ + currentTurn: input.currentTurn, + progressPercent: input.progressPercent, + userInputSignal, + driftRisk, + quickFillRequested: input.quickFillRequested, + }); + + return { + currentTurn: input.currentTurn, + progressPercent: input.progressPercent, + userInputSignal, + driftRisk, + quickFillRequested: input.quickFillRequested, + conversationMode, + judgementSummary: summarizeDynamicState({ + userInputSignal, + driftRisk, + conversationMode, + }), + }; +} + +export function buildPromptDynamicState(input: { + currentTurn: number; + progressPercent: number; + quickFillRequested: boolean; + currentAnchorContent: EightAnchorContent; + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; +}, inference?: PromptDynamicStateInference | null): PromptDynamicState { + const fallbackState = buildRuleBasedPromptDynamicState(input); + + if (!inference) { + return fallbackState; + } + + const userInputSignal = isPromptUserInputSignal(inference.userInputSignal) + ? inference.userInputSignal + : fallbackState.userInputSignal; + const driftRisk = isPromptDriftRisk(inference.driftRisk) + ? inference.driftRisk + : fallbackState.driftRisk; + const conversationMode = isPromptConversationMode(inference.conversationMode) + ? inference.conversationMode + : fallbackState.conversationMode; + const judgementSummary = + toText(inference.judgementSummary) || + summarizeDynamicState({ + userInputSignal, + driftRisk, + conversationMode, + }); + + return { + currentTurn: input.currentTurn, + progressPercent: input.progressPercent, + userInputSignal, + driftRisk, + quickFillRequested: input.quickFillRequested, + conversationMode, + judgementSummary, + }; +} + +export function buildPromptDynamicStateInferencePrompt(input: { + currentTurn: number; + progressPercent: number; + quickFillRequested: boolean; + currentAnchorContent: EightAnchorContent; + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; +}) { + const currentAnchorContent = + normalizeEightAnchorContent(input.currentAnchorContent) ?? + createEmptyEightAnchorContent(); + + return { + systemPrompt: [ + STATE_INFERENCE_SYSTEM_PROMPT, + STATE_INFERENCE_OUTPUT_CONTRACT, + ].join('\n\n'), + userPrompt: [ + `当前轮次:${input.currentTurn}`, + `当前完成度:${input.progressPercent}`, + `是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`, + renderCurrentAnchorContext(currentAnchorContent), + renderChatHistoryContext(input.chatHistory), + ].join('\n\n'), + }; +} + +function renderDynamicStateContext(dynamicState: PromptDynamicState) { + return `上一轮预判得到的创作状态如下。 +正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。 + +创作状态: +- userInputSignal: ${dynamicState.userInputSignal} +- driftRisk: ${dynamicState.driftRisk} +- conversationMode: ${dynamicState.conversationMode} +- judgementSummary: ${dynamicState.judgementSummary}`; +} + +function renderCurrentAnchorContext(anchorContent: EightAnchorContent) { + return `当前完整设定结构如下。 +你必须把它视为上一版有效世界底子。 + +如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。 +如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。 + +当前完整设定结构: +${toJson(normalizeEightAnchorContent(anchorContent))}`; +} + +function renderChatHistoryContext( + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, +) { + return `以下是用户聊天记录。 +请重点理解最近几轮里用户新增、修正、强调的设定信息。 +不要把早期已经被用户否定的内容继续当成最终结论。 + +用户聊天记录: +${toJson(chatHistory)}`; +} + +export function buildEightAnchorSingleTurnPrompt(input: { + currentTurn: number; + progressPercent: number; + quickFillRequested: boolean; + currentAnchorContent: EightAnchorContent; + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; + dynamicState?: PromptDynamicStateInference | PromptDynamicState | null; +}) { + const currentAnchorContent = + normalizeEightAnchorContent(input.currentAnchorContent) ?? + createEmptyEightAnchorContent(); + const dynamicState = buildPromptDynamicState({ + ...input, + currentAnchorContent, + }, input.dynamicState); + + return { + prompt: [ + BASE_SYSTEM_PROMPT, + GLOBAL_HARD_RULES, + MODE_RULES[dynamicState.conversationMode], + USER_SIGNAL_RULES[dynamicState.userInputSignal], + dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null, + renderDynamicStateContext(dynamicState), + renderCurrentAnchorContext(currentAnchorContent), + renderChatHistoryContext(input.chatHistory), + OUTPUT_CONTRACT_REMINDER, + ] + .filter(Boolean) + .join('\n\n'), + dynamicState, + }; +} diff --git a/server-node/src/repositories/customWorldLibraryMetadata.ts b/server-node/src/repositories/customWorldLibraryMetadata.ts index 0cfb4b28..2b4ed11c 100644 --- a/server-node/src/repositories/customWorldLibraryMetadata.ts +++ b/server-node/src/repositories/customWorldLibraryMetadata.ts @@ -19,6 +19,106 @@ function readImageSrc(value: unknown) { return readString(value) || null; } +type CustomWorldCoverRenderMode = 'image' | 'scene_with_roles'; + +function normalizeCoverCharacterRoleIds( + value: unknown, + playableRoles: Record[], +) { + const availableIds = new Set( + playableRoles.map((role) => readString(role.id)).filter(Boolean), + ); + const selectedIds = readArray(value) + .map((entry) => readString(entry)) + .filter((entry) => entry && availableIds.has(entry)); + + if (selectedIds.length > 0) { + return [...new Set(selectedIds)].slice(0, 3); + } + + return [...availableIds].slice(0, 3); +} + +function resolveOpeningSceneImageSrc(profile: CustomWorldProfileRecord) { + const campImage = isRecord(profile.camp) + ? readImageSrc(profile.camp.imageSrc) + : null; + if (campImage) { + return campImage; + } + + return ( + readArray(profile.landmarks) + .map((landmark) => + isRecord(landmark) ? readImageSrc(landmark.imageSrc) : null, + ) + .find(Boolean) || null + ); +} + +function resolveLeadPlayableImageSrc(playableRoles: Record[]) { + return ( + playableRoles + .map((role) => readImageSrc(role.imageSrc)) + .find(Boolean) || null + ); +} + +export function resolveCustomWorldCoverPresentation( + profile: CustomWorldProfileRecord, +): { + imageSrc: string | null; + renderMode: CustomWorldCoverRenderMode; + characterImageSrcs: string[]; + sourceType: 'default' | 'uploaded' | 'generated'; +} { + const playableRoles = readArray(profile.playableNpcs).filter(isRecord); + const cover = isRecord(profile.cover) ? profile.cover : null; + const requestedSourceType = readString(cover?.sourceType); + const sourceType = + requestedSourceType === 'uploaded' || + requestedSourceType === 'generated' + ? requestedSourceType + : 'default'; + + if (sourceType !== 'default') { + const explicitImageSrc = readImageSrc(cover?.imageSrc); + if (explicitImageSrc) { + return { + imageSrc: explicitImageSrc, + renderMode: 'image', + characterImageSrcs: [], + sourceType, + }; + } + } + + const openingSceneImageSrc = resolveOpeningSceneImageSrc(profile); + const roleById = new Map( + playableRoles.map((role) => [readString(role.id), role] as const), + ); + const characterImageSrcs = normalizeCoverCharacterRoleIds( + cover?.characterRoleIds, + playableRoles, + ) + .map((roleId) => readImageSrc(roleById.get(roleId)?.imageSrc)) + .filter((imageSrc): imageSrc is string => Boolean(imageSrc)); + const leadPlayableImageSrc = resolveLeadPlayableImageSrc(playableRoles); + + return { + imageSrc: openingSceneImageSrc || leadPlayableImageSrc, + renderMode: + openingSceneImageSrc && characterImageSrcs.length > 0 + ? 'scene_with_roles' + : 'image', + characterImageSrcs: + openingSceneImageSrc && characterImageSrcs.length > 0 + ? characterImageSrcs + : [], + sourceType: 'default', + }; +} + function detectThemeMode( profile: Pick< CustomWorldProfileRecord, @@ -64,28 +164,7 @@ function detectThemeMode( } export function buildCustomWorldCoverImageSrc(profile: CustomWorldProfileRecord) { - const explicitCampImage = isRecord(profile.camp) - ? readImageSrc(profile.camp.imageSrc) - : null; - if (explicitCampImage) { - return explicitCampImage; - } - - const landmarkImage = readArray(profile.landmarks) - .map((landmark) => (isRecord(landmark) ? readImageSrc(landmark.imageSrc) : null)) - .find(Boolean); - if (landmarkImage) { - return landmarkImage; - } - - const playableImage = readArray(profile.playableNpcs) - .map((role) => (isRecord(role) ? readImageSrc(role.imageSrc) : null)) - .find(Boolean); - if (playableImage) { - return playableImage; - } - - return null; + return resolveCustomWorldCoverPresentation(profile).imageSrc; } export function extractCustomWorldLibraryMetadata(profile: CustomWorldProfileRecord) { diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index dfa38db7..75b8dc27 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -9,7 +9,6 @@ import type { CustomWorldGalleryResponse, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, - PLATFORM_THEMES, PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, @@ -21,7 +20,10 @@ import type { RuntimeSettings, SavedGameSnapshotInput, } from '../../../packages/shared/src/contracts/runtime.js'; -import { CUSTOM_WORLD_GENERATION_MODES } from '../../../packages/shared/src/contracts/runtime.js'; +import { + CUSTOM_WORLD_GENERATION_MODES, + PLATFORM_THEMES, +} from '../../../packages/shared/src/contracts/runtime.js'; import type { QuestGenerationRequest, RuntimeItemIntentRequest, @@ -70,6 +72,12 @@ import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGene import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js'; import { generateQuestForNpcEncounter } from '../services/questService.js'; import { generateRuntimeItemIntents } from '../services/runtimeItemService.js'; +import { + customWorldCoverImageSchema, + customWorldCoverUploadSchema, + generateCustomWorldCoverImage, + uploadCustomWorldCoverImage, +} from '../services/customWorldCoverAssetService.js'; import { generateSceneImage, sceneImageSchema, @@ -420,6 +428,24 @@ export function createRuntimeRoutes(context: AppContext) { }), ); + router.post( + '/custom-world/cover-image', + routeMeta({ operation: 'runtime.customWorld.coverImage' }), + asyncHandler(async (request, response) => { + const payload = customWorldCoverImageSchema.parse(request.body); + sendApiResponse(response, await generateCustomWorldCoverImage(context, payload)); + }), + ); + + router.post( + '/custom-world/cover-upload', + routeMeta({ operation: 'runtime.customWorld.coverUpload' }), + asyncHandler(async (request, response) => { + const payload = customWorldCoverUploadSchema.parse(request.body); + sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload)); + }), + ); + router.post( '/custom-world/scene-image', routeMeta({ operation: 'runtime.customWorld.sceneImage' }), diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts index 867271e4..4919c7fd 100644 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -19,11 +19,14 @@ import { buildCustomWorldLandmarkNetworkBatchPrompt, buildCustomWorldLandmarkSeedBatchJsonRepairPrompt, buildCustomWorldLandmarkSeedBatchPrompt, - buildCustomWorldRawProfileFromFramework, buildCustomWorldRoleBatchJsonRepairPrompt, buildCustomWorldRoleBatchPrompt, buildCustomWorldRoleOutlineBatchJsonRepairPrompt, buildCustomWorldRoleOutlineBatchPrompt, +} from '../prompts/customWorldPrompts.js'; +import { + buildCompiledCustomWorldProfile, + buildCustomWorldRawProfileFromFramework, type CustomWorldGenerationFramework, type CustomWorldGenerationLandmarkOutline, type CustomWorldGenerationRoleBatchStage, @@ -32,9 +35,8 @@ import { normalizeCustomWorldGenerationFramework, normalizeCustomWorldGenerationLandmarkOutlineBatch, normalizeCustomWorldGenerationRoleOutlineBatch, -} from '../../../src/services/customWorld.js'; -import { buildExpandedCustomWorldProfile } from '../../../src/services/customWorldBuilder.js'; -import type { CustomWorldProfile } from '../../../src/types.js'; +} from '../modules/custom-world/runtimeProfile.js'; +import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; import { buildDraftSummaryFromIntent, type CreatorCharacterSeedRecord, @@ -792,7 +794,7 @@ type DraftProgressCallback = ( payload: DraftProgressPayload, ) => void | Promise; -type MergeableNamedRecord = Record & { +type MergeableNamedRecord = { name: string; }; @@ -1366,7 +1368,9 @@ function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) { }); } -function buildDraftThreadsFromRuntimeProfile(profile: CustomWorldProfile) { +function buildDraftThreadsFromRuntimeProfile( + profile: CustomWorldProfile, +): CustomWorldFoundationDraftThread[] { const graphThreads = [ ...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2), ...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2), @@ -1558,8 +1562,12 @@ function convertRuntimeProfileToFoundationDraft(params: { summary: clampText(params.profile.camp.description, 88), } satisfies CustomWorldFoundationDraftCamp) : null, - themePack: params.profile.themePack ?? null, - storyGraph: params.profile.storyGraph ?? null, + themePack: + (params.profile.themePack as unknown as Record | null) ?? + null, + storyGraph: + (params.profile.storyGraph as unknown as Record | null) ?? + null, factions, threads, chapters: [chapter], @@ -1718,7 +1726,7 @@ async function buildFoundationDraftProfileWithLlm(params: { rawProfile.storyNpcs = storyDetailed; rawProfile.landmarks = framework.landmarks; - const runtimeProfile = buildExpandedCustomWorldProfile( + const runtimeProfile = buildCompiledCustomWorldProfile( rawProfile, settingText, ); diff --git a/server-node/src/services/customWorldAgentPhase2.test.ts b/server-node/src/services/customWorldAgentPhase2.test.ts index 8d8297f9..a083ceb4 100644 --- a/server-node/src/services/customWorldAgentPhase2.test.ts +++ b/server-node/src/services/customWorldAgentPhase2.test.ts @@ -74,6 +74,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { profilesByUser.set(userId, current); return current; }, + async listProfileSaveArchives() { + return []; + }, + async resumeProfileSaveArchive() { + return null; + }, async listCustomWorldSessions(userId) { return [...getSessionBucket(userId).values()]; }, diff --git a/server-node/src/services/customWorldAgentPhase3.test.ts b/server-node/src/services/customWorldAgentPhase3.test.ts index dd7d20e4..7e350f12 100644 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -66,6 +66,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { profilesByUser.set(userId, current); return current; }, + async listProfileSaveArchives() { + return []; + }, + async resumeProfileSaveArchive() { + return null; + }, async listCustomWorldSessions(userId) { return [...getSessionBucket(userId).values()]; }, diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts index b82cd13f..b5e347f0 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -67,6 +67,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { profilesByUser.set(userId, current); return current; }, + async listProfileSaveArchives() { + return []; + }, + async resumeProfileSaveArchive() { + return null; + }, async listCustomWorldSessions(userId) { return [...getSessionBucket(userId).values()]; }, diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts index 74c54bca..e68710ee 100644 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -66,6 +66,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { profilesByUser.set(userId, current); return current; }, + async listProfileSaveArchives() { + return []; + }, + async resumeProfileSaveArchive() { + return null; + }, async listCustomWorldSessions(userId) { return [...getSessionBucket(userId).values()]; }, diff --git a/server-node/src/services/customWorldCoverAssetService.ts b/server-node/src/services/customWorldCoverAssetService.ts new file mode 100644 index 00000000..904eaacb --- /dev/null +++ b/server-node/src/services/customWorldCoverAssetService.ts @@ -0,0 +1,566 @@ +import fs from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { z } from 'zod'; + +import type { AppContext } from '../context.js'; +import { badRequest } from '../errors.js'; +import { extractApiErrorMessage } from '../http.js'; + +const TEXT_TO_IMAGE_COVER_MODEL = 'wan2.2-t2i-flash'; +const REFERENCE_IMAGE_COVER_MODEL = 'qwen-image-2.0'; + +const coverRoleSchema = z.object({ + id: z.string().trim().optional().default(''), + name: z.string().trim().optional().default(''), + title: z.string().trim().optional().default(''), + role: z.string().trim().optional().default(''), + description: z.string().trim().optional().default(''), + imageSrc: z.string().trim().optional().default(''), +}); + +const coverCampSchema = z.object({ + name: z.string().trim().optional().default(''), + description: z.string().trim().optional().default(''), + imageSrc: z.string().trim().optional().default(''), +}); + +const coverLandmarkSchema = z.object({ + id: z.string().trim().optional().default(''), + name: z.string().trim().optional().default(''), + description: z.string().trim().optional().default(''), + imageSrc: z.string().trim().optional().default(''), +}); + +const coverProfileSchema = z.object({ + id: z.string().trim().optional().default(''), + name: z.string().trim().optional().default(''), + subtitle: z.string().trim().optional().default(''), + summary: z.string().trim().optional().default(''), + tone: z.string().trim().optional().default(''), + playerGoal: z.string().trim().optional().default(''), + settingText: z.string().trim().optional().default(''), + camp: coverCampSchema.nullable().optional(), + landmarks: z.array(coverLandmarkSchema).optional().default([]), + playableNpcs: z.array(coverRoleSchema).optional().default([]), +}); + +export const customWorldCoverImageSchema = z.object({ + profile: coverProfileSchema, + userPrompt: z.string().trim().optional().default(''), + referenceImageSrc: z.string().trim().optional().default(''), + characterRoleIds: z.array(z.string().trim()).max(3).optional().default([]), + size: z.string().trim().optional().default('1600*900'), +}); + +export const customWorldCoverUploadSchema = z.object({ + profileId: z.string().trim().optional().default(''), + worldName: z.string().trim().optional().default(''), + imageDataUrl: z.string().trim().min(1), +}); + +type CoverProfile = z.infer; + +function parseImageDataUrl(source: string) { + const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); + if (!matched) { + return null; + } + + return { + buffer: Buffer.from(matched[2], 'base64'), + mimeType: matched[1], + }; +} + +async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) { + const trimmedSource = source.trim(); + if (!trimmedSource) { + return ''; + } + + const parsedDataUrl = parseImageDataUrl(trimmedSource); + if (parsedDataUrl) { + return trimmedSource; + } + + if (!trimmedSource.startsWith('/')) { + throw badRequest('参考图必须是 Data URL 或 public 目录下的 URL。'); + } + + const normalizedSource = path.posix + .normalize(trimmedSource) + .replace(/^\/+/u, ''); + const absolutePath = path.resolve( + rootDir, + 'public', + ...normalizedSource.split('/'), + ); + const publicRoot = path.resolve(rootDir, 'public'); + if (!absolutePath.startsWith(publicRoot)) { + throw badRequest('参考图路径越界。'); + } + + const buffer = await readFile(absolutePath); + const extension = path + .extname(absolutePath) + .replace(/^\./u, '') + .toLowerCase(); + const mimeType = (() => { + switch (extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + default: + return 'image/png'; + } + })(); + + return `data:${mimeType};base64,${buffer.toString('base64')}`; +} + +function collectStringsByKey( + value: unknown, + targetKey: string, + results: string[], +) { + if (typeof value === 'string') { + return; + } + + if (Array.isArray(value)) { + value.forEach((entry) => collectStringsByKey(entry, targetKey, results)); + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + Object.entries(value).forEach(([key, nestedValue]) => { + if ( + key === targetKey && + typeof nestedValue === 'string' && + nestedValue.trim() + ) { + results.push(nestedValue.trim()); + return; + } + + collectStringsByKey(nestedValue, targetKey, results); + }); +} + +function findFirstStringByKey(value: unknown, targetKey: string) { + const results: string[] = []; + collectStringsByKey(value, targetKey, results); + return results[0] ?? ''; +} + +function extractTaskId(payload: Record) { + return findFirstStringByKey(payload, 'task_id'); +} + +function extractImageUrls(payload: Record) { + const urls: string[] = []; + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'url', urls); + return [...new Set(urls)]; +} + +function sanitizeSegment(value: string, fallback: string) { + const normalized = value + .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') + .replace(/-+/gu, '-') + .replace(/^-+|-+$/gu, ''); + + return (normalized || fallback).slice(0, 48); +} + +function resolveSelectedRoles( + profile: CoverProfile, + requestedRoleIds: string[], +) { + const roleById = new Map( + profile.playableNpcs.map((role) => [role.id.trim(), role] as const), + ); + const selectedRoles = [...new Set(requestedRoleIds.map((roleId) => roleId.trim()))] + .map((roleId) => roleById.get(roleId)) + .filter((role): role is z.infer => Boolean(role)); + + if (selectedRoles.length > 0) { + return selectedRoles.slice(0, 3); + } + + return profile.playableNpcs.slice(0, 3); +} + +function buildCustomWorldCoverImagePrompt( + profile: CoverProfile, + requestedRoleIds: string[], + userPrompt: string, + options: { + hasReferenceImage?: boolean; + } = {}, +) { + const openingScene = profile.camp ?? profile.landmarks[0] ?? null; + const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds); + const roleSummary = selectedRoles + .map((role) => + [role.name, role.title || role.role, role.description] + .filter(Boolean) + .join(' / '), + ) + .filter(Boolean) + .join(';'); + + return [ + '为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。', + '画面重点是“开局场景 + 2 到 3 个主要角色”的主视觉,不是纯背景图,也不是 UI 截图。', + '构图需要有明显前中后景层次,前景角色清晰、主体集中、适合移动端缩略显示。', + '不要出现任何标题文字、UI、按钮、水印、logo、边框或排版装饰。', + options.hasReferenceImage + ? '已提供一张参考图,请尽量沿用其构图、镜头或色彩气质。' + : '', + profile.name ? `作品名:${profile.name}。` : '', + profile.subtitle ? `副标题:${profile.subtitle}。` : '', + profile.settingText ? `玩家设定:${profile.settingText}。` : '', + profile.summary ? `世界概述:${profile.summary}。` : '', + profile.tone ? `整体基调:${profile.tone}。` : '', + profile.playerGoal ? `主线目标:${profile.playerGoal}。` : '', + openingScene?.name ? `开局场景:${openingScene.name}。` : '', + openingScene?.description ? `场景描述:${openingScene.description}。` : '', + roleSummary ? `需要出现的角色主形象:${roleSummary}。` : '', + userPrompt ? `额外要求:${userPrompt}。` : '', + '整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。', + ] + .filter(Boolean) + .join('\n'); +} + +async function createCoverImageTask(params: { + baseUrl: string; + apiKey: string; + prompt: string; + size: string; +}) { + const response = await fetch( + `${params.baseUrl}/services/aigc/text2image/image-synthesis`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + 'X-DashScope-Async': 'enable', + }, + body: JSON.stringify({ + model: TEXT_TO_IMAGE_COVER_MODEL, + input: { + prompt: params.prompt, + }, + parameters: { + n: 1, + size: params.size, + prompt_extend: true, + watermark: false, + }, + }), + }, + ); + const responseText = await response.text(); + + if (!response.ok) { + throw badRequest( + extractApiErrorMessage(responseText, '创建作品封面生成任务失败'), + ); + } + + return JSON.parse(responseText) as Record; +} + +async function createCoverImageFromReference(params: { + baseUrl: string; + apiKey: string; + prompt: string; + size: string; + referenceImage: string; +}) { + const response = await fetch( + `${params.baseUrl}/services/aigc/multimodal-generation/generation`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: REFERENCE_IMAGE_COVER_MODEL, + input: { + messages: [ + { + role: 'user', + content: [ + { image: params.referenceImage }, + { text: params.prompt }, + ], + }, + ], + }, + parameters: { + n: 1, + size: params.size, + prompt_extend: true, + watermark: false, + }, + }), + }, + ); + const responseText = await response.text(); + + if (!response.ok) { + throw badRequest( + extractApiErrorMessage(responseText, '创建参考图封面任务失败'), + ); + } + + const responsePayload = JSON.parse(responseText) as Record; + const imageUrl = extractImageUrls(responsePayload)[0] ?? ''; + if (!imageUrl) { + throw badRequest('封面生成未返回图片地址'); + } + + return { + imageUrl, + actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(), + taskId: `cover-edit-${Date.now()}`, + }; +} + +async function saveGeneratedCoverAsset(params: { + context: AppContext; + profile: CoverProfile; + imageUrl: string; + taskId: string; + prompt: string; + actualPrompt: string; + size: string; + model: string; +}) { + const imageResponse = await fetch(params.imageUrl); + if (!imageResponse.ok) { + throw badRequest('下载作品封面失败'); + } + + const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); + const contentType = imageResponse.headers.get('content-type') || ''; + const extension = contentType.includes('png') + ? 'png' + : contentType.includes('webp') + ? 'webp' + : 'jpg'; + const assetId = `custom-cover-${Date.now()}`; + const worldSegment = sanitizeSegment( + params.profile.id || params.profile.name, + 'world', + ); + const relativeDir = path.join( + 'generated-custom-world-covers', + worldSegment, + assetId, + ); + const outputDir = path.join(params.context.config.publicDir, relativeDir); + fs.mkdirSync(outputDir, { recursive: true }); + const fileName = `cover.${extension}`; + fs.writeFileSync(path.join(outputDir, fileName), imageBuffer); + + const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`; + fs.writeFileSync( + path.join(outputDir, 'manifest.json'), + `${JSON.stringify( + { + assetId, + sourceType: 'generated', + taskId: params.taskId, + model: params.model, + size: params.size, + prompt: params.prompt, + actualPrompt: params.actualPrompt, + imageSrc, + worldName: params.profile.name, + createdAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + + return { + imageSrc, + assetId, + sourceType: 'generated' as const, + model: params.model, + size: params.size, + taskId: params.taskId, + prompt: params.prompt, + actualPrompt: params.actualPrompt, + }; +} + +export async function uploadCustomWorldCoverImage( + context: AppContext, + input: z.infer, +) { + const payload = customWorldCoverUploadSchema.parse(input); + const parsedDataUrl = parseImageDataUrl(payload.imageDataUrl); + if (!parsedDataUrl) { + throw badRequest('上传封面必须是有效图片 Data URL。'); + } + + const extension = parsedDataUrl.mimeType.includes('png') + ? 'png' + : parsedDataUrl.mimeType.includes('webp') + ? 'webp' + : 'jpg'; + const assetId = `custom-cover-upload-${Date.now()}`; + const worldSegment = sanitizeSegment( + payload.profileId || payload.worldName, + 'world', + ); + const relativeDir = path.join( + 'generated-custom-world-covers', + worldSegment, + assetId, + ); + const outputDir = path.join(context.config.publicDir, relativeDir); + fs.mkdirSync(outputDir, { recursive: true }); + const fileName = `cover.${extension}`; + fs.writeFileSync(path.join(outputDir, fileName), parsedDataUrl.buffer); + + const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`; + fs.writeFileSync( + path.join(outputDir, 'manifest.json'), + `${JSON.stringify( + { + assetId, + sourceType: 'uploaded', + imageSrc, + worldName: payload.worldName, + profileId: payload.profileId, + createdAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + + return { + imageSrc, + assetId, + sourceType: 'uploaded' as const, + }; +} + +export async function generateCustomWorldCoverImage( + context: AppContext, + input: z.infer, +) { + const payload = customWorldCoverImageSchema.parse(input); + const prompt = buildCustomWorldCoverImagePrompt( + payload.profile, + payload.characterRoleIds, + payload.userPrompt, + { + hasReferenceImage: Boolean(payload.referenceImageSrc.trim()), + }, + ); + const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, ''); + const referenceImage = payload.referenceImageSrc.trim() + ? await resolveReferenceImageAsDataUrl( + context.config.projectRoot, + payload.referenceImageSrc, + ) + : ''; + + if (referenceImage) { + const referenceResult = await createCoverImageFromReference({ + baseUrl, + apiKey: context.config.dashScope.apiKey, + prompt, + size: payload.size, + referenceImage, + }); + + return saveGeneratedCoverAsset({ + context, + profile: payload.profile, + imageUrl: referenceResult.imageUrl, + taskId: referenceResult.taskId, + prompt, + actualPrompt: referenceResult.actualPrompt, + size: payload.size, + model: REFERENCE_IMAGE_COVER_MODEL, + }); + } + + const createPayload = await createCoverImageTask({ + baseUrl, + apiKey: context.config.dashScope.apiKey, + prompt, + size: payload.size, + }); + const taskId = extractTaskId(createPayload); + if (!taskId) { + throw badRequest('作品封面任务未返回 task_id'); + } + + const deadline = Date.now() + context.config.dashScope.requestTimeoutMs; + let imageUrl = ''; + let actualPrompt = ''; + + while (Date.now() < deadline) { + const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, { + headers: { + Authorization: `Bearer ${context.config.dashScope.apiKey}`, + }, + }); + const pollText = await pollResponse.text(); + if (!pollResponse.ok) { + throw badRequest( + extractApiErrorMessage(pollText, '查询作品封面任务失败'), + ); + } + + const pollPayload = JSON.parse(pollText) as Record; + const status = findFirstStringByKey(pollPayload, 'task_status').trim(); + if (status === 'SUCCEEDED') { + imageUrl = extractImageUrls(pollPayload)[0] ?? ''; + actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim(); + break; + } + if (status === 'FAILED' || status === 'UNKNOWN') { + throw badRequest( + extractApiErrorMessage(pollText, '作品封面生成任务失败'), + ); + } + + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + if (!imageUrl) { + throw badRequest('作品封面生成超时或未返回图片地址'); + } + + return saveGeneratedCoverAsset({ + context, + profile: payload.profile, + imageUrl, + taskId, + prompt, + actualPrompt, + size: payload.size, + model: TEXT_TO_IMAGE_COVER_MODEL, + }); +} diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts index 15bca835..4af0db2e 100644 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ b/server-node/src/services/customWorldWorkSummaryService.ts @@ -7,6 +7,7 @@ import type { CustomWorldProfileRecord, } from '../../../packages/shared/src/contracts/runtime.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; +import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { buildDraftSummaryFromIntent, @@ -140,12 +141,19 @@ function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) { }; } -function resolvePublishedCover(profile: Record) { - const camp = toRecord(profile.camp); - const playableNpcs = toRecordArray(profile.playableNpcs); - const leadNpc = toRecord(playableNpcs[0]); +function resolveDraftCover(session: CustomWorldAgentSessionRecord) { + const draftProfile = toRecord(session.draftProfile); + if (!draftProfile) { + return { + imageSrc: null, + renderMode: 'image' as const, + characterImageSrcs: [], + }; + } - return toText(camp?.imageSrc) || toText(leadNpc?.imageSrc) || null; + return resolveCustomWorldCoverPresentation( + draftProfile as CustomWorldProfileRecord, + ); } function isLibraryEntry( @@ -175,6 +183,7 @@ export async function listCustomWorldWorkSummaries( const draftItems: CustomWorldWorkSummary[] = sessions.map((session) => { const counts = resolveDraftCounts(session); const roleAssetProgress = resolveDraftRoleAssetProgress(session); + const coverPresentation = resolveDraftCover(session); return { workId: `draft:${session.sessionId}`, @@ -185,7 +194,9 @@ export async function listCustomWorldWorkSummaries( normalizeFoundationDraftProfile(session.draftProfile)?.subtitle || formatDraftStageLabel(session.stage), summary: resolveDraftSummary(session), - coverImageSrc: null, + coverImageSrc: coverPresentation.imageSrc, + coverRenderMode: coverPresentation.renderMode, + coverCharacterImageSrcs: coverPresentation.characterImageSrcs, updatedAt: session.updatedAt, publishedAt: null, stage: session.stage, @@ -213,6 +224,7 @@ export async function listCustomWorldWorkSummaries( (libraryEntry ? toText(libraryEntry.updatedAt) : '') || toText(profileRecord.updatedAt) || new Date().toISOString(); + const coverPresentation = resolveCustomWorldCoverPresentation(profileRecord); const roleVisualReadyCount = playableNpcs.filter( (entry) => Boolean(toText(entry.imageSrc)) && @@ -240,7 +252,9 @@ export async function listCustomWorldWorkSummaries( '这个世界已经可以直接进入体验。', coverImageSrc: (libraryEntry ? libraryEntry.coverImageSrc : null) || - resolvePublishedCover(profileRecord), + coverPresentation.imageSrc, + coverRenderMode: coverPresentation.renderMode, + coverCharacterImageSrcs: coverPresentation.characterImageSrcs, updatedAt, publishedAt: (libraryEntry ? toText(libraryEntry.publishedAt) : '') || diff --git a/server-node/src/services/eightAnchorPromptBuilder.ts b/server-node/src/services/eightAnchorPromptBuilder.ts index dfc749dc..3b84733a 100644 --- a/server-node/src/services/eightAnchorPromptBuilder.ts +++ b/server-node/src/services/eightAnchorPromptBuilder.ts @@ -1,784 +1 @@ -import type { - EightAnchorContent, - HiddenLineValue, - IconicElementValue, - KeyRelationshipValue, - ThemeBoundaryValue, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - createEmptyEightAnchorContent, - normalizeEightAnchorContent, -} from './eightAnchorCompatibilityService.js'; - -export type PromptUserInputSignal = - | 'rich' - | 'normal' - | 'sparse' - | 'correction' - | 'delegate'; - -export type PromptDriftRisk = 'low' | 'medium' | 'high'; - -export type PromptConversationMode = - | 'bootstrap' - | 'expand' - | 'compress' - | 'repair_direction' - | 'force_complete' - | 'closing'; - -export type PromptDynamicState = { - currentTurn: number; - progressPercent: number; - userInputSignal: PromptUserInputSignal; - driftRisk: PromptDriftRisk; - quickFillRequested: boolean; - conversationMode: PromptConversationMode; - judgementSummary: string; -}; - -export type PromptDynamicStateInference = { - userInputSignal?: unknown; - driftRisk?: unknown; - conversationMode?: unknown; - judgementSummary?: unknown; -}; - -const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。 - -你正在和用户一起共创一个游戏世界。每一轮你都必须读取: -1. 当前完整设定结构 -2. 用户聊天记录 - -然后输出: -1. 一版新的完整设定结构 -2. 当前 progress 百分比 -3. 一段直接回复用户的话 - -你必须把“新的完整设定结构”视为下一轮的唯一有效版本。 -你的输出会直接覆盖上一版设定结构。 - -你不是在做局部 patch。 -你不是在做解释报告。 -你不是在给开发者写分析。 -你是在同时完成: -1. 世界设定更新 -2. 当前推进程度判断 -3. 对用户的共创回复`; - -const GLOBAL_HARD_RULES = `全局硬约束: - -1. 必须输出完整的设定结构,而不是只输出变化部分。 -2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。 -3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。 -4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。 -5. progressPercent 最低为 0,不允许为负数。 -6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。 -7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。 -8. replyText 不要写成长篇策划文,不要展开大段世界观百科。 -9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。 -10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。 -11. 你输出的 JSON 必须可以被直接解析。 -12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。`; - -const MODE_RULES: Record = { - bootstrap: `当前模式:bootstrap - -目标: -1. 先把世界的基本方向抓住 -2. 不要一次塞太多新设定 -3. 回复要降低用户开口压力 - -本轮行为要求: -1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索 -2. 如果用户信息很少,不要强行把整套结构一次补满 -3. replyText 要像共创搭档,而不是像审问 -4. 默认只推进一个最关键的问题方向 -5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步 -6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题 -7. 不要把问题问得像表单采集,不要一口气追问多个维度 - -用户体验要求: -1. 让用户觉得“现在很容易继续往下说” -2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉 -3. replyText 最好短、稳、可接话 -4. 如果用户信息很少,也不要显得冷淡或机械`, - expand: `当前模式:expand - -目标: -1. 在保持现有方向的前提下,把设定结构逐步补全 -2. 尽量让一轮输入覆盖多个关键维度 - -本轮行为要求: -1. 继续保留上一版里仍成立的设定 -2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段 -3. replyText 要明确体现“你已经理解了哪些内容” -4. 不要突然大幅改写已经成形的世界 -5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步 -6. 可以适度替用户整理,但不要把回复写成总结报告 -7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感 - -用户体验要求: -1. 让用户感到“我刚说的内容都被接住了” -2. 回复里可以带一点顺势整理感,但不要太像会议纪要 -3. 不要无视用户刚提供的高价值细节 -4. 不要让用户觉得系统在自顾自重写世界`, - compress: `当前模式:compress - -目标: -1. 开始收束当前设定 -2. 减少无效发散 -3. 让 progress 更接近可进入下一阶段 - -本轮行为要求: -1. 新的设定结构优先保留稳定内容,不要无端重写 -2. 对用户本轮输入做高密度吸收 -3. replyText 要更聚焦,不要绕圈 -4. 默认只推进当前最影响 completion 的一步 -5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支 -6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist -7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问 - -用户体验要求: -1. 让用户感觉世界正在变得更稳,而不是越来越散 -2. 让推进感更明确,但不要显得催促 -3. 回复语气应更笃定一些,减少反复横跳 -4. 不要把用户刚补进来的细节又冲淡掉`, - repair_direction: `当前模式:repair_direction - -目标: -1. 处理用户对既有设定的修正 -2. 避免世界方向飘散或自相矛盾 - -本轮行为要求: -1. 如果用户明确改口,新的设定结构必须体现修正后的方向 -2. 对已经不再成立的旧设定,不要机械保留 -3. progressPercent 可以停滞,也可以小幅回落,但不能为负 -4. replyText 要承认用户的修正,并顺着修正后的方向继续聊 -5. 先处理“改掉什么”,再决定“往哪里继续推” -6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向 -7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境 - -用户体验要求: -1. 让用户感到“我刚刚的纠偏真的生效了” -2. 不要和用户辩论旧方案为什么也行 -3. 不要表现出对修正的不情愿 -4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`, - force_complete: `当前模式:force_complete - -目标: -1. 基于当前方向直接补齐剩余设定 -2. 生成一版尽量完整、可进入下一阶段的设定结构 -3. 结束当前收集阶段 - -本轮行为要求: -1. 尽量保留已经形成的世界方向 -2. 对明显缺失的关键维度进行合理补全 -3. 不要继续拉长聊天,不要再追问用户 -4. progressPercent 直接输出为 100 -5. replyText 要自然引导用户点击“生成游戏设定草稿” -6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突 -7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经 -8. replyText 更像阶段完成提示,不再像继续采集信息的对话 - -用户体验要求: -1. 让用户感到“系统已经帮我把能补的补好了” -2. 不要在这一步突然冒出很多陌生设定把用户吓出戏 -3. 回复要有完成感,但不要太官话 -4. 清楚告诉用户下一步可以做什么`, - closing: `当前模式:closing - -目标: -1. 尽量形成一版可用的设定底子 -2. 不再继续发散新世界观 - -本轮行为要求: -1. 优先收束,而不是扩写 -2. 不要大改已经成形的核心设定 -3. progressPercent 接近完成时,replyText 要更像确认与推进 -4. 如果用户没有大改方向,尽量让下一版内容更稳定 -5. 可以轻微补足缺口,但不要再大开新支线 -6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感 -7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题 - -用户体验要求: -1. 让用户感觉作品已经快成了,而不是还在无穷试探 -2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探 -3. 保持留白感,不要把所有东西都一次说死 -4. 让用户自然过渡到下一阶段,而不是突然被切断对话`, -}; - -const USER_SIGNAL_RULES: Record = { - rich: `本轮用户输入信息密度高。 -请尽量从这一轮里提取多个锚点,不要只更新单一方向。 -如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`, - normal: `本轮用户输入为正常补充。 -请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`, - sparse: `本轮用户输入较少或较虚。 -请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。 -replyText 要让用户容易继续往下说。`, - correction: `本轮用户在修正或推翻旧设定。 -请优先吸收修正,不要机械复读旧版本。 -新的完整设定结构必须以修正后的方向为准。`, - delegate: `本轮用户把部分决定权交给你。 -你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。 -新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`, -}; - -const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。 - -这表示用户接受你基于当前方向自动补完剩余设定。 - -本轮要求: -1. 不要再继续提问 -2. 直接输出一版尽量完整的设定结构 -3. progressPercent 直接输出为 100 -4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`; - -const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。 -你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。 - -你必须综合以下信息判断: -1. 当前轮次 currentTurn -2. 当前完成度 progressPercent -3. 用户是否要求自动补全 quickFillRequested -4. 当前完整设定结构 -5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息 - -你需要输出 4 个字段: -1. userInputSignal:只能是 rich / normal / sparse / correction / delegate -2. driftRisk:只能是 low / medium / high -3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing -4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么 - -请按下面的语义判断。 - -一、userInputSignal 定义 -1. rich -- 用户这一轮给了多条可直接落地的有效信息 -- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个 -- 正式生成时应优先高密度吸收,不要只更新一个点 - -2. normal -- 用户在顺着当前方向做正常补充 -- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统 -- 正式生成时应稳定推进并自然接住用户内容 - -3. sparse -- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实 -- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达 -- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问 -- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题 - -4. correction -- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定 -- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction -- correction 的优先级高于 rich 和 normal - -5. delegate -- 用户把部分决定权交给系统 -- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案” -- delegate 关注的是授权关系,不只是信息多寡 - -二、driftRisk 定义 -1. low -- 当前轮输入与已有方向基本一致 -- 没有明显改口或冲突 - -2. medium -- 当前轮带来一定方向变化或扩张 -- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散 - -3. high -- 用户明确纠偏、改口、替换方向,或最近多轮反复修正 -- 这时最重要的是防止旧方向重新回流到正式生成结果里 - -三、conversationMode 选择原则 -1. bootstrap -- 适用于前期、信息少、核心方向未稳定 -- replyText 更适合低压力确认和单点启发 - -2. expand -- 适用于方向已成形,正在顺着现有路线继续补充 -- replyText 更适合总结已接住的内容并往前推一步 - -3. compress -- 适用于中后段,已有骨架,需要开始收束 -- replyText 更适合聚焦最关键缺口,而不是继续开支线 - -4. repair_direction -- 适用于用户正在纠偏 -- replyText 更适合先承认修正,再沿修正后的方向继续推进 - -5. force_complete -- 适用于用户明确要求自动补全 -- replyText 不再提问,而应给出完成感和下一步引导 - -6. closing -- 适用于接近完成但并非强制一键补全 -- replyText 更像确认与收束,而不是前期式探索 - -四、优先级规则 -1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete -2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction -3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate -4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择 - -五、关于 replyText 风格的专门判断要求 -1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问 -2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多 -3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈 -4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进 -5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法 - -六、关于 replyText 用语的硬约束 -1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词 -2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点 -3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户 -4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构 -5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语 - -七、关于 judgementSummary 的写法 -1. 必须简洁,不要写成长篇分析 -2. 必须直接服务于下一轮正式生成 -3. 最好同时包含两层信息: -- 为什么这么判断 -- 正式生成时最该优先做什么,或最该避免什么 - -八、硬性约束 -1. 只能输出 JSON,不能输出解释、代码块或额外说明 -2. 不能发明上下文里不存在的设定事实 -3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定” -4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态 -5. judgementSummary 必须是中文 -6. 输出值必须严格落在给定枚举中`; - -const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字: -{ - "userInputSignal": "normal", - "driftRisk": "low", - "conversationMode": "expand", - "judgementSummary": "" -}`; - -const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字: -{ - "replyText": "", - "progressPercent": 0, - "nextAnchorContent": { - "worldPromise": { - "hook": "", - "differentiator": "", - "desiredExperience": "" - }, - "playerFantasy": { - "playerRole": "", - "corePursuit": "", - "fearOfLoss": "" - }, - "themeBoundary": { - "toneKeywords": [], - "aestheticDirectives": [], - "forbiddenDirectives": [] - }, - "playerEntryPoint": { - "openingIdentity": "", - "openingProblem": "", - "entryMotivation": "" - }, - "coreConflict": { - "surfaceConflicts": [], - "hiddenCrisis": "", - "firstTouchedConflict": "" - }, - "keyRelationships": [ - { - "pairs": "", - "relationshipType": "", - "secretOrCost": "" - } - ], - "hiddenLines": { - "hiddenTruths": [], - "misdirectionHints": [], - "revealPacing": "" - }, - "iconicElements": { - "iconicMotifs": [], - "institutionsOrArtifacts": [], - "hardRules": [] - } - } -}`; - -function toJson(value: unknown) { - return JSON.stringify(value, null, 2); -} - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function getLatestUserText( - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, -) { - return ( - [...chatHistory] - .reverse() - .find((entry) => entry.role === 'user' && entry.content.trim())?.content ?? - '' - ); -} - -function includesAny(text: string, patterns: RegExp[]) { - return patterns.some((pattern) => pattern.test(text)); -} - -function isPromptUserInputSignal( - value: unknown, -): value is PromptUserInputSignal { - return ( - value === 'rich' || - value === 'normal' || - value === 'sparse' || - value === 'correction' || - value === 'delegate' - ); -} - -function isPromptDriftRisk(value: unknown): value is PromptDriftRisk { - return value === 'low' || value === 'medium' || value === 'high'; -} - -function isPromptConversationMode( - value: unknown, -): value is PromptConversationMode { - return ( - value === 'bootstrap' || - value === 'expand' || - value === 'compress' || - value === 'repair_direction' || - value === 'force_complete' || - value === 'closing' - ); -} - -export function detectUserInputSignal( - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, -): PromptUserInputSignal { - const latestUserText = getLatestUserText(chatHistory).trim(); - - if (!latestUserText) { - return 'sparse'; - } - - if (includesAny(latestUserText, [/(不是|改成|改为|换成|重来|推翻|修正)/u])) { - return 'correction'; - } - - if (includesAny(latestUserText, [/(你帮我想|你来定|你决定|你补完)/u])) { - return 'delegate'; - } - - const segments = latestUserText - .split(/[。!?;\n]/u) - .map((item) => item.trim()) - .filter(Boolean); - - if (latestUserText.length <= 10 || segments.length <= 1) { - return 'sparse'; - } - - if (segments.length >= 3 || latestUserText.length >= 60) { - return 'rich'; - } - - return 'normal'; -} - -function summarizeDynamicState( - state: Pick< - PromptDynamicState, - 'userInputSignal' | 'driftRisk' | 'conversationMode' - >, -) { - return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`; -} - -function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) { - return Boolean( - value && - (value.toneKeywords.length > 0 || - value.aestheticDirectives.length > 0 || - value.forbiddenDirectives.length > 0), - ); -} - -function isRelationshipsFilled(value: KeyRelationshipValue[]) { - return value.length > 0; -} - -function isHiddenLinesFilled(value: HiddenLineValue | null) { - return Boolean( - value && - (value.hiddenTruths.length > 0 || - value.misdirectionHints.length > 0 || - value.revealPacing), - ); -} - -function isIconicElementsFilled(value: IconicElementValue | null) { - return Boolean( - value && - (value.iconicMotifs.length > 0 || - value.institutionsOrArtifacts.length > 0 || - value.hardRules.length > 0), - ); -} - -export function detectDriftRisk(params: { - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; - anchorContent: EightAnchorContent; - progressPercent: number; -}) { - const latestUserText = getLatestUserText(params.chatHistory).trim(); - const recentUserMessages = params.chatHistory - .filter((entry) => entry.role === 'user') - .slice(-3) - .map((entry) => entry.content.trim()) - .filter(Boolean); - - const correctionCount = recentUserMessages.filter((entry) => - /(不是|改成|改为|换成|推翻|重来|修正)/u.test(entry), - ).length; - - if ( - correctionCount >= 2 || - (params.progressPercent >= 65 && - /(不是|改成|改为|换成|重来|推翻)/u.test(latestUserText)) - ) { - return 'high' as const; - } - - const normalizedContent = normalizeEightAnchorContent(params.anchorContent); - const filledCount = [ - Boolean(normalizedContent.worldPromise), - Boolean(normalizedContent.playerFantasy), - isThemeBoundaryFilled(normalizedContent.themeBoundary), - Boolean(normalizedContent.playerEntryPoint), - Boolean(normalizedContent.coreConflict), - isRelationshipsFilled(normalizedContent.keyRelationships), - isHiddenLinesFilled(normalizedContent.hiddenLines), - isIconicElementsFilled(normalizedContent.iconicElements), - ].filter(Boolean).length; - - if (filledCount >= 3 && latestUserText.length >= 40) { - return 'medium' as const; - } - - return 'low' as const; -} - -export function pickConversationMode(params: { - currentTurn: number; - progressPercent: number; - userInputSignal: PromptUserInputSignal; - driftRisk: PromptDriftRisk; - quickFillRequested: boolean; -}) { - if (params.quickFillRequested) { - return 'force_complete' as const; - } - - if ( - params.userInputSignal === 'correction' || - params.driftRisk === 'high' - ) { - return 'repair_direction' as const; - } - - if (params.progressPercent >= 85 || params.currentTurn >= 15) { - return 'closing' as const; - } - - if (params.currentTurn > 10 || params.progressPercent >= 65) { - return 'compress' as const; - } - - if (params.currentTurn <= 10 && params.progressPercent < 65) { - return 'expand' as const; - } - - return 'bootstrap' as const; -} - -function buildRuleBasedPromptDynamicState(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; -}): PromptDynamicState { - const userInputSignal = detectUserInputSignal(input.chatHistory); - const driftRisk = detectDriftRisk({ - chatHistory: input.chatHistory, - anchorContent: input.currentAnchorContent, - progressPercent: input.progressPercent, - }); - - const conversationMode = pickConversationMode({ - currentTurn: input.currentTurn, - progressPercent: input.progressPercent, - userInputSignal, - driftRisk, - quickFillRequested: input.quickFillRequested, - }); - - return { - currentTurn: input.currentTurn, - progressPercent: input.progressPercent, - userInputSignal, - driftRisk, - quickFillRequested: input.quickFillRequested, - conversationMode, - judgementSummary: summarizeDynamicState({ - userInputSignal, - driftRisk, - conversationMode, - }), - }; -} - -export function buildPromptDynamicState(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; -}, inference?: PromptDynamicStateInference | null): PromptDynamicState { - const fallbackState = buildRuleBasedPromptDynamicState(input); - - if (!inference) { - return fallbackState; - } - - const userInputSignal = isPromptUserInputSignal(inference.userInputSignal) - ? inference.userInputSignal - : fallbackState.userInputSignal; - const driftRisk = isPromptDriftRisk(inference.driftRisk) - ? inference.driftRisk - : fallbackState.driftRisk; - const conversationMode = isPromptConversationMode(inference.conversationMode) - ? inference.conversationMode - : fallbackState.conversationMode; - const judgementSummary = - toText(inference.judgementSummary) || - summarizeDynamicState({ - userInputSignal, - driftRisk, - conversationMode, - }); - - return { - currentTurn: input.currentTurn, - progressPercent: input.progressPercent, - userInputSignal, - driftRisk, - quickFillRequested: input.quickFillRequested, - conversationMode, - judgementSummary, - }; -} - -export function buildPromptDynamicStateInferencePrompt(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; -}) { - const currentAnchorContent = - normalizeEightAnchorContent(input.currentAnchorContent) ?? - createEmptyEightAnchorContent(); - - return { - systemPrompt: [ - STATE_INFERENCE_SYSTEM_PROMPT, - STATE_INFERENCE_OUTPUT_CONTRACT, - ].join('\n\n'), - userPrompt: [ - `当前轮次:${input.currentTurn}`, - `当前完成度:${input.progressPercent}`, - `是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`, - renderCurrentAnchorContext(currentAnchorContent), - renderChatHistoryContext(input.chatHistory), - ].join('\n\n'), - }; -} - -function renderDynamicStateContext(dynamicState: PromptDynamicState) { - return `上一轮预判得到的创作状态如下。 -正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。 - -创作状态: -- userInputSignal: ${dynamicState.userInputSignal} -- driftRisk: ${dynamicState.driftRisk} -- conversationMode: ${dynamicState.conversationMode} -- judgementSummary: ${dynamicState.judgementSummary}`; -} - -function renderCurrentAnchorContext(anchorContent: EightAnchorContent) { - return `当前完整设定结构如下。 -你必须把它视为上一版有效世界底子。 - -如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。 -如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。 - -当前完整设定结构: -${toJson(normalizeEightAnchorContent(anchorContent))}`; -} - -function renderChatHistoryContext( - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, -) { - return `以下是用户聊天记录。 -请重点理解最近几轮里用户新增、修正、强调的设定信息。 -不要把早期已经被用户否定的内容继续当成最终结论。 - -用户聊天记录: -${toJson(chatHistory)}`; -} - -export function buildEightAnchorSingleTurnPrompt(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; - dynamicState?: PromptDynamicStateInference | PromptDynamicState | null; -}) { - const currentAnchorContent = - normalizeEightAnchorContent(input.currentAnchorContent) ?? - createEmptyEightAnchorContent(); - const dynamicState = buildPromptDynamicState({ - ...input, - currentAnchorContent, - }, input.dynamicState); - - return { - prompt: [ - BASE_SYSTEM_PROMPT, - GLOBAL_HARD_RULES, - MODE_RULES[dynamicState.conversationMode], - USER_SIGNAL_RULES[dynamicState.userInputSignal], - dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null, - renderDynamicStateContext(dynamicState), - renderCurrentAnchorContext(currentAnchorContent), - renderChatHistoryContext(input.chatHistory), - OUTPUT_CONTRACT_REMINDER, - ] - .filter(Boolean) - .join('\n\n'), - dynamicState, - }; -} +export * from '../prompts/eightAnchorPrompts.js'; diff --git a/server-node/src/services/sceneImageService.test.ts b/server-node/src/services/sceneImageService.test.ts index f139780d..83bbcbf0 100644 --- a/server-node/src/services/sceneImageService.test.ts +++ b/server-node/src/services/sceneImageService.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -48,10 +52,9 @@ function sendJson(res: ServerResponse, payload: unknown) { } async function withHttpServer( - buildHandler: (baseUrl: string) => ( - req: IncomingMessage, - res: ServerResponse, - ) => void | Promise, + buildHandler: ( + baseUrl: string, + ) => (req: IncomingMessage, res: ServerResponse) => void | Promise, run: (baseUrl: string) => Promise, ) { let handler: ( @@ -93,7 +96,9 @@ async function withHttpServer( } test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-scene-image-'), + ); const capturedRequests: Array<{ pathname: string; @@ -104,7 +109,9 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t (baseUrl) => async (req, res) => { const url = new URL(req.url || '/', baseUrl); const bodyText = - req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined; + req.method === 'POST' + ? (await readRequestBody(req)).toString('utf8') + : undefined; capturedRequests.push({ pathname: url.pathname, bodyText, @@ -122,7 +129,10 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/scene-task-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v1/tasks/scene-task-1' + ) { sendJson(res, { output: { task_status: 'SUCCEEDED', @@ -168,7 +178,8 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t assert.equal(result.actualPrompt, '整理后的场景提示词'); const createRequest = capturedRequests.find( - (entry) => entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis', + (entry) => + entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis', ); assert.ok(createRequest?.bodyText); @@ -186,17 +197,20 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t assert.equal(createPayload.input.negative_prompt, '模糊'); assert.equal(createPayload.parameters.size, '1280*720'); - const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1)); + const savedImagePath = path.join( + tempRoot, + 'public', + result.imageSrc.slice(1), + ); assert.equal(fs.existsSync(savedImagePath), true); }, ); }); -test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-')); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true }); - fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER); +test('generateSceneImage builds the scene prompt on the server when the client only submits world and landmark context', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-scene-image-'), + ); const capturedRequests: Array<{ pathname: string; @@ -207,7 +221,9 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is (baseUrl) => async (req, res) => { const url = new URL(req.url || '/', baseUrl); const bodyText = - req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined; + req.method === 'POST' + ? (await readRequestBody(req)).toString('utf8') + : undefined; capturedRequests.push({ pathname: url.pathname, bodyText, @@ -215,7 +231,134 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/multimodal-generation/generation' + url.pathname === '/api/v1/services/aigc/text2image/image-synthesis' + ) { + sendJson(res, { + output: { + task_id: 'scene-task-2', + }, + }); + return; + } + + if ( + req.method === 'GET' && + url.pathname === '/api/v1/tasks/scene-task-2' + ) { + sendJson(res, { + output: { + task_status: 'SUCCEEDED', + results: [ + { + url: `${baseUrl}/downloads/scene.png`, + actual_prompt: '服务端整理后的像素风提示词', + }, + ], + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/downloads/scene.png') { + res.statusCode = 200; + res.setHeader('Content-Type', 'image/png'); + res.end(PNG_BUFFER); + return; + } + + res.statusCode = 404; + res.end('not found'); + }, + async (dashScopeBaseUrl) => { + const context = { + config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), + } as AppContext; + + const result = await generateSceneImage(context, { + worldName: '', + profileId: '', + landmarkName: '', + landmarkId: '', + userPrompt: '想让灯塔更偏暴风夜', + profile: { + id: 'world-3', + name: '潮雾群岛', + subtitle: '迷雾海界', + summary: '岛链被旧航道和风暴一起缠住。', + tone: '潮湿、压迫、带着未知回声', + playerGoal: '先找到断线的引路火', + settingText: '玩家在海雾和旧航道之间寻找可以靠岸的线索。', + }, + landmark: { + id: 'landmark-3', + name: '旧港灯塔', + description: '灯塔外墙被海盐侵蚀,塔下平台还能勉强落脚。', + dangerLevel: 'high', + }, + }); + + assert.equal(result.ok, true); + + const createRequest = capturedRequests.find( + (entry) => + entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis', + ); + assert.ok(createRequest?.bodyText); + + const createPayload = JSON.parse(createRequest.bodyText) as { + input: { + prompt: string; + negative_prompt?: string; + }; + }; + + assert.match(createPayload.input.prompt, /世界:潮雾群岛,迷雾海界。/u); + assert.match(createPayload.input.prompt, /场景名称:旧港灯塔。/u); + assert.match( + createPayload.input.prompt, + /本次想要生成的画面内容:想让灯塔更偏暴风夜。/u, + ); + assert.match(createPayload.input.prompt, /危险感强烈/u); + assert.equal( + createPayload.input.negative_prompt, + '文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头', + ); + }, + ); +}); + +test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-scene-image-'), + ); + const publicDir = path.join(tempRoot, 'public'); + fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true }); + fs.writeFileSync( + path.join(publicDir, 'scene_bg', 'reference-layout.png'), + PNG_BUFFER, + ); + + const capturedRequests: Array<{ + pathname: string; + bodyText?: string; + }> = []; + + await withHttpServer( + (baseUrl) => async (req, res) => { + const url = new URL(req.url || '/', baseUrl); + const bodyText = + req.method === 'POST' + ? (await readRequestBody(req)).toString('utf8') + : undefined; + capturedRequests.push({ + pathname: url.pathname, + bodyText, + }); + + if ( + req.method === 'POST' && + url.pathname === + '/api/v1/services/aigc/multimodal-generation/generation' ) { sendJson(res, { output: { @@ -235,7 +378,10 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is return; } - if (req.method === 'GET' && url.pathname === '/downloads/reference-scene.png') { + if ( + req.method === 'GET' && + url.pathname === '/downloads/reference-scene.png' + ) { res.statusCode = 200; res.setHeader('Content-Type', 'image/png'); res.end(PNG_BUFFER); @@ -273,7 +419,8 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is const createRequest = capturedRequests.find( (entry) => - entry.pathname === '/api/v1/services/aigc/multimodal-generation/generation', + entry.pathname === + '/api/v1/services/aigc/multimodal-generation/generation', ); assert.ok(createRequest?.bodyText); diff --git a/server-node/src/services/sceneImageService.ts b/server-node/src/services/sceneImageService.ts index 16068715..3f04e4d6 100644 --- a/server-node/src/services/sceneImageService.ts +++ b/server-node/src/services/sceneImageService.ts @@ -4,12 +4,33 @@ import path from 'node:path'; import { z } from 'zod'; +import { + buildCustomWorldSceneImagePrompt, + DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, +} from '../prompts/customWorldPrompts.js'; import type { AppContext } from '../context.js'; import { badRequest } from '../errors.js'; import { extractApiErrorMessage } from '../http.js'; +const sceneImageProfileSchema = z.object({ + id: z.string().trim().optional().default(''), + name: z.string().trim().optional().default(''), + subtitle: z.string().trim().optional().default(''), + summary: z.string().trim().optional().default(''), + tone: z.string().trim().optional().default(''), + playerGoal: z.string().trim().optional().default(''), + settingText: z.string().trim().optional().default(''), +}); + +const sceneImageLandmarkSchema = z.object({ + id: z.string().trim().optional().default(''), + name: z.string().trim().optional().default(''), + description: z.string().trim().optional().default(''), + dangerLevel: z.string().trim().optional().default(''), +}); + export const sceneImageSchema = z.object({ - prompt: z.string().trim().min(1), + prompt: z.string().trim().optional().default(''), negativePrompt: z.string().trim().optional().default(''), size: z.string().trim().optional().default('1280*720'), model: z.string().trim().optional().default(''), @@ -18,6 +39,9 @@ export const sceneImageSchema = z.object({ landmarkName: z.string().trim().optional().default(''), landmarkId: z.string().trim().optional().default(''), referenceImageSrc: z.string().trim().optional().default(''), + userPrompt: z.string().trim().optional().default(''), + profile: sceneImageProfileSchema.optional(), + landmark: sceneImageLandmarkSchema.optional(), }); const TEXT_TO_IMAGE_SCENE_MODEL = 'wan2.2-t2i-flash'; const REFERENCE_IMAGE_SCENE_MODEL = 'qwen-image-2.0'; @@ -63,7 +87,10 @@ async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) { } const buffer = await readFile(absolutePath); - const extension = path.extname(absolutePath).replace(/^\./u, '').toLowerCase(); + const extension = path + .extname(absolutePath) + .replace(/^\./u, '') + .toLowerCase(); const mimeType = (() => { switch (extension) { case 'jpg': @@ -98,7 +125,11 @@ function collectStringsByKey( } Object.entries(value).forEach(([key, nestedValue]) => { - if (key === targetKey && typeof nestedValue === 'string' && nestedValue.trim()) { + if ( + key === targetKey && + typeof nestedValue === 'string' && + nestedValue.trim() + ) { results.push(nestedValue.trim()); return; } @@ -244,17 +275,53 @@ function ensurePayload( payload: z.infer, _defaultModel: string, ) { - if (!payload.landmarkName && !payload.landmarkId) { - throw badRequest('landmarkName 或 landmarkId 至少要提供一个'); - } - const referenceImageSrc = typeof payload.referenceImageSrc === 'string' ? payload.referenceImageSrc.trim() : ''; + const profile = payload.profile ?? sceneImageProfileSchema.parse({}); + const landmark = payload.landmark ?? sceneImageLandmarkSchema.parse({}); + const profileId = payload.profileId.trim() || profile.id; + const worldName = payload.worldName.trim() || profile.name; + const landmarkId = payload.landmarkId.trim() || landmark.id; + const landmarkName = payload.landmarkName.trim() || landmark.name; + + if (!landmarkName && !landmarkId) { + throw badRequest('landmarkName 或 landmarkId 至少要提供一个'); + } + const prompt = + payload.prompt.trim() || + buildCustomWorldSceneImagePrompt( + { + ...profile, + id: profileId, + name: worldName, + }, + { + ...landmark, + id: landmarkId, + name: landmarkName, + }, + payload.userPrompt, + { + hasReferenceImage: Boolean(referenceImageSrc), + }, + ); + if (!prompt) { + throw badRequest('prompt 不能为空'); + } + const negativePrompt = + payload.negativePrompt.trim() || + DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT; return { ...payload, + prompt, + negativePrompt, + worldName, + profileId, + landmarkName, + landmarkId, referenceImageSrc, model: referenceImageSrc ? REFERENCE_IMAGE_SCENE_MODEL @@ -286,7 +353,11 @@ async function saveSceneImageAsset(params: { const worldSegment = (payload.profileId || payload.worldName || 'world') .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') .slice(0, 48); - const landmarkSegment = (payload.landmarkId || payload.landmarkName || 'landmark') + const landmarkSegment = ( + payload.landmarkId || + payload.landmarkName || + 'landmark' + ) .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') .slice(0, 48); const relativeDir = path.join( @@ -338,7 +409,10 @@ export async function generateSceneImage( context: AppContext, input: z.infer, ) { - const payload = ensurePayload(input, context.config.dashScope.imageModel); + const payload = ensurePayload( + sceneImageSchema.parse(input), + context.config.dashScope.imageModel, + ); const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, ''); const referenceImage = payload.referenceImageSrc.trim() ? await resolveReferenceImageAsDataUrl( diff --git a/src/components/AdventurePanel.npcChat.test.tsx b/src/components/AdventurePanel.npcChat.test.tsx index 81b69c09..707b283b 100644 --- a/src/components/AdventurePanel.npcChat.test.tsx +++ b/src/components/AdventurePanel.npcChat.test.tsx @@ -57,6 +57,11 @@ test('adventure panel treats negative affinity updates as relationship change sy acknowledgeQuestCompletion: () => undefined, claimQuestReward: () => null, }} + npcChatQuestOfferUi={{ + replacePendingOffer: async () => false, + abandonPendingOffer: () => false, + acceptPendingOffer: () => null, + }} goalStack={{ northStarGoal: null, activeGoal: null, diff --git a/src/components/AdventurePanel.test.tsx b/src/components/AdventurePanel.test.tsx index 76a9cee5..ad4905cf 100644 --- a/src/components/AdventurePanel.test.tsx +++ b/src/components/AdventurePanel.test.tsx @@ -75,6 +75,11 @@ function renderPanel( acknowledgeQuestCompletion: () => undefined, claimQuestReward: () => null, }} + npcChatQuestOfferUi={{ + replacePendingOffer: async () => false, + abandonPendingOffer: () => false, + acceptPendingOffer: () => null, + }} goalStack={{ northStarGoal: null, activeGoal: null, @@ -174,3 +179,67 @@ test('adventure panel shows npc chat custom input and exit button in chat mode', expect(html).toContain('发送'); expect(html).not.toContain('换一换'); }); + +test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => { + const viewOption = createOption('npc_chat_quest_offer_view', '查看任务'); + viewOption.runtimePayload = { + npcChatQuestOfferAction: 'view', + }; + const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务'); + replaceOption.runtimePayload = { + npcChatQuestOfferAction: 'replace', + }; + const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务'); + abandonOption.runtimePayload = { + npcChatQuestOfferAction: 'abandon', + }; + const currentStory: StoryMoment = { + text: '柳无声把真正的委托说了出来。', + displayMode: 'dialogue', + dialogue: [ + { speaker: 'player', text: '你像是还有别的话想说。' }, + { speaker: 'npc', speakerName: '柳无声', text: '确实有一件事想正式托付给你。' }, + ], + options: [viewOption, replaceOption, abandonOption], + npcChatState: { + npcId: 'npc-liu', + npcName: '柳无声', + turnCount: 2, + customInputPlaceholder: '输入你想对 TA 说的话', + pendingQuestOffer: { + quest: { + id: 'quest-liu-1', + issuerNpcId: 'npc-liu', + issuerNpcName: '柳无声', + sceneId: 'scene-bamboo', + title: '竹林密信', + description: '替柳无声查清竹林中的密信来源。', + summary: '去竹林查清密信来源。', + objective: { + kind: 'inspect_treasure', + requiredCount: 1, + }, + progress: 0, + status: 'active', + reward: { + affinityBonus: 5, + currency: 10, + items: [], + }, + rewardText: '完成后可获得报酬。', + }, + }, + }, + }; + + const html = renderPanel(currentStory, [viewOption, replaceOption, abandonOption], { + onSubmitNpcChatInput: () => true, + onExitNpcChat: () => true, + }); + + expect(html).toContain('查看任务'); + expect(html).toContain('更换任务'); + expect(html).toContain('放弃任务'); + expect(html).not.toContain('发送'); + expect(html).not.toContain('输入你想对 TA 说的话'); +}); diff --git a/src/components/AdventurePanel.tsx b/src/components/AdventurePanel.tsx index 73daf3ca..eebc3769 100644 --- a/src/components/AdventurePanel.tsx +++ b/src/components/AdventurePanel.tsx @@ -28,7 +28,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects'; import { isQuestReadyToClaim } from '../data/questFlow'; import { getScenePresetById } from '../data/scenePresets'; import { getOptionImpactSummary } from '../hooks/combatStoryUtils'; -import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration'; +import type { + BattleRewardUi, + NpcChatQuestOfferUi, + QuestFlowUi, +} from '../hooks/useStoryGeneration'; import type { ChapterState, Character, @@ -70,6 +74,7 @@ interface AdventurePanelProps { worldType: WorldType | null; quests: QuestLogEntry[]; questUi: QuestFlowUi; + npcChatQuestOfferUi: NpcChatQuestOfferUi; goalStack: GoalStackState; goalPulse: GoalPulseEvent | null; onDismissGoalPulse: () => void; @@ -625,6 +630,7 @@ export function AdventurePanel({ worldType, quests, questUi, + npcChatQuestOfferUi, goalStack, goalPulse, onDismissGoalPulse, @@ -646,6 +652,8 @@ export function AdventurePanel({ const dialogueTurns = currentStory.dialogue ?? []; const npcChatState = currentStory.npcChatState ?? null; const isNpcChatMode = Boolean(npcChatState); + const pendingNpcQuestOffer = npcChatState?.pendingQuestOffer?.quest ?? null; + const isNpcQuestOfferMode = Boolean(pendingNpcQuestOffer); const isStoryStreaming = Boolean(currentStory.streaming); const shouldHideChoiceUi = hideOptions; const storyScrollContainerRef = useRef(null); @@ -689,8 +697,10 @@ export function AdventurePanel({ [quests], ); const selectedQuest = useMemo( - () => quests.find((quest) => quest.id === selectedQuestId) ?? null, - [quests, selectedQuestId], + () => + quests.find((quest) => quest.id === selectedQuestId) ?? + (pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null), + [pendingNpcQuestOffer, quests, selectedQuestId], ); const rewardQuest = useMemo( () => quests.find((quest) => quest.id === rewardQuestId) ?? null, @@ -901,6 +911,27 @@ export function AdventurePanel({ Boolean(selectedRewardItem); const handleOptionChoice = (option: StoryOption) => { + const pendingQuestAction = + typeof option.runtimePayload?.npcChatQuestOfferAction === 'string' + ? option.runtimePayload.npcChatQuestOfferAction + : null; + if (pendingQuestAction && pendingNpcQuestOffer) { + if (pendingQuestAction === 'view') { + setSelectedQuestId(pendingNpcQuestOffer.id); + return; + } + + if (pendingQuestAction === 'replace') { + void npcChatQuestOfferUi.replacePendingOffer(); + return; + } + + if (pendingQuestAction === 'abandon') { + npcChatQuestOfferUi.abandonPendingOffer(); + return; + } + } + if ( option.interaction?.kind === 'npc' && option.interaction.action === 'quest_accept' @@ -1179,7 +1210,7 @@ export function AdventurePanel({ ); })} - {isNpcChatMode ? ( + {isNpcChatMode && !isNpcQuestOfferMode ? (
{ + const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer(); + if (!acceptedQuestId) return null; + setSelectedQuestId(null); + return acceptedQuestId; + }} /> )} diff --git a/src/components/CustomWorldCoverArtwork.tsx b/src/components/CustomWorldCoverArtwork.tsx new file mode 100644 index 00000000..b9141b18 --- /dev/null +++ b/src/components/CustomWorldCoverArtwork.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react'; + +import type { CustomWorldCoverRenderMode } from '../services/customWorldCover'; + +const COVER_PORTRAIT_CLASS_NAMES = [ + 'h-[54%] w-[24%] translate-y-[8%]', + 'h-[68%] w-[30%]', + 'h-[56%] w-[24%] translate-y-[10%]', +] as const; + +type CustomWorldCoverArtworkProps = { + imageSrc?: string | null; + title: string; + fallbackLabel: string; + renderMode?: CustomWorldCoverRenderMode; + characterImageSrcs?: string[]; + className?: string; + overlay?: ReactNode; +}; + +export function CustomWorldCoverArtwork({ + imageSrc, + title, + fallbackLabel, + renderMode = 'image', + characterImageSrcs = [], + className = '', + overlay, +}: CustomWorldCoverArtworkProps) { + const coverCharacterImageSrcs = characterImageSrcs.slice(0, 3); + + return ( +
+ {imageSrc ? ( + {title} + ) : null} +
+ {!imageSrc ? ( +
+ {fallbackLabel} +
+ ) : null} + {renderMode === 'scene_with_roles' && coverCharacterImageSrcs.length > 0 ? ( + <> +
+
+ {coverCharacterImageSrcs.map((characterImageSrc, index) => ( +
+ +
+ ))} +
+ + ) : null} + {overlay ? ( +
{overlay}
+ ) : null} +
+ ); +} + +export default CustomWorldCoverArtwork; diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index fb94f7cf..f21256a1 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -18,9 +18,11 @@ import { resolveCustomWorldLandmarkImageMap, } from '../data/customWorldVisuals'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; +import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover'; import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent'; import { AnimationState, Character, CustomWorldProfile } from '../types'; import { CharacterAnimator } from './CharacterAnimator'; +import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork'; import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; @@ -941,6 +943,10 @@ export function CustomWorldEntityCatalog({ 1 + (pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0), } satisfies Record; + const coverPresentation = useMemo( + () => resolveCustomWorldCoverPresentation(profile), + [profile], + ); const bulkDeleteTab: BulkDeleteTab | null = activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null; @@ -1124,6 +1130,40 @@ export function CustomWorldEntityCatalog({
+
+ {coverPresentation.sourceType === 'uploaded' + ? '上传封面' + : coverPresentation.sourceType === 'generated' + ? 'AI封面' + : '默认封面'} + + } + actions={ + !readOnly ? ( + onEditTarget({ kind: 'cover' })} + tone="sky" + > + 编辑 + + ) : null + } + > +
+ +
+
+
({ ...createProfileWithLandmark(), camp: { name: '潮灯居', diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 6276ef83..e72e408e 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -24,7 +24,17 @@ import { generateCustomWorldSceneImage, generateCustomWorldSceneNpc, } from '../services/aiService'; +import { + generateCustomWorldCoverImage, + uploadCustomWorldCoverImage, + type CustomWorldCoverAssetResult, +} from '../services/customWorldCoverAssetService'; +import { buildSkillActionPrompt } from '../prompts/customWorldEntityActionPrompts'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; +import { + buildDefaultCustomWorldCoverProfile, + resolveCustomWorldCoverPresentation, +} from '../services/customWorldCover'; import { AnimationState, type Character, @@ -33,6 +43,7 @@ import { CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, + type CustomWorldCoverProfile, type CustomWorldRoleInitialItem, type CustomWorldRoleRelation, type CustomWorldRoleSkill, @@ -47,6 +58,7 @@ import { } from './asset-studio/characterAssetWorkflowPersistence'; import { useAuthUi } from './auth/AuthUiContext'; import { CharacterAnimator } from './CharacterAnimator'; +import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork'; import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults'; import { CustomWorldNpcPortrait, @@ -57,6 +69,7 @@ import { PixelIcon } from './PixelIcon'; export type CustomWorldEditorTarget = | { kind: 'world' } + | { kind: 'cover' } | { kind: 'camp' } | { kind: 'playable'; mode: 'create' } | { kind: 'playable'; mode: 'edit'; id: string } @@ -273,24 +286,6 @@ function inferSkillActionTemplateId(skill: Pick; - skill: Pick; -}) { - 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(' '); -} - function createRoleRelationDraft(seedLabel: string, index: number): CustomWorldRoleRelation { return { id: createEntryId('relation', seedLabel, Date.now() + index), @@ -1687,6 +1682,408 @@ function SceneImageGenerationModal({ ); } +const FIXED_COVER_IMAGE_SIZE = '1600*900'; + +function buildGeneratedCoverProfile( + result: CustomWorldCoverAssetResult, +): CustomWorldCoverProfile { + return { + sourceType: result.sourceType, + imageSrc: result.imageSrc, + characterRoleIds: [], + }; +} + +function CoverImageGenerationModal({ + profile, + onApply, + onClose, +}: { + profile: CustomWorldProfile; + onApply: (result: CustomWorldCoverAssetResult) => void; + onClose: () => void; +}) { + const initialPresentation = useMemo( + () => resolveCustomWorldCoverPresentation(profile), + [profile], + ); + const [userPrompt, setUserPrompt] = useDraft(profile.summary || profile.name); + const [referenceImageSrc, setReferenceImageSrc] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + const [latestResult, setLatestResult] = + useState(null); + const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false); + + const previewImageSrc = latestResult?.imageSrc || initialPresentation.imageSrc; + + const handleReferenceImageChange = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0]; + event.currentTarget.value = ''; + if (!file) { + return; + } + + try { + const dataUrl = await readImageFileAsDataUrl(file); + setReferenceImageSrc(dataUrl); + setError(null); + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '参考图读取失败,请重试。', + ); + } + }; + + const handleRequestClose = () => { + if (isGenerating) { + return; + } + if (latestResult) { + setIsExitConfirmOpen(true); + return; + } + onClose(); + }; + + const handleGenerate = async () => { + if (!userPrompt.trim()) { + setError('请先补一句你想要的封面氛围。'); + return; + } + + setIsGenerating(true); + setError(null); + + try { + const result = await generateCustomWorldCoverImage({ + profile, + userPrompt, + referenceImageSrc, + characterRoleIds: + profile.cover?.sourceType === 'default' + ? profile.cover.characterRoleIds + : buildDefaultCustomWorldCoverProfile(profile).characterRoleIds, + size: FIXED_COVER_IMAGE_SIZE, + }); + setLatestResult(result); + } catch (generationError) { + setError( + generationError instanceof Error + ? generationError.message + : '作品封面生成失败,请稍后重试。', + ); + } finally { + setIsGenerating(false); + } + }; + + const handleSave = () => { + if (!latestResult || isGenerating) { + return; + } + onApply(latestResult); + onClose(); + }; + + return ( + <> + +
+
+ +