1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-11 15:43:32 +08:00
parent f19e482c8f
commit 0981d6ee1b
78 changed files with 1102 additions and 8510 deletions

View File

@@ -16,7 +16,7 @@ import {
} from './buildDamage';
import { getCharacterCombatTags } from './buildTags';
import { getCharacterById } from './characterPresets';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
function requireCharacter(characterId: string) {
const character = getCharacterById(characterId);
@@ -37,8 +37,8 @@ function cloneCharacter(
},
} satisfies Character;
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaProfile = buildCharacterAttributeProfile(
nextCharacter,
wuxiaSchema,
@@ -150,7 +150,7 @@ describe('buildDamage', () => {
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
const character = requireCharacter('sword-princess');
const breakdown = getCompanionBuildDamageBreakdown(character);
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
expect(breakdown.rows.length).toBeGreaterThan(0);
@@ -381,7 +381,7 @@ describe('buildDamage', () => {
it('does not allow resource attributes to enter tag bonus rows', () => {
const character = requireCharacter('sword-princess');
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
const mpBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: buildEquipmentItem({

View File

@@ -41,7 +41,7 @@ import {
buildCustomWorldStarterInventoryItems,
} from './customWorldCharacterLoadout';
import { getRuntimeCustomWorldProfile, isCustomWorldType } from './customWorldRuntime';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
function defineSkill(skill: CharacterSkillDefinition): CharacterSkillDefinition {
return skill;
@@ -289,8 +289,8 @@ function hydrateCharacterRoleData(
customRole?: CustomWorldRuntimeRole | null;
} = {},
) {
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaProfile = buildCharacterAttributeProfile(character, wuxiaSchema);
const xianxiaProfile = buildCharacterAttributeProfile(character, xianxiaSchema);
const customProfile = options.customWorldProfile
@@ -520,7 +520,7 @@ export function getInventoryItems(character: Character, worldType: WorldType | n
];
}
const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attributeProfiles' | 'resourceProfile'>> = [
const BASE_ROLE_TEMPLATE_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attributeProfiles' | 'resourceProfile'>> = [
{
id: 'sword-princess',
name: '剑之公主',
@@ -563,7 +563,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '追查失落王庭誓剑流入江湖的踪迹',
goal: '在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人',
monologue: '你来到这个武侠世界,是为追查失落王庭誓剑流入江湖的踪迹。此行最重要的目标,是在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人。',
monologue: '你来到这片旧桥与边城交错的地界,是为追查失落王庭誓剑流入各方势力的踪迹。此行最重要的目标,是在野心家之前找回誓剑,并逼出宫变幕后之人。',
surfaceHook: '我追着一件不该流落在外的王庭旧物而来。',
immediateConcern: '前面盯着这条线的人不止一拨,走错一步就会被人截住。',
guardedMotive: '我来这里不是巡游散心,有件旧账必须先查清。',
@@ -571,7 +571,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '王庭圣印坠入云海裂隙,你循着残光闯入了仙域',
goal: '寻回圣印,截断借它开启天门禁制的野心',
monologue: '你来到这个仙侠世界,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
monologue: '你来到这片灵潮翻涌的高空异境,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
surfaceHook: '我循着一道王庭残光追到了这里。',
immediateConcern: '云海里的局势已经被人搅乱,圣印不会等你慢慢摸索。',
guardedMotive: '我来这里是为收回一件必须回到我手里的东西。',
@@ -720,7 +720,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '追着一份指向边军叛徒的密图进入江湖',
goal: '找出贩卖军情的人,并截回被转移的军械账册',
monologue: '你来到这个武侠世界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
monologue: '你来到这片边城动荡未平的地界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
surfaceHook: '我追着一份旧军密图走到了这片江湖。',
immediateConcern: '这条线上的人都擅长放假风声,前路不只一层埋伏。',
guardedMotive: '我在追一条和边军旧案有关的线,但还不到全说的时候。',
@@ -728,7 +728,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '星舟坠毁后,你顺着碎裂航迹漂进了仙域云海',
goal: '找回星图核心,查清是谁击落了你的船队',
monologue: '你来到这个仙侠世界,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
monologue: '你来到这片灵潮与云海交错的异境,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
surfaceHook: '我是顺着一段断掉的航迹飘进来的。',
immediateConcern: '云海里残留的痕迹还没散干净,说明对方离得不远。',
guardedMotive: '我在追查一场坠毁后的尾线,暂时只确认到这里。',
@@ -939,7 +939,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '追着偷走密信的人潜入了这片雨夜江湖',
goal: '夺回密信,查清究竟是谁把你推上了被追杀的路',
monologue: '你来到这个武侠世界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
monologue: '你来到这片雨夜与旧案交错的地界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
surfaceHook: '我追着一个偷走东西的人摸进了这里。',
immediateConcern: '前面这条路像是专门给人设的套,闯快了只会替别人探雷。',
guardedMotive: '我来这儿是为了追一封信,也顺便追查是谁想让我闭嘴。',
@@ -947,7 +947,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '密信指向一座只会在月湖现身的仙门残阵',
goal: '找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁',
monologue: '你来到这个仙侠世界,是因为那封密信把你引向了一座只会在月湖现身的仙门残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
monologue: '你来到这片月湖与残阵交错的异境,是因为那封密信把你引向了一座只会在月湖现身的残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
surfaceHook: '有封信把我一路引到了月湖这一带。',
immediateConcern: '残阵现身的时间很短,再慢一点就只剩空壳。',
guardedMotive: '我来这里不只是找阵眼,还想弄明白一件跟我自己有关的怪事。',
@@ -1034,7 +1034,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '循着毁掉拳馆的凶手线索来到了这片江湖',
goal: '找到凶手首领,让拳馆遗物和弟子名册不再被人践踏',
monologue: '你来到这个武侠世界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
monologue: '你来到这片拳馆旧怨未平的地界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
surfaceHook: '我追着一帮砸了拳馆的人一路追到了这里。',
immediateConcern: '前面那股气味不对,像是有人刚动过手脚。',
guardedMotive: '我来这里是为了算账,也为了把该带回去的东西带回去。',
@@ -1042,7 +1042,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '师门遗物在灵火裂隙里传来回应,你一路追进了熔境',
goal: '夺回遗物中的真传拳谱,阻止它被人炼成杀器',
monologue: '你来到这个仙侠世界,是因为师门遗物在灵火裂隙里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
monologue: '你来到这片灵火裂隙仍在回响的异境,是因为师门遗物在里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
surfaceHook: '我顺着师门遗物的回应一路追到了熔境。',
immediateConcern: '灵火裂隙正往外吐东西,再拖下去只会更难收拾。',
guardedMotive: '我来这里是为了把师门留下的东西抢回来,别的以后再细说。',
@@ -1222,7 +1222,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.WUXIA]: opening({
reason: '奉旧部最后一道军令,独自赶来守住山门防线',
goal: '找回失散军旗,重新拼起已经溃散的同袍',
monologue: '你来到这个武侠世界,是奉着旧部最后一道军令赶来守住山门防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
monologue: '你来到这片山门与防线都在失守边缘的地界,是奉着旧部最后一道军令赶来守住防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
surfaceHook: '我奉着一条没法搁下的旧军令守在这里。',
immediateConcern: '山门前的防线已经松了,再往前走的人很可能被卷进去。',
guardedMotive: '我来这里不是巡查,是在补一段还没补上的旧阵线。',
@@ -1230,7 +1230,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
[WorldType.XIANXIA]: opening({
reason: '雷坛异动引发旧式甲胄共鸣,你被迫一路追进了仙域',
goal: '封住失控雷坛,避免整支旧军的甲魂被拿去驱使',
monologue: '你来到这个仙侠世界,是因为雷坛异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
monologue: '你来到这片雷坛异动不断放大的异境,是因为那场异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
surfaceHook: '我顺着旧甲的共鸣一路追到了雷坛附近。',
immediateConcern: '这里的雷势还在往上走,再晚一点就不是一两个人能压住的事。',
guardedMotive: '我来这里是为了封住一处失控源头,也为了不让旧军的东西落到错的人手里。',
@@ -1502,7 +1502,8 @@ function mergeCharacterPreset(baseCharacter: Character): Character {
});
}
export const PRESET_CHARACTERS: Character[] = BASE_PRESET_CHARACTERS.map(mergeCharacterPreset);
export const ROLE_TEMPLATE_CHARACTERS: Character[] =
BASE_ROLE_TEMPLATE_CHARACTERS.map(mergeCharacterPreset);
const runtimeCharacterOverrides = new Map<string, Character>();
let runtimeCustomWorldCharacters: Character[] = [];
@@ -1668,15 +1669,15 @@ function pickCustomWorldRoleTemplateCharacter(
fallbackIndex: number,
profile?: CustomWorldProfile | null,
) {
const fallbackTemplateCharacter = PRESET_CHARACTERS[
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
] ?? PRESET_CHARACTERS[0];
const fallbackTemplateCharacter = ROLE_TEMPLATE_CHARACTERS[
fallbackIndex % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
] ?? ROLE_TEMPLATE_CHARACTERS[0];
if (!fallbackTemplateCharacter) {
throw new Error('Missing preset characters for custom world generation');
}
const explicitTemplateCharacter = role.templateCharacterId
? PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
: null;
if (explicitTemplateCharacter) {
return explicitTemplateCharacter;
@@ -1696,7 +1697,7 @@ function pickCustomWorldRoleTemplateCharacter(
},
);
const referenceTemplateCharacter = referenceTemplateCharacterId
? PRESET_CHARACTERS.find(
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === referenceTemplateCharacterId,
) ?? null
: null;
@@ -1704,7 +1705,7 @@ function pickCustomWorldRoleTemplateCharacter(
return referenceTemplateCharacter;
}
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
const heuristicTemplateCharacter = ROLE_TEMPLATE_CHARACTERS.find(
character =>
character.id === resolveFallbackRecruitTemplateCharacterId([
role.role,
@@ -1722,11 +1723,11 @@ function pickCustomWorldRoleTemplateCharacter(
export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile | null) {
if (!profile) {
return PRESET_CHARACTERS;
return ROLE_TEMPLATE_CHARACTERS;
}
if (profile.playableNpcs.length === 0) {
return PRESET_CHARACTERS;
return ROLE_TEMPLATE_CHARACTERS;
}
return profile.playableNpcs.map((role, index) => {
@@ -1780,7 +1781,7 @@ export function setRuntimeCharacterOverrides(characters: Character[] | null) {
export function getCharacterById(characterId: string) {
return runtimeCharacterOverrides.get(characterId)
?? PRESET_CHARACTERS.find(character => character.id === characterId)
?? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === characterId)
?? null;
}

View File

@@ -642,9 +642,15 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const settingText = toText(value.settingText, toText(value.summary, name));
if (!name) return null;
const templateWorldType = value.templateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: WorldType.WUXIA;
const compatibilityTemplateWorldType =
value.compatibilityTemplateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: value.compatibilityTemplateWorldType === WorldType.WUXIA
? WorldType.WUXIA
: value.templateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: WorldType.WUXIA;
const templateWorldType = compatibilityTemplateWorldType;
const subtitle = toText(value.subtitle);
const summary = toText(value.summary);
const tone = toText(value.tone);
@@ -687,6 +693,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
tone,
playerGoal,
templateWorldType,
compatibilityTemplateWorldType,
majorFactions: [],
coreConflicts: [summary || playerGoal || settingText || name],
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),

View File

@@ -8,6 +8,7 @@ import {
type CustomWorldProfile,
WorldType,
} from '../types';
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
import {
getMonsterPresetsByWorld,
type HostileNpcPreset,
@@ -181,7 +182,10 @@ function scoreMonsterPresetWithArchetype(
}
export function getCustomWorldMonsterPresetPool(
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
const presets = getAllMonsterPresets();
const creatureArchetypes =
@@ -191,7 +195,9 @@ export function getCustomWorldMonsterPresetPool(
return presets;
}
const preferredWorldType = profile?.templateWorldType ?? null;
const preferredWorldType = profile
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
: null;
const scoredPresets = presets
.map((preset) => {
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
@@ -223,7 +229,10 @@ export function getCustomWorldMonsterPresetPool(
export function resolveCustomWorldNpcMonsterPreset(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
const sourceText = buildMonsterSourceText(npc);
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
@@ -235,7 +244,9 @@ export function resolveCustomWorldNpcMonsterPreset(
return null;
}
const preferredWorldType = profile?.templateWorldType ?? worldType ?? null;
const preferredWorldType = profile
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
: worldType ?? null;
const referenceArchetype = resolveCreatureArchetypeForSource(
profile as CustomWorldProfile | null | undefined,
npc,
@@ -271,7 +282,10 @@ export function resolveCustomWorldNpcMonsterPreset(
export function resolveCustomWorldNpcMonsterPresetId(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
}

View File

@@ -1,7 +1,7 @@
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
resolveCustomWorldAnchorWorldType,
resolveCustomWorldCompatibilityTemplateWorldType,
} from '../services/customWorldTheme';
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
@@ -15,13 +15,15 @@ export function getRuntimeCustomWorldProfile() {
return runtimeCustomWorldProfile;
}
export function resolveRuleWorldType(
export function resolveCompatibilityTemplateWorldType(
worldType: WorldType | null | undefined,
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
): WorldTemplateType | null {
if (!worldType) return null;
if (worldType === WorldType.CUSTOM) {
return customWorldProfile ? resolveCustomWorldAnchorWorldType(customWorldProfile) : WorldType.WUXIA;
return customWorldProfile
? resolveCustomWorldCompatibilityTemplateWorldType(customWorldProfile)
: WorldType.WUXIA;
}
return worldType;
}

View File

@@ -10,6 +10,7 @@ import {
type WorldTemplateType,
WorldType,
} from '../types';
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
@@ -80,7 +81,7 @@ const SCENE_MATCH_STOP_CHARS = new Set([
'桥',
]);
const WUXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
const MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
{
name: '山门石阶',
keywords: ['山门', '石阶', '门派', '山路', '石门', '宗门'],
@@ -131,7 +132,7 @@ const WUXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
},
] as const;
const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
const ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
{
name: '云海仙门',
keywords: ['云海', '仙门', '山门', '灵门', '云阶', '门阙'],
@@ -182,12 +183,12 @@ const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
},
] as const;
const WORLD_SCENE_IMAGE_REFERENCES: Record<
const COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES: Record<
WorldTemplateType,
readonly SceneImageReference[]
> = {
[WorldType.WUXIA]: WUXIA_SCENE_IMAGE_REFERENCES,
[WorldType.XIANXIA]: XIANXIA_SCENE_IMAGE_REFERENCES,
[WorldType.WUXIA]: MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES,
[WorldType.XIANXIA]: ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES,
};
type CustomWorldSceneImageMatchOptions = {
@@ -259,7 +260,7 @@ function uniqueStrings(values: Array<string | null | undefined>) {
function buildSceneReferencePool(worldType: WorldTemplateType) {
const pool = collectWorldSceneImagePool(worldType);
const references = WORLD_SCENE_IMAGE_REFERENCES[worldType] ?? [];
const references = COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES[worldType] ?? [];
return references.map((reference, index) => ({
...reference,
@@ -488,6 +489,7 @@ export function resolveCustomWorldLandmarkImage(
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'ownedSettingLayers'
>,
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
@@ -502,7 +504,7 @@ export function resolveCustomWorldLandmarkImage(
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
index,
profile.templateWorldType,
resolveCustomWorldCompatibilityTemplateWorldType(profile),
{
profile,
landmark,
@@ -521,6 +523,7 @@ export function resolveCustomWorldLandmarkImageMap(
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
@@ -559,6 +562,7 @@ export function resolveCustomWorldCampSceneImage(
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
@@ -575,7 +579,7 @@ export function resolveCustomWorldCampSceneImage(
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
-1,
profile.templateWorldType,
resolveCustomWorldCompatibilityTemplateWorldType(profile),
{
profile,
landmark: {

View File

@@ -4,7 +4,10 @@ import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, Runti
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
import { buildDefaultAxisVector } from './attributeResolver';
import {normalizeBuildTags} from './buildTags';
import { buildRuntimeCustomWorldInventoryItems, resolveRuleWorldType } from './customWorldRuntime';
import {
buildRuntimeCustomWorldInventoryItems,
resolveCompatibilityTemplateWorldType,
} from './customWorldRuntime';
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
@@ -13,7 +16,7 @@ import {
buildRuntimeItemAiIntent,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemNarrative';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
worldType: WorldType;
@@ -930,7 +933,7 @@ function buildHostileNpcBehaviorVectors(preset: {
function hydrateHostileNpcPresetRoleData(
preset: Omit<HostileNpcPreset, 'attributeProfile' | 'behaviorVectors'>,
): HostileNpcPreset {
const schema = getPresetWorldAttributeSchema(
const schema = getTemplateWorldAttributeSchema(
preset.worldType === WorldType.XIANXIA ? WorldType.XIANXIA : WorldType.WUXIA,
);
@@ -955,7 +958,8 @@ export function getHostileNpcPresetById(worldType: WorldType, monsterId: string)
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].find(monster => monster.id === monsterId) ?? null;
}
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType].find(monster => monster.id === monsterId) ?? null;
}
@@ -965,7 +969,8 @@ export function getHostileNpcPresetsByWorld(worldType: WorldType) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM];
}
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType];
}

View File

@@ -11,7 +11,7 @@ import {
WorldType,
} from '../types';
import { resolveRoleCombatStats } from './attributeCombat';
import { resolveRuleWorldType } from './customWorldRuntime';
import { resolveCompatibilityTemplateWorldType } from './customWorldRuntime';
import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets';
export const METERS_TO_PIXELS = 48;
@@ -78,7 +78,8 @@ function getHostileNpcFormationSlots(
worldType: WorldType,
monsterCount: number,
): HostileNpcFormationSlot[] {
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
const frontX = FRONT_HOSTILE_NPC_ANCHOR_X[resolvedWorldType];
const centerSlot = { xMeters: frontX, yOffset: 0 };
const lowerBackSlot = {

View File

@@ -471,14 +471,14 @@ function buildLegacyDesign(
category,
rarity,
tags: dedupe(tags),
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两个世界中以不同风格登场,适合作为${category}基础模板继续扩展。`,
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两套兼容模板中以不同风格登场,适合作为${category}基础模板继续扩展。`,
worldAffinity: "neutral",
equipmentSlotId: slot,
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName},适用于武侠世界的基础${category}条目。`,
`${xianxiaName},适用于仙侠世界的基础${category}条目。`,
`${wuxiaName},适用于边城模板的基础${category}条目。`,
`${xianxiaName},适用于灵潮模板的基础${category}条目。`,
),
statProfile,
useProfile,
@@ -544,8 +544,8 @@ function buildArmoryDesign(
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}武侠 build。`,
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑仙侠战法。`,
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}边城模板 build。`,
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑灵潮模板战法。`,
),
statProfile,
useProfile: null,
@@ -633,8 +633,8 @@ function buildPotionDesign(
worldProfiles: buildWorldProfiles(
"药瓶",
"灵瓶",
"武侠世界常见的炼药容器。",
"仙侠世界常用的盛装灵液器皿。",
"边城模板里常见的炼药容器。",
"灵潮模板里常用的盛装灵液器皿。",
),
statProfile: null,
useProfile: null,
@@ -708,8 +708,8 @@ function buildPotionDesign(
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}常见于江湖行囊,用于快速续战或调息。`,
`${xianxiaName}多用于洞府与试炼前后,负责补元、聚灵与压缩冷却。`,
`${wuxiaName}常见于边城模板的远行行囊,用于快速续战或调息。`,
`${xianxiaName}多用于灵潮模板的据点与试炼前后,负责补元、聚灵与压缩冷却。`,
),
statProfile: null,
useProfile,
@@ -757,8 +757,8 @@ function buildGemDesign(
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}偏向江湖匠造、镶嵌与兵刃锻造。`,
`${xianxiaName}更适合灵器镶嵌与灵力 build 核心堆叠。`,
`${wuxiaName}偏向边城模板里的匠造、镶嵌与兵刃锻造。`,
`${xianxiaName}更适合灵潮模板里的灵器镶嵌与灵力 build 核心堆叠。`,
),
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
useProfile: null,
@@ -830,7 +830,7 @@ function buildSkillRelicDesign(
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}适合在武侠世界里解释为武学秘卷、战术符印或绝招凭证。`,
`${wuxiaName}适合在边城模板里解释为武学秘卷、战术符印或绝招凭证。`,
`${xianxiaName}可作为功法玉简、灵术法印或灵能强化器,用于构筑法修流派。`,
),
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
@@ -916,8 +916,8 @@ function buildUtilityDesign(
worldProfiles: buildWorldProfiles(
wuxiaName || readable,
xianxiaName || readable,
`${wuxiaName || readable}更适合武侠世界的江湖使用语境。`,
`${xianxiaName || readable}更适合仙侠世界的灵物/法器语境。`,
`${wuxiaName || readable}更适合边城模板的在地使用语境。`,
`${xianxiaName || readable}更适合灵潮模板的灵物/法器语境。`,
),
statProfile,
useProfile,

View File

@@ -11,8 +11,8 @@ const STRUCTURAL_TAG_LABELS: Record<string, string> = {
healing: '疗伤',
mana: '法力',
rare: '稀有',
wuxia: '武侠',
xianxia: '仙侠',
wuxia: '边城模板',
xianxia: '灵潮模板',
neutral: '中性',
};

View File

@@ -14,7 +14,7 @@ function resolvePublicAssetPath(assetPath: string) {
}
describe('scene background assets', () => {
it('ships background files for every wuxia and xianxia scene preset', () => {
it('ships background files for every compatibility template scene preset', () => {
const scenes = [
...getScenePresetsByWorld(WorldType.WUXIA),
...getScenePresetsByWorld(WorldType.XIANXIA),
@@ -27,7 +27,7 @@ describe('scene background assets', () => {
}
});
it('returns existing default custom world backgrounds for both anchor worlds', () => {
it('returns existing default custom world backgrounds for both compatibility templates', () => {
const wuxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.WUXIA);
const xianxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.XIANXIA);
@@ -40,7 +40,7 @@ describe('scene background assets', () => {
'/generated-custom-world-scenes/test-world/generated-ruins.png';
const profile: CustomWorldProfile = {
id: 'custom-world-test',
settingText: '荒城断碑与边关旧营并存的武侠世界',
settingText: '荒城断碑与边关旧营并存的边城地界',
name: '断碑边城',
subtitle: '烽烟未熄',
summary: '边关旧营与残城废墟彼此相望,玩家要追查旧案余烬。',

View File

@@ -22,11 +22,14 @@ import {
buildCustomWorldPlayableCharacters,
getCharacterHomeSceneId,
getCharacterNpcSceneIds,
PRESET_CHARACTERS,
ROLE_TEMPLATE_CHARACTERS,
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
import {
getRuntimeCustomWorldProfile,
resolveCompatibilityTemplateWorldType,
} from './customWorldRuntime';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
@@ -111,7 +114,8 @@ function buildImagePath(packName: string, imageNumber: number) {
}
function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
const refs: string[] = [];
let globalIndex = 0;
@@ -571,7 +575,7 @@ function resolveSceneNpcGender(
function buildCharacterNpcPool(sceneId: string, worldType: WorldType) {
const npcs: SceneNpc[] = [];
for (const character of PRESET_CHARACTERS) {
for (const character of ROLE_TEMPLATE_CHARACTERS) {
const characterId = character.id;
const sceneIds = getCharacterNpcSceneIds(worldType, characterId);
if (sceneIds.includes(sceneId)) {

View File

@@ -2,7 +2,10 @@ import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSetti
import type {CustomWorldProfile, WorldAttributeSchema} from '../types';
import {WorldType} from '../types';
export const PRESET_WORLD_ATTRIBUTE_SCHEMAS: Record<Exclude<WorldType, WorldType.CUSTOM>, WorldAttributeSchema> = {
export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
Exclude<WorldType, WorldType.CUSTOM>,
WorldAttributeSchema
> = {
[WorldType.WUXIA]: {
id: 'schema:wuxia:v1',
worldId: WorldType.WUXIA,
@@ -155,8 +158,10 @@ export const PRESET_WORLD_ATTRIBUTE_SCHEMAS: Record<Exclude<WorldType, WorldType
},
};
export function getPresetWorldAttributeSchema(worldType: Exclude<WorldType, WorldType.CUSTOM>) {
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[worldType];
export function getTemplateWorldAttributeSchema(
worldType: Exclude<WorldType, WorldType.CUSTOM>,
) {
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[worldType];
}
export function getWorldAttributeSchema(
@@ -171,8 +176,8 @@ export function getWorldAttributeSchema(
}
if (worldType === WorldType.XIANXIA) {
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[WorldType.XIANXIA];
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[WorldType.XIANXIA];
}
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[WorldType.WUXIA];
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[WorldType.WUXIA];
}