@@ -1,5 +1,9 @@
import { isRecord , readStoredJson , writeStoredJson } from '../persistence/storage' ;
import { generateWorldAttributeSchema } from '../services/attributeSchemaGenerat or' ;
import {
isRec ord ,
readStoredJson ,
writeStoredJson ,
} from '../persistence/storage' ;
import { generateWorldAttributeSchema } from '../services/attributeSchemaGenerator' ;
import { buildFallbackCustomWorldCampScene } from '../services/customWorldCamp' ;
import {
buildCustomWorldAnchorPackFromIntent ,
@@ -26,16 +30,23 @@ import {
CustomWorldRoleInitialItem ,
CustomWorldRoleSkill ,
EquipmentSlotId ,
ItemAttributeResonance ,
ItemRarity ,
ItemStatProfile ,
ItemUseProfile ,
KnowledgeFact ,
RoleAttributeProfile ,
SceneNarrativeResidue ,
ThemePack ,
ThreadContract ,
WorldType ,
WorldStoryGraph ,
} from '../types' ;
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS ,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY ,
} from './affinityLevels' ;
import { coerceWorldAttributeSchema } from './attributeValidation' ;
import { coerceWorldAttributeSchema } from './attributeValidation' ;
import {
type CustomWorldLandmarkDraft ,
normalizeCustomWorldLandmarks ,
@@ -48,11 +59,30 @@ 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 ITEM_RARITIES = new Set < ItemRarity > ( [ 'common' , 'uncommon' , 'rare' , 'epic' , 'legendary' ] ) ;
const ITEM_RARITIES = new Set < ItemRarity > ( [
'common' ,
'uncommon' ,
'rare' ,
'epic' ,
'legendary' ,
] ) ;
const EQUIPMENT_SLOTS = new Set < EquipmentSlotId > ( [ 'weapon' , 'armor' , 'relic' ] ) ;
const ANIMATION_STATES = new Set < AnimationState > ( Object . values ( AnimationState ) ) ;
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set < CustomWorldNpcVisualRace > ( [ 'human' , 'elf' , 'orc' , 'goblin' ] ) ;
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set < CustomWorldNpcVisualGearType > ( [ 'cloth' , 'leather' , 'metal' , 'melee' , 'magic' , 'ranged' ] ) ;
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set < CustomWorldNpcVisualRace > ( [
'human' ,
'elf' ,
'orc' ,
'goblin' ,
] ) ;
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES =
new Set < CustomWorldNpcVisualGearType > ( [
'cloth' ,
'leather' ,
'metal' ,
'melee' ,
'magic' ,
'ranged' ,
] ) ;
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set ( [
'武器' ,
'护甲' ,
@@ -65,7 +95,12 @@ const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
] ) ;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS ;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ '表层来意' , '旧事裂痕' , '隐藏执念' , '最终底牌' ] as const ;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [
'表层来意' ,
'旧事裂痕' ,
'隐藏执念' ,
'最终底牌' ,
] as const ;
type CustomWorldRoleFallbackSource = {
name : string ;
@@ -92,25 +127,43 @@ function toText(value: unknown, fallback = '') {
function toStringArray ( value : unknown ) {
return Array . isArray ( value )
? value
. filter ( ( item ) : item is string = > typeof item === 'string' )
. map ( item = > item . trim ( ) )
. filter ( Boolean )
. filter ( ( item ) : item is string = > typeof item === 'string' )
. map ( ( item ) = > item . trim ( ) )
. filter ( Boolean )
: [ ] ;
}
function toOptionalNumber ( value : unknown ) {
return typeof value === 'number' && Number . isFinite ( value ) ? value : undefined ;
return typeof value === 'number' && Number . isFinite ( value )
? value
: undefined ;
}
function toOptionalInteger ( value : unknown ) {
return typeof value === 'number' && Number . isFinite ( value ) ? Math . round ( value ) : undefined ;
return typeof value === 'number' && Number . isFinite ( value )
? Math . round ( value )
: undefined ;
}
function preserveStructuredRecord < T > ( value : unknown ) : T | null {
return isRecord ( value ) ? ( value as T ) : null ;
}
function preserveStructuredRecordArray < T > ( value : unknown ) : T [ ] | null {
return Array . isArray ( value )
? ( value . filter ( ( entry ) : entry is Record < string , unknown > = > isRecord ( entry ) ) as T [ ] )
: null ;
}
function normalizeInitialAffinity ( value : unknown , fallback : number ) {
const resolved = typeof value === 'number' && Number . isFinite ( value )
? Math . round ( value )
: fallback ;
return Math . max ( MIN_CUSTOM_WORLD_AFFINITY , Math . min ( MAX_CUSTOM_WORLD_AFFINITY , resolved ) ) ;
const resolved =
typeof value === 'number' && Number . isFinite ( value )
? Math . round ( value )
: fallback ;
return Math . max (
MIN_CUSTOM_WORLD_AFFINITY ,
Math . min ( MAX_CUSTOM_WORLD_AFFINITY , resolved ) ,
) ;
}
function truncateText ( value : string , maxLength : number ) {
@@ -125,7 +178,7 @@ function splitNarrativeSentences(text: string) {
if ( ! normalized ) return [ ] ;
const matches = normalized . match ( / [ ^ 。 ! ? ! ? ] + [ 。 ! ? ! ? ] ? / g u ) ;
return ( matches ? ? [ normalized ] ) . map ( item = > item . trim ( ) ) . filter ( Boolean ) ;
return ( matches ? ? [ normalized ] ) . map ( ( item ) = > item . trim ( ) ) . filter ( Boolean ) ;
}
function normalizeRoleItemCategory ( value : unknown , fallback = '材料' ) {
@@ -143,12 +196,17 @@ function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
return fallback ;
}
function buildFallbackBackstoryReveal ( source : CustomWorldRoleFallbackSource ) : CharacterBackstoryRevealConfig {
const normalizedBackstory = source . backstory . trim ( ) || ` ${ source . name } 对自己的过去仍有保留。 ` ;
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 = sour ce. description . trim ( ) || truncateText ( normalizedBackstory , 42 ) ;
const backstoryDetail =
backstorySenten ces . slice ( 0 , 2 ) . join ( '' ) || normalizedBackstory ;
const publicSummary =
source . description . trim ( ) || truncateText ( normalizedBackstory , 42 ) ;
const fallbackContents = [
source . description . trim ( ) || backstoryLead ,
backstoryDetail ,
@@ -163,17 +221,28 @@ function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): Ch
return {
publicSummary ,
privateChatUnlockAffinity : DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY ,
chapters : CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map ( ( affinityRequired , index ) = > ( {
id : ` saved-backstory- ${ index + 1 } ` ,
title : CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES [ index ] ? ? ` 背景片段 ${ index + 1 } ` ,
affinityRequired ,
teaser : truncateText ( fallbackContents [ index ] ? ? normalizedBackstory , 22 ) ,
content : truncateText ( fallbackContents [ index ] ? ? normalizedBackstory , 72 ) ,
contextSnippet : truncateText (
` ${ source . name } 的背景正在向“ ${ fallbackContents [ index ] ? ? normalizedBackstory } ”这条线索展开。 ` ,
48 ,
) ,
} ) satisfies CharacterBackstoryChapter ) ,
chapters : CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map (
( affinityRequired , index ) = >
( {
id : ` saved-backstory- ${ index + 1 } ` ,
title :
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES [ index ] ? ?
` 背景片段 ${ index + 1 } ` ,
affinityRequired ,
teaser : truncateText (
fallbackContents [ index ] ? ? normalizedBackstory ,
22 ,
) ,
content : truncateText (
fallbackContents [ index ] ? ? normalizedBackstory ,
72 ,
) ,
contextSnippet : truncateText (
` ${ source . name } 的背景正在向“ ${ fallbackContents [ index ] ? ? normalizedBackstory } ”这条线索展开。 ` ,
48 ,
) ,
} ) satisfies CharacterBackstoryChapter ,
) ,
} ;
}
@@ -193,21 +262,38 @@ function normalizeBackstoryReveal(
return {
publicSummary : toText ( value . publicSummary , fallback . publicSummary ) ,
privateChatUnlockAffinity :
typeof value . privateChatUnlockAffinity === 'number' && Number . isFinite ( value . privateChatUnlockAffinity )
? normalizeInitialAff inity ( value . privateChatUnlockAffinity , DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY )
typeof value . privateChatUnlockAffinity === 'number' &&
Number . isF inite ( value . privateChatUnlockAffinity )
? normalizeInitialAffinity (
value . privateChatUnlockAffinity ,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY ,
)
: fallback . privateChatUnlockAffinity ,
chapters : CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map ( ( defaultAffinity , index ) = > {
const rawChapter = rawChapters [ index ] ;
const fallback Chapter = fallback . c hapters[ index ] ;
return {
id : rawChapter ? toText ( rawChapter . id , fallbackChapter ? . id ) : fallbackChapter ? . id ? ? ` saved-backstory- ${ index + 1 } ` ,
title : rawChapter ? toText ( rawChapter . title , fallbackChapter ? . title ) : fallbackChapter ? . title ? ? ` 背景片段 ${ index + 1 } ` ,
affinityRequired : fallbackChapter?.affinityRequired ? ? defaultAffinity ,
teaser : rawChapter ? toText ( rawChapter . teaser , fallbackChapter ? . teaser ) : fallbackChapter ? . teaser ? ? '' ,
content : rawChapter ? toText ( rawChapter . content , fallbackChapter ? . content ) : fallbackChapter ? . content ? ? '' ,
contextSnippet : rawChapter ? toText ( rawChapter . contextSnippet , fallbackChapter ? . contextSnippet ) : fallbackChapter ? . contextSnippet ? ? '' ,
} satisfies CharacterBackstoryChapter ;
} ) ,
chapters : CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map (
( defaultAffinity , index ) = > {
const raw Chapter = rawC hapters[ index ] ;
const fallbackChapter = fallback . chapters [ index ] ;
return {
id : rawChapter
? toText ( rawChapter . id , fallbackChapter ? . id )
: ( fallbackChapter ? . id ? ? ` saved-backstory- ${ index + 1 } ` ) ,
title : rawChapter
? toText ( rawChapter . title , fallbackChapter ? . title )
: ( fallbackChapter ? . title ? ? ` 背景片段 ${ index + 1 } ` ) ,
affinityRequired :
fallbackChapter?.affinityRequired ? ? defaultAffinity ,
teaser : rawChapter
? toText ( rawChapter . teaser , fallbackChapter ? . teaser )
: ( fallbackChapter ? . teaser ? ? '' ) ,
content : rawChapter
? toText ( rawChapter . content , fallbackChapter ? . content )
: ( fallbackChapter ? . content ? ? '' ) ,
contextSnippet : rawChapter
? toText ( rawChapter . contextSnippet , fallbackChapter ? . contextSnippet )
: ( fallbackChapter ? . contextSnippet ? ? '' ) ,
} satisfies CharacterBackstoryChapter ;
} ,
) ,
} satisfies CharacterBackstoryRevealConfig ;
}
@@ -217,19 +303,28 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
{
id : 'saved-role-skill-1' ,
name : ` ${ nameSeed } 起手 ` ,
summary : truncateText ( source . combatStyle || ` ${ source . name } 擅长稳住局面。 ` , 36 ) ,
summary : truncateText (
source . combatStyle || ` ${ source . name } 擅长稳住局面。 ` ,
36 ,
) ,
style : '起手压制' ,
} ,
{
id : 'saved-role-skill-2' ,
name : ` ${ nameSeed } 变招 ` ,
summary : truncateText ( source . personality || ` ${ source . name } 习惯在周旋中找破绽。 ` , 36 ) ,
summary : truncateText (
source . personality || ` ${ source . name } 习惯在周旋中找破绽。 ` ,
36 ,
) ,
style : '机动周旋' ,
} ,
{
id : 'saved-role-skill-3' ,
name : ` ${ nameSeed } 底牌 ` ,
summary : truncateText ( source . motivation || ` ${ source . name } 会在关键时刻亮出压箱手段。 ` , 36 ) ,
summary : truncateText (
source . motivation || ` ${ source . name } 会在关键时刻亮出压箱手段。 ` ,
36 ,
) ,
style : '爆发终结' ,
} ,
] satisfies CustomWorldRoleSkill [ ] ;
@@ -241,18 +336,23 @@ function normalizeRoleSkills(
) {
const normalized = Array . isArray ( value )
? value
. filter ( isRecord )
. map ( ( entry , index ) = > ( {
id : toText ( entry . id , ` saved-role-skill- ${ index + 1 } ` ) ,
name : toText ( entry . name ) ,
summary : toText ( entry . summary , toText ( entry . description ) ) ,
styl e : toText ( entry . style , toText ( entry . category , '常用' ) ) ,
} satisfies CustomWorldRoleSkill ) )
. filter ( entry = > entry . name )
. slice ( 0 , 3 )
. filter ( isRecord )
. map (
( entry , index ) = >
( {
id : toText ( entry . id , ` saved-role-skill- ${ index + 1 } ` ) ,
nam e : toText ( entry . name ) ,
summary : toText ( entry . summary , toText ( entry . description ) ) ,
style : toText ( entry . style , toText ( entry . category , '常用' ) ) ,
} ) satisfies CustomWorldRoleSkill ,
)
. filter ( ( entry ) = > entry . name )
. slice ( 0 , 3 )
: [ ] ;
return normalized . length > 0 ? normalized : buildFallbackRoleSkills ( fallbackSource ) ;
return normalized . length > 0
? normalized
: buildFallbackRoleSkills ( fallbackSource ) ;
}
function buildFallbackRoleInitialItems ( source : CustomWorldRoleFallbackSource ) {
@@ -264,7 +364,10 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
category : '武器' ,
quantity : 1 ,
rarity : 'rare' ,
description : truncateText ( source . combatStyle || ` ${ source . name } 随身携带的主要作战物件。 ` , 36 ) ,
description : truncateText (
source . combatStyle || ` ${ source . name } 随身携带的主要作战物件。 ` ,
36 ,
) ,
tags : source.tags.slice ( 0 , 2 ) ,
} ,
{
@@ -273,7 +376,10 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
category : '消耗品' ,
quantity : 2 ,
rarity : 'uncommon' ,
description : truncateText ( source . personality || ` ${ source . name } 为长期行动准备的基础补给。 ` , 36 ) ,
description : truncateText (
source . personality || ` ${ source . name } 为长期行动准备的基础补给。 ` ,
36 ,
) ,
tags : source.relationshipHooks.slice ( 0 , 2 ) ,
} ,
{
@@ -282,7 +388,12 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
category : '专属物品' ,
quantity : 1 ,
rarity : 'rare' ,
description : truncateText ( source . backstory || source . motivation || ` ${ source . name } 不愿随意交出的信物。 ` , 36 ) ,
description : truncateText (
source . backstory ||
source . motivation ||
` ${ source . name } 不愿随意交出的信物。 ` ,
36 ,
) ,
tags : [ . . . source . tags , . . . source . relationshipHooks ] . slice ( 0 , 3 ) ,
} ,
] satisfies CustomWorldRoleInitialItem [ ] ;
@@ -294,23 +405,29 @@ function normalizeRoleInitialItems(
) {
const normalized = Array . isArray ( value )
? value
. filter ( isRecord )
. map ( ( entry , index ) = > ( {
id : toText ( entry . id , ` saved-role-item- ${ index + 1 } ` ) ,
name : toText ( entry . name ) ,
category : normalizeRoleItemCategory ( entry . category ) ,
quantity :
typeof entry . quantity === 'number' && Number . isFinite ( entry . quantit y)
? Math . max ( 1 , Math . min ( 99 , Math . round ( entry . quantity ) ) )
: 1 ,
rarity : typeof entry . rarity === 'string' && ITEM_RARITIES . has ( entry . rarity as ItemRar ity)
? entry . rarity as ItemRarity
: 'rare' ,
description : toText ( entry . description ) ,
tags : toStringArray ( entry . tags ) ,
} satisfies CustomWorldRoleInitialItem ) )
. filter ( entry = > entry . name )
. slice ( 0 , 3 )
. filter ( isRecord )
. map (
( entry , index ) = >
( {
id : toText ( entry . id , ` saved-role-item- ${ index + 1 } ` ) ,
name : toText ( entry . name ) ,
category : normalizeRoleItemCategory ( entry . categor y) ,
quantity :
typeof entry . quantity === 'number' &&
Number . isFinite ( entry . quant ity )
? Math . max ( 1 , Math . min ( 99 , Math . round ( entry . quantity ) ) )
: 1 ,
rarity :
typeof entry . rarity === 'string' &&
ITEM_RARITIES . has ( entry . rarity as ItemRarity )
? ( entry . rarity as ItemRarity )
: 'rare' ,
description : toText ( entry . description ) ,
tags : toStringArray ( entry . tags ) ,
} ) satisfies CustomWorldRoleInitialItem ,
)
. filter ( ( entry ) = > entry . name )
. slice ( 0 , 3 )
: [ ] ;
return normalized . length > 0
@@ -319,17 +436,24 @@ function normalizeRoleInitialItems(
}
function normalizeEquipmentSlot ( value : unknown ) {
return typeof value === 'string' && EQUIPMENT_SLOTS . has ( value as EquipmentSlotId )
? value as EquipmentSlotId
return typeof value === 'string' &&
EQUIPMENT_SLOTS . has ( value as EquipmentSlotId )
? ( value as EquipmentSlotId )
: null ;
}
function normalizeCustomWorldNpcVisualGear ( value : unknown ) : CustomWorldNpcVisualGear | null {
function normalizeCustomWorldNpcVisualGear (
value : unknown ,
) : CustomWorldNpcVisualGear | null {
if ( ! isRecord ( value ) ) return null ;
const type = typeof value . type === 'string' && CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES . has ( value . type as CustomWorldNpcVisualGearType )
? value . type as CustomWorldNpcVisualGearType
: null ;
const type =
typeof value . type === 'string' &&
CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES . has (
value . type as CustomWorldNpcVisualGearType ,
)
? ( value . type as CustomWorldNpcVisualGearType )
: null ;
const file = toText ( value . file ) ;
if ( ! type || ! file ) return null ;
@@ -341,12 +465,16 @@ function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisual
} ;
}
function normalizeCustomWorldNpcVisual ( value : unknown ) : CustomWorldNpcVisual | undefined {
function normalizeCustomWorldNpcVisual (
value : unknown ,
) : CustomWorldNpcVisual | undefined {
if ( ! isRecord ( value ) ) return undefined ;
const race = typeof value . race === 'string' && CUSTOM_WORLD_NPC_VISUAL_RACES . has ( value . race as CustomWorldNpcVisualRace )
? value . race as CustomWorldNpcVisualRace
: null ;
const race =
typeof value . race === 'string' &&
CUSTOM_WORLD_NPC_VISUAL_RACES . has ( value . race as CustomWorldNpcVisualRace )
? ( value . race as CustomWorldNpcVisualRace )
: null ;
if ( ! race ) return undefined ;
@@ -357,8 +485,14 @@ function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | u
hairColorIndex : Math.max ( 1 , toOptionalInteger ( value . hairColorIndex ) ? ? 1 ) ,
hairStyleFrame : Math.max ( 0 , toOptionalInteger ( value . hairStyleFrame ) ? ? 0 ) ,
facialHairEnabled : Boolean ( value . facialHairEnabled ) ,
facialHairColorIndex : Math.max ( 1 , toOptionalInteger ( value . facialHairColorIndex ) ? ? 1 ) ,
facialHairStyleFrame : Math.max ( 0 , toOptionalInteger ( value . facialHairStyleFrame ) ? ? 0 ) ,
facialHairColorIndex : Math.max (
1 ,
toOptionalInteger ( value . facialHairColorIndex ) ? ? 1 ,
) ,
facialHairStyleFrame : Math.max (
0 ,
toOptionalInteger ( value . facialHairStyleFrame ) ? ? 0 ,
) ,
headgear : normalizeCustomWorldNpcVisualGear ( value . headgear ) ,
mainHand : normalizeCustomWorldNpcVisualGear ( value . mainHand ) ,
offHand : normalizeCustomWorldNpcVisualGear ( value . offHand ) ,
@@ -407,9 +541,9 @@ function normalizeGeneratedAnimationMap(value: unknown) {
} ) ;
return entries . length > 0
? Object . fromEntries ( entries ) as Partial <
? ( Object . fromEntries ( entries ) as Partial <
Record < AnimationState , CharacterAnimationConfig >
>
> )
: undefined ;
}
@@ -423,7 +557,9 @@ function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
incomingDamageMultiplier : toOptionalNumber ( value . incomingDamageMultiplier ) ,
} ;
return Object . values ( profile ) . some ( entry = > entry !== undefined ) ? profile : null ;
return Object . values ( profile ) . some ( ( entry ) = > entry !== undefined )
? profile
: null ;
}
function normalizeItemUseProfile ( value : unknown ) : ItemUseProfile | null {
@@ -435,10 +571,15 @@ function normalizeItemUseProfile(value: unknown): ItemUseProfile | null {
cooldownReduction : toOptionalNumber ( value . cooldownReduction ) ,
} ;
return Object . values ( profile ) . some ( entry = > entry !== undefined ) ? profile : null ;
return Object . values ( profile ) . some ( ( entry ) = > entry !== undefined )
? profile
: null ;
}
function normalizePlayableNpc ( value : unknown , index : number ) : CustomWorldPlayableNpc | null {
function normalizePlayableNpc (
value : unknown ,
index : number ,
) : CustomWorldPlayableNpc | null {
if ( ! isRecord ( value ) ) return null ;
const name = toText ( value . name ) ;
@@ -456,13 +597,14 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
personality : toText ( value . personality ) ,
motivation : toText ( value . motivation , toText ( value . description ) ) ,
combatStyle : toText ( value . combatStyle ) ,
relationshipHooks : relationshipHooks.length > 0 ? relationshipHooks : tags.slice ( 0 , 3 ) ,
relationshipHooks :
relationshipHooks.length > 0 ? relationshipHooks : tags.slice ( 0 , 3 ) ,
tags : tags.length > 0 ? tags : relationshipHooks.slice ( 0 , 5 ) ,
} satisfies CustomWorldRoleFallbackSource ;
return {
id : toText ( value . id , ` saved-playable- ${ index + 1 } ` ) ,
name ,
return {
id : toText ( value . id , ` saved-playable- ${ index + 1 } ` ) ,
name ,
title ,
role ,
description : fallbackSource.description ,
@@ -470,21 +612,37 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
personality : fallbackSource.personality ,
motivation : fallbackSource.motivation ,
combatStyle : fallbackSource.combatStyle ,
initialAffinity : normalizeInitialAffinity ( value . initialAffinity , DEFAULT_PLAYABLE_INITIAL_AFFINITY ) ,
relationshipHooks : fallbackSource.relationshipHooks ,
tags : fallbackSource.tags ,
backstoryReveal : normalizeBackstoryReveal ( value . backstoryReveal , fallbackSource ) ,
skills : normalizeRoleSkills ( value . skills , fallbackSource ) ,
initialItems : normalizeRoleInitialItems ( value . initialItems , fallbackSource) ,
imageSrc : toText ( value . imageSrc ) || undefined ,
generatedVisualAssetId : toText ( value . generatedVisualAssetId ) || undefined ,
generatedAnimationSetId : toText ( value . generatedAnimationSetId ) || undefined ,
animationMap : normalizeGeneratedAnimationMap ( value . animationMap ) ,
templateCharacterId : toText ( value . templateCharacterId ) || undefined ,
} ;
}
initialAffinity : normalizeInitialAffinity (
value . initialAffinity ,
DEFAULT_PLAYABLE_INITIAL_AFFINITY ,
) ,
relationshipHooks : fallbackSource.relationshipHooks ,
tags : fallbackSource.tags ,
backstoryReveal : normalizeBackstoryReveal (
value . backstoryReveal ,
fallbackSource ,
) ,
skills : normalizeRoleSkills ( value . skills , fallbackSource ) ,
initialItems : normalizeRoleInitialItems ( value . initialItems , fallbackSource ) ,
imageSrc : toText ( value . imageSrc ) || undefined ,
generatedVisualAssetId : toText ( value . generatedVisualAssetId ) || undefined ,
generatedAnimationSetId : toText ( value . generatedAnimationSetId ) || undefined ,
animationMap : normalizeGeneratedAnimationMap ( value . animationMap ) ,
attributeProfile :
preserveStructuredRecord < RoleAttributeProfile > ( value . attributeProfile ) ? ?
undefined ,
narrativeProfile :
preserveStructuredRecord < CustomWorldPlayableNpc [ 'narrativeProfile' ] > (
value . narrativeProfile ,
) ? ? undefined ,
templateCharacterId : toText ( value . templateCharacterId ) || undefined ,
} ;
}
function normalizeStoryNpc ( value : unknown , index : number ) : CustomWorldNpc | null {
function normalizeStoryNpc (
value : unknown ,
index : number ,
) : CustomWorldNpc | null {
if ( ! isRecord ( value ) ) return null ;
const name = toText ( value . name ) ;
@@ -502,13 +660,14 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
personality : toText ( value . personality ) ,
motivation : toText ( value . motivation ) ,
combatStyle : toText ( value . combatStyle ) ,
relationshipHooks : relationshipHooks.length > 0 ? relationshipHooks : tags.slice ( 0 , 3 ) ,
relationshipHooks :
relationshipHooks.length > 0 ? relationshipHooks : tags.slice ( 0 , 3 ) ,
tags : tags.length > 0 ? tags : relationshipHooks.slice ( 0 , 5 ) ,
} satisfies CustomWorldRoleFallbackSource ;
return {
id : toText ( value . id , ` saved-story- ${ index + 1 } ` ) ,
name ,
return {
id : toText ( value . id , ` saved-story- ${ index + 1 } ` ) ,
name ,
title ,
role ,
description : fallbackSource.description ,
@@ -516,28 +675,43 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
personality : fallbackSource.personality ,
motivation : fallbackSource.motivation ,
combatStyle : fallbackSource.combatStyle ,
initialAffinity : normalizeInitialAffinity ( value . initialAffinity , DEFAULT_STORY_NPC_INITIAL_AFFINITY ) ,
relationshipHooks : fallbackSource.relationshipHooks ,
tags : fallbackSource.tags ,
backstoryReveal : normalizeBackstoryReveal ( value . backstoryReveal , fallbackSource ) ,
skills : normalizeRoleSkills ( value . skills , fallbackSource ) ,
initialItems : normalizeRoleInitialItems ( value . initialItems , fallbackSource) ,
imageSrc : toText ( value . imageSrc ) || undefined ,
generatedVisualAssetId : toText ( value . generatedVisualAssetId ) || undefined ,
generatedAnimationSetId : toText ( value . generatedAnimationSetId ) || undefined ,
animationMap : normalizeGeneratedAnimationMap ( value . animationMap ) ,
visual : normalizeCustomWorldNpcVisual ( value . visual ) ,
} ;
}
initialAffinity : normalizeInitialAffinity (
value . initialAffinity ,
DEFAULT_STORY_NPC_INITIAL_AFFINITY ,
) ,
relationshipHooks : fallbackSource.relationshipHooks ,
tags : fallbackSource.tags ,
backstoryReveal : normalizeBackstoryReveal (
value . backstoryReveal ,
fallbackSource ,
) ,
skills : normalizeRoleSkills ( value . skills , fallbackSource ) ,
initialItems : normalizeRoleInitialItems ( value . initialItems , fallbackSource ) ,
imageSrc : toText ( value . imageSrc ) || undefined ,
generatedVisualAssetId : toText ( value . generatedVisualAssetId ) || undefined ,
generatedAnimationSetId : toText ( value . generatedAnimationSetId ) || undefined ,
animationMap : normalizeGeneratedAnimationMap ( value . animationMap ) ,
attributeProfile :
preserveStructuredRecord < RoleAttributeProfile > ( value . attributeProfile ) ? ?
undefined ,
narrativeProfile :
preserveStructuredRecord < CustomWorldNpc [ 'narrativeProfile' ] > (
value . narrativeProfile ,
) ? ? undefined ,
visual : normalizeCustomWorldNpcVisual ( value . visual ) ,
} ;
}
function normalizeItem ( value : unknown , index : number ) : CustomWorldItem | null {
if ( ! isRecord ( value ) ) return null ;
const name = toText ( value . name ) ;
const category = toText ( value . category ) ;
const rarity = typeof value . rarity === 'string' && ITEM_RARITIES . has ( value . rarity as ItemRarity )
? value . rarity as ItemRarity
: null ;
const rarity =
typeof value . rarity === 'string' &&
ITEM_RARITIES . has ( value . rarity as ItemRarity )
? ( value . rarity as ItemRarity )
: null ;
if ( ! name || ! category || ! rarity ) return null ;
return {
@@ -549,15 +723,25 @@ function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
tags : toStringArray ( value . tags ) ,
iconSrc : toText ( value . iconSrc ) || undefined ,
sourcePath : toText ( value . sourcePath ) || undefined ,
origin : value.origin === 'generated' || value . origin === 'catalog' ? value.origin : undefined ,
origin :
value.origin === 'generated' || value . origin === 'catalog'
? value.origin
: undefined ,
equipmentSlotId : normalizeEquipmentSlot ( value . equipmentSlotId ) ,
statProfile : normalizeItemStatProfile ( value . statProfile ) ,
useProfile : normalizeItemUseProfile ( value . useProfile ) ,
value : toOptionalNumber ( value . value ) ,
attributeResonance :
preserveStructuredRecord < ItemAttributeResonance > (
value . attributeResonance ,
) ? ? undefined ,
} ;
}
function normalizeLandmark ( value : unknown , index : number ) : CustomWorldLandmark | null {
function normalizeLandmark (
value : unknown ,
index : number ,
) : CustomWorldLandmark | null {
if ( ! isRecord ( value ) ) return null ;
const name = toText ( value . name ) ;
@@ -569,6 +753,10 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark |
description : toText ( value . description ) ,
dangerLevel : toText ( value . dangerLevel ) ,
imageSrc : toText ( value . imageSrc ) || undefined ,
narrativeResidues :
preserveStructuredRecordArray < SceneNarrativeResidue > (
value . narrativeResidues ,
) ? ? undefined ,
sceneNpcIds : [ ] ,
connections : [ ] ,
} ;
@@ -578,7 +766,12 @@ function normalizeCampScene(
value : unknown ,
fallbackProfile : Pick <
CustomWorldProfile ,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldTyp e'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
> ,
) {
const fallback = buildFallbackCustomWorldCampScene ( fallbackProfile ) ;
@@ -655,6 +848,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const summary = toText ( value . summary ) ;
const tone = toText ( value . tone ) ;
const playerGoal = toText ( value . playerGoal ) ;
const majorFactions = toStringArray ( value . majorFactions ) ;
const coreConflicts = toStringArray ( value . coreConflicts ) ;
const resolvedCoreConflicts =
coreConflicts . length > 0
? coreConflicts
: [ summary || playerGoal || settingText || name ] ;
const camp = normalizeCampScene ( value . camp , {
name ,
summary ,
@@ -670,18 +869,18 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
summary ,
tone ,
playerGoal ,
majorFactions : [ ] ,
coreConflicts : [ summary || playerGoal || settingText || name ] ,
majorFactions ,
coreConflicts : resolvedCoreConflicts ,
} ) ;
const storyNpcs = Array . isArray ( value . storyNpcs )
? value . storyNpcs
. map ( ( entry , index ) = > normalizeStoryNpc ( entry , index ) )
. filter ( ( entry ) : entry is CustomWorldNpc = > Boolean ( entry ) )
. map ( ( entry , index ) = > normalizeStoryNpc ( entry , index ) )
. filter ( ( entry ) : entry is CustomWorldNpc = > Boolean ( entry ) )
: [ ] ;
const landmarkDrafts = Array . isArray ( value . landmarks )
? value . landmarks
. map ( ( entry , index ) = > normalizeLandmarkDraft ( entry , index ) )
. filter ( ( entry ) : entry is CustomWorldLandmarkDraft = > Boolean ( entry ) )
. map ( ( entry , index ) = > normalizeLandmarkDraft ( entry , index ) )
. filter ( ( entry ) : entry is CustomWorldLandmarkDraft = > Boolean ( entry ) )
: [ ] ;
const normalizedProfile = {
@@ -694,27 +893,34 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
playerGoal ,
templateWorldType ,
compatibilityTemplateWorldType ,
majorFactions : [ ] ,
coreConflicts : [ summary || playerGoal || settingText || name ] ,
attributeSchema : coerceWorldAttributeSchema ( value . attributeSchema , generatedAttributeSchema ) ,
majorFactions ,
coreConflicts : resolvedCoreConflicts ,
attributeSchema : coerceWorldAttributeSchema (
value . attributeSchema ,
generatedAttributeSchema ,
) ,
playableNpcs : Array.isArray ( value . playableNpcs )
? value . playableNpcs
. map ( ( entry , index ) = > normalizePlayableNpc ( entry , index ) )
. filter ( ( entry ) : entry is CustomWorldPlayableNpc = > Boolean ( entry ) )
. map ( ( entry , index ) = > normalizePlayableNpc ( entry , index ) )
. filter ( ( entry ) : entry is CustomWorldPlayableNpc = > Boolean ( entry ) )
: [ ] ,
storyNpcs ,
items : Array.isArray ( value . items )
? value . items
. map ( ( entry , index ) = > normalizeItem ( entry , index ) )
. filter ( ( entry ) : entry is CustomWorldItem = > Boolean ( entry ) )
. map ( ( entry , index ) = > normalizeItem ( entry , index ) )
. filter ( ( entry ) : entry is CustomWorldItem = > Boolean ( entry ) )
: [ ] ,
camp ,
landmarks : normalizeCustomWorldLandmarks ( {
landmarks : landmarkDrafts ,
storyNpcs ,
} ) ,
themePack : null ,
storyGraph : null ,
themePack : preserveStructuredRecord < ThemePack > ( value . themePack ) ,
storyGraph : preserveStructuredRecord < WorldStoryGraph > ( value . storyGraph ) ,
knowledgeFacts :
preserveStructuredRecordArray < KnowledgeFact > ( value . knowledgeFacts ) ,
threadContracts :
preserveStructuredRecordArray < ThreadContract > ( value . threadContracts ) ,
creatorIntent : normalizeCustomWorldCreatorIntent ( value . creatorIntent ) ,
anchorPack :
value.anchorPack && typeof value . anchorPack === 'object'
@@ -733,9 +939,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
? value . generationMode
: 'full' ,
generationStatus :
value.generationStatus === 'key_only' || value . generationStatus === 'complete'
value.generationStatus === 'key_only' ||
value . generationStatus === 'complete'
? value . generationStatus
: 'complete' ,
scenarioPackId : toText ( value . scenarioPackId ) || null ,
campaignPackId : toText ( value . campaignPackId ) || null ,
} satisfies CustomWorldProfile ;
return {
@@ -747,9 +956,15 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
} ;
}
export function normalizeCustomWorldProfileRecord (
value : unknown ,
) : CustomWorldProfile | null {
return normalizeProfile ( value ) ;
}
function writeProfiles ( profiles : CustomWorldProfile [ ] ) {
const normalizedProfiles = profiles
. map ( profile = > normalizeProfile ( profile ) )
. map ( ( profile ) = > normalizeProfile ( profile ) )
. filter ( ( profile ) : profile is CustomWorldProfile = > Boolean ( profile ) )
. slice ( 0 , MAX_SAVED_CUSTOM_WORLDS ) ;
@@ -772,13 +987,17 @@ export function readSavedCustomWorldProfiles() {
return (
readStoredJson ( {
key : CUSTOM_WORLD_LIBRARY_STORAGE_KEY ,
parse : value = > {
if ( ! isRecord ( value ) || value . version !== CUSTOM_WORLD_LIBRARY_VERSION || ! Array . isArray ( value . profiles ) ) {
parse : ( value) = > {
if (
! isRecord ( value ) ||
value . version !== CUSTOM_WORLD_LIBRARY_VERSION ||
! Array . isArray ( value . profiles )
) {
return null ;
}
return value . profiles
. map ( profile = > normalizeProfile ( profile ) )
. map ( ( profile ) = > normalizeProfile ( profile ) )
. filter ( ( profile ) : profile is CustomWorldProfile = > Boolean ( profile ) )
. slice ( 0 , MAX_SAVED_CUSTOM_WORLDS ) ;
} ,
@@ -789,7 +1008,9 @@ export function readSavedCustomWorldProfiles() {
export function upsertSavedCustomWorldProfile ( profile : CustomWorldProfile ) {
const nextProfiles = [
profile ,
. . . readSavedCustomWorldProfiles ( ) . filter ( savedProfile = > savedProfile . id !== profile . id ) ,
. . . readSavedCustomWorldProfiles ( ) . filter (
( savedProfile ) = > savedProfile . id !== profile . id ,
) ,
] ;
return writeProfiles ( nextProfiles ) ;
}