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/.env.example b/.env.example index f95a6784..370637f3 100644 --- a/.env.example +++ b/.env.example @@ -99,7 +99,7 @@ VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" # Server-side DashScope endpoint and API key used by the local scene-image proxy. DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" -DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990" +DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY" # Optional model name for custom-world scene image generation. DASHSCOPE_IMAGE_MODEL="wan2.7-image" @@ -107,10 +107,17 @@ DASHSCOPE_IMAGE_MODEL="wan2.7-image" # Optional model names for character asset studio. DASHSCOPE_CHARACTER_VISUAL_MODEL="wan2.7-image-pro" DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL="wan2.7-image-pro" -DASHSCOPE_CHARACTER_VIDEO_MODEL="wan2.7-i2v" DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL="wan2.7-r2v" DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL="wan2.2-animate-move" +# Optional Ark Seedance config for character animation image-to-video. +# If omitted, image-to-video will fall back to `ARK_API_KEY` / `LLM_API_KEY` +# and `ARK_BASE_URL` / `LLM_BASE_URL`. +ARK_CHARACTER_VIDEO_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" +ARK_CHARACTER_VIDEO_API_KEY="" +ARK_CHARACTER_VIDEO_MODEL="doubao-seedance-2-0-fast-260128" +ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS="420000" + # Optional: server-side polling timeout for custom-world scene image generation, in milliseconds. DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS="150000" diff --git a/.env.local b/.env.local index 4dcfe5e5..c858f20a 100644 --- a/.env.local +++ b/.env.local @@ -1,5 +1,9 @@ VITE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" +ARK_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" +ARK_CHARACTER_VIDEO_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" +ARK_CHARACTER_VIDEO_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" +ARK_CHARACTER_VIDEO_MODEL="doubao-seedance-2-0-fast-260128" VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990" 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 fb66a71a..0f2ca507 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # AGENTS.md ## 项目约束 +- 前端工程node版本使用22.22.2 +- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 - 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中 - 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。 - 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。 @@ -15,6 +17,7 @@ - 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。 - 禁止将功能说明描述类的文本默认写入UI界面中。 - prd文档中每个模块的描述要落地设计到可以精准编码到位,不能出现需求落地漂移。 +- 点击按钮弹出独立的面板的设计不要实现成在当前面板下面显示内容。 ## 文档图谱 @@ -79,7 +82,7 @@ docs/ │ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md │ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md │ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md -├─ reference/ +├─ reference/[QwenSpriteSheetTool.tsx](src/tools/QwenSpriteSheetTool.tsx) │ ├─ README.md │ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md └─ technical/ diff --git a/docs/audits/README.md b/docs/audits/README.md index e7516ac1..af0182cb 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -15,6 +15,8 @@ - [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md):Function 运行时完整测试、服务端承接验证与当前门禁缺口。 - [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。 - [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。 +- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。 +- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 ## 推荐使用方式 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 new file mode 100644 index 00000000..58a6ce54 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md @@ -0,0 +1,608 @@ +# 工程清理与后端边界审计(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. 审计目标 + +本次审计只回答四类问题: + +1. 项目里哪些内容已经是高置信度的垃圾、临时产物或无入口代码。 +2. 哪些实现属于双份真相、重复映射或旧链路残留。 +3. 哪些前端代码仍然承担了应迁移到 Express 后端的职责。 +4. 哪些文件已经大到会持续拖累迭代效率,需要优先拆分。 + +--- + +## 1. 结论先行 + +当前仓库的主要问题不是“有一些小工具没人用”,而是四类结构性噪音同时存在: + +1. **仓库噪音产物仍然很多。** + 根目录残留了大量 `.codex-*.log`、`tmp_*`、旧截图/HTML,以及 `temp-build-goal-check/` 这类大体量检查产物,已经不是单个文件层面的脏数据,而是在持续污染工程视野。 +2. **旧入口和新入口并存,形成了明显的冗余链路。** + `scripts/dev-server/localApiPlugins.ts` 已经退出当前正式开发入口,但仍保留了 LLM proxy、JSON 写盘、资产发布等整套旧 Vite 本地 API 机制。 +3. **前端仍然承载了过多运行时规则与 AI 编排。** + `src/services/ai.ts`、`src/services/customWorld.ts`、`src/hooks/story/npcEncounterActions.ts` 这类文件,仍在浏览器里承担 prompt 组装、规则判定、奖励结算、剧情推进等职责。 +4. **后端边界还没有真正闭合。** + `server-node` 虽然已经承接了大量路由和运行时动作,但仍直接 import `src/services/customWorld*.ts` 和 `src/types.ts`,说明后端领域层还没有完全从前端目录中独立出来。 + +一句话判断: + +**这轮优先级不该再是继续堆功能,而是先清仓库噪音与无入口孤岛,再把前后端双份真相收口,最后拆新的巨型热点文件。** + +--- + +## 2. 本次审计方法与口径 + +### 2.1 方法 + +本次审计结合了四类证据: + +1. 文档基线: + - `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` + - `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` + - `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` + - `scripts/dev-server/README.md` +2. 当前入口核对: + - `src/main.tsx` + - `src/routing/appRoutes.tsx` + - `src/App.tsx` + - `package.json` + - `server-node/package.json` +3. 静态依赖扫描: + - 对 `src/`、`server-node/src/`、`packages/shared/src/`、`scripts/` 共 `650` 个 TS/JS 文件做本地依赖图扫描。 +4. 定向 grep: + - 核对旧 dev 插件入口、后端跨层 import、localStorage 使用、运行时快照双写、重复映射代码。 + +### 2.2 口径说明 + +为避免误判,本次审计明确排除了两类对象: + +1. **包脚本入口**:例如 `scripts/build-gate.mjs`、`scripts/check-encoding.mjs`、`server-node/build.mjs` 这类由 `package.json` 直接执行的脚本,不因“无 import”而判为垃圾。 +2. **字符串路径消费的资源**:例如 `src/data/itemOverrides.json`、`src/data/monsterOverrides.json` 会被校验脚本和 editor route 以文件路径读取,不按“无 import”处理。 + +另外,当前工作区存在未提交改动,因此本次结论以**已纳入当前主链且能确认未接线/重复/越界的内容**为主,不把明显的当日 WIP 文件计入垃圾结论。 + +--- + +## 3. 高置信度垃圾、临时产物与无入口代码 + +## 3.1 仓库噪音产物已经到了需要集中清理的程度 + +### 证据 + +| 项目 | 当前证据 | 判断 | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| 根目录日志/临时文件 | 根目录命中 `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__/` | 纯缓存产物,不应长期留在仓库工作区中 | + +### 影响 + +1. 根目录信噪比明显下降,真实工程文件被大量一次性产物淹没。 +2. `temp-build-goal-check/` 虽然已被 `.gitignore` 和 `vite.config.ts` 的 watch 忽略模式覆盖,但 `.eslintrc.cjs` 的 `ignorePatterns` 里没有对应口径,仍存在工具口径不一致问题。 +3. 这类目录会持续干扰检索、review、lint 判断和本地扫描速度。 + +### 建议 + +1. 把根目录临时日志、扫描 txt/html、旧截图统一迁到单独的 `tmp/` 或本地缓存目录,默认不留在仓库根目录。 +2. 把 `temp-build-goal-check/` 改成真正的外置检查产物目录,或者在 lint/脚本口径上一起排除。 +3. 清理 `scripts/__pycache__/`,并统一补上 Python 缓存忽略规则。 + +--- + +## 3.2 旧 Vite 本地 API 插件链已经退出主入口,但仍保留整套旧实现 + +### 证据 + +1. `scripts/dev-server/README.md` 已明确写明:`scripts/dev-server/**` 不再是当前开发入口,只保留为迁移参考。 +2. `scripts/dev-server/localApiPlugins.ts` 当前仍有 `1664` 行。 +3. 仓库内已经找不到 `localApiPlugins` 的实际代码入口引用,当前只剩文档引用。 +4. 该文件内部仍然同时定义和拼装: + - `createLlmProxyPlugin` + - `createJsonFileEditorPlugin` + - `createCustomWorldSceneImagePlugin` + - `createCharacterVisualPublishPlugin` + - `createCharacterAnimationPublishPlugin` + - `createCharacterAssetStudioPlugins` + - `createQwenSpriteSheetToolPlugins` + +### 判断 + +这不是“一个小工具暂时没用”,而是**整条旧 editor/assets 本地 API 链路仍然完整保留在仓库里**。它在工程上已经属于高置信度的历史残留。 + +### 建议 + +1. 如果只保留迁移证据,建议把 `scripts/dev-server/localApiPlugins.ts` 和相关说明迁到 `docs/reference/` 或单独的 `archive/` 目录。 +2. 如果确实还要保留参考代码,至少要在文件顶部加更强的“只读参考、禁止继续扩展”标识,并从主工程扫描面上进一步隔离。 +3. 不建议继续在这条旧链路里新增任何 `/api/*` 能力。 + +--- + +## 3.3 当前存在一批“无运行时入口”或“仅测试引用”的孤岛模块 + +### 高置信度无入口/仅测试引用清单 + +| 模块 | 证据 | 判断 | +| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `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` | 当前无运行时引用 | 小体量零散孤岛 | + +### 判断 + +这批文件不一定都应该“立刻删除”,但它们已经满足两个至少其一: + +1. 当前正式入口完全不消费。 +2. 只剩测试在消费,本体没有真实运行时位置。 + +所以它们至少都应该进入以下三选一处理: + +1. 立即归档/删除。 +2. 明确接回正式入口。 +3. 改名或迁目录,标明“实验稿/参考稿/未接线”身份。 + +### 特别提醒 + +`src/components/custom-world-home/` 和 `src/components/custom-world-agent/` 这两组文件里,存在**已经有一定 UI 完成度、但没有进入真实路由/流程**的情况。 +这类文件最危险的点不是体量,而是会让后来者误以为“这块功能已经在主链上”。 + +--- + +## 4. 冗余实现与双份真相 + +## 4.1 Story option interaction 映射在前后端各维护了一份 + +### 证据 + +1. 前端 `src/services/runtimeStoryService.ts` 的 `buildRuntimeOptionInteraction` 维护了 `npcActionMap`、`treasureActionMap`。 +2. 后端 `server-node/src/modules/story/storyActionService.ts` 的 `buildStoryOptionInteraction` 维护了几乎同构的一份 `npcActionMap`、`treasureActionMap`。 + +### 风险 + +1. 任何一个 functionId 增删改,前后端都要同步。 +2. 一边先改、一边漏改时,表现层和运行时层会出现静默漂移。 + +### 建议 + +把 interaction/view model 映射收口到后端,前端只消费后端返回的结构,不再根据 `functionId` 本地重建一遍交互语义。 + +--- + +## 4.2 浏览历史已经有后端接口,但前端仍维护本地真相与迁移状态 + +### 证据 + +1. `src/components/game-shell/PreGameSelectionFlow.tsx` 中,`appendBrowseHistoryEntry` 先调用 `writePlatformBrowseHistory` 写本地,再调用 `upsertProfileBrowseHistory` 写后端。 +2. 同文件启动阶段又会先读 `readPlatformBrowseHistory`,再根据 `hasPendingPlatformBrowseHistoryMigration` 把本地历史同步回后端。 +3. 后端 `server-node/src/routes/runtimeRoutes.ts` 已经提供了 `/profile/browse-history` 路由,而前端 `src/services/storageService.ts` 也已有对应 API SDK。 + +### 判断 + +当前浏览历史并不是单纯的“本地缓存”,而是**本地存储 + 远端持久化 + 迁移标记**三套状态并存。 + +### 建议 + +1. 后端结果作为唯一真相源。 +2. 前端如果要保留缓存,只保留一个明确的 cache wrapper,不再把它做成独立状态系统。 +3. `markPlatformBrowseHistoryMigrated` 这种迁移标记应尽量在后端一次性收口,而不是长期停留在正式前端逻辑里。 + +--- + +## 4.3 运行时快照依然由前端先落本地,再与后端会话互相回填 + +### 证据 + +1. `src/hooks/story/runtimeStoryCoordinator.ts` 在读状态和提交 action 前都会先调用 `putSaveSnapshot`。 +2. 同文件以及 `src/services/runtimeStoryService.ts` 又会在响应后多次 `rehydrateSavedSnapshot`。 +3. 这意味着浏览器仍然在“后端 action 之前”先写一份自己的快照解释。 + +### 判断 + +这条链路说明当前运行时还处在**前端快照解释权没有完全退出**的过渡状态。 + +### 建议 + +1. 前端逐步退化为 view model 消费层。 +2. 运行时快照、版本迁移、恢复解释权继续往后端收口。 +3. 前端保留最小必要的离线展示缓存,但不再成为正式运行时状态真相来源。 + +--- + +## 4.4 旧 Vite 本地 API 与正式 Express 路由仍然形成重复能力面 + +### 证据 + +1. `scripts/dev-server/localApiPlugins.ts` 里仍有 JSON 编辑、场景图生成、角色视觉发布、角色动作发布等插件。 +2. 当前正式路径已经迁到: + - `server-node/src/modules/editor/**` + - `server-node/src/modules/assets/**` +3. `scripts/dev-server/README.md` 已明确说明旧链路只保留为迁移参考。 + +### 判断 + +这属于典型的**旧能力未删除,新能力已落地,双链路长期并存**。 + +### 建议 + +尽快把旧 Vite 本地 API 参考实现移出主工程扫描面,避免后续继续被误用或被误认为正式入口。 + +--- + +## 5. 需要迁移到后端的代码 + +## 5.1 `src/services/ai.ts` 仍然承担了过多正式运行时职责 + +### 当前职责 + +`src/services/ai.ts` 当前约 `2632` 行,仍然同时承担: + +1. function 可用性与 option 构造相关逻辑。 +2. NPC 对话 / 招募 prompt 构造。 +3. 自定义世界生成 prompt 与 JSON 修复请求。 +4. 直接调用 `requestPlainTextCompletion` / `streamPlainTextCompletion`。 +5. 浏览器内 fallback 与响应解析。 + +### 判断 + +这不是单纯的“前端请求 SDK”,而是**前端仍在承担正式运行时 AI orchestration**。 + +### 建议迁移方向 + +1. prompt 组装、模型调用、超时重试、JSON repair 继续收口到 `server-node/src/modules/ai/**`。 +2. 前端只保留轻量 SDK 和展示态拼装。 +3. fallback 如果必须保留,也应明确区分“开发兜底”与“正式运行时”。 + +--- + +## 5.2 `src/services/customWorld.ts` 仍然是前端侧的大型规则中心 + +### 当前职责 + +`src/services/customWorld.ts` 当前约 `2413` 行,仍然承担: + +1. 世界框架与角色/地标 outline 归一化。 +2. 世界属性 schema 生成。 +3. `ownedSettingLayers` 归一化。 +4. 最终世界 profile 校验。 +5. fallback story graph/theme pack 生成。 + +### 当前越界证据 + +后端目前直接从以下文件 import 这些能力: + +1. `server-node/src/modules/ai/customWorldOrchestrator.ts` +2. `server-node/src/services/customWorldAgentFoundationDraftService.ts` + +它们仍直接引用: + +1. `src/services/customWorld.js` +2. `src/services/customWorldBuilder.js` +3. `src/services/customWorldCreatorIntent.js` +4. `src/types.js` + +### 判断 + +这说明自定义世界的核心领域规则仍然以**前端目录为事实源**,后端只是在反向复用。 + +### 建议迁移方向 + +1. `types/schema/contracts` 抽到 `packages/shared`。 +2. 规则编译、校验、fallback 与 AI 编排迁到 `server-node`。 +3. 前端只保留编辑器表现层和字段草稿态。 + +--- + +## 5.3 `src/hooks/story/npcEncounterActions.ts` 仍在浏览器里做任务、奖励、战斗与招募结算 + +### 当前职责 + +`src/hooks/story/npcEncounterActions.ts` 当前约 `1623` 行,仍然直接编排: + +1. `quest_accept` / `quest_turn_in` +2. 招募、切磋、离开、帮助奖励 +3. 掉落/背包写入 +4. HP / MP / cooldown 奖励变化 +5. NPC 亲和度变化 +6. 战斗场景切换与遭遇状态推进 + +### 判断 + +这条链已经明显超出“前端表现协调层”的边界,仍属于**正式运行时规则在前端执行**。 + +### 建议迁移方向 + +1. quest 信号推进 -> `server-node/src/modules/quest/**` +2. 奖励与背包变更 -> `server-node/src/modules/inventory/**` +3. 招募/关系变化 -> `server-node/src/modules/npc/**` +4. 战斗结算 -> `server-node/src/modules/combat/**` + +前端应该只保留选项触发、加载态、动画态和最终结果展示。 + +--- + +## 5.4 `src/services/apiClient.ts` 仍保留了本地 token 与自动登录凭证存储 + +### 证据 + +`src/services/apiClient.ts` 当前仍把以下内容放在 `window.localStorage`: + +1. access token +2. 自动登录用户名 +3. 自动登录密码 + +### 判断 + +这既是安全面问题,也是边界问题。 +在“后端负责鉴权、前端只做表现”的目标下,正式凭证体系不应长期依赖浏览器本地保存账号密码。 + +### 建议迁移方向 + +1. 正式态优先走服务端 session / HttpOnly cookie。 +2. 自动登录不要继续保存明文用户名/密码。 +3. 前端仅保留最小必要的登录态感知,不保留额外认证真相。 + +--- + +## 6. 需要优先优化和拆分的代码 + +## 6.1 `src/components/CustomWorldEntityEditorModal.tsx` + +### 当前状态 + +文件体量约 `4487` 行,已同时吞下: + +1. 世界营地编辑 +2. playable NPC 编辑 +3. story NPC 编辑 +4. 地标与世界地图布局 +5. 场景图生成 +6. 技能编辑 +7. 初始物品编辑 +8. 资产工作台串联 +9. 多层 modal 开关与保存逻辑 + +### 判断 + +这是当前前端最明显的“巨型工作台单体文件”。 + +### 建议拆分方向 + +1. 按实体拆:营地 / playable NPC / story NPC / 地标。 +2. 按能力拆:基础信息 / 关系 / 技能 / 初始物品 / 视觉资产。 +3. 把 AI 生成与资产工作流进一步外置成独立 coordinator。 + +--- + +## 6.2 `server-node/src/modules/assets/characterAssetRoutes.ts` + +### 当前状态 + +文件体量约 `3579` 行,已同时承担: + +1. route 注册 +2. 请求解析 +3. LLM prompt bundle 生成 +4. JSON 解析与修复 +5. 文件系统写盘 +6. visual publish +7. animation publish +8. 资产目录管理 + +### 直接证据 + +文件内同时存在: + +1. `mkdir` / `writeFile` +2. `UpstreamLlmClient` +3. `parseJsonResponseText` +4. 多条 publish 路径 +5. 大量本地文件落盘逻辑 + +### 建议拆分方向 + +1. route 层 +2. prompt bundle service +3. file publish service +4. animation persistence service +5. asset metadata service + +--- + +## 6.3 `src/services/ai.ts` + +### 当前状态 + +文件体量约 `2632` 行,同时承载运行时 story、自定义世界、NPC 对话、招募等多条链路。 + +### 建议 + +即使短期内不能全部迁后端,也应该先按职责拆成: + +1. runtime story client +2. npc dialogue client +3. recruit dialogue client +4. custom world generation client +5. parser / fallback / error helpers + +--- + +## 6.4 `src/services/customWorld.ts` + +### 当前状态 + +文件体量约 `2413` 行,已经变成世界生成、校验、归一化、fallback 的综合体。 + +### 建议 + +至少拆成: + +1. 世界框架与 outline schema +2. profile normalize / validate +3. role / landmark 编译器 +4. fallback builder +5. world rule helpers + +--- + +## 6.5 `src/hooks/story/npcEncounterActions.ts` + +### 当前状态 + +文件体量约 `1623` 行,已经不是单纯 hook,而是前端运行时 action resolver。 + +### 建议 + +按动作域拆开: + +1. npc chat / recruit +2. npc help / affinity +3. quest accept / turn-in +4. battle entry / exit +5. async streaming / typewriter / presentation glue + +--- + +## 7. 推荐执行顺序 + +### 第一阶段:先清仓库噪音和旧入口残留 + +1. 清根目录日志、扫描文件、旧截图、`__pycache__` +2. 迁出 `temp-build-goal-check/` +3. 明确处置 `scripts/dev-server/localApiPlugins.ts` + +### 第二阶段:再处理无入口孤岛模块 + +1. 逐个确认 `GameShell.tsx`、custom-world-home、custom-world-agent、旧 flow hooks 是要接回还是归档 +2. 对确认不再使用的 stub / helper / generated dead file 直接清理 + +### 第三阶段:把双份真相收口 + +1. runtime option interaction 映射只保留一份 +2. 浏览历史以后端为真相源 +3. 运行时快照解释权继续后移 +4. 清理 `server-node -> src/**` 的反向依赖 + +### 第四阶段:最后拆巨型热点文件 + +1. `CustomWorldEntityEditorModal.tsx` +2. `characterAssetRoutes.ts` +3. `ai.ts` +4. `customWorld.ts` +5. `npcEncounterActions.ts` + +--- + +## 8. 本文依据 + +文档依据: + +1. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` +2. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +3. `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` +4. `scripts/dev-server/README.md` + +当前仓库扫描依据: + +1. `src/main.tsx` +2. `src/routing/appRoutes.tsx` +3. `src/App.tsx` +4. `package.json` +5. `server-node/package.json` +6. `vite.config.ts` +7. `.eslintrc.cjs` +8. `git grep` 对关键模块引用、后端跨层 import、localStorage、旧 dev 插件入口的扫描结果 diff --git a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md new file mode 100644 index 00000000..a7538437 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md @@ -0,0 +1,384 @@ +# 工程清理与后端边界复核审计(2026-04-20) + +更新时间:`2026-04-20` + +## 0. 审计目标 + +这份文档不是重复 `2026-04-19` 的原始扫描,而是基于当前仓库状态做一轮复核,重点回答三个问题: + +1. 昨天审计里已经提出的问题,哪些今天已经真正落地。 +2. 哪些结论在当前代码里仍然成立,哪些表述需要纠正。 +3. 当前工程热点和边界问题有没有发生迁移。 + +--- + +## 1. 结论先行 + +和 `2026-04-19` 那份基线相比,当前仓库已经有一批明确进展: + +1. **旧 Vite 本地 API 链路已经真正出清。** + `scripts/dev-server/` 当前只剩一份 `README.md`,旧的 `localApiPlugins.ts`、角色资产插件、精灵表插件都不在仓库里了。 +2. **根目录噪音产物已经清理完成。** + 当前根目录临时日志/扫描产物扫描结果为空,`temp-build-goal-check/` 也不存在。 +3. **`server-node -> src/**` 反向依赖已经收掉。** + 当前复核没有再发现 `server-node/src/**` 直接 import 前端 `src/**` 的情况。 +4. **runtime option interaction 已经收口成后端单一真相。** + 这部分现在由 `server-node/src/modules/story/runtimeSession.ts` 统一构造,前端 `src/services/runtimeStoryService.ts` 不再本地再建一份映射表。 + +但这不代表边界问题已经结束,当前剩余问题主要集中在三块: + +1. **前端仍保留运行时镜像与登录凭证本地真相。** + `runtimeStoryCoordinator.ts` 仍会先写本地快照,`apiClient.ts` 仍把 token/自动登录凭证放在 `localStorage`。 +2. **NPC 聊天任务链路还没有完全后端化。** + “聊天后挂出待接委托”已经移到后端,但“更换待接委托”这条分支仍由前端 `npcEncounterActions.ts` 触发 `generateQuestForNpcEncounter(...)`。 +3. **未接线孤岛和热点文件问题仍然明显。** + 一批 UI/Hook/Prompt 残留模块还没有正式入口;同时热点已经从已删除的旧插件链路,转移到 `CustomWorldEntityEditorModal.tsx`、`storyPromptBuilders.ts`、`runtimeProfile.ts`、`PreGameSelectionFlow.tsx`、`PlatformHomeView.tsx` 等新中心。 + +一句话判断: + +**当前仓库已经完成“清垃圾、拆旧入口、切断后端反向依赖”的第一阶段,但还没有完成“前端退出运行时真相”和“未接线孤岛归档”的第二阶段。** + +--- + +## 2. 已完成项复核 + +## 2.1 旧 dev-server 链路已经不是“逻辑上废弃”,而是“代码上删除” + +### 当前证据 + +| 项目 | 当前状态 | 结论 | +| --- | --- | --- | +| `scripts/dev-server/` | 当前只剩 `README.md` 一份说明文件 | 旧 Vite 本地 API 链路已从仓库代码层出清 | +| `scripts/dev-server/README.md` | 已明确声明当前正式入口为 `scripts/dev-node.mjs + server-node/src/modules/**` | 文档与代码状态一致 | + +### 结论 + +`2026-04-19` 文档里关于旧本地 API 插件链路的清理结论,在当前仓库里已经可以确认成立,不再只是“计划删除”。 + +--- + +## 2.2 根目录噪音产物已经从当前工作区移除 + +### 当前证据 + +| 项目 | 当前状态 | 结论 | +| --- | --- | --- | +| 根目录历史日志/扫描产物 | 本轮扫描结果为空 | 之前的 `.codex-*.log`、`tmp_*`、旧截图/HTML 不再占据当前工作区 | +| `temp-build-goal-check/` | 当前不存在 | 大体量检查产物已移出当前仓库视野 | + +### 结论 + +`2026-04-19` 文档中关于“仓库噪音产物”的问题,在当前工作区层面已经完成首轮治理。 +这部分不再是当前工程第一优先级。 + +--- + +## 2.3 `server-node -> src/**` 反向依赖已清零 + +### 当前证据 + +本轮用脚本复核 `server-node/src/**` 中所有 `import` 后,当前结果为: + +`NO_DIRECT_SERVER_TO_FRONTEND_SRC_IMPORTS` + +同时,仓库里已经看不到类似下面这类旧反向依赖: + +1. `server-node -> src/services/customWorld.js` +2. `server-node -> src/services/customWorldBuilder.js` +3. `server-node -> src/services/customWorldCreatorIntent.js` +4. `server-node -> src/types.js` + +### 结论 + +`2026-04-19` 文档里“清理 `server-node -> src/**` 反向依赖”的阶段性目标,在当前仓库里已经真正落地。 + +--- + +## 2.4 runtime option interaction 已经收口到后端 + +### 当前证据 + +1. `server-node/src/modules/story/runtimeSession.ts` 当前仍保留 `buildOptionInteraction(...)`,负责构造: + - `npcActionMap` + - `treasureActionMap` +2. `src/services/runtimeStoryService.ts` 当前只做: + - 直接读取 `option.interaction` + - 把后端返回的 interaction 投影成 `StoryOption` +3. 前端文件里已经找不到旧的 `buildRuntimeOptionInteraction` / `npcActionMap` / `treasureActionMap` 实现。 + +### 结论 + +这项收口已经成立,当前不会再出现“前后端各维护一份 interaction 映射表”的旧问题。 + +--- + +## 2.5 浏览器端的 quest/runtime item 本地 LLM fallback 已移除 + +### 当前证据 + +1. `src/services/questDirector.ts` + - 浏览器路径先请求 `/api/runtime/quests/generate` + - 后端失败时只走 deterministic fallback compile +2. `src/services/runtimeItemAiDirector.ts` + - 浏览器路径先请求 `/api/runtime/items/runtime-intent` + - 后端失败时只返回 deterministic fallback intents +3. 这两个文件虽然仍保留 `requestChatMessageContent(...)` 分支,但那是非浏览器分支,不再是浏览器端正式兜底链路。 + +### 结论 + +`2026-04-19` 文档里关于“浏览器本地 LLM fallback”这部分,当前应更新为: + +**浏览器端本地 LLM fallback 已移除,但这两个模块仍然是双环境混合实现,还没有彻底后端化。** + +--- + +## 3. 需要纠正的旧文档表述 + +## 3.1 NPC 任务链路不是“全部后端化”,而是“挂单已后移、换单仍前触发” + +### 需要纠正的点 + +`2026-04-19` 文档中的回填里有一条表述是: + +“`src/hooks/story/npcEncounterActions.ts` 不再在 NPC 单轮聊天完成后本地调用 `generateQuestForNpcEncounter(...)` 再决定是否挂出待接委托。” + +### 当前代码状态 + +这句话对“聊天后挂出待接委托”这条主链是成立的,因为当前后端 `server-node/src/modules/ai/chatOrchestrator.ts` 已经会回填 `pendingQuestOffer`。 + +但它对整条 NPC 任务链路来说并不完整,因为当前前端仍保留这条分支: + +1. `src/hooks/story/npcEncounterActions.ts` +2. `replacePendingNpcQuestOffer()` +3. `generateQuestForNpcEncounter(...)` + +也就是: + +**待接委托的“正式挂出”已后端化,但“更换委托”仍然由前端动作流发起。** + +### 当前应改成的结论 + +更准确的描述应该是: + +1. NPC 单轮聊天里“是否挂出待接委托”的决定权已收回后端。 +2. 但待接委托的“换单/重抽”分支仍通过前端 `npcEncounterActions.ts -> questDirector.ts` 发起。 + +--- + +## 4. 当前仍然成立的遗留问题 + +## 4.1 未接线/仅测试引用孤岛模块仍然明显 + +本轮依赖图复核后,当前仍能确认一批高置信度孤岛模块: + +| 模块 | 当前状态 | 说明 | +| --- | --- | --- | +| `src/components/GameShell.tsx` | `765` 行,无运行时引用 | 旧版壳层残留仍在 | +| `src/components/custom-world-home/CustomWorldCreationHub.tsx` | `161` 行,仅测试引用 | UI 已有完成度,但仍未进入正式入口 | +| `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` | `147` 行,无运行时引用 | 未接线入口壳层 | +| `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` | `91` 行,无运行时引用 | agent UI 孤岛仍在 | +| `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` | `116` 行,无运行时引用 | agent UI 孤岛仍在 | +| `src/hooks/story/storyBootstrap.ts` | `250` 行,无运行时引用 | 旧 bootstrap hook 仍未归档 | +| `src/hooks/useEquipmentFlow.ts` | `134` 行,无运行时引用 | 旧 flow hook 残留 | +| `src/hooks/useForgeFlow.ts` | `159` 行,无运行时引用 | 旧 flow hook 残留 | +| `src/hooks/useInventoryFlow.ts` | `100` 行,无运行时引用 | 旧 flow hook 残留 | +| `src/services/customWorldPresentation.stub.ts` | `55` 行,无运行时引用 | 占位 stub 仍在 | +| `src/services/typewriter.ts` | `7` 行,无运行时引用 | 小型 helper 残留 | +| `src/prompts/customWorldOrchestratorPrompts.ts` | `9` 行,无运行时引用 | prompt source 已迁走后留下的孤岛 | +| `src/prompts/storyOrchestratorPrompts.ts` | `6` 行,无运行时引用 | prompt source 已迁走后留下的孤岛 | +| `src/data/buildTagSimilarity.generated.ts` | `823` 行,无运行时引用 | 生成产物未接入正式业务链路 | + +### 说明 + +`src/data/itemOverrides.json`、`src/data/monsterOverrides.json` 这类文件虽然没有 import 引用,但会被脚本和 editor route 以路径消费,所以不计入垃圾判断。 + +### 结论 + +仓库已经完成“删旧插件”,但还没有完成“清未接线孤岛”。 +当前这批模块应该进入明确处置表: + +1. 直接归档/删除 +2. 正式接回入口 +3. 改名/迁目录,标记为实验稿 + +--- + +## 4.2 前端仍保留运行时镜像真相 + +### 当前证据 + +1. `src/hooks/story/runtimeStoryCoordinator.ts` + - 仍会在读状态和提交动作前先 `putSaveSnapshot(...)` + - 仍会在响应后多次 `rehydrateSavedSnapshot(...)` +2. `src/services/runtimeStoryService.ts` + - 仍对响应快照做 `rehydrateSavedSnapshot(...)` + +### 结论 + +当前运行时已经不是“前端主算”,但仍然是: + +**前端先写一份本地镜像,再和后端会话互相回填。** + +这说明前端还没有完全退出正式运行时状态解释层。 + +--- + +## 4.3 前端仍保留本地登录凭证真相 + +### 当前证据 + +`src/services/apiClient.ts` 当前仍把以下内容写入 `window.localStorage`: + +1. `ACCESS_TOKEN_KEY` +2. `AUTO_AUTH_USERNAME_KEY` +3. `AUTO_AUTH_PASSWORD_KEY` + +对应代码仍包括: + +1. `window.localStorage.getItem(...)` +2. `window.localStorage.setItem(...)` +3. `window.localStorage.removeItem(...)` + +### 结论 + +这一点和“前端只做表现、后端负责鉴权”的目标仍然不一致。 +尤其是自动登录用户名/密码继续存本地,风险和边界问题都还在。 + +--- + +## 4.4 quest/runtime item 仍是双环境混合实现 + +### 当前证据 + +1. `src/services/questDirector.ts` + - 浏览器路径走 `requestJson('/api/runtime/quests/generate')` + - 非浏览器路径仍有 `requestChatMessageContent(...)` +2. `src/services/runtimeItemAiDirector.ts` + - 浏览器路径走 `requestJson('/api/runtime/items/runtime-intent')` + - 非浏览器路径仍有 `requestChatMessageContent(...)` +3. `src/hooks/story/npcEncounterActions.ts` + - 当前仍 import `generateQuestForNpcEncounter` + - `replacePendingNpcQuestOffer()` 仍会调用它 + +### 结论 + +浏览器兜底已经收掉,但模块职责仍然是混合的: + +1. 同一个文件同时承担前端 SDK 和非浏览器编排逻辑 +2. NPC 换单动作仍由前端发起服务调用 + +这部分还不能算真正后端化完成。 + +--- + +## 4.5 `src/services/ai.ts` 仍然是浏览器端正式 AI orchestration 热点 + +### 当前证据 + +`src/services/ai.ts` 当前约 `2608` 行,仍直接使用: + +1. `requestChatMessageContent` +2. `requestPlainTextCompletion` +3. `streamPlainTextCompletion` + +### 结论 + +这说明浏览器侧的大型 AI orchestration 仍然没有真正退出主工程。 +虽然部分链路已经迁走,但整体边界还没有收完。 + +--- + +## 5. 当前热点已经发生迁移 + +## 5.1 当前主要大文件快照 + +| 文件 | 当前行数 | 判断 | +| --- | --- | --- | +| `src/components/CustomWorldEntityEditorModal.tsx` | `4898` | 仍是前端最大热点 | +| `server-node/src/modules/assets/characterAssetRoutes.ts` | `3181` | 仍是后端资产链路最大热点 | +| `src/services/ai.ts` | `2608` | 浏览器 AI orchestration 热点仍在 | +| `src/data/npcInteractions.ts` | `2409` | 仍是大型规则数据中心 | +| `server-node/src/services/customWorldAgentFoundationDraftService.ts` | `1902` | custom world agent 后端热点上升 | +| `src/prompts/storyPromptBuilders.ts` | `1882` | prompt source 已成为新的前端热点 | +| `server-node/src/modules/custom-world/runtimeProfile.ts` | `1735` | custom world runtime 编译中心已转到后端 | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | `1547` | 平台/入口流程热点上升 | +| `src/components/game-shell/PlatformHomeView.tsx` | `1522` | 平台首页热点上升 | +| `src/services/customWorld.ts` | `1489` | 仍然大,但已明显缩小 | +| `src/hooks/story/npcEncounterActions.ts` | `1434` | 仍然是前端 action 热点 | + +--- + +## 5.2 热点变化判断 + +和 `2026-04-19` 相比,当前热点不是单纯“没变”,而是出现了明显迁移: + +1. `characterAssetRoutes.ts` 从 `3579` 行降到 `3181` 行,说明资产路由已经有过一轮拆分,但仍然偏大。 +2. `src/services/customWorld.ts` 从 `2413` 行降到 `1489` 行,说明自定义世界规则已拆出一部分。 +3. `src/hooks/story/npcEncounterActions.ts` 从 `1623` 行降到 `1434` 行,说明 NPC 运行时逻辑也有收口。 +4. 新的复杂度中心开始转移到: + - `src/prompts/storyPromptBuilders.ts` + - `server-node/src/modules/custom-world/runtimeProfile.ts` + - `src/components/game-shell/PreGameSelectionFlow.tsx` + - `src/components/game-shell/PlatformHomeView.tsx` + +### 结论 + +当前问题已经不再是“原来的热点完全没动”,而是: + +**部分旧热点正在缩小,但复杂度正在向 prompt source、custom world runtime profile、平台入口壳层继续迁移。** + +--- + +## 6. 最新建议执行顺序 + +### 第一阶段:先清理当前仍明确无入口的孤岛 + +1. 处理 `GameShell.tsx` +2. 处理 `custom-world-home/*` +3. 处理 `custom-world-agent/*` +4. 处理 `storyBootstrap.ts`、`useEquipmentFlow.ts`、`useForgeFlow.ts`、`useInventoryFlow.ts` +5. 处理已脱钩的 `src/prompts/*OrchestratorPrompts.ts` + +### 第二阶段:再收运行时和鉴权真相 + +1. 收掉 `runtimeStoryCoordinator.ts` 的本地快照前置写入 +2. 收掉 `apiClient.ts` 中的自动登录用户名/密码本地持久化 +3. 优先把 token/session 统一到服务端鉴权边界 + +### 第三阶段:补完 NPC 任务链路的后端化 + +1. 把“更换待接委托”从 `npcEncounterActions.ts -> questDirector.ts` 继续迁到后端 +2. 把 `questDirector.ts` / `runtimeItemAiDirector.ts` 拆成明确的后端服务与前端 SDK 两层 + +### 第四阶段:最后拆新热点 + +1. `CustomWorldEntityEditorModal.tsx` +2. `characterAssetRoutes.ts` +3. `storyPromptBuilders.ts` +4. `runtimeProfile.ts` +5. `PreGameSelectionFlow.tsx` +6. `PlatformHomeView.tsx` + +--- + +## 7. 本文依据 + +文档依据: + +1. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md` +2. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` +3. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +4. `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` + +当前仓库复核依据: + +1. `scripts/dev-server/README.md` +2. `server-node/src/modules/story/runtimeSession.ts` +3. `src/services/runtimeStoryService.ts` +4. `src/hooks/story/runtimeStoryCoordinator.ts` +5. `src/hooks/story/npcEncounterActions.ts` +6. `src/services/questDirector.ts` +7. `src/services/runtimeItemAiDirector.ts` +8. `src/services/apiClient.ts` +9. 当前依赖图扫描结果与当前大文件体量扫描结果 + diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index 93c44c5f..8d3891dc 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -4,16 +4,25 @@ ## 当前推荐入口 -1. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) +1. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) + 这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 +2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) + 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 +3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 -2. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) +4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) 适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。 -3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) +5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 适合看第一轮系统性工程扫描,了解最早的问题基线。 ## 融合结论 +- 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。 +- 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。 - 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。 - 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。 +- `2026-04-19` 这一轮把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。 +- `2026-04-20` 这一轮进一步确认:前两类已经阶段性完成,当前真正剩下的是边界尾巴和新热点迁移。 - 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。 -- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01` 的顺序回看演进。 +- 如果是要看当前清理和边界收口的最新状态,优先看 `2026-04-20`。 +- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19 -> 2026-04-20` 的顺序回看演进。 diff --git a/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md new file mode 100644 index 00000000..8deb90f7 --- /dev/null +++ b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md @@ -0,0 +1,870 @@ +# 等级成长、章节经验节奏与 NPC 自动定级设计 + +更新时间:`2026-04-20` + +## 实现进度(2026-04-20 第一批) + +当前仓库已按本设计先落地第一批稳定能力: + +1. 已新增 `playerProgression` 正式成长状态,包含等级、当前等级经验、总经验与下级阈值。 +2. 已新增等级基准与经验结算服务,并接入前后端存档归一化,旧存档默认回填为 `Lv.1 / 0 XP`。 +3. 已给 `QuestReward` 补上 `experience`,新生成任务会按当前等级与任务结构给出任务经验。 +4. 已将 Express 后端 `npc_quest_turn_in` 接入经验发放与升级处理,任务交付结果会反馈 `经验 +N` 与升级信息。 +5. 已在冒险主面板补充最小等级展示:`Lv.` 与细经验条;任务奖励面板可看到经验数值。 +6. 已收回任务日志里的直接领奖入口,任务奖励结算当前以 NPC 交付链路为准。 + +本轮仍未落地的部分: + +1. 击败敌对 NPC 经验。 +2. 章节经验预算 / ledger 统计。 +3. 按章节自动定级 NPC 与运行时敌对经验掉落。 + +## 0. 目标 + +这次设计解决 5 个必须同时成立的问题: + +1. 玩家需要正式拥有 `等级 / 当前经验 / 总经验 / 升级` 这条成长主链。 +2. 经验只从两类明确来源进入: + - 完成任务 + - 击败敌对 NPC +3. 同等级实体必须具备同一档 `参考强度`,不能再靠散落在各处的静态数值各自漂移。 +4. 系统需要能按章节评估玩家经验获取速度,而不是只在整体通关后回看“升太快/升太慢”。 +5. 不同章节里的 NPC 需要按章节目标等级自动定级,保证这一章的敌我强度、经验产出和升级节奏互相闭合。 + +一句话结论: + +**等级必须成为后端统一裁决的成长基线;章节必须先产出“目标玩家等级带 + 经验预算”,再由这套预算反推任务经验、击杀经验和本章 NPC 自动等级。** + +--- + +## 1. 基于当前仓库的判断 + +结合当前代码与文档,现状已经有足够好的骨架,但等级系统这一层还完全缺位。 + +### 1.1 已经具备的基础 + +1. `src/data/questFlow.ts` + + - 已有 `QuestLogEntry / QuestStep / QuestProgressSignal / chapter quest`。 + - 已经能把场景章节任务接到运行时主链。 + +2. `server-node/src/modules/quest/questStoryActionService.ts` + + - 已经把 `接任务 / 交任务` 收回后端。 + - 任务结算时已经集中处理货币、背包、好感变化。 + +3. `server-node/src/modules/quest/questRuntimeSignalService.ts` + + - 已经会在 `npc_chat / 击败敌对 NPC / 宝藏 / 切磋` 后投递 quest signal。 + +4. `src/services/storyEngine/chapterDirector.ts` + + - 已经能用当前场景章节任务推导 `opening -> expansion -> turning_point -> climax -> aftermath`。 + +5. `src/types/customWorld.ts` + + - 已经有 `sceneChapterBlueprints`,说明章节顺序、幕推进和 NPC 编排已经有正式挂点。 + +6. `src/types/attributes.ts`、`src/data/hostileNpcPresets.ts` + - 已经有统一属性画像、怪物/NPC 统一实体方向。 + - 当前敌对实体已有 `baseStats / attributeProfile / behaviorVectors`,可以继续向“同级同参考强度”收束。 + +### 1.2 当前缺口 + +当前最核心的缺口有 6 个: + +1. `GameState` 没有玩家等级成长状态。 +2. `QuestReward` 没有经验字段。 +3. `SceneHostileNpc / SceneNpc` 没有正式等级和击杀经验字段。 +4. 当前 hostile preset 的 `hp/maxHp` 仍是静态绝对值,不受章节节奏控制。 +5. 章节系统没有“本章目标入场等级 / 出章等级 / 经验预算”的结构。 +6. 没有“按章节自动定级”的编译器,也没有“本章经验是否超发/欠发”的记账面板。 + +一句话总结: + +**现在仓库里已经有章节、任务、NPC 和属性系统,但还没有“成长预算层”,所以强度、奖励和章节节奏仍然缺少同一把尺。** + +--- + +## 2. 核心决策 + +## 2.1 等级、经验与 NPC 定级全部由 Express 后端裁决 + +必须坚持: + +1. 前端只展示 `等级 / 经验条 / 升级结果 / NPC 等级徽标`。 +2. 经验发放、升级、章节经验预算、NPC 自动定级全部在 Express 后端计算。 +3. 前端不本地推演“这次应该升几级”“这个 NPC 应该是多少级”。 + +推荐新增领域目录: + +- `server-node/src/modules/progression/` + +建议首批模块: + +- `levelBenchmarks.ts` +- `playerProgressionService.ts` +- `chapterProgressionPlanner.ts` +- `chapterExperienceLedger.ts` +- `npcLevelResolver.ts` +- `progressionRuntimeSignalService.ts` + +## 2.2 MVP 经验来源只认两类事件 + +首版只允许两类正式经验来源: + +1. `quest_turned_in` + + - 任务真正交付时发经验。 + - 不在“接任务”“任务 ready_to_turn_in”时发经验。 + +2. `hostile_npc_defeated` + - 仅限敌对 NPC / 怪物胜利结算后发经验。 + - 不对 `npc_spar_completed`、普通聊天、观察、宝藏直接发经验。 + +这样做的原因是: + +1. 最容易和当前后端任务/战斗链路接上。 +2. 经验来源清晰,便于做章节预算。 +3. 避免系统一开始就被碎片经验源冲散。 + +## 2.3 同等级 = 同参考强度 + +这是本次设计最重要的规则: + +1. 等级是所有可比较实体共享的强度基线。 +2. 同等级玩家、敌对 NPC、可战斗剧情 NPC,必须共享同一档 `参考强度`。 +3. 世界属性 schema 只决定“强在哪种风格上”,不决定“同级谁天然强一截”。 + +也就是说: + +- `Lv.8` 的重甲敌人和 `Lv.8` 的迅捷刺客可以打法不同 +- 但两者的 `参考强度预算` 必须是同一档 + +真正的强弱差只允许来自: + +1. 等级差 +2. 装备 / Build / Buff / Debuff +3. 章节中明确声明的 `boss / elite` 角色通过更高等级体现,而不是同级偷加隐藏倍数 + +## 2.4 章节先出经验预算,再反推等级 + +章节设计从这次开始必须按下面顺序计算: + +```text +章节顺序 +-> 本章玩家目标入场等级 / 出章等级 +-> 本章总经验预算 +-> 任务经验份额 / 击杀经验份额 +-> 本章 NPC 自动等级 +-> 本章实际经验记账与偏差评估 +``` + +不能反过来先手写一堆 NPC 强度,再看玩家能不能接住。 + +## 2.5 UI 只做极简表达 + +为了符合当前项目“UI 不默认堆规则说明”的约束,前台只建议新增 4 个轻量展示: + +1. 玩家信息区: + + - `Lv. X` + - 一条细经验条 + +2. 敌对 NPC 名牌: + + - `Lv. X` + +3. 任务交付结果: + + - `经验 +N` + +4. 升级提示: + - 单条 toast 或单行系统反馈 + +不在界面里默认放: + +- 经验公式说明 +- 章节经验预算说明 +- 等级规则解释文案 + +--- + +## 3. 数据结构设计 + +## 3.1 玩家成长状态 + +建议新增: + +```ts +export interface PlayerProgressionState { + level: number; + currentLevelXp: number; + totalXp: number; + xpToNextLevel: number; + pendingLevelUps?: number; + lastGrantedSource?: 'quest' | 'hostile_npc' | null; +} +``` + +挂载位置建议: + +- `src/types/game.ts` +- `GameState.playerProgression` + +原则: + +1. 这不是 `runtimeStats` 的一部分。 +2. `runtimeStats` 继续做统计计数。 +3. `playerProgression` 是正式玩法状态。 + +## 3.2 等级基准表 + +建议新增: + +```ts +export interface LevelBenchmark { + level: number; + xpToNextLevel: number; + cumulativeXpRequired: number; + referenceStrength: number; + baseHp: number; + baseMana: number; + baselineDamageScale: number; +} +``` + +单一真相源建议放在: + +- `server-node/src/modules/progression/levelBenchmarks.ts` + +前端只通过后端投影拿结果,不自己保存第二份表。 + +## 3.3 实体等级档案 + +建议新增: + +```ts +export type ProgressionRole = + | 'guide' + | 'ambient' + | 'support' + | 'hostile_standard' + | 'hostile_elite' + | 'hostile_boss' + | 'rival'; + +export interface EntityLevelProfile { + level: number; + referenceStrength: number; + chapterId?: string | null; + chapterIndex?: number | null; + progressionRole: ProgressionRole; + source: 'chapter_auto' | 'preset_override' | 'manual'; +} +``` + +建议接入: + +- `src/types/scene.ts` + - `SceneNpc.levelProfile?: EntityLevelProfile` + - `SceneHostileNpc.levelProfile?: EntityLevelProfile` + +## 3.4 任务奖励扩展 + +建议扩展: + +```ts +export interface QuestReward { + affinityBonus: number; + currency: number; + experience: number; + items: InventoryItem[]; + storyHint?: string; + intel?: { ... }; +} +``` + +说明: + +1. 经验是任务奖励的一等字段。 +2. 经验文本不走 story hint 兜底。 +3. 任务经验由后端编译,不交给 AI 决定。 + +## 3.5 敌对 NPC 经验掉落 + +建议扩展: + +```ts +export interface SceneHostileNpc { + ... + experienceReward?: number; +} +``` + +首版只给运行时敌对 NPC 挂经验值,不强行把它沉到所有 preset 原始数据中。 + +原因: + +1. 经验应该跟章节定级一起编译。 +2. 同一个 hostile preset 出现在不同章节时,等级和经验都应不同。 +3. 静态 preset 继续只表达“风格”和“原型”,不再表达最终强度。 + +## 3.6 章节成长计划 + +建议新增运行时编译结果: + +```ts +export interface ChapterProgressionPlan { + chapterId: string; + chapterIndex: number; + totalChapters: number; + entryPseudoLevel: number; + exitPseudoLevel: number; + entryLevel: number; + exitLevel: number; + totalXpBudget: number; + questXpBudget: number; + hostileXpBudget: number; + expectedHostileDefeatCount: number; + paceBand: 'opening_fast' | 'steady' | 'pressure' | 'finale_dense'; +} +``` + +建议作为后端运行时编译结果缓存,不作为创作者直接编辑字段。 + +## 3.7 章节经验记账 + +建议新增: + +```ts +export interface ChapterExperienceLedger { + chapterId: string; + chapterIndex: number; + levelAtEntry: number; + levelAtExit?: number | null; + plannedTotalXp: number; + plannedQuestXp: number; + plannedHostileXp: number; + actualQuestXp: number; + actualHostileXp: number; + expectedHostileDefeatCount: number; + actualHostileDefeatCount: number; +} +``` + +用途: + +1. 评估每一章经验速度。 +2. 判断本章是否超发/欠发。 +3. 为下一轮调参提供依据。 + +--- + +## 4. 等级曲线与参考强度 + +## 4.1 首版等级目标 + +首版建议: + +1. 系统支持 `Lv.1 ~ Lv.20` +2. 当前主线正常通章目标不是满级 +3. 标准单轮战役通关目标等级建议落在 `Lv.14 ~ Lv.15` + +这样做的原因是: + +1. 级差足够表达章节成长 +2. 不会让前期升级过细、后期又没有空间 +3. 还保留后续营地、精英支线、长期养成的余量 + +## 4.2 升级经验公式 + +建议基线公式: + +```ts +xpToNextLevel(level) = 60 + 20 * (level - 1) + 8 * (level - 1) * (level - 1); +``` + +由此生成 `LevelBenchmark[]`,不在业务代码里散落重复公式。 + +说明: + +1. 前期升级快,便于建立成长反馈 +2. 中后期门槛逐步拉开,避免章节尾段失控 +3. 可直接序列化成常量表用于测试 + +## 4.3 参考强度公式 + +建议基线公式: + +```ts +referenceStrength(level) = + 100 + 16 * (level - 1) + 6 * (level - 1) * (level - 1); +``` + +并同步产出: + +```ts +baseHp(level); +baseMana(level); +baselineDamageScale(level); +``` + +重要约束: + +1. `referenceStrength` 是同级比较标尺。 +2. style 只允许在同一档预算内重分布,不允许抬高总强度。 +3. `elite / boss` 不允许用同级隐藏倍率偷强度,必须通过更高等级体现。 + +## 4.4 现有静态数值如何迁移 + +当前 `src/data/hostileNpcPresets.ts` 里的: + +- `baseStats.hp` +- `baseStats.maxHp` +- `speed` +- `attackRange` + +不建议继续全部视为最终强度。 + +迁移原则: + +1. `attackRange / speed` 继续保留为战斗风格参数。 +2. `hp / maxHp` 改为“风格形状参考”,最终值由 `等级基准 + 风格分布` 决定。 +3. 现有 preset 的高血量、高机动、高压制,只用于决定“同级下怎么分布”,不改变同级总参考强度。 + +--- + +## 5. 经验发放规则 + +## 5.1 任务经验 + +任务经验只在 `turn_in` 时发放。 + +建议公式: + +```ts +baseQuestXp(targetLevel) = xpToNextLevel(targetLevel) * 0.45; + +questXp = + baseQuestXp(targetLevel) * + stepCountMultiplier * + narrativeTypeMultiplier * + urgencyMultiplier; +``` + +建议倍率: + +| 条件 | 倍率 | +| ------------------------------------------ | ------ | +| `steps = 1` | `0.85` | +| `steps = 2` | `1.0` | +| `steps >= 3` | `1.12` | +| `investigation / retrieval / relationship` | `1.0` | +| `trial / bounty` | `1.08` | +| `urgency = high` | `1.05` | + +最终规则: + +1. 结果四舍五入到 `5` 的倍数。 +2. 章节主任务优先从本章 `questXpBudget` 出数。 +3. 普通 NPC 支线如果不绑定章节,则按 `targetLevel` 单独计算。 + +## 5.2 击败敌对 NPC 经验 + +建议公式: + +```ts +baseKillXp(targetLevel) = xpToNextLevel(targetLevel) * 0.08; + +killXp = + baseKillXp(targetLevel) * + stageMultiplier * + levelDeltaMultiplier * + repeatPenalty; +``` + +建议倍率: + +| 条件 | 倍率 | +| -------------------------------- | ----------------- | +| `opening` | `0.9` | +| `expansion` | `1.0` | +| `turning_point` | `1.05` | +| `climax` | `1.15` | +| 玩家高于目标 `2` 级 | `0.7` | +| 玩家高于目标 `4` 级 | `0.3` | +| 玩家低于目标 `2` 级 | `1.15` | +| 同章同类敌对实体超过预计击杀数后 | `0.5 -> 0.2 -> 0` | + +解释: + +1. 同章重复刷怪必须衰减。 +2. 击杀经验要响应等级差,避免低章 farming。 +3. 高潮压轴敌人可以给更多经验,但仍受章节预算约束。 + +## 5.3 经验发放顺序 + +推荐统一顺序: + +```text +规则动作成功 +-> 生成经验 grant +-> 写入 playerProgression.totalXp / currentLevelXp +-> 处理升级 +-> 回写章节 ledger +-> 生成前端提示 +``` + +不要把经验结算拆在前端多个回调里各自加一次。 + +--- + +## 6. 章节经验速度评估 + +## 6.1 章节顺序来源 + +章节索引 `chapterIndex` 建议按下面顺序解析: + +1. 有 `campaign pack` 时,优先用 campaign 正式顺序 +2. 否则有 `sceneChapterBlueprints` 时,用蓝图顺序 +3. 再否则,对 `landmarks` 从营地出发做最短路径排序 +4. 若存在并列,则回退到稳定的 landmark 原始顺序 + +这样才能给每章一个稳定的“这是第几章”。 + +## 6.2 目标等级带 + +建议先计算“伪等级进度”,再换算成经验预算: + +```ts +chapterBoundaryPseudoLevel(i) = + 1 + curve(i / totalChapters) * (terminalStoryLevel - 1); +``` + +建议 `curve` 用轻微前快后稳的函数: + +```ts +curve(progress) = Math.pow(progress, 0.92); +``` + +随后: + +```ts +entryPseudoLevel = chapterBoundaryPseudoLevel(chapterIndex - 1); +exitPseudoLevel = chapterBoundaryPseudoLevel(chapterIndex); +chapterXpBudget = + xpForPseudoLevel(exitPseudoLevel) - xpForPseudoLevel(entryPseudoLevel); +``` + +这样做的好处是: + +1. 每一章都有明确的入章/出章目标 +2. 等级增幅随章节自然变慢 +3. 经验速度评估可以直接落成表格 + +## 6.3 章节经验份额 + +默认建议: + +| 章节类型 | 任务经验占比 | 击杀经验占比 | +| --------------- | ------------ | ------------ | +| 调查/关系型章节 | `75%` | `25%` | +| 平衡型章节 | `65%` | `35%` | +| 战斗/试炼型章节 | `55%` | `45%` | + +章节类型判定可由下面几项共同决定: + +1. `SceneChapterBlueprint.acts` 数量 +2. 当前章节 hostile NPC 数量 +3. 当前章节任务 step 中战斗目标占比 +4. `dangerLevel` +5. linked thread 是否为主线高压线程 + +## 6.4 实际速度评估规则 + +每章结束后,至少计算下面三个值: + +1. `actualTotalXp / plannedTotalXp` +2. `actualHostileXp / plannedHostileXp` +3. `levelAtExit - plannedExitLevel` + +建议判定: + +| 偏差 | 判断 | +| ----------- | -------- | +| `±10%` 内 | 正常 | +| `10% ~ 20%` | 需观察 | +| `> 20%` | 必须调参 | + +这就是“评估每一章获得经验速度”的正式口径,不再用主观感觉判断。 + +--- + +## 7. NPC 自动定级规则 + +## 7.1 默认角色分类 + +建议默认按当前幕和敌我属性推导 `progressionRole`: + +1. 当前幕 `primaryNpcId` + + - 若 hostile:`hostile_elite` 或 `hostile_boss` + - 若非 hostile:`guide` 或 `rival` + +2. 非主角色 hostile NPC + + - `hostile_standard` + +3. 非主角色友方 NPC + - `support` 或 `ambient` + +如需修正,再允许章节蓝图加可选 override,但不要求创作者每次手填。 + +## 7.2 等级锚点 + +每章先得到: + +1. `entryLevel` +2. `exitLevel` + +然后按当前阶段得到阶段锚点: + +| 阶段 | 目标锚点 | +| --------------- | ----------------------------- | +| `opening` | 接近 `entryLevel` | +| `expansion` | `entryLevel ~ exitLevel` 中段 | +| `turning_point` | 接近 `exitLevel` | +| `climax` | `exitLevel` | +| `aftermath` | `exitLevel - 1` 或持平 | + +## 7.3 最终定级 + +建议公式: + +```ts +baseStageLevel = interpolate(entryLevel, exitLevel, stageProgress); + +npcLevel = round(baseStageLevel) + roleOffset(progressionRole); +``` + +建议 offset: + +| role | offset | +| ------------------ | -------- | +| `ambient` | `-1` | +| `support` | `0` | +| `guide` | `0` | +| `rival` | `0 ~ +1` | +| `hostile_standard` | `0` | +| `hostile_elite` | `+1` | +| `hostile_boss` | `+2` | + +约束: + +1. 统一 clamp 到 `1 ~ terminalStoryLevel + 2` +2. 不允许出现“第 3 章普通怪高于第 6 章精英”的跨章倒挂 +3. `hostile_boss` 如果需要更强,必须给更高等级,不准同级偷倍数 + +## 7.4 同级不同风格 + +NPC 等级确定后,再把 `referenceStrength` 套到具体风格: + +1. 重装型: + + - 生命占比更高 + - 爆发占比更低 + +2. 迅捷型: + + - 生命占比更低 + - 出手与压制占比更高 + +3. 控场型: + - 法力/控制预算更高 + +但这一步只能做“分布调整”,不能改变同级总参考强度。 + +--- + +## 8. 与当前仓库的接入点 + +## 8.1 第一批必须改的类型 + +1. `src/types/game.ts` + + - 新增 `playerProgression` + +2. `src/types/story.ts` + + - `QuestReward.experience` + +3. `src/types/scene.ts` + + - `SceneNpc.levelProfile` + - `SceneHostileNpc.levelProfile` + - `SceneHostileNpc.experienceReward` + +4. `packages/shared/src/contracts/story.ts` + - 如果需要让前后端合同正式共享等级展示字段,在这里补最小契约 + +## 8.2 第一批必须改的后端模块 + +1. `server-node/src/modules/quest/questStoryActionService.ts` + + - `resolveQuestTurnInAction(...)` 里追加任务经验发放 + +2. `server-node/src/modules/quest/questRuntimeSignalService.ts` + + - 保持 quest signal 职责 + - 不直接负责经验裁决,只把可用信号交给 progression 模块 + +3. `server-node/src/modules/combat/**` + + - 在胜利结算后发 hostile NPC 经验 + +4. `server-node/src/modules/story/**` + + - 在切章、进场、恢复场景时接入章节成长计划与 ledger + +5. 新增 `server-node/src/modules/progression/**` + - 成为等级、经验、章节定级唯一真相源 + +## 8.3 第一批不建议重写的部分 + +这轮不建议一开始就重写: + +1. 整套前端战斗 UI +2. 整套属性系统 +3. Quest UI 大面板结构 +4. 所有 hostile preset 原始配置文件 + +更稳的做法是: + +1. 先让后端算出等级与经验 +2. 再把结果投影到现有运行时字段 +3. 最后再逐步清理旧静态强度残留 + +--- + +## 9. 迁移策略 + +## 9.1 旧存档兼容 + +旧存档没有 `playerProgression` 时: + +1. 默认初始化为 `Lv.1` +2. `totalXp = 0` +3. `currentLevelXp = 0` +4. `xpToNextLevel = benchmark[1].xpToNextLevel` + +如果后续希望更平滑,可在第二轮增加“按当前章节进度反推起始等级”的迁移脚本,但首版先不要让迁移复杂化。 + +## 9.2 旧 hostile preset 兼容 + +旧 preset 里的 `hp/maxHp` 首版处理建议: + +1. 先保留原字段作为 style hint +2. 运行时用 level benchmark 覆盖最终 `hp/maxHp` +3. 保证当前素材和行为标签不需要重做 + +## 9.3 旧任务兼容 + +旧任务没有 `reward.experience` 时: + +1. 默认按 `0` 处理 +2. 仅新生成或重新编译的任务带经验 +3. 章节主任务优先切到新编译链 + +--- + +## 10. 开发顺序 + +## 阶段 A:先把等级状态立住 + +先做: + +1. `PlayerProgressionState` +2. `LevelBenchmark[]` +3. 经验加点与升级服务 + +验收: + +1. 后端能正确加经验与升级 +2. 前端能稳定展示 `Lv. X / 经验条` + +## 阶段 B:接任务经验 + +先做: + +1. `QuestReward.experience` +2. `quest turn-in` 经验发放 +3. 任务结果文案里补 `经验 +N` + +验收: + +1. 交付任务后能加经验 +2. 升级时能正确连跳 + +## 阶段 C:接章节预算与 NPC 自动定级 + +先做: + +1. `ChapterProgressionPlan` +2. `npcLevelResolver` +3. runtime hostile NPC 经验值生成 + +验收: + +1. 进入不同章节时 NPC 等级自动变化 +2. 同级不同风格但参考强度一致 + +## 阶段 D:接击败敌对 NPC 经验与章节 ledger + +先做: + +1. hostile defeat 经验 +2. `ChapterExperienceLedger` +3. 章节偏差评估输出 + +验收: + +1. 每章都能看到计划/实际经验偏差 +2. 重复刷同章敌对 NPC 不会破坏曲线 + +--- + +## 11. 验收标准 + +做到下面这些,才算这次等级系统设计真正落地: + +1. 玩家正式拥有 `等级 + 经验 + 升级` 主链。 +2. 经验来源只通过后端发放,前端不本地算经验。 +3. 同等级实体共享同一档 `参考强度`。 +4. 每章都能生成 `入章等级 / 出章等级 / 经验预算`。 +5. 每章的 NPC 都能按章节自动定级。 +6. 完成任务、击败敌对 NPC 都能稳定获得经验。 +7. 章节结束后能评估“这一章经验速度是否正常”。 +8. 现有任务、章节、属性和 hostile NPC 主链不被推翻,只是在其上新增成长预算层。 + +--- + +## 12. 最后结论 + +这次等级系统设计的重点,不是简单在 UI 上加一个 `Lv.1`,而是把当前仓库里已经存在的: + +1. 章节闭环 +2. 任务结算 +3. 敌对 NPC 胜利事件 +4. 统一属性与 hostile preset + +收束到一条新的成长主链: + +**章节先给出目标等级与经验速度,系统再按这套速度自动设置 NPC 等级,并把任务交付与击败敌对 NPC 统一变成可控的经验入口。** + +这样之后,等级不再只是一个展示数字,而会真正变成: + +- 玩家成长速度的刻度 +- 同级参考强度的刻度 +- 章节节奏是否合理的刻度 +- 不同章节 NPC 强度自动落位的刻度 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 new file mode 100644 index 00000000..372f9e19 --- /dev/null +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -0,0 +1,193 @@ +# 平台首页公开浏览与登录弹窗拦截设计 + +更新时间:`2026-04-19` + +## 0. 背景 + +当前仓库里的账号 PRD 默认要求“未登录先登录,再进入平台”。 + +这次产品策略调整为: + +- 用户进入平台后,默认可以直接浏览首页 +- 只有在尝试进入作品、进入世界、开始创作等受保护动作时,才检查登录 +- 登录界面不再是完整页面,而是覆盖在当前平台上的轻量弹窗 + +这份设计只覆盖当前一次前台入口改造,目标是把边界写清楚到可以直接编码,不再让登录策略和平台首页互相冲突。 + +--- + +## 1. 本次目标 + +1. 未登录用户可以正常进入平台首页并浏览公开内容。 +2. 点击作品卡片时,若未登录,弹出登录弹窗;登录成功后继续进入刚才点击的作品。 +3. 打开创作类型选择后,点击具体游戏类型开始创作时,若未登录,弹出登录弹窗;登录成功后继续刚才的创作动作。 +4. 登录 UI 改成极简弹窗,只保留窗口标题、必要输入框、必要按钮、错误态与关闭能力。 +5. 未登录态下不要继续请求“我的作品 / 个人看板 / 云端浏览历史 / 云端存档列表”这类受保护数据,避免首页公开态出现无意义报错。 + +--- + +## 2. 公开态与受保护动作边界 + +## 2.1 未登录允许访问 + +- 平台首页主视图 +- 精选推荐 +- 最新发布 +- 创作类型选择弹窗本身的展示 +- 本地浏览历史展示(若存在) + +说明: + +- “允许访问”只代表允许看,不代表允许进入作品详情、开始世界或创建内容。 +- 首页公开态必须保持可读,不因账号接口 401 出现整屏报错。 + +## 2.2 未登录必须拦截 + +- 点击任意作品卡片 +- 点击作品详情中的“开始游戏” +- 点击作品详情中的“继续创作 / 发布 / 下架 / 删除”等作者动作 +- 点击创作类型卡片,开始进入具体创作工作台 +- 其他后续新增的“进入世界 / 开始正式创作”入口 + +拦截方式统一为: + +- 保持当前页面上下文不跳走 +- 直接弹出登录弹窗 +- 登录成功后自动继续刚才被拦截的动作 + +--- + +## 3. 登录弹窗设计 + +## 3.1 展示形态 + +- 使用居中的 modal 覆盖层 +- 背景保留平台当前页面,只加遮罩和轻微模糊 +- 移动端优先,弹窗宽度贴近屏幕边缘,底部和顶部留出安全边距 +- 桌面端保持紧凑,不做双栏 hero,不再单独占满整页 + +## 3.2 内容约束 + +弹窗内默认只保留: + +- 标题:`登录账号` +- 手机号输入框 +- 验证码输入框 +- 获取验证码按钮 +- 登录主按钮 +- 微信登录按钮(当后端开放时) +- 图形验证码输入区(仅后端要求时出现) +- 错误提示 +- 关闭按钮 + +明确不再保留: + +- 品牌副标题 +- 功能介绍段落 +- 规则说明卡片 +- “先登录再同步进度”这类描述性文案 +- 占据视觉主体的装饰信息块 + +## 3.3 登录成功后的行为 + +- 手机号登录成功后,关闭弹窗 +- 当前平台页面不刷新 +- 若用户是被某个受保护动作拦截进入登录,则自动恢复该动作 +- 若用户只是主动点“登录”按钮,则关闭弹窗并停留在当前页面 + +## 3.4 关闭行为 + +- 用户主动关闭弹窗时,只关闭弹窗,不改变当前平台页面 +- 不清空首页浏览状态 +- 不自动跳转到其他 tab + +--- + +## 4. 前端状态约束 + +## 4.1 AuthGate + +`AuthGate` 需要从“未登录整页拦截器”调整为“平台级账号状态提供器”: + +- `checking / recovering`:仍可显示加载态,避免首屏闪烁 +- `unauthenticated`:渲染平台内容,同时允许按需打开登录弹窗 +- `ready`:渲染平台内容和账号能力 +- `pending_bind_phone`:继续保留当前绑定手机号流程,不在这次入口改造里拆散 + +同时需要在 context 中提供: + +- 当前用户 +- 打开登录弹窗 +- 打开账号面板 +- `requireAuth(action)` 能力 + +`requireAuth(action)` 约束: + +- 已登录:直接执行 `action` +- 未登录:弹出登录弹窗,并缓存 `action` +- 登录成功:自动执行缓存的 `action` + +## 4.2 平台首页数据加载 + +`PreGameSelectionFlow` 在未登录时只读取: + +- 公开作品广场 +- 本地浏览历史 + +公开作品广场前端请求约束: + +- `listCustomWorldGallery` +- `getCustomWorldGalleryDetail` + +这两类公开请求必须走“公开只读请求”通道: + +- 不主动附带 `Authorization` +- 不因本地 access token 失效去触发 `/api/auth/refresh` +- refresh cookie 缺失、refresh 失败、账号状态过期时,不能把首页公开作品广场一起拖成错误态 + +未登录时不读取: + +- 自定义世界库 +- 个人看板 +- 云端浏览历史 +- 云端运行时设置 +- 云端存档快照 +- 云端存档列表 + +未登录态的对应前台表现: + +- “我的创作”显示空态,不显示账号接口错误 +- “个人页”显示未登录态入口,可手动打开登录弹窗 +- 音量等运行时设置继续使用本地缓存,不触发 `/api/runtime/settings` +- 未登录态不显示“继续远端存档”能力,也不触发 `/api/runtime/save/snapshot` +- 未登录态的“存档”Tab 只展示登录引导,不触发 `/api/runtime/profile/save-archives` + +--- + +## 5. 代码落点 + +本次实现最少要覆盖: + +- `src/components/auth/AuthGate.tsx` +- `src/components/auth/AuthUiContext.ts` +- `src/components/auth/LoginScreen.tsx` +- `src/components/game-shell/PreGameSelectionFlow.tsx` +- `src/components/game-shell/PlatformHomeView.tsx` +- `src/components/game-shell/PlatformCreationTypeModal.tsx` + +测试至少覆盖: + +- 未登录时平台首页仍能渲染 +- 未登录点击作品卡片会打开登录弹窗 +- 未登录点击创作类型卡片会打开登录弹窗 +- 登录成功后会继续刚才被拦截的动作 + +--- + +## 6. 验收标准 + +1. 用户首次进入平台时,不会先看到整页登录页,而是能看到首页内容。 +2. 未登录点击作品时,直接弹出登录弹窗,登录后自动进入对应作品流。 +3. 未登录选择 RPG 创作类型时,直接弹出登录弹窗,登录后自动进入创作工作台。 +4. 登录弹窗内没有介绍性大段文字,只剩必要输入与按钮。 +5. 未登录态首页不会因个人接口失败而出现“读取个人看板失败”“读取作品库失败”之类报错。 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 new file mode 100644 index 00000000..e77883a2 --- /dev/null +++ b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md @@ -0,0 +1,118 @@ +# 平台层 UI 去像素化刷新设计 + +更新时间:`2026-04-20` + +## 1. 目标 + +本次刷新只覆盖平台层功能 UI,不改游戏内 HUD、战斗、地图、剧情面板等像素风界面。 + +目标有 5 个: + +1. 平台层正文与功能信息不再使用像素字体 +2. 平台层不再使用像素九宫格边框、像素图标、像素背景纹理这类平台 chrome +3. 原有紫蓝深色方案沉淀为平台暗色主题 +4. 新参考图沉淀为平台亮色主题:白色主面板、粉橘主强调、暖白背景、高亮图卡 +5. 平台默认使用亮色主题,移动端保持现有布局结构不变,桌面端允许在不改变业务入口的前提下重组为控制台式平台壳层 + +## 2. 覆盖范围 + +本次统一按 `!gameState.worldType` 的平台态处理,覆盖: + +- 平台首页 `PlatformHomeView` +- 作品详情 `PlatformWorldDetailView` +- 创作类型弹窗 `PlatformCreationTypeModal` +- 平台创作链路中的生成页、结果页、目录页、编辑弹窗 + +明确不覆盖: + +- 进入世界后的游戏内 UI +- 地图、战斗、剧情面板、角色面板、背包面板等像素 RPG 界面 +- 世界内容本身的数据图片、角色主图、场景图等作品内容素材 + +说明: + +- “不再引用像素素材”指平台 chrome 不再依赖像素框、像素按钮、像素关闭图标、像素底纹等 UI 资源 +- 作品内容图仍可展示,但平台层不再用 `image-rendering: pixelated` 强化像素感 + +## 3. 视觉原则 + +### 3.1 风格来源 + +直接对齐现有登录页和绑定手机号页的成熟样式,并吸收本次参考图的桌面端气质: + +- 暗色主题:顶部与边缘的紫蓝径向高光 + 深色纵向渐变背景 +- 亮色主题:暖白控制台外壳 + 粉橘主强调 + 轻紫细节高光 +- 大圆角卡片 +- 半透明玻璃质感 +- 平台正文与功能信息统一使用 `Inter + Noto Serif SC` +- 左上角品牌区允许使用专用像素字标组件或直接使用 `Fusion Pixel` 文本,但仅限品牌 logo,不向正文、按钮、标签扩散 +- 品牌 logo 只能复用游戏现有 `Fusion Pixel`,不允许再引入第二套像素字体文件 + +主题基准: + +- 暗色主题: + 底色以深靛蓝、深紫黑为主,高光以亮紫、蓝青为主 +- 亮色主题: + 底色以暖白、浅粉白、浅橘白为主,强调色以高饱和粉色、橘粉色为主,局部可带少量紫色作装饰 +- 平台默认主题使用亮色主题;暗色主题保留为可切换方案,不作为当前默认展示 + +### 3.2 排版 + +- 平台层正文、按钮、说明、功能标签统一使用非像素字体 +- 左上角 `叙世 / GENARRATIVE` 品牌字标允许单独做成像素化 logo +- `GENARRATIVE` 与 `叙世` 都优先直接使用游戏内同款 `Fusion Pixel` +- 品牌字标默认保持正常像素字观感,禁止再叠双层粗阴影或手动加粗到影响识别 +- 品牌字标直接使用字体文件内原字形,不额外做运行时描字、轮廓拼字或伪粗体处理 +- 主标题保留明显层级,但不再做像素描边效果 +- 微型标签维持高字距英文/中文短标签,用来保留产品感和秩序感 + +### 3.3 组件约束 + +- 面板:使用玻璃卡片,不再用九宫格像素框 +- 按钮:使用圆角胶囊按钮或渐变主按钮,不再用像素按钮框 +- 图标:优先使用 `lucide-react` +- Tab:移动端底部结构不变,但图标与底座改成非像素风;桌面端切换为左侧纵向导航轨道 +- 弹窗:沿用登录页的圆角浮层和半透明遮罩,不再使用像素弹窗边框 +- 桌面壳层:首页允许增加顶部工具栏、左侧导航轨、中央内容舞台与右侧趋势面板的组合 +- 登录页、绑定手机号、账户弹窗、平台详情、创作生成页、结果页、编辑弹窗都必须共享同一套平台主题 token,禁止再各自写一套独立旧色板 +- 创作中心、Agent 工作台、草稿详情抽屉、资产工坊、启动弹窗、生成弹窗这类二三级平台面板必须显式挂载平台主题壳层或平台 remap 容器,禁止直接在局部面板里写死旧深色 modal 底和旧输入框底色 +- 平台“我的”页中的“设置”入口必须打开真正的设置面板;账号信息、设备管理、安全状态属于设置面板中的分区,不允许再把账号信息弹层直接充当设置页 +- 设置面板必须支持平台亮色 / 暗色主题切换,并复用同一套平台 token 驱动登录页、首页、详情页与二三级面板 +- 首页移动端底部 Tab 与桌面侧边导航的图标底座、图标颜色、文字状态必须全部由平台 token 驱动;暗色主题下不得出现过浅底座和错误文字色,亮色主题下不得残留旧灰蓝 inactive 状态 +- 首页、存档页、作品详情这类平台主导航与局部 Tab 的 active fill、active shadow、icon shell fill 必须全部来自主题 token;暗色主题禁止继续复用亮色主题的粉橘高光、白色 active 底座 +- 创作链路中的吸顶返回栏、目录 Tab 条、搜索工具条也必须走平台亮暗主题 token;暗色主题禁止继续写死暖白渐变或浅粉背景作为顶部衬底 +- “我的”页账号主卡必须跟随平台亮 / 暗主题联动,不允许继续写死浅色渐变卡面与 `slate` 系按钮 + +## 4. 交互与布局约束 + +- 移动端保持原有页面布局层级、区块顺序、操作入口位置不变 +- 桌面端首页允许参考图示重组为“顶部工具栏 + 左侧纵向导航 + 主 Hero 卡 + 右侧趋势列表 + 下方内容卡组” +- 桌面端的重组只改变视觉排布;自 `2026-04-19` 起平台主入口调整为“首页 / 创作 / 存档 / 我的”,四个入口的操作路径都必须保持清晰稳定 +- 移动端优先,底部 tab 与主卡片点击区域不能缩小 +- 不在平台 UI 面板里额外堆砌规则说明 +- 所有视觉替换必须是局部补丁,不做无必要的大规模结构重写 + +## 5. 实现约束 + +- 平台态从 `fusion-pixel-app` 中隔离,避免被全局像素字体覆盖 +- 品牌区禁止新增额外像素字体包;平台层只允许保留现有 `public/fusion-pixel.ttf` 这一份像素字体资源 +- 平台态背景不再使用 `/UI/Background_fill.png` +- 新样式优先沉淀为平台专用 class / theme token,避免把游戏内像素 class 改坏 +- 平台默认挂载亮色主题 class,旧紫蓝方案保留为暗色主题 class +- 亮色主题需要补齐统一的 overlay、progress track、status pill token,登录弹层与二三级功能面板禁止继续沿用旧深色遮罩与紫蓝强调残留 +- 亮色主题下平台壳层与各个 Tab 页的 page stage 必须以暖白底为主,禁止继续让高饱和深粉底或旧深色底透成页面主背景 +- 亮色主题下平台主内容区、page stage、移动端底部 Tab 容器都必须使用接近实色的暖白底,禁止继续用高透明度浅色层叠在深底上造成整体发灰 +- 平台态中仍保留旧 Tailwind 深色类的历史组件,必须通过平台 remap 容器或平台专用 class 统一收口,不能放任 `bg-[#111318]`、`bg-black/*`、`bg-white/*` 这类旧类在亮色主题下直接裸露 +- 编辑弹窗保留业务结构与表单逻辑,只替换壳层样式 + +## 6. 验收标准 + +达到以下结果才算完成: + +1. 除左上角品牌像素字标外,平台首页、详情、登录、绑定手机号、账户弹窗、创作入口、创作结果页不再出现像素字体 +2. 平台层按钮、面板、关闭按钮、底部 tab 不再依赖像素 UI 素材 +3. 平台默认展示亮色主题,暗色主题保留为独立主题方案 +4. 平台层二三级面板、表单、状态卡、弹窗与登录体系不再残留旧金橙 / 青蓝 / 深黑混搭方案 +5. 平台层世界封面与角色预览不再使用 `pixelated` 渲染 +6. 游戏内像素 UI 保持原样,不出现误改 +7. 手机端布局保持稳定,桌面端在参考图方向下完成控制台化重组 diff --git a/docs/design/README.md b/docs/design/README.md index c3106211..38ebb042 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -10,9 +10,11 @@ - [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。 - [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。 +- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。 - [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 +29,8 @@ - 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。 - 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。 - 做角色关系、同伴互动、对话表现时,先看后两份。 +- 做“高好感聊天里如何顺着上下文自然抛出委托、并让任务在聊天内领取”的需求时,优先看新增的聊天委托流程设计稿。 - 做剧情引擎章节化、场景闭环、章节任务接入时,优先看新增的场景章节设计稿。 - 做“单章节体验还缺什么、该补哪种情感 / 抉择 / 试炼模块”时,优先看新增的章节对标补强设计稿。 +- 做等级成长、任务/击败敌对 NPC 发经验、章节经验速度评估、NPC 自动定级时,优先看新增的等级系统设计稿。 - 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。 diff --git a/docs/experience/AGENT_UI_CHANGELOG.md b/docs/experience/AGENT_UI_CHANGELOG.md index 230d4e69..94bf6d96 100644 --- a/docs/experience/AGENT_UI_CHANGELOG.md +++ b/docs/experience/AGENT_UI_CHANGELOG.md @@ -113,4 +113,12 @@ --- +## 8. 2026-04-18 补充记录 + +- `GameShellRuntime` 进入游戏壳时,会主动隐藏认证层提供的右上角全局账号信息条。 +- 原因不是账号功能下线,而是这个悬浮条会遮挡冒险主场景内容,移动端更明显。 +- 账号相关入口保留在平台首页 / 个人页内部按钮与账号弹窗,不再占用游戏 HUD 区域。 + +--- + *文档目的:交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。* diff --git a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md index f36d8343..7288de06 100644 --- a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md +++ b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md @@ -89,6 +89,12 @@ - 在底部工具区,队伍/背包改成 icon 后更紧凑。 - 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。 +### 4.5 冒险主场景不要挂右上角账号悬浮条 +- 冒险页右上角属于画面演出和战斗/剧情信息的高频观察区。 +- 全局账号信息条挂在这里,会直接压住场景、敌人血条或顶部提示,手机端尤其明显。 +- 结论: + 账号入口应收回平台首页、个人页或设置面板,不要在实际冒险主场景常驻悬浮显示。 + ## 5. 队伍面板经验 ### 5.1 移动端成员列表不能太“卡片化” diff --git a/docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md b/docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md index 93a093c2..fb0e209e 100644 --- a/docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md +++ b/docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md @@ -76,6 +76,19 @@ - 流程层优先按“职责”拆,不按“文件长度”拆。 - 状态修改逻辑尽量集中到 hook 内,不要散落在多个组件按钮回调里。 +## 3.1 AI 草稿数据进列表前,要先补本地稳定标识 + +自定义世界、角色草稿、澄清问题、生成结果卡片这类数据,在草稿态或兼容旧数据时,`id` 可能为空。 + +经验: + +- React 列表的 `key` 不要直接裸用这类可能为空的 `id`。 +- 当前选中态、草稿缓存、轮播焦点也不要直接绑空 `id`,否则会出现“点了第二张卡,结果还是第一张卡被选中”的错位。 +- 更稳的做法是: + - 业务数据层尽量补齐真实 id + - UI 层再补一层本地稳定 `selectionKey` / fallback render key + - fallback 至少带上 `index + 名称种子`,保证当前列表内唯一 + ## 4. AI 只适合生成叙事,不适合决定关键规则 实践中最稳定的策略是: diff --git a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md index 254b09f5..bd6b3451 100644 --- a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md +++ b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md @@ -1,6 +1,13 @@ # 账号系统与登录入口重构 PRD -更新时间:`2026-04-09` +更新时间:`2026-04-19` + +> 2026-04-19 入口策略补充: +> 平台首页现调整为“未登录也可浏览公开首页”,不再要求用户先登录才能进入平台。 +> 登录拦截点改为“点击作品进入详情/世界”与“选择游戏类型开始创作”等受保护动作触发时再弹出登录弹窗。 +> 本次入口策略与弹窗约束以 +> [`docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md`](../design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md) +> 为准;本 PRD 中“先登录再进入开始界面”的旧表述不再作为当前前台入口实现依据。 ## 0. 目标 @@ -175,7 +182,7 @@ MVP 阶段建议采用最稳妥规则: 4. 微信后强制绑定手机号 5. 账号会话管理 6. 账号与存档/自定义世界/运行时设置统一绑定 -7. 基础账号中心与退出登录 +7. 基础账号中心、平台设置面板与退出登录 ## 3.2 本期不做 @@ -460,6 +467,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含: - 已绑定手机号(脱敏展示) - 微信绑定状态 - 最近登录时间 +- 平台设置面板中的亮色 / 暗色主题切换 - 退出登录 二期可以再补: 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 d66a709e..e50d1951 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 @@ -1,6 +1,6 @@ # AI 角色形象与角色动画 MVP PRD -更新时间:`2026-04-04` +更新时间:`2026-04-19` ## 0. 一句话结论 @@ -254,49 +254,49 @@ MVP 支持三种主形象输入方式: MVP 必须与当前项目可扮演角色动作槽位对齐。 -第一版要求以下基础动作槽位不能为空: +当前落地实现补充约束(`2026-04-20`): -| 动作槽位 | 是否必填 | 备注 | -| --- | --- | --- | -| `idle` | 必填 | 循环动作 | -| `acquire` | 必填 | 可由短变体衍生 | -| `attack` | 必填 | 一次性动作 | -| `run` | 必填 | 循环动作 | -| `jump` | 必填 | 一次性动作 | -| `double_jump` | 必填 | 可由跳跃二次变体生成 | -| `jump_attack` | 必填 | 一次性动作 | -| `dash` | 必填 | 一次性动作 | -| `hurt` | 必填 | 一次性动作 | -| `die` | 必填 | 一次性动作 | -| `climb` | 必填 | 可由模板生成 | -| `wall_slide` | 必填 | 可由攀爬停帧变体生成 | +- 角色资产工坊固定生成入口仍为 `idle / run / attack / die` +- `run / attack` 是固定基础必生成动作 +- `idle / die` 改为固定可选动作,不再作为发布硬门槛 +- `idle` 未生成时默认直接使用主图静止显示 +- `die` 未生成时默认播放一段基于主图的倒地过渡动画,并最终停在翻转倒地姿态 +- 角色已配置的每个技能,都必须在技能编辑面板里补出对应动作预览 +- 图生视频默认走火山方舟 `Seedance` 首尾帧方案 +- 接口请求体中的两张参考图分别固定为 `first_frame / last_frame` +- 固定参数为 `1:1`、`480p`、`4 秒`、单次 `1` 个视频 +- 提示词中的动作名统一传英文动作名 -这里“不能为空”指的是: +第一版动作生成按下面两层规则落地: -- 每个槽位必须最终指向一套可播放的资源 -- 允许少量槽位由近似动作衍生 -- 但不允许在运行时读到空动画映射 +| 类别 | 动作槽位 | 是否必填 | 备注 | +| -------- | ------------------------------- | -------- | -------------------------------------------------- | +| 基础动作 | `run` | 必填 | 角色移动主循环动作 | +| 基础动作 | `attack` | 必填 | 角色普通攻击主动作 | +| 技能动作 | `skills[*].actionPreviewConfig` | 必填 | 当前角色每个已配置技能都要有独立动作资源 | +| 可选动作 | `idle` | 可选 | 缺失时默认走主图静止待机 | +| 可选动作 | `die` | 可选 | 缺失时默认走主图倒地过渡动画,最终停在翻转倒地姿态 | + +这里“必生成”指的是: + +- `run / attack` 必须最终指向可播放资源 +- 每个已配置技能都必须带独立 `actionPreviewConfig` +- 发布判定不再要求 `idle / die` 一定存在动画映射 +- 运行时仍然不能出现无可用表现;`idle / die` 的缺口由默认兜底承担 ## 8.2 技能动作要求 -本期不要求自动补齐: +本期不再要求把整套固定技能枚举一次性自动补齐,但对“角色当前实际配置的技能”改为必做: -- `skill1` -- `skill1_jump` -- `skill1_bullet` -- `skill1_bullet_fx` -- `skill2` -- `skill2_jump` -- `skill3` -- `skill3_jump` -- `skill3_bullet` -- `skill3_bullet_fx` -- `skill4` +- 不要求预先把 `skill1 / skill2 / skill3 / skill4` 这套历史枚举全部补满 +- 只要求当前角色 `skills` 数组里的每个技能都生成独立动作预览 +- 技能动作生成入口继续放在技能编辑面板逐个处理,不塞进固定四按钮里 结论: -- 技能动作本期可选 -- 基础动作本期必做 +- 技能动作从“固定枚举可选”调整为“按角色已配技能必做” +- 固定基础动作收敛为 `run / attack` +- `idle / die` 保留为可选增强动作 ## 8.3 动作生成方式 @@ -345,7 +345,6 @@ MVP 支持两种方式: - `attack` - `jump_attack` -- `hurt` - `die` 要求末帧清晰,不与下一动作切换冲突。 @@ -490,7 +489,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/**` 建议新增: @@ -598,7 +597,7 @@ type GeneratedCharacterAnimationAsset = { 目标: -- 让基础动作槽位全部非空,并可一键发布 +- 让必生成动作全部就绪,并为 `idle / die` 提供明确默认兜底 产出: @@ -634,4 +633,3 @@ type GeneratedCharacterAnimationAsset = { - 路径清晰 - 能真正进入当前仓库 - 后续可以在此基础上再加技能动作、剧情演出和多供应商增强路线 - diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md index 50d32193..d03205f8 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md @@ -200,7 +200,7 @@ 在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为: ```ts -kind === 'character' +kind === 'character'; ``` 显示按钮: @@ -239,11 +239,19 @@ kind === 'character' 基于主图生成当前工坊支持的核心动作: +1. `run` +2. `attack` + +可选增强动作: + 1. `idle` -2. `run` -3. `attack` -4. `hurt` -5. `die` +2. `die` + +补充约束: + +1. `run / attack` 为固定必生成动作 +2. 角色已配置技能时,对应技能动作也属于必生成动作 +3. `idle / die` 只作为可选增强,缺失时分别走主图静止 / 主图倒地过渡动画兜底,死亡动画最终停在翻转倒地姿态 ### 阶段 D:动作发布 @@ -350,15 +358,15 @@ type CustomWorldRoleAssetStatus = 发布主图成功后,必须写回: ```ts -imageSrc -generatedVisualAssetId +imageSrc; +generatedVisualAssetId; ``` 发布动作成功后,必须写回: ```ts -generatedAnimationSetId -animationMap +generatedAnimationSetId; +animationMap; ``` ### 明确要求 @@ -440,8 +448,8 @@ type SyncRoleAssetsResult = { ### 输入 ```ts -buildRoleAssetStudioContext(snapshot, roleId) -applyRoleAssetPublishResult(snapshot, payload) +buildRoleAssetStudioContext(snapshot, roleId); +applyRoleAssetPublishResult(snapshot, payload); ``` ### 说明 @@ -465,8 +473,8 @@ applyRoleAssetPublishResult(snapshot, payload) ### 导出函数建议 ```ts -rebuildRoleAssetCoverage(draftProfile) -mergeRoleAssetIntoDraftProfile(draftProfile, payload) +rebuildRoleAssetCoverage(draftProfile); +mergeRoleAssetIntoDraftProfile(draftProfile, payload); ``` ## 10.3 修改 `customWorldAgentOrchestrator.ts` @@ -598,7 +606,7 @@ showRoleAssetStudio: boolean; 3. 统一回调: ```ts -onPublishSuccess(payload) +onPublishSuccess(payload); ``` ### `onPublishSuccess` 最小字段 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md index cfa80262..eb8c9b46 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md @@ -39,9 +39,11 @@ 目标用户分三类: 1. 轻创作者 + - 有世界灵感,但不擅长结构化填表 2. 中度创作者 + - 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段 3. 重度创作者 @@ -138,37 +140,48 @@ 本次 PRD 必须复用以下现有基础: 1. `src/services/customWorldCreatorIntent.ts` + - 已有创作者意图、锚点包、锁定状态的基础结构 2. `src/types/customWorld.ts` + - 已有 `creatorIntent / anchorPack / lockState / generationMode / generationStatus` 3. `src/services/aiService.ts` + - 已有自定义世界 session 与生成 API 客户端 4. `server-node/src/services/customWorldSessionStore.ts` + - 已有澄清问题与 session 的基础概念 5. `server-node/src/services/customWorldGenerationService.ts` + - 已有分阶段生成骨架 6. `src/components/game-shell/PreGameSelectionFlow.tsx` + - 已有世界创建流程入口 7. `src/components/CustomWorldResultView.tsx` + - 已有结果页壳层 8. `src/components/CustomWorldRoleAssetStudioModal.tsx` + - 已有角色主图与核心动作资产工坊原型 9. `src/services/ai.ts` + - 已有 `generateCustomWorldSceneImage(...)` 场景图生成入口 10. `server-node/src/modules/assets/characterAssetRoutes.ts` - - 已有角色主图发布、角色动作发布、动作模板等资产路由 + +- 已有角色主图发布、角色动作发布、动作模板等资产路由 11. `server-node/src/routes/runtimeRoutes.ts` - - 已有 `/custom-world/scene-image` 场景背景图生成路由 + +- 已有 `/custom-world/scene-image` 场景背景图生成路由 ## 3.2 必须替换或重构的现有行为 @@ -220,6 +233,7 @@ 最终必须输出两类产物: 1. 创作工作产物 + - 世界圣经摘要 - 关键角色卡 - 关键地点卡 @@ -478,9 +492,11 @@ type CustomWorldAssetPriorityTier = 'hero' | 'featured' | 'supporting'; 判定规则: 1. `hero` + - 所有 `playableNpcs` 2. `featured` + - 被锁定的 `storyNpcs` - 主线第一幕直接关联的 `storyNpcs` - 势力代表角色 @@ -499,6 +515,7 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting'; 判定规则: 1. `key` + - `camp` - 被锁定的 `landmark` - 主线第一幕直接关联的 `landmark` @@ -530,32 +547,43 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting'; ### 动作抽卡策略 -角色动作不能一开始就把完整核心动作集全部抽出来。 +角色动作不能一开始就把所有动作一次性抽完。 -必须采用两段式: +必须采用“先必需、再增强”的两层策略: -#### 阶段 A:动作试片 +#### 阶段 A:基础必需动作 -每个角色先只生成: +每个角色先生成: -1. `idle` +1. `run` 2. `attack` 用途: 1. 检查角色一致性是否稳定 -2. 检查动作风格是否匹配 +2. 检查移动和出手两条主动作是否可用 3. 检查武器、衣摆和轮廓是否容易漂移 -#### 阶段 B:完整核心动作集 +#### 阶段 B:技能动作补齐 -只有当动作试片确认通过后,才允许生成: +当角色基础动作通过后,再逐个补当前角色已经配置的技能动作。 -1. `run` -2. `hurt` -3. `die` +要求: -加上已确认的 `idle / attack`,组成当前阶段完整核心动作集。 +1. 每个技能都必须有独立 `actionPreviewConfig` +2. 技能动作入口放在技能编辑面板,不并入固定四按钮 + +#### 可选增强动作 + +以下动作不再作为发布硬门槛,可按需要补: + +1. `idle` +2. `die` + +默认兜底: + +1. `idle` 缺失时使用主图静止 +2. `die` 缺失时使用主图倒地过渡动画,最终停在翻转倒地姿态 ### 场景图抽卡策略 @@ -616,16 +644,26 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting'; 发布前,每个角色至少需要以下动作槽位可用: -1. `idle` -2. `run` -3. `attack` -4. `hurt` -5. `die` +1. `run` +2. `attack` +3. 当前角色 `skills` 中每个技能的 `actionPreviewConfig` 判定方式: 1. `generatedAnimationSetId` 非空 -2. `animationMap` 中以上 5 个槽位都存在有效映射 +2. `animationMap` 中至少存在有效的 `run / attack` +3. `skills` 数组里的每个技能都带有效 `actionPreviewConfig` + +可选动作: + +1. `idle` +2. `die` + +说明: + +1. `idle / die` 不再是发布硬门槛 +2. `idle` 缺失时运行时默认使用主图静止 +3. `die` 缺失时运行时默认播放主图倒地过渡动画,最终停在翻转倒地姿态 说明: @@ -664,34 +702,34 @@ type CustomWorldAgentStage = ## 6.2 状态迁移规则 -| 当前阶段 | 触发 | 下一阶段 | -| --- | --- | --- | -| `collecting_intent` | 最小锚点不足,Agent 追问 | `clarifying` | -| `clarifying` | 用户补齐锚点 | `foundation_review` | -| `collecting_intent` | 用户信息已足够并请求底稿 | `foundation_review` | -| `foundation_review` | 用户精修关键对象 | `object_refining` | -| `object_refining` | 用户请求生成角色或场景资产 | `visual_refining` | -| `visual_refining` | 关键角色与场景资产进入可用状态 | `long_tail_review` | -| `object_refining` | 用户明确跳过人工精修并走自动补齐 | `long_tail_review` | -| `long_tail_review` | 用户请求发布 | `ready_to_publish` | -| `ready_to_publish` | 发布成功 | `published` | -| 任意阶段 | 发生不可恢复错误 | `error` | +| 当前阶段 | 触发 | 下一阶段 | +| ------------------- | -------------------------------- | ------------------- | +| `collecting_intent` | 最小锚点不足,Agent 追问 | `clarifying` | +| `clarifying` | 用户补齐锚点 | `foundation_review` | +| `collecting_intent` | 用户信息已足够并请求底稿 | `foundation_review` | +| `foundation_review` | 用户精修关键对象 | `object_refining` | +| `object_refining` | 用户请求生成角色或场景资产 | `visual_refining` | +| `visual_refining` | 关键角色与场景资产进入可用状态 | `long_tail_review` | +| `object_refining` | 用户明确跳过人工精修并走自动补齐 | `long_tail_review` | +| `long_tail_review` | 用户请求发布 | `ready_to_publish` | +| `ready_to_publish` | 发布成功 | `published` | +| 任意阶段 | 发生不可恢复错误 | `error` | ## 6.3 阶段显示规则 前端顶部摘要区必须展示当前阶段中文标签: -| 阶段 | 展示文案 | -| --- | --- | +| 阶段 | 展示文案 | +| ------------------- | ------------ | | `collecting_intent` | 收集世界锚点 | -| `clarifying` | 补充关键设定 | +| `clarifying` | 补充关键设定 | | `foundation_review` | 校对世界底稿 | -| `object_refining` | 精修关键对象 | -| `visual_refining` | 生成视觉资产 | -| `long_tail_review` | 补全长尾内容 | -| `ready_to_publish` | 准备发布 | -| `published` | 已发布 | -| `error` | 处理异常 | +| `object_refining` | 精修关键对象 | +| `visual_refining` | 生成视觉资产 | +| `long_tail_review` | 补全长尾内容 | +| `ready_to_publish` | 准备发布 | +| `published` | 已发布 | +| `error` | 处理异常 | --- @@ -980,7 +1018,15 @@ interface SendCustomWorldAgentMessageResponse { type CustomWorldAgentActionRequest = | { action: 'lock_cards'; cardIds: string[] } | { action: 'unlock_cards'; cardIds: string[] } - | { action: 'regenerate_scope'; scope: 'focus_card' | 'long_tail_npcs' | 'long_tail_landmarks' | 'sidequest_seeds'; targetCardId?: string | null } + | { + action: 'regenerate_scope'; + scope: + | 'focus_card' + | 'long_tail_npcs' + | 'long_tail_landmarks' + | 'sidequest_seeds'; + targetCardId?: string | null; + } | { action: 'draft_foundation' } | { action: 'generate_role_assets'; roleIds: string[] } | { @@ -1909,9 +1955,12 @@ Agent 会话每次 operation 完成后自动保存 session snapshot。 15. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` 16. `src/components/custom-world-agent/CustomWorldSceneAssetStudioModal.tsx` 17. `src/components/CustomWorldRoleAssetStudioModal.tsx` - - 改成 Agent 可调用版 + +- 改成 Agent 可调用版 + 18. `src/components/asset-studio/characterAssetWorkflowPersistence.ts` - - 继续复用现有资产接口客户端 + +- 继续复用现有资产接口客户端 ## 15.3 backend diff --git a/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md b/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md new file mode 100644 index 00000000..c3cfb4bb --- /dev/null +++ b/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md @@ -0,0 +1,233 @@ +# AI Native 战斗单行为 Function PRD(2026-04-18) + +## 1. 目标 + +本次迭代把战斗中的 function 从“战术风格 function”收敛为“单次可直接结算的原子行为 function”。 + +核心目标: + +1. 战斗中一次点击只完成一个明确行为,不再做连续多轮击打、连续多 actor 轮转的 function 设计。 +2. 战斗中除逃跑外,不再为每次动作额外触发剧情推理,而是直接结算数值并刷新下一轮战斗选项。 +3. 只有在逃跑成功或战斗正式结束后,才触发一次剧情推理,生成脱战后的 storyText 与后续剧情选项。 + +--- + +## 2. 新战斗 option 池 + +当 `inBattle = true` 时,默认战斗选项池固定收敛为以下结构,顺序按下列规则输出: + +1. `battle_attack_basic` +2. `battle_recover_breath` +3. `inventory_use` +4. `battle_use_skill` +5. `battle_escape_breakout` + +其中第 4 项不是单个 option,而是“每个技能一个 option 实例”。 + +### 2.1 普通攻击 + +- functionId:`battle_attack_basic` +- 含义:不消耗灵力的基础攻击。 +- 结算:直接结算一次基础伤害。 +- 不触发剧情推理。 + +### 2.2 恢复 + +- functionId:`battle_recover_breath` +- 含义:本回合做恢复与节奏调整。 +- 结算:直接恢复血量/灵力,并推进技能冷却。 +- 不触发剧情推理。 + +### 2.3 使用物品 + +- functionId:`inventory_use` +- 含义:战斗中直接使用一个可结算的消耗品。 +- 本期战斗选项池只给一个“推荐可用物品” option,不展开整包物品列表。 +- option 必须携带 `runtimePayload.itemId`。 +- 若当前没有可用消耗品,则仍保留该项,但以 disabled 态展示。 +- 不触发剧情推理。 + +### 2.4 使用技能 + +- functionId:`battle_use_skill` +- 每个角色技能都生成一个独立 option。 +- option 文案直接对应技能名,不再包装成“稳扎试探 / 破架 / 终结窗口”这类抽象战术文案。 +- option 必须携带 `runtimePayload.skillId`。 +- 若技能因蓝量不足或冷却中不可用,仍保留该 option,但以 disabled 态展示。 +- 点击后直接结算该技能本次效果,不触发剧情推理。 + +### 2.5 逃跑 + +- functionId:`battle_escape_breakout` +- 含义:立即尝试脱离当前战斗。 +- 结算:直接处理脱战结果。 +- 逃跑成功后必须触发剧情推理。 + +--- + +## 3. 旧战斗 function 的处理 + +以下旧 function 不再进入默认战斗选项池: + +- `battle_all_in_crush` +- `battle_guard_break` +- `battle_probe_pressure` +- `battle_feint_step` +- `battle_finisher_window` + +兼容规则: + +- 后端仍允许解析这些旧 functionId,避免旧存档 / 旧 currentStory 点击时报错。 +- 兼容结算统一按“单次攻击型行为”处理,不再保留旧的战术风格分支。 +- 新生成的新选项、新 currentStory、新 viewModel 不再继续下发这些旧 function。 + +--- + +## 4. 单行为结算规则 + +### 4.1 单次点击的边界 + +一次点击只允许完成一次玩家声明行为: + +- 普通攻击 +- 恢复 +- 使用物品 +- 使用某个具体技能 +- 逃跑 + +不再允许一次点击里继续串: + +- 多轮连续攻击 +- 多个技能连续释放 +- 多个角色依次轮转 +- 为了“表现完整”再补一整串额外战斗回合 + +### 4.2 回合感保留 + +虽然不再做连续多轮击打,但每个战斗动作仍然视为消耗了一个战斗回合,因此: + +- 技能冷却要按“本次动作结束后”推进 +- 恢复类动作可额外提供冷却推进收益 +- 物品动作在战斗态下也算一次战斗回合 +- 战斗中使用物品要先结算物品恢复 / buff / 额外冷却收益,再结算这一回合是否承受敌方单次反击 + +### 4.3 结果文本 + +ongoing battle 的本地/后端结果文本只负责说明这一次动作结算结果,不负责续写新的剧情段落。 + +例如: + +- 你挥出一记普通攻击,命中前方敌人。 +- 你稳住呼吸,恢复了部分气血与灵力。 +- 你立刻服下疗伤药,当前状态回升。 +- 你释放了【试锋斩】,直接压低了对方血线。 + +--- + +## 5. 剧情推理触发边界 + +### 5.1 不触发剧情推理的情况 + +当动作执行后仍处于战斗中时,以下 function 不触发剧情推理: + +- `battle_attack_basic` +- `battle_recover_breath` +- `inventory_use` +- `battle_use_skill` +- 旧攻击类兼容 function + +此时系统行为为: + +1. 直接结算动作 +2. 更新 HP / MP / CD / 物品 / 战斗状态 +3. 直接刷新新一轮战斗选项 +4. `storyText` 直接使用本次结算结果文本,不请求 AI 续写 + +### 5.2 必须触发剧情推理的情况 + +以下情况必须触发剧情推理: + +1. `battle_escape_breakout` 执行后成功脱战 +2. 任意战斗动作执行后,战斗正式结束 + +战斗正式结束包括: + +- 敌方被击败 +- 切磋结束 +- 玩家被系统判定为本轮战斗已断开 + +此时系统行为为: + +1. 先完成数值结算与状态落地 +2. 再以“本次动作 + 本次战斗结果”为上下文触发一次剧情推理 +3. 生成脱战后的 `storyText` 与非战斗态 options + +--- + +## 6. 前后端数据约束 + +### 6.1 Option 扩展字段 + +为了支持“单 functionId + 多实例技能/物品 option”,战斗 option 允许携带以下运行时字段: + +- `runtimePayload` + - `skillId?: string` + - `itemId?: string` +- `disabled?: boolean` +- `disabledReason?: string` + +### 6.2 前端职责 + +- 前端只负责展示 option、透传 `runtimePayload`、展示 disabled 态 +- 前端不再自己推导战斗中“是否需要剧情推理” +- 前端不再把技能 option 重写成抽象战术描述 + +### 6.3 后端职责 + +- 后端负责生成战斗 option 池 +- 后端负责解析 `skillId / itemId` +- 后端负责决定 battle ongoing / battle end / escape 后是否触发剧情推理 + +--- + +## 7. 本次落地范围 + +本期必须落地: + +1. 后端 runtime 战斗 option 池切换到单行为模型 +2. 后端 combat resolution 支持普通攻击 / 指定技能 / 恢复 / 战斗物品 / 逃跑 +3. 后端只在逃跑或战斗结束后做剧情推理 +4. 前端支持透传战斗 option 的 `runtimePayload` +5. 前端支持 disabled battle option 展示 +6. 文档、测试同步更新 + +补充落地备注(2026-04-20): + +- `inventory_use` 在战斗中按战斗动作结算,而不是按非战斗库存动作直接短路返回 +- 战斗态 `inventory_use` 使用后要消费物品、累计 `itemsUsed`、推进 1 回合基础冷却,再叠加物品自带的 `cooldownReduction` +- 若物品动作结算后战斗仍在继续,`storyText` 直接等于本次战斗结果文本,不触发 AI 续写 + +本期不做: + +1. 新增复杂目标选择 UI +2. 一次展开完整背包的战斗 item 子面板 +3. 重做整套战斗演出系统 +4. 把所有旧本地 battle plan 彻底删除到只剩后端一条链 + +--- + +## 8. 验收口径 + +满足以下条件视为本次需求完成: + +1. 战斗中不再出现 `battle_all_in_crush / battle_guard_break / battle_probe_pressure / battle_feint_step / battle_finisher_window` 作为默认候选项。 +2. 战斗默认候选项能看到: + - 普通攻击 + - 恢复 + - 使用物品 + - 每个技能一个独立技能项 + - 逃跑 +3. 点击普通攻击 / 恢复 / 使用物品 / 技能时,不请求新的剧情推理,直接返回结算结果并刷新下一轮战斗 options。 +4. 点击逃跑成功后,会请求一次剧情推理并切回脱战后的剧情 options。 +5. 任意攻击或技能把敌人打死后,会请求一次剧情推理并切回脱战后的剧情 options。 +6. 旧存档里残留旧 battle functionId 时,不会因为 function 不识别而报错。 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 45b66479..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. 后端接口设计 @@ -484,6 +576,29 @@ interface ListCustomWorldWorksResponse { - 当前用户作品量预计不大 - 先把结构做稳,比先做分页更重要 +### 公开浏览与登录边界 + +创作首页与世界选择页必须拆分两类数据: + +1. 公开浏览数据 +2. 当前用户私有数据 + +其中以下接口必须定义为公开只读: + +- `GET /api/runtime/custom-world-gallery` +- `GET /api/runtime/custom-world-gallery/:ownerUserId/:profileId` + +明确约束: + +1. 未登录用户进入世界选择页时,也必须能读取公开作品广场 +2. 公开作品广场读取不能依赖 access token,也不能因为 refresh 失败返回 401 +3. 已发布作品详情允许未登录用户查看 +4. 只有“继续创作 / 发布 / 下架 / 删除 / 查看我的草稿 / 查看我的统计”等私有能力必须要求登录 + +也就是说: + +**平台首页要支持“先浏览公开作品,再决定是否登录进入世界或开始创作”。** + ## 9.3 数据来源 ### 草稿来源 @@ -677,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_FEATURE_PRD_INDEX_2026-04-16.md b/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md index 31c947e1..146a247a 100644 --- a/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md +++ b/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md @@ -17,17 +17,20 @@ ## 1. 当前“我的”Tab 功能拆分 -当前页面可拆成以下 `9` 个独立功能: +说明: + +- 自 `2026-04-19` 起,“最近游玩 / 历史浏览”已从“我的”页迁出,改为平台一级主 Tab“存档”。 +- 对应母文档见 [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)。 + +当前“我的”页保留以下 `7` 个独立功能: 1. 账号资料与身份卡 2. 会员中心与充值 3. 我的数据看板 -4. 最近游玩 -5. 历史浏览 -6. 邀请好友 -7. 填邀请码 -8. 玩家社区 -9. 设置与账号安全 +4. 邀请好友 +5. 填邀请码 +6. 玩家社区 +7. 设置与账号安全 --- @@ -36,12 +39,11 @@ 1. [MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md) 2. [MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md) 3. [MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md) -4. [MY_TAB_RECENT_PLAY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_RECENT_PLAY_PRD_2026-04-16.md) -5. [MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md) -6. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md) -7. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md) -8. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md) -9. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md) +4. [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md) +5. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md) +6. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md) +7. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md) +8. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md) --- @@ -51,20 +53,19 @@ 1. 账号资料与身份卡 2. 设置与账号安全 -3. 最近游玩 -4. 历史浏览 -5. 我的数据看板 -6. 会员中心与充值 -7. 邀请好友 -8. 填邀请码 -9. 玩家社区 +3. 我的数据看板 +4. 平台存档 Tab +5. 会员中心与充值 +6. 邀请好友 +7. 填邀请码 +8. 玩家社区 原因: - `1 + 2` 复用现有账号系统最多,最容易先落地 -- `3 + 4 + 5` 直接增强“我的”页内容密度,短期收益高 -- `6 + 7` 涉及商业化和关系绑定,依赖结算与奖励台账 -- `8` 最适合放在平台内容层能力稳定后再做 +- `3 + 4` 直接增强账号资产与回流体验,短期收益高 +- `5 + 6` 涉及商业化和关系绑定,依赖结算与奖励台账 +- `7` 最适合放在平台内容层能力稳定后再做 --- 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..86735fca 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-20` ## 0. 目标 @@ -56,7 +56,12 @@ ## 3. 信息架构 -设置中心建议固定为五段: +设置中心首层固定为两段: + +1. 主题外观 +2. 账号信息 + +其中“账号信息”二级面板固定承载以下内容: 1. 账号概况 2. 当前安全状态 @@ -64,6 +69,17 @@ 4. 更换手机号 5. 账号操作记录 +交互层级要求补充为: + +1. 设置首页只展示“主题外观”“账号信息”两个分区入口与危险操作,不在首页内联展开具体详情 +2. 点击任一分区入口后,必须进入独立二级面板 +3. 安全状态、登录设备、操作记录不再作为首页独立入口,统一归入“账号信息”二级面板 +4. 更换手机号属于独立操作面板,不允许在账号信息面板内直接展开表单 +5. 设置首页头部只保留一套主标题,不允许在内容区再重复放置“设置首页”“选择要管理的内容”这类二次标题块 +6. 子面板导航动作必须单一明确;同一层面板内有“返回”时,不再同时展示“关闭” +7. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动 +8. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden` + 底部保留两个危险操作按钮: 1. 退出登录 @@ -80,10 +96,15 @@ - 登录方式 - 手机号脱敏值 - 微信绑定状态 -- 账号状态 这里只看信息,不做大编辑动作。 +标题约束: + +- 设置首页标题固定表达“设置”或“设置与账号安全” +- 设置首页标题区域不展示手机号,也不允许把手机号当作主标题替代昵称 +- 手机号只允许出现在账号概况信息项中,以脱敏值展示 + ## 4.2 当前安全状态 展示当前账号命中的风控保护: @@ -188,8 +209,14 @@ 1. 设置继续采用当前账号弹窗基础形态即可 2. 移动端优先底部弹层,桌面端可居中弹窗 -3. 更换手机号区域默认折叠 -4. 危险操作按钮与普通按钮必须明显区分 +3. 设置首页只保留“主题外观”“账号信息”两个入口,不再单独展示安全状态、登录设备、操作记录入口 +4. “账号信息”二级面板直接承载账号概况、安全状态、登录设备、操作记录四块内容,移动端优先纵向滚动,桌面端保持同一面板内稳定扫读 +5. 更换手机号必须通过独立操作面板完成,不再使用当前面板内联展开表单 +6. 危险操作按钮与普通按钮必须明显区分 +7. 设置首页标题处禁止展示手机号、脱敏手机号或手机号形态的 displayName +8. 设置首页不额外堆砌规则说明文案,标题下直接进入可操作内容 +9. 子面板采用覆盖式独立面板承载详情,返回上一级时恢复首页,不在同层同时出现双导航动作 +10. 面板切换必须保证键盘焦点始终停留在当前活跃面板内,返回上一级后焦点恢复到触发入口 --- diff --git a/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md b/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md new file mode 100644 index 00000000..e51924d9 --- /dev/null +++ b/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md @@ -0,0 +1,258 @@ +# 平台“存档”Tab PRD + +更新时间:`2026-04-20` + +## 0. 目标 + +把原本堆在“我的”页中的“最近游玩 / 历史浏览”移出,新增平台一级主 Tab“存档”,用于承载当前账号在平台里玩过的所有游戏留下的最近可恢复存档。 + +这次改动的核心目标不是做复杂多槽位存档系统,而是先落地一个稳定、可跨设备同步、可直接继续游玩的账号级存档入口。 + +--- + +## 1. 信息架构调整 + +## 1.1 平台主导航 + +平台主导航从: + +- 首页 +- 创作 +- 我的 + +调整为: + +- 首页 +- 创作 +- 存档 +- 我的 + +移动端底部导航与桌面端左侧纵向导航都必须同步调整。 + +## 1.2 “我的”页调整 + +“我的”页删除以下内容块: + +- 最近游玩 +- 历史浏览 + +“我的”页保留: + +- 账号资料与身份卡 +- 数据看板 +- 常用功能 +- 设置与账号安全 + +说明: + +- 历史浏览本期直接从“我的”页移除,不再占据个人页首屏空间。 +- 存档能力统一收口到平台一级“存档”Tab,不再同时在“我的”页重复展示。 + +--- + +## 2. 存档定义 + +## 2.1 本期存档口径 + +本期“存档”Tab 展示的是: + +- 当前账号在每个已游玩游戏 / 世界下保留的最近一个可恢复存档 + +不是: + +- 同一游戏下的完整多槽位存档管理页 +- 手动重命名 / 置顶 / 删除存档系统 + +## 2.2 世界唯一键 + +服务端必须按 `worldKey` 聚合最近存档: + +- 自定义世界:`custom:` +- 内建世界:`builtin:` + +同一账号、同一 `worldKey` 只保留最近一次成功保存的可恢复存档。 + +## 2.3 生命周期 + +1. 玩家每次成功写入运行时快照时,同步刷新该世界的最近存档记录。 +2. 删除当前活动快照时,不删除历史存档归档。 +3. 点击“继续游玩”时,从该世界最近存档恢复为当前活动快照,再进入游戏。 + +--- + +## 3. 界面设计 + +## 3.1 存档 Tab 首屏结构 + +页面首屏直接展示存档列表,不再单独保留顶部“最近存档”摘要卡。 + +列表容器本身需要承担首屏入口作用: + +- 用户进入“存档”Tab 后第一屏就看到可恢复存档列表 +- 不额外重复展示首个存档的大卡摘要 +- 存档数量、排序状态如需表达,应收敛在列表标题或轻量状态信息中 + +不要在 UI 中默认堆规则说明文案,只保留简洁的状态表达。 + +## 3.2 列表排序 + +列表按 `lastPlayedAt` 倒序。 + +最近更新的存档始终在最前面。 + +## 3.3 列表项字段 + +每个列表项必须展示: + +- 游戏名称 +- 最后游玩时间 +- 游戏信息 + +### 3.3.1 移动端卡片布局约束 + +- 移动端列表卡片中的封面只能作为独立缩略图或弱化背景层使用,不能直接占满整张卡片并压在正文信息下方。 +- 标题、时间、摘要所在的信息区必须保持 `min-width: 0` 的可收缩布局,长标题不能把正文挤出屏幕外。 +- 世界名称最多展示 2 行,游戏信息最多展示 3 行,超出后截断,不允许横向溢出。 +- 时间标签、状态标签在窄屏下必须允许换行或独立成行,不能为了保持单行导致卡片内容错位。 +- 列表卡片缩略图区域比例固定,文本区与缩略图区在移动端需要保持稳定对齐,避免出现上下参差和视觉歪斜。 + +其中“游戏信息”优先级如下: + +1. `continueGameDigest` +2. 当前故事文本摘要 +3. 世界简介 / 场景简介 +4. 若都没有则给出简洁兜底文案 + +可附带展示封面,但封面不是必填验收项。 + +## 3.4 点击行为 + +点击列表项后: + +1. 调用后端恢复接口 +2. 将所选存档切换为当前活动快照 +3. 直接进入游戏继续游玩 + +前端不允许自行拼装恢复上下文。 + +## 3.5 空状态 + +### 已登录但无存档 + +- 展示轻量空态 +- 引导去首页开始游玩 + +### 未登录 + +- 展示登录态空壳 +- 不请求受保护的云端存档列表接口 + +--- + +## 4. 默认进入逻辑 + +当满足以下条件时,玩家进入网站后的平台首页默认进入“存档”Tab: + +1. 当前处于登录状态 +2. 当前账号至少存在一个存档 + +否则: + +- 仍默认进入“首页”Tab + +注意: + +- 这个默认进入逻辑只在平台首屏初始化时执行,不能覆盖用户手动切换后的选择。 + +--- + +## 5. 后端设计 + +## 5.1 新增数据表 + +建议新增 `profile_save_archives`: + +- `user_id` +- `world_key` +- `owner_user_id` +- `profile_id` +- `world_type` +- `world_name` +- `world_subtitle` +- `summary_text` +- `cover_image_src` +- `saved_at` +- `bottom_tab` +- `game_state_json` +- `current_story_json` +- `updated_at` + +约束: + +- 主键:`user_id + world_key` +- 排序索引:`user_id + saved_at desc` + +## 5.2 写入规则 + +每次 `/api/runtime/save/snapshot` 成功写入后: + +1. 正常更新当前活动快照 +2. 同步 upsert 对应 `world_key` 的存档归档 +3. 继续保留原有个人看板 / 已玩作品同步逻辑 + +## 5.3 列表接口 + +### `GET /api/runtime/profile/save-archives` + +返回: + +- 当前账号全部最近存档 + +字段至少包含: + +- `worldKey` +- `worldName` +- `worldSubtitle` +- `summaryText` +- `coverImageSrc` +- `lastPlayedAt` +- `worldType` +- `profileId` +- `ownerUserId` + +## 5.4 恢复接口 + +### `POST /api/runtime/profile/save-archives/:worldKey/resume` + +用途: + +- 将指定存档归档恢复为当前活动快照 +- 返回恢复后的快照 + +限制: + +- 恢复动作不能重复记账,不得再次累计个人资产流水 +- 恢复动作不能重复累计已玩时长 +- 恢复动作不能破坏现有快照水合逻辑 + +--- + +## 6. 前端实现要求 + +1. `PlatformHomeView` 新增 `存档` 主 Tab。 +2. `PreGameSelectionFlow` 在平台数据加载时同时拉取存档列表。 +3. 已登录且有存档时,平台首屏默认选中 `存档` Tab。 +4. “我的”页删除“最近游玩 / 历史浏览”两个区块。 +5. 点击存档列表项时必须经过后端恢复接口,恢复成功后直接进入游戏。 +6. 移动端优先,列表项点击区域不能过小。 + +--- + +## 7. 验收标准 + +1. 已登录账号可以在“存档”Tab 看到所有已玩过世界的最近存档。 +2. 列表按最近更新时间倒序。 +3. 列表项可看到游戏名称、最后游玩时间和游戏信息。 +4. 点击列表项后可直接继续对应游戏。 +5. 已登录且至少有一个存档时,进入网站默认打开“存档”Tab。 +6. 未登录时不请求云端存档列表,也不会出现受保护接口报错。 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 a21f9403..35669b1d 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 @@ -265,6 +265,23 @@ 但对“第一版角色动作资产生产”来说,它更适合作为增强通道,不建议先做成唯一主依赖。 +实现更新(`2026-04-19`): + +- 当前仓库的 `image-to-video` 角色动作生成入口已切到火山方舟 `Seedance` +- 采用双参考图首尾帧方案:图片 1 约束首帧,图片 2 约束尾帧 +- 当前请求体中的两张参考图角色分别固定为 `first_frame / last_frame` +- 当前固定参数为 `1:1`、`480p`、`4 秒`、单次 `1` 个视频 +- 当前固定动作入口收敛为 `idle / run / attack / die`,不再内置固定 `hurt` +- 提示词里传给视频模型的动作名统一使用英文动作名 + +实现更新(`2026-04-20`): + +- `run / attack` 是当前固定动作入口里的基础必生成动作 +- `idle / die` 改为可选增强动作,不再作为资产完成度硬门槛 +- `idle` 缺失时运行时默认使用主图静止 +- `die` 缺失时运行时默认播放一段基于主图的倒地过渡动画,并最终停在翻转倒地姿态 +- 技能动作不走固定按钮,但对当前角色 `skills` 中的每个技能都属于必生成动作 + ## 5.3 补充路线:腾讯云相关能力 腾讯云相关接口里,`提交图片跳舞任务` 提供了: @@ -422,12 +439,17 @@ - `idle` - `run` - `attack` -- `jump` -- `hurt` - `die` 系统自动选择对应参考视频模板。 +其中: + +- `run / attack` 属于固定必生成动作 +- `idle / die` 属于固定可选动作,未生成时走默认兜底 + +`jump`、`hurt` 这类扩展动作不再作为当前编辑器固定按钮,改为后续扩展动作槽位或手动补齐。 + ### B. 视频驱动 用户上传参考动作视频,系统抽姿态后再生成角色动作。 @@ -498,6 +520,8 @@ flowchart LR - 文生图时,优先生成与当前项目角色素材视角一致的单人全身图 - 有参考图时,优先做“角色指定 + 风格收敛 + 视角纠偏” - 用户直接上传素材时,先做校验、裁切、背景清理和尺寸标准化 +- 编辑器未上传参考图时,主形象阶段默认附加一张由项目内可扮演角色 idle 帧拼成的风格参考板,用来锁定像素动作角色的轮廓语言、右朝向、体型比例与配色组织,避免模型只放大 Q 版比例却丢掉像素感 +- 风格约束优先级里,“像素动作角色感”高于“Q 版比例提示”;比例只允许轻度偏大头,不允许退化成普通软萌插画或儿童绘本风 ### 角色视角要求 @@ -623,6 +647,16 @@ flowchart LR 8. 生成 Sprite Sheet 9. 输出动画元数据 +### 当前工程的抠像补充策略 + +针对角色动作视频抽帧后常见的“后段帧出现白底”“角色轮廓残留绿幕像素点”问题,当前工程内的背景清理不再只依赖单一绿幕阈值,而是统一改为以下顺序: + +1. 先识别边界连通的可移除背景区域,同时覆盖纯绿色绿幕和高亮低色差白底。 +2. 再向主体边缘的半透明软边做一轮有限扩张,把压缩后残留的白边、绿边纳入透明化处理。 +3. 最后对贴近透明边缘的像素做去污,优先压掉绿色溢色,并把白边/绿边颜色拉回附近前景主体颜色,减少抽帧后的轮廓发白、发绿。 + +这样可以避免把角色内部的白色高光、白色装备整体误删,同时能更稳定地清理视频模型在末段帧里偶发的白背景和压缩噪点。 + ### 像素化策略 推荐做法: @@ -703,7 +737,14 @@ export interface GeneratedCharacterAnimationAsset { id: string; characterId: string; visualAssetId: string; - action: BaseAnimationSlot | 'cast' | 'talk' | 'skill1' | 'skill2' | 'skill3' | 'skill4'; + action: + | BaseAnimationSlot + | 'cast' + | 'talk' + | 'skill1' + | 'skill2' + | 'skill3' + | 'skill4'; sourceProvider: 'aliyun-wan' | 'volc-seedance' | 'tencent' | 'local'; sourceMode: 'template' | 'video-drive' | 'audio-drive'; frameCount: number; @@ -826,7 +867,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 本地插件链路。 建议新增: @@ -912,63 +954,54 @@ draft ### 12.1 基础动作槽位必须非空 -第一版要求以下基础动作槽位全部有内容: +第一版要求以下动作能力按“必生成 / 可选兜底”拆开: -| 动作槽位 | 是否必填 | 建议来源 | -| --- | --- | --- | -| `idle` | 必填 | 模板生成 | -| `acquire` | 必填 | 可由短持物 / 抬手动作生成 | -| `attack` | 必填 | 模板生成 | -| `run` | 必填 | 模板生成 | -| `jump` | 必填 | 模板生成 | -| `double_jump` | 必填 | 可由跳跃二次变体生成 | -| `jump_attack` | 必填 | 跳跃攻击模板 | -| `dash` | 必填 | 冲刺模板 | -| `hurt` | 必填 | 受击模板 | -| `die` | 必填 | 倒地 / 消散模板 | -| `climb` | 必填 | 攀爬模板 | -| `wall_slide` | 必填 | 可由攀爬或停滞帧变体生成 | +当前编辑器固定生成入口补充说明(`2026-04-19`): -这里“不能为空”指的是: +- 固定按钮只保留 `idle / run / attack / die` +- `hurt` 不再作为固定生成按钮 +- 如果运行时仍需 `hurt` 资源,应通过后续扩展动作槽位或手动补齐 -- 每个基础动作槽位必须能挂到一套可播放资产 -- 不允许在运行时出现 `null` 或空映射 -- 个别低优先动作允许由近似动作衍生,但槽位本身必须有有效资源 +| 动作能力 | 是否必填 | 建议来源 | +| ------------------------------- | -------- | ---------------------------------------------------------- | +| `run` | 必填 | 模板生成 | +| `attack` | 必填 | 模板生成 | +| `skills[*].actionPreviewConfig` | 必填 | 技能编辑面板逐个生成 | +| `idle` | 可选 | 模板生成;缺失时默认主图静止 | +| `die` | 可选 | 模板生成;缺失时默认主图倒地过渡动画,最终停在翻转倒地姿态 | -### 12.2 技能动作不是第一版强制项 +这里“必填”指的是: -第一版可选: +- `run / attack` 必须能挂到一套可播放资产 +- 角色当前每个技能都必须有可播放的 `actionPreviewConfig` +- `idle / die` 不再进入“缺失即阻塞发布”的判断 +- 运行时表现仍然不能空白;`idle / die` 的缺口由默认兜底承接 -- `skill1` -- `skill1_jump` -- `skill1_bullet` -- `skill1_bullet_fx` -- `skill2` -- `skill2_jump` -- `skill3` -- `skill3_jump` -- `skill3_bullet` -- `skill3_bullet_fx` -- `skill4` +### 12.2 技能动作改为“按角色已配技能强制” + +第一版不再要求预留整套固定技能枚举,但要求: + +- 当前角色 `skills` 数组里的每个技能都要补出 `actionPreviewConfig` +- 技能动作继续在技能编辑面板逐个生成,不并入固定四按钮 策略建议: -- 基础动作先全量补齐 -- 技能动作后续按角色职业差异再补 +- 先补 `run / attack` +- 再逐个补当前角色已有技能动作 +- `idle / die` 作为可选增强按需要补 - 投射物与特效优先继续复用当前项目已有素材与技能特效系统 ### 12.3 第一阶段优先模板 先优先做这些高价值模板: -| 模板 | 推荐时长 | 是否循环 | 说明 | -| --- | --- | --- | --- | -| `idle` | 2s-4s | 是 | 微动作、呼吸 | -| `run` | 2s-3s | 是 | 固定侧向 | -| `attack` | 2s-4s | 否 | 近战基础攻击 | -| `jump` | 1s-2s | 否 | 起跳与空中姿态 | -| `hurt` | 1s-2s | 否 | 受击短动作 | -| `die` | 2s-4s | 否 | 倒下或消散 | +| 模板 | 推荐时长 | 是否循环 | 说明 | +| -------- | -------- | -------- | -------------- | +| `idle` | 2s-4s | 是 | 微动作、呼吸 | +| `run` | 2s-3s | 是 | 固定侧向 | +| `attack` | 2s-4s | 否 | 近战基础攻击 | +| `jump` | 1s-2s | 否 | 起跳与空中姿态 | +| `die` | 2s-4s | 否 | 倒下或消散 | ### 12.4 不建议第一阶段就重投入的动作 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 new file mode 100644 index 00000000..ec9ced16 --- /dev/null +++ b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md @@ -0,0 +1,190 @@ +# Prompt 目录收口方案(2026-04-19) + +## 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/**` + +问题主要有三类: + +1. 业务逻辑和 prompt 文本混写,改提示词时容易顺手改坏运行时逻辑。 +2. 同一类 prompt 缺少集中入口,排查系统 prompt / user prompt / repair prompt 成本高。 +3. 老桥接层、测试和新业务链路同时依赖时,迁移成本高,容易出现导出断裂。 + +这次收口目标不是“重写全部 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 +``` + +当前职责划分: + +- `chatPromptBuilders.ts` + - 角色私聊 / NPC 聊天 / 招募对话 prompt +- `storyPromptBuilders.ts` + - 主剧情 system prompt 与 user prompt builder +- `storyOrchestratorPrompts.ts` + - 剧情语言修复 prompt +- `questPrompts.ts` + - 任务意图 system prompt 与 user prompt builder +- `runtimeItemPrompts.ts` + - 运行时物品意图 system prompt 与 user prompt 文本装配 +- `customWorldOrchestratorPrompts.ts` + - 自定义世界主编排 JSON 生成与 repair prompt +- `customWorldAgentPrompts.ts` + - 世界草稿 JSON prompt、补角色 / 补地点 prompt +- `customWorldEntityPrompts.ts` + - 世界编辑器角色 / 场景实体生成 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. 落地规则 + +### 3.1 业务模块只做两件事 + +1. 整理运行时上下文 +2. 调用 `server-node/src/prompts/**` 下的脚本输出 prompt + +不要在业务模块里继续直接内联大段 system prompt / repair prompt / user prompt 模板文本。 + +### 3.2 Prompt 文件只放文本相关职责 + +允许放: + +- system prompt 常量 +- user prompt builder +- repair prompt builder +- prompt 专用的文本摘要函数 + +不建议放: + +- 运行时状态 mutation +- 仓储读写 +- HTTP 处理 +- 与 prompt 无关的领域推导 + +### 3.3 兼容层保留旧导出 + +本轮对已有纯 prompt builder 文件采取了兼容迁移,旧路径保留为薄 re-export: + +- `server-node/src/modules/ai/chatPromptBuilders.ts` +- `server-node/src/modules/ai/storyPromptBuilders.ts` +- `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 文件,保证兼容性。 + +## 4. 后续新增 prompt 的写法 + +新增提示词时按下面顺序处理: + +1. 先判断属于后端、前端/编辑器还是共享工具层。 +2. 后端正式业务优先补到 `server-node/src/prompts/*.ts`。 +3. 前端/编辑器 prompt 优先补到 `src/prompts/*.ts`。 +4. 可复用的共享资产 prompt 优先补到 `packages/shared/src/prompts/*.ts`。 +5. 业务模块只传入已经整理好的上下文字段,不在模块内部继续拼长文本。 +6. 至少补一条该 prompt 的调用链测试或现有测试断言。 + +建议命名: + +- system prompt:`XXX_SYSTEM_PROMPT` +- repair prompt:`buildXXXRepairPrompt` +- user prompt:`buildXXXPrompt` +- 纯文本装配:`buildXXXPromptText` + +## 5. 本轮范围与当前状态 + +本轮已经收口: + +- Story +- Chat +- Quest +- Runtime Item +- Custom World 主编排 +- Custom World Agent 草稿增补 +- Custom World 编辑器角色 / 场景 / 场景 NPC 生成 +- Character Asset +- Eight Anchor +- Scene Image +- 前端剧情 / 私聊 / 任务 / 物品 prompt 兼容层 +- 编辑器与工具链 prompt 种子 + +当前状态: + +- 正式业务 prompt 主源已经集中到 prompt 目录。 +- 旧 `services/`、`tools/`、`components/` 下保留的相关文件主要是兼容层或调用方。 +- 当前没有再发现需要优先继续抽离的大块业务 prompt 正文。 + +## 6. 验证方式 + +本轮调整后建议至少执行: + +- `npm run check:encoding` +- `npm run server-node:test` +- `npm --prefix server-node run build` + +本轮实测结果: + +- `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 ac1fed43..dafab7a9 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,8 @@ ## 文档列表 +- [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/` 的目录方案、兼容策略与后续新增规则。 - [SPACETIME_DEV_URI_HOTFIX_2026-04-20.md](./SPACETIME_DEV_URI_HOTFIX_2026-04-20.md):修复开发默认配置把 Spacetime 连接误指向 Vite `3000` 端口的问题。 - [SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md](./SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md):本地 token 失效时自动降级匿名连接,并提示“登录已过期”的热修记录。 - [NODE_DEV_STARTUP_HOTFIX_2026-04-20.md](./NODE_DEV_STARTUP_HOTFIX_2026-04-20.md):`npm run dev` 启动失败的热修记录、根因与验证结果。 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/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md new file mode 100644 index 00000000..f2ef4c67 --- /dev/null +++ b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md @@ -0,0 +1,97 @@ +# 场景多幕创作与流程改造实施进度 2026-04-20 + +更新时间:`2026-04-20` + +## 1. 本轮落地范围 + +本轮先完成 `scene_chapter` 的第一批基础链路,让“场景章节 -> 多幕 -> 主角色 -> 幕背景/相遇 NPC”真正进入现有创作工具和草稿系统。 + +本轮目标不是一次性做完 PRD 全量能力,而是先把下面这条主干打通: + +1. 草稿层可以承载 `scene chapter / scene act` +2. 草稿编译器可以把 `scene_chapter` 编译成正式卡片 +3. 创作页可以看到、打开、编辑 `scene_chapter` +4. 编辑后的幕信息可以正确写回草稿 +5. 运行时共享层先具备读取幕背景、主角色、相遇 NPC 池的基础能力 + +## 2. 本轮已落地 + +## 2.1 草稿与运行时结构 + +已补齐多幕相关结构: + +1. `CustomWorldFoundationDraftProfile.sceneChapters` +2. `CustomWorldFoundationDraftSceneChapter` +3. `CustomWorldFoundationDraftSceneAct` +4. `CustomWorldProfile.sceneChapterBlueprints` +5. `StoryEngineMemoryState.currentSceneActState` + +同时补齐了地点/营地草稿里的 `imageSrc`,避免幕背景回落时丢失现有场景图资产引用。 + +## 2.2 scene_chapter 草稿编译 + +`server-node/src/services/customWorldAgentDraftCompiler.ts` 已完成第一批接入: + +1. `scene_chapter` 正式进入草稿编译结果 +2. 支持从显式 `sceneChapters` 或地点/章节数据回退生成场景章节卡 +3. 每张卡会编译出场景摘要、幕结构总览、每幕背景图、主角色、辅助 NPC、幕目标、过渡钩子 +4. 每幕生成动态可编辑 section id +5. 已增加基础警告: + - 幕数不足 + - 缺背景图 + - 缺相遇 NPC + - 主角色不在第一位 + - 缺线程挂钩 + - NPC 或线程引用失配 + +## 2.3 scene_chapter 草稿编辑 + +`server-node/src/services/customWorldAgentDraftEditService.ts` 已支持: + +1. 编辑场景章节标题、摘要 +2. 编辑每幕标题、摘要、背景图链接、相遇 NPC、幕目标、过渡钩子 +3. `encounterNpcIds` 支持用角色 id 或角色名回写 +4. 回写后自动用第一位 NPC 覆盖 `primaryNpcId` + +`server-node/src/services/customWorldAgentChangeSummaryService.ts` 也已支持解析 `scene_chapter` 标题。 + +## 2.4 创作页展示 + +前端已完成第一批接入: + +1. 草稿抽屉正式加入 `scene_chapter` 分组 +2. `scene_chapter` 分组顺序位于 `chapter` 后、`thread` 前 +3. 详情面板已支持 `场景章节` 类型标签 +4. 幕背景 section 在详情面板里会直接渲染图片预览 +5. 编辑面板已支持幕摘要 / 相遇 NPC / 幕目标 / 过渡钩子等动态多行字段 + +## 2.5 运行时基础层 + +本轮同步补齐了幕运行的基础读取能力,便于下一轮继续接游戏流程: + +1. 当前幕背景图优先覆盖场景默认背景 +2. 当前幕相遇 NPC 池可参与场景相遇过滤 +3. 当前幕主角色与负好感有限聊天的判定 helper 已建立 +4. 场景预览层已能识别“负好感主角色不直接自动开战”的基础分支 + +## 3. 当前仍未完成 + +下面这些仍属于 PRD 未完项,需要下一轮继续: + +1. 创作页里的“新增幕 / 删除幕 / 调整幕顺序”交互 +2. 背景图配置与 NPC 配置的独立面板化交互 +3. 发布期 `qualityFindings` / blocker 的正式接入 +4. `SceneActRuntimeState` 的完整推进与持久化 +5. 当前幕主角色负好感 `5` 轮聊天限制的前后端完整闭环 +6. 第 `5` 轮“铺垫式收束”提示与强制退出聊天态 +7. 幕切换后的系统提示与 Adventure 面板状态展示 + +## 4. 下一轮建议顺序 + +建议下一轮按下面顺序继续: + +1. 先补 `SceneActRuntimeState` 初始化与幕推进 +2. 再接 `npcEncounterActions / aiService / chatOrchestrator` 的负好感有限聊天闭环 +3. 最后补创作页的幕增删改序和独立配置面板 + +这样可以先把“能跑”补齐,再把“编辑体验”补完整。 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/chromaKey.ts b/packages/shared/src/assets/chromaKey.ts new file mode 100644 index 00000000..7b517cc6 --- /dev/null +++ b/packages/shared/src/assets/chromaKey.ts @@ -0,0 +1,478 @@ +export type MutableRgbaBuffer = Uint8Array | Uint8ClampedArray; + +const SOFT_EDGE_ALPHA_THRESHOLD = 224; +const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD = 96; + +function clamp01(value: number) { + return Math.max(0, Math.min(1, value)); +} + +function lerp(from: number, to: number, t: number) { + return from + (to - from) * clamp01(t); +} + +function computeGreenBackgroundScore( + red: number, + green: number, + blue: number, + alpha: number, +) { + if (alpha === 0) { + return 1; + } + + const greenLead = green - Math.max(red, blue); + if (green < 52 || greenLead <= 8) { + return 0; + } + + const greenRatio = green / Math.max(1, red + blue); + if (greenRatio <= 0.52) { + return 0; + } + + return clamp01( + ((green - 52) / 168) * 0.22 + + ((greenLead - 8) / 96) * 0.53 + + ((greenRatio - 0.52) / 0.82) * 0.25, + ); +} + +function computeWhiteBackgroundScore( + red: number, + green: number, + blue: number, + alpha: number, +) { + if (alpha === 0) { + return 1; + } + + const maxChannel = Math.max(red, green, blue); + const minChannel = Math.min(red, green, blue); + const average = (red + green + blue) / 3; + if (average < 188 || minChannel < 168) { + return 0; + } + + const spread = maxChannel - minChannel; + const neutrality = 1 - clamp01((spread - 6) / 34); + const brightness = clamp01((average - 188) / 55); + const floor = clamp01((minChannel - 168) / 60); + return clamp01(neutrality * (brightness * 0.85 + floor * 0.15)); +} + +function collectForegroundNeighborColor( + pixels: MutableRgbaBuffer, + width: number, + height: number, + x: number, + y: number, + backgroundMask: Uint8Array, + backgroundHints: Float32Array, +) { + let totalWeight = 0; + let totalRed = 0; + let totalGreen = 0; + let totalBlue = 0; + + for (let offsetY = -2; offsetY <= 2; offsetY += 1) { + for (let offsetX = -2; offsetX <= 2; offsetX += 1) { + if (offsetX === 0 && offsetY === 0) { + continue; + } + + const nextX = x + offsetX; + const nextY = y + offsetY; + if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) { + continue; + } + + const nextPixelIndex = nextY * width + nextX; + if (backgroundMask[nextPixelIndex]) { + continue; + } + + if ((backgroundHints[nextPixelIndex] ?? 0) >= 0.18) { + continue; + } + + const nextOffset = nextPixelIndex * 4; + const nextAlpha = pixels[nextOffset + 3] ?? 0; + if (nextAlpha < FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) { + continue; + } + + const distance = Math.abs(offsetX) + Math.abs(offsetY); + const weight = + (nextAlpha / 255) * + (distance <= 1 ? 1.8 : distance === 2 ? 1.2 : 0.7); + + totalWeight += weight; + totalRed += (pixels[nextOffset] ?? 0) * weight; + totalGreen += (pixels[nextOffset + 1] ?? 0) * weight; + totalBlue += (pixels[nextOffset + 2] ?? 0) * weight; + } + } + + if (totalWeight <= 0) { + return null; + } + + return { + red: Math.round(totalRed / totalWeight), + green: Math.round(totalGreen / totalWeight), + blue: Math.round(totalBlue / totalWeight), + }; +} + +export function removeBackgroundFromRgba( + pixels: MutableRgbaBuffer, + width: number, + height: number, +) { + const pixelCount = width * height; + if (pixelCount <= 0) { + return false; + } + + const backgroundMask = new Uint8Array(pixelCount); + const greenScores = new Float32Array(pixelCount); + const whiteScores = new Float32Array(pixelCount); + const backgroundHints = new Float32Array(pixelCount); + const queue: number[] = []; + let queueIndex = 0; + let changed = false; + + for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) { + const offset = pixelIndex * 4; + const red = pixels[offset] ?? 0; + const green = pixels[offset + 1] ?? 0; + const blue = pixels[offset + 2] ?? 0; + const alpha = pixels[offset + 3] ?? 0; + const greenScore = computeGreenBackgroundScore(red, green, blue, alpha); + const whiteScore = computeWhiteBackgroundScore(red, green, blue, alpha); + const transparencyHint = clamp01((56 - alpha) / 56) * 0.75; + + greenScores[pixelIndex] = greenScore; + whiteScores[pixelIndex] = whiteScore; + backgroundHints[pixelIndex] = Math.max( + greenScore, + whiteScore, + transparencyHint, + ); + } + + const trySeedBackground = (pixelIndex: number) => { + if (backgroundMask[pixelIndex]) { + return; + } + + const offset = pixelIndex * 4; + const alpha = pixels[offset + 3] ?? 0; + const strongCandidate = + alpha < 40 || + (greenScores[pixelIndex] ?? 0) > 0.12 || + (whiteScores[pixelIndex] ?? 0) > 0.32; + + if (!strongCandidate) { + return; + } + + backgroundMask[pixelIndex] = 1; + queue.push(pixelIndex); + }; + + for (let x = 0; x < width; x += 1) { + trySeedBackground(x); + trySeedBackground((height - 1) * width + x); + } + + for (let y = 1; y < height - 1; y += 1) { + trySeedBackground(y * width); + trySeedBackground(y * width + width - 1); + } + + while (queueIndex < queue.length) { + const pixelIndex = queue[queueIndex]!; + queueIndex += 1; + + const x = pixelIndex % width; + const y = Math.floor(pixelIndex / width); + const neighborIndexes = [ + x > 0 ? pixelIndex - 1 : -1, + x + 1 < width ? pixelIndex + 1 : -1, + y > 0 ? pixelIndex - width : -1, + y + 1 < height ? pixelIndex + width : -1, + ]; + + for (const nextPixelIndex of neighborIndexes) { + if (nextPixelIndex < 0 || backgroundMask[nextPixelIndex]) { + continue; + } + + const nextOffset = nextPixelIndex * 4; + const nextAlpha = pixels[nextOffset + 3] ?? 0; + const nextGreenScore = greenScores[nextPixelIndex] ?? 0; + const nextWhiteScore = whiteScores[nextPixelIndex] ?? 0; + const nextHint = backgroundHints[nextPixelIndex] ?? 0; + const reachableSoftEdge = + nextHint > 0.08 && + nextAlpha < SOFT_EDGE_ALPHA_THRESHOLD && + (nextGreenScore > 0.04 || nextWhiteScore > 0.08 || nextAlpha < 180); + + if ( + nextAlpha < 40 || + nextGreenScore > 0.12 || + nextWhiteScore > 0.32 || + reachableSoftEdge + ) { + backgroundMask[nextPixelIndex] = 1; + queue.push(nextPixelIndex); + } + } + } + + for (let iteration = 0; iteration < 2; iteration += 1) { + const expandedMask = new Uint8Array(backgroundMask); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const pixelIndex = y * width + x; + if (expandedMask[pixelIndex]) { + continue; + } + + const alpha = pixels[pixelIndex * 4 + 3] ?? 0; + const hint = backgroundHints[pixelIndex] ?? 0; + if (alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06) { + continue; + } + + let adjacentBackgroundCount = 0; + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + if (offsetX === 0 && offsetY === 0) { + continue; + } + + const nextX = x + offsetX; + const nextY = y + offsetY; + if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) { + continue; + } + + if (backgroundMask[nextY * width + nextX]) { + adjacentBackgroundCount += 1; + } + } + } + + if ( + adjacentBackgroundCount >= 2 || + (adjacentBackgroundCount >= 1 && hint > 0.18) + ) { + expandedMask[pixelIndex] = 1; + } + } + } + + backgroundMask.set(expandedMask); + } + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const pixelIndex = y * width + x; + if (!backgroundMask[pixelIndex]) { + continue; + } + + const offset = pixelIndex * 4; + const alpha = pixels[offset + 3] ?? 0; + if (alpha === 0) { + continue; + } + + const matteScore = Math.max( + backgroundHints[pixelIndex] ?? 0, + greenScores[pixelIndex] ?? 0, + whiteScores[pixelIndex] ?? 0, + ); + + let foregroundSupport = 0; + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + if (offsetX === 0 && offsetY === 0) { + continue; + } + + const nextX = x + offsetX; + const nextY = y + offsetY; + if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) { + continue; + } + + const nextPixelIndex = nextY * width + nextX; + if (backgroundMask[nextPixelIndex]) { + continue; + } + + const nextAlpha = pixels[nextPixelIndex * 4 + 3] ?? 0; + if (nextAlpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) { + foregroundSupport += 1; + } + } + } + + let nextAlpha = alpha; + if (matteScore > 0.9 || foregroundSupport === 0) { + nextAlpha = 0; + } else if (matteScore > 0.72 && foregroundSupport <= 1) { + nextAlpha = Math.min(alpha, Math.round(alpha * 0.08)); + } else { + nextAlpha = Math.min( + alpha, + Math.round(alpha * Math.max(0.08, 1 - matteScore * 0.95)), + ); + } + + if (foregroundSupport >= 3 && matteScore < 0.55) { + nextAlpha = Math.max(nextAlpha, Math.round(alpha * 0.22)); + } + + if (nextAlpha < 10) { + nextAlpha = 0; + } + + if (nextAlpha !== alpha) { + pixels[offset + 3] = nextAlpha; + changed = true; + } + } + } + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const pixelIndex = y * width + x; + const offset = pixelIndex * 4; + const alpha = pixels[offset + 3] ?? 0; + if (alpha === 0) { + continue; + } + + let touchesTransparentEdge = false; + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + if (offsetX === 0 && offsetY === 0) { + continue; + } + + const nextX = x + offsetX; + const nextY = y + offsetY; + if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) { + touchesTransparentEdge = true; + continue; + } + + const nextPixelIndex = nextY * width + nextX; + if ( + backgroundMask[nextPixelIndex] || + (pixels[nextPixelIndex * 4 + 3] ?? 0) < 16 + ) { + touchesTransparentEdge = true; + } + } + } + + if (!touchesTransparentEdge) { + continue; + } + + const greenScore = greenScores[pixelIndex] ?? 0; + const whiteScore = whiteScores[pixelIndex] ?? 0; + const contamination = Math.max( + greenScore, + whiteScore, + backgroundMask[pixelIndex] ? 0.35 : 0, + alpha < 220 ? ((220 - alpha) / 220) * 0.25 : 0, + ); + + if (contamination < 0.06) { + continue; + } + + let red = pixels[offset] ?? 0; + let green = pixels[offset + 1] ?? 0; + let blue = pixels[offset + 2] ?? 0; + const sample = collectForegroundNeighborColor( + pixels, + width, + height, + x, + y, + backgroundMask, + backgroundHints, + ); + const blend = clamp01( + Math.max(contamination * 0.82, touchesTransparentEdge ? 0.22 : 0), + ); + + if (sample) { + red = Math.round(lerp(red, sample.red, blend)); + green = Math.round(lerp(green, sample.green, blend)); + blue = Math.round(lerp(blue, sample.blue, blend)); + + if (greenScore > 0.04) { + green = Math.min(green, sample.green + 18); + } + + if (whiteScore > 0.1) { + red = Math.min(red, sample.red + 26); + green = Math.min(green, sample.green + 26); + blue = Math.min(blue, sample.blue + 26); + } + } else { + if (greenScore > 0.04) { + green = Math.max( + Math.max(red, blue), + Math.round(green - (green - Math.max(red, blue)) * 0.78), + ); + } + + if (whiteScore > 0.12) { + const spread = Math.max(red, green, blue) - Math.min(red, green, blue); + if (spread < 20) { + const tonedValue = Math.round(((red + green + blue) / 3) * 0.88); + red = Math.min(red, tonedValue); + green = Math.min(green, tonedValue); + blue = Math.min(blue, tonedValue); + } + } + } + + let nextAlpha = alpha; + const edgeFade = Math.max(greenScore * 0.35, whiteScore * 0.28); + if (edgeFade > 0.08) { + nextAlpha = Math.min(alpha, Math.round(alpha * (1 - edgeFade))); + if (nextAlpha < 10) { + nextAlpha = 0; + } + } + + if ( + red !== (pixels[offset] ?? 0) || + green !== (pixels[offset + 1] ?? 0) || + blue !== (pixels[offset + 2] ?? 0) || + nextAlpha !== alpha + ) { + pixels[offset] = red; + pixels[offset + 1] = green; + pixels[offset + 2] = blue; + pixels[offset + 3] = nextAlpha; + changed = true; + } + } + } + + return changed; +} diff --git a/packages/shared/src/assets/qwenSprite.ts b/packages/shared/src/assets/qwenSprite.ts index 24ee57da..67957218 100644 --- a/packages/shared/src/assets/qwenSprite.ts +++ b/packages/shared/src/assets/qwenSprite.ts @@ -1,147 +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 CHIBI_STYLE_TEXT = - 'Q版大头身动作角色,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑,接近经典横版像素动作角色的身体比例,不要写实长身比例。'; -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 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, - `风格要求:Q版大头身动作角色,清爽可爱,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑。深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。`, - '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。', - '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', - '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。', - characterBrief.trim(), - ] - .filter(Boolean) - .join('\n'); -} - -export function buildVideoActionPrompt(options: { - actionTemplate: QwenSpriteActionTemplate; - actionDetailText: string; - useChromaKey: boolean; - characterBrief: string; -}) { - return [ - `单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`, - `角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`, - `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, - `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, - `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, - `风格要求:${CHIBI_STYLE_TEXT} ${PIXEL_STYLE_TEXT} 清爽可爱,高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, - `动作结构:${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..9bb63bd9 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; @@ -195,6 +197,11 @@ export interface CustomWorldFoundationDraftCharacter { relationToPlayer: string; threadIds: string[]; summary: string; + skills?: Array<{ + id: string; + name: string; + actionPreviewConfig?: Record | null; + }>; imageSrc?: string | null; generatedVisualAssetId?: string | null; generatedAnimationSetId?: string | null; @@ -210,6 +217,7 @@ export interface CustomWorldFoundationDraftLandmark { importance: string; secret?: string; dangerLevel?: string; + imageSrc?: string | null; characterIds: string[]; threadIds: string[]; summary: string; @@ -244,9 +252,48 @@ export interface CustomWorldFoundationDraftCamp { description: string; mood: string; dangerLevel?: string; + imageSrc?: string | null; summary: string; } +export type CustomWorldSceneActStage = + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath'; + +export type CustomWorldSceneActAdvanceRule = + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + +export interface CustomWorldFoundationDraftSceneAct { + id: string; + title: string; + summary: string; + stageCoverage: CustomWorldSceneActStage[]; + backgroundImageSrc?: string | null; + backgroundAssetId?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + linkedThreadIds: string[]; + actGoal: string; + transitionHook: string; + advanceRule: CustomWorldSceneActAdvanceRule; +} + +export interface CustomWorldFoundationDraftSceneChapter { + id: string; + sceneId: string; + sceneName: string; + title: string; + summary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + acts: CustomWorldFoundationDraftSceneAct[]; +} + export interface CustomWorldFoundationDraftProfile { name: string; subtitle: string; @@ -264,6 +311,7 @@ export interface CustomWorldFoundationDraftProfile { factions: CustomWorldFoundationDraftFaction[]; threads: CustomWorldFoundationDraftThread[]; chapters: CustomWorldFoundationDraftChapter[]; + sceneChapters: CustomWorldFoundationDraftSceneChapter[]; worldHook: string; playerPremise: string; openingSituation: string; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index ae0c9e01..abf69d00 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -2,6 +2,9 @@ import type { JsonObject } from './common'; export const SAVE_SNAPSHOT_VERSION = 2; export const DEFAULT_MUSIC_VOLUME = 0.42; +export const DEFAULT_PLATFORM_THEME = 'light'; +export const PLATFORM_THEMES = ['light', 'dark'] as const; +export type PlatformTheme = (typeof PLATFORM_THEMES)[number]; export type SavedGameSnapshot< TGameState = unknown, @@ -28,6 +31,7 @@ export type SavedGameSnapshotInput< export type RuntimeSettings = { musicVolume: number; + platformTheme: PlatformTheme; }; export type BasicOkResult = { @@ -73,6 +77,31 @@ export type ProfilePlayStatsResponse = { updatedAt: string | null; }; +export type ProfileSaveArchiveSummary = { + worldKey: string; + ownerUserId: string | null; + profileId: string | null; + worldType: string | null; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + lastPlayedAt: string; +}; + +export type ProfileSaveArchiveListResponse = { + entries: ProfileSaveArchiveSummary[]; +}; + +export type ProfileSaveArchiveResumeResponse< + TGameState = unknown, + TBottomTab extends string = string, + TCurrentStory = unknown, +> = { + entry: ProfileSaveArchiveSummary; + snapshot: SavedGameSnapshot; +}; + export type CustomWorldPublicationStatus = 'draft' | 'published'; export type CustomWorldThemeMode = | 'martial' diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts index e5b58a4d..f21b109a 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[]; @@ -87,6 +83,26 @@ export type PlainTextResponse = { text: string; }; +export type NpcChatTurnLimitReason = 'negative_affinity'; + +export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close'; + +export type NpcChatTurnDirective = { + sceneActId?: string | null; + turnLimit?: number | null; + remainingTurns?: number | null; + limitReason?: NpcChatTurnLimitReason | null; + closingMode?: NpcChatTurnClosingMode | null; + forceExitAfterTurn?: boolean; +}; + +export type NpcChatTurnCompletionDirective = { + turnLimit?: number | null; + remainingTurns?: number | null; + forceExit?: boolean; + closingMode?: NpcChatTurnClosingMode; +}; + export type CharacterChatReplyRequest< TCharacter = unknown, TStoryMoment = unknown, @@ -164,23 +180,41 @@ export type NpcChatTurnRequest< TContext = unknown, TConversationTurn = unknown, TNpcState = unknown, + TQuestOfferState = unknown, + TQuestOfferEncounter = unknown, + TChatDirective = NpcChatTurnDirective, > = { worldType: string; - character: TCharacter; + character?: TCharacter; + player?: TCharacter; encounter: TEncounter; monsters: TMonster[]; history: TStoryMoment[]; context: TContext; - conversationHistory: TConversationTurn[]; + conversationHistory?: TConversationTurn[]; + dialogue?: TConversationTurn[]; playerMessage: string; npcState: TNpcState; + questOfferContext?: { + state: TQuestOfferState; + encounter: TQuestOfferEncounter; + turnCount: number; + } | null; + chatDirective?: TChatDirective | 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; + chatDirective?: NpcChatTurnCompletionDirective | null; }; export type NpcRecruitDialogueRequest< @@ -259,6 +293,8 @@ export const TASK5_RUNTIME_FUNCTION_IDS = [ 'idle_observe_signs', 'idle_rest_focus', 'idle_travel_next_scene', + 'battle_attack_basic', + 'battle_use_skill', 'battle_all_in_crush', 'battle_escape_breakout', 'battle_feint_step', @@ -311,6 +347,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 @@ -324,6 +382,8 @@ 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 e65a6d69..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`, @@ -2030,10 +2039,169 @@ test('profile dashboard aggregates wallet, play time and played works at the acc }); }); +test('profile save archives list worlds by last played time and can resume a selected archive', async () => { + await withTestServer('profile-save-archives', async ({ baseUrl }) => { + const user = await authEntry(baseUrl, 'archive_user', 'secret123'); + + const firstSaveResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(user.token, { + method: 'PUT', + body: JSON.stringify({ + savedAt: '2026-04-19T08:00:00.000Z', + bottomTab: 'adventure', + currentStory: { + text: '潮声还在旧灯塔下回荡。', + options: [], + }, + gameState: { + worldType: 'CUSTOM', + playerCurrency: 120, + runtimeStats: { + playTimeMs: 5400000, + }, + storyEngineMemory: { + continueGameDigest: '回到裂潮边城的旧灯塔继续追查假航灯。', + }, + customWorldProfile: { + id: 'world-aurora', + name: '裂潮边城', + summary: '潮声与城线之间的冷铁边疆。', + }, + }, + }), + }), + ); + assert.equal(firstSaveResponse.status, 200); + + const secondSaveResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(user.token, { + method: 'PUT', + body: JSON.stringify({ + savedAt: '2026-04-19T10:15:00.000Z', + bottomTab: 'inventory', + currentStory: { + text: '江湖新章的风雨夜刚刚开始。', + options: [], + }, + gameState: { + worldType: 'WUXIA', + playerCurrency: 86, + runtimeStats: { + playTimeMs: 900000, + }, + currentScenePreset: { + name: '江湖新章', + summary: '雨夜客栈里的新委托。', + }, + }, + }), + }), + ); + assert.equal(secondSaveResponse.status, 200); + + const listResponse = await httpRequest( + `${baseUrl}/api/runtime/profile/save-archives`, + withBearer(user.token), + ); + const listPayload = (await listResponse.json()) as { + entries: Array<{ + worldKey: string; + worldName: string; + summaryText: string; + lastPlayedAt: string; + }>; + }; + + assert.equal(listResponse.status, 200); + assert.deepEqual( + listPayload.entries.map((entry) => entry.worldKey), + ['builtin:WUXIA', 'custom:world-aurora'], + ); + assert.equal(listPayload.entries[0]?.worldName, '江湖新章'); + assert.equal( + listPayload.entries[1]?.summaryText, + '回到裂潮边城的旧灯塔继续追查假航灯。', + ); + assert.equal( + listPayload.entries[0]?.lastPlayedAt, + '2026-04-19T10:15:00.000Z', + ); + + const resumeResponse = await httpRequest( + `${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent('custom:world-aurora')}`, + withBearer(user.token, { + method: 'POST', + }), + ); + const resumePayload = (await resumeResponse.json()) as { + entry: { + worldKey: string; + }; + snapshot: { + bottomTab: string; + gameState: { + playerCurrency: number; + customWorldProfile: { + id: string; + name: string; + } | null; + }; + }; + }; + + assert.equal(resumeResponse.status, 200); + assert.equal(resumePayload.entry.worldKey, 'custom:world-aurora'); + assert.equal(resumePayload.snapshot.bottomTab, 'adventure'); + assert.equal(resumePayload.snapshot.gameState.playerCurrency, 120); + assert.equal( + resumePayload.snapshot.gameState.customWorldProfile?.id, + 'world-aurora', + ); + + const currentSnapshotResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(user.token), + ); + const currentSnapshotPayload = (await currentSnapshotResponse.json()) as { + bottomTab: string; + gameState: { + playerCurrency: number; + customWorldProfile: { + id: string; + } | null; + }; + }; + + assert.equal(currentSnapshotResponse.status, 200); + assert.equal(currentSnapshotPayload.bottomTab, 'adventure'); + assert.equal(currentSnapshotPayload.gameState.playerCurrency, 120); + assert.equal( + currentSnapshotPayload.gameState.customWorldProfile?.id, + 'world-aurora', + ); + + const dashboardResponse = await httpRequest( + `${baseUrl}/api/runtime/profile/dashboard`, + withBearer(user.token), + ); + const dashboardPayload = (await dashboardResponse.json()) as { + walletBalance: number; + totalPlayTimeMs: number; + playedWorldCount: number; + }; + + assert.equal(dashboardResponse.status, 200); + assert.equal(dashboardPayload.walletBalance, 86); + assert.equal(dashboardPayload.totalPlayTimeMs, 6300000); + assert.equal(dashboardPayload.playedWorldCount, 2); + }); +}); + test('custom worlds stay private until published and then appear in the public gallery', async () => { await withTestServer('custom-world-gallery', async ({ baseUrl }) => { const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123'); - const viewer = await authEntry(baseUrl, 'gallery_viewer', 'secret123'); const upsertResponse = await httpRequest( `${baseUrl}/api/runtime/custom-world-library/world-a`, @@ -2084,15 +2252,11 @@ test('custom worlds stay private until published and then appear in the public g const galleryBeforePublish = await httpRequest( `${baseUrl}/api/runtime/custom-world-gallery`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, ); const galleryBeforePayload = (await galleryBeforePublish.json()) as { entries: unknown[]; }; + assert.equal(galleryBeforePublish.status, 200); assert.deepEqual(galleryBeforePayload.entries, []); const publishResponse = await httpRequest( @@ -2114,11 +2278,6 @@ test('custom worlds stay private until published and then appear in the public g const galleryAfterPublish = await httpRequest( `${baseUrl}/api/runtime/custom-world-gallery`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, ); const galleryAfterPayload = (await galleryAfterPublish.json()) as { entries: Array<{ @@ -2139,11 +2298,6 @@ test('custom worlds stay private until published and then appear in the public g const galleryDetail = await httpRequest( `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, ); const galleryDetailPayload = (await galleryDetail.json()) as { entry: { @@ -2175,11 +2329,6 @@ test('custom worlds stay private until published and then appear in the public g const galleryAfterUnpublish = await httpRequest( `${baseUrl}/api/runtime/custom-world-gallery`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, ); const galleryAfterUnpublishPayload = (await galleryAfterUnpublish.json()) as { 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/db/migrations.ts b/server-node/src/db/migrations.ts index b84fd58f..db8d9da5 100644 --- a/server-node/src/db/migrations.ts +++ b/server-node/src/db/migrations.ts @@ -30,6 +30,7 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ `CREATE TABLE IF NOT EXISTS runtime_settings ( user_id TEXT PRIMARY KEY, music_volume REAL NOT NULL, + platform_theme TEXT NOT NULL DEFAULT 'light', updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, @@ -316,4 +317,38 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ )`, ], }, + { + id: '20260419_014_profile_save_archives', + name: 'profile save archives', + statements: [ + `CREATE TABLE IF NOT EXISTS profile_save_archives ( + user_id TEXT NOT NULL, + world_key TEXT NOT NULL, + owner_user_id TEXT, + profile_id TEXT, + world_type TEXT, + world_name TEXT NOT NULL DEFAULT '', + world_subtitle TEXT NOT NULL DEFAULT '', + summary_text TEXT NOT NULL DEFAULT '', + cover_image_src TEXT, + saved_at TEXT NOT NULL, + bottom_tab TEXT NOT NULL, + game_state_json JSONB NOT NULL, + current_story_json JSONB, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, world_key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS profile_save_archives_user_saved_idx + ON profile_save_archives (user_id, saved_at DESC)`, + ], + }, + { + id: '20260419_015_runtime_settings_platform_theme', + name: 'runtime settings platform theme', + statements: [ + `ALTER TABLE runtime_settings + ADD COLUMN IF NOT EXISTS platform_theme TEXT NOT NULL DEFAULT 'light'`, + ], + }, ]; diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts index 834910c9..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), @@ -125,14 +139,108 @@ function describeAffinityShift(affinityDelta: number) { } function buildFallbackNpcChatSuggestions(playerMessage: string) { - const topic = playerMessage.trim() || '刚才那句话'; + const topic = Array.from(playerMessage.trim() || '刚才那句') + .slice(0, 8) + .join(''); return [ - `顺着“${topic}”再追问一句`, - '先表明你的判断,再看对方反应', - '换个更轻一点的语气继续聊下去', + '你刚才那句是什么意思', + `这事和${topic}有关吗`, + '你愿意再说清楚点吗', ]; } +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, @@ -227,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, @@ -236,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/chatPromptBuilders.ts b/server-node/src/modules/ai/chatPromptBuilders.ts index e1698545..86bd2775 100644 --- a/server-node/src/modules/ai/chatPromptBuilders.ts +++ b/server-node/src/modules/ai/chatPromptBuilders.ts @@ -1,455 +1 @@ -import type { - CharacterChatReplyRequest, - CharacterChatSuggestionsRequest, - CharacterChatSummaryRequest, - NpcChatDialogueRequest, - NpcChatTurnRequest, - NpcRecruitDialogueRequest, -} from '../../../../packages/shared/src/contracts/story.js'; - -type JsonRecord = Record; - -export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。 -只回复这名角色此刻会对玩家说的话。 -不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。 -保持人设,结合最近剧情和关系变化,回复简洁自然。`; - -export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。 -只输出纯文本,共 3 行,每行一条。 -不要加编号、项目符号、Markdown 或额外说明。 -三条建议语气要有区分:关心、追问、轻松或拉近关系。`; - -export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。 -只输出一段简洁文字。 -包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`; - -export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。 -你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 - -硬性规则: -- 每一行都必须严格以“你:”或“角色名字:”开头。 -- 第一行必须是“你:”开头。 -- 总行数控制在 4 到 6 行。 -- 玩家和对方至少各说 2 次。 -- 这段内容只是聊天,不是做决定。 -- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。 -- 禁止把情报直接写成对玩家的指令。 -- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`; - -export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。 -你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 - -硬性规则: -- 每一行都必须严格以“你:”或“角色名字:”开头。 -- 第一行必须是“你:”开头。 -- 总行数控制在 4 到 6 行。 -- 玩家和对方至少各说 2 次。 -- 这段对话的目标是把“邀请对方入队”自然谈成。 -- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。 -- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。 -- 最后一行必须由对方明确答应加入队伍。`; - -export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。 -你只输出这名 NPC 此刻会对玩家说的一轮回复。 -只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 -回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`; - -export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。 -只输出纯文本,共 3 行,每行 1 条。 -不要加编号、项目符号、Markdown、JSON 或额外说明。 -三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`; - -function asRecord(value: unknown): JsonRecord | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as JsonRecord) - : null; -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : null; -} - -function readNumber(value: unknown, fallback = 0) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function readStringArray(value: unknown) { - return Array.isArray(value) - ? value - .map((item) => readString(item)) - .filter((item): item is string => Boolean(item)) - : []; -} - -function describeWorld(worldType: string) { - switch (worldType) { - case 'WUXIA': - return '边城模板'; - case 'XIANXIA': - return '灵潮模板'; - case 'CUSTOM': - return '自定义世界'; - default: - return worldType || '未知世界'; - } -} - -function describeStats(label: string, record: JsonRecord | null) { - const hp = readNumber(record?.hp); - const maxHp = Math.max(1, readNumber(record?.maxHp, hp)); - const mana = readNumber(record?.mana); - const maxMana = Math.max(1, readNumber(record?.maxMana, mana)); - - return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`; -} - -function describeCharacter(label: string, value: unknown) { - const record = asRecord(value); - const name = readString(record?.name) ?? '未知角色'; - const title = readString(record?.title) ?? '未知称号'; - const description = readString(record?.description) ?? '暂无额外描述'; - const personality = readString(record?.personality) ?? '性格信息未显式提供'; - - return [ - `${label}姓名:${name}`, - `${label}称号:${title}`, - `${label}描述:${description}`, - `${label}性格:${personality}`, - ].join('\n'); -} - -function describeStoryHistory(history: unknown) { - if (!Array.isArray(history) || history.length === 0) { - return '近期剧情:暂无。'; - } - - const lines = history - .slice(-4) - .map((item) => readString(asRecord(item)?.text)) - .filter((item): item is string => Boolean(item)); - - return lines.length > 0 - ? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n') - : '近期剧情:暂无。'; -} - -function describeConversationHistory(history: unknown) { - if (!Array.isArray(history) || history.length === 0) { - return '聊天记录:暂无。'; - } - - const lines = history - .slice(-12) - .map((item) => { - const record = asRecord(item); - const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色'; - const text = readString(record?.text); - - return text ? `- ${speaker}:${text}` : null; - }) - .filter((item): item is string => Boolean(item)); - - return lines.length > 0 - ? ['聊天记录:', ...lines].join('\n') - : '聊天记录:暂无。'; -} - -function describeNpcConversationHistory(history: unknown, npcName: string) { - if (!Array.isArray(history) || history.length === 0) { - return '当前聊天记录:暂无。'; - } - - const lines = history - .slice(-10) - .map((item) => { - const record = asRecord(item); - const speaker = readString(record?.speaker); - const speakerName = readString(record?.speakerName); - const text = readString(record?.text); - if (!text) return null; - - if (speaker === 'player') { - return `- 玩家:${text}`; - } - - if (speaker === 'npc') { - return `- ${speakerName ?? npcName}:${text}`; - } - - if (speaker === 'system') { - return `- 系统提示:${text}`; - } - - return `- ${speakerName ?? '同伴'}:${text}`; - }) - .filter((item): item is string => Boolean(item)); - - return lines.length > 0 - ? ['当前聊天记录:', ...lines].join('\n') - : '当前聊天记录:暂无。'; -} - -function describeSceneContext(context: unknown) { - const record = asRecord(context); - const sceneName = readString(record?.sceneName) ?? '当前区域'; - const sceneDescription = - readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。'; - const inBattle = record?.inBattle === true ? '战斗中' : '非战斗'; - const customWorldProfile = asRecord(record?.customWorldProfile); - const customWorldName = readString(customWorldProfile?.name); - const customWorldSummary = readString(customWorldProfile?.summary); - - return [ - `世界补充:${customWorldName ?? '无'}`, - customWorldSummary ? `世界摘要:${customWorldSummary}` : null, - `场景:${sceneName}`, - `场景描述:${sceneDescription}`, - `当前状态:${inBattle}`, - describeStats('玩家', record), - ] - .filter(Boolean) - .join('\n'); -} - -function describeTargetStatus(status: unknown) { - const record = asRecord(status); - const roleLabel = readString(record?.roleLabel) ?? '同行角色'; - const affinity = record?.affinity; - - return [ - `对方身份:${roleLabel}`, - describeStats('对方', record), - typeof affinity === 'number' ? `当前好感:${affinity}` : null, - ] - .filter(Boolean) - .join('\n'); -} - -function describeEncounter(encounter: unknown) { - const record = asRecord(encounter); - const npcName = readString(record?.npcName) ?? '眼前角色'; - const contextText = - readString(record?.context) ?? - readString(record?.npcDescription) ?? - '你们正在当前遭遇里继续对话。'; - - return { - npcName, - block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'), - }; -} - -function describeMonsters(monsters: unknown) { - if (!Array.isArray(monsters) || monsters.length === 0) { - return '当前敌对目标:无。'; - } - - const lines = monsters - .slice(0, 4) - .map((item) => { - const record = asRecord(item); - const name = - readString(record?.name) ?? - readString(record?.npcName) ?? - readString(record?.id); - const hp = readNumber(record?.hp); - const maxHp = Math.max(1, readNumber(record?.maxHp, hp)); - - return name ? `- ${name}(生命 ${hp}/${maxHp})` : null; - }) - .filter((item): item is string => Boolean(item)); - - return lines.length > 0 - ? ['当前敌对目标:', ...lines].join('\n') - : '当前敌对目标:无。'; -} - -function describeTargetCharacterName(payload: { - targetCharacter?: unknown; - encounter?: unknown; -}) { - return ( - readString(asRecord(payload.targetCharacter)?.name) ?? - readString(asRecord(payload.encounter)?.npcName) ?? - '对方' - ); -} - -export function buildCharacterPanelChatPrompt( - payload: CharacterChatReplyRequest, -) { - const targetName = describeTargetCharacterName(payload); - - return [ - `世界:${describeWorld(payload.worldType)}`, - describeSceneContext(payload.context), - describeCharacter('玩家 / ', payload.playerCharacter), - describeCharacter('对方 / ', payload.targetCharacter), - describeTargetStatus(payload.targetStatus), - describeStoryHistory(payload.storyHistory), - payload.conversationSummary - ? `之前聊天摘要:${payload.conversationSummary}` - : '之前聊天摘要:暂无。', - describeConversationHistory(payload.conversationHistory), - `玩家刚刚对 ${targetName} 说:${payload.playerMessage}`, - `现在请以 ${targetName} 的身份,直接回复玩家。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildCharacterPanelChatSuggestionPrompt( - payload: CharacterChatSuggestionsRequest, -) { - const targetName = describeTargetCharacterName(payload); - const latestCharacterReply = Array.isArray(payload.conversationHistory) - ? [...payload.conversationHistory] - .reverse() - .map((item) => asRecord(item)) - .find((record) => readString(record?.speaker) === 'character') - : null; - const latestReplyText = readString(latestCharacterReply?.text); - - return [ - `世界:${describeWorld(payload.worldType)}`, - describeSceneContext(payload.context), - describeCharacter('玩家 / ', payload.playerCharacter), - describeCharacter('对方 / ', payload.targetCharacter), - describeTargetStatus(payload.targetStatus), - describeStoryHistory(payload.storyHistory), - payload.conversationSummary - ? `之前聊天摘要:${payload.conversationSummary}` - : '之前聊天摘要:暂无。', - describeConversationHistory(payload.conversationHistory), - latestReplyText - ? `角色刚刚的回复:${latestReplyText}` - : `玩家正准备与 ${targetName} 开始一段新的私聊。`, - `请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildCharacterPanelChatSummaryPrompt( - payload: CharacterChatSummaryRequest, -) { - const targetName = describeTargetCharacterName(payload); - - return [ - `世界:${describeWorld(payload.worldType)}`, - describeSceneContext(payload.context), - describeCharacter('玩家 / ', payload.playerCharacter), - describeCharacter('对方 / ', payload.targetCharacter), - describeTargetStatus(payload.targetStatus), - describeStoryHistory(payload.storyHistory), - payload.previousSummary - ? `旧摘要:${payload.previousSummary}` - : '旧摘要:暂无。', - describeConversationHistory(payload.conversationHistory), - `请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -function buildNpcDialoguePromptBase( - payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest, -) { - const encounter = describeEncounter(payload.encounter); - - return [ - `世界:${describeWorld(payload.worldType)}`, - describeSceneContext(payload.context), - describeCharacter('玩家 / ', payload.character), - encounter.block, - describeMonsters(payload.monsters), - describeStoryHistory(payload.history), - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildStrictNpcChatDialoguePrompt( - payload: NpcChatDialogueRequest, -) { - const encounter = describeEncounter(payload.encounter); - const context = asRecord(payload.context); - const openingCampBackground = readString(context?.openingCampBackground); - const openingCampDialogue = readString(context?.openingCampDialogue); - const allowedTopics = readStringArray(context?.encounterAllowedTopics); - const blockedTopics = readStringArray(context?.encounterBlockedTopics); - - return [ - buildNpcDialoguePromptBase(payload), - openingCampBackground ? `营地开场背景:${openingCampBackground}` : null, - openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null, - allowedTopics.length > 0 - ? `当前更适合谈的内容:${allowedTopics.join('、')}` - : null, - blockedTopics.length > 0 - ? `当前避免直接说破:${blockedTopics.join('、')}` - : null, - `当前聊天主题:${payload.topic}`, - payload.resultSummary - ? `这段聊天希望带来的变化:${payload.resultSummary}` - : '这段聊天要让气氛、情报或关系出现一层新的变化。', - `请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildNpcRecruitDialoguePrompt( - payload: NpcRecruitDialogueRequest, -) { - const encounter = describeEncounter(payload.encounter); - - return [ - buildNpcDialoguePromptBase(payload), - `玩家邀请:${payload.invitationText}`, - payload.recruitSummary - ? `招募补充条件:${payload.recruitSummary}` - : '这轮对话已经具备自然邀请对方入队的条件。', - '这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。', - `最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildNpcChatTurnReplyPrompt( - payload: NpcChatTurnRequest, -) { - const encounter = describeEncounter(payload.encounter); - const npcState = asRecord(payload.npcState); - const affinity = readNumber(npcState?.affinity, 0); - const chattedCount = readNumber(npcState?.chattedCount, 0); - - return [ - buildNpcDialoguePromptBase(payload), - describeNpcConversationHistory(payload.conversationHistory, encounter.npcName), - `当前关系值:${affinity}`, - `已聊天轮次:${chattedCount}`, - `玩家刚刚说:${payload.playerMessage}`, - `现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildNpcChatTurnSuggestionPrompt( - payload: NpcChatTurnRequest, - npcReply: string, -) { - const encounter = describeEncounter(payload.encounter); - - return [ - buildNpcDialoguePromptBase(payload), - describeNpcConversationHistory(payload.conversationHistory, encounter.npcName), - `玩家刚刚说:${payload.playerMessage}`, - `NPC 刚刚回复:${npcReply}`, - `请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`, - ] - .filter(Boolean) - .join('\n\n'); -} +export * from '../../prompts/chatPromptBuilders.js'; diff --git a/server-node/src/modules/ai/customWorldOrchestrator.ts b/server-node/src/modules/ai/customWorldOrchestrator.ts index 0426ef1a..8294e443 100644 --- a/server-node/src/modules/ai/customWorldOrchestrator.ts +++ b/server-node/src/modules/ai/customWorldOrchestrator.ts @@ -5,34 +5,32 @@ 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, + CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT, + CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT, +} from '../../prompts/customWorldOrchestratorPrompts.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; type GeneratedProfile = Record; - -const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。 -只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`; -const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。 -你会收到一段本应为单个 JSON 对象的文本。 -你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。 -不要输出 Markdown、代码块、解释、注释或额外文字。 -尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`; const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000; const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3; @@ -278,59 +276,6 @@ function createCustomWorldGenerationReporter( }; } -function buildCustomWorldProfilePrompt(params: { - settingText: string; - generationSeedText: string; - creatorIntent: CustomWorldCreatorIntent | null; - generationMode: CustomWorldGenerationMode; -}) { - const targets = getCustomWorldGenerationTargets(params.generationMode); - const creatorIntentText = - params.creatorIntent && hasMeaningfulCustomWorldCreatorIntent(params.creatorIntent) - ? buildCustomWorldCreatorIntentGenerationText(params.creatorIntent) - : ''; - - return [ - '请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。', - '必须严格输出单个 JSON 对象,不要 Markdown,不要解释。', - '', - `生成模式:${params.generationMode}`, - `可扮演角色数量:${targets.playableCount}`, - `场景角色数量:${targets.storyCount}`, - `关键场景数量:${targets.landmarkCount}`, - '', - '创作者输入:', - params.generationSeedText, - creatorIntentText ? `\n结构化创作锚点:\n${creatorIntentText}` : '', - '', - '输出 JSON 字段要求:', - '- name, subtitle, summary, tone, playerGoal, templateWorldType', - '- majorFactions: string[],coreConflicts: string[]', - '- camp: { name, description, dangerLevel }', - '- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags', - '- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags', - '- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections', - '- connections 每项包含 targetLandmarkName, relativePosition, summary,targetLandmarkName 必须指向本次输出的其他场景名', - '', - '约束:', - '- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。', - '- 角色名字、势力名、场景名必须互相区分,避免重复。', - '- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。', - '- templateWorldType 只能是 WUXIA 或 XIANXIA。', - '- dangerLevel 使用 low、medium、high、extreme 之一。', - '- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。', - '- 不要预生成物品档案;items 如需输出,必须为空数组。', - ].filter(Boolean).join('\n'); -} - -function buildCustomWorldProfileRepairPrompt(responseText: string) { - return [ - '请修复下面的自定义世界 JSON。', - '只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。', - responseText, - ].join('\n\n'); -} - async function parseCustomWorldJsonStage(params: { llmClient: UpstreamLlmClient; responseText: string; @@ -424,16 +369,21 @@ export async function generateCustomWorldProfileFromOrchestrator( creatorIntent, generationMode, } = resolveCustomWorldGenerationInput(input); + const targets = getCustomWorldGenerationTargets(generationMode); + const creatorIntentText = + creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent) + ? buildCustomWorldCreatorIntentGenerationText(creatorIntent) + : ''; const reporter = createCustomWorldGenerationReporter(options.onProgress); try { throwIfCustomWorldGenerationAborted(options.signal); reporter.begin('prepare', '正在整理创作者输入与结构化锚点。'); const userPrompt = buildCustomWorldProfilePrompt({ - settingText, generationSeedText, - creatorIntent, generationMode, + creatorIntentText, + targets, }); reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。'); @@ -446,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/ai/storyOrchestrator.ts b/server-node/src/modules/ai/storyOrchestrator.ts index 54a38690..b29e97c7 100644 --- a/server-node/src/modules/ai/storyOrchestrator.ts +++ b/server-node/src/modules/ai/storyOrchestrator.ts @@ -1,6 +1,10 @@ import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js'; import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; +import { + buildStoryLanguageRepairPrompt, + STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT, +} from '../../prompts/storyOrchestratorPrompts.js'; import { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js'; type JsonRecord = Record; @@ -64,12 +68,6 @@ type RawOptionItem = { actionText?: string; }; -const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。 -你会收到一个已经解析过的剧情 JSON 对象。 -你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。 -必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。 -只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`; - const DEFAULT_VISUALS = { playerAnimation: 'idle' as const, playerMoveMeters: 0, @@ -83,6 +81,8 @@ const STATIC_FALLBACK_OPTION_MAP: Record< string, Partial & { actionText: string } > = { + battle_attack_basic: { actionText: '普通攻击' }, + battle_use_skill: { actionText: '释放技能' }, battle_all_in_crush: { actionText: '正面强压敌人' }, battle_escape_breakout: { actionText: '先脱离眼前追杀' }, battle_feint_step: { actionText: '借假动作切进身位' }, @@ -334,11 +334,9 @@ function resolveOptionsFromOptionCatalog( function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) { if (context.inBattle === true) { return [ - 'battle_probe_pressure', - 'battle_guard_break', + 'battle_attack_basic', 'battle_recover_breath', - 'battle_feint_step', - 'battle_finisher_window', + 'battle_use_skill', 'battle_escape_breakout', ]; } @@ -381,25 +379,6 @@ function getFallbackOptions( ); } -function buildStoryLanguageRepairPrompt(response: AIResponse) { - return [ - '请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。', - '只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。', - JSON.stringify( - { - storyText: response.storyText, - encounter: response.encounter ?? null, - options: response.options.map((option) => ({ - functionId: option.functionId, - actionText: option.actionText, - })), - }, - null, - 2, - ), - ].join('\n\n'); -} - function needsStoryLanguageRepair(response: AIResponse) { return hasMixedNarrativeLanguage(response.storyText); } diff --git a/server-node/src/modules/ai/storyPromptBuilders.test.ts b/server-node/src/modules/ai/storyPromptBuilders.test.ts new file mode 100644 index 00000000..c871fb2b --- /dev/null +++ b/server-node/src/modules/ai/storyPromptBuilders.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildUserPrompt } from './storyPromptBuilders.js'; + +test('buildUserPrompt adds post-chat reevaluation guidance for npc option catalogs', () => { + const prompt = buildUserPrompt({ + worldType: 'WUXIA', + character: { + name: '沈行', + title: '试剑客', + description: '测试角色', + personality: '谨慎', + }, + monsters: [], + history: [ + { text: '你:刚才那句话是什么意思?' }, + { text: '山道客:你最好别继续深究。' }, + ], + context: { + sceneName: '山道', + sceneDescription: '风声贴着碎石一路往前卷。', + encounterName: '山道客', + playerHp: 100, + playerMaxHp: 100, + playerMana: 30, + playerMaxMana: 30, + inBattle: false, + pendingSceneEncounter: false, + lastFunctionId: 'npc_chat', + }, + choice: '结束与山道客的这轮交谈,重新观察当前局势', + requestOptions: { + optionCatalog: [ + { + functionId: 'npc_chat', + actionText: '继续交谈', + }, + { + functionId: 'npc_help', + actionText: '请求援手', + }, + { + functionId: 'npc_trade', + actionText: '看看能交换什么', + }, + ], + }, + }); + + assert.match(prompt, /刚结束一轮 NPC 交谈后/u); + assert.match(prompt, /不要退回/u); + assert.match(prompt, /目录只是合法 function 范围/u); +}); diff --git a/server-node/src/modules/ai/storyPromptBuilders.ts b/server-node/src/modules/ai/storyPromptBuilders.ts index f1ffcccf..b13bba4d 100644 --- a/server-node/src/modules/ai/storyPromptBuilders.ts +++ b/server-node/src/modules/ai/storyPromptBuilders.ts @@ -1,163 +1 @@ -type JsonRecord = Record; - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : null; -} - -function readNumber(value: unknown, fallback = 0) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function describeWorld(worldType: string) { - switch (worldType) { - case 'WUXIA': - return '边城模板'; - case 'XIANXIA': - return '灵潮模板'; - case 'CUSTOM': - return '自定义世界'; - default: - return worldType || '未知世界'; - } -} - -function describeCharacter(character: JsonRecord) { - return [ - `主角:${readString(character.name) ?? '未知角色'}`, - `称号:${readString(character.title) ?? '未知称号'}`, - `描述:${readString(character.description) ?? '暂无'}`, - `性格:${readString(character.personality) ?? '未显式提供'}`, - ].join('\n'); -} - -function describeMonsters(monsters: JsonRecord[]) { - if (monsters.length <= 0) { - return '当前敌对目标:无。'; - } - - return [ - '当前敌对目标:', - ...monsters.slice(0, 4).map((monster) => { - const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标'; - const hp = readNumber(monster.hp); - const maxHp = Math.max(1, readNumber(monster.maxHp, hp)); - return `- ${name}(生命 ${hp}/${maxHp})`; - }), - ].join('\n'); -} - -function describeStoryHistory(history: JsonRecord[]) { - if (history.length <= 0) { - return '近期剧情:暂无。'; - } - - return [ - '近期剧情:', - ...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`), - ].join('\n'); -} - -function describeRequestOptions(options: { - availableOptions?: Array>; - optionCatalog?: Array>; -}) { - const available = options.availableOptions ?? []; - const catalog = options.optionCatalog ?? []; - - if (available.length > 0) { - return [ - '固定可选项列表:', - ...available.map((option, index) => { - const functionId = readString(option.functionId) ?? 'unknown'; - const actionText = - readString(option.actionText) ?? - readString(option.text) ?? - '未提供文案'; - return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`; - }), - '必须保持数量不变,functionId 不变,可以重写 actionText。'.trim(), - ].join('\n'); - } - - if (catalog.length > 0) { - return [ - '当前局面可调用的交互选项目录:', - ...catalog.map((option, index) => { - const functionId = readString(option.functionId) ?? 'unknown'; - const actionText = - readString(option.actionText) ?? - readString(option.text) ?? - '未提供文案'; - return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`; - }), - 'functionId 只能从上面目录里选择。'.trim(), - ].join('\n'); - } - - return '当前没有固定目录,请根据局势生成合理选项。'; -} - -export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。 -输出格式必须严格符合: -{ - "storyText": "剧情文本", - "encounter": null, - "options": [ - { - "functionId": "预定义功能ID", - "actionText": "选项显示文本" - } - ] -} - -严格规则: -- 所有文本必须是中文。 -- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。 -- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。 -- options 只允许输出 functionId 和 actionText。 -- 如果当前不是“继续推进后下一刻会遇到什么”的场景,encounter 必须保持为 null。`; - -export function buildUserPrompt(params: { - worldType: string; - character: JsonRecord; - monsters: JsonRecord[]; - history: JsonRecord[]; - context: JsonRecord; - choice?: string; - requestOptions?: { - availableOptions?: Array>; - optionCatalog?: Array>; - }; -}) { - const sceneName = readString(params.context.sceneName) ?? '当前区域'; - const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。'; - const encounterName = readString(params.context.encounterName); - const playerHp = readNumber(params.context.playerHp); - const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp)); - const playerMana = readNumber(params.context.playerMana); - const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana)); - const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗'; - const pendingSceneEncounter = - params.context.pendingSceneEncounter === true ? '是' : '否'; - - return [ - `世界:${describeWorld(params.worldType)}`, - `场景:${sceneName}`, - `场景描述:${sceneDescription}`, - encounterName ? `当前面前对象:${encounterName}` : null, - `当前状态:${inBattle}`, - `玩家生命:${playerHp}/${playerMaxHp}`, - `玩家灵力:${playerMana}/${playerMaxMana}`, - `是否需要判断下一刻遭遇:${pendingSceneEncounter}`, - describeCharacter(params.character), - describeMonsters(params.monsters), - describeStoryHistory(params.history), - params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。', - describeRequestOptions(params.requestOptions ?? {}), - params.context.pendingSceneEncounter === true - ? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter;否则 encounter 必须为 null。' - : '当前这一步不是新的遭遇生成流程,encounter 必须为 null。', - ] - .filter(Boolean) - .join('\n\n'); -} +export * from '../../prompts/storyPromptBuilders.js'; diff --git a/server-node/src/modules/assets/characterAssetRoutes.test.ts b/server-node/src/modules/assets/characterAssetRoutes.test.ts index d6a3f4f0..f7e61d86 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.test.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.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'; @@ -8,6 +12,7 @@ import test from 'node:test'; import express from 'express'; import { PNG } from 'pngjs'; +import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js'; import type { AppConfig } from '../../config.js'; import { createCharacterAssetRoutes } from './characterAssetRoutes.js'; @@ -33,6 +38,57 @@ function createGreenScreenFixturePngBuffer() { return PNG.sync.write(png); } +function setPngPixel( + png: PNG, + x: number, + y: number, + rgba: [number, number, number, number], +) { + const offset = (y * png.width + x) * 4; + png.data[offset] = rgba[0]; + png.data[offset + 1] = rgba[1]; + png.data[offset + 2] = rgba[2]; + png.data[offset + 3] = rgba[3]; +} + +function createWhiteBackdropFixturePngBuffer() { + const png = new PNG({ width: 5, height: 5 }); + + for (let y = 0; y < png.height; y += 1) { + for (let x = 0; x < png.width; x += 1) { + setPngPixel(png, x, y, [255, 255, 255, 255]); + } + } + + for (let y = 1; y <= 3; y += 1) { + for (let x = 1; x <= 3; x += 1) { + setPngPixel(png, x, y, [220, 62, 86, 255]); + } + } + + setPngPixel(png, 2, 2, [244, 244, 244, 255]); + + return PNG.sync.write(png); +} + +function createGreenHaloFixturePngBuffer() { + const png = new PNG({ width: 5, height: 5 }); + + for (let y = 0; y < png.height; y += 1) { + for (let x = 0; x < png.width; x += 1) { + setPngPixel(png, x, y, [0, 255, 0, 255]); + } + } + + for (let y = 1; y <= 3; y += 1) { + setPngPixel(png, 1, y, [164, 186, 126, 255]); + setPngPixel(png, 2, y, [220, 60, 82, 255]); + setPngPixel(png, 3, y, [208, 52, 76, 255]); + } + + return PNG.sync.write(png); +} + function readPngAlphaValues(buffer: Buffer) { const png = PNG.sync.read(buffer); return Array.from({ length: png.width * png.height }, (_, index) => { @@ -40,18 +96,38 @@ function readPngAlphaValues(buffer: Buffer) { }); } +function readPngPixel( + buffer: Buffer, + x: number, + y: number, +): { red: number; green: number; blue: number; alpha: number } { + const png = PNG.sync.read(buffer); + const offset = (y * png.width + x) * 4; + + return { + red: png.data[offset] ?? 0, + green: png.data[offset + 1] ?? 0, + blue: png.data[offset + 2] ?? 0, + alpha: png.data[offset + 3] ?? 0, + }; +} + const GREEN_SCREEN_PNG_BUFFER = createGreenScreenFixturePngBuffer(); +const WHITE_BACKDROP_PNG_BUFFER = createWhiteBackdropFixturePngBuffer(); +const GREEN_HALO_PNG_BUFFER = createGreenHaloFixturePngBuffer(); function createTestConfig( projectRoot: string, - dashScopeBaseUrl: string, + upstreamBaseUrl: string, ): AppConfig { return { projectRoot, assetsApiEnabled: true, rawEnv: { - DASHSCOPE_BASE_URL: dashScopeBaseUrl, + DASHSCOPE_BASE_URL: upstreamBaseUrl, DASHSCOPE_API_KEY: 'test-dashscope-key', + ARK_BASE_URL: upstreamBaseUrl, + ARK_API_KEY: 'test-ark-key', }, } as AppConfig; } @@ -74,10 +150,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: ( @@ -150,8 +225,38 @@ async function withAssetRouteServer( } } +test('removeBackgroundFromRgba strips border-connected white background and keeps enclosed white highlights', () => { + const png = PNG.sync.read(WHITE_BACKDROP_PNG_BUFFER); + + const changed = removeBackgroundFromRgba(png.data, png.width, png.height); + + assert.equal(changed, true); + assert.equal(png.data[3] ?? 255, 0); + assert.equal(png.data[(2 * png.width + 2) * 4 + 3] ?? 0, 255); +}); + +test('removeBackgroundFromRgba reduces green spill on edge pixels without eroding the foreground core', () => { + const cleanedBuffer = (() => { + const png = PNG.sync.read(GREEN_HALO_PNG_BUFFER); + removeBackgroundFromRgba(png.data, png.width, png.height); + return PNG.sync.write(png); + })(); + + const haloPixel = readPngPixel(cleanedBuffer, 1, 2); + const corePixel = readPngPixel(cleanedBuffer, 2, 2); + + assert.equal(corePixel.alpha, 255); + assert.equal(corePixel.red > corePixel.green, true); + assert.equal( + haloPixel.alpha < 120 || haloPixel.green <= haloPixel.red + 12, + true, + ); +}); + test('character visual generation converts public reference images into data urls before calling DashScope', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-visual-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-visual-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'reference.png'), PNG_BUFFER); @@ -174,7 +279,10 @@ test('character visual generation converts public reference images into data url return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/visual-task-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v1/tasks/visual-task-1' + ) { sendJson(res, { output: { task_status: 'SUCCEEDED', @@ -233,129 +341,163 @@ test('character visual generation converts public reference images into data url content: Array<{ text?: string; image?: string }>; }>; }; + parameters: { + negative_prompt?: string; + }; }; const content = createPayload.input.messages[0]?.content ?? []; assert.match(content[0]?.text ?? '', /右向斜侧身/u); assert.match(content[0]?.text ?? '', /纯绿色绿幕/u); assert.match(content[0]?.text ?? '', /1:1 正方形画幅|1:1 正方形画布/u); - assert.match(content[0]?.text ?? '', /2 到 2\.5 头身|2 到 3 头身/u); - assert.match(content[0]?.text ?? '', /躯干与四肢短而紧凑/u); - assert.match(content[0]?.text ?? '', /深色粗轮廓配合清晰大色块/u); + assert.match(content[0]?.text ?? '', /3 到 4 头身/u); + assert.match(content[0]?.text ?? '', /像素动作角色/u); + assert.match(content[0]?.text ?? '', /不要退化成软萌 Q版大头贴/u); assert.match(content[0]?.text ?? '', /不要把主题词自动扩写成背景建筑/u); assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u); + assert.match( + createPayload.parameters.negative_prompt ?? '', + /软萌 Q版大头贴/u, + ); assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u); - const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1)); + const savedDraftPath = path.join( + tempRoot, + 'public', + payload.drafts[0]!.imageSrc.slice(1), + ); assert.equal(fs.existsSync(savedDraftPath), true); - assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedDraftPath)), [0, 255]); + assert.deepEqual( + readPngAlphaValues(fs.readFileSync(savedDraftPath)), + [0, 255], + ); }); }, ); }); test('character prompt bundle generation falls back to local defaults when llm client is unavailable', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-'), + ); - await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => { - const response = await fetch(`${assetBaseUrl}/api/assets/character-prompts/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - roleKind: 'story', - characterName: '港口向导', - roleTitle: '潮灯守望者', - roleLabel: '旧港引路人', - description: '熟悉黑潮与暗礁,身上带着潮雾气息。', - backstory: '常年守在废弃灯塔附近,为误入者指路。', - personality: '冷静克制,但会在关键时刻出手。', - motivation: '想守住最后一段仍能靠岸的航道。', - combatStyle: '短刀与信号灯配合,动作利落。', - tags: ['潮雾', '守望', '引路'], - characterBriefText: '角色名称:港口向导\n角色头衔:潮灯守望者\n世界身份:旧港引路人', - }), - }); + await withAssetRouteServer( + createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), + async (assetBaseUrl) => { + const response = await fetch( + `${assetBaseUrl}/api/assets/character-prompts/generate`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + roleKind: 'story', + characterName: '港口向导', + roleTitle: '潮灯守望者', + roleLabel: '旧港引路人', + description: '熟悉黑潮与暗礁,身上带着潮雾气息。', + backstory: '常年守在废弃灯塔附近,为误入者指路。', + personality: '冷静克制,但会在关键时刻出手。', + motivation: '想守住最后一段仍能靠岸的航道。', + combatStyle: '短刀与信号灯配合,动作利落。', + tags: ['潮雾', '守望', '引路'], + characterBriefText: + '角色名称:港口向导\n角色头衔:潮灯守望者\n世界身份:旧港引路人', + }), + }, + ); - assert.equal(response.status, 200); - const payload = (await response.json()) as { - source: string; - visualPromptText: string; - animationPromptText: string; - scenePromptText: string; - }; + assert.equal(response.status, 200); + const payload = (await response.json()) as { + source: string; + visualPromptText: string; + animationPromptText: string; + scenePromptText: string; + }; - assert.equal(payload.source, 'fallback'); - assert.match(payload.visualPromptText, /港口向导/u); - assert.match(payload.visualPromptText, /右向斜侧身/u); - assert.match(payload.visualPromptText, /纯绿色绿幕/u); - assert.match(payload.visualPromptText, /2 到 2\.5 头身/u); - assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u); - assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/u); - assert.match(payload.animationPromptText, /动作/u); - assert.match(payload.scenePromptText, /场景/u); - }); + assert.equal(payload.source, 'fallback'); + assert.match(payload.visualPromptText, /港口向导/u); + assert.match(payload.visualPromptText, /右向斜侧身/u); + assert.match(payload.visualPromptText, /纯绿色绿幕/u); + assert.match(payload.visualPromptText, /2 到 2\.5 头身/u); + assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u); + assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/u); + assert.match(payload.animationPromptText, /动作/u); + assert.match(payload.scenePromptText, /场景/u); + }, + ); }); test('character workflow cache persists unsaved studio state', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-workflow-cache-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'), + ); - await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => { - const saveResponse = await fetch(`${assetBaseUrl}/api/assets/character-workflow-cache`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - visualPromptText: '潮雾港守望者', - animationPromptText: '短刀起手,收招利落', - visualDrafts: [ - { - id: 'draft-1', - label: '候选 1', - imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png', - width: 1024, - height: 1536, - }, - ], - selectedVisualDraftId: 'draft-1', - selectedAnimation: 'idle', - imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png', - generatedVisualAssetId: 'visual-1', - generatedAnimationSetId: 'animation-set-1', - animationMap: { - idle: { - basePath: '/generated-animations/harbor-guide/animation-set-1/idle', + await withAssetRouteServer( + createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), + async (assetBaseUrl) => { + const saveResponse = await fetch( + `${assetBaseUrl}/api/assets/character-workflow-cache`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, + body: JSON.stringify({ + characterId: 'harbor-guide', + visualPromptText: '潮雾港守望者', + animationPromptText: '短刀起手,收招利落', + visualDrafts: [ + { + id: 'draft-1', + label: '候选 1', + imageSrc: + '/generated-character-drafts/harbor-guide/draft-1.png', + width: 1024, + height: 1536, + }, + ], + selectedVisualDraftId: 'draft-1', + selectedAnimation: 'idle', + imageSrc: + '/generated-characters/harbor-guide/visual/visual-1/master.png', + generatedVisualAssetId: 'visual-1', + generatedAnimationSetId: 'animation-set-1', + animationMap: { + idle: { + basePath: + '/generated-animations/harbor-guide/animation-set-1/idle', + }, + }, + }), }, - }), - }); + ); - assert.equal(saveResponse.status, 200); + assert.equal(saveResponse.status, 200); - const readResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache/harbor-guide`, - ); - assert.equal(readResponse.status, 200); + const readResponse = await fetch( + `${assetBaseUrl}/api/assets/character-workflow-cache/harbor-guide`, + ); + assert.equal(readResponse.status, 200); - const payload = (await readResponse.json()) as { - cache: { - characterId: string; - selectedVisualDraftId: string; - generatedVisualAssetId?: string; - animationMap?: Record; - } | null; - }; + const payload = (await readResponse.json()) as { + cache: { + characterId: string; + selectedVisualDraftId: string; + generatedVisualAssetId?: string; + animationMap?: Record; + } | null; + }; - assert.equal(payload.cache?.characterId, 'harbor-guide'); - assert.equal(payload.cache?.selectedVisualDraftId, 'draft-1'); - assert.equal(payload.cache?.generatedVisualAssetId, 'visual-1'); - assert.equal( - payload.cache?.animationMap?.idle?.basePath, - '/generated-animations/harbor-guide/animation-set-1/idle', - ); - }); + assert.equal(payload.cache?.characterId, 'harbor-guide'); + assert.equal(payload.cache?.selectedVisualDraftId, 'draft-1'); + assert.equal(payload.cache?.generatedVisualAssetId, 'visual-1'); + assert.equal( + payload.cache?.animationMap?.idle?.basePath, + '/generated-animations/harbor-guide/animation-set-1/idle', + ); + }, + ); }); test('character workflow cache skips rewriting unchanged payloads', async () => { @@ -381,7 +523,8 @@ test('character workflow cache skips rewriting unchanged payloads', async () => ], selectedVisualDraftId: 'draft-1', selectedAnimation: 'idle', - imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png', + imageSrc: + '/generated-characters/harbor-guide/visual/visual-1/master.png', generatedVisualAssetId: 'visual-1', generatedAnimationSetId: 'animation-set-1', animationMap: { @@ -522,9 +665,18 @@ test('character workflow cache stays isolated for different character ids', asyn } | null; }; - assert.equal(firstReadPayload.cache?.characterId, firstPayload.characterId); - assert.equal(firstReadPayload.cache?.visualPromptText, firstPayload.visualPromptText); - assert.equal(secondReadPayload.cache?.characterId, secondPayload.characterId); + assert.equal( + firstReadPayload.cache?.characterId, + firstPayload.characterId, + ); + assert.equal( + firstReadPayload.cache?.visualPromptText, + firstPayload.visualPromptText, + ); + assert.equal( + secondReadPayload.cache?.characterId, + secondPayload.characterId, + ); assert.equal( secondReadPayload.cache?.visualPromptText, secondPayload.visualPromptText, @@ -534,32 +686,40 @@ test('character workflow cache stays isolated for different character ids', asyn }); test('character animation publish returns frame dimensions in animation map', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-animation-publish-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-animation-publish-'), + ); await withAssetRouteServer( createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => { - const response = await fetch(`${assetBaseUrl}/api/assets/character-animation/publish`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - visualAssetId: 'visual-1', - updateCharacterOverride: false, - animations: { - run: { - framesDataUrls: [`data:image/png;base64,${PNG_BUFFER.toString('base64')}`], - fps: 12, - loop: true, - frameWidth: 144, - frameHeight: 192, - previewVideoPath: '/generated-character-drafts/harbor-guide/animation/run/preview.mp4', - }, + const response = await fetch( + `${assetBaseUrl}/api/assets/character-animation/publish`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - }), - }); + body: JSON.stringify({ + characterId: 'harbor-guide', + visualAssetId: 'visual-1', + updateCharacterOverride: false, + animations: { + run: { + framesDataUrls: [ + `data:image/png;base64,${PNG_BUFFER.toString('base64')}`, + ], + fps: 12, + loop: true, + frameWidth: 144, + frameHeight: 192, + previewVideoPath: + '/generated-character-drafts/harbor-guide/animation/run/preview.mp4', + }, + }, + }), + }, + ); assert.equal(response.status, 200); const payload = (await response.json()) as { @@ -588,7 +748,9 @@ test('character animation publish returns frame dimensions in animation map', as }); test('character visual publish removes green screen before saving master and previews', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-visual-publish-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-visual-publish-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'draft.png'), GREEN_SCREEN_PNG_BUFFER); @@ -596,29 +758,36 @@ test('character visual publish removes green screen before saving master and pre await withAssetRouteServer( createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => { - const response = await fetch(`${assetBaseUrl}/api/assets/character-visual/publish`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const response = await fetch( + `${assetBaseUrl}/api/assets/character-visual/publish`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + characterId: 'harbor-guide', + sourceMode: 'image-to-image', + promptText: '潮雾港向导', + selectedPreviewSource: '/draft.png', + previewSources: ['/draft.png'], + width: 1024, + height: 1024, + updateCharacterOverride: false, + }), }, - body: JSON.stringify({ - characterId: 'harbor-guide', - sourceMode: 'image-to-image', - promptText: '潮雾港向导', - selectedPreviewSource: '/draft.png', - previewSources: ['/draft.png'], - width: 1024, - height: 1024, - updateCharacterOverride: false, - }), - }); + ); assert.equal(response.status, 200); const payload = (await response.json()) as { portraitPath: string; }; - const savedMasterPath = path.join(tempRoot, 'public', payload.portraitPath.slice(1)); + const savedMasterPath = path.join( + tempRoot, + 'public', + payload.portraitPath.slice(1), + ); const savedPreviewPath = path.join( tempRoot, 'public', @@ -631,64 +800,52 @@ test('character visual publish removes green screen before saving master and pre assert.equal(fs.existsSync(savedMasterPath), true); assert.equal(fs.existsSync(savedPreviewPath), true); - assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedMasterPath)), [0, 255]); - assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedPreviewPath)), [0, 255]); + assert.deepEqual( + readPngAlphaValues(fs.readFileSync(savedMasterPath)), + [0, 255], + ); + assert.deepEqual( + readPngAlphaValues(fs.readFileSync(savedPreviewPath)), + [0, 255], + ); }, ); }); -test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-')); +test('character animation image-to-video flow sends first and last frame data urls to Ark seedance with fixed params', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-video-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); - let uploadCalled = false; let videoSynthesisPayloadText = ''; await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); + (arkBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', arkBaseUrl); - if (req.method === 'GET' && url.pathname === '/api/v1/uploads') { + if (req.method === 'POST' && url.pathname === '/api/v3/contents/generations/tasks') { + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { - data: { - upload_host: `${dashScopeBaseUrl}/upload`, - upload_dir: 'uploads/test-dir', - policy: 'policy', - signature: 'signature', - oss_access_key_id: 'oss-key', - }, + id: 'ark-video-task-1', + status: 'queued', }); return; } - if (req.method === 'POST' && url.pathname === '/upload') { - uploadCalled = true; - await readRequestBody(req); - res.statusCode = 200; - res.end('ok'); - return; - } - if ( - req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis' + req.method === 'GET' && + url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-1' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); sendJson(res, { - output: { - task_id: 'video-task-1', - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-1') { - sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + id: 'ark-video-task-1', + status: 'succeeded', + content: { + video_url: `${arkBaseUrl}/downloads/preview.mp4`, }, }); return; @@ -704,8 +861,8 @@ test('character animation image-to-video flow uploads a public visual source and res.statusCode = 404; res.end('not found'); }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + async (arkBaseUrl) => { + const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); await withAssetRouteServer(config, async (assetBaseUrl) => { const response = await fetch( `${assetBaseUrl}/api/assets/character-animation/generate`, @@ -725,12 +882,13 @@ test('character animation image-to-video flow uploads a public visual source and referenceVideoDataUrls: [], frameCount: 8, fps: 8, - durationSeconds: 4, + durationSeconds: 7, loop: true, useChromaKey: true, resolution: '720P', + ratio: '16:9', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }), @@ -741,25 +899,48 @@ test('character animation image-to-video flow uploads a public visual source and const payload = (await response.json()) as { previewVideoPath: string; }; - assert.equal(uploadCalled, true); const videoPayload = JSON.parse(videoSynthesisPayloadText) as { - input: { - media: Array<{ type: string; url: string }>; - }; + resolution?: string; + ratio?: string; + duration?: number; + content: Array<{ + type: string; + role?: string; + image_url?: { url?: string }; + }>; }; - assert.equal(videoPayload.input.media[0]?.type, 'first_frame'); - assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u); + assert.equal(videoPayload.resolution, '480p'); + assert.equal(videoPayload.ratio, '1:1'); + assert.equal(videoPayload.duration, 4); + assert.equal(videoPayload.content[1]?.type, 'image_url'); + assert.equal(videoPayload.content[1]?.role, 'first_frame'); + assert.match( + videoPayload.content[1]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.content[2]?.type, 'image_url'); + assert.equal(videoPayload.content[2]?.role, 'last_frame'); + assert.match( + videoPayload.content[2]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); - const savedVideoPath = path.join(tempRoot, 'public', payload.previewVideoPath.slice(1)); + const savedVideoPath = path.join( + tempRoot, + 'public', + payload.previewVideoPath.slice(1), + ); assert.equal(fs.existsSync(savedVideoPath), true); }); }, ); }); -test('character animation non-loop image-to-video uses first and last master frames', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-')); +test('character animation non-loop image-to-video keeps first and last reference images in Ark request', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-kf2v-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); @@ -767,27 +948,32 @@ test('character animation non-loop image-to-video uses first and last master fra let videoSynthesisPayloadText = ''; await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); + (arkBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', arkBaseUrl); if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/image2video/video-synthesis' + url.pathname === '/api/v3/contents/generations/tasks' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { - output: { - task_id: 'video-task-kf2v-1', - }, + id: 'ark-video-task-kf2v-1', + status: 'queued', }); return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-kf2v-1' + ) { sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + id: 'ark-video-task-kf2v-1', + status: 'succeeded', + content: { + video_url: `${arkBaseUrl}/downloads/preview.mp4`, }, }); return; @@ -803,8 +989,8 @@ test('character animation non-loop image-to-video uses first and last master fra res.statusCode = 404; res.end('not found'); }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + async (arkBaseUrl) => { + const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); await withAssetRouteServer(config, async (assetBaseUrl) => { const response = await fetch( `${assetBaseUrl}/api/assets/character-animation/generate`, @@ -827,9 +1013,10 @@ test('character animation non-loop image-to-video uses first and last master fra durationSeconds: 4, loop: false, useChromaKey: true, - resolution: '720P', + resolution: '480p', + ratio: '1:1', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }), @@ -840,25 +1027,34 @@ test('character animation non-loop image-to-video uses first and last master fra const videoPayload = JSON.parse(videoSynthesisPayloadText) as { model: string; - input: { - first_frame_url?: string; - last_frame_url?: string; - }; - parameters: { - resolution?: string; - }; + content: Array<{ + type: string; + role?: string; + image_url?: { url?: string }; + }>; + resolution?: string; }; - assert.equal(videoPayload.model, 'wan2.2-kf2v-flash'); - assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u); - assert.match(videoPayload.input.last_frame_url ?? '', /^data:image\/png;base64,/u); - assert.equal(videoPayload.parameters.resolution, '480P'); + assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); + assert.equal(videoPayload.content[1]?.role, 'first_frame'); + assert.match( + videoPayload.content[1]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.content[2]?.role, 'last_frame'); + assert.match( + videoPayload.content[2]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.resolution, '480p'); }); }, ); }); -test('character animation die image-to-video does not send a last frame reference', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-die-')); +test('character animation die image-to-video still uses Ark first and last frame references', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-kf2v-die-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); @@ -866,27 +1062,32 @@ test('character animation die image-to-video does not send a last frame referenc let videoSynthesisPayloadText = ''; await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); + (arkBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', arkBaseUrl); if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/image2video/video-synthesis' + url.pathname === '/api/v3/contents/generations/tasks' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { - output: { - task_id: 'video-task-kf2v-die-1', - }, + id: 'ark-video-task-die-1', + status: 'queued', }); return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-die-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-die-1' + ) { sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + id: 'ark-video-task-die-1', + status: 'succeeded', + content: { + video_url: `${arkBaseUrl}/downloads/preview.mp4`, }, }); return; @@ -902,8 +1103,8 @@ test('character animation die image-to-video does not send a last frame referenc res.statusCode = 404; res.end('not found'); }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + async (arkBaseUrl) => { + const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); await withAssetRouteServer(config, async (assetBaseUrl) => { const response = await fetch( `${assetBaseUrl}/api/assets/character-animation/generate`, @@ -926,9 +1127,10 @@ test('character animation die image-to-video does not send a last frame referenc durationSeconds: 4, loop: false, useChromaKey: true, - resolution: '720P', + resolution: '480p', + ratio: '1:1', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }), @@ -939,23 +1141,37 @@ test('character animation die image-to-video does not send a last frame referenc const videoPayload = JSON.parse(videoSynthesisPayloadText) as { model: string; - input: { - first_frame_url?: string; - last_frame_url?: string; - prompt?: string; - }; + content: Array<{ + type: string; + role?: string; + image_url?: { url?: string }; + text?: string; + }>; }; - assert.equal(videoPayload.model, 'wan2.2-kf2v-flash'); - assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u); - assert.equal(videoPayload.input.last_frame_url, undefined); - assert.match(videoPayload.input.prompt ?? '', /尾帧停在死亡结束姿态/u); + assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); + assert.equal(videoPayload.content[1]?.role, 'first_frame'); + assert.match( + videoPayload.content[1]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.content[2]?.role, 'last_frame'); + assert.match( + videoPayload.content[2]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.match( + videoPayload.content[0]?.text ?? '', + /动作英文名:die|动作英文名是 die/u, + ); }); }, ); }); -test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url only', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-i2v-loop-')); +test('character animation loop image-to-video uses Ark seedance fixed params and keeps two reference images', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-i2v-loop-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); @@ -963,27 +1179,32 @@ test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url let videoSynthesisPayloadText = ''; await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); + (arkBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', arkBaseUrl); if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis' + url.pathname === '/api/v3/contents/generations/tasks' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { - output: { - task_id: 'video-task-i2v-loop-1', - }, + id: 'ark-video-task-loop-1', + status: 'queued', }); return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-i2v-loop-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-loop-1' + ) { sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + id: 'ark-video-task-loop-1', + status: 'succeeded', + content: { + video_url: `${arkBaseUrl}/downloads/preview.mp4`, }, }); return; @@ -999,8 +1220,8 @@ test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url res.statusCode = 404; res.end('not found'); }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + async (arkBaseUrl) => { + const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); await withAssetRouteServer(config, async (assetBaseUrl) => { const response = await fetch( `${assetBaseUrl}/api/assets/character-animation/generate`, @@ -1023,9 +1244,10 @@ test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url durationSeconds: 4, loop: true, useChromaKey: true, - resolution: '720P', + resolution: '480p', + ratio: '1:1', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.6-i2v-flash', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }), @@ -1036,29 +1258,38 @@ test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url const videoPayload = JSON.parse(videoSynthesisPayloadText) as { model: string; - input: { - img_url?: string; - first_frame_url?: string; - last_frame_url?: string; - }; - parameters: { - audio?: boolean; - resolution?: string; - }; + content: Array<{ + type: string; + role?: string; + image_url?: { url?: string }; + }>; + resolution?: string; + ratio?: string; + duration?: number; }; - assert.equal(videoPayload.model, 'wan2.6-i2v-flash'); - assert.match(videoPayload.input.img_url ?? '', /^data:image\/png;base64,/u); - assert.equal(videoPayload.input.first_frame_url, undefined); - assert.equal(videoPayload.input.last_frame_url, undefined); - assert.equal(videoPayload.parameters.audio, false); - assert.equal(videoPayload.parameters.resolution, '720P'); + assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); + assert.equal(videoPayload.content[1]?.role, 'first_frame'); + assert.match( + videoPayload.content[1]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.content[2]?.role, 'last_frame'); + assert.match( + videoPayload.content[2]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.resolution, '480p'); + assert.equal(videoPayload.ratio, '1:1'); + assert.equal(videoPayload.duration, 4); }); }, ); }); test('character animation reference-to-video can use only reference image media', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-r2v-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-r2v-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); @@ -1091,9 +1322,12 @@ test('character animation reference-to-video can use only reference image media' if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis' + url.pathname === + '/api/v1/services/aigc/video-generation/video-synthesis' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { output: { task_id: 'video-task-r2v-1', @@ -1102,7 +1336,10 @@ test('character animation reference-to-video can use only reference image media' return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-r2v-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v1/tasks/video-task-r2v-1' + ) { sendJson(res, { output: { task_status: 'SUCCEEDED', @@ -1163,7 +1400,10 @@ test('character animation reference-to-video can use only reference image media' }; }; assert.equal(videoPayload.input.media[0]?.type, 'reference_image'); - assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u); + assert.match( + videoPayload.input.media[0]?.url ?? '', + /^oss:\/\/uploads\/test-dir\//u, + ); assert.equal(videoPayload.input.media.length, 1); }); }, diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index 85c86603..bb0d125b 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -8,57 +8,62 @@ import http, { import https from 'node:https'; import path from 'node:path'; -import { type NextFunction, type Request, type Response, Router } from 'express'; +import { + type NextFunction, + type Request, + type Response, + Router, +} from 'express'; import { PNG } from 'pngjs'; -import { - buildMasterPrompt, - buildVideoActionPrompt, - getActionTemplateById, -} from '../../../../packages/shared/src/assets/qwenSprite.js'; +import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.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 = '/api/assets/character-prompts/generate'; +const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH = + '/api/assets/character-prompts/generate'; const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache'; const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate'; const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish'; const CHARACTER_VISUAL_JOBS_PATH = '/api/assets/character-visual/jobs/'; -const CHARACTER_ANIMATION_GENERATE_PATH = '/api/assets/character-animation/generate'; -const CHARACTER_ANIMATION_PUBLISH_PATH = '/api/assets/character-animation/publish'; +const CHARACTER_ANIMATION_GENERATE_PATH = + '/api/assets/character-animation/generate'; +const CHARACTER_ANIMATION_PUBLISH_PATH = + '/api/assets/character-animation/publish'; const CHARACTER_ANIMATION_JOBS_PATH = '/api/assets/character-animation/jobs/'; -const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = '/api/assets/character-animation/import-video'; -const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/assets/character-animation/templates'; +const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = + '/api/assets/character-animation/import-video'; +const CHARACTER_ANIMATION_TEMPLATES_PATH = + '/api/assets/character-animation/templates'; +const DEFAULT_ARK_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'; 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_LOOP_VIDEO_MODEL = 'wan2.6-i2v-flash'; +const DEFAULT_CHARACTER_VIDEO_MODEL = 'doubao-seedance-2-0-fast-260128'; const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v'; const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move'; +const FIXED_ARK_CHARACTER_VIDEO_RESOLUTION = '480p'; +const FIXED_ARK_CHARACTER_VIDEO_RATIO = '1:1'; +const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS = 4; 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 CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。 -你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。 -你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。 -输出格式必须严格为: -{ - "visualPromptText": "角色主图提示词", - "animationPromptText": "角色动作提示词", - "scenePromptText": "角色关联场景提示词" -} - -硬性约束: -- 所有字段都必须是自然中文。 -- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。 -- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。 -- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。 -- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。 -- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。 -- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`; +const ARK_VIDEO_TASK_POLL_INTERVAL_MS = 5000; const BUILT_IN_MOTION_TEMPLATES = [ { @@ -82,13 +87,6 @@ const BUILT_IN_MOTION_TEMPLATES = [ promptSuffix: '短促前踏后横斩,收招干净。', notes: '适合近战角色的基础攻击模板。', }, - { - id: 'hurt_back', - label: '受击后仰', - animation: 'hurt', - promptSuffix: '身体后仰,重心短暂失衡后稳住。', - notes: '适合方案三的受击模板。', - }, { id: 'die_fall', label: '倒地死亡', @@ -113,44 +111,7 @@ type DecodedMediaPayload = { function applyGreenScreenAlphaToPngBuffer(buffer: Buffer) { try { const png = PNG.sync.read(buffer); - const pixels = png.data; - let changed = false; - - for (let index = 0; index < pixels.length; index += 4) { - const red = pixels[index] ?? 0; - const green = pixels[index + 1] ?? 0; - const blue = pixels[index + 2] ?? 0; - const alpha = pixels[index + 3] ?? 0; - const greenRatio = green / Math.max(1, red + blue); - - if (alpha === 0) { - continue; - } - - const greenLead = green - Math.max(red, blue); - if (green <= 72 || greenLead <= 20 || greenRatio <= 0.72) { - continue; - } - - let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6)); - - if (green > 120 && greenLead > 48 && greenRatio > 1.12) { - nextAlpha = 0; - } - - if (nextAlpha === alpha) { - continue; - } - - pixels[index + 3] = nextAlpha; - if (nextAlpha > 0) { - pixels[index + 1] = Math.min( - green, - Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)), - ); - } - changed = true; - } + const changed = removeBackgroundFromRgba(png.data, png.width, png.height); return changed ? PNG.sync.write(png) : buffer; } catch { @@ -171,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; @@ -234,7 +187,9 @@ function serializeWorkflowCacheComparableValue( ? value.selectedVisualDraftId : '', selectedAnimation: - typeof value.selectedAnimation === 'string' ? value.selectedAnimation : '', + typeof value.selectedAnimation === 'string' + ? value.selectedAnimation + : '', imageSrc: typeof value.imageSrc === 'string' ? value.imageSrc : '', generatedVisualAssetId: typeof value.generatedVisualAssetId === 'string' @@ -250,7 +205,11 @@ function serializeWorkflowCacheComparableValue( function readJsonBody(req: IncomingMessage & { body?: unknown }) { const parsedBody = req.body; - if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) { + if ( + parsedBody && + typeof parsedBody === 'object' && + !Array.isArray(parsedBody) + ) { return Promise.resolve(parsedBody as Record); } @@ -298,6 +257,10 @@ function normalizeDashScopeBaseUrl(value: string) { return value.replace(/\/$/u, ''); } +function normalizeArkBaseUrl(value: string) { + return value.replace(/\/$/u, ''); +} + function resolveRuntimeEnv(config: AppConfig) { return config.rawEnv; } @@ -325,125 +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, @@ -505,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()}`; } @@ -631,7 +439,9 @@ function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { .map((item) => item.trim()) .filter(Boolean); const mimeType = metadataParts[0] ?? 'application/octet-stream'; - const isBase64 = metadataParts.some((item) => item.toLowerCase() === 'base64'); + const isBase64 = metadataParts.some( + (item) => item.toLowerCase() === 'base64', + ); if (!isBase64) { throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); @@ -730,10 +540,7 @@ async function resolveCharacterVisualPayload( ); } -async function resolveMediaSourceAsDataUrl( - rootDir: string, - source: string, -) { +async function resolveMediaSourceAsDataUrl(rootDir: string, source: string) { if (/^data:/u.test(source)) { return source; } @@ -742,6 +549,11 @@ async function resolveMediaSourceAsDataUrl( return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; } +async function resolveCharacterVisualAsDataUrl(rootDir: string, source: string) { + const payload = await resolveCharacterVisualPayload(rootDir, source); + return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; +} + function requestResponse( urlString: string, options: { @@ -792,10 +604,9 @@ function requestResponse( }); } -function getRequestPathname( - req: IncomingMessage & { originalUrl?: string }, -) { - return new URL(req.originalUrl || req.url || '/', 'http://localhost').pathname; +function getRequestPathname(req: IncomingMessage & { originalUrl?: string }) { + return new URL(req.originalUrl || req.url || '/', 'http://localhost') + .pathname; } function requestTextResponse( @@ -1050,6 +861,101 @@ async function waitForDashScopeTask( throw new Error('任务执行超时,请稍后重试。'); } +function normalizeGenerationTaskStatus(value: string) { + return value.trim().toLowerCase().replace(/\s+/gu, '_'); +} + +function extractGenerationTaskStatus(payload: Record) { + const topLevelStatus = + typeof payload.status === 'string' ? payload.status.trim() : ''; + const output = isRecordValue(payload.output) ? payload.output : null; + const outputStatus = + output && typeof output.task_status === 'string' + ? output.task_status.trim() + : ''; + const nestedTaskStatus = findFirstStringByKey(payload, 'task_status') ?? ''; + const nestedStatus = findFirstStringByKey(payload, 'status') ?? ''; + + return normalizeGenerationTaskStatus( + topLevelStatus || outputStatus || nestedTaskStatus || nestedStatus, + ); +} + +function isCompletedGenerationTaskStatus(status: string) { + return [ + 'completed', + 'complete', + 'done', + 'finished', + 'success', + 'succeeded', + 'succeed', + ].includes(status); +} + +function isFailedGenerationTaskStatus(status: string) { + return [ + 'failed', + 'canceled', + 'cancelled', + 'error', + 'aborted', + 'rejected', + 'expired', + 'unknown', + ].includes(status); +} + +async function waitForArkContentGenerationTask( + 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}/contents/generations/tasks/${encodeURIComponent(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 taskStatus = extractGenerationTaskStatus(parsed); + + if (extractVideoUrl(parsed) || isCompletedGenerationTaskStatus(taskStatus)) { + return parsed; + } + + if (isFailedGenerationTaskStatus(taskStatus)) { + throw new Error( + extractApiErrorMessage(response.bodyText, '视频生成任务执行失败。'), + ); + } + + await sleep(options.intervalMs); + } + + throw new Error('视频生成任务执行超时,请稍后重试。'); +} + function findFirstStringByKey( value: unknown, targetKey: string, @@ -1108,7 +1014,10 @@ function collectStringsByKey( } function extractTaskId(payload: Record) { - return findFirstStringByKey(payload, 'task_id') ?? ''; + const topLevelId = + typeof payload.id === 'string' && payload.id.trim() ? payload.id.trim() : ''; + + return topLevelId || (findFirstStringByKey(payload, 'task_id') ?? ''); } function extractVideoUrl(payload: Record) { @@ -1126,106 +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 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[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 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': @@ -1267,7 +1076,10 @@ async function handleGenerateCharacterPromptBundle( const motivation = clampPromptSeedText(body.motivation, 180); const combatStyle = clampPromptSeedText(body.combatStyle, 180); const tags = isStringArray(body.tags) - ? body.tags.map((item) => clampPromptSeedText(item, 24)).filter(Boolean).slice(0, 8) + ? body.tags + .map((item) => clampPromptSeedText(item, 24)) + .filter(Boolean) + .slice(0, 8) : []; if (!characterBriefText) { @@ -1393,17 +1205,17 @@ async function handleGenerateCharacterVisuals( 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 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( @@ -1428,51 +1240,52 @@ async function handleGenerateCharacterVisuals( return; } - if (!promptText && !characterBriefText && sourceMode === 'text-to-image') { - sendJson(res, 400, { - error: { message: '文生主形象需要填写角色设定。' }, - }); + if (!promptText && !characterBriefText && sourceMode === 'text-to-image') { + sendJson(res, 400, { + error: { message: '文生主形象需要填写角色设定。' }, + }); return; } - let activeTaskId = ''; - let activePrompt = ''; - try { - const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText); - const normalizedReferenceImages = await Promise.all( - referenceImageDataUrls.map((image) => - resolveMediaSourceAsDataUrl(rootDir, image), - ), - ); - activePrompt = finalPrompt; - const content = [ - { text: finalPrompt }, - ...normalizedReferenceImages.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, - }, + let activeTaskId = ''; + let activePrompt = ''; + try { + const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText); + const normalizedReferenceImages = await Promise.all( + referenceImageDataUrls.map((image) => + resolveMediaSourceAsDataUrl(rootDir, image), + ), + ); + activePrompt = finalPrompt; + const content = [ + { text: finalPrompt }, + ...normalizedReferenceImages.map((image) => ({ image })), + ]; + const createTaskResponse = await proxyJsonRequest( + `${baseUrl}/services/aigc/image-generation/generation`, + apiKey, + { + model, + input: { + messages: [ + { + role: 'user', + content, + }, + ], }, - { - 'X-DashScope-Async': 'enable', + parameters: { + n: candidateCount, + size, + negative_prompt: buildNpcVisualNegativePrompt(), + prompt_extend: true, + watermark: false, }, - ); + }, + { + 'X-DashScope-Async': 'enable', + }, + ); if ( createTaskResponse.statusCode < 200 || @@ -1614,7 +1427,8 @@ async function handleGenerateCharacterVisuals( prompt: activePrompt, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - errorMessage: error instanceof Error ? error.message : '生成角色主形象失败。', + errorMessage: + error instanceof Error ? error.message : '生成角色主形象失败。', }); } sendJson(res, 500, { @@ -1642,19 +1456,12 @@ async function handleGenerateCharacterAnimation( runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, ); const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - const timeoutMs = Number( + const dashScopeTimeoutMs = 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); @@ -1708,7 +1515,7 @@ async function handleGenerateCharacterAnimation( const resolution = typeof body.resolution === 'string' && body.resolution.trim() ? body.resolution.trim() - : '720P'; + : '480p'; const imageSequenceModel = typeof body.imageSequenceModel === 'string' && body.imageSequenceModel.trim() @@ -1719,25 +1526,11 @@ async function handleGenerateCharacterAnimation( const requestedVideoModel = typeof body.videoModel === 'string' && body.videoModel.trim() ? body.videoModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || + : runtimeEnv.ARK_CHARACTER_VIDEO_MODEL || + runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || DEFAULT_CHARACTER_VIDEO_MODEL; - const loopVideoModel = - runtimeEnv.DASHSCOPE_CHARACTER_LOOP_VIDEO_MODEL || - (requestedVideoModel === 'wan2.2-kf2v-flash' - ? DEFAULT_CHARACTER_LOOP_VIDEO_MODEL - : requestedVideoModel) || - DEFAULT_CHARACTER_LOOP_VIDEO_MODEL; - const keyframeVideoModel = - runtimeEnv.DASHSCOPE_CHARACTER_KEYFRAME_VIDEO_MODEL || - DEFAULT_CHARACTER_VIDEO_MODEL; - const videoModel = - strategy === 'image-to-video' ? (loop ? loopVideoModel : keyframeVideoModel) : requestedVideoModel; - const durationSeconds = - videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds; - const normalizedResolution = getLowestSupportedVideoResolution( - videoModel, - videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution, - ); + const videoModel = requestedVideoModel; + const durationSeconds = requestedDurationSeconds; const referenceVideoModel = typeof body.referenceVideoModel === 'string' && body.referenceVideoModel.trim() @@ -1750,6 +1543,24 @@ async function handleGenerateCharacterAnimation( ? body.motionTransferModel.trim() : runtimeEnv.DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL || DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL; + const arkBaseUrl = normalizeArkBaseUrl( + runtimeEnv.ARK_CHARACTER_VIDEO_BASE_URL || + runtimeEnv.ARK_BASE_URL || + runtimeEnv.LLM_BASE_URL || + DEFAULT_ARK_BASE_URL, + ); + const arkApiKey = + runtimeEnv.ARK_CHARACTER_VIDEO_API_KEY || + runtimeEnv.ARK_API_KEY || + runtimeEnv.LLM_API_KEY || + ''; + const arkTimeoutMs = Number( + runtimeEnv.ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || + runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || + DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, + ); + const normalizedArkResolution = FIXED_ARK_CHARACTER_VIDEO_RESOLUTION; + const normalizedArkRatio = FIXED_ARK_CHARACTER_VIDEO_RATIO; if (!visualSource) { sendJson(res, 400, { @@ -1758,6 +1569,20 @@ async function handleGenerateCharacterAnimation( return; } + if (strategy === 'image-to-video' && !arkApiKey) { + sendJson(res, 500, { + error: { message: '缺少 ARK_API_KEY,无法生成角色动作。' }, + }); + return; + } + + if (strategy !== 'image-to-video' && !apiKey) { + sendJson(res, 500, { + error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色动作。' }, + }); + return; + } + let activeTaskId = ''; let activePrompt = ''; let activeModel = ''; @@ -1852,8 +1677,8 @@ async function handleGenerateCharacterAnimation( const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs + Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 + ? dashScopeTimeoutMs : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, }); @@ -1945,7 +1770,7 @@ async function handleGenerateCharacterAnimation( } if (strategy === 'image-to-video') { - const finalPrompt = buildNpcAnimationPrompt({ + const finalPrompt = buildArkCharacterAnimationPrompt({ animation, promptText, useChromaKey, @@ -1960,82 +1785,50 @@ async function handleGenerateCharacterAnimation( }); activePrompt = finalPrompt; activeModel = videoModel; - const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash'; - const isWan26I2vFlash = videoModel === 'wan2.6-i2v-flash'; - const visualInputRef = - isKf2vFlash || isWan26I2vFlash - ? await resolveMediaSourceAsDataUrl(rootDir, visualSource) - : await uploadFileToDashScope( - baseUrl, - apiKey, - videoModel, - `${characterId}-${animation}-visual`, - await resolveMediaSourcePayload(rootDir, visualSource), - ); - const resolvedLastFrameSource = - !loop && animation !== 'die' - ? lastFrameImageDataUrl || visualSource - : ''; - const lastFrameRef = resolvedLastFrameSource - ? isKf2vFlash - ? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource) - : await uploadFileToDashScope( - baseUrl, - apiKey, - videoModel, - `${characterId}-${animation}-last-frame`, - await resolveMediaSourcePayload( - rootDir, - resolvedLastFrameSource, - ), - ) - : ''; + const visualInputRef = await resolveCharacterVisualAsDataUrl( + rootDir, + visualSource, + ); + const resolvedLastFrameSource = lastFrameImageDataUrl || visualSource; + const lastFrameRef = await resolveCharacterVisualAsDataUrl( + rootDir, + resolvedLastFrameSource, + ); const createVideoRequestBody = (prompt: string) => ({ model: videoModel, - input: isKf2vFlash - ? { - prompt, - first_frame_url: visualInputRef, - ...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}), - } - : isWan26I2vFlash - ? { - prompt, - img_url: visualInputRef, - } - : { - prompt, - media: [ - { type: 'first_frame', url: visualInputRef }, - ...(lastFrameRef - ? [{ type: 'last_frame', url: lastFrameRef }] - : []), - ], - }, - parameters: { - duration: durationSeconds, - resolution: normalizedResolution, - ...(isKf2vFlash - ? { prompt_extend: true, watermark: false } - : {}), - ...(isWan26I2vFlash ? { audio: false } : {}), - }, + content: [ + { + type: 'text', + text: prompt, + }, + { + type: 'image_url', + image_url: { + url: visualInputRef, + }, + role: 'first_frame', + }, + { + type: 'image_url', + image_url: { + url: lastFrameRef, + }, + role: 'last_frame', + }, + ], + resolution: normalizedArkResolution, + ratio: normalizedArkRatio, + duration: FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS, + watermark: false, }); - const videoSynthesisEndpoint = isKf2vFlash - ? `${baseUrl}/services/aigc/image2video/video-synthesis` - : `${baseUrl}/services/aigc/video-generation/video-synthesis`; const { response: createTaskResponse, prompt: submittedPrompt } = await proxyJsonRequestWithPromptFallback({ - urlString: videoSynthesisEndpoint, - apiKey, + urlString: `${arkBaseUrl}/contents/generations/tasks`, + apiKey: arkApiKey, buildBody: createVideoRequestBody, primaryPrompt: finalPrompt, fallbackPrompt, - extraHeaders: { - 'X-DashScope-Async': 'enable', - 'X-DashScope-OssResourceResolve': 'enable', - }, }); activePrompt = submittedPrompt; @@ -2063,7 +1856,7 @@ async function handleGenerateCharacterAnimation( activeTaskId = taskId; if (!taskId) { - throw new Error('图生视频任务未返回 task_id。'); + throw new Error('角色动作视频任务未返回 id。'); } const createdAt = new Date().toISOString(); @@ -2080,13 +1873,18 @@ async function handleGenerateCharacterAnimation( 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 taskResult = await waitForArkContentGenerationTask( + arkBaseUrl, + arkApiKey, + taskId, + { + timeoutMs: + Number.isFinite(arkTimeoutMs) && arkTimeoutMs > 0 + ? arkTimeoutMs + : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, + intervalMs: ARK_VIDEO_TASK_POLL_INTERVAL_MS, + }, + ); const videoUrl = extractVideoUrl(taskResult); if (!videoUrl) { @@ -2264,8 +2062,8 @@ async function handleGenerateCharacterAnimation( const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs + Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 + ? dashScopeTimeoutMs : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, }); @@ -2467,8 +2265,8 @@ async function handleGenerateCharacterAnimation( const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs + Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 + ? dashScopeTimeoutMs : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, }); @@ -2564,7 +2362,8 @@ async function handleGenerateCharacterAnimation( prompt: activePrompt, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - errorMessage: error instanceof Error ? error.message : '生成角色动作失败。', + errorMessage: + error instanceof Error ? error.message : '生成角色动作失败。', }); } sendJson(res, 500, { @@ -2588,7 +2387,9 @@ async function handleReadCharacterJobStatus( const pathname = getRequestPathname(req); const prefix = - kind === 'visual' ? CHARACTER_VISUAL_JOBS_PATH : CHARACTER_ANIMATION_JOBS_PATH; + kind === 'visual' + ? CHARACTER_VISUAL_JOBS_PATH + : CHARACTER_ANIMATION_JOBS_PATH; const taskId = decodeURIComponent(pathname.slice(prefix.length)).trim(); if (!taskId) { @@ -2603,9 +2404,7 @@ async function handleReadCharacterJobStatus( sendJson(res, 404, { error: { message: - error instanceof Error - ? error.message - : '未找到对应的任务记录。', + error instanceof Error ? error.message : '未找到对应的任务记录。', }, }); } @@ -2689,8 +2488,7 @@ async function handleImportCharacterAnimationVideo( } catch (error) { sendJson(res, 500, { error: { - message: - error instanceof Error ? error.message : '导入动作视频失败。', + message: error instanceof Error ? error.message : '导入动作视频失败。', }, }); } @@ -2863,10 +2661,10 @@ async function handleSaveCharacterWorkflowCache( const existingCache = (await readJsonObjectFile(cacheFilePath)) as | CharacterAssetWorkflowCacheRecord | Record; - const comparablePayload = serializeWorkflowCacheComparableValue(payloadBase); - const comparableExisting = serializeWorkflowCacheComparableValue( - existingCache, - ); + const comparablePayload = + serializeWorkflowCacheComparableValue(payloadBase); + const comparableExisting = + serializeWorkflowCacheComparableValue(existingCache); if ( isRecordValue(existingCache) && @@ -3046,9 +2844,7 @@ async function handlePublishCharacterVisual( sendJson(res, 500, { error: { message: - error instanceof Error - ? error.message - : '发布角色主形象失败。', + error instanceof Error ? error.message : '发布角色主形象失败。', }, }); } @@ -3125,7 +2921,8 @@ async function handlePublishCharacterAnimation( } const fps = - typeof rawAnimation.fps === 'number' && Number.isFinite(rawAnimation.fps) + typeof rawAnimation.fps === 'number' && + Number.isFinite(rawAnimation.fps) ? rawAnimation.fps : 8; const loop = rawAnimation.loop === true; @@ -3152,7 +2949,10 @@ async function handlePublishCharacterAnimation( ); frameExtension = framePayload.extension; const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`; - await writeFile(path.join(actionDir, frameFileName), framePayload.buffer); + await writeFile( + path.join(actionDir, frameFileName), + framePayload.buffer, + ); framePaths.push( `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}/${frameFileName}`, ); @@ -3249,9 +3049,7 @@ async function handlePublishCharacterAnimation( sendJson(res, 500, { error: { message: - error instanceof Error - ? error.message - : '发布角色基础动作失败。', + error instanceof Error ? error.message : '发布角色基础动作失败。', }, }); } @@ -3266,7 +3064,8 @@ function toExpressHandler( return (request: Request, response: Response, next: NextFunction) => { Promise.resolve( handler( - request as Request & IncomingMessage & { body?: unknown; originalUrl?: string }, + request as Request & + IncomingMessage & { body?: unknown; originalUrl?: string }, response as Response & ServerResponse, ), ).catch(next); @@ -3329,7 +3128,12 @@ export function createCharacterAssetRoutes( router.use( CHARACTER_VISUAL_JOBS_PATH, toExpressHandler((request, response) => - handleReadCharacterJobStatus(config.projectRoot, request, response, 'visual'), + handleReadCharacterJobStatus( + config.projectRoot, + request, + response, + 'visual', + ), ), ); router.use( @@ -3347,13 +3151,22 @@ export function createCharacterAssetRoutes( router.use( CHARACTER_ANIMATION_JOBS_PATH, toExpressHandler((request, response) => - handleReadCharacterJobStatus(config.projectRoot, request, response, 'animation'), + handleReadCharacterJobStatus( + config.projectRoot, + request, + response, + 'animation', + ), ), ); router.use( CHARACTER_ANIMATION_IMPORT_VIDEO_PATH, toExpressHandler((request, response) => - handleImportCharacterAnimationVideo(config.projectRoot, request, response), + handleImportCharacterAnimationVideo( + config.projectRoot, + request, + response, + ), ), ); router.use( diff --git a/server-node/src/modules/combat/combatResolutionService.ts b/server-node/src/modules/combat/combatResolutionService.ts index e31a5d00..21152e8b 100644 --- a/server-node/src/modules/combat/combatResolutionService.ts +++ b/server-node/src/modules/combat/combatResolutionService.ts @@ -1,10 +1,24 @@ import type { RuntimeBattlePresentation, + RuntimeStoryChoicePayload, RuntimeStoryPatch, } from '../../../../packages/shared/src/contracts/story.js'; +import { + buildInventoryUseResultText, + incrementGameRuntimeStats, + isInventoryItemUsable, + removeInventoryItem, + resolveInventoryItemUseEffect, +} from '../../bridges/legacyInventoryRuntimeBridge.js'; import { conflict } from '../../errors.js'; +import { + appendBuildBuffs, + resolvePlayerOutgoingDamageResult, +} from '../runtime/runtimeBuildModule.js'; import { getEncounterNpcState, + getPlayerCharacter, + getPlayerSkillCooldowns, setEncounterNpcState, type RuntimeSession, } from '../story/runtimeSession.js'; @@ -16,6 +30,18 @@ type CombatActionConfig = { counterMultiplier: number; heal?: number; manaRestore?: number; + cooldownBonus?: number; + selectedSkillId?: string | null; + appliedCooldownTurns?: number; + buildBuffs?: Array<{ + id: string; + name: string; + tags: string[]; + durationTurns: number; + }>; + consumedItemId?: string | null; + usedItem?: RuntimeCombatInventoryItem | null; + itemEffect?: NonNullable> | null; }; export type CombatResolution = { @@ -26,51 +52,80 @@ export type CombatResolution = { storyText?: string; }; -const COMBAT_ACTIONS: Record = { - battle_all_in_crush: { - actionText: '正面强压', - manaCost: 14, - baseDamage: 22, - counterMultiplier: 1.25, - }, - battle_feint_step: { - actionText: '虚晃切步', - manaCost: 8, - baseDamage: 16, - counterMultiplier: 0.7, - }, - battle_finisher_window: { - actionText: '抓破绽终结', - manaCost: 10, - baseDamage: 18, - counterMultiplier: 0.9, - }, - battle_guard_break: { - actionText: '破架重击', - manaCost: 9, - baseDamage: 17, - counterMultiplier: 0.95, - }, - battle_probe_pressure: { - actionText: '稳步试探', - manaCost: 5, - baseDamage: 12, - counterMultiplier: 0.8, - }, - battle_recover_breath: { - actionText: '边守边调息', - manaCost: 0, - baseDamage: 0, - counterMultiplier: 0.55, - heal: 12, - manaRestore: 9, - }, +const LEGACY_ATTACK_FUNCTION_IDS = new Set([ + 'battle_all_in_crush', + 'battle_guard_break', + 'battle_probe_pressure', + 'battle_feint_step', + 'battle_finisher_window', +]); + +type RuntimeCombatInventoryItem = Parameters< + typeof resolveInventoryItemUseEffect +>[0] & { + id: string; + quantity: number; }; +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function readNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function readArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + function getAliveTarget(session: RuntimeSession) { return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null; } +function getCombatInventoryItem( + session: RuntimeSession, + itemId: string, +): RuntimeCombatInventoryItem | null { + const rawItem = readArray(session.rawGameState.playerInventory).find( + (candidate) => isObject(candidate) && readString(candidate.id) === itemId, + ); + if (!rawItem || !isObject(rawItem)) { + return null; + } + + const name = readString(rawItem.name, itemId); + if (!name) { + return null; + } + + const rarity = readString(rawItem.rarity, 'common'); + const normalizedRarity = + rarity === 'legendary' || + rarity === 'epic' || + rarity === 'rare' || + rarity === 'uncommon' + ? rarity + : 'common'; + + return { + id: itemId, + name, + quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))), + rarity: normalizedRarity, + tags: readArray(rawItem.tags).filter( + (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, + ), + useProfile: isObject(rawItem.useProfile) + ? (rawItem.useProfile as RuntimeCombatInventoryItem['useProfile']) + : undefined, + }; +} + function applySparAffinityReward(session: RuntimeSession) { const npcState = getEncounterNpcState(session); const encounter = session.currentEncounter; @@ -124,19 +179,167 @@ function finishBattle( } } +function buildBasicAttackBaseDamage(session: RuntimeSession) { + const character = getPlayerCharacter(session); + if (!character) { + return 10; + } + + return Math.max( + 8, + Math.round( + character.attributes.strength * 0.85 + + character.attributes.agility * 0.45, + ), + ); +} + +function tickCooldownMap( + cooldowns: Record, + turns: number, +) { + let nextCooldowns = cooldowns; + + for (let index = 0; index < Math.max(0, Math.floor(turns)); index += 1) { + nextCooldowns = Object.fromEntries( + Object.entries(nextCooldowns).map(([skillId, value]) => [ + skillId, + Math.max(0, Math.floor(value) - 1), + ]), + ); + } + + return nextCooldowns; +} + +function resolveCombatActionConfig(params: { + session: RuntimeSession; + functionId: string; + payload?: RuntimeStoryChoicePayload; +}) { + const { session, functionId, payload } = params; + + if (functionId === 'battle_recover_breath') { + return { + actionText: '恢复', + manaCost: 0, + baseDamage: 0, + counterMultiplier: 0.55, + heal: 12, + manaRestore: 9, + cooldownBonus: 1, + selectedSkillId: null, + } satisfies CombatActionConfig; + } + + if (functionId === 'battle_attack_basic' || LEGACY_ATTACK_FUNCTION_IDS.has(functionId)) { + return { + actionText: '普通攻击', + manaCost: 0, + baseDamage: buildBasicAttackBaseDamage(session), + counterMultiplier: 1, + selectedSkillId: null, + } satisfies CombatActionConfig; + } + + if (functionId === 'battle_use_skill') { + const character = getPlayerCharacter(session); + if (!character) { + throw conflict('缺少玩家角色,无法结算技能动作'); + } + + const skillId = readString(isObject(payload) ? payload.skillId : ''); + if (!skillId) { + throw conflict('battle_use_skill 缺少 skillId'); + } + + const skill = character.skills.find((candidate) => candidate.id === skillId); + if (!skill) { + throw conflict(`未找到技能:${skillId}`); + } + + const cooldowns = getPlayerSkillCooldowns(session); + if ((cooldowns[skill.id] ?? 0) > 0) { + throw conflict(`${skill.name} 仍在冷却中`); + } + + return { + actionText: skill.name, + manaCost: skill.manaCost, + baseDamage: skill.damage, + counterMultiplier: 0.95, + selectedSkillId: skill.id, + appliedCooldownTurns: skill.cooldownTurns, + buildBuffs: skill.buildBuffs ?? [], + } satisfies CombatActionConfig; + } + + if (functionId === 'inventory_use') { + const character = getPlayerCharacter(session); + if (!character) { + throw conflict('缺少玩家角色,无法结算战斗物品动作'); + } + + const itemId = readString(isObject(payload) ? payload.itemId : ''); + if (!itemId) { + throw conflict('inventory_use 缺少 itemId'); + } + + const item = getCombatInventoryItem(session, itemId); + if (!item || item.quantity <= 0) { + throw conflict('未找到可用于战斗结算的物品'); + } + + if (!isInventoryItemUsable(item)) { + throw conflict(`${item.name} 当前不可在战斗中直接使用`); + } + + const effect = resolveInventoryItemUseEffect(item, character); + if ( + !effect || + ((effect.hpRestore ?? 0) <= 0 && + (effect.manaRestore ?? 0) <= 0 && + (effect.cooldownReduction ?? 0) <= 0 && + (effect.buildBuffs?.length ?? 0) <= 0) + ) { + throw conflict(`${item.name} 当前没有可直接结算的战斗效果`); + } + + return { + actionText: `使用${item.name}`, + manaCost: 0, + baseDamage: 0, + counterMultiplier: 0.72, + heal: effect.hpRestore, + manaRestore: effect.manaRestore, + cooldownBonus: effect.cooldownReduction, + selectedSkillId: null, + buildBuffs: effect.buildBuffs, + consumedItemId: item.id, + usedItem: item, + itemEffect: effect, + } satisfies CombatActionConfig; + } + + throw conflict(`暂不支持的战斗动作:${functionId}`); +} + export function resolveCombatAction( session: RuntimeSession, - functionId: string, + params: { + functionId: string; + payload?: RuntimeStoryChoicePayload; + }, ): CombatResolution { const target = getAliveTarget(session); if (!session.inBattle || !target) { throw conflict('当前不在可结算战斗态,不能执行该战斗动作'); } - if (functionId === 'battle_escape_breakout') { + if (params.functionId === 'battle_escape_breakout') { finishBattle(session, 'escaped'); return { - actionText: '强行脱离战斗', + actionText: '逃跑', resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`, battle: { targetId: target.id, @@ -146,7 +349,7 @@ export function resolveCombatAction( patches: [ { type: 'battle_resolved', - functionId, + functionId: params.functionId, targetId: target.id, outcome: 'escaped', }, @@ -165,27 +368,85 @@ export function resolveCombatAction( }; } - const action = COMBAT_ACTIONS[functionId]; - if (!action) { - throw conflict(`暂不支持的战斗动作:${functionId}`); - } - + const action = resolveCombatActionConfig({ + session, + functionId: params.functionId, + payload: params.payload, + }); if (action.manaCost > session.playerMana) { throw conflict('当前灵力不足,无法执行这个战斗动作'); } + const character = getPlayerCharacter(session); + if (!character) { + throw conflict('缺少玩家角色,无法结算战斗动作'); + } + const isSpar = session.currentNpcBattleMode === 'spar'; - const targetHpRatio = target.hp / Math.max(target.maxHp, 1); - const damageBonus = - functionId === 'battle_finisher_window' && targetHpRatio <= 0.4 ? 8 : 0; - const damageDealt = isSpar ? 1 : action.baseDamage + damageBonus; + const damageResult = + action.baseDamage > 0 + ? resolvePlayerOutgoingDamageResult( + session.rawGameState as Parameters[0], + character, + action.baseDamage, + 1, + `${params.functionId}:${action.selectedSkillId ?? 'default'}:${target.id}:${session.runtimeVersion}`, + ) + : null; + const damageDealt = isSpar + ? action.baseDamage > 0 + ? 1 + : 0 + : damageResult?.damage ?? 0; session.playerMana -= action.manaCost; session.playerHp += action.heal ?? 0; session.playerMana += action.manaRestore ?? 0; + + let nextCooldowns = tickCooldownMap(getPlayerSkillCooldowns(session), 1); + if ((action.cooldownBonus ?? 0) > 0) { + nextCooldowns = tickCooldownMap(nextCooldowns, action.cooldownBonus ?? 0); + } + if (action.selectedSkillId && (action.appliedCooldownTurns ?? 0) > 0) { + nextCooldowns = { + ...nextCooldowns, + [action.selectedSkillId]: action.appliedCooldownTurns, + }; + } + session.rawGameState.playerSkillCooldowns = nextCooldowns; + + if (action.consumedItemId) { + session.rawGameState.playerInventory = removeInventoryItem( + session.rawGameState.playerInventory as Parameters[0], + action.consumedItemId, + 1, + ); + session.rawGameState.runtimeStats = incrementGameRuntimeStats( + (isObject(session.rawGameState.runtimeStats) + ? session.rawGameState.runtimeStats + : { + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }) as Parameters[0], + { itemsUsed: 1 }, + ); + } + + if (action.buildBuffs?.length) { + session.rawGameState.activeBuildBuffs = appendBuildBuffs( + (session.rawGameState.activeBuildBuffs as Parameters[0]) ?? + [], + action.buildBuffs as Parameters[1], + ); + } + clampPlayerVitals(session); - target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt); + if (damageDealt > 0) { + target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt); + } const patches: RuntimeStoryPatch[] = []; let resultText = ''; @@ -204,12 +465,15 @@ export function resolveCombatAction( } else { finishBattle(session, 'victory'); outcome = 'victory'; - resultText = `你彻底压垮了${target.name}的节奏,这场战斗已经收口。`; + resultText = `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。`; } } else { const baseCounter = isSpar ? 1 - : Math.max(4, Math.round(target.maxHp * 0.16 * action.counterMultiplier)); + : Math.max( + 4, + Math.round(target.maxHp * 0.14 * action.counterMultiplier), + ); damageTaken = baseCounter; session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken); @@ -220,7 +484,10 @@ export function resolveCombatAction( patches.push(affinityPatch); } outcome = 'spar_complete'; - resultText = `${target.name}也逼到了你的极限,这场切磋点到为止,双方都默认收手。`; + resultText = + params.functionId === 'inventory_use' && action.usedItem + ? `你刚用下${action.usedItem.name}稳住一口气,但${target.name}还是把你逼到了极限,这场切磋点到为止。` + : `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`; } else if (!isSpar && session.playerHp <= 0) { session.playerHp = 0; session.inBattle = false; @@ -229,16 +496,32 @@ export function resolveCombatAction( session.npcInteractionActive = false; session.currentEncounter = null; outcome = 'escaped'; - resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`; + resultText = + params.functionId === 'inventory_use' && action.usedItem + ? `你刚把${action.usedItem.name}用下去,却还是被${target.name}压到失去战斗能力,这轮正面冲突只能先断开。` + : `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`; + } else if (params.functionId === 'battle_recover_breath') { + resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`; + } else if ( + params.functionId === 'inventory_use' && + action.usedItem && + action.itemEffect + ) { + resultText = `${buildInventoryUseResultText( + action.usedItem, + action.itemEffect, + ).replace(/。$/u, '')},但${target.name}仍在持续逼近。`; + } else if (params.functionId === 'battle_use_skill') { + resultText = `${action.actionText}命中了${target.name},这一轮技能效果已经直接结算。`; } else { - resultText = `${action.actionText}命中了${target.name},但对方仍然顶住并回敬了一轮压力。`; + resultText = `${action.actionText}命中了${target.name},本次攻击已经完成结算。`; } } patches.push( { type: 'battle_resolved', - functionId, + functionId: params.functionId, targetId: target.id, damageDealt, damageTaken, 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..2bc15047 --- /dev/null +++ b/server-node/src/modules/custom-world/runtimeTypes.ts @@ -0,0 +1,427 @@ +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 type SceneActStage = + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath'; + +export type SceneActAdvanceRule = + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + +export interface SceneActBlueprint { + id: string; + sceneId: string; + title: string; + summary: string; + stageCoverage: SceneActStage[]; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + linkedThreadIds: string[]; + advanceRule: SceneActAdvanceRule; + actGoal: string; + transitionHook: string; +} + +export interface SceneChapterBlueprint { + id: string; + sceneId: string; + title: string; + summary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + acts: SceneActBlueprint[]; +} + +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; + sceneChapterBlueprints?: SceneChapterBlueprint[] | 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/progression/levelBenchmarks.ts b/server-node/src/modules/progression/levelBenchmarks.ts new file mode 100644 index 00000000..a0bebb88 --- /dev/null +++ b/server-node/src/modules/progression/levelBenchmarks.ts @@ -0,0 +1,63 @@ +export interface LevelBenchmark { + level: number; + xpToNextLevel: number; + cumulativeXpRequired: number; + referenceStrength: number; + baseHp: number; + baseMana: number; + baselineDamageScale: number; +} + +export const MAX_PLAYER_LEVEL = 20; + +function clampLevel(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 1; + } + + return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value))); +} + +function roundMetric(value: number, digits = 3) { + return Number(value.toFixed(digits)); +} + +function computeXpToNextLevel(level: number) { + const scale = Math.max(0, level - 1); + return 60 + 20 * scale + 8 * scale * scale; +} + +function buildLevelBenchmarks(maxLevel: number) { + const benchmarks: LevelBenchmark[] = []; + let cumulativeXpRequired = 0; + + for (let level = 1; level <= maxLevel; level += 1) { + const scale = level - 1; + const xpToNextLevel = level >= maxLevel ? 0 : computeXpToNextLevel(level); + + benchmarks.push({ + level, + xpToNextLevel, + cumulativeXpRequired, + referenceStrength: 100 + 16 * scale + 6 * scale * scale, + baseHp: 180 + 24 * scale + 10 * scale * scale, + baseMana: 80 + 14 * scale + 6 * scale * scale, + baselineDamageScale: roundMetric(1 + 0.12 * scale + 0.03 * scale * scale), + }); + + cumulativeXpRequired += xpToNextLevel; + } + + return benchmarks; +} + +const LEVEL_BENCHMARKS = buildLevelBenchmarks(MAX_PLAYER_LEVEL); +const LEVEL_BENCHMARKS_BY_LEVEL = new Map( + LEVEL_BENCHMARKS.map((benchmark) => [benchmark.level, benchmark]), +); + +export function getLevelBenchmark(level: number) { + return ( + LEVEL_BENCHMARKS_BY_LEVEL.get(clampLevel(level)) ?? LEVEL_BENCHMARKS[0]! + ); +} diff --git a/server-node/src/modules/progression/playerProgressionService.test.ts b/server-node/src/modules/progression/playerProgressionService.test.ts new file mode 100644 index 00000000..26a717da --- /dev/null +++ b/server-node/src/modules/progression/playerProgressionService.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createInitialPlayerProgressionState, + grantPlayerExperience, + normalizePlayerProgressionState, +} from './playerProgressionService.js'; + +test('player progression starts at level 1 with the first upgrade threshold', () => { + const initialState = createInitialPlayerProgressionState(); + + assert.deepEqual(initialState, { + level: 1, + currentLevelXp: 0, + totalXp: 0, + xpToNextLevel: 60, + pendingLevelUps: 0, + lastGrantedSource: null, + }); +}); + +test('grantPlayerExperience upgrades level state from quest rewards', () => { + const result = grantPlayerExperience( + { + level: 1, + currentLevelXp: 50, + totalXp: 50, + xpToNextLevel: 60, + }, + 40, + { + source: 'quest', + }, + ); + + assert.equal(result.grantedXp, 40); + assert.equal(result.previousLevel, 1); + assert.equal(result.nextLevel, 2); + assert.equal(result.levelUps, 1); + assert.equal(result.state.level, 2); + assert.equal(result.state.currentLevelXp, 30); + assert.equal(result.state.totalXp, 90); + assert.equal(result.state.xpToNextLevel, 88); + assert.equal(result.state.lastGrantedSource, 'quest'); +}); + +test('normalizePlayerProgressionState backfills legacy partial progression payloads', () => { + const normalized = normalizePlayerProgressionState({ + level: 3, + currentLevelXp: 15, + }); + + assert.equal(normalized.level, 3); + assert.equal(normalized.currentLevelXp, 15); + assert.equal(normalized.totalXp, 163); + assert.equal(normalized.xpToNextLevel, 132); +}); diff --git a/server-node/src/modules/progression/playerProgressionService.ts b/server-node/src/modules/progression/playerProgressionService.ts new file mode 100644 index 00000000..2e66174f --- /dev/null +++ b/server-node/src/modules/progression/playerProgressionService.ts @@ -0,0 +1,192 @@ +import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js'; + +type JsonRecord = Record; + +export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc'; + +export interface PlayerProgressionState { + level: number; + currentLevelXp: number; + totalXp: number; + xpToNextLevel: number; + pendingLevelUps?: number; + lastGrantedSource?: PlayerProgressionGrantSource | null; +} + +export interface PlayerExperienceGrantResult { + state: PlayerProgressionState; + grantedXp: number; + previousLevel: number; + nextLevel: number; + levelUps: number; + leveledUp: boolean; + reachedMaxLevel: boolean; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function clampNonNegativeInteger(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + return Math.max(0, Math.floor(value)); +} + +function clampLevel(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 1; + } + + return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value))); +} + +function normalizeLastGrantedSource(value: unknown) { + return value === 'quest' || value === 'hostile_npc' ? value : null; +} + +function resolveLevelFromTotalXp(totalXp: number) { + let resolvedLevel = 1; + + for (let level = 2; level <= MAX_PLAYER_LEVEL; level += 1) { + if (totalXp < getLevelBenchmark(level).cumulativeXpRequired) { + break; + } + + resolvedLevel = level; + } + + return resolvedLevel; +} + +function buildProgressionStateFromTotalXp( + totalXp: number, + lastGrantedSource: PlayerProgressionGrantSource | null = null, +): PlayerProgressionState { + const normalizedTotalXp = clampNonNegativeInteger(totalXp); + const level = resolveLevelFromTotalXp(normalizedTotalXp); + const benchmark = getLevelBenchmark(level); + + if (level >= MAX_PLAYER_LEVEL) { + return { + level, + currentLevelXp: 0, + totalXp: normalizedTotalXp, + xpToNextLevel: 0, + pendingLevelUps: 0, + lastGrantedSource, + }; + } + + return { + level, + currentLevelXp: Math.max( + 0, + normalizedTotalXp - benchmark.cumulativeXpRequired, + ), + totalXp: normalizedTotalXp, + xpToNextLevel: benchmark.xpToNextLevel, + pendingLevelUps: 0, + lastGrantedSource, + }; +} + +export function createInitialPlayerProgressionState(): PlayerProgressionState { + return buildProgressionStateFromTotalXp(0); +} + +export function normalizePlayerProgressionState( + value: unknown, +): PlayerProgressionState { + if (!isRecord(value)) { + return createInitialPlayerProgressionState(); + } + + const explicitLevel = clampLevel(value.level); + const explicitCurrentLevelXp = clampNonNegativeInteger(value.currentLevelXp); + const totalXp = clampNonNegativeInteger(value.totalXp); + const hasExplicitProgress = explicitLevel > 1 || explicitCurrentLevelXp > 0; + const derivedTotalXp = + totalXp > 0 || !hasExplicitProgress + ? totalXp + : getLevelBenchmark(explicitLevel).cumulativeXpRequired + + Math.min( + explicitCurrentLevelXp, + getLevelBenchmark(explicitLevel).xpToNextLevel, + ); + + return { + ...buildProgressionStateFromTotalXp( + derivedTotalXp, + normalizeLastGrantedSource(value.lastGrantedSource), + ), + pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps), + }; +} + +export function grantPlayerExperience( + value: unknown, + amount: number, + options: { + source: PlayerProgressionGrantSource; + }, +): PlayerExperienceGrantResult { + const currentState = normalizePlayerProgressionState(value); + const grantedXp = clampNonNegativeInteger(amount); + + if (grantedXp <= 0) { + return { + state: { + ...currentState, + pendingLevelUps: 0, + }, + grantedXp: 0, + previousLevel: currentState.level, + nextLevel: currentState.level, + levelUps: 0, + leveledUp: false, + reachedMaxLevel: currentState.level >= MAX_PLAYER_LEVEL, + }; + } + + const nextState = buildProgressionStateFromTotalXp( + currentState.totalXp + grantedXp, + options.source, + ); + const levelUps = Math.max(0, nextState.level - currentState.level); + + return { + state: { + ...nextState, + pendingLevelUps: 0, + }, + grantedXp, + previousLevel: currentState.level, + nextLevel: nextState.level, + levelUps, + leveledUp: levelUps > 0, + reachedMaxLevel: nextState.level >= MAX_PLAYER_LEVEL, + }; +} + +export function buildExperienceGrantResultText( + result: PlayerExperienceGrantResult, +) { + if (result.grantedXp <= 0) { + return ''; + } + + const parts = [`经验 +${result.grantedXp}`]; + + if (result.leveledUp) { + parts.push( + result.levelUps > 1 + ? `连升 ${result.levelUps} 级,达到 Lv.${result.nextLevel}` + : `升至 Lv.${result.nextLevel}`, + ); + } + + return `${parts.join(',')}。`; +} diff --git a/server-node/src/modules/quest/questProgressionService.ts b/server-node/src/modules/quest/questProgressionService.ts index ae190f43..8e6ef575 100644 --- a/server-node/src/modules/quest/questProgressionService.ts +++ b/server-node/src/modules/quest/questProgressionService.ts @@ -3,10 +3,16 @@ import { normalizeQuestLogEntries, } from '../../bridges/legacyQuestProgressBridge.js'; -export type QuestLogEntry = Parameters[0][number]; -export type QuestProgressSignal = Parameters[1]; +export type QuestLogEntry = Parameters< + typeof normalizeQuestLogEntries +>[0][number]; +export type QuestProgressSignal = Parameters< + typeof applyQuestProgressSignal +>[1]; -type QuestMutationFailureCode = 'quest_not_found' | 'quest_not_ready_to_turn_in'; +type QuestMutationFailureCode = + | 'quest_not_found' + | 'quest_not_ready_to_turn_in'; export type QuestMutationFailure = { ok: false; @@ -61,7 +67,9 @@ function buildSuccess( }; } -export function normalizeQuestEntries(quests: QuestLogEntry[]): QuestLogEntry[] { +export function normalizeQuestEntries( + quests: QuestLogEntry[], +): QuestLogEntry[] { return normalizeQuestLogEntries(quests); } @@ -116,10 +124,7 @@ export function getQuestForIssuer( ); } -export function acceptQuest( - quests: QuestLogEntry[], - quest: QuestLogEntry, -) { +export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) { const normalizedQuests = normalizeQuestEntries(quests); if (findQuestById(normalizedQuests, quest.id)) { return normalizedQuests; @@ -136,17 +141,26 @@ export function buildQuestAcceptResultText(quest: QuestLogEntry) { }`; } -export function buildQuestTurnInResultText(quest: QuestLogEntry) { +export function buildQuestTurnInResultText( + quest: QuestLogEntry, + options: { + experienceText?: string | null; + } = {}, +) { const normalizedQuest = normalizeQuestEntries([quest])[0]!; - const itemText = normalizedQuest.reward.items.map((item) => item.name).join('、'); + const itemText = + normalizedQuest.reward.items.map((item) => item.name).join('、') || '补给'; const intelText = normalizedQuest.reward.intel?.rumorText ? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}` : ''; const storyHintText = normalizedQuest.reward.storyHint ? ` ${normalizedQuest.reward.storyHint}` : ''; + const experienceText = options.experienceText?.trim() + ? ` ${options.experienceText.trim()}` + : ''; - return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和 ${itemText}${intelText}。${storyHintText}`; + return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和${itemText}${intelText}。${experienceText}${storyHintText}`; } export function isQuestReadyToClaim(quest: QuestLogEntry) { @@ -154,10 +168,7 @@ export function isQuestReadyToClaim(quest: QuestLogEntry) { return status === 'ready_to_turn_in' || status === 'completed'; } -export function markQuestTurnedIn( - quests: QuestLogEntry[], - questId: string, -) { +export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) { return quests.map((quest) => quest.id === questId ? normalizeQuestEntries([ diff --git a/server-node/src/modules/quest/questStoryActionService.ts b/server-node/src/modules/quest/questStoryActionService.ts index 23e1f447..42f35395 100644 --- a/server-node/src/modules/quest/questStoryActionService.ts +++ b/server-node/src/modules/quest/questStoryActionService.ts @@ -2,6 +2,10 @@ import type { RuntimeStoryActionRequest, RuntimeStoryPatch, } from '../../../../packages/shared/src/contracts/story.js'; +import { + buildExperienceGrantResultText, + grantPlayerExperience, +} from '../progression/playerProgressionService.js'; import { conflict, invalidRequest } from '../../errors.js'; import { appendStoryEngineCarrierMemory, @@ -37,6 +41,9 @@ type QuestStoryResolution = { type JsonRecord = Record; type RuntimeGameState = Parameters[0]; +type RuntimeQuestLogEntry = NonNullable< + ReturnType +>; type RuntimeNpcState = Parameters< typeof markNpcFirstMeaningfulContactResolved >[0]; @@ -83,11 +90,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,26 +163,38 @@ function ensureEncounterQuestContext(session: RuntimeSession) { function resolveQuestAcceptAction( session: RuntimeSession, + currentStory?: unknown, ): QuestStoryResolution { - const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); + const { state, encounter, npcKey, npcState } = + ensureEncounterQuestContext(session); const quests = Array.isArray(state.quests) ? state.quests : []; const existingQuest = getQuestForIssuer(quests, npcKey); if (existingQuest) { 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, + context: { + worldType: state.worldType, + recentStoryMoments: Array.isArray(state.storyHistory) + ? state.storyHistory.slice(-6) + : [], + playerCharacter: state.playerCharacter ?? null, + playerProgression: state.playerProgression ?? null, + }, + currentQuests: quests.map((item) => ({ + id: item.id, + issuerNpcId: item.issuerNpcId, + status: item.status, + })), + }); if (!quest) { throw conflict('当前场景缺少可落地的委托抓手,暂时无法接取任务。'); } @@ -165,7 +229,8 @@ function resolveQuestTurnInAction( session: RuntimeSession, request: RuntimeStoryActionRequest, ): QuestStoryResolution { - const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); + const { state, encounter, npcKey, npcState } = + ensureEncounterQuestContext(session); const quests = Array.isArray(state.quests) ? state.quests : []; const questId = readQuestId(request); const quest = @@ -186,11 +251,22 @@ function resolveQuestTurnInAction( } const nextAffinity = npcState.affinity + quest.reward.affinityBonus; + const experienceGrant = grantPlayerExperience( + state.playerProgression, + quest.reward.experience ?? 0, + { + source: 'quest', + }, + ); let nextState = { ...state, quests: turnInResult.nextQuests, + playerProgression: experienceGrant.state, playerCurrency: state.playerCurrency + quest.reward.currency, - playerInventory: addInventoryItems(state.playerInventory, quest.reward.items), + playerInventory: addInventoryItems( + state.playerInventory, + quest.reward.items, + ), npcStates: { ...state.npcStates, [npcKey]: { @@ -209,7 +285,9 @@ function resolveQuestTurnInAction( return { actionText: `向${encounter.npcName}交付委托`, - resultText: buildQuestTurnInResultText(quest), + resultText: buildQuestTurnInResultText(quest, { + experienceText: buildExperienceGrantResultText(experienceGrant), + }), patches: [ { type: 'npc_affinity_changed', @@ -228,10 +306,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/quest/runtimeQuestModule.ts b/server-node/src/modules/quest/runtimeQuestModule.ts index 6c5c6b72..66a97e2f 100644 --- a/server-node/src/modules/quest/runtimeQuestModule.ts +++ b/server-node/src/modules/quest/runtimeQuestModule.ts @@ -5,8 +5,14 @@ import { QUEST_REWARD_THEMES, QUEST_URGENCY_LEVELS, } from '../../../../packages/shared/src/contracts/story.js'; +import { + buildQuestIntentPrompt, + QUEST_INTENT_SYSTEM_PROMPT, +} from '../../prompts/questPrompts.js'; import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js'; +export { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT }; + export type QuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; export type QuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number]; export type QuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; @@ -32,6 +38,7 @@ export type QuestRewardItem = { export type QuestReward = { affinityBonus: number; currency: number; + experience?: number; items: QuestRewardItem[]; intel?: { rumorText: string; @@ -144,7 +151,11 @@ export type QuestOpportunity = { }; export type QuestProgressSignal = - | { kind: 'hostile_npc_defeated'; sceneId?: string | null; hostileNpcId: string } + | { + kind: 'hostile_npc_defeated'; + sceneId?: string | null; + hostileNpcId: string; + } | { kind: 'treasure_inspected'; sceneId?: string | null } | { kind: 'npc_spar_completed'; npcId: string } | { kind: 'npc_talk_completed'; npcId: string } @@ -183,6 +194,12 @@ export type QuestGenerationContext = { name?: string; title?: string; } | null; + playerProgression?: { + level?: number; + currentLevelXp?: number; + totalXp?: number; + xpToNextLevel?: number; + } | null; playerHp?: number; playerMaxHp?: number; playerMana?: number; @@ -248,6 +265,7 @@ type RuntimeStateLike = { currentScenePreset?: RuntimeSceneLike | null; storyHistory: Array<{ text: string }>; playerCharacter?: QuestGenerationContext['playerCharacter']; + playerProgression?: QuestGenerationContext['playerProgression']; playerHp?: number; playerMaxHp?: number; playerMana?: number; @@ -261,7 +279,11 @@ type RuntimeStateLike = { }; const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed']; -const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired']; +const TERMINAL_QUEST_STATUSES: QuestStatus[] = [ + 'turned_in', + 'failed', + 'expired', +]; function clampProgress(progress: number | undefined, requiredCount: number) { return Math.max(0, Math.min(requiredCount, Math.round(progress ?? 0))); @@ -335,7 +357,8 @@ function getScenePrimaryThreat( } const hostileNpc = - scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? null; + scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? + null; if (hostileNpc) { const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id; return { @@ -428,13 +451,71 @@ function buildRewardItems(params: { } } +function computeXpToNextLevel(level: number) { + const scale = Math.max(0, level - 1); + return 60 + 20 * scale + 8 * scale * scale; +} + +function resolveQuestTargetLevel(context?: QuestGenerationContext) { + const level = context?.playerProgression?.level; + if (typeof level !== 'number' || !Number.isFinite(level)) { + return 1; + } + + return Math.max(1, Math.floor(level)); +} + +function resolveQuestStepCountMultiplier(stepCount: number) { + if (stepCount <= 1) { + return 0.85; + } + + if (stepCount === 2) { + return 1; + } + + return 1.12; +} + +function resolveQuestNarrativeXpMultiplier(narrativeType: QuestNarrativeType) { + return narrativeType === 'trial' || narrativeType === 'bounty' ? 1.08 : 1; +} + +function resolveQuestUrgencyXpMultiplier(urgency: QuestUrgency) { + return urgency === 'high' ? 1.05 : 1; +} + +function buildQuestExperienceReward(params: { + context?: QuestGenerationContext; + narrativeType: QuestNarrativeType; + urgency: QuestUrgency; + stepCount: number; +}) { + const baseQuestXp = + computeXpToNextLevel(resolveQuestTargetLevel(params.context)) * 0.45; + + return Math.max( + 5, + Math.round( + (baseQuestXp * + resolveQuestStepCountMultiplier(params.stepCount) * + resolveQuestNarrativeXpMultiplier(params.narrativeType) * + resolveQuestUrgencyXpMultiplier(params.urgency)) / + 5, + ) * 5, + ); +} + function buildQuestReward(params: { issuerNpcId: string; issuerNpcName: string; worldType: string | null | undefined; rewardTheme: QuestRewardTheme; narrativeType: QuestNarrativeType; + urgency: QuestUrgency; + stepCount: number; scene: QuestSceneSnapshot | null; + context?: QuestGenerationContext; }) { const baseCurrency = params.rewardTheme === 'intel' @@ -447,10 +528,17 @@ function buildQuestReward(params: { const reward: QuestReward = { affinityBonus: - params.narrativeType === 'relationship' || params.narrativeType === 'trial' + params.narrativeType === 'relationship' || + params.narrativeType === 'trial' ? 14 : 12, currency: baseCurrency, + experience: buildQuestExperienceReward({ + context: params.context, + narrativeType: params.narrativeType, + urgency: params.urgency, + stepCount: params.stepCount, + }), items: buildRewardItems(params), storyHint: `${params.issuerNpcName}把和眼前局势最相关的收获留给了你。`, }; @@ -473,10 +561,12 @@ function buildRewardText( ) { const itemText = reward.items.map((item) => item.name).join('、') || '当前局势相关的补给'; + const experienceText = + (reward.experience ?? 0) > 0 ? `、经验 +${reward.experience}` : ''; const intelText = reward.intel?.rumorText ? `,以及情报“${reward.intel.rumorText}”` : ''; - return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency( + return `完成后可获得好感 +${reward.affinityBonus}${experienceText}、${formatCurrency( reward.currency, worldType, )}、${itemText}${intelText}。`; @@ -516,7 +606,7 @@ function buildPrimaryQuestStep(params: { : [threat.kind]; const chosenKind = preferredKinds.includes(threat.kind) ? threat.kind - : preferredKinds[0] ?? threat.kind; + : (preferredKinds[0] ?? threat.kind); if (chosenKind === 'inspect_treasure' && scene) { return { @@ -600,7 +690,9 @@ function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) { return title; } - return fallbackTitle.length <= 12 ? fallbackTitle : fallbackTitle.slice(0, 10); + return fallbackTitle.length <= 12 + ? fallbackTitle + : fallbackTitle.slice(0, 10); } function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { @@ -612,7 +704,8 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { Math.max(1, Math.round(step.requiredCount ?? 1)), ), })); - const activeStep = steps.find((step) => step.progress < step.requiredCount) ?? null; + const activeStep = + steps.find((step) => step.progress < step.requiredCount) ?? null; const terminal = isTerminalStatus(quest.status); const rewardReady = !terminal && !activeStep ? 'completed' : quest.status; @@ -620,10 +713,21 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { ...quest, title: normalizeQuestTitle(quest.title, quest.title), summary: quest.summary.trim() || quest.description.trim(), - progress: activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0, - objective: deriveObjectiveFromStep(activeStep ?? steps[steps.length - 1] ?? null), + progress: + activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0, + objective: deriveObjectiveFromStep( + activeStep ?? steps[steps.length - 1] ?? null, + ), status: terminal ? quest.status : rewardReady, completionNotified: quest.completionNotified ?? false, + reward: { + affinityBonus: Math.round(quest.reward.affinityBonus ?? 0), + currency: Math.max(0, Math.round(quest.reward.currency ?? 0)), + experience: Math.max(0, Math.round(quest.reward.experience ?? 0)), + items: quest.reward.items ?? [], + intel: quest.reward.intel, + storyHint: quest.reward.storyHint, + }, rewardText: quest.rewardText.trim(), steps, activeStepId: activeStep?.id ?? null, @@ -653,7 +757,9 @@ function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) { case 'npc_talk_completed': return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId; case 'scene_reached': - return step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId; + return ( + step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId + ); case 'item_delivered': return ( step.kind === 'deliver_item' && @@ -669,169 +775,6 @@ function getSignalProgressIncrement(signal: QuestProgressSignal) { return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1; } -function summarizeRecentStoryMoments(context: QuestGenerationContext) { - const moments = context.recentStoryMoments - .slice(-4) - .map((moment) => `- ${moment.text}`) - .join('\n'); - - return moments || '- 暂无近期剧情记录'; -} - -function summarizeCurrentQuests(context: QuestGenerationContext) { - const summary = context.currentQuestSummary - ?.map( - (quest) => - `- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`, - ) - .join('\n'); - - return summary || '- 当前没有进行中的任务'; -} - -function summarizeCompanions(context: QuestGenerationContext) { - const active = - context.activeCompanions?.map((companion) => companion.characterId).join('、') || - '无'; - const roster = - context.rosterCompanions?.map((companion) => companion.characterId).join('、') || - '无'; - return `当前同行角色:${active}\n队伍名册:${roster}`; -} - -function summarizePlayerState(context: QuestGenerationContext) { - const playerName = context.playerCharacter?.name ?? '未知角色'; - const playerTitle = context.playerCharacter?.title ?? '未知称号'; - const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`; - const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`; - const inventory = - context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无'; - - return [ - `玩家:${playerName}(${playerTitle})`, - `生命:${hp}`, - `灵力:${mana}`, - `背包快照:${inventory}`, - ].join('\n'); -} - -function summarizeScene( - scene: QuestSceneSnapshot | null, - context: QuestGenerationContext, -) { - const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无'; - const treasureHintCount = context.currentSceneTreasureHintCount ?? 0; - - return [ - `场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`, - `场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`, - `敌对角色 ID:${hostileNpcIds}`, - `宝藏线索数量:${treasureHintCount}`, - ].join('\n'); -} - -function summarizeActiveThreads(context: QuestGenerationContext) { - return context.activeThreadIds?.length - ? context.activeThreadIds.join('、') - : '暂无明确激活线程'; -} - -function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) { - const profile = context.issuerNarrativeProfile; - if (!profile) { - return '暂无额外叙事档案'; - } - - return [ - `公开面:${profile.publicMask ?? '暂无'}`, - `表层线:${profile.visibleLine ?? '暂无'}`, - `当前压力:${profile.immediatePressure ?? '暂无'}`, - profile.reactionHooks?.length - ? `反应钩子:${profile.reactionHooks.join('、')}` - : null, - ] - .filter(Boolean) - .join('\n'); -} - -function describeWorld(worldType: QuestGenerationContext['worldType']) { - switch (worldType) { - case 'WUXIA': - return '边城模板'; - case 'XIANXIA': - return '灵潮模板'; - case 'CUSTOM': - return '自定义世界'; - default: - return '未知世界'; - } -} - -export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。 -只返回 JSON,不要输出 Markdown。 - -输出结构: -{ - "intent": { - "title": "中文任务标题", - "description": "中文任务描述", - "summary": "中文短摘要", - "narrativeType": "bounty|escort|investigation|retrieval|relationship|trial", - "dramaticNeed": "string", - "issuerGoal": "string", - "playerHook": "string", - "worldReason": "string", - "recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"], - "urgency": "low|medium|high", - "intimacy": "transactional|cooperative|trust_based", - "rewardTheme": "currency|resource|relationship|intel|rare_item", - "followupHooks": ["string"] - } -} - -规则: -- 所有自然语言字段都必须使用中文。 -- 任务必须扎根于当前场景、发布者和近期剧情。 -- 不要编造奖励、ID、数量、状态或不受支持的规则变化。 -- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。 -- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。 -- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。 -- description 解释任务为什么在当前剧情里成立,避免纯规则说明。 -- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`; - -export function buildQuestIntentPrompt(params: { - context: QuestGenerationContext; - scene: QuestSceneSnapshot | null; - opportunity: QuestOpportunity; -}) { - const { context, scene, opportunity } = params; - const customWorldSummary = context.customWorldProfile - ? `${context.customWorldProfile.name ?? '自定义世界'}: ${ - context.customWorldProfile.summary ?? '暂无摘要' - }` - : '无'; - - return [ - `世界:${describeWorld(context.worldType)}`, - `自定义世界摘要:${customWorldSummary}`, - `发布角色:${context.issuerNpcName}(${context.issuerNpcId})`, - `发布者身份:${context.issuerNpcContext || '暂无'}`, - `发布者好感:${context.issuerAffinity ?? 0}`, - `发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`, - `发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`, - `当前激活线程:${summarizeActiveThreads(context)}`, - `发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`, - `当前遭遇类型:${context.encounterKind ?? '无'}`, - summarizeScene(scene, context), - summarizePlayerState(context), - summarizeCompanions(context), - `当前任务机会:${opportunity.reason}`, - `当前任务列表:\n${summarizeCurrentQuests(context)}`, - `近期剧情片段:\n${summarizeRecentStoryMoments(context)}`, - '现在请基于这次具体局势,生成一个自然生长出来的任务意图。', - ].join('\n\n'); -} - export function buildQuestGenerationContextFromState(params: { state: RuntimeStateLike; encounter: RuntimeEncounterLike; @@ -858,7 +801,8 @@ export function buildQuestGenerationContextFromState(params: { issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0, { recruited: issuerState?.recruited, }), - activeThreadIds: state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [], + activeThreadIds: + state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [], encounterKind: encounter.kind ?? 'npc', currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0, @@ -867,6 +811,7 @@ export function buildQuestGenerationContextFromState(params: { .map((npc) => npc.monsterPresetId ?? npc.id), recentStoryMoments: state.storyHistory.slice(-6), playerCharacter: state.playerCharacter ?? null, + playerProgression: state.playerProgression ?? null, playerHp: state.playerHp, playerMaxHp: state.playerMaxHp, playerMana: state.playerMana, @@ -888,15 +833,21 @@ export function findQuestById(quests: QuestLogEntry[], questId: string) { return quests.find((quest) => quest.id === questId) ?? null; } -export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string) { +export function getQuestForIssuer( + quests: QuestLogEntry[], + issuerNpcId: string, +) { return ( normalizeQuestLogEntries(quests).find( - (quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + (quest) => + quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', ) ?? null ); } -export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity { +export function evaluateQuestOpportunity( + params: QuestPreviewRequest, +): QuestOpportunity { const { issuerNpcId, scene, currentQuests = [] } = params; if (!scene) { return { @@ -907,7 +858,8 @@ export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOppo if ( currentQuests.some( - (quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + (quest) => + quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', ) ) { return { @@ -1045,7 +997,10 @@ export function compileQuestIntentToQuest( worldType: params.worldType, rewardTheme: intent.rewardTheme, narrativeType: intent.narrativeType, + urgency: intent.urgency, + stepCount: steps.length, scene: params.scene, + context: params.context, }); const rewardText = buildRewardText(reward, params.worldType); diff --git a/server-node/src/modules/runtime-item/runtimeItemModule.ts b/server-node/src/modules/runtime-item/runtimeItemModule.ts index 9c24e2d0..b03938c5 100644 --- a/server-node/src/modules/runtime-item/runtimeItemModule.ts +++ b/server-node/src/modules/runtime-item/runtimeItemModule.ts @@ -2,6 +2,12 @@ import { RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, RUNTIME_ITEM_TONE_VALUES, } from '../../../../packages/shared/src/contracts/story.js'; +import { + buildRuntimeItemIntentPromptText, + RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, +} from '../../prompts/runtimeItemPrompts.js'; + +export { RUNTIME_ITEM_INTENT_SYSTEM_PROMPT }; export type RuntimeItemFunctionalBias = (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; @@ -573,48 +579,16 @@ function describePlan( ].join('\n'); } -export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。 -你只返回 JSON,不要输出 Markdown、解释或代码块。 - -输出结构: -{ - "intents": [ - { - "shortNameSeed": "中文短种子", - "sourcePhrase": "中文来源短语", - "reasonToAppear": "中文出现理由", - "relationHooks": ["中文关系钩子"], - "desiredBuildTags": ["中文 build 标签"], - "desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"], - "tone": "grim|mysterious|martial|ritual|survival", - "visibleClue": "玩家第一眼能抓到的痕迹", - "witnessMark": "它见证过什么的使用痕", - "unfinishedBusiness": "背后仍未结清的问题", - "hiddenHook": "更深一层但别直接讲穿的钩子", - "reactionHooks": ["以后谁会对它起反应"], - "namingPattern": "命名范式建议" - } - ] -} - -规则: -- intents 数量必须与输入物品数量完全一致,顺序也必须一致。 -- 所有自然语言字段都必须使用中文。 -- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。 -- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。 -- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。 -- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`; - export function buildRuntimeItemIntentPrompt(params: { context: RuntimeItemGenerationContext; plans: RuntimeItemPlan[]; }) { - return [ - `生成渠道:${params.context.generationChannel}`, - `以下每个物品都需要给出一条可编译的运行时物品意图。`, - ...params.plans.map((plan, index) => describePlan(params.context, plan, index)), - '请严格返回 JSON。', - ].join('\n\n'); + return buildRuntimeItemIntentPromptText({ + generationChannel: params.context.generationChannel, + planBlocks: params.plans.map((plan, index) => + describePlan(params.context, plan, index), + ), + }); } function buildBaseRuntimeContext(params: { diff --git a/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts b/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts index 23fb56f8..577e50e0 100644 --- a/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts +++ b/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts @@ -67,6 +67,7 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor rewardText: '完成后可领取测试奖励。', reward: { currency: 10, + experience: 0, items: [], }, steps: [ @@ -128,6 +129,8 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor assert.equal(snapshot.gameState.playerMaxMana, 95); assert.equal(snapshot.gameState.playerMana, 22); assert.equal(snapshot.gameState.playerCurrency, 160); + assert.equal(snapshot.gameState.playerProgression.level, 1); + assert.equal(snapshot.gameState.playerProgression.totalXp, 0); assert.deepEqual(snapshot.gameState.roster, []); assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []); assert.equal( @@ -200,7 +203,16 @@ test('runtime snapshot hydration backfills starter loadout when legacy saves omi assert.ok(snapshot); assert.equal(snapshot.gameState.playerMaxHp, 208); assert.equal(snapshot.gameState.playerMaxMana, 1009); - assert.equal(snapshot.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon'); - assert.equal(snapshot.gameState.playerEquipment.armor?.id, 'starter:hero:armor'); - assert.equal(snapshot.gameState.playerEquipment.relic?.id, 'starter:hero:relic'); + assert.equal( + snapshot.gameState.playerEquipment.weapon?.id, + 'starter:hero:weapon', + ); + assert.equal( + snapshot.gameState.playerEquipment.armor?.id, + 'starter:hero:armor', + ); + assert.equal( + snapshot.gameState.playerEquipment.relic?.id, + 'starter:hero:relic', + ); }); diff --git a/server-node/src/modules/runtime/runtimeSnapshotHydration.ts b/server-node/src/modules/runtime/runtimeSnapshotHydration.ts index 583ec99d..7e81f92d 100644 --- a/server-node/src/modules/runtime/runtimeSnapshotHydration.ts +++ b/server-node/src/modules/runtime/runtimeSnapshotHydration.ts @@ -1,5 +1,6 @@ import { jsonClone } from '../../http.js'; import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; +import { normalizePlayerProgressionState } from '../progression/playerProgressionService.js'; import { normalizeQuestEntries } from '../quest/questProgressionService.js'; import { createEmptyEquipmentLoadout, @@ -61,9 +62,7 @@ function clampNonNegativeInteger(value: unknown) { } function normalizeBottomTab(value: unknown) { - return value === 'character' || value === 'inventory' - ? value - : 'adventure'; + return value === 'character' || value === 'inventory' ? value : 'adventure'; } function buildSaveMigrationManifest() { @@ -135,9 +134,7 @@ function normalizeRuntimeStats( ? Math.max(0, rawStats.playTimeMs) : 0, lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null, - hostileNpcsDefeated: clampNonNegativeInteger( - rawStats.hostileNpcsDefeated, - ), + hostileNpcsDefeated: clampNonNegativeInteger(rawStats.hostileNpcsDefeated), questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted), itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed), scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled), @@ -146,28 +143,30 @@ function normalizeRuntimeStats( function normalizeCharacterChats(value: unknown) { return Object.fromEntries( - Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => { - const rawRecord = isRecord(record) ? record : {}; + Object.entries(isRecord(value) ? value : {}).map( + ([characterId, record]) => { + const rawRecord = isRecord(record) ? record : {}; - return [ - characterId, - { - history: readArray(rawRecord.history) - .filter( - (turn) => - isRecord(turn) && - typeof turn.text === 'string' && - (turn.speaker === 'player' || turn.speaker === 'character'), - ) - .map((turn) => ({ - speaker: turn.speaker, - text: turn.text, - })), - summary: readString(rawRecord.summary), - updatedAt: readString(rawRecord.updatedAt) || null, - }, - ]; - }), + return [ + characterId, + { + history: readArray(rawRecord.history) + .filter( + (turn) => + isRecord(turn) && + typeof turn.text === 'string' && + (turn.speaker === 'player' || turn.speaker === 'character'), + ) + .map((turn) => ({ + speaker: turn.speaker, + text: turn.text, + })), + summary: readString(rawRecord.summary), + updatedAt: readString(rawRecord.updatedAt) || null, + }, + ]; + }, + ), ); } @@ -194,14 +193,18 @@ function dedupeCompanions(value: unknown) { return readArray(value) .map((entry) => normalizeCompanionState(entry)) - .filter((entry): entry is NonNullable> => { - if (!entry || seenNpcIds.has(entry.npcId)) { - return false; - } + .filter( + ( + entry, + ): entry is NonNullable> => { + if (!entry || seenNpcIds.has(entry.npcId)) { + return false; + } - seenNpcIds.add(entry.npcId); - return true; - }); + seenNpcIds.add(entry.npcId); + return true; + }, + ); } function normalizeRoster( @@ -258,9 +261,8 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) { ) ? ( ( - ( - customWorldProfile.ownedSettingLayers as JsonRecord - ).ruleProfile as JsonRecord + (customWorldProfile.ownedSettingLayers as JsonRecord) + .ruleProfile as JsonRecord ).economyProfile as JsonRecord ).initialCurrency : undefined, @@ -270,7 +272,9 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) { return Math.max(0, Math.round(customWorldInitialCurrency)); } - return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160; + return readString(gameState.worldType).toUpperCase() === 'XIANXIA' + ? 140 + : 160; } function normalizeEquipmentLoadout(value: unknown) { @@ -319,7 +323,9 @@ function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) { return [...tags]; } -function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] { +function getLegacyCharacterEquipment( + character: JsonRecord, +): LegacyCharacterEquipmentItem[] { const equipmentById: Record = { 'sword-princess': [ { slot: '武器', item: '王庭剑', rarity: '稀有' }, @@ -495,7 +501,9 @@ function normalizeGameState(gameState: unknown) { ); const resolvedEquipment = normalizeEquipmentLoadout(rawState.playerEquipment) ?? - (playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null); + (playerCharacter + ? buildLegacyStarterEquipmentLoadout(playerCharacter) + : null); const baseResourceProfile = playerCharacter ? buildCharacterResourceProfile(playerCharacter) : null; @@ -512,14 +520,18 @@ function normalizeGameState(gameState: unknown) { const normalizedCommonState = { ...rawStateWithoutEquipment, customWorldProfile: - isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null - ? rawState.customWorldProfile ?? null + isRecord(rawState.customWorldProfile) || + rawState.customWorldProfile === null + ? (rawState.customWorldProfile ?? null) : null, runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, { isActiveRun: Boolean( rawState.playerCharacter && rawState.currentScene === 'Story', ), }), + playerProgression: normalizePlayerProgressionState( + rawState.playerProgression, + ), storyEngineMemory, chapterState: rawState.chapterState ?? @@ -530,7 +542,7 @@ function normalizeGameState(gameState: unknown) { rawState.campaignState ?? (isRecord(storyEngineMemory.campaignState) ? storyEngineMemory.campaignState - : storyEngineMemory.campaignState ?? null), + : (storyEngineMemory.campaignState ?? null)), activeScenarioPackId: readString(rawState.activeScenarioPackId) || readString( @@ -623,7 +635,9 @@ function normalizeGameState(gameState: unknown) { }; } -export function normalizeSavedSnapshotPayload(snapshot: T) { +export function normalizeSavedSnapshotPayload( + snapshot: T, +) { return { ...snapshot, bottomTab: normalizeBottomTab(snapshot.bottomTab), 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 02bfb3d7..f307a000 100644 --- a/server-node/src/modules/story/runtimeSession.ts +++ b/server-node/src/modules/story/runtimeSession.ts @@ -1,11 +1,18 @@ import type { + RuntimeStoryChoicePayload, RuntimeStoryEncounterViewModel, + RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryViewModel, Task5RuntimeOptionScope, } from '../../../../packages/shared/src/contracts/story.js'; import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js'; import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; +import { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js'; +import { + isInventoryItemUsable, + resolveInventoryItemUseEffect, +} from '../runtime/runtimeInventoryEffectsModule.js'; type JsonRecord = Record; type StoryHistoryRole = 'action' | 'result'; @@ -62,6 +69,58 @@ export type RuntimeCompanion = { joinedAtAffinity: number; }; +type RuntimePlayerAttributes = { + strength: number; + agility: number; + intelligence: number; + spirit: number; +}; + +type RuntimePlayerSkill = { + id: string; + name: string; + damage: number; + manaCost: number; + cooldownTurns: number; + buildBuffs?: Array<{ + id: string; + sourceType: 'skill' | 'item' | 'forge'; + sourceId: string; + name: string; + tags: string[]; + durationTurns: number; + maxStacks?: number; + }>; +}; + +type RuntimePlayerCharacter = { + attributes: RuntimePlayerAttributes; + skills: RuntimePlayerSkill[]; +}; + +type RuntimeBattleItemUseProfile = { + hpRestore?: number; + manaRestore?: number; + cooldownReduction?: number; + buildBuffs?: Array<{ + id: string; + sourceType: 'item'; + sourceId: string; + name: string; + tags: string[]; + durationTurns: number; + }>; +}; + +type RuntimeBattleInventoryItem = { + id: string; + name: string; + quantity: number; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + tags: string[]; + useProfile?: RuntimeBattleItemUseProfile; +}; + export type RuntimeSession = { sessionId: string; runtimeVersion: number; @@ -97,6 +156,8 @@ const STORY_FUNCTION_IDS = new Set([ ]); const COMBAT_FUNCTION_IDS = new Set([ + 'battle_attack_basic', + 'battle_use_skill', 'battle_all_in_crush', 'battle_escape_breakout', 'battle_feint_step', @@ -104,6 +165,7 @@ const COMBAT_FUNCTION_IDS = new Set([ 'battle_guard_break', 'battle_probe_pressure', 'battle_recover_breath', + 'inventory_use', ]); const NPC_FUNCTION_IDS = new Set([ @@ -120,8 +182,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: { @@ -164,6 +225,16 @@ const FUNCTION_DEFINITIONS: Record = { detailText: '收束当前遭遇并切往下一段场景流程。', scope: 'story', }, + battle_attack_basic: { + actionText: '普通攻击', + detailText: '本回合执行一次不耗蓝的基础攻击。', + scope: 'combat', + }, + battle_use_skill: { + actionText: '释放技能', + detailText: '直接执行一个具体技能,不再包装成抽象战术动作。', + scope: 'combat', + }, battle_all_in_crush: { actionText: '正面强压', detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。', @@ -195,8 +266,13 @@ const FUNCTION_DEFINITIONS: Record = { scope: 'combat', }, battle_recover_breath: { - actionText: '边守边调息', - detailText: '优先回稳资源,但仍可能吃到轻量反击。', + actionText: '恢复', + detailText: '直接恢复资源,并推进本回合冷却。', + scope: 'combat', + }, + inventory_use: { + actionText: '使用物品', + detailText: '战斗中优先执行一个可立即结算的消耗品。', scope: 'combat', }, npc_chat: { @@ -331,13 +407,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, ), }; } @@ -381,7 +460,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, @@ -414,7 +496,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; } @@ -430,6 +515,393 @@ function normalizeHostileNpcs(value: unknown) { .filter((entry): entry is RuntimeHostileNpc => Boolean(entry)); } +function normalizePlayerSkill(value: unknown): RuntimePlayerSkill | null { + const rawSkill = isObject(value) ? value : null; + if (!rawSkill) { + return null; + } + + const id = readString(rawSkill.id); + const name = readString(rawSkill.name, id); + if (!id || !name) { + return null; + } + + return { + id, + name, + damage: Math.max(1, Math.round(readNumber(rawSkill.damage, 1))), + manaCost: Math.max(0, Math.round(readNumber(rawSkill.manaCost, 0))), + cooldownTurns: Math.max( + 0, + Math.round(readNumber(rawSkill.cooldownTurns, 0)), + ), + buildBuffs: readArray(rawSkill.buildBuffs) + .map((entry) => { + const rawBuff = isObject(entry) ? entry : null; + if (!rawBuff) { + return null; + } + + const buffId = readString(rawBuff.id); + const sourceId = readString(rawBuff.sourceId); + const name = readString(rawBuff.name, buffId); + if (!buffId || !sourceId || !name) { + return null; + } + + const sourceType = readString(rawBuff.sourceType, 'skill'); + return { + id: buffId, + sourceType: + sourceType === 'item' || sourceType === 'forge' + ? sourceType + : 'skill', + sourceId, + name, + tags: readArray(rawBuff.tags).filter( + (tag): tag is string => + typeof tag === 'string' && tag.trim().length > 0, + ), + durationTurns: Math.max( + 1, + Math.round(readNumber(rawBuff.durationTurns, 1)), + ), + maxStacks: + typeof rawBuff.maxStacks === 'number' && + Number.isFinite(rawBuff.maxStacks) + ? Math.max(1, Math.round(rawBuff.maxStacks)) + : undefined, + } satisfies NonNullable[number]; + }) + .filter( + ( + entry, + ): entry is NonNullable[number] => + Boolean(entry), + ), + }; +} + +function normalizePlayerCharacter( + value: unknown, +): RuntimePlayerCharacter | null { + const rawCharacter = isObject(value) ? value : null; + const rawAttributes = isObject(rawCharacter?.attributes) + ? rawCharacter.attributes + : null; + if (!rawCharacter || !rawAttributes) { + return null; + } + + return { + attributes: { + strength: Math.max(0, Math.round(readNumber(rawAttributes.strength, 0))), + agility: Math.max(0, Math.round(readNumber(rawAttributes.agility, 0))), + intelligence: Math.max( + 0, + Math.round(readNumber(rawAttributes.intelligence, 0)), + ), + spirit: Math.max(0, Math.round(readNumber(rawAttributes.spirit, 0))), + }, + skills: readArray(rawCharacter.skills) + .map((entry) => normalizePlayerSkill(entry)) + .filter((entry): entry is RuntimePlayerSkill => Boolean(entry)), + }; +} + +function normalizeBattleInventoryItem( + value: unknown, +): RuntimeBattleInventoryItem | null { + const rawItem = isObject(value) ? value : null; + if (!rawItem) { + return null; + } + + const id = readString(rawItem.id); + const name = readString(rawItem.name, id); + if (!id || !name) { + return null; + } + + const rarity = readString(rawItem.rarity, 'common'); + const normalizedRarity = + rarity === 'legendary' || + rarity === 'epic' || + rarity === 'rare' || + rarity === 'uncommon' + ? rarity + : 'common'; + const useProfile = isObject(rawItem.useProfile) + ? (cloneJson(rawItem.useProfile) as RuntimeBattleItemUseProfile) + : undefined; + + return { + id, + name, + quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))), + rarity: normalizedRarity, + tags: readArray(rawItem.tags).filter( + (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, + ), + useProfile, + }; +} + +export function getPlayerCharacter(session: RuntimeSession) { + return normalizePlayerCharacter(session.rawGameState.playerCharacter); +} + +export function getPlayerSkillCooldowns(session: RuntimeSession) { + const rawCooldowns = isObject(session.rawGameState.playerSkillCooldowns) + ? session.rawGameState.playerSkillCooldowns + : {}; + + return Object.fromEntries( + Object.entries(rawCooldowns).map(([skillId, turns]) => [ + skillId, + Math.max(0, Math.round(readNumber(turns, 0))), + ]), + ) as Record; +} + +function getBattleInventoryItems(session: RuntimeSession) { + return readArray(session.rawGameState.playerInventory) + .map((entry) => normalizeBattleInventoryItem(entry)) + .filter((entry): entry is RuntimeBattleInventoryItem => Boolean(entry)); +} + +function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) { + return Math.max( + 8, + Math.round( + character.attributes.strength * 0.85 + + character.attributes.agility * 0.45, + ), + ); +} + +function buildBattleDisabledOption(params: { + session: RuntimeSession; + functionId: string; + actionText?: string; + detailText?: string; + reason: string; + payload?: RuntimeStoryChoicePayload; +}) { + return buildOptionView(params.session, params.functionId, { + actionText: params.actionText, + detailText: params.detailText, + payload: params.payload, + disabled: true, + reason: params.reason, + }); +} + +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>, +) { + const parts = [ + effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null, + effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null, + effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null, + effect.buildBuffs.length > 0 + ? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}` + : null, + ].filter(Boolean); + + return parts.join(' / ') || '立即结算一次物品效果'; +} + +function pickPreferredBattleItem(session: RuntimeSession) { + const character = getPlayerCharacter(session); + if (!character) { + return null; + } + + 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); + + 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; + + 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) { + const character = getPlayerCharacter(session); + if (!character) { + return []; + } + + const cooldowns = getPlayerSkillCooldowns(session); + + return character.skills.map((skill) => { + const remainingCooldown = cooldowns[skill.id] ?? 0; + const damage = resolvePlayerOutgoingDamageResult( + session.rawGameState as Parameters< + typeof resolvePlayerOutgoingDamageResult + >[0], + character, + skill.damage, + 1, + `runtime-skill-preview:${skill.id}`, + ).damage; + const detailText = [ + `耗蓝 ${skill.manaCost}`, + `伤害 ${damage}`, + `冷却 ${skill.cooldownTurns}`, + ].join(' / '); + + if (remainingCooldown > 0) { + return buildBattleDisabledOption({ + session, + functionId: 'battle_use_skill', + actionText: skill.name, + detailText, + payload: { skillId: skill.id }, + reason: `冷却中,还需 ${remainingCooldown} 回合`, + }); + } + + if (skill.manaCost > session.playerMana) { + return buildBattleDisabledOption({ + session, + functionId: 'battle_use_skill', + actionText: skill.name, + detailText, + payload: { skillId: skill.id }, + reason: '灵力不足', + }); + } + + return buildOptionView(session, 'battle_use_skill', { + actionText: skill.name, + detailText, + payload: { skillId: skill.id }, + }); + }); +} + +function buildBattleActionOptions(session: RuntimeSession) { + const character = getPlayerCharacter(session); + const itemCandidate = pickPreferredBattleItem(session); + const basicAttackDamage = character + ? resolvePlayerOutgoingDamageResult( + session.rawGameState as Parameters< + typeof resolvePlayerOutgoingDamageResult + >[0], + character, + buildBasicAttackBaseDamage(character), + 1, + 'runtime-basic-attack-preview', + ).damage + : 0; + + return [ + buildOptionView(session, 'battle_attack_basic', { + detailText: + basicAttackDamage > 0 + ? `不耗蓝 / 伤害 ${basicAttackDamage}` + : '不耗蓝的基础攻击', + }), + buildOptionView(session, 'battle_recover_breath', { + actionText: '恢复', + detailText: '回血 12 / 回蓝 9 / 冷却 -1', + }), + itemCandidate + ? 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(session, 'battle_escape_breakout'), + ] satisfies RuntimeStoryOptionView[]; +} + export function getEncounterKey(encounter: RuntimeEncounter) { return encounter.id || encounter.npcName; } @@ -462,7 +934,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, @@ -540,6 +1015,7 @@ export function setEncounterNpcState( } function buildOptionView( + session: RuntimeSession, functionId: string, overrides: Partial = {}, ): RuntimeStoryOptionView { @@ -550,6 +1026,7 @@ function buildOptionView( actionText: functionId, detailText: '', scope: 'story', + interaction: buildOptionInteraction(session, functionId), ...overrides, }; } @@ -559,6 +1036,7 @@ function buildOptionView( actionText: definition.actionText, detailText: definition.detailText, scope: definition.scope, + interaction: buildOptionInteraction(session, functionId), ...overrides, }; } @@ -613,53 +1091,49 @@ function hasGiftablePlayerInventory(session: RuntimeSession) { export function buildAvailableOptions(session: RuntimeSession) { if (session.inBattle) { - return [ - 'battle_probe_pressure', - 'battle_guard_break', - 'battle_feint_step', - 'battle_finisher_window', - 'battle_all_in_crush', - 'battle_recover_breath', - 'battle_escape_breakout', - ].map((functionId) => buildOptionView(functionId)); + return buildBattleActionOptions(session); } if (session.currentEncounter?.kind === 'npc') { 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 ( @@ -667,14 +1141,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 ? { @@ -686,15 +1161,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'), ]; } @@ -705,7 +1180,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( @@ -784,6 +1259,10 @@ 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, visuals: { playerAnimation: 'idle', playerMoveMeters: 0, @@ -813,8 +1292,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 97409ce8..337f7b13 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,55 +459,138 @@ 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, entry.token, { - worldType: 'WUXIA', - storyHistory: [], - currentEncounter: { - kind: 'npc', - id: 'npc_bandit_01', - npcName: '断桥匪首', - npcDescription: '手提短刀的拦路匪徒', - context: '桥口劫匪', - hostile: true, - }, - npcInteractionActive: false, - sceneHostileNpcs: [ - { + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', id: 'npc_bandit_01', - name: '断桥匪首', - hp: 12, - maxHp: 28, - description: '桥口劫匪', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + hostile: true, }, - ], - inBattle: true, - playerHp: 42, - playerMaxHp: 50, - playerMana: 20, - playerMaxMana: 20, - npcStates: { - npc_bandit_01: { - affinity: -12, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, + npcInteractionActive: false, + sceneHostileNpcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hp: 12, + maxHp: 28, + description: '桥口劫匪', + }, + ], + inBattle: true, + playerHp: 42, + playerMaxHp: 50, + playerMana: 20, + playerMaxMana: 20, + playerSkillCooldowns: {}, + npcStates: { + npc_bandit_01: { + affinity: -12, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, }, - }, - companions: [], - currentNpcBattleMode: 'fight', - currentNpcBattleOutcome: null, - }); + currentNpcBattleMode: 'fight', + currentNpcBattleOutcome: null, + }), + ); const response = await httpRequest( `${baseUrl}/api/runtime/story/actions/resolve`, @@ -455,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( @@ -464,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; @@ -482,13 +661,524 @@ 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', + ); + }); +}); + +test('runtime story state exposes the single-action combat option pool with runtime payload metadata', async () => { + await withTestServer('combat-state-options', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_combat_state', 'secret123'); + const playerCharacter = { + ...requirePlayerCharacter(), + skills: [ + { + id: 'slash', + name: '试锋斩', + animation: 'attack', + damage: 18, + manaCost: 4, + cooldownTurns: 2, + range: 1, + style: 'steady', + }, + { + id: 'wind-step', + name: '断风步', + animation: 'attack', + damage: 12, + manaCost: 2, + cooldownTurns: 0, + range: 1, + style: 'steady', + }, + ], + }; + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + playerCharacter, + currentEncounter: { + kind: 'npc', + id: 'npc_bandit_01', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + hostile: true, + }, + npcInteractionActive: false, + sceneHostileNpcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hp: 36, + maxHp: 36, + description: '桥口劫匪', + }, + ], + inBattle: true, + playerMana: 6, + playerMaxMana: 16, + playerSkillCooldowns: { + slash: 2, + 'wind-step': 0, + }, + playerInventory: [ + { + id: 'focus-tonic', + category: '消耗品', + name: '凝神灵液', + quantity: 1, + rarity: 'rare', + tags: ['mana'], + useProfile: { + manaRestore: 6, + }, + }, + ], + npcStates: { + npc_bandit_01: { + affinity: -12, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + currentNpcBattleMode: 'fight', + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/state/runtime-main`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const payload = (await response.json()) as { + viewModel: { + status: { + inBattle: boolean; + }; + availableOptions: Array<{ + functionId: string; + actionText: string; + payload?: { + skillId?: string; + itemId?: string; + }; + disabled?: boolean; + reason?: string; + }>; + }; + }; + + assert.equal(response.status, 200); + assert.equal(payload.viewModel.status.inBattle, true); + assert.deepEqual( + payload.viewModel.availableOptions.map((option) => option.functionId), + [ + 'battle_attack_basic', + 'battle_recover_breath', + 'inventory_use', + 'battle_use_skill', + 'battle_use_skill', + 'battle_escape_breakout', + ], + ); + + const itemOption = payload.viewModel.availableOptions[2]; + assert.equal(itemOption?.functionId, 'inventory_use'); + assert.equal(itemOption?.payload?.itemId, 'focus-tonic'); + assert.equal(itemOption?.disabled, undefined); + + const slashOption = payload.viewModel.availableOptions[3]; + assert.equal(slashOption?.actionText, '试锋斩'); + assert.equal(slashOption?.payload?.skillId, 'slash'); + assert.equal(slashOption?.disabled, true); + assert.match(slashOption?.reason ?? '', /冷却中/u); + + const windStepOption = payload.viewModel.availableOptions[4]; + assert.equal(windStepOption?.actionText, '断风步'); + assert.equal(windStepOption?.payload?.skillId, 'wind-step'); + assert.equal(windStepOption?.disabled, undefined); + }); +}); + +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 playerCharacter = { + ...requirePlayerCharacter(), + skills: [ + { + id: 'slash', + name: '试锋斩', + animation: 'attack', + damage: 18, + manaCost: 4, + cooldownTurns: 2, + range: 1, + style: 'steady', + buildBuffs: [ + { + id: 'slash:buff', + sourceType: 'skill', + sourceId: 'slash', + name: '试锋余势', + tags: ['快剑'], + durationTurns: 2, + }, + ], + }, + ], + }; + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + playerCharacter, + currentEncounter: { + kind: 'npc', + id: 'npc_bandit_01', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + hostile: true, + }, + npcInteractionActive: false, + sceneHostileNpcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hp: 80, + maxHp: 80, + description: '桥口劫匪', + }, + ], + inBattle: true, + playerHp: 32, + playerMaxHp: 40, + playerMana: 9, + playerMaxMana: 16, + playerSkillCooldowns: {}, + activeBuildBuffs: [], + npcStates: { + npc_bandit_01: { + affinity: -12, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + currentNpcBattleMode: 'fight', + }), + ); + + 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: 'battle_use_skill', + payload: { + skillId: 'slash', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + serverVersion: number; + viewModel: { + player: { + mana: number; + }; + status: { + inBattle: boolean; + }; + availableOptions: Array<{ + functionId: string; + actionText: string; + payload?: { + skillId?: string; + }; + disabled?: boolean; + reason?: string; + }>; + }; + presentation: { + resultText: string; + storyText: string; + battle: { + outcome: string; + damageDealt: number; + } | null; + }; + snapshot: { + gameState: { + playerMana: number; + playerSkillCooldowns: Record; + activeBuildBuffs: Array<{ + id: string; + }>; + }; + }; + patches: Array<{ + type: string; + functionId?: string; + }>; + }; + + assert.equal(response.status, 200); + 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.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.ok( + payload.patches.some( + (patch) => + patch.type === 'battle_resolved' && + patch.functionId === 'battle_use_skill', + ), + ); + + const skillOption = payload.viewModel.availableOptions.find( + (option) => + option.functionId === 'battle_use_skill' && + option.payload?.skillId === 'slash', + ); + assert.ok(skillOption); + assert.equal(skillOption.actionText, '试锋斩'); + assert.equal(skillOption.disabled, true); + assert.match(skillOption.reason ?? '', /冷却中/u); + }); +}); + +test('runtime story actions resolve inventory_use as a single ongoing combat turn', async () => { + await withTestServer('combat-use-item', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_combat_item', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_bandit_01', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + hostile: true, + }, + npcInteractionActive: false, + sceneHostileNpcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hp: 80, + maxHp: 80, + description: '桥口劫匪', + }, + ], + inBattle: true, + playerHp: 20, + playerMaxHp: 40, + playerMana: 4, + playerMaxMana: 16, + playerSkillCooldowns: { + slash: 2, + }, + activeBuildBuffs: [], + playerInventory: [ + { + id: 'focus-tonic', + category: '消耗品', + name: '凝神灵液', + quantity: 1, + rarity: 'rare', + tags: ['mana', 'healing'], + useProfile: { + hpRestore: 12, + manaRestore: 6, + cooldownReduction: 1, + buildBuffs: [ + { + id: 'focus-tonic:buff', + sourceType: 'item', + sourceId: 'focus-tonic', + name: '凝神增益', + tags: ['快剑'], + durationTurns: 2, + }, + ], + }, + }, + ], + npcStates: { + npc_bandit_01: { + affinity: -12, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + currentNpcBattleMode: 'fight', + }), + ); + + 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: 'inventory_use', + payload: { + itemId: 'focus-tonic', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + serverVersion: number; + viewModel: { + player: { + hp: number; + mana: number; + }; + status: { + inBattle: boolean; + }; + availableOptions: Array<{ + functionId: string; + actionText: string; + payload?: { + skillId?: string; + itemId?: string; + }; + disabled?: boolean; + reason?: string; + }>; + }; + presentation: { + resultText: string; + storyText: string; + battle: { + outcome: string; + damageTaken: number; + } | null; + }; + snapshot: { + gameState: { + playerHp: number; + playerMana: number; + playerSkillCooldowns: Record; + runtimeStats: { + itemsUsed: number; + }; + playerInventory: unknown[]; + activeBuildBuffs: Array<{ + id: string; + }>; + }; + }; + patches: Array<{ + type: string; + functionId?: string; + }>; + }; + + assert.equal(response.status, 200); + assert.equal(payload.serverVersion, 1); + assert.equal(payload.presentation.battle?.outcome, 'ongoing'); + assert.equal(payload.presentation.battle?.damageTaken, 8); + 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.hp, 24); + assert.equal(payload.viewModel.player.mana, 10); + assert.equal(payload.snapshot.gameState.playerHp, 24); + assert.equal(payload.snapshot.gameState.playerMana, 10); + assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 0); + assert.equal(payload.snapshot.gameState.runtimeStats.itemsUsed, 1); + assert.deepEqual(payload.snapshot.gameState.playerInventory, []); + assert.equal( + payload.snapshot.gameState.activeBuildBuffs[0]?.id, + 'focus-tonic:buff', + ); + assert.ok( + payload.patches.some( + (patch) => + patch.type === 'battle_resolved' && + patch.functionId === 'inventory_use', + ), + ); + + const inventoryOption = payload.viewModel.availableOptions.find( + (option) => option.functionId === 'inventory_use', + ); + assert.ok(inventoryOption); + assert.equal(inventoryOption.disabled, true); + assert.match(inventoryOption.reason ?? '', /暂无可用物品/u); + + const skillOption = payload.viewModel.availableOptions.find( + (option) => + option.functionId === 'battle_use_skill' && + option.payload?.skillId === 'slash', + ); + assert.ok(skillOption); + assert.equal(skillOption.actionText, '试锋斩'); + assert.equal(skillOption.disabled, undefined); }); }); 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, @@ -648,14 +1338,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, @@ -735,7 +1435,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, ); }); @@ -815,15 +1516,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, @@ -882,13 +1594,134 @@ 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'); @@ -1037,6 +1870,10 @@ test('runtime story actions progress quests from combat victories and npc turn-i gameState: { quests: Array<{ status: string }>; playerCurrency: number; + playerProgression: { + level: number; + totalXp: number; + }; playerInventory: Array<{ name: string }>; npcStates: { npc_bandit_01: { @@ -1048,10 +1885,17 @@ 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.playerProgression.totalXp > 0); + assert.ok(turnInPayload.snapshot.gameState.playerProgression.level >= 1); 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 55fc7032..15c84ef6 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/story/storyActionService.ts @@ -24,9 +24,7 @@ import { 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, @@ -95,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' @@ -117,68 +118,25 @@ 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, -) { - const detailParts = [option.detailText, option.disabled ? option.reason : null] - .filter(Boolean) - .join(' '); - +function buildStoryOptionFromRuntimeOption(option: RuntimeStoryOptionView) { return { functionId: option.functionId, actionText: option.actionText, text: option.actionText, - detailText: detailParts || undefined, + detailText: option.detailText, visuals: DEFAULT_STORY_OPTION_VISUALS, - interaction: buildStoryOptionInteraction(session, option), + interaction: option.interaction, + runtimePayload: option.payload, + disabled: option.disabled, + disabledReason: option.reason, } satisfies JsonRecord; } function buildStoryOptionsFromRuntimeOptions( - session: RuntimeSession, + _session: RuntimeSession, options: RuntimeStoryOptionView[], ) { - return options - .filter((option) => !option.disabled) - .map((option) => buildStoryOptionFromRuntimeOption(session, option)); + return options.map((option) => buildStoryOptionFromRuntimeOption(option)); } function escapeRegExp(value: string) { @@ -290,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, @@ -304,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; @@ -415,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; @@ -463,6 +571,17 @@ function normalizeStatusPatch(session: RuntimeSession) { } satisfies RuntimeStoryPatch; } +function shouldGenerateReasonedCombatStory( + resolution: StoryResolution, +) { + const outcome = resolution.battle?.outcome; + return ( + outcome === 'victory' || + outcome === 'spar_complete' || + outcome === 'escaped' + ); +} + function clearEncounterState(session: RuntimeSession) { session.currentEncounter = null; session.npcInteractionActive = false; @@ -507,7 +626,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; } @@ -523,7 +647,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), @@ -627,7 +753,8 @@ function resolveStoryFlowAction( case 'story_continue_adventure': return { actionText: '继续推进冒险', - resultText: '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。', + resultText: + '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。', patches: [normalizeStatusPatch(session)], }; case 'story_opening_camp_dialogue': { @@ -659,7 +786,8 @@ function resolveStoryFlowAction( return { actionText: '交换开场判断', - resultText: '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。', + resultText: + '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。', patches: [normalizeStatusPatch(session)], }; } @@ -667,7 +795,8 @@ function resolveStoryFlowAction( clearEncounterState(session); return { actionText: '返回营地', - resultText: '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。', + resultText: + '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。', patches: [ normalizeStatusPatch(session), { @@ -679,13 +808,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': @@ -696,7 +827,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: '你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。', @@ -780,8 +914,15 @@ export async function resolveRuntimeStoryAction(params: { const previousEncounter = session.currentEncounter ? { ...session.currentEncounter } : null; - if (isCombatFunctionId(functionId)) { - resolution = resolveCombatAction(session, functionId); + const shouldResolveAsCombat = + functionId === 'inventory_use' ? session.inBattle : isCombatFunctionId(functionId); + if (shouldResolveAsCombat) { + resolution = resolveCombatAction(session, { + functionId, + payload: isObject(params.request.action.payload) + ? params.request.action.payload + : undefined, + }); } else if (isNpcFunctionId(functionId)) { resolution = resolveNpcInteraction(session, functionId); } else if (isSupportedInventoryStoryFunctionId(functionId)) { @@ -789,7 +930,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)) { @@ -821,9 +964,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') ) { @@ -843,7 +998,10 @@ export async function resolveRuntimeStoryAction(params: { } catch { savedCurrentStory = buildLegacyCurrentStory(storyText, options); } - } else if (params.llmClient && isCombatFunctionId(functionId)) { + } else if ( + params.llmClient && + shouldGenerateReasonedCombatStory(resolution) + ) { try { const generatedPayload = await generateReasonedStoryPayload({ llmClient: params.llmClient, 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/chatPromptBuilders.ts b/server-node/src/prompts/chatPromptBuilders.ts new file mode 100644 index 00000000..65009b11 --- /dev/null +++ b/server-node/src/prompts/chatPromptBuilders.ts @@ -0,0 +1,471 @@ +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcChatTurnRequest, + NpcRecruitDialogueRequest, +} from '../../../packages/shared/src/contracts/story.js'; + +type JsonRecord = Record; + +export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。 +只回复这名角色此刻会对玩家说的话。 +不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。 +保持人设,结合最近剧情和关系变化,回复简洁自然。`; + +export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。 +只输出纯文本,共 3 行,每行一条。 +不要加编号、项目符号、Markdown 或额外说明。 +三条建议语气要有区分:关心、追问、轻松或拉近关系。`; + +export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。 +只输出一段简洁文字。 +包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`; + +export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。 +你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 + +硬性规则: +- 每一行都必须严格以“你:”或“角色名字:”开头。 +- 第一行必须是“你:”开头。 +- 总行数控制在 4 到 6 行。 +- 玩家和对方至少各说 2 次。 +- 这段内容只是聊天,不是做决定。 +- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。 +- 禁止把情报直接写成对玩家的指令。 +- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`; + +export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。 +你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 + +硬性规则: +- 每一行都必须严格以“你:”或“角色名字:”开头。 +- 第一行必须是“你:”开头。 +- 总行数控制在 4 到 6 行。 +- 玩家和对方至少各说 2 次。 +- 这段对话的目标是把“邀请对方入队”自然谈成。 +- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。 +- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。 +- 最后一行必须由对方明确答应加入队伍。`; + +export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。 +你只输出这名 NPC 此刻会对玩家说的一轮回复。 +只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 +回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`; + +export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。 +只输出纯文本,共 3 行,每行 1 条。 +不要加编号、项目符号、Markdown、JSON 或额外说明。 +三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as JsonRecord) + : null; +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function readNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function readStringArray(value: unknown) { + return Array.isArray(value) + ? value + .map((item) => readString(item)) + .filter((item): item is string => Boolean(item)) + : []; +} + +function describeWorld(worldType: string) { + switch (worldType) { + case 'WUXIA': + return '边城模板'; + case 'XIANXIA': + return '灵潮模板'; + case 'CUSTOM': + return '自定义世界'; + default: + return worldType || '未知世界'; + } +} + +function describeStats(label: string, record: JsonRecord | null) { + const hp = readNumber(record?.hp); + const maxHp = Math.max(1, readNumber(record?.maxHp, hp)); + const mana = readNumber(record?.mana); + const maxMana = Math.max(1, readNumber(record?.maxMana, mana)); + + return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`; +} + +function describeCharacter(label: string, value: unknown) { + const record = asRecord(value); + const name = readString(record?.name) ?? '未知角色'; + const title = readString(record?.title) ?? '未知称号'; + const description = readString(record?.description) ?? '暂无额外描述'; + const personality = readString(record?.personality) ?? '性格信息未显式提供'; + + return [ + `${label}姓名:${name}`, + `${label}称号:${title}`, + `${label}描述:${description}`, + `${label}性格:${personality}`, + ].join('\n'); +} + +function describeStoryHistory(history: unknown) { + if (!Array.isArray(history) || history.length === 0) { + return '近期剧情:暂无。'; + } + + const lines = history + .slice(-4) + .map((item) => readString(asRecord(item)?.text)) + .filter((item): item is string => Boolean(item)); + + return lines.length > 0 + ? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n') + : '近期剧情:暂无。'; +} + +function describeConversationHistory(history: unknown) { + if (!Array.isArray(history) || history.length === 0) { + return '聊天记录:暂无。'; + } + + const lines = history + .slice(-12) + .map((item) => { + const record = asRecord(item); + const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色'; + const text = readString(record?.text); + + return text ? `- ${speaker}:${text}` : null; + }) + .filter((item): item is string => Boolean(item)); + + return lines.length > 0 + ? ['聊天记录:', ...lines].join('\n') + : '聊天记录:暂无。'; +} + +function describeNpcConversationHistory(history: unknown, npcName: string) { + if (!Array.isArray(history) || history.length === 0) { + return '当前聊天记录:暂无。'; + } + + const lines = history + .slice(-10) + .map((item) => { + const record = asRecord(item); + const speaker = readString(record?.speaker); + const speakerName = readString(record?.speakerName); + const text = readString(record?.text); + if (!text) return null; + + if (speaker === 'player') { + return `- 玩家:${text}`; + } + + if (speaker === 'npc') { + return `- ${speakerName ?? npcName}:${text}`; + } + + if (speaker === 'system') { + return `- 系统提示:${text}`; + } + + return `- ${speakerName ?? '同伴'}:${text}`; + }) + .filter((item): item is string => Boolean(item)); + + return lines.length > 0 + ? ['当前聊天记录:', ...lines].join('\n') + : '当前聊天记录:暂无。'; +} + +function describeSceneContext(context: unknown) { + const record = asRecord(context); + const sceneName = readString(record?.sceneName) ?? '当前区域'; + const sceneDescription = + readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。'; + const inBattle = record?.inBattle === true ? '战斗中' : '非战斗'; + const customWorldProfile = asRecord(record?.customWorldProfile); + const customWorldName = readString(customWorldProfile?.name); + const customWorldSummary = readString(customWorldProfile?.summary); + + return [ + `世界补充:${customWorldName ?? '无'}`, + customWorldSummary ? `世界摘要:${customWorldSummary}` : null, + `场景:${sceneName}`, + `场景描述:${sceneDescription}`, + `当前状态:${inBattle}`, + describeStats('玩家', record), + ] + .filter(Boolean) + .join('\n'); +} + +function describeTargetStatus(status: unknown) { + const record = asRecord(status); + const roleLabel = readString(record?.roleLabel) ?? '同行角色'; + const affinity = record?.affinity; + + return [ + `对方身份:${roleLabel}`, + describeStats('对方', record), + typeof affinity === 'number' ? `当前好感:${affinity}` : null, + ] + .filter(Boolean) + .join('\n'); +} + +function describeEncounter(encounter: unknown) { + const record = asRecord(encounter); + const npcName = readString(record?.npcName) ?? '眼前角色'; + const contextText = + readString(record?.context) ?? + readString(record?.npcDescription) ?? + '你们正在当前遭遇里继续对话。'; + + return { + npcName, + block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'), + }; +} + +function describeMonsters(monsters: unknown) { + if (!Array.isArray(monsters) || monsters.length === 0) { + return '当前敌对目标:无。'; + } + + const lines = monsters + .slice(0, 4) + .map((item) => { + const record = asRecord(item); + const name = + readString(record?.name) ?? + readString(record?.npcName) ?? + readString(record?.id); + const hp = readNumber(record?.hp); + const maxHp = Math.max(1, readNumber(record?.maxHp, hp)); + + return name ? `- ${name}(生命 ${hp}/${maxHp})` : null; + }) + .filter((item): item is string => Boolean(item)); + + return lines.length > 0 + ? ['当前敌对目标:', ...lines].join('\n') + : '当前敌对目标:无。'; +} + +function describeTargetCharacterName(payload: { + targetCharacter?: unknown; + encounter?: unknown; +}) { + return ( + readString(asRecord(payload.targetCharacter)?.name) ?? + readString(asRecord(payload.encounter)?.npcName) ?? + '对方' + ); +} + +export function buildCharacterPanelChatPrompt( + payload: CharacterChatReplyRequest, +) { + const targetName = describeTargetCharacterName(payload); + + return [ + `世界:${describeWorld(payload.worldType)}`, + describeSceneContext(payload.context), + describeCharacter('玩家 / ', payload.playerCharacter), + describeCharacter('对方 / ', payload.targetCharacter), + describeTargetStatus(payload.targetStatus), + describeStoryHistory(payload.storyHistory), + payload.conversationSummary + ? `之前聊天摘要:${payload.conversationSummary}` + : '之前聊天摘要:暂无。', + describeConversationHistory(payload.conversationHistory), + `玩家刚刚对 ${targetName} 说:${payload.playerMessage}`, + `现在请以 ${targetName} 的身份,直接回复玩家。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildCharacterPanelChatSuggestionPrompt( + payload: CharacterChatSuggestionsRequest, +) { + const targetName = describeTargetCharacterName(payload); + const latestCharacterReply = Array.isArray(payload.conversationHistory) + ? [...payload.conversationHistory] + .reverse() + .map((item) => asRecord(item)) + .find((record) => readString(record?.speaker) === 'character') + : null; + const latestReplyText = readString(latestCharacterReply?.text); + + return [ + `世界:${describeWorld(payload.worldType)}`, + describeSceneContext(payload.context), + describeCharacter('玩家 / ', payload.playerCharacter), + describeCharacter('对方 / ', payload.targetCharacter), + describeTargetStatus(payload.targetStatus), + describeStoryHistory(payload.storyHistory), + payload.conversationSummary + ? `之前聊天摘要:${payload.conversationSummary}` + : '之前聊天摘要:暂无。', + describeConversationHistory(payload.conversationHistory), + latestReplyText + ? `角色刚刚的回复:${latestReplyText}` + : `玩家正准备与 ${targetName} 开始一段新的私聊。`, + `请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildCharacterPanelChatSummaryPrompt( + payload: CharacterChatSummaryRequest, +) { + const targetName = describeTargetCharacterName(payload); + + return [ + `世界:${describeWorld(payload.worldType)}`, + describeSceneContext(payload.context), + describeCharacter('玩家 / ', payload.playerCharacter), + describeCharacter('对方 / ', payload.targetCharacter), + describeTargetStatus(payload.targetStatus), + describeStoryHistory(payload.storyHistory), + payload.previousSummary + ? `旧摘要:${payload.previousSummary}` + : '旧摘要:暂无。', + describeConversationHistory(payload.conversationHistory), + `请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +function buildNpcDialoguePromptBase( + payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest, +) { + const encounter = describeEncounter(payload.encounter); + const character = + (payload as NpcChatTurnRequest).character ?? + (payload as NpcChatTurnRequest).player; + if (!(payload as NpcChatTurnRequest).character && character) { + (payload as NpcChatTurnRequest).character = character; + } + + return [ + `世界:${describeWorld(payload.worldType)}`, + describeSceneContext(payload.context), + describeCharacter('玩家 / ', payload.character), + encounter.block, + describeMonsters(payload.monsters), + describeStoryHistory(payload.history), + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildStrictNpcChatDialoguePrompt( + payload: NpcChatDialogueRequest, +) { + const encounter = describeEncounter(payload.encounter); + const context = asRecord(payload.context); + const openingCampBackground = readString(context?.openingCampBackground); + const openingCampDialogue = readString(context?.openingCampDialogue); + const allowedTopics = readStringArray(context?.encounterAllowedTopics); + const blockedTopics = readStringArray(context?.encounterBlockedTopics); + + return [ + buildNpcDialoguePromptBase(payload), + openingCampBackground ? `营地开场背景:${openingCampBackground}` : null, + openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null, + allowedTopics.length > 0 + ? `当前更适合谈的内容:${allowedTopics.join('、')}` + : null, + blockedTopics.length > 0 + ? `当前避免直接说破:${blockedTopics.join('、')}` + : null, + `当前聊天主题:${payload.topic}`, + payload.resultSummary + ? `这段聊天希望带来的变化:${payload.resultSummary}` + : '这段聊天要让气氛、情报或关系出现一层新的变化。', + `请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildNpcRecruitDialoguePrompt( + payload: NpcRecruitDialogueRequest, +) { + const encounter = describeEncounter(payload.encounter); + + return [ + buildNpcDialoguePromptBase(payload), + `玩家邀请:${payload.invitationText}`, + payload.recruitSummary + ? `招募补充条件:${payload.recruitSummary}` + : '这轮对话已经具备自然邀请对方入队的条件。', + '这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。', + `最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildNpcChatTurnReplyPrompt( + payload: NpcChatTurnRequest, +) { + const encounter = describeEncounter(payload.encounter); + const npcState = asRecord(payload.npcState); + const conversationHistory = + Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0 + ? payload.conversationHistory + : payload.dialogue ?? payload.conversationHistory ?? []; + const affinity = readNumber(npcState?.affinity, 0); + const chattedCount = readNumber(npcState?.chattedCount, 0); + + return [ + buildNpcDialoguePromptBase(payload), + describeNpcConversationHistory(conversationHistory, encounter.npcName), + `当前关系值:${affinity}`, + `已聊天轮次:${chattedCount}`, + `玩家刚刚说:${payload.playerMessage}`, + `现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildNpcChatTurnSuggestionPrompt( + payload: NpcChatTurnRequest, + npcReply: string, +) { + const encounter = describeEncounter(payload.encounter); + const conversationHistory = + Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0 + ? payload.conversationHistory + : payload.dialogue ?? payload.conversationHistory ?? []; + + return [ + buildNpcDialoguePromptBase(payload), + describeNpcConversationHistory(conversationHistory, encounter.npcName), + `玩家刚刚说:${payload.playerMessage}`, + `NPC 刚刚回复:${npcReply}`, + `请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`, + '每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。', + '每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。', + ] + .filter(Boolean) + .join('\n\n'); +} diff --git a/server-node/src/prompts/customWorldAgentPrompts.ts b/server-node/src/prompts/customWorldAgentPrompts.ts new file mode 100644 index 00000000..ce20fcd7 --- /dev/null +++ b/server-node/src/prompts/customWorldAgentPrompts.ts @@ -0,0 +1,57 @@ +export const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的世界草稿 JSON 生成器。 +只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`; + +export const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。 +你会收到一段本应为单个 JSON 对象的文本。 +你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。 +不要输出 Markdown、代码块、解释、注释或额外文字。`; + +export const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT = + '你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。'; + +export const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT = + '你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。'; + +export function buildCustomWorldAgentCharacterExpansionPrompt(params: { + worldName: string; + worldSummary: string; + creatorIntentSummary: string; + anchorSummary: string; + existingNames: string[]; + count: number; + promptSeed: string; +}) { + return [ + `当前世界:${params.worldName}`, + `世界摘要:${params.worldSummary}`, + `创作意图摘要:${params.creatorIntentSummary}`, + `参考锚点:${params.anchorSummary}`, + `已有角色:${params.existingNames.join('、') || '暂无'}`, + `数量:${params.count}`, + `补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, + '返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。', + 'threadIds 必须优先引用现有线程 id。', + ].join('\n'); +} + +export function buildCustomWorldAgentLandmarkExpansionPrompt(params: { + worldName: string; + worldSummary: string; + creatorIntentSummary: string; + anchorSummary: string; + existingNames: string[]; + count: number; + promptSeed: string; +}) { + return [ + `当前世界:${params.worldName}`, + `世界摘要:${params.worldSummary}`, + `创作意图摘要:${params.creatorIntentSummary}`, + `参考锚点:${params.anchorSummary}`, + `已有地点:${params.existingNames.join('、') || '暂无'}`, + `数量:${params.count}`, + `补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, + '返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。', + 'threadIds / characterIds 必须优先引用现有对象 id。', + ].join('\n'); +} diff --git a/server-node/src/prompts/customWorldEntityPrompts.ts b/server-node/src/prompts/customWorldEntityPrompts.ts new file mode 100644 index 00000000..0d910e5c --- /dev/null +++ b/server-node/src/prompts/customWorldEntityPrompts.ts @@ -0,0 +1,249 @@ +type ParsedRole = { + id: string; + name: string; + title: string; + role: string; + description: string; + visualDescription: string; + actionDescription: string; + sceneVisualDescription: string; + backstory: string; + personality: string; + motivation: string; + tags: string[]; +}; + +type ParsedLandmarkConnection = { + targetLandmarkId: string; + summary: string; + relativePosition: string; +}; + +type ParsedLandmark = { + id: string; + name: string; + description: string; + visualDescription: string; + dangerLevel: string; + sceneNpcIds: string[]; + connections: ParsedLandmarkConnection[]; +}; + +type ParsedProfile = { + name: string; + settingText: string; + summary: string; + tone: string; + playerGoal: string; + playableNpcs: ParsedRole[]; + storyNpcs: ParsedRole[]; + landmarks: ParsedLandmark[]; +}; + +export const CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT = + '你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。'; + +function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) { + if (roles.length === 0) { + return emptyText; + } + + return roles + .slice(0, 12) + .map( + (role, index) => + `${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${ + role.role || '未写' + } / 描述:${role.description || '未写'} / 背景:${ + role.backstory || '未写' + } / 性格:${role.personality || '未写'} / 动机:${ + role.motivation || '未写' + } / 形象:${role.visualDescription || '未写'} / 动作表现:${ + role.actionDescription || '未写' + } / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${ + role.tags.join('、') || '暂无' + }`, + ) + .join('\n'); +} + +function buildLandmarkReferenceText(profile: ParsedProfile) { + if (profile.landmarks.length === 0) { + return '当前还没有场景设定。'; + } + + const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc])); + const landmarkById = new Map( + profile.landmarks.map((landmark) => [landmark.id, landmark]), + ); + + return profile.landmarks + .slice(0, 12) + .map((landmark, index) => { + const sceneNpcNames = landmark.sceneNpcIds + .map((npcId) => storyNpcById.get(npcId)?.name ?? '') + .filter(Boolean) + .join('、'); + const connectionNames = landmark.connections + .map((connection) => { + const targetName = + landmarkById.get(connection.targetLandmarkId)?.name || + connection.targetLandmarkId; + return `${targetName}(${connection.relativePosition} / ${ + connection.summary || '无说明' + })`; + }) + .join('、'); + + return `${index + 1}. ${landmark.name} / 危险度:${ + landmark.dangerLevel || 'medium' + } / 描述:${landmark.description || '未写'} / 画面:${ + landmark.visualDescription || '未写' + } / 场景角色:${ + sceneNpcNames || '暂无' + } / 连接:${connectionNames || '暂无'}`; + }) + .join('\n'); +} + +export function buildPlayablePrompt(profile: ParsedProfile) { + return [ + `世界名:${profile.name}`, + `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, + `世界摘要:${profile.summary || '未填写'}`, + `世界基调:${profile.tone || '未填写'}`, + `玩家主线目标:${profile.playerGoal || '未填写'}`, + `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, + `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, + `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, + '请基于上面全部上下文,生成 1 名新的“可扮演角色”。', + '要求:', + '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', + '- 必须保留明确的协作价值、成长空间和入队理由。', + '- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。', + '- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。', + '- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。', + '- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。', + '- 只返回 JSON,不要输出解释或 Markdown。', + 'JSON 结构:', + '{', + ' "playableNpc": {', + ' "name": "角色名",', + ' "title": "称号",', + ' "role": "身份",', + ' "description": "一句到两句定位描述",', + ' "visualDescription": "角色形象描述",', + ' "actionDescription": "动作表现描述",', + ' "sceneVisualDescription": "角色关联场景画面描述",', + ' "backstory": "背景经历",', + ' "personality": "性格特点",', + ' "motivation": "当前动机",', + ' "combatStyle": "战斗风格",', + ' "initialAffinity": 22,', + ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', + ' "tags": ["标签1", "标签2", "标签3"],', + ' "publicSummary": "公开背景摘要",', + ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', + ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', + ' "skills": [', + ' { "name": "技能1", "summary": "说明", "style": "风格" },', + ' { "name": "技能2", "summary": "说明", "style": "风格" },', + ' { "name": "技能3", "summary": "说明", "style": "风格" }', + ' ],', + ' "initialItems": [', + ' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', + ' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', + ' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }', + ' ]', + ' }', + '}', + ].join('\n'); +} + +export function buildStoryPrompt(profile: ParsedProfile) { + return [ + `世界名:${profile.name}`, + `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, + `世界摘要:${profile.summary || '未填写'}`, + `世界基调:${profile.tone || '未填写'}`, + `玩家主线目标:${profile.playerGoal || '未填写'}`, + `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, + `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, + `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, + '请基于上面全部上下文,生成 1 名新的“场景角色”。', + '要求:', + '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', + '- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。', + '- 角色应与具体场景、关系链或局势变化发生绑定。', + '- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。', + '- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。', + '- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。', + '- 只返回 JSON,不要输出解释或 Markdown。', + 'JSON 结构:', + '{', + ' "storyNpc": {', + ' "name": "角色名",', + ' "title": "称号",', + ' "role": "身份",', + ' "description": "一句到两句定位描述",', + ' "visualDescription": "角色形象描述",', + ' "actionDescription": "动作表现描述",', + ' "sceneVisualDescription": "角色关联场景画面描述",', + ' "backstory": "背景经历",', + ' "personality": "性格特点",', + ' "motivation": "当前动机",', + ' "combatStyle": "战斗风格",', + ' "initialAffinity": 6,', + ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', + ' "tags": ["标签1", "标签2", "标签3"],', + ' "publicSummary": "公开背景摘要",', + ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', + ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', + ' "skills": [', + ' { "name": "技能1", "summary": "说明", "style": "风格" },', + ' { "name": "技能2", "summary": "说明", "style": "风格" },', + ' { "name": "技能3", "summary": "说明", "style": "风格" }', + ' ],', + ' "initialItems": [', + ' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', + ' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', + ' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }', + ' ]', + ' }', + '}', + ].join('\n'); +} + +export function buildLandmarkPrompt(profile: ParsedProfile) { + return [ + `世界名:${profile.name}`, + `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, + `世界摘要:${profile.summary || '未填写'}`, + `世界基调:${profile.tone || '未填写'}`, + `玩家主线目标:${profile.playerGoal || '未填写'}`, + `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, + `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, + `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, + '请基于上面全部上下文,生成 1 个新的“场景”。', + '要求:', + '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。', + '- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。', + '- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。', + '- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。', + '- 只返回 JSON,不要输出解释或 Markdown。', + 'JSON 结构:', + '{', + ' "landmark": {', + ' "name": "场景名",', + ' "description": "场景描述",', + ' "visualDescription": "场景画面描述",', + ' "dangerLevel": "low|medium|high|extreme",', + ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', + ' "connections": [', + ' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },', + ' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }', + ' ]', + ' }', + '}', + ].join('\n'); +} diff --git a/server-node/src/prompts/customWorldOrchestratorPrompts.ts b/server-node/src/prompts/customWorldOrchestratorPrompts.ts new file mode 100644 index 00000000..e2f99a32 --- /dev/null +++ b/server-node/src/prompts/customWorldOrchestratorPrompts.ts @@ -0,0 +1,61 @@ +export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。 +只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`; + +export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。 +你会收到一段本应为单个 JSON 对象的文本。 +你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。 +不要输出 Markdown、代码块、解释、注释或额外文字。 +尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`; + +export function buildCustomWorldProfilePrompt(params: { + generationSeedText: string; + creatorIntentText?: string; + generationMode: string; + targets: { + playableCount: number; + storyCount: number; + landmarkCount: number; + }; +}) { + return [ + '请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。', + '必须严格输出单个 JSON 对象,不要 Markdown,不要解释。', + '', + `生成模式:${params.generationMode}`, + `可扮演角色数量:${params.targets.playableCount}`, + `场景角色数量:${params.targets.storyCount}`, + `关键场景数量:${params.targets.landmarkCount}`, + '', + '创作者输入:', + params.generationSeedText, + params.creatorIntentText ? `\n结构化创作锚点:\n${params.creatorIntentText}` : '', + '', + '输出 JSON 字段要求:', + '- name, subtitle, summary, tone, playerGoal, templateWorldType', + '- majorFactions: string[],coreConflicts: string[]', + '- camp: { name, description, dangerLevel }', + '- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags', + '- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags', + '- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections', + '- connections 每项包含 targetLandmarkName, relativePosition, summary,targetLandmarkName 必须指向本次输出的其他场景名', + '', + '约束:', + '- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。', + '- 角色名字、势力名、场景名必须互相区分,避免重复。', + '- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。', + '- templateWorldType 只能是 WUXIA 或 XIANXIA。', + '- dangerLevel 使用 low、medium、high、extreme 之一。', + '- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。', + '- 不要预生成物品档案;items 如需输出,必须为空数组。', + ] + .filter(Boolean) + .join('\n'); +} + +export function buildCustomWorldProfileRepairPrompt(responseText: string) { + return [ + '请修复下面的自定义世界 JSON。', + '只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。', + responseText, + ].join('\n\n'); +} 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/customWorldSceneNpcPrompts.ts b/server-node/src/prompts/customWorldSceneNpcPrompts.ts new file mode 100644 index 00000000..cf433c88 --- /dev/null +++ b/server-node/src/prompts/customWorldSceneNpcPrompts.ts @@ -0,0 +1,104 @@ +type ParsedStoryNpc = { + name: string; + title: string; + role: string; + description: string; + personality: string; + motivation: string; +}; + +type ParsedLandmark = { + name: string; + description: string; + dangerLevel: string; +}; + +type ParsedProfile = { + name: string; + settingText: string; + storyNpcs: ParsedStoryNpc[]; + landmarks: ParsedLandmark[]; +}; + +export const CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT = + '你是游戏世界编辑器的场景 NPC 生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。'; + +export function buildCustomWorldSceneNpcPrompt( + profile: ParsedProfile, + landmark: ParsedLandmark, + sceneNpcs: ParsedStoryNpc[], + otherNpcs: ParsedStoryNpc[], +) { + const sceneNpcSummary = sceneNpcs.length + ? sceneNpcs + .map( + (npc, index) => + `${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`, + ) + .join('\n') + : '当前场景还没有已加入 NPC。'; + + const reserveNpcSummary = otherNpcs.length + ? otherNpcs + .slice(0, 8) + .map( + (npc, index) => + `${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`, + ) + .join('\n') + : '暂无其他场景角色参考。'; + + const landmarkSummary = profile.landmarks + .slice(0, 10) + .map( + (entry, index) => + `${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`, + ) + .join('\n'); + + return [ + `世界名:${profile.name}`, + `世界设定:${profile.settingText || '未提供额外设定文本。'}`, + `当前目标场景:${landmark.name}`, + `场景描述:${landmark.description || '未填写'}`, + `危险度:${landmark.dangerLevel || '中'}`, + `当前场景已加入 NPC:\n${sceneNpcSummary}`, + `其他可参考 NPC:\n${reserveNpcSummary}`, + `世界内其他场景概览:\n${landmarkSummary}`, + '请生成 1 名适合加入当前场景的新 NPC。', + '要求:', + '- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。', + '- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。', + '- 关系钩子、技能、初始物品都要可直接进入编辑器。', + '- 返回 JSON,不要额外解释。', + 'JSON 结构:', + '{', + ' "npc": {', + ' "name": "角色名",', + ' "title": "头衔",', + ' "role": "身份",', + ' "description": "一句到两句角色描述",', + ' "backstory": "背景",', + ' "personality": "性格",', + ' "motivation": "动机",', + ' "combatStyle": "战斗风格",', + ' "initialAffinity": 6,', + ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', + ' "tags": ["标签1", "标签2", "标签3"],', + ' "publicSummary": "公开背景摘要",', + ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', + ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', + ' "skills": [', + ' { "name": "技能1", "summary": "说明", "style": "风格" },', + ' { "name": "技能2", "summary": "说明", "style": "风格" },', + ' { "name": "技能3", "summary": "说明", "style": "风格" }', + ' ],', + ' "initialItems": [', + ' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', + ' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },', + ' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }', + ' ]', + ' }', + '}', + ].join('\n'); +} 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/prompts/questPrompts.ts b/server-node/src/prompts/questPrompts.ts new file mode 100644 index 00000000..c3233597 --- /dev/null +++ b/server-node/src/prompts/questPrompts.ts @@ -0,0 +1,168 @@ +import type { + QuestGenerationContext, + QuestOpportunity, + QuestSceneSnapshot, +} from '../modules/quest/runtimeQuestModule.js'; + +function summarizeRecentStoryMoments(context: QuestGenerationContext) { + const moments = context.recentStoryMoments + .slice(-4) + .map((moment) => `- ${moment.text}`) + .join('\n'); + + return moments || '- 暂无近期剧情记录'; +} + +function summarizeCurrentQuests(context: QuestGenerationContext) { + const summary = context.currentQuestSummary + ?.map( + (quest) => + `- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`, + ) + .join('\n'); + + return summary || '- 当前没有进行中的任务'; +} + +function summarizeCompanions(context: QuestGenerationContext) { + const active = + context.activeCompanions?.map((companion) => companion.characterId).join('、') || + '无'; + const roster = + context.rosterCompanions?.map((companion) => companion.characterId).join('、') || + '无'; + return `当前同行角色:${active}\n队伍名册:${roster}`; +} + +function summarizePlayerState(context: QuestGenerationContext) { + const playerName = context.playerCharacter?.name ?? '未知角色'; + const playerTitle = context.playerCharacter?.title ?? '未知称号'; + const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`; + const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`; + const inventory = + context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无'; + + return [ + `玩家:${playerName}(${playerTitle})`, + `生命:${hp}`, + `灵力:${mana}`, + `背包快照:${inventory}`, + ].join('\n'); +} + +function summarizeScene( + scene: QuestSceneSnapshot | null, + context: QuestGenerationContext, +) { + const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无'; + const treasureHintCount = context.currentSceneTreasureHintCount ?? 0; + + return [ + `场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`, + `场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`, + `敌对角色 ID:${hostileNpcIds}`, + `宝藏线索数量:${treasureHintCount}`, + ].join('\n'); +} + +function summarizeActiveThreads(context: QuestGenerationContext) { + return context.activeThreadIds?.length + ? context.activeThreadIds.join('、') + : '暂无明确激活线程'; +} + +function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) { + const profile = context.issuerNarrativeProfile; + if (!profile) { + return '暂无额外叙事档案'; + } + + return [ + `公开面:${profile.publicMask ?? '暂无'}`, + `表层线:${profile.visibleLine ?? '暂无'}`, + `当前压力:${profile.immediatePressure ?? '暂无'}`, + profile.reactionHooks?.length + ? `反应钩子:${profile.reactionHooks.join('、')}` + : null, + ] + .filter(Boolean) + .join('\n'); +} + +function describeWorld(worldType: QuestGenerationContext['worldType']) { + switch (worldType) { + case 'WUXIA': + return '边城模板'; + case 'XIANXIA': + return '灵潮模板'; + case 'CUSTOM': + return '自定义世界'; + default: + return '未知世界'; + } +} + +export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。 +只返回 JSON,不要输出 Markdown。 + +输出结构: +{ + "intent": { + "title": "中文任务标题", + "description": "中文任务描述", + "summary": "中文短摘要", + "narrativeType": "bounty|escort|investigation|retrieval|relationship|trial", + "dramaticNeed": "string", + "issuerGoal": "string", + "playerHook": "string", + "worldReason": "string", + "recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"], + "urgency": "low|medium|high", + "intimacy": "transactional|cooperative|trust_based", + "rewardTheme": "currency|resource|relationship|intel|rare_item", + "followupHooks": ["string"] + } +} + +规则: +- 所有自然语言字段都必须使用中文。 +- 任务必须扎根于当前场景、发布者和近期剧情。 +- 不要编造奖励、ID、数量、状态或不受支持的规则变化。 +- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。 +- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。 +- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。 +- description 解释任务为什么在当前剧情里成立,避免纯规则说明。 +- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`; + +export function buildQuestIntentPrompt(params: { + context: QuestGenerationContext; + scene: QuestSceneSnapshot | null; + opportunity: QuestOpportunity; +}) { + const { context, scene, opportunity } = params; + const customWorldSummary = context.customWorldProfile + ? `${context.customWorldProfile.name ?? '自定义世界'}: ${ + context.customWorldProfile.summary ?? '暂无摘要' + }` + : '无'; + + return [ + `世界:${describeWorld(context.worldType)}`, + `自定义世界摘要:${customWorldSummary}`, + `发布角色:${context.issuerNpcName}(${context.issuerNpcId})`, + `发布者身份:${context.issuerNpcContext || '暂无'}`, + `发布者好感:${context.issuerAffinity ?? 0}`, + `发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`, + `发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`, + `当前激活线程:${summarizeActiveThreads(context)}`, + `发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`, + `当前遭遇类型:${context.encounterKind ?? '无'}`, + summarizeScene(scene, context), + summarizePlayerState(context), + summarizeCompanions(context), + `当前任务机会:${opportunity.reason}`, + `当前任务列表:\n${summarizeCurrentQuests(context)}`, + `近期剧情片段:\n${summarizeRecentStoryMoments(context)}`, + '现在请基于这次具体局势,生成一个自然生长出来的任务意图。', + ].join('\n\n'); +} diff --git a/server-node/src/prompts/runtimeItemPrompts.ts b/server-node/src/prompts/runtimeItemPrompts.ts new file mode 100644 index 00000000..e41521fe --- /dev/null +++ b/server-node/src/prompts/runtimeItemPrompts.ts @@ -0,0 +1,43 @@ +export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。 +你只返回 JSON,不要输出 Markdown、解释或代码块。 + +输出结构: +{ + "intents": [ + { + "shortNameSeed": "中文短种子", + "sourcePhrase": "中文来源短语", + "reasonToAppear": "中文出现理由", + "relationHooks": ["中文关系钩子"], + "desiredBuildTags": ["中文 build 标签"], + "desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"], + "tone": "grim|mysterious|martial|ritual|survival", + "visibleClue": "玩家第一眼能抓到的痕迹", + "witnessMark": "它见证过什么的使用痕", + "unfinishedBusiness": "背后仍未结清的问题", + "hiddenHook": "更深一层但别直接讲穿的钩子", + "reactionHooks": ["以后谁会对它起反应"], + "namingPattern": "命名范式建议" + } + ] +} + +规则: +- intents 数量必须与输入物品数量完全一致,顺序也必须一致。 +- 所有自然语言字段都必须使用中文。 +- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。 +- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。 +- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。 +- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`; + +export function buildRuntimeItemIntentPromptText(params: { + generationChannel: string; + planBlocks: string[]; +}) { + return [ + `生成渠道:${params.generationChannel}`, + '以下每个物品都需要给出一条可编译的运行时物品意图。', + ...params.planBlocks, + '请严格返回 JSON。', + ].join('\n\n'); +} diff --git a/server-node/src/prompts/storyOrchestratorPrompts.ts b/server-node/src/prompts/storyOrchestratorPrompts.ts new file mode 100644 index 00000000..bb28a158 --- /dev/null +++ b/server-node/src/prompts/storyOrchestratorPrompts.ts @@ -0,0 +1,33 @@ +type StoryRepairResponse = { + storyText: string; + encounter?: unknown; + options: Array<{ + functionId: string; + actionText: string; + }>; +}; + +export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。 +你会收到一个已经解析过的剧情 JSON 对象。 +你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。 +必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。 +只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`; + +export function buildStoryLanguageRepairPrompt(response: StoryRepairResponse) { + return [ + '请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。', + '只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。', + JSON.stringify( + { + storyText: response.storyText, + encounter: response.encounter ?? null, + options: response.options.map((option) => ({ + functionId: option.functionId, + actionText: option.actionText, + })), + }, + null, + 2, + ), + ].join('\n\n'); +} diff --git a/server-node/src/prompts/storyPromptBuilders.ts b/server-node/src/prompts/storyPromptBuilders.ts new file mode 100644 index 00000000..346a8fac --- /dev/null +++ b/server-node/src/prompts/storyPromptBuilders.ts @@ -0,0 +1,197 @@ +type JsonRecord = Record; + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function readNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function describeWorld(worldType: string) { + switch (worldType) { + case 'WUXIA': + return '边城模板'; + case 'XIANXIA': + return '灵潮模板'; + case 'CUSTOM': + return '自定义世界'; + default: + return worldType || '未知世界'; + } +} + +function describeCharacter(character: JsonRecord) { + return [ + `主角:${readString(character.name) ?? '未知角色'}`, + `称号:${readString(character.title) ?? '未知称号'}`, + `描述:${readString(character.description) ?? '暂无'}`, + `性格:${readString(character.personality) ?? '未显式提供'}`, + ].join('\n'); +} + +function describeMonsters(monsters: JsonRecord[]) { + if (monsters.length <= 0) { + return '当前敌对目标:无。'; + } + + return [ + '当前敌对目标:', + ...monsters.slice(0, 4).map((monster) => { + const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标'; + const hp = readNumber(monster.hp); + const maxHp = Math.max(1, readNumber(monster.maxHp, hp)); + return `- ${name}(生命 ${hp}/${maxHp})`; + }), + ].join('\n'); +} + +function describeStoryHistory(history: JsonRecord[]) { + if (history.length <= 0) { + return '近期剧情:暂无。'; + } + + return [ + '近期剧情:', + ...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`), + ].join('\n'); +} + +function describeRequestOptions(options: { + availableOptions?: Array>; + optionCatalog?: Array>; +}) { + const available = options.availableOptions ?? []; + const catalog = options.optionCatalog ?? []; + + if (available.length > 0) { + return [ + '固定可选项列表:', + ...available.map((option, index) => { + const functionId = readString(option.functionId) ?? 'unknown'; + const actionText = + readString(option.actionText) ?? + readString(option.text) ?? + '未提供文案'; + return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`; + }), + '必须保持数量不变,functionId 不变,可以重写 actionText。'.trim(), + ].join('\n'); + } + + if (catalog.length > 0) { + return [ + '当前局面可调用的交互选项目录:', + ...catalog.map((option, index) => { + const functionId = readString(option.functionId) ?? 'unknown'; + const actionText = + readString(option.actionText) ?? + readString(option.text) ?? + '未提供文案'; + return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`; + }), + 'functionId 只能从上面目录里选择。'.trim(), + ].join('\n'); + } + + return '当前没有固定目录,请根据局势生成合理选项。'; +} + +function hasNpcOptionCatalog(options: { + availableOptions?: Array>; + optionCatalog?: Array>; +}) { + return (options.optionCatalog ?? []).some((option) => + (readString(option.functionId) ?? '').startsWith('npc_'), + ); +} + +function isPostNpcChatReevaluation(params: { + choice?: string; + context: JsonRecord; + requestOptions?: { + availableOptions?: Array>; + optionCatalog?: Array>; + }; +}) { + return ( + readString(params.context.lastFunctionId) === 'npc_chat' && + hasNpcOptionCatalog(params.requestOptions ?? {}) && + Boolean(readString(params.choice)) + ); +} + +export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。 +输出格式必须严格符合: +{ + "storyText": "剧情文本", + "encounter": null, + "options": [ + { + "functionId": "预定义功能ID", + "actionText": "选项显示文本" + } + ] +} + +严格规则: +- 所有文本必须是中文。 +- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。 +- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。 +- options 只允许输出 functionId 和 actionText。 +- 如果当前不是“继续推进后下一刻会遇到什么”的场景,encounter 必须保持为 null。`; + +export function buildUserPrompt(params: { + worldType: string; + character: JsonRecord; + monsters: JsonRecord[]; + history: JsonRecord[]; + context: JsonRecord; + choice?: string; + requestOptions?: { + availableOptions?: Array>; + optionCatalog?: Array>; + }; +}) { + const sceneName = readString(params.context.sceneName) ?? '当前区域'; + const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。'; + const encounterName = readString(params.context.encounterName); + const playerHp = readNumber(params.context.playerHp); + const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp)); + const playerMana = readNumber(params.context.playerMana); + const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana)); + const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗'; + const pendingSceneEncounter = + params.context.pendingSceneEncounter === true ? '是' : '否'; + const postNpcChatReevaluation = isPostNpcChatReevaluation(params); + + return [ + `世界:${describeWorld(params.worldType)}`, + `场景:${sceneName}`, + `场景描述:${sceneDescription}`, + encounterName ? `当前面前对象:${encounterName}` : null, + `当前状态:${inBattle}`, + `玩家生命:${playerHp}/${playerMaxHp}`, + `玩家灵力:${playerMana}/${playerMaxMana}`, + `是否需要判断下一刻遭遇:${pendingSceneEncounter}`, + describeCharacter(params.character), + describeMonsters(params.monsters), + describeStoryHistory(params.history), + params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。', + describeRequestOptions(params.requestOptions ?? {}), + postNpcChatReevaluation + ? '当前这一步是刚结束一轮 NPC 交谈后,对眼前局势的再次判断。storyText 必须先落出刚才那段聊天带来的态度变化、气氛变化或新暴露的信息,再进入下一步局势。' + : null, + postNpcChatReevaluation + ? '如果输出 npc_ 开头的选项,这些 actionText 必须直接承接刚才聊到的话题、关系变化或对方态度,写成此刻自然浮现的回应,不要退回“继续交谈”“请求援手”“看看能交换什么”这类通用模板。' + : null, + postNpcChatReevaluation + ? '当前目录只是合法 function 范围,不代表都要出现;只保留此刻真正自然浮现、和刚才聊天结果有关的选项。' + : null, + params.context.pendingSceneEncounter === true + ? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter;否则 encounter 必须为 null。' + : '当前这一步不是新的遭遇生成流程,encounter 必须为 null。', + ] + .filter(Boolean) + .join('\n\n'); +} 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/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index 666b456a..ade6f6c8 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -9,6 +9,7 @@ import type { ProfileDashboardSummary, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, + ProfileSaveArchiveSummary, ProfileWalletLedgerEntry, RuntimeSettings, SavedGameSnapshot, @@ -19,6 +20,7 @@ import { type CustomWorldPublicationStatus, type CustomWorldSessionRecord, DEFAULT_MUSIC_VOLUME, + DEFAULT_PLATFORM_THEME, SAVE_SNAPSHOT_VERSION, } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; @@ -39,6 +41,7 @@ type SnapshotRow = QueryResultRow & { type SettingsRow = QueryResultRow & { musicVolume: number; + platformTheme: RuntimeSettings['platformTheme']; }; type CustomWorldEntryRow = QueryResultRow & { @@ -127,6 +130,23 @@ type ProfileWorldSnapshotMeta = { worldSubtitle: string; }; +type ProfileSaveArchiveRow = QueryResultRow & { + worldKey: string; + ownerUserId: string | null; + profileId: string | null; + worldType: string | null; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + savedAt: string; + bottomTab: string; + gameState: unknown; + currentStory: unknown; +}; + +type ProfileSaveArchiveMeta = Omit; + export type RuntimeRepositoryPort = { getSnapshot(userId: string): Promise; putSnapshot( @@ -136,6 +156,14 @@ export type RuntimeRepositoryPort = { getProfileDashboard(userId: string): Promise; listProfileWalletLedger(userId: string): Promise; getProfilePlayStats(userId: string): Promise; + listProfileSaveArchives(userId: string): Promise; + resumeProfileSaveArchive( + userId: string, + worldKey: string, + ): Promise<{ + entry: ProfileSaveArchiveSummary; + snapshot: SavedSnapshot; + } | null>; deleteSnapshot(userId: string): Promise; getSettings(userId: string): Promise; putSettings( @@ -313,6 +341,10 @@ function normalizePlatformBrowseHistoryWriteEntry( }; } +function readSavedStoryText(value: unknown) { + return readString(asRecord(value)?.text); +} + function readFiniteNumber(value: unknown) { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -600,6 +632,90 @@ function toProfilePlayedWorkSummary( }; } +function toProfileSaveArchiveSummary( + row: Pick< + ProfileSaveArchiveRow, + | 'worldKey' + | 'ownerUserId' + | 'profileId' + | 'worldType' + | 'worldName' + | 'subtitle' + | 'summaryText' + | 'coverImageSrc' + | 'savedAt' + >, +): ProfileSaveArchiveSummary { + const subtitle = row.subtitle || ''; + return { + worldKey: row.worldKey, + ownerUserId: row.ownerUserId, + profileId: row.profileId, + worldType: row.worldType, + worldName: row.worldName || '未命名游戏', + subtitle, + summaryText: row.summaryText || subtitle || '继续推进上一次保存的故事。', + coverImageSrc: row.coverImageSrc || null, + lastPlayedAt: row.savedAt, + }; +} + +function resolveProfileSaveArchiveMeta( + snapshot: SavedSnapshot, +): ProfileSaveArchiveMeta | null { + const worldMeta = resolveProfileWorldSnapshotMeta(snapshot); + if (!worldMeta) { + return null; + } + + const gameState = asRecord(snapshot.gameState); + const continueGameDigest = readString( + asRecord(gameState?.storyEngineMemory)?.continueGameDigest, + ); + const currentStoryText = readSavedStoryText(snapshot.currentStory); + const customWorldProfile = asRecord(gameState?.customWorldProfile); + + if (customWorldProfile) { + const profileId = readString(customWorldProfile.id) || 'custom-world'; + const metadata = extractCustomWorldLibraryMetadata( + normalizeStoredProfile(profileId, customWorldProfile), + ); + + return { + worldKey: worldMeta.worldKey, + ownerUserId: worldMeta.ownerUserId, + profileId: worldMeta.profileId, + worldType: worldMeta.worldType, + worldName: worldMeta.worldTitle || metadata.worldName || '自定义世界', + subtitle: metadata.subtitle || worldMeta.worldSubtitle || '', + summaryText: + continueGameDigest || + currentStoryText || + metadata.summaryText || + worldMeta.worldSubtitle || + '继续推进上一次保存的故事。', + coverImageSrc: metadata.coverImageSrc, + }; + } + + const currentScenePreset = asRecord(gameState?.currentScenePreset); + + return { + worldKey: worldMeta.worldKey, + ownerUserId: worldMeta.ownerUserId, + profileId: worldMeta.profileId, + worldType: worldMeta.worldType, + worldName: worldMeta.worldTitle || '未命名游戏', + subtitle: worldMeta.worldSubtitle || '', + summaryText: + continueGameDigest || + currentStoryText || + worldMeta.worldSubtitle || + '继续推进上一次保存的故事。', + coverImageSrc: readString(currentScenePreset?.imageSrc) || null, + }; +} + export class RuntimeRepository implements RuntimeRepositoryPort { constructor(private readonly db: AppDatabase) {} @@ -663,6 +779,29 @@ export class RuntimeRepository implements RuntimeRepositoryPort { return result.rows[0] ?? null; } + private async findProfileSaveArchive(userId: string, worldKey: string) { + const result = await this.db.query( + `SELECT world_key AS "worldKey", + owner_user_id AS "ownerUserId", + profile_id AS "profileId", + world_type AS "worldType", + world_name AS "worldName", + world_subtitle AS subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + saved_at AS "savedAt", + bottom_tab AS "bottomTab", + game_state_json AS "gameState", + current_story_json AS "currentStory" + FROM profile_save_archives + WHERE user_id = $1 + AND world_key = $2`, + [userId, worldKey], + ); + + return result.rows[0] ?? null; + } + private async upsertProfileDashboardState( userId: string, state: { @@ -686,6 +825,49 @@ export class RuntimeRepository implements RuntimeRepositoryPort { ); } + private async upsertCurrentSnapshot( + userId: string, + snapshot: SavedSnapshot, + ) { + const now = new Date().toISOString(); + const result = await this.db.query( + `INSERT INTO save_snapshots ( + user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_id) DO UPDATE SET + version = EXCLUDED.version, + saved_at = EXCLUDED.saved_at, + bottom_tab = EXCLUDED.bottom_tab, + game_state_json = EXCLUDED.game_state_json, + current_story_json = EXCLUDED.current_story_json, + updated_at = EXCLUDED.updated_at + RETURNING version, + saved_at AS "savedAt", + game_state_json AS "gameState", + bottom_tab AS "bottomTab", + current_story_json AS "currentStory"`, + [ + userId, + snapshot.version, + snapshot.savedAt, + snapshot.bottomTab, + snapshot.gameState, + snapshot.currentStory, + now, + ], + ); + + const row = result.rows[0]; + + return { + version: row.version, + savedAt: row.savedAt, + gameState: row.gameState, + bottomTab: row.bottomTab, + currentStory: row.currentStory, + } satisfies SavedSnapshot; + } + private async syncProfileDashboardFromSnapshot( userId: string, snapshot: SavedSnapshot, @@ -788,6 +970,67 @@ export class RuntimeRepository implements RuntimeRepositoryPort { }); } + private async syncProfileSaveArchiveFromSnapshot( + userId: string, + snapshot: SavedSnapshot, + ) { + const archiveMeta = resolveProfileSaveArchiveMeta(snapshot); + if (!archiveMeta) { + return; + } + + const syncedAt = snapshot.savedAt || new Date().toISOString(); + + await this.db.query( + `INSERT INTO profile_save_archives ( + user_id, + world_key, + owner_user_id, + profile_id, + world_type, + world_name, + world_subtitle, + summary_text, + cover_image_src, + saved_at, + bottom_tab, + game_state_json, + current_story_json, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (user_id, world_key) DO UPDATE SET + owner_user_id = EXCLUDED.owner_user_id, + profile_id = EXCLUDED.profile_id, + world_type = EXCLUDED.world_type, + world_name = EXCLUDED.world_name, + world_subtitle = EXCLUDED.world_subtitle, + summary_text = EXCLUDED.summary_text, + cover_image_src = EXCLUDED.cover_image_src, + saved_at = EXCLUDED.saved_at, + bottom_tab = EXCLUDED.bottom_tab, + game_state_json = EXCLUDED.game_state_json, + current_story_json = EXCLUDED.current_story_json, + updated_at = EXCLUDED.updated_at`, + [ + userId, + archiveMeta.worldKey, + archiveMeta.ownerUserId, + archiveMeta.profileId, + archiveMeta.worldType, + archiveMeta.worldName, + archiveMeta.subtitle, + archiveMeta.summaryText, + archiveMeta.coverImageSrc, + syncedAt, + snapshot.bottomTab, + snapshot.gameState, + snapshot.currentStory, + syncedAt, + ], + ); + } + private async syncCustomWorldProfileFromSnapshot( userId: string, snapshot: SavedSnapshot, @@ -883,45 +1126,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort { bottomTab: payload.bottomTab, currentStory: payload.currentStory, } satisfies SavedSnapshot; - const now = new Date().toISOString(); - - const result = await this.db.query( - `INSERT INTO save_snapshots ( - user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (user_id) DO UPDATE SET - version = EXCLUDED.version, - saved_at = EXCLUDED.saved_at, - bottom_tab = EXCLUDED.bottom_tab, - game_state_json = EXCLUDED.game_state_json, - current_story_json = EXCLUDED.current_story_json, - updated_at = EXCLUDED.updated_at - RETURNING version, - saved_at AS "savedAt", - game_state_json AS "gameState", - bottom_tab AS "bottomTab", - current_story_json AS "currentStory"`, - [ - userId, - snapshot.version, - snapshot.savedAt, - snapshot.bottomTab, - snapshot.gameState, - snapshot.currentStory, - now, - ], - ); - - const row = result.rows[0]; - const persistedSnapshot = { - version: row.version, - savedAt: row.savedAt, - gameState: row.gameState, - bottomTab: row.bottomTab, - currentStory: row.currentStory, - } satisfies SavedSnapshot; + const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot); await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot); + await this.syncProfileSaveArchiveFromSnapshot(userId, persistedSnapshot); await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot); return persistedSnapshot; @@ -993,6 +1201,50 @@ export class RuntimeRepository implements RuntimeRepositoryPort { } satisfies ProfilePlayStatsResponse; } + async listProfileSaveArchives(userId: string) { + const result = await this.db.query( + `SELECT world_key AS "worldKey", + owner_user_id AS "ownerUserId", + profile_id AS "profileId", + world_type AS "worldType", + world_name AS "worldName", + world_subtitle AS subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + saved_at AS "savedAt", + bottom_tab AS "bottomTab", + game_state_json AS "gameState", + current_story_json AS "currentStory" + FROM profile_save_archives + WHERE user_id = $1 + ORDER BY saved_at DESC`, + [userId], + ); + + return result.rows.map((row) => toProfileSaveArchiveSummary(row)); + } + + async resumeProfileSaveArchive(userId: string, worldKey: string) { + const archive = await this.findProfileSaveArchive(userId, worldKey); + if (!archive) { + return null; + } + + const snapshot = { + version: SAVE_SNAPSHOT_VERSION, + savedAt: archive.savedAt, + gameState: archive.gameState, + bottomTab: archive.bottomTab, + currentStory: archive.currentStory, + } satisfies SavedSnapshot; + const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot); + + return { + entry: toProfileSaveArchiveSummary(archive), + snapshot: persistedSnapshot, + }; + } + async deleteSnapshot(userId: string) { await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [ userId, @@ -1001,9 +1253,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort { async getSettings(userId: string) { const result = await this.db.query( - `SELECT music_volume AS "musicVolume" - FROM runtime_settings - WHERE user_id = $1`, + `SELECT music_volume AS "musicVolume", + platform_theme AS "platformTheme" + FROM runtime_settings + WHERE user_id = $1`, [userId], ); const row = result.rows[0]; @@ -1013,26 +1266,41 @@ export class RuntimeRepository implements RuntimeRepositoryPort { typeof row?.musicVolume === 'number' ? row.musicVolume : DEFAULT_MUSIC_VOLUME, + platformTheme: + row?.platformTheme === 'dark' + ? 'dark' + : DEFAULT_PLATFORM_THEME, } satisfies RuntimeSettings; } async putSettings(userId: string, settings: RuntimeSettings) { const nextSettings = { musicVolume: Math.max(0, Math.min(1, settings.musicVolume)), + platformTheme: + settings.platformTheme === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME, } satisfies RuntimeSettings; const result = await this.db.query( - `INSERT INTO runtime_settings (user_id, music_volume, updated_at) - VALUES ($1, $2, $3) + `INSERT INTO runtime_settings (user_id, music_volume, platform_theme, updated_at) + VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET music_volume = EXCLUDED.music_volume, + platform_theme = EXCLUDED.platform_theme, updated_at = EXCLUDED.updated_at - RETURNING music_volume AS "musicVolume"`, - [userId, nextSettings.musicVolume, new Date().toISOString()], + RETURNING music_volume AS "musicVolume", + platform_theme AS "platformTheme"`, + [ + userId, + nextSettings.musicVolume, + nextSettings.platformTheme, + new Date().toISOString(), + ], ); return { musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume, + platformTheme: + result.rows[0]?.platformTheme ?? nextSettings.platformTheme, } satisfies RuntimeSettings; } diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index 9477a62a..75b8dc27 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -14,11 +14,16 @@ import type { PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, + ProfileSaveArchiveListResponse, + ProfileSaveArchiveResumeResponse, ProfileWalletLedgerResponse, 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, @@ -67,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, @@ -89,6 +100,7 @@ const saveSnapshotSchema = z.object({ const settingsSchema = z.object({ musicVolume: z.number().min(0).max(1), + platformTheme: z.enum(PLATFORM_THEMES), }); const platformBrowseHistoryEntrySchema = z.object({ @@ -184,6 +196,41 @@ export function createRuntimeRoutes(context: AppContext) { }); }); + router.get( + '/runtime/custom-world-gallery', + routeMeta({ operation: 'runtime.customWorldGallery.list' }), + asyncHandler(async (_request, response) => { + sendApiResponse(response, { + entries: await context.runtimeRepository.listPublishedCustomWorldGallery(), + } satisfies CustomWorldGalleryResponse); + }), + ); + + router.get( + '/runtime/custom-world-gallery/:ownerUserId/:profileId', + routeMeta({ operation: 'runtime.customWorldGallery.detail' }), + asyncHandler(async (request, response) => { + const ownerUserId = readParam(request.params.ownerUserId); + const profileId = readParam(request.params.profileId); + if (!ownerUserId || !profileId) { + throw badRequest('ownerUserId and profileId are required'); + } + + const entry = + await context.runtimeRepository.getPublishedCustomWorldGalleryDetail( + ownerUserId, + profileId, + ); + if (!entry) { + throw notFound('public custom world not found'); + } + + sendApiResponse(response, { + entry, + } satisfies CustomWorldGalleryDetailResponse); + }), + ); + router.use(requireAuth); router.use( '/runtime/custom-world/agent', @@ -313,6 +360,65 @@ export function createRuntimeRoutes(context: AppContext) { ); }); + routeCompatPaths('/profile/save-archives').forEach((path, index) => { + router.get( + path, + routeMeta({ + operation: + index === 0 + ? 'profile.saveArchives.list' + : 'profile.saveArchives.list.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: await context.runtimeRepository.listProfileSaveArchives( + request.userId!, + ), + }); + }), + ); + }); + + [ + '/profile/save-archives/:worldKey', + '/runtime/profile/save-archives/:worldKey', + ].forEach((path, index) => { + router.post( + path, + routeMeta({ + operation: + index === 0 + ? 'profile.saveArchives.resume' + : 'profile.saveArchives.resume.compat', + }), + asyncHandler(async (request, response) => { + const worldKey = + typeof request.params.worldKey === 'string' + ? request.params.worldKey.trim() + : ''; + + if (!worldKey) { + throw badRequest('worldKey 不能为空'); + } + + const resumedArchive = + await context.runtimeRepository.resumeProfileSaveArchive( + request.userId!, + worldKey, + ); + + if (!resumedArchive) { + throw notFound('指定存档不存在'); + } + + sendApiResponse(response, { + entry: resumedArchive.entry, + snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!, + }); + }), + ); + }); + router.post( '/llm/chat/completions', routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }), @@ -322,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' }), @@ -450,42 +574,6 @@ export function createRuntimeRoutes(context: AppContext) { }), ); - router.get( - '/runtime/custom-world-gallery', - routeMeta({ operation: 'runtime.customWorldGallery.list' }), - asyncHandler(async (_request, response) => { - sendApiResponse(response, { - entries: - await context.runtimeRepository.listPublishedCustomWorldGallery(), - } satisfies CustomWorldGalleryResponse); - }), - ); - - router.get( - '/runtime/custom-world-gallery/:ownerUserId/:profileId', - routeMeta({ operation: 'runtime.customWorldGallery.detail' }), - asyncHandler(async (request, response) => { - const ownerUserId = readParam(request.params.ownerUserId); - const profileId = readParam(request.params.profileId); - if (!ownerUserId || !profileId) { - throw badRequest('ownerUserId and profileId are required'); - } - - const entry = - await context.runtimeRepository.getPublishedCustomWorldGalleryDetail( - ownerUserId, - profileId, - ); - if (!entry) { - throw notFound('public custom world not found'); - } - - sendApiResponse(response, { - entry, - } satisfies CustomWorldGalleryDetailResponse); - }), - ); - router.put( '/runtime/custom-world-library/:profileId', routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), diff --git a/server-node/src/services/chatService.test.ts b/server-node/src/services/chatService.test.ts new file mode 100644 index 00000000..55c02d01 --- /dev/null +++ b/server-node/src/services/chatService.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { npcChatTurnRequestSchema } from './chatService.js'; + +test('npc chat turn schema normalizes player and dialogue aliases', () => { + const payload = npcChatTurnRequestSchema.parse({ + worldType: 'WUXIA', + player: { + id: 'hero', + name: '沈行', + }, + encounter: { + id: 'npc-liu', + npcName: '柳无声', + }, + monsters: [], + history: [], + context: { + sceneName: '客栈内室', + }, + dialogue: [ + { + speaker: 'player', + text: '你刚才那句话是什么意思?', + }, + ], + playerMessage: '你能说得再明白一点吗?', + npcState: { + affinity: 4, + chattedCount: 1, + recruited: false, + }, + questOfferContext: { + state: { + currentScenePreset: { + id: 'scene-inn', + }, + }, + encounter: { + id: 'npc-liu', + npcName: '柳无声', + }, + turnCount: 2, + }, + chatDirective: { + sceneActId: 'scene-inn-act-1', + turnLimit: 5, + remainingTurns: 3, + limitReason: 'negative_affinity', + closingMode: 'free', + forceExitAfterTurn: false, + }, + }); + + assert.equal(payload.character.name, '沈行'); + assert.deepEqual(payload.conversationHistory, [ + { + speaker: 'player', + text: '你刚才那句话是什么意思?', + }, + ]); + assert.equal(payload.questOfferContext?.turnCount, 2); + assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1'); + assert.equal(payload.chatDirective?.remainingTurns, 3); +}); diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts index c4682a18..020ecb6f 100644 --- a/server-node/src/services/chatService.ts +++ b/server-node/src/services/chatService.ts @@ -23,13 +23,29 @@ const baseCharacterChatSchema = z.object({ const baseNpcChatSchema = z.object({ worldType: z.string().trim().min(1), - character: jsonObjectSchema, + character: jsonObjectSchema.optional(), + player: jsonObjectSchema.optional(), encounter: jsonObjectSchema, monsters: z.array(jsonObjectSchema).default([]), history: z.array(jsonObjectSchema).default([]), context: jsonObjectSchema, }); +const npcChatDirectiveSchema = z.object({ + sceneActId: z.string().trim().min(1).nullable().optional(), + turnLimit: z.number().int().nonnegative().nullable().optional(), + remainingTurns: z.number().int().nonnegative().nullable().optional(), + limitReason: z.enum(['negative_affinity']).nullable().optional(), + closingMode: z.enum(['free', 'foreshadow_close']).nullable().optional(), + forceExitAfterTurn: z.boolean().optional(), +}); + +const npcChatQuestOfferContextSchema = z.object({ + state: jsonObjectSchema, + encounter: jsonObjectSchema, + turnCount: z.number().int().nonnegative(), +}); + export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({ conversationSummary: z.string().optional().default(''), playerMessage: z.string().trim().min(1), @@ -47,17 +63,37 @@ export const characterChatSummaryRequestSchema = baseCharacterChatSchema.extend( ) satisfies z.ZodType; export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({ + character: jsonObjectSchema, topic: z.string().trim().min(1), resultSummary: z.string().optional().default(''), }) satisfies z.ZodType; -export const npcChatTurnRequestSchema = baseNpcChatSchema.extend({ - conversationHistory: z.array(jsonObjectSchema).default([]), - playerMessage: z.string().trim().min(1), - npcState: jsonObjectSchema, -}) satisfies z.ZodType; +export const npcChatTurnRequestSchema = baseNpcChatSchema + .extend({ + conversationHistory: z.array(jsonObjectSchema).optional(), + dialogue: z.array(jsonObjectSchema).optional(), + playerMessage: z.string().trim().min(1), + npcState: jsonObjectSchema, + questOfferContext: npcChatQuestOfferContextSchema.nullable().optional(), + chatDirective: npcChatDirectiveSchema.nullable().optional(), + }) + .superRefine((value, ctx) => { + if (!value.character && !value.player) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'npc chat turn request requires character or player', + path: ['character'], + }); + } + }) + .transform((value) => ({ + ...value, + character: value.character ?? value.player ?? {}, + conversationHistory: value.conversationHistory ?? value.dialogue ?? [], + })) satisfies z.ZodType; export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({ + character: jsonObjectSchema, invitationText: z.string().trim().min(1), recruitSummary: z.string().optional().default(''), }) satisfies z.ZodType; diff --git a/server-node/src/services/customWorldAgentChangeSummaryService.ts b/server-node/src/services/customWorldAgentChangeSummaryService.ts index 2ac6c56e..50885e5d 100644 --- a/server-node/src/services/customWorldAgentChangeSummaryService.ts +++ b/server-node/src/services/customWorldAgentChangeSummaryService.ts @@ -45,6 +45,7 @@ function resolveCardTitle( draftProfile.landmarks.find((entry) => entry.id === cardId)?.name || draftProfile.threads.find((entry) => entry.id === cardId)?.title || draftProfile.chapters.find((entry) => entry.id === cardId)?.title || + draftProfile.sceneChapters.find((entry) => entry.id === cardId)?.title || (draftProfile.camp?.id === cardId ? draftProfile.camp.name : '') || '当前卡片' ); diff --git a/server-node/src/services/customWorldAgentDraftCompiler.test.ts b/server-node/src/services/customWorldAgentDraftCompiler.test.ts new file mode 100644 index 00000000..72f34471 --- /dev/null +++ b/server-node/src/services/customWorldAgentDraftCompiler.test.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { updateDraftCardSections } from './customWorldAgentDraftEditService.js'; +import { + CustomWorldAgentDraftCompiler, + normalizeFoundationDraftProfile, +} from './customWorldAgentDraftCompiler.js'; + +function createSceneChapterDraftProfile() { + return { + name: '雾港列岛', + summary: '潮雾、旧航道和失序港口缠在一起的海岛世界。', + tone: '冷峻、克制、带着海盐和旧铁锈味道。', + playerGoal: '先在失序的港口里站稳,再找出谁在提前布网。', + coreConflicts: ['旧航道解释权正在被重新争夺'], + iconicElements: ['潮雾钟声', '盐火灯塔'], + playableNpcs: [ + { + id: 'npc-lin', + name: '林潮', + title: '守潮人', + role: '码头引路人', + publicIdentity: '码头上最懂回潮时间的人。', + publicMask: '码头上最懂回潮时间的人。', + currentPressure: '必须决定今晚要不要帮玩家进港。', + hiddenHook: '他知道第一批被转移的货不是普通货。', + relationToPlayer: '对玩家保持试探,但还愿意给一次机会。', + threadIds: ['thread-smuggling'], + summary: '他像向导,也像仍在权衡站位的守门人。', + }, + ], + storyNpcs: [ + { + id: 'npc-yan', + name: '晏九', + title: '黑市中间人', + role: '封锁码头的人', + publicIdentity: '他负责把不该上岸的东西挡在潮线外。', + publicMask: '他负责把不该上岸的东西挡在潮线外。', + currentPressure: '必须让今晚的码头保持沉默。', + hiddenHook: '他已经替更大的势力提前清过一次场。', + relationToPlayer: '对玩家带着明显敌意,但又不想立刻翻脸。', + threadIds: ['thread-smuggling'], + summary: '他像威胁,也像握着下一跳线索的人。', + }, + ], + landmarks: [ + { + id: 'landmark-docks', + name: '潮汐码头', + description: '涨潮时会吞没半条旧栈桥的码头。', + purpose: '承接玩家和封锁者的第一次正式碰撞。', + mood: '潮声压低,空气里有明显不欢迎的意味。', + importance: '这里是玩家第一章必须破开的门槛。', + secret: '今晚靠岸的货和旧航道失踪案有关。', + dangerLevel: '中高', + imageSrc: '/images/scene/docks-base.webp', + characterIds: ['npc-lin', 'npc-yan'], + threadIds: ['thread-smuggling'], + summary: '这里不是背景,而是第一章真正开始收紧的地方。', + }, + ], + factions: [], + threads: [ + { + id: 'thread-smuggling', + title: '失踪货船去哪了', + type: 'main', + conflictType: '明线', + conflict: '有人在重写旧航道的夜间进出规则。', + stakes: '如果玩家跟不上这条线,整个港口都会先把他排除在外。', + characterIds: ['npc-lin', 'npc-yan'], + landmarkIds: ['landmark-docks'], + summary: '旧航道的解释权正在被重新洗牌。', + }, + ], + chapters: [ + { + id: 'chapter-docks', + title: '码头开场', + openingEvent: '一艘不该靠岸的船提前抵达潮线外。', + playerGoal: '先确认谁在码头上拥有发言权。', + characterIds: ['npc-lin', 'npc-yan'], + landmarkIds: ['landmark-docks'], + understandingShift: '玩家会意识到这不是简单的港口封锁。', + summary: '码头上的第一次碰撞会直接决定后续节奏。', + }, + ], + sceneChapters: [ + { + id: 'scene-chapter-docks', + sceneId: 'landmark-docks', + sceneName: '潮汐码头', + title: '潮汐码头章节', + summary: '玩家会在这里完成试探、逼问和第一次局部收束。', + linkedThreadIds: ['thread-smuggling'], + linkedLandmarkIds: ['landmark-docks'], + acts: [ + { + id: 'act-docks-1', + title: '雾里靠岸', + summary: '玩家刚抵达时,林潮先决定要不要放行。', + stageCoverage: ['opening'], + backgroundImageSrc: '/images/scene/docks-act-1.webp', + encounterNpcIds: ['npc-lin', 'npc-yan'], + primaryNpcId: 'npc-lin', + linkedThreadIds: ['thread-smuggling'], + actGoal: '先让玩家拿到码头里的第一句真话。', + transitionHook: '确认站位后,真正的封锁者会压上来。', + advanceRule: 'after_primary_contact', + }, + { + id: 'act-docks-2', + title: '封锁加压', + summary: '晏九开始把玩家往更危险的方向逼。', + stageCoverage: ['turning_point', 'climax', 'aftermath'], + backgroundImageSrc: '/images/scene/docks-act-2.webp', + encounterNpcIds: ['npc-yan', 'npc-lin'], + primaryNpcId: 'npc-yan', + linkedThreadIds: ['thread-smuggling'], + actGoal: '把矛盾推向必须接住的下一跳。', + transitionHook: '第 2 幕收束时必须把下一步追踪方向抛出来。', + advanceRule: 'after_chapter_resolution', + }, + ], + }, + ], + }; +} + +test('draft compiler compiles scene chapter cards with act-level editable sections', () => { + const draftProfile = createSceneChapterDraftProfile(); + const compiler = new CustomWorldAgentDraftCompiler(); + + const draftCards = compiler.compileDraftCards(draftProfile); + const sceneChapterCard = draftCards.find((entry) => entry.kind === 'scene_chapter'); + const detail = compiler.getDraftCardDetail(draftProfile, 'scene-chapter-docks'); + + assert.ok(sceneChapterCard); + assert.equal(sceneChapterCard?.title, '潮汐码头章节'); + assert.match(sceneChapterCard?.subtitle ?? '', /2 幕/u); + assert.ok(detail); + assert.equal(detail?.kind, 'scene_chapter'); + assert.ok(detail?.editableSectionIds.includes('title')); + assert.ok(detail?.editableSectionIds.includes('act:act-docks-1:title')); + assert.ok( + detail?.sections.some( + (section) => + section.id === 'act:act-docks-1:backgroundImageSrc' && + section.value === '/images/scene/docks-act-1.webp', + ), + ); + assert.ok( + detail?.sections.some( + (section) => + section.id === 'act:act-docks-2:primaryNpcId' && + section.value.includes('晏九'), + ), + ); +}); + +test('updateDraftCardSections rewrites scene chapter act NPC order and primary npc', () => { + const updatedDraftProfile = updateDraftCardSections({ + draftProfile: JSON.parse(JSON.stringify(createSceneChapterDraftProfile())), + cardId: 'scene-chapter-docks', + sections: [ + { + sectionId: 'title', + value: '潮汐码头对峙章', + }, + { + sectionId: 'act:act-docks-1:title', + value: '封港前夜', + }, + { + sectionId: 'act:act-docks-1:backgroundImageSrc', + value: '/images/scene/docks-act-1-night.webp', + }, + { + sectionId: 'act:act-docks-1:encounterNpcIds', + value: '晏九\n林潮', + }, + { + sectionId: 'act:act-docks-1:transitionHook', + value: '第 1 幕最后要把玩家逼到必须继续追的方向上。', + }, + ], + }); + + const normalized = normalizeFoundationDraftProfile(updatedDraftProfile); + const updatedSceneChapter = normalized?.sceneChapters.find( + (entry) => entry.id === 'scene-chapter-docks', + ); + const updatedAct = updatedSceneChapter?.acts.find((entry) => entry.id === 'act-docks-1'); + + assert.ok(updatedSceneChapter); + assert.ok(updatedAct); + assert.equal(updatedSceneChapter?.title, '潮汐码头对峙章'); + assert.equal(updatedAct?.title, '封港前夜'); + assert.equal( + updatedAct?.backgroundImageSrc, + '/images/scene/docks-act-1-night.webp', + ); + assert.deepEqual(updatedAct?.encounterNpcIds, ['npc-yan', 'npc-lin']); + assert.equal(updatedAct?.primaryNpcId, 'npc-yan'); + assert.equal( + updatedAct?.transitionHook, + '第 1 幕最后要把玩家逼到必须继续追的方向上。', + ); +}); diff --git a/server-node/src/services/customWorldAgentDraftCompiler.ts b/server-node/src/services/customWorldAgentDraftCompiler.ts index 2d459f0a..648fd663 100644 --- a/server-node/src/services/customWorldAgentDraftCompiler.ts +++ b/server-node/src/services/customWorldAgentDraftCompiler.ts @@ -10,6 +10,8 @@ import type { CustomWorldFoundationDraftFaction, CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftProfile, + CustomWorldFoundationDraftSceneAct, + CustomWorldFoundationDraftSceneChapter, CustomWorldFoundationDraftThread, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { @@ -74,6 +76,39 @@ const EDITABLE_CAMP_SECTION_IDS = [ 'dangerLevel', ] as const; +const EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS = [ + 'title', + 'summary', +] as const; + +const SCENE_ACT_STAGE_ORDER = [ + 'opening', + 'expansion', + 'turning_point', + 'climax', + 'aftermath', +] as const; + +const SCENE_ACT_STAGE_LABELS: Record< + CustomWorldFoundationDraftSceneAct['stageCoverage'][number], + string +> = { + opening: '开场', + expansion: '铺展', + turning_point: '转折', + climax: '高潮', + aftermath: '余波', +}; + +const SCENE_ACT_ADVANCE_RULE_LABELS: Record< + CustomWorldFoundationDraftSceneAct['advanceRule'], + string +> = { + after_primary_contact: '主角色首次有效接触后推进', + after_active_step_complete: '当前主动步骤完成后推进', + after_chapter_resolution: '章节进入收束后推进', +}; + function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } @@ -101,6 +136,28 @@ function toStringArray(value: unknown, maxCount = 8) { ); } +function normalizeCharacterSkills(value: unknown, fallbackName: string) { + const skills = toRecordArray(value) + .map((item, index) => ({ + id: toText(item.id) || `skill-${index + 1}`, + name: toText(item.name) || `技能${index + 1}`, + actionPreviewConfig: toRecord(item.actionPreviewConfig), + })) + .filter((item) => Boolean(item.id)); + + if (skills.length > 0) { + return skills; + } + + return [ + { + id: 'skill-1', + name: `${clampText(fallbackName, 10) || '角色'}招牌动作`, + actionPreviewConfig: null, + }, + ]; +} + function slugify(value: string) { const normalized = value .trim() @@ -149,9 +206,40 @@ function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) { if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS]; if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS]; if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS]; + if (kind === 'scene_chapter') return [...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS]; return []; } +function resolveSceneChapterEditableSectionIds( + sceneChapter: CustomWorldFoundationDraftSceneChapter, +) { + return [ + ...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS, + ...sceneChapter.acts.flatMap((act) => [ + `act:${act.id}:title`, + `act:${act.id}:summary`, + `act:${act.id}:backgroundImageSrc`, + `act:${act.id}:encounterNpcIds`, + `act:${act.id}:actGoal`, + `act:${act.id}:transitionHook`, + ]), + ]; +} + +function resolveSceneActStageCoverageLabel( + stageCoverage: CustomWorldFoundationDraftSceneAct['stageCoverage'], +) { + return stageCoverage + .map((stage) => SCENE_ACT_STAGE_LABELS[stage] || stage) + .join('、'); +} + +function resolveSceneActAdvanceRuleLabel( + advanceRule: CustomWorldFoundationDraftSceneAct['advanceRule'], +) { + return SCENE_ACT_ADVANCE_RULE_LABELS[advanceRule] || advanceRule; +} + function normalizeFaction( value: unknown, index: number, @@ -243,6 +331,7 @@ function normalizeCharacter( ].join(';'), 120, ), + skills: normalizeCharacterSkills(record.skills, role || title || name || '角色'), imageSrc: toText(record.imageSrc) || null, generatedVisualAssetId: toText(record.generatedVisualAssetId) || null, generatedAnimationSetId: toText(record.generatedAnimationSetId) || null, @@ -287,6 +376,7 @@ function normalizeLandmark( importance: secret || '玩家第一次抵达就会意识到它不只是背景', secret: secret || '玩家第一次抵达就会意识到它不只是背景', dangerLevel: dangerLevel || '中', + imageSrc: toText(record.imageSrc) || null, characterIds: toStringArray(record.characterIds, 8), threadIds: toStringArray(record.threadIds, 8), summary: @@ -410,6 +500,7 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null { description: description || '玩家暂时还能整顿情报和喘口气的地方', mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势', dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势', + imageSrc: toText(record.imageSrc) || null, summary: summary || clampText( @@ -422,6 +513,342 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null { }; } +function normalizeStageCoverage(value: unknown) { + const stageCoverage = Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter( + ( + entry, + ): entry is CustomWorldFoundationDraftSceneAct['stageCoverage'][number] => + SCENE_ACT_STAGE_ORDER.includes( + entry as (typeof SCENE_ACT_STAGE_ORDER)[number], + ), + ) + : []; + + return [...new Set(stageCoverage)]; +} + +function buildFallbackSceneActStageCoverage(index: number, actCount: number) { + if (actCount <= 2) { + return index === 0 + ? (['opening', 'expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage']) + : (['turning_point', 'climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']); + } + + if (actCount === 3) { + if (index === 0) { + return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + } + if (index === 1) { + return ['expansion', 'turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + } + return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + } + + if (actCount === 4) { + if (index === 0) return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + if (index === 1) return ['expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + if (index === 2) return ['turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + } + + return [SCENE_ACT_STAGE_ORDER[Math.min(index, SCENE_ACT_STAGE_ORDER.length - 1)]]; +} + +function normalizeSceneAct( + value: unknown, + index: number, + fallback: { + sceneId: string; + sceneName: string; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + linkedThreadIds: string[]; + actCount: number; + }, +): CustomWorldFoundationDraftSceneAct | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const title = toText(record.title); + const summary = toText(record.summary); + const encounterNpcIds = toStringArray( + record.encounterNpcIds, + Math.max(1, fallback.encounterNpcIds.length || 8), + ); + const stageCoverage = normalizeStageCoverage(record.stageCoverage); + + if (!title && !summary && encounterNpcIds.length === 0) { + return null; + } + + const resolvedEncounterNpcIds = + encounterNpcIds.length > 0 ? encounterNpcIds : fallback.encounterNpcIds; + const primaryNpcId = toText(record.primaryNpcId) || resolvedEncounterNpcIds[0] || ''; + + return { + id: + toText(record.id) || + createId(`scene-act-${fallback.sceneId}`, title || fallback.sceneName, index), + title: title || `第 ${index + 1} 幕`, + summary: + summary || + clampText( + [ + title || `第 ${index + 1} 幕`, + toText(record.actGoal) || '这一幕仍需继续精修', + ].join(';'), + 120, + ), + stageCoverage: + stageCoverage.length > 0 + ? stageCoverage + : buildFallbackSceneActStageCoverage(index, fallback.actCount), + backgroundImageSrc: + toText(record.backgroundImageSrc) || fallback.backgroundImageSrc || null, + backgroundAssetId: toText(record.backgroundAssetId) || null, + encounterNpcIds: resolvedEncounterNpcIds, + primaryNpcId, + linkedThreadIds: + toStringArray(record.linkedThreadIds, 8).length > 0 + ? toStringArray(record.linkedThreadIds, 8) + : fallback.linkedThreadIds, + actGoal: + toText(record.actGoal) || + (index === 0 + ? `先在${fallback.sceneName}接住开场 lead` + : index === fallback.actCount - 1 + ? `把${fallback.sceneName}这一章收住` + : `继续逼近${fallback.sceneName}的核心压力`), + transitionHook: + toText(record.transitionHook) || + (index === fallback.actCount - 1 + ? '这一幕结束后会把问题推向下一跳。' + : '完成当前推进后,局势会进入下一幕。'), + advanceRule: + toText(record.advanceRule) === 'after_primary_contact' || + toText(record.advanceRule) === 'after_active_step_complete' || + toText(record.advanceRule) === 'after_chapter_resolution' + ? (toText(record.advanceRule) as CustomWorldFoundationDraftSceneAct['advanceRule']) + : index === 0 + ? 'after_primary_contact' + : index === fallback.actCount - 1 + ? 'after_chapter_resolution' + : 'after_active_step_complete', + }; +} + +function buildFallbackSceneActs(params: { + sceneId: string; + sceneName: string; + sceneSummary: string; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + linkedThreadIds: string[]; +}) { + const actCount = 3; + + return [ + { + id: `${params.sceneId}-act-1`, + title: `初见 ${params.sceneName}`, + summary: clampText( + `玩家第一次真正接住${params.sceneName}这一章的入口。${params.sceneSummary}`, + 120, + ), + stageCoverage: buildFallbackSceneActStageCoverage(0, actCount), + backgroundImageSrc: params.backgroundImageSrc || null, + backgroundAssetId: null, + encounterNpcIds: params.encounterNpcIds, + primaryNpcId: params.encounterNpcIds[0] || '', + linkedThreadIds: params.linkedThreadIds, + actGoal: `先在${params.sceneName}接住开场 lead`, + transitionHook: '和主角色完成首次有效接触后,局势会继续加压。', + advanceRule: 'after_primary_contact', + }, + { + id: `${params.sceneId}-act-2`, + title: `${params.sceneName}承压`, + summary: clampText( + `玩家开始确认${params.sceneName}不只是背景,而是这一章真正承压的地方。`, + 120, + ), + stageCoverage: buildFallbackSceneActStageCoverage(1, actCount), + backgroundImageSrc: params.backgroundImageSrc || null, + backgroundAssetId: null, + encounterNpcIds: params.encounterNpcIds, + primaryNpcId: params.encounterNpcIds[0] || '', + linkedThreadIds: params.linkedThreadIds, + actGoal: `继续逼近${params.sceneName}的核心压力`, + transitionHook: '完成当前主动 step 后,这一章会转向收束。', + advanceRule: 'after_active_step_complete', + }, + { + id: `${params.sceneId}-act-3`, + title: `${params.sceneName}收束`, + summary: clampText( + `这一幕承担${params.sceneName}的局部收束和下一跳 handoff。`, + 120, + ), + stageCoverage: buildFallbackSceneActStageCoverage(2, actCount), + backgroundImageSrc: params.backgroundImageSrc || null, + backgroundAssetId: null, + encounterNpcIds: params.encounterNpcIds, + primaryNpcId: params.encounterNpcIds[0] || '', + linkedThreadIds: params.linkedThreadIds, + actGoal: `把${params.sceneName}这一章收住`, + transitionHook: '这一幕结束后需要把后续方向明确抛给玩家。', + advanceRule: 'after_chapter_resolution', + }, + ] satisfies CustomWorldFoundationDraftSceneAct[]; +} + +function normalizeSceneChapter( + value: unknown, + index: number, + fallback: { + sceneId: string; + sceneName: string; + sceneSummary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + }, +): CustomWorldFoundationDraftSceneChapter | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const sceneId = toText(record.sceneId) || fallback.sceneId; + const sceneName = toText(record.sceneName) || fallback.sceneName; + const title = toText(record.title); + const summary = toText(record.summary); + const actsInput = Array.isArray(record.acts) ? record.acts : []; + const actCount = Math.min(5, Math.max(2, actsInput.length || 3)); + const linkedThreadIds = + toStringArray(record.linkedThreadIds, 8).length > 0 + ? toStringArray(record.linkedThreadIds, 8) + : fallback.linkedThreadIds; + const linkedLandmarkIds = + toStringArray(record.linkedLandmarkIds, 8).length > 0 + ? toStringArray(record.linkedLandmarkIds, 8) + : fallback.linkedLandmarkIds; + + const acts = actsInput + .map((entry, actIndex) => + normalizeSceneAct(entry, actIndex, { + sceneId, + sceneName, + backgroundImageSrc: fallback.backgroundImageSrc, + encounterNpcIds: fallback.encounterNpcIds, + linkedThreadIds, + actCount, + }), + ) + .filter((entry): entry is CustomWorldFoundationDraftSceneAct => Boolean(entry)) + .slice(0, 5); + + return { + id: toText(record.id) || createId('scene-chapter', sceneName || title, index), + sceneId, + sceneName, + title: title || `${sceneName}章节`, + summary: + summary || + clampText( + [ + sceneName, + fallback.sceneSummary || '这一章的场景节拍仍可继续收紧', + ].join(':'), + 140, + ), + linkedThreadIds, + linkedLandmarkIds, + acts: acts.length >= 2 ? acts : buildFallbackSceneActs({ + sceneId, + sceneName, + sceneSummary: fallback.sceneSummary, + backgroundImageSrc: fallback.backgroundImageSrc, + encounterNpcIds: fallback.encounterNpcIds, + linkedThreadIds, + }), + }; +} + +function buildFallbackSceneChapters(params: { + landmarks: CustomWorldFoundationDraftLandmark[]; + characters: CustomWorldFoundationDraftCharacter[]; + threads: CustomWorldFoundationDraftThread[]; + chapters: CustomWorldFoundationDraftChapter[]; +}) { + const fallbackCharacterIds = params.characters.slice(0, 3).map((entry) => entry.id); + + return params.landmarks.map((landmark, index) => { + const matchingChapter = + params.chapters.find((chapter) => chapter.landmarkIds.includes(landmark.id)) ?? null; + const encounterNpcIds = + landmark.characterIds.length > 0 ? landmark.characterIds : fallbackCharacterIds; + const linkedThreadIds = + landmark.threadIds.length > 0 + ? landmark.threadIds + : params.threads + .filter((thread) => thread.landmarkIds.includes(landmark.id)) + .map((thread) => thread.id) + .slice(0, 4); + + return { + id: `scene-chapter-${landmark.id}`, + sceneId: landmark.id, + sceneName: landmark.name, + title: matchingChapter?.title || `${landmark.name}章节`, + summary: + matchingChapter?.summary || + clampText( + [landmark.summary, matchingChapter?.openingEvent || '这一章会从这里真正展开'] + .filter(Boolean) + .join(';'), + 140, + ), + linkedThreadIds, + linkedLandmarkIds: [landmark.id], + acts: buildFallbackSceneActs({ + sceneId: landmark.id, + sceneName: landmark.name, + sceneSummary: landmark.summary, + backgroundImageSrc: landmark.imageSrc || null, + encounterNpcIds, + linkedThreadIds, + }), + } satisfies CustomWorldFoundationDraftSceneChapter; + }); +} + +function resolveSceneChapterFallbackFromRecord(item: unknown, index: number) { + const record = toRecord(item); + const linkedLandmarkIds = toStringArray(record?.linkedLandmarkIds, 8); + return { + sceneId: toText(record?.sceneId) || linkedLandmarkIds[0] || `scene-${index + 1}`, + sceneName: + toText(record?.sceneName) || + toText(record?.title) || + `场景章节 ${index + 1}`, + sceneSummary: + toText(record?.summary) || + '这一章仍可继续精修场景幕结构。', + linkedThreadIds: toStringArray(record?.linkedThreadIds, 8), + linkedLandmarkIds, + backgroundImageSrc: toText(record?.backgroundImageSrc) || null, + encounterNpcIds: toStringArray(record?.encounterNpcIds, 8), + }; +} + export function normalizeFoundationDraftProfile( value: unknown, ): CustomWorldFoundationDraftProfile | null { @@ -474,6 +901,28 @@ export function normalizeFoundationDraftProfile( Boolean(item), ), ); + const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]); + const explicitSceneChapters = toRecordArray(record.sceneChapters) + .map((item, index) => + normalizeSceneChapter( + item, + index, + resolveSceneChapterFallbackFromRecord(item, index), + ), + ) + .filter((item): item is CustomWorldFoundationDraftSceneChapter => + Boolean(item), + ); + const sceneChapters = dedupeById( + explicitSceneChapters.length > 0 + ? explicitSceneChapters + : buildFallbackSceneChapters({ + landmarks, + characters: mergedCharacters, + threads, + chapters, + }) + ); const camp = normalizeCamp(record.camp); const hasStructuredFoundationContent = playableNpcs.length > 0 || @@ -482,13 +931,12 @@ export function normalizeFoundationDraftProfile( factions.length > 0 || threads.length > 0 || chapters.length > 0 || + sceneChapters.length > 0 || Boolean(camp); if (!hasStructuredFoundationContent) { return null; } - - const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]); const coreConflicts = toStringArray(record.coreConflicts, 6); return { @@ -539,6 +987,7 @@ export function normalizeFoundationDraftProfile( factions, threads, chapters, + sceneChapters, worldHook: toText(record.worldHook) || name || summary, playerPremise: toText(record.playerPremise), openingSituation: toText(record.openingSituation), @@ -636,6 +1085,84 @@ function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) { return warnings; } +function buildSceneChapterWarnings(params: { + sceneChapter: CustomWorldFoundationDraftSceneChapter; + characterById: Map; + threadById: Map; + landmarkById: Map; +}) { + const { sceneChapter, characterById, threadById, landmarkById } = params; + const warnings: string[] = []; + + if (sceneChapter.acts.length < 2) { + warnings.push('这个场景章节至少需要 2 幕。'); + } + if (sceneChapter.acts.length > 5) { + warnings.push('这个场景章节当前超过 5 幕,建议先收束到 5 幕以内。'); + } + + const linkedLandmarks = sceneChapter.linkedLandmarkIds + .map((id) => landmarkById.get(id)) + .filter((entry): entry is CustomWorldFoundationDraftLandmark => Boolean(entry)); + + sceneChapter.acts.forEach((act, index) => { + const actLabel = `第 ${index + 1} 幕`; + const primaryNpcId = act.encounterNpcIds[0] || act.primaryNpcId; + const actThreadIds = + act.linkedThreadIds.length > 0 + ? act.linkedThreadIds + : sceneChapter.linkedThreadIds; + + if (!act.backgroundImageSrc && !act.backgroundAssetId) { + warnings.push(`${actLabel}还没有绑定背景图。`); + } + if (act.encounterNpcIds.length === 0) { + warnings.push(`${actLabel}还没有配置相遇 NPC。`); + } + if (!primaryNpcId) { + warnings.push(`${actLabel}缺少主角色。`); + } + if (act.primaryNpcId && act.primaryNpcId !== (act.encounterNpcIds[0] ?? '')) { + warnings.push(`${actLabel}的主角色必须放在相遇 NPC 的第一位。`); + } + if (actThreadIds.length === 0) { + warnings.push(`${actLabel}还没有挂到明确线程。`); + } + + const unresolvedNpcIds = act.encounterNpcIds.filter((id) => !characterById.has(id)); + if (unresolvedNpcIds.length > 0) { + warnings.push( + `${actLabel}存在未进入当前世界角色池的 NPC:${unresolvedNpcIds + .slice(0, 3) + .join('、')}。`, + ); + } + + const unresolvedThreadIds = actThreadIds.filter((id) => !threadById.has(id)); + if (unresolvedThreadIds.length > 0) { + warnings.push( + `${actLabel}存在未绑定的线程引用:${unresolvedThreadIds + .slice(0, 3) + .join('、')}。`, + ); + } + + if (primaryNpcId && characterById.has(primaryNpcId)) { + const linkedToLandmark = linkedLandmarks.some((landmark) => + landmark.characterIds.includes(primaryNpcId), + ); + const linkedToThread = actThreadIds.some((threadId) => + threadById.get(threadId)?.characterIds.includes(primaryNpcId), + ); + if (!linkedToLandmark && !linkedToThread) { + warnings.push(`${actLabel}的主角色和当前场景/线程的关联还不够明确。`); + } + } + }); + + return warnings; +} + function buildCampWarnings() { return [] as string[]; } @@ -650,6 +1177,7 @@ function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharac generatedVisualAssetId: character.generatedVisualAssetId, generatedAnimationSetId: character.generatedAnimationSetId, animationMap: character.animationMap, + skills: character.skills ?? [], }, roleKind: 'story', }); @@ -773,6 +1301,7 @@ export class CustomWorldAgentDraftCompiler { ...profile.landmarks.map((entry) => entry.id), ...profile.threads.map((entry) => entry.id), ...profile.chapters.map((entry) => entry.id), + ...profile.sceneChapters.map((entry) => entry.id), ].slice(0, 12), sections: [ buildSection('title', '标题', profile.name), @@ -1025,6 +1554,129 @@ export class CustomWorldAgentDraftCompiler { }); }); + profile.sceneChapters.forEach((sceneChapter) => { + const uniqueNpcIds = [...new Set(sceneChapter.acts.flatMap((act) => act.encounterNpcIds))]; + const readyBackgroundCount = sceneChapter.acts.filter( + (act) => Boolean(act.backgroundImageSrc || act.backgroundAssetId), + ).length; + const warnings = buildSceneChapterWarnings({ + sceneChapter, + characterById, + threadById, + landmarkById, + }); + + pushCard({ + id: sceneChapter.id, + kind: 'scene_chapter', + title: sceneChapter.title, + subtitle: clampText( + `${sceneChapter.sceneName} · ${sceneChapter.acts.length} 幕 · 背景 ${readyBackgroundCount}/${sceneChapter.acts.length}`, + 40, + ), + summary: sceneChapter.summary, + linkedIds: [ + ...sceneChapter.linkedLandmarkIds, + ...sceneChapter.linkedThreadIds, + ...uniqueNpcIds, + ].slice(0, 12), + sections: [ + buildSection('sceneName', '所属场景', sceneChapter.sceneName), + buildSection('title', '场景章节标题', sceneChapter.title), + buildSection('summary', '场景章节摘要', sceneChapter.summary), + buildSection( + 'actOverview', + '幕结构总览', + sceneChapter.acts + .map((act, index) => { + const primaryNpcName = + resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) || + '待补主角色'; + const supportNpcNames = + resolveCharacterNames(act.encounterNpcIds.slice(1)) || '当前没有辅助 NPC'; + return [ + `第 ${index + 1} 幕|${act.title}`, + `主角色:${primaryNpcName}`, + `辅助 NPC:${supportNpcNames}`, + `目标:${act.actGoal}`, + `过渡:${act.transitionHook}`, + ].join('\n'); + }) + .join('\n\n'), + ), + buildSection( + 'linkedLandmarkIds', + '关联地点', + resolveLandmarkNames(sceneChapter.linkedLandmarkIds), + ), + buildSection( + 'linkedThreadIds', + '关联线程', + resolveThreadTitles(sceneChapter.linkedThreadIds), + ), + ...sceneChapter.acts.flatMap((act, index) => { + const actLabel = `第 ${index + 1} 幕`; + const encounterNpcValue = + resolveCharacterNames(act.encounterNpcIds) || + act.encounterNpcIds.join('、'); + const primaryNpcValue = + resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) || + act.encounterNpcIds[0] || + act.primaryNpcId; + const actThreadTitles = + resolveThreadTitles( + act.linkedThreadIds.length > 0 + ? act.linkedThreadIds + : sceneChapter.linkedThreadIds, + ) || '待补线程挂钩'; + + return [ + buildSection(`act:${act.id}:title`, `${actLabel}标题`, act.title), + buildSection(`act:${act.id}:summary`, `${actLabel}摘要`, act.summary), + buildSection( + `act:${act.id}:backgroundImageSrc`, + `${actLabel}背景图`, + act.backgroundImageSrc || act.backgroundAssetId || '', + ), + buildSection( + `act:${act.id}:encounterNpcIds`, + `${actLabel}相遇 NPC`, + encounterNpcValue, + ), + buildSection( + `act:${act.id}:primaryNpcId`, + `${actLabel}主角色`, + primaryNpcValue, + ), + buildSection( + `act:${act.id}:stageCoverage`, + `${actLabel}阶段覆盖`, + resolveSceneActStageCoverageLabel(act.stageCoverage), + ), + buildSection(`act:${act.id}:actGoal`, `${actLabel}目标`, act.actGoal), + buildSection( + `act:${act.id}:transitionHook`, + `${actLabel}过渡钩子`, + act.transitionHook, + ), + buildSection( + `act:${act.id}:linkedThreadIds`, + `${actLabel}关联线程`, + actThreadTitles, + ), + buildSection( + `act:${act.id}:advanceRule`, + `${actLabel}推进规则`, + resolveSceneActAdvanceRuleLabel(act.advanceRule), + ), + ]; + }), + ], + editableSectionIds: resolveSceneChapterEditableSectionIds(sceneChapter), + warningMessages: warnings, + }); + }); + return cards; } } diff --git a/server-node/src/services/customWorldAgentDraftEditService.ts b/server-node/src/services/customWorldAgentDraftEditService.ts index f01dd7a5..c81c0aaa 100644 --- a/server-node/src/services/customWorldAgentDraftEditService.ts +++ b/server-node/src/services/customWorldAgentDraftEditService.ts @@ -23,6 +23,7 @@ const EDITABLE_SECTION_IDS = { thread: new Set(['title', 'summary', 'conflictType', 'stakes']), chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']), camp: new Set(['name', 'description', 'dangerLevel']), + sceneChapter: new Set(['title', 'summary']), } as const; function normalizePatches(sections: DraftSectionPatch[]) { @@ -52,6 +53,17 @@ function parseStringList(value: string) { return [...new Set(value.split(/[\n;;]+/u).map((item) => item.trim()).filter(Boolean))]; } +function parseReferenceList(value: string) { + return [ + ...new Set( + value + .split(/[\n,,、;;]+/u) + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; +} + function resolveThreadType(value: string) { if (value.includes('暗') || value.toLowerCase() === 'hidden') { return 'hidden' as const; @@ -60,6 +72,61 @@ function resolveThreadType(value: string) { return 'main' as const; } +function parseSceneActSectionId(sectionId: string) { + const match = sectionId.match( + /^act:([^:]+):(title|summary|backgroundImageSrc|encounterNpcIds|actGoal|transitionHook)$/u, + ); + if (!match) { + return null; + } + + return { + actId: match[1], + field: match[2] as + | 'title' + | 'summary' + | 'backgroundImageSrc' + | 'encounterNpcIds' + | 'actGoal' + | 'transitionHook', + }; +} + +function resolveCharacterIdByReference( + value: string, + draftProfile: NonNullable>, +) { + const characters = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs]; + return ( + characters.find((entry) => entry.id === value)?.id || + characters.find((entry) => entry.name === value)?.id || + '' + ); +} + +function parseEncounterNpcIds( + value: string, + draftProfile: NonNullable>, +) { + const references = parseReferenceList(value); + if (references.length === 0) { + throw badRequest('scene act requires at least one encounter NPC'); + } + + const unresolvedReferences = references.filter( + (reference) => !resolveCharacterIdByReference(reference, draftProfile), + ); + if (unresolvedReferences.length > 0) { + throw badRequest( + `unknown scene act NPC reference: ${unresolvedReferences.join('、')}`, + ); + } + + return references.map((reference) => + resolveCharacterIdByReference(reference, draftProfile), + ); +} + export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) { const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); if (!draftProfile) { @@ -293,6 +360,70 @@ export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) { return draftProfile as unknown as Record; } + const sceneChapter = draftProfile.sceneChapters.find( + (entry) => entry.id === params.cardId, + ); + if (sceneChapter) { + patches.forEach(({ sectionId, value }) => { + if (EDITABLE_SECTION_IDS.sceneChapter.has(sectionId as never)) { + if (sectionId === 'title') { + sceneChapter.title = value; + return; + } + + if (sectionId === 'summary') { + sceneChapter.summary = value; + } + return; + } + + const parsedSceneActSection = parseSceneActSectionId(sectionId); + if (!parsedSceneActSection) { + throw badRequest(`section ${sectionId} is not editable for scene_chapter`); + } + + const targetAct = sceneChapter.acts.find( + (entry) => entry.id === parsedSceneActSection.actId, + ); + if (!targetAct) { + throw notFound(`scene act ${parsedSceneActSection.actId} not found`); + } + + if (parsedSceneActSection.field === 'title') { + targetAct.title = value; + return; + } + + if (parsedSceneActSection.field === 'summary') { + targetAct.summary = value; + return; + } + + if (parsedSceneActSection.field === 'backgroundImageSrc') { + targetAct.backgroundImageSrc = value || null; + return; + } + + if (parsedSceneActSection.field === 'encounterNpcIds') { + const encounterNpcIds = parseEncounterNpcIds(value, draftProfile); + targetAct.encounterNpcIds = encounterNpcIds; + targetAct.primaryNpcId = encounterNpcIds[0] || ''; + return; + } + + if (parsedSceneActSection.field === 'actGoal') { + targetAct.actGoal = value; + return; + } + + if (parsedSceneActSection.field === 'transitionHook') { + targetAct.transitionHook = value; + } + }); + + return draftProfile as unknown as Record; + } + if (draftProfile.camp?.id === params.cardId) { patches.forEach(({ sectionId, value }) => { if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) { diff --git a/server-node/src/services/customWorldAgentEntityGenerationService.ts b/server-node/src/services/customWorldAgentEntityGenerationService.ts index 999fd3af..358ed137 100644 --- a/server-node/src/services/customWorldAgentEntityGenerationService.ts +++ b/server-node/src/services/customWorldAgentEntityGenerationService.ts @@ -3,6 +3,12 @@ import type { CustomWorldFoundationDraftLandmark, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { badRequest } from '../errors.js'; +import { + buildCustomWorldAgentCharacterExpansionPrompt, + buildCustomWorldAgentLandmarkExpansionPrompt, + CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT, + CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT, +} from '../prompts/customWorldAgentPrompts.js'; import { getWorldFoundationCardId, normalizeFoundationDraftProfile, @@ -438,22 +444,18 @@ async function requestCharacterSuggestionsFromLlm(params: { params.profile.summary; const content = await params.llmClient.requestMessageContent({ - systemPrompt: - '你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。', - userPrompt: [ - `当前世界:${params.profile.name}`, - `世界摘要:${params.profile.summary}`, - `创作意图摘要:${creatorIntentSummary}`, - `参考锚点:${anchorSummary}`, - `已有角色:${getAllCharacters(params.profile) + systemPrompt: CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT, + userPrompt: buildCustomWorldAgentCharacterExpansionPrompt({ + worldName: params.profile.name, + worldSummary: params.profile.summary, + creatorIntentSummary, + anchorSummary, + existingNames: getAllCharacters(params.profile) .slice(0, 10) - .map((entry) => entry.name) - .join('、') || '暂无'}`, - `数量:${params.count}`, - `补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, - '返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。', - 'threadIds 必须优先引用现有线程 id。', - ].join('\n'), + .map((entry) => entry.name), + count: params.count, + promptSeed: params.promptSeed, + }), timeoutMs: 45000, debugLabel: 'custom-world-agent-generate-characters', }); @@ -478,22 +480,18 @@ async function requestLandmarkSuggestionsFromLlm(params: { params.profile.summary; const content = await params.llmClient.requestMessageContent({ - systemPrompt: - '你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。', - userPrompt: [ - `当前世界:${params.profile.name}`, - `世界摘要:${params.profile.summary}`, - `创作意图摘要:${creatorIntentSummary}`, - `参考锚点:${anchorSummary}`, - `已有地点:${params.profile.landmarks + systemPrompt: CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT, + userPrompt: buildCustomWorldAgentLandmarkExpansionPrompt({ + worldName: params.profile.name, + worldSummary: params.profile.summary, + creatorIntentSummary, + anchorSummary, + existingNames: params.profile.landmarks .slice(0, 10) - .map((entry) => entry.name) - .join('、') || '暂无'}`, - `数量:${params.count}`, - `补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, - '返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。', - 'threadIds / characterIds 必须优先引用现有对象 id。', - ].join('\n'), + .map((entry) => entry.name), + count: params.count, + promptSeed: params.promptSeed, + }), timeoutMs: 45000, debugLabel: 'custom-world-agent-generate-landmarks', }); diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts index 4760763f..4919c7fd 100644 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -8,6 +8,10 @@ import type { EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; +import { + FOUNDATION_JSON_ONLY_SYSTEM_PROMPT, + FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT, +} from '../prompts/customWorldAgentPrompts.js'; import { buildCustomWorldFrameworkJsonRepairPrompt, buildCustomWorldFrameworkPrompt, @@ -15,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, @@ -28,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, @@ -770,13 +776,6 @@ function buildChapter(params: { }; } -const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的世界草稿 JSON 生成器。 -只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`; -const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。 -你会收到一段本应为单个 JSON 对象的文本。 -你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。 -不要输出 Markdown、代码块、解释、注释或额外文字。`; - const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3; const FOUNDATION_DRAFT_STORY_COUNT = 6; const FOUNDATION_DRAFT_LANDMARK_COUNT = 4; @@ -795,7 +794,7 @@ type DraftProgressCallback = ( payload: DraftProgressPayload, ) => void | Promise; -type MergeableNamedRecord = Record & { +type MergeableNamedRecord = { name: string; }; @@ -1369,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), @@ -1561,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], @@ -1721,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 483c51cd..a083ceb4 100644 --- a/server-node/src/services/customWorldAgentPhase2.test.ts +++ b/server-node/src/services/customWorldAgentPhase2.test.ts @@ -47,6 +47,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { async getSettings() { return { musicVolume: 0.42, + platformTheme: 'light', }; }, async putSettings(_userId, settings) { @@ -73,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 bdcda008..7e350f12 100644 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { async getSettings() { return { musicVolume: 0.42, + platformTheme: 'light', }; }, async putSettings(_userId, settings) { @@ -65,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 f4e586d9..b5e347f0 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -40,6 +40,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { async getSettings() { return { musicVolume: 0.42, + platformTheme: 'light', }; }, async putSettings(_userId, settings) { @@ -66,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 8463122b..5e3559f6 100644 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { async getSettings() { return { musicVolume: 0.42, + platformTheme: 'light', }; }, async putSettings(_userId, settings) { @@ -65,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()]; }, @@ -112,12 +119,16 @@ async function createObjectRefiningSession( seedText: '一个被潮雾切开的列岛世界。', }); - const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, { - clientMessageId: 'phase5-ready-1', - text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', - focusCardId: null, - selectedCardIds: [], - }); + const message1 = await orchestrator.submitMessage( + userId, + createdSession.sessionId, + { + clientMessageId: 'phase5-ready-1', + text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', + focusCardId: null, + selectedCardIds: [], + }, + ); await waitForOperation( orchestrator, userId, @@ -125,12 +136,16 @@ async function createObjectRefiningSession( message1.operation.operationId, ); - const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, { - clientMessageId: 'phase5-ready-2', - text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', - focusCardId: null, - selectedCardIds: [], - }); + const message2 = await orchestrator.submitMessage( + userId, + createdSession.sessionId, + { + clientMessageId: 'phase5-ready-2', + text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', + focusCardId: null, + selectedCardIds: [], + }, + ); await waitForOperation( orchestrator, userId, @@ -187,7 +202,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in session.sessionId, response.operation.operationId, ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); assert.equal(operation?.status, 'completed'); assert.equal(snapshot?.stage, 'visual_refining'); @@ -209,7 +227,9 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile }); const userId = 'user-phase5-sync-role-assets'; const session = await createObjectRefiningSession(orchestrator, userId); - const characterCard = session.draftCards.find((card) => card.kind === 'character'); + const characterCard = session.draftCards.find( + (card) => card.kind === 'character', + ); assert.ok(characterCard); @@ -248,33 +268,48 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile session.sessionId, response.operation.operationId, ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); - const syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find( - (entry) => entry.id === characterCard!.id, + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + const syncedRole = [ + ...(profile?.playableNpcs ?? []), + ...(profile?.storyNpcs ?? []), + ].find((entry) => entry.id === characterCard!.id); + const syncedCard = snapshot?.draftCards.find( + (card) => card.id === characterCard!.id, ); - const syncedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id); const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find( (entry) => entry.roleId === characterCard!.id, ); const latestRecord = await sessionStore.get(userId, session.sessionId); assert.equal(operation?.status, 'completed'); - assert.equal(syncedRole?.imageSrc, '/generated/characters/shenli-portrait.png'); + assert.equal( + syncedRole?.imageSrc, + '/generated/characters/shenli-portrait.png', + ); assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1'); assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1'); assert.equal( - (syncedRole?.animationMap as Record | null)?.idle - ?.basePath, + (syncedRole?.animationMap as Record | null) + ?.idle?.basePath, '/generated/characters/shenli/idle', ); - assert.equal(syncedAssetSummary?.status, 'complete'); - assert.equal(syncedCard?.assetStatusLabel, '动作已就绪'); - assert.ok(syncedCard?.subtitle.includes('动作已就绪')); + const syncedSkillIds = syncedRole?.skills.map((skill) => skill.id) ?? []; + assert.ok(syncedSkillIds.length > 0); + assert.equal(syncedAssetSummary?.status, 'animations_ready'); + assert.deepEqual( + syncedAssetSummary?.missingAnimations, + syncedSkillIds.map((skillId) => `skill:${skillId}`), + ); + assert.equal(syncedCard?.assetStatusLabel, '动作补齐中'); + assert.ok(syncedCard?.subtitle.includes('动作补齐中')); assert.ok( snapshot?.messages.some( (message) => - message.kind === 'action_result' && message.text.includes('动作已就绪'), + message.kind === 'action_result' && message.text.includes('动作补齐中'), ), ); assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2); diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts new file mode 100644 index 00000000..cdecaa36 --- /dev/null +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildRoleAssetSummary } from './customWorldAgentRoleAssetStateService.js'; + +test('role asset summary only requires run attack and configured skill actions', () => { + const summary = buildRoleAssetSummary({ + role: { + id: 'role-shenli', + name: '沈砺', + threadIds: ['thread-1'], + imageSrc: '/generated/shenli/portrait.png', + generatedVisualAssetId: 'visual-shenli', + generatedAnimationSetId: 'animation-shenli', + animationMap: { + run: { basePath: '/generated/shenli/run' }, + attack: { basePath: '/generated/shenli/attack' }, + }, + skills: [ + { + id: 'skill-tidelight', + name: '潮灯斩', + actionPreviewConfig: { + basePath: '/generated/shenli/skill-tidelight', + }, + }, + ], + }, + roleKind: 'playable', + }); + + assert.equal(summary.status, 'complete'); + assert.deepEqual(summary.missingAnimations, []); +}); + +test('role asset summary marks missing skill actions as required gaps', () => { + const summary = buildRoleAssetSummary({ + role: { + id: 'role-yunhe', + name: '云禾', + threadIds: [], + imageSrc: '/generated/yunhe/portrait.png', + generatedVisualAssetId: 'visual-yunhe', + generatedAnimationSetId: 'animation-yunhe', + animationMap: { + run: { basePath: '/generated/yunhe/run' }, + attack: { basePath: '/generated/yunhe/attack' }, + }, + skills: [ + { + id: 'skill-wave', + name: '断潮步', + actionPreviewConfig: null, + }, + ], + }, + roleKind: 'story', + }); + + assert.equal(summary.status, 'animations_ready'); + assert.deepEqual(summary.missingAnimations, ['skill:skill-wave']); +}); + +test('role asset summary treats idle and die as optional', () => { + const summary = buildRoleAssetSummary({ + role: { + id: 'role-lin', + name: '林砂', + threadIds: [], + imageSrc: '/generated/lin/portrait.png', + generatedVisualAssetId: 'visual-lin', + generatedAnimationSetId: 'animation-lin', + animationMap: { + run: { basePath: '/generated/lin/run' }, + attack: { basePath: '/generated/lin/attack' }, + }, + skills: [], + }, + roleKind: 'story', + }); + + assert.equal(summary.status, 'complete'); + assert.deepEqual(summary.missingAnimations, []); +}); diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.ts index d8ca5b02..95c9df67 100644 --- a/server-node/src/services/customWorldAgentRoleAssetStateService.ts +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.ts @@ -5,13 +5,13 @@ import type { CustomWorldRoleAssetSummary, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -const CORE_ROLE_ANIMATION_KEYS = [ - 'idle', - 'run', - 'attack', - 'hurt', - 'die', -] as const; +const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const; + +type DraftRoleSkillRecord = { + id: string; + name: string; + actionPreviewConfig?: Record | null; +}; type DraftRoleRecord = { id: string; @@ -21,6 +21,7 @@ type DraftRoleRecord = { generatedVisualAssetId?: string | null; generatedAnimationSetId?: string | null; animationMap?: Record | null; + skills: DraftRoleSkillRecord[]; }; type DraftRoleKind = 'playable' | 'story'; @@ -65,11 +66,8 @@ function toAnimationMap(value: unknown) { return toRecord(value); } -function hasAnimationSlot( - animationMap: Record | null | undefined, - slot: string, -) { - const entry = toRecord(animationMap?.[slot]); +function hasAnimationAsset(entryValue: unknown) { + const entry = toRecord(entryValue); if (!entry) { return false; } @@ -77,6 +75,41 @@ function hasAnimationSlot( return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath)); } +function hasAnimationSlot( + animationMap: Record | null | undefined, + slot: string, +) { + return hasAnimationAsset(animationMap?.[slot]); +} + +function normalizeRoleSkills(value: unknown, fallbackName = '角色') { + const skills = toRecordArray(value) + .map((item, index) => ({ + id: toText(item.id) || `skill-${index + 1}`, + name: toText(item.name) || `技能${index + 1}`, + actionPreviewConfig: toRecord(item.actionPreviewConfig), + })) + .filter((item) => Boolean(item.id)); + + if (skills.length > 0) { + return skills; + } + + return [ + { + id: 'skill-1', + name: `${toText(fallbackName).slice(0, 10) || '角色'}招牌动作`, + actionPreviewConfig: null, + }, + ]; +} + +function collectMissingSkillActions(role: DraftRoleRecord) { + return role.skills + .filter((skill) => !hasAnimationAsset(skill.actionPreviewConfig)) + .map((skill) => `skill:${skill.id}`); +} + function resolvePriorityTier( role: DraftRoleRecord, roleKind: DraftRoleKind, @@ -127,6 +160,7 @@ function collectDraftRoles(profileInput: unknown) { generatedVisualAssetId: toText(item.generatedVisualAssetId) || null, generatedAnimationSetId: toText(item.generatedAnimationSetId) || null, animationMap: toAnimationMap(item.animationMap), + skills: normalizeRoleSkills(item.skills, toText(item.role) || name), }; }; @@ -160,7 +194,9 @@ function collectDraftRoles(profileInput: unknown) { ]; } -export function resolveRoleAssetStatusLabel(status: CustomWorldRoleAssetStatus) { +export function resolveRoleAssetStatusLabel( + status: CustomWorldRoleAssetStatus, +) { if (status === 'complete') { return '动作已就绪'; } @@ -182,9 +218,12 @@ export function buildRoleAssetSummary(params: { }): CustomWorldRoleAssetSummary { const { role, roleKind } = params; const priorityTier = resolvePriorityTier(role, roleKind); - const missingAnimations = CORE_ROLE_ANIMATION_KEYS.filter( - (slot) => !hasAnimationSlot(role.animationMap, slot), - ); + const missingAnimations = [ + ...REQUIRED_ROLE_ANIMATION_KEYS.filter( + (slot) => !hasAnimationSlot(role.animationMap, slot), + ), + ...collectMissingSkillActions(role), + ]; const hasPortrait = Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId); const hasAnimationSet = Boolean(role.generatedAnimationSetId); @@ -210,10 +249,7 @@ export function buildRoleAssetSummary(params: { }; } -export function getRoleAssetSummaryById( - draftProfile: unknown, - roleId: string, -) { +export function getRoleAssetSummaryById(draftProfile: unknown, roleId: string) { const roleEntry = collectDraftRoles(draftProfile).find( (entry) => entry.role.id === roleId, ); @@ -281,8 +317,7 @@ export function mergeRoleAssetIntoDraftProfile( return touched; }; - const touched = - updateRoleList('playableNpcs') || updateRoleList('storyNpcs'); + const touched = updateRoleList('playableNpcs') || updateRoleList('storyNpcs'); if (!touched || !updatedRole) { throw new Error('目标角色不存在,无法同步角色资产。'); 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/customWorldEntityGenerationService.ts b/server-node/src/services/customWorldEntityGenerationService.ts index f2655ae4..6c614f81 100644 --- a/server-node/src/services/customWorldEntityGenerationService.ts +++ b/server-node/src/services/customWorldEntityGenerationService.ts @@ -1,5 +1,11 @@ import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { badRequest } from '../errors.js'; +import { + buildLandmarkPrompt, + buildPlayablePrompt, + buildStoryPrompt, + CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT, +} from '../prompts/customWorldEntityPrompts.js'; import type { UpstreamLlmClient } from './llmClient.js'; type CustomWorldEntityKind = 'playable' | 'story' | 'landmark'; @@ -319,69 +325,6 @@ function normalizeProfile(value: unknown): ParsedProfile { }; } -function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) { - if (roles.length === 0) { - return emptyText; - } - - return roles - .slice(0, 12) - .map( - (role, index) => - `${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${ - role.role || '未写' - } / 描述:${role.description || '未写'} / 背景:${ - role.backstory || '未写' - } / 性格:${role.personality || '未写'} / 动机:${ - role.motivation || '未写' - } / 形象:${role.visualDescription || '未写'} / 动作表现:${ - role.actionDescription || '未写' - } / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${ - role.tags.join('、') || '暂无' - }`, - ) - .join('\n'); -} - -function buildLandmarkReferenceText(profile: ParsedProfile) { - if (profile.landmarks.length === 0) { - return '当前还没有场景设定。'; - } - - const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc])); - const landmarkById = new Map( - profile.landmarks.map((landmark) => [landmark.id, landmark]), - ); - - return profile.landmarks - .slice(0, 12) - .map((landmark, index) => { - const sceneNpcNames = landmark.sceneNpcIds - .map((npcId) => storyNpcById.get(npcId)?.name ?? '') - .filter(Boolean) - .join('、'); - const connectionNames = landmark.connections - .map((connection) => { - const targetName = - landmarkById.get(connection.targetLandmarkId)?.name || - connection.targetLandmarkId; - return `${targetName}(${connection.relativePosition} / ${ - connection.summary || '无说明' - })`; - }) - .join('、'); - - return `${index + 1}. ${landmark.name} / 危险度:${ - landmark.dangerLevel || 'medium' - } / 描述:${landmark.description || '未写'} / 画面:${ - landmark.visualDescription || '未写' - } / 场景角色:${ - sceneNpcNames || '暂无' - } / 连接:${connectionNames || '暂无'}`; - }) - .join('\n'); -} - function buildUniqueRoleName(existingNames: Set, startIndex: number) { for (let attempt = 0; attempt < 120; attempt += 1) { const index = startIndex + attempt; @@ -563,148 +506,6 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) { }; } -function buildPlayablePrompt(profile: ParsedProfile) { - return [ - `世界名:${profile.name}`, - `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, - `世界摘要:${profile.summary || '未填写'}`, - `世界基调:${profile.tone || '未填写'}`, - `玩家主线目标:${profile.playerGoal || '未填写'}`, - `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, - `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, - `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, - '请基于上面全部上下文,生成 1 名新的“可扮演角色”。', - '要求:', - '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', - '- 必须保留明确的协作价值、成长空间和入队理由。', - '- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。', - '- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。', - '- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。', - '- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。', - '- 只返回 JSON,不要输出解释或 Markdown。', - 'JSON 结构:', - '{', - ' "playableNpc": {', - ' "name": "角色名",', - ' "title": "称号",', - ' "role": "身份",', - ' "description": "一句到两句定位描述",', - ' "visualDescription": "角色形象描述",', - ' "actionDescription": "动作表现描述",', - ' "sceneVisualDescription": "角色关联场景画面描述",', - ' "backstory": "背景经历",', - ' "personality": "性格特点",', - ' "motivation": "当前动机",', - ' "combatStyle": "战斗风格",', - ' "initialAffinity": 22,', - ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', - ' "tags": ["标签1", "标签2", "标签3"],', - ' "publicSummary": "公开背景摘要",', - ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', - ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', - ' "skills": [', - ' { "name": "技能1", "summary": "说明", "style": "风格" },', - ' { "name": "技能2", "summary": "说明", "style": "风格" },', - ' { "name": "技能3", "summary": "说明", "style": "风格" }', - ' ],', - ' "initialItems": [', - ' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }', - ' ]', - ' }', - '}', - ].join('\n'); -} - -function buildStoryPrompt(profile: ParsedProfile) { - return [ - `世界名:${profile.name}`, - `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, - `世界摘要:${profile.summary || '未填写'}`, - `世界基调:${profile.tone || '未填写'}`, - `玩家主线目标:${profile.playerGoal || '未填写'}`, - `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, - `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, - `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, - '请基于上面全部上下文,生成 1 名新的“场景角色”。', - '要求:', - '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', - '- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。', - '- 角色应与具体场景、关系链或局势变化发生绑定。', - '- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。', - '- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。', - '- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。', - '- 只返回 JSON,不要输出解释或 Markdown。', - 'JSON 结构:', - '{', - ' "storyNpc": {', - ' "name": "角色名",', - ' "title": "称号",', - ' "role": "身份",', - ' "description": "一句到两句定位描述",', - ' "visualDescription": "角色形象描述",', - ' "actionDescription": "动作表现描述",', - ' "sceneVisualDescription": "角色关联场景画面描述",', - ' "backstory": "背景经历",', - ' "personality": "性格特点",', - ' "motivation": "当前动机",', - ' "combatStyle": "战斗风格",', - ' "initialAffinity": 6,', - ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', - ' "tags": ["标签1", "标签2", "标签3"],', - ' "publicSummary": "公开背景摘要",', - ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', - ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', - ' "skills": [', - ' { "name": "技能1", "summary": "说明", "style": "风格" },', - ' { "name": "技能2", "summary": "说明", "style": "风格" },', - ' { "name": "技能3", "summary": "说明", "style": "风格" }', - ' ],', - ' "initialItems": [', - ' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }', - ' ]', - ' }', - '}', - ].join('\n'); -} - -function buildLandmarkPrompt(profile: ParsedProfile) { - return [ - `世界名:${profile.name}`, - `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, - `世界摘要:${profile.summary || '未填写'}`, - `世界基调:${profile.tone || '未填写'}`, - `玩家主线目标:${profile.playerGoal || '未填写'}`, - `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, - `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, - `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, - '请基于上面全部上下文,生成 1 个新的“场景”。', - '要求:', - '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。', - '- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。', - '- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。', - '- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。', - '- 只返回 JSON,不要输出解释或 Markdown。', - 'JSON 结构:', - '{', - ' "landmark": {', - ' "name": "场景名",', - ' "description": "场景描述",', - ' "visualDescription": "场景画面描述",', - ' "dangerLevel": "low|medium|high|extreme",', - ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', - ' "connections": [', - ' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },', - ' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }', - ' ]', - ' }', - '}', - ].join('\n'); -} - function ensureUniqueName(name: string, existingNames: string[], fallbackName: string) { const normalized = name.trim() || fallbackName; if (!existingNames.includes(normalized)) { @@ -1040,8 +841,7 @@ async function requestGeneratedEntity( : buildLandmarkPrompt(profile); const content = await llmClient.requestMessageContent({ - systemPrompt: - '你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。', + systemPrompt: CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT, userPrompt, timeoutMs: 45000, debugLabel: `custom-world-generate-${kind}`, diff --git a/server-node/src/services/customWorldSceneNpcGenerationService.ts b/server-node/src/services/customWorldSceneNpcGenerationService.ts index 8e271131..02ae065b 100644 --- a/server-node/src/services/customWorldSceneNpcGenerationService.ts +++ b/server-node/src/services/customWorldSceneNpcGenerationService.ts @@ -1,5 +1,9 @@ import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { badRequest } from '../errors.js'; +import { + buildCustomWorldSceneNpcPrompt, + CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT, +} from '../prompts/customWorldSceneNpcPrompts.js'; import type { UpstreamLlmClient } from './llmClient.js'; type SceneNpcGenerationInput = { @@ -288,86 +292,6 @@ function buildFallbackDraft( }; } -function buildPrompt( - profile: ParsedProfile, - landmark: ParsedLandmark, - sceneNpcs: ParsedStoryNpc[], - otherNpcs: ParsedStoryNpc[], -) { - const sceneNpcSummary = sceneNpcs.length - ? sceneNpcs - .map( - (npc, index) => - `${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`, - ) - .join('\n') - : '当前场景还没有已加入 NPC。'; - - const reserveNpcSummary = otherNpcs.length - ? otherNpcs - .slice(0, 8) - .map( - (npc, index) => - `${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`, - ) - .join('\n') - : '暂无其他场景角色参考。'; - - const landmarkSummary = profile.landmarks - .slice(0, 10) - .map( - (entry, index) => - `${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`, - ) - .join('\n'); - - return [ - `世界名:${profile.name}`, - `世界设定:${profile.settingText || '未提供额外设定文本。'}`, - `当前目标场景:${landmark.name}`, - `场景描述:${landmark.description || '未填写'}`, - `危险度:${landmark.dangerLevel || '中'}`, - `当前场景已加入 NPC:\n${sceneNpcSummary}`, - `其他可参考 NPC:\n${reserveNpcSummary}`, - `世界内其他场景概览:\n${landmarkSummary}`, - '请生成 1 名适合加入当前场景的新 NPC。', - '要求:', - '- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。', - '- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。', - '- 关系钩子、技能、初始物品都要可直接进入编辑器。', - '- 返回 JSON,不要额外解释。', - 'JSON 结构:', - '{', - ' "npc": {', - ' "name": "角色名",', - ' "title": "头衔",', - ' "role": "身份",', - ' "description": "一句到两句角色描述",', - ' "backstory": "背景",', - ' "personality": "性格",', - ' "motivation": "动机",', - ' "combatStyle": "战斗风格",', - ' "initialAffinity": 6,', - ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', - ' "tags": ["标签1", "标签2", "标签3"],', - ' "publicSummary": "公开背景摘要",', - ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', - ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', - ' "skills": [', - ' { "name": "技能1", "summary": "说明", "style": "风格" },', - ' { "name": "技能2", "summary": "说明", "style": "风格" },', - ' { "name": "技能3", "summary": "说明", "style": "风格" }', - ' ],', - ' "initialItems": [', - ' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }', - ' ]', - ' }', - '}', - ].join('\n'); -} - function sanitizeGeneratedNpc( rawValue: unknown, profile: ParsedProfile, @@ -571,9 +495,13 @@ export async function generateSceneNpcForLandmark( try { const content = await llmClient.requestMessageContent({ - systemPrompt: - '你是游戏世界编辑器的角色生成器。你必须只返回可解析 JSON,不要输出解释、前言或 markdown 代码块之外的额外内容。', - userPrompt: buildPrompt(profile, landmark, sceneNpcs, otherNpcs), + systemPrompt: CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT, + userPrompt: buildCustomWorldSceneNpcPrompt( + profile, + landmark, + sceneNpcs, + otherNpcs, + ), debugLabel: 'custom-world-scene-npc', }); const parsed = parseJsonResponseText(content); 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 new file mode 100644 index 00000000..707b283b --- /dev/null +++ b/src/components/AdventurePanel.npcChat.test.tsx @@ -0,0 +1,107 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, test } from 'vitest'; + +import { AdventurePanel } from './AdventurePanel'; +import { type Character, type StoryMoment, WorldType } from '../types'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '测试主角', + backstory: '测试背景', + avatar: '/hero.png', + portrait: '/hero.png', + assetFolder: 'hero', + assetVariant: 'default', + attributes: { + strength: 10, + agility: 10, + intelligence: 8, + spirit: 9, + }, + personality: 'calm', + skills: [], + adventureOpenings: {}, + } as Character; +} + +test('adventure panel treats negative affinity updates as relationship change system messages', () => { + const currentStory: StoryMoment = { + text: '你们的语气忽然冷了下来。', + displayMode: 'dialogue', + dialogue: [ + { speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' }, + { speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 }, + ], + options: [], + }; + + const html = renderToStaticMarkup( + undefined} + onChoice={() => undefined} + onOpenCharacter={() => undefined} + onOpenInventory={() => undefined} + playerCharacter={createCharacter()} + worldType={WorldType.WUXIA} + quests={[]} + questUi={{ + acknowledgeQuestCompletion: () => undefined, + claimQuestReward: () => null, + }} + npcChatQuestOfferUi={{ + replacePendingOffer: async () => false, + abandonPendingOffer: () => false, + acceptPendingOffer: () => null, + }} + goalStack={{ + northStarGoal: null, + activeGoal: null, + immediateStepGoal: null, + supportGoals: [], + }} + goalPulse={null} + onDismissGoalPulse={() => undefined} + battleRewardUi={{ + reward: null, + dismiss: () => undefined, + }} + playerHp={100} + playerMaxHp={100} + playerMana={20} + playerMaxMana={20} + playerSkillCooldowns={{}} + inBattle={false} + currentNpcBattleMode={null} + statistics={{ + playTimeMs: 0, + hostileNpcsDefeated: 0, + questsAccepted: 0, + questsCompleted: 0, + questsTurnedIn: 0, + itemsUsed: 0, + scenesTraveled: 0, + currentSceneName: '竹林古道', + playerCurrency: 0, + inventoryItemCount: 0, + inventoryStackCount: 0, + activeCompanionCount: 0, + rosterCompanionCount: 0, + }} + musicVolume={0.6} + onMusicVolumeChange={() => undefined} + onSaveAndExit={() => undefined} + />, + ); + + expect(html).toContain('关系变化'); + expect(html).toContain('关系转冷 好感 -2'); +}); 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 1d05fa10..c5665c81 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; @@ -91,6 +96,10 @@ interface AdventurePanelProps { scenesTraveled: number; currentSceneName: string; playerCurrency: number; + playerLevel?: number; + playerCurrentLevelXp?: number; + playerXpToNextLevel?: number; + playerTotalXp?: number; inventoryItemCount: number; inventoryStackCount: number; activeCompanionCount: number; @@ -200,7 +209,7 @@ function getDialogueTurnLabel( turn: NonNullable[number], ) { if (turn.speaker === 'system') { - return turn.affinityDelta && turn.affinityDelta > 0 ? '关系变化' : '系统'; + return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统'; } if (turn.speaker === 'player') { @@ -271,6 +280,19 @@ function formatPlayTime(playTimeMs: number) { return `${minutes}分${String(seconds).padStart(2, '0')}秒`; } +function getPlayerProgressionRatio( + statistics: AdventurePanelProps['statistics'], +) { + const currentLevelXp = Math.max(0, statistics.playerCurrentLevelXp ?? 0); + const xpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0); + + if (xpToNextLevel <= 0) { + return 1; + } + + return Math.max(0, Math.min(1, currentLevelXp / xpToNextLevel)); +} + function getOptionGoalAffordanceClass(option: StoryOption) { switch (option.goalAffordance?.relation) { case 'advance': @@ -462,6 +484,17 @@ function QuestRewardGrid({ 货币 +
+
+ + + +{quest.reward.experience ?? 0} + +
+
+ 经验 +
+
(null); @@ -653,11 +689,12 @@ export function AdventurePanel({ currentStory.deferredOptions?.length, ); const saveAndExitDisabled = isLoading || isStoryStreaming; - const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest' - ? goalStack.activeGoal - : goalStack.immediateStepGoal?.sourceKind === 'quest' - ? goalStack.immediateStepGoal - : null; + const primaryQuestGoal = + goalStack.activeGoal?.sourceKind === 'quest' + ? goalStack.activeGoal + : goalStack.immediateStepGoal?.sourceKind === 'quest' + ? goalStack.immediateStepGoal + : null; const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false); const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false); const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); @@ -670,7 +707,8 @@ export function AdventurePanel({ string | null >(null); const [rewardQuestId, setRewardQuestId] = useState(null); - const [rewardQuestHandoff, setRewardQuestHandoff] = useState(null); + const [rewardQuestHandoff, setRewardQuestHandoff] = + useState(null); const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState< string | null >(null); @@ -689,8 +727,12 @@ 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, @@ -889,6 +931,13 @@ export function AdventurePanel({ ], [statistics], ); + const playerLevel = Math.max(1, statistics.playerLevel ?? 1); + const playerCurrentLevelXp = Math.max( + 0, + statistics.playerCurrentLevelXp ?? 0, + ); + const playerXpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0); + const playerProgressionRatio = getPlayerProgressionRatio(statistics); const shouldMountAdventureOverlays = isGoalPanelOpen || isSettingsPanelOpen || @@ -901,6 +950,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' @@ -1029,13 +1099,34 @@ export function AdventurePanel({
-
-
+
+
+
Lv.{playerLevel}
+
+ {playerXpToNextLevel > 0 + ? `${playerCurrentLevelXp}/${playerXpToNextLevel}` + : 'MAX'} +
+
+
+
+
+
+
+
@@ -1065,7 +1156,7 @@ export function AdventurePanel({ type="button" onClick={onRefreshOptions} aria-label="换一换选项" - className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white" + className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white" >