Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-05 22:20:30 +08:00
parent 89cecda7da
commit fcd8d727b0
57 changed files with 7646 additions and 1425 deletions

130
src/data/affinityLevels.ts Normal file
View File

@@ -0,0 +1,130 @@
import type { RoleRelationState } from '../types';
export type AffinityLevelId =
| 'hostile'
| 'guarded'
| 'eased'
| 'friendly'
| 'trusted'
| 'close';
export type AffinityLevelMeta = {
id: AffinityLevelId;
label: string;
minAffinity: number;
markerAffinity: number;
nextAffinity: number | null;
description: string;
accentClassName: string;
relationStance: RoleRelationState['stance'];
};
export const AFFINITY_PROGRESS_MIN = -40;
export const AFFINITY_PROGRESS_MAX = 90;
export const AFFINITY_LEVELS: AffinityLevelMeta[] = [
{
id: 'hostile',
label: '敌对',
minAffinity: Number.NEGATIVE_INFINITY,
markerAffinity: AFFINITY_PROGRESS_MIN,
nextAffinity: 0,
description:
'好感落入负值区间后,会按敌对关系处理,靠近时通常直接进入对战。',
accentClassName: 'border-rose-300/28 bg-rose-500/14 text-rose-100',
relationStance: 'hostile',
},
{
id: 'guarded',
label: '戒备',
minAffinity: 0,
markerAffinity: 0,
nextAffinity: 15,
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
relationStance: 'guarded',
},
{
id: 'eased',
label: '缓和',
minAffinity: 15,
markerAffinity: 15,
nextAffinity: 30,
description:
'戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
relationStance: 'neutral',
},
{
id: 'friendly',
label: '友善',
minAffinity: 30,
markerAffinity: 30,
nextAffinity: 60,
description:
'态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
accentClassName:
'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
relationStance: 'cooperative',
},
{
id: 'trusted',
label: '信任',
minAffinity: 60,
markerAffinity: 60,
nextAffinity: 90,
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
relationStance: 'bonded',
},
{
id: 'close',
label: '深交',
minAffinity: 90,
markerAffinity: 90,
nextAffinity: null,
description:
'关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
relationStance: 'bonded',
},
];
export const DEFAULT_AFFINITY_LEVEL = AFFINITY_LEVELS[0]!;
export const AFFINITY_PROGRESS_MARKERS = AFFINITY_LEVELS.map((level) => ({
value: level.markerAffinity,
label: level.label,
}));
export const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [
getAffinityLevelMetaById('eased').minAffinity,
getAffinityLevelMetaById('friendly').minAffinity,
getAffinityLevelMetaById('trusted').minAffinity,
getAffinityLevelMetaById('close').minAffinity,
] as const satisfies readonly [number, number, number, number];
export const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY =
getAffinityLevelMetaById('trusted').minAffinity;
export function getAffinityLevelMetaById(levelId: AffinityLevelId) {
const level = AFFINITY_LEVELS.find((entry) => entry.id === levelId);
if (!level) {
throw new Error(`Unknown affinity level id: ${levelId}`);
}
return level;
}
export function getAffinityLevelMeta(affinity: number) {
return (
[...AFFINITY_LEVELS]
.reverse()
.find((level) => affinity >= level.minAffinity) ?? DEFAULT_AFFINITY_LEVEL
);
}
export function resolveRelationStanceFromAffinity(
affinity: number,
): RoleRelationState['stance'] {
return getAffinityLevelMeta(affinity).relationStance;
}

108
src/data/attributeCombat.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { RoleAttributeProfile } from '../types';
const DEFAULT_ATTRIBUTE_SLOT_VALUE = 48;
export const ATTRIBUTE_COMBAT_BONUS_LABELS = {
axis_a: '攻击力',
axis_b: '生命上限',
axis_c: '生命恢复',
axis_d: '攻击速度',
axis_e: '暴击率',
axis_f: '暴击伤害',
} as const;
export interface RoleCombatStats {
attackPowerValue: number;
maxHpValue: number;
recoveryValue: number;
attackSpeedValue: number;
critChanceValue: number;
critDamageValue: number;
attackPowerMultiplier: number;
maxHpBonus: number;
storyRecovery: number;
turnSpeed: number;
critChance: number;
critDamageMultiplier: number;
}
function roundNumber(value: number, digits = 4) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getAttributeSlotValue(
profile: RoleAttributeProfile | null | undefined,
slotId: keyof typeof ATTRIBUTE_COMBAT_BONUS_LABELS,
) {
const value = profile?.values?.[slotId];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
return DEFAULT_ATTRIBUTE_SLOT_VALUE;
}
export function resolveRoleCombatStats(
profile: RoleAttributeProfile | null | undefined,
options: {
baseSpeed?: number;
} = {},
): RoleCombatStats {
const attackPowerValue = getAttributeSlotValue(profile, 'axis_a');
const maxHpValue = getAttributeSlotValue(profile, 'axis_b');
const recoveryValue = getAttributeSlotValue(profile, 'axis_c');
const attackSpeedValue = getAttributeSlotValue(profile, 'axis_d');
const critChanceValue = getAttributeSlotValue(profile, 'axis_e');
const critDamageValue = getAttributeSlotValue(profile, 'axis_f');
const baseSpeed = options.baseSpeed ?? 0;
return {
attackPowerValue,
maxHpValue,
recoveryValue,
attackSpeedValue,
critChanceValue,
critDamageValue,
attackPowerMultiplier: roundNumber(1 + attackPowerValue / 240),
maxHpBonus: Math.max(1, Math.round(maxHpValue / 2)),
storyRecovery: Math.max(3, Math.round(recoveryValue / 12)),
turnSpeed: baseSpeed > 0
? roundNumber(baseSpeed * (0.55 + attackSpeedValue / 100))
: roundNumber(Math.max(1, attackSpeedValue / 12)),
critChance: roundNumber(clamp(critChanceValue / 500, 0.04, 0.24)),
critDamageMultiplier: roundNumber(
Math.max(1.45, 1.25 + critDamageValue / 120),
),
};
}
export function rollDeterministicCombatValue(seed: string) {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return ((hash >>> 0) % 10000) / 10000;
}
export function resolveCriticalStrike(
profile: RoleAttributeProfile | null | undefined,
seed: string,
) {
const stats = resolveRoleCombatStats(profile);
const roll = rollDeterministicCombatValue(seed);
return {
isCritical: roll < stats.critChance,
roll,
critChance: stats.critChance,
critDamageMultiplier: stats.critDamageMultiplier,
};
}

View File

@@ -11,15 +11,12 @@ import type {
WorldType,
} from '../types';
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
import { resolveRelationStanceFromAffinity } from './affinityLevels';
import {normalizeAttributeVector, roundNumber} from './attributeValidation';
import {getWorldAttributeSchema} from './worldAttributeSchemas';
export function resolveRelationStance(affinity: number): RoleRelationState['stance'] {
if (affinity <= -30) return 'hostile';
if (affinity <= 14) return 'guarded';
if (affinity <= 34) return 'neutral';
if (affinity <= 59) return 'cooperative';
return 'bonded';
return resolveRelationStanceFromAffinity(affinity);
}
export function buildRelationState(affinity: number): RoleRelationState {

View File

@@ -1,14 +1,21 @@
import {describe, expect, it} from 'vitest';
import { describe, expect, it } from 'vitest';
import {AnimationState, type Character, type EquipmentLoadout, type GameState, type InventoryItem, WorldType} from '../types';
import {
AnimationState,
type Character,
type EquipmentLoadout,
type GameState,
type InventoryItem,
WorldType,
} from '../types';
import { buildCharacterAttributeProfile } from './attributeProfileGenerator';
import {
getBuildContributionAttributeRows,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
} from './buildDamage';
import {getCharacterCombatTags} from './buildTags';
import {getCharacterById} from './characterPresets';
import { getCharacterCombatTags } from './buildTags';
import { getCharacterById } from './characterPresets';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
function requireCharacter(characterId: string) {
@@ -17,7 +24,10 @@ function requireCharacter(characterId: string) {
return character!;
}
function cloneCharacter(character: Character, overrides: Partial<Character> = {}) {
function cloneCharacter(
character: Character,
overrides: Partial<Character> = {},
) {
const nextCharacter = {
...character,
...overrides,
@@ -29,8 +39,14 @@ function cloneCharacter(character: Character, overrides: Partial<Character> = {}
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaProfile = buildCharacterAttributeProfile(nextCharacter, wuxiaSchema);
const xianxiaProfile = buildCharacterAttributeProfile(nextCharacter, xianxiaSchema);
const wuxiaProfile = buildCharacterAttributeProfile(
nextCharacter,
wuxiaSchema,
);
const xianxiaProfile = buildCharacterAttributeProfile(
nextCharacter,
xianxiaSchema,
);
return {
...nextCharacter,
@@ -54,7 +70,12 @@ function buildEquipmentItem(params: {
}): InventoryItem {
return {
id: params.id,
category: params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
category:
params.slot === 'weapon'
? 'weapon'
: params.slot === 'armor'
? 'armor'
: 'relic',
name: params.name,
quantity: 1,
rarity: 'rare',
@@ -71,7 +92,10 @@ function buildEquipmentItem(params: {
};
}
function buildGameState(loadout: EquipmentLoadout, activeBuildBuffs: GameState['activeBuildBuffs'] = []) {
function buildGameState(
loadout: EquipmentLoadout,
activeBuildBuffs: GameState['activeBuildBuffs'] = [],
) {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
@@ -130,17 +154,25 @@ describe('buildDamage', () => {
expect(breakdown.rows.length).toBeGreaterThan(0);
breakdown.rows.forEach(row => {
const contributionSum = Object.values(row.attributeContributions)
.reduce((sum, value) => sum + value, 0);
const modifierSum = Object.values(row.attributeModifierDeltas)
.reduce((sum, value) => sum + value, 0);
breakdown.rows.forEach((row) => {
const contributionSum = Object.values(row.attributeContributions).reduce(
(sum, value) => sum + value,
0,
);
const modifierSum = Object.values(row.attributeModifierDeltas).reduce(
(sum, value) => sum + value,
0,
);
const attributeRows = getBuildContributionAttributeRows(row, schema);
const activeSlots = Object.entries(row.attributeModifierDeltas).filter(
([, value]) => value > 0.0001,
);
expect(contributionSum).toBeCloseTo(row.fitScore, 4);
expect(modifierSum).toBeCloseTo(row.bonusDelta, 4);
expect(attributeRows.length).toBeGreaterThan(0);
attributeRows.forEach(attributeRow => {
expect(activeSlots.length).toBeLessThanOrEqual(2);
attributeRows.forEach((attributeRow) => {
expect(attributeRow.similarity).toBeGreaterThanOrEqual(0);
expect(attributeRow.weight).toBeGreaterThanOrEqual(0);
expect(attributeRow.modifierDelta).toBeGreaterThanOrEqual(0);
@@ -153,25 +185,33 @@ describe('buildDamage', () => {
const combatTags = getCharacterCombatTags(baseCharacter);
expect(combatTags.length).toBeGreaterThanOrEqual(3);
const fullBreakdown = getCompanionBuildDamageBreakdown(cloneCharacter(baseCharacter, {
combatTags,
}));
const trimmedBreakdown = getCompanionBuildDamageBreakdown(cloneCharacter(baseCharacter, {
combatTags: combatTags.slice(0, 2),
}));
const fullBreakdown = getCompanionBuildDamageBreakdown(
cloneCharacter(baseCharacter, {
combatTags,
}),
);
const trimmedBreakdown = getCompanionBuildDamageBreakdown(
cloneCharacter(baseCharacter, {
combatTags: combatTags.slice(0, 2),
}),
);
const sharedLabels = combatTags.slice(0, 2);
sharedLabels.forEach(label => {
const fullRow = fullBreakdown.rows.find(row => row.label === label);
const trimmedRow = trimmedBreakdown.rows.find(row => row.label === label);
sharedLabels.forEach((label) => {
const fullRow = fullBreakdown.rows.find((row) => row.label === label);
const trimmedRow = trimmedBreakdown.rows.find(
(row) => row.label === label,
);
expect(fullRow?.bonusDelta).toBe(trimmedRow?.bonusDelta);
expect(fullRow?.fitScore).toBe(trimmedRow?.fitScore);
});
expect(trimmedBreakdown.rows.find(row => row.label === combatTags[2])).toBeUndefined();
expect(
trimmedBreakdown.rows.find((row) => row.label === combatTags[2]),
).toBeUndefined();
});
it('gives the same loadout noticeably different build multipliers for different attribute profiles', () => {
it('keeps the same build multiplier for different attribute profiles when tags are unchanged', () => {
const baseCharacter = requireCharacter('sword-princess');
const [primaryTag, secondaryTag] = getCharacterCombatTags(baseCharacter);
@@ -214,11 +254,21 @@ describe('buildDamage', () => {
},
});
const agileBreakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout), agileCharacter);
const mageBreakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout), mageCharacter);
const agileBreakdown = getPlayerBuildDamageBreakdown(
buildGameState(loadout),
agileCharacter,
);
const mageBreakdown = getPlayerBuildDamageBreakdown(
buildGameState(loadout),
mageCharacter,
);
expect(agileBreakdown.buildDamageMultiplier).toBeGreaterThan(mageBreakdown.buildDamageMultiplier);
expect(agileBreakdown.buildDamageMultiplier - mageBreakdown.buildDamageMultiplier).toBeGreaterThan(0.02);
expect(agileBreakdown.buildDamageMultiplier).toBe(
mageBreakdown.buildDamageMultiplier,
);
expect(agileBreakdown.buildDamageBonus).toBe(
mageBreakdown.buildDamageBonus,
);
});
it('includes both buff tags and set tags in the final additive build bonus', () => {
@@ -246,19 +296,22 @@ describe('buildDamage', () => {
relic: null,
} satisfies EquipmentLoadout;
const breakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout, [
{
id: 'buff-1',
sourceType: 'skill',
sourceId: 'test-skill',
name: 'Test Buff',
tags: [primaryTag],
durationTurns: 2,
},
]), character);
const breakdown = getPlayerBuildDamageBreakdown(
buildGameState(loadout, [
{
id: 'buff-1',
sourceType: 'skill',
sourceId: 'test-skill',
name: 'Test Buff',
tags: [primaryTag],
durationTurns: 2,
},
]),
character,
);
expect(breakdown.rows.some(row => row.source === 'buff')).toBe(true);
expect(breakdown.rows.some(row => row.source === 'set')).toBe(true);
expect(breakdown.rows.some((row) => row.source === 'buff')).toBe(true);
expect(breakdown.rows.some((row) => row.source === 'set')).toBe(true);
expect(breakdown.buildDamageBonus).toBeGreaterThan(0);
});
@@ -266,50 +319,116 @@ describe('buildDamage', () => {
const character = requireCharacter('sword-princess');
const equipmentOnlyTag = 'balanced';
const weaponBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
weapon: buildEquipmentItem({
id: 'weapon-only',
name: 'Weapon Only',
slot: 'weapon',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
const weaponBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: buildEquipmentItem({
id: 'weapon-only',
name: 'Weapon Only',
slot: 'weapon',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
}),
armor: null,
relic: null,
}),
armor: null,
relic: null,
}), character);
const armorBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
weapon: null,
armor: buildEquipmentItem({
id: 'armor-only',
name: 'Armor Only',
slot: 'armor',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
character,
);
const armorBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: null,
armor: buildEquipmentItem({
id: 'armor-only',
name: 'Armor Only',
slot: 'armor',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
}),
relic: null,
}),
relic: null,
}), character);
const relicBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
weapon: null,
armor: null,
relic: buildEquipmentItem({
id: 'relic-only',
name: 'Relic Only',
slot: 'relic',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
character,
);
const relicBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: null,
armor: null,
relic: buildEquipmentItem({
id: 'relic-only',
name: 'Relic Only',
slot: 'relic',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
}),
}),
}), character);
character,
);
const weaponRow = weaponBreakdown.rows.find(row => row.source === 'weapon');
const armorRow = armorBreakdown.rows.find(row => row.source === 'armor');
const relicRow = relicBreakdown.rows.find(row => row.source === 'relic');
const weaponRow = weaponBreakdown.rows.find(
(row) => row.source === 'weapon',
);
const armorRow = armorBreakdown.rows.find((row) => row.source === 'armor');
const relicRow = relicBreakdown.rows.find((row) => row.source === 'relic');
expect(weaponRow?.sourceCoefficient).toBe(0.85);
expect(armorRow?.sourceCoefficient).toBe(0.75);
expect(relicRow?.sourceCoefficient).toBe(0.8);
expect(weaponRow?.bonusDelta ?? 0).toBeGreaterThan(relicRow?.bonusDelta ?? 0);
expect(relicRow?.bonusDelta ?? 0).toBeGreaterThan(armorRow?.bonusDelta ?? 0);
expect(weaponRow?.bonusDelta ?? 0).toBeGreaterThan(
relicRow?.bonusDelta ?? 0,
);
expect(relicRow?.bonusDelta ?? 0).toBeGreaterThan(
armorRow?.bonusDelta ?? 0,
);
});
it('does not allow resource attributes to enter tag bonus rows', () => {
const character = requireCharacter('sword-princess');
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
const mpBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: buildEquipmentItem({
id: 'mana-weapon',
name: 'Mana Weapon',
slot: 'weapon',
role: 'mana',
tags: ['mana'],
}),
armor: null,
relic: null,
}),
character,
);
const hpBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: buildEquipmentItem({
id: 'fortress-weapon',
name: 'Fortress Weapon',
slot: 'weapon',
role: 'fortress',
tags: ['fortress'],
}),
armor: null,
relic: null,
}),
character,
);
const mpRow = mpBreakdown.rows.find((row) => row.source === 'weapon');
const hpRow = hpBreakdown.rows.find((row) => row.source === 'weapon');
const mpAttributeRows = mpRow
? getBuildContributionAttributeRows(mpRow, schema)
: [];
const hpAttributeRows = hpRow
? getBuildContributionAttributeRows(hpRow, schema)
: [];
expect(
mpAttributeRows.every(
(attribute) => !attribute.slotId.startsWith('resource_'),
),
).toBe(true);
expect(
hpAttributeRows.every(
(attribute) => !attribute.slotId.startsWith('resource_'),
),
).toBe(true);
});
});

View File

@@ -5,21 +5,21 @@ import type {
EquipmentLoadout,
GameState,
InventoryItem,
RoleAttributeProfile,
SceneMonster,
TimedBuildBuff,
WorldAttributeSchema,
} from '../types';
import { WorldType } from '../types';
import {
WorldType,
} from '../types';
resolveCriticalStrike,
resolveRoleCombatStats,
} from './attributeCombat';
import {
getNormalizedAttributeWeights,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from './attributeResolver';
import { normalizeAttributeVector } from './attributeValidation';
import {getBuildTagAttributeSimilarityProfile} from './buildTagAttributeAffinity';
import { getBuildTagAttributeSimilarityProfile } from './buildTagAttributeAffinity';
import {
buildSetBuildTagLabel,
getBuildTagDefinition,
@@ -35,6 +35,16 @@ import { getEquipmentBonuses } from './equipmentEffects';
const MAX_ACTIVE_BUILD_TAGS = 8;
export const BASE_TAG_BONUS = 0.12;
export const MAX_BUILD_BONUS = 0.6;
export type BuildContributionQuality =
| 'common'
| 'fine'
| 'rare'
| 'epic'
| 'legendary';
export type BuildContributionResourceLabels = {
maxHp?: string | null;
maxMp?: string | null;
};
export type BuildTagSource =
| 'buff'
@@ -83,6 +93,37 @@ export type BuildContributionAttributeRow = {
percent: number;
};
export type OutgoingDamageResult = {
damage: number;
isCritical: boolean;
critChance: number;
critDamageMultiplier: number;
attackPowerMultiplier: number;
};
type BuildContributionTarget = {
slotId: string;
label: string;
definition: string;
};
type ResolvedTagAffinity = {
rawSimilarity: AttributeVector;
};
const BUILD_CONTRIBUTION_QUALITY_LEVELS: Array<{
tier: BuildContributionQuality;
label: string;
minimumBonus: number;
colorRatio: number;
}> = [
{ tier: 'legendary', label: '传说', minimumBonus: 0.06, colorRatio: 1 },
{ tier: 'epic', label: '史诗', minimumBonus: 0.045, colorRatio: 0.78 },
{ tier: 'rare', label: '稀有', minimumBonus: 0.03, colorRatio: 0.56 },
{ tier: 'fine', label: '优秀', minimumBonus: 0.018, colorRatio: 0.32 },
{ tier: 'common', label: '普通', minimumBonus: 0, colorRatio: 0.08 },
];
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -126,7 +167,8 @@ function pushTag(
label: normalizedLabel,
source,
priority,
relatedTags: relatedTags && relatedTags.length > 0 ? [...relatedTags] : undefined,
relatedTags:
relatedTags && relatedTags.length > 0 ? [...relatedTags] : undefined,
});
}
@@ -142,16 +184,21 @@ function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
if (!loadout) return [];
const tags: ResolvedBuildTag[] = [];
const setPieces = new Map<string, { count: number; tags: string[]; setName: string }>();
const setPieces = new Map<
string,
{ count: number; tags: string[]; setName: string }
>();
([
['weapon', loadout.weapon],
['armor', loadout.armor],
['relic', loadout.relic],
] as const).forEach(([slotId, item]) => {
(
[
['weapon', loadout.weapon],
['armor', loadout.armor],
['relic', loadout.relic],
] as const
).forEach(([slotId, item]) => {
if (!item) return;
const itemTags = getItemBuildTags(item);
itemTags.forEach(tag => pushTag(tags, tag, slotId, 60));
itemTags.forEach((tag) => pushTag(tags, tag, slotId, 60));
const setId = item.buildProfile?.setId?.trim();
const setName = item.buildProfile?.setName?.trim();
@@ -167,7 +214,7 @@ function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
setPieces.set(setId, entry);
});
setPieces.forEach(entry => {
setPieces.forEach((entry) => {
if (entry.count < 2) return;
pushTag(
tags,
@@ -184,7 +231,7 @@ function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
function dedupeAndLimitTags(tags: ResolvedBuildTag[]) {
const bestByLabel = new Map<string, ResolvedBuildTag>();
tags.forEach(tag => {
tags.forEach((tag) => {
const existing = bestByLabel.get(tag.label);
if (!existing || tag.priority > existing.priority) {
bestByLabel.set(tag.label, tag);
@@ -192,70 +239,147 @@ function dedupeAndLimitTags(tags: ResolvedBuildTag[]) {
});
return [...bestByLabel.values()]
.sort((left, right) => right.priority - left.priority || left.label.localeCompare(right.label, 'zh-CN'))
.sort(
(left, right) =>
right.priority - left.priority ||
left.label.localeCompare(right.label, 'zh-CN'),
)
.slice(0, MAX_ACTIVE_BUILD_TAGS);
}
function averageAttributeVectors(vectors: AttributeVector[], slotIds: readonly string[]) {
function averageAttributeVectors(
vectors: AttributeVector[],
slotIds: readonly string[],
) {
if (vectors.length === 0) {
const evenShare = 1 / Math.max(slotIds.length, 1);
return Object.fromEntries(slotIds.map(slotId => [slotId, evenShare]));
return Object.fromEntries(slotIds.map((slotId) => [slotId, evenShare]));
}
return Object.fromEntries(
slotIds.map(slotId => [
slotIds.map((slotId) => [
slotId,
roundNumber(vectors.reduce((sum, vector) => sum + (vector[slotId] ?? 0), 0) / vectors.length, 4),
roundNumber(
vectors.reduce((sum, vector) => sum + (vector[slotId] ?? 0), 0) /
vectors.length,
4,
),
]),
);
}
function resolveTagAffinity(tag: ResolvedBuildTag, schema: WorldAttributeSchema) {
function resolveTagAffinity(
tag: ResolvedBuildTag,
schema: WorldAttributeSchema,
) {
const definition = getBuildTagDefinition(tag.label);
if (definition) {
return getBuildTagAttributeSimilarityProfile(definition.id, schema);
return {
rawSimilarity: getBuildTagAttributeSimilarityProfile(
definition.id,
schema,
).rawSimilarity,
} satisfies ResolvedTagAffinity;
}
const relatedAffinities = (tag.relatedTags ?? []).flatMap(relatedTag => {
const relatedDefinition = getBuildTagDefinition(relatedTag);
if (!relatedDefinition) {
return [];
}
const relatedSchemaAffinities = (tag.relatedTags ?? []).flatMap(
(relatedTag) => {
const relatedDefinition = getBuildTagDefinition(relatedTag);
if (!relatedDefinition) {
return [];
}
return [getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema).rawSimilarity];
});
const rawSimilarity = averageAttributeVectors(relatedAffinities, schema.slots.map(slot => slot.slotId));
return [
getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema)
.rawSimilarity,
];
},
);
const rawSimilarity = averageAttributeVectors(
relatedSchemaAffinities,
schema.slots.map((slot) => slot.slotId),
);
return {
rawSimilarity,
normalizedSimilarity: normalizeAttributeVector(rawSimilarity, schema.slots.map(slot => slot.slotId)),
};
} satisfies ResolvedTagAffinity;
}
function resolveContributionTargets(
schema: WorldAttributeSchema,
_resourceLabels?: BuildContributionResourceLabels | null,
) {
return schema.slots.map((slot) => ({
slotId: slot.slotId,
label: slot.name,
definition: slot.definition,
})) satisfies BuildContributionTarget[];
}
function buildAttributeContributions(
profile: RoleAttributeProfile | null | undefined,
tagAffinity: AttributeVector,
tagAffinity: ResolvedTagAffinity,
schema: WorldAttributeSchema,
sourceCoefficient: number,
resourceLabels?: BuildContributionResourceLabels | null,
) {
const slotIds = schema.slots.map(slot => slot.slotId);
const attributeWeights = getNormalizedAttributeWeights(profile, schema);
const normalizedAffinity = normalizeAttributeVector(tagAffinity ?? {}, slotIds);
const targets = resolveContributionTargets(schema, resourceLabels);
const slotIds = targets.map((target) => target.slotId);
const rawSimilarity = Object.fromEntries(
targets.map((target) => {
return [
target.slotId,
roundNumber(tagAffinity.rawSimilarity[target.slotId] ?? 0, 4),
];
}),
);
const normalizedAffinity = normalizeAttributeVector(rawSimilarity, slotIds);
const effectiveSlotIds = new Set(
[...slotIds]
.sort((left, right) => {
const difference =
(normalizedAffinity[right] ?? 0) - (normalizedAffinity[left] ?? 0);
if (Math.abs(difference) > 0.0001) {
return difference;
}
return left.localeCompare(right, 'zh-CN');
})
.slice(0, 2),
);
const attributeContributions = Object.fromEntries(
slotIds.map(slotId => [
slotIds.map((slotId) => [
slotId,
roundNumber((attributeWeights[slotId] ?? 0) * (normalizedAffinity[slotId] ?? 0), 4),
roundNumber(
effectiveSlotIds.has(slotId) ? (normalizedAffinity[slotId] ?? 0) : 0,
4,
),
]),
);
const attributeWeights = Object.fromEntries(
slotIds.map((slotId) => [
slotId,
roundNumber(
effectiveSlotIds.has(slotId) ? (normalizedAffinity[slotId] ?? 0) : 0,
4,
),
]),
);
const attributeModifierDeltas = Object.fromEntries(
slotIds.map(slotId => [
slotIds.map((slotId) => [
slotId,
roundNumber(BASE_TAG_BONUS * sourceCoefficient * (attributeContributions[slotId] ?? 0), 4),
roundNumber(
BASE_TAG_BONUS *
sourceCoefficient *
(attributeContributions[slotId] ?? 0),
4,
),
]),
);
const fitScore = roundNumber(
slotIds.reduce((sum, slotId) => sum + (attributeContributions[slotId] ?? 0), 0),
slotIds.reduce(
(sum, slotId) => sum + (attributeContributions[slotId] ?? 0),
0,
),
4,
);
@@ -270,8 +394,8 @@ function buildAttributeContributions(
function buildBreakdownFromTags(
tags: ResolvedBuildTag[],
profile: RoleAttributeProfile | null | undefined,
schema: WorldAttributeSchema,
resourceLabels?: BuildContributionResourceLabels | null,
): BuildDamageBreakdown {
if (tags.length === 0) {
return {
@@ -283,7 +407,7 @@ function buildBreakdownFromTags(
};
}
const rows = tags.map(currentTag => {
const rows = tags.map((currentTag) => {
const tagAffinity = resolveTagAffinity(currentTag, schema);
const sourceCoefficient = getSourceCoefficient(currentTag.source);
const {
@@ -292,9 +416,17 @@ function buildBreakdownFromTags(
normalizedAffinity,
attributeContributions,
attributeModifierDeltas,
} = buildAttributeContributions(profile, tagAffinity.normalizedSimilarity, schema, sourceCoefficient);
} = buildAttributeContributions(
tagAffinity,
schema,
sourceCoefficient,
resourceLabels,
);
const bonusDelta = roundNumber(
Object.values(attributeModifierDeltas).reduce((sum, value) => sum + value, 0),
Object.values(attributeModifierDeltas).reduce(
(sum, value) => sum + value,
0,
),
4,
);
@@ -312,13 +444,17 @@ function buildBreakdownFromTags(
});
const buildDamageBonus = roundNumber(
clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, MAX_BUILD_BONUS),
clamp(
rows.reduce((sum, row) => sum + row.bonusDelta, 0),
0,
MAX_BUILD_BONUS,
),
4,
);
const buildDamageMultiplier = roundNumber(1 + buildDamageBonus, 4);
return {
tags: tags.map(tag => tag.label),
tags: tags.map((tag) => tag.label),
baseTagCount: tags.length,
buildDamageBonus,
buildDamageMultiplier,
@@ -347,68 +483,139 @@ export function getBuildSourceLabel(source: BuildTagSource) {
}
}
export function getBuildContributionQuality(
bonusDelta: number,
): (typeof BUILD_CONTRIBUTION_QUALITY_LEVELS)[number] {
const fallbackLevel =
BUILD_CONTRIBUTION_QUALITY_LEVELS[
BUILD_CONTRIBUTION_QUALITY_LEVELS.length - 1
] ?? BUILD_CONTRIBUTION_QUALITY_LEVELS[0]!;
return (
BUILD_CONTRIBUTION_QUALITY_LEVELS.find(
(level) => bonusDelta >= level.minimumBonus,
) ?? fallbackLevel
);
}
export function getBuildContributionQualityLabel(bonusDelta: number) {
return getBuildContributionQuality(bonusDelta).label;
}
export function getBuildContributionQualityRatio(bonusDelta: number) {
return getBuildContributionQuality(bonusDelta).colorRatio;
}
export function formatBuildContributionPercent(value: number, digits = 1) {
const percentValue = roundNumber(value * 100, digits);
const normalizedDigits = Math.max(0, digits);
return `${percentValue >= 0 ? '+' : ''}${percentValue.toFixed(normalizedDigits)}%`;
}
export function getBuildContributionAttributeRows(
row: Pick<
BuildContributionRow,
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
| 'attributeContributions'
| 'attributeModifierDeltas'
| 'attributeSimilarities'
| 'attributeWeights'
>,
schema: WorldAttributeSchema,
minimumValue = 0.0001,
options: {
minimumValue?: number;
resourceLabels?: BuildContributionResourceLabels | null;
} = {},
) {
const totalModifierDelta = Object.values(row.attributeModifierDeltas ?? {}).reduce((sum, value) => sum + value, 0);
const minimumValue = options.minimumValue ?? 0.0001;
const totalModifierDelta = Object.values(
row.attributeModifierDeltas ?? {},
).reduce((sum, value) => sum + value, 0);
const targets = resolveContributionTargets(schema, options.resourceLabels);
return schema.slots
.map(slot => {
const value = roundNumber(row.attributeContributions[slot.slotId] ?? 0, 4);
const modifierDelta = roundNumber(row.attributeModifierDeltas?.[slot.slotId] ?? 0, 4);
const percent = totalModifierDelta > 0 ? roundNumber(modifierDelta / totalModifierDelta, 4) : 0;
return targets
.map((target) => {
const value = roundNumber(
row.attributeContributions[target.slotId] ?? 0,
4,
);
const modifierDelta = roundNumber(
row.attributeModifierDeltas?.[target.slotId] ?? 0,
4,
);
const percent =
totalModifierDelta > 0
? roundNumber(modifierDelta / totalModifierDelta, 4)
: 0;
return {
slotId: slot.slotId,
label: slot.name,
definition: slot.definition,
similarity: roundNumber(row.attributeSimilarities?.[slot.slotId] ?? 0, 4),
weight: roundNumber(row.attributeWeights?.[slot.slotId] ?? 0, 4),
slotId: target.slotId,
label: target.label,
definition: target.definition,
similarity: roundNumber(
row.attributeSimilarities?.[target.slotId] ?? 0,
4,
),
weight: roundNumber(row.attributeWeights?.[target.slotId] ?? 0, 4),
value,
modifierDelta,
percent,
} satisfies BuildContributionAttributeRow;
})
.filter(entry => entry.value > minimumValue || entry.modifierDelta > minimumValue)
.sort((left, right) => right.value - left.value || left.label.localeCompare(right.label, 'zh-CN'));
.filter(
(entry) =>
entry.value > minimumValue || entry.modifierDelta > minimumValue,
)
.sort(
(left, right) =>
right.modifierDelta - left.modifierDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
}
export function describeBuildContribution(
row: Pick<
BuildContributionRow,
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
| 'attributeContributions'
| 'attributeModifierDeltas'
| 'attributeSimilarities'
| 'attributeWeights'
>,
schema: WorldAttributeSchema,
limit = 2,
options: {
limit?: number;
resourceLabels?: BuildContributionResourceLabels | null;
} = {},
) {
const topRows = getBuildContributionAttributeRows(row, schema).slice(0, limit);
const limit = options.limit ?? 2;
const topRows = getBuildContributionAttributeRows(row, schema, options).slice(
0,
limit,
);
if (topRows.length === 0) {
return '\u5f53\u524d\u5c5e\u6027\u9002\u914d\u8f83\u5f31';
return '\u6682\u65e0\u53ef\u89c1\u5c5e\u6027\u52a0\u6210';
}
if (topRows.length === 1) {
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc`;
}
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc\uff0c${topRows[1]?.label ?? '\u8f85\u52a9\u5c5e\u6027'}\u8f85\u52a9`;
return topRows
.map(
(entry) =>
`${entry.label} ${formatBuildContributionPercent(entry.modifierDelta)}`,
)
.join('');
}
function getPlayerBuffs(gameState: GameState) {
return (gameState.activeBuildBuffs ?? []).filter(buff => (buff.durationTurns ?? 0) > 0);
return (gameState.activeBuildBuffs ?? []).filter(
(buff) => (buff.durationTurns ?? 0) > 0,
);
}
export function tickBuildBuffs(buffs: TimedBuildBuff[] | null | undefined) {
return (buffs ?? [])
.map(buff => ({
.map((buff) => ({
...buff,
durationTurns: Math.max(0, (buff.durationTurns ?? 0) - 1),
}))
.filter(buff => buff.durationTurns > 0);
.filter((buff) => buff.durationTurns > 0);
}
export function appendBuildBuffs(
@@ -417,9 +624,12 @@ export function appendBuildBuffs(
) {
const merged = new Map<string, TimedBuildBuff>();
[...(baseBuffs ?? []), ...(additions ?? [])].forEach(buff => {
[...(baseBuffs ?? []), ...(additions ?? [])].forEach((buff) => {
const existing = merged.get(buff.id);
if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) {
if (
!existing ||
(buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)
) {
merged.set(buff.id, {
...buff,
tags: normalizeBuildTags(buff.tags),
@@ -427,18 +637,28 @@ export function appendBuildBuffs(
}
});
return [...merged.values()].filter(buff => buff.tags.length > 0 && buff.durationTurns > 0);
return [...merged.values()].filter(
(buff) => buff.tags.length > 0 && buff.durationTurns > 0,
);
}
export function getPlayerBuildDamageBreakdown(gameState: GameState, character: Character) {
export function getPlayerBuildDamageBreakdown(
gameState: GameState,
character: Character,
) {
const tags: ResolvedBuildTag[] = [];
getTimedBuildBuffTags(getPlayerBuffs(gameState)).forEach(tag => pushTag(tags, tag, 'buff', 100));
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
getLoadoutBuildTags(gameState.playerEquipment).forEach(tag => tags.push(tag));
getTimedBuildBuffTags(getPlayerBuffs(gameState)).forEach((tag) =>
pushTag(tags, tag, 'buff', 100),
);
getCharacterCombatTags(character).forEach((tag) =>
pushTag(tags, tag, 'character', 90),
);
getLoadoutBuildTags(gameState.playerEquipment).forEach((tag) =>
tags.push(tag),
);
return buildBreakdownFromTags(
dedupeAndLimitTags(tags),
resolveCharacterAttributeProfile(character, gameState.worldType, gameState.customWorldProfile),
resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile),
);
}
@@ -449,12 +669,14 @@ export function getCompanionBuildDamageBreakdown(
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const tags: ResolvedBuildTag[] = [];
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
const resolvedWorldType = worldType ?? (customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
getCharacterCombatTags(character).forEach((tag) =>
pushTag(tags, tag, 'character', 90),
);
const resolvedWorldType =
worldType ?? (customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
return buildBreakdownFromTags(
dedupeAndLimitTags(tags),
resolveCharacterAttributeProfile(character, resolvedWorldType, customWorldProfile),
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
);
}
@@ -465,13 +687,19 @@ export function getMonsterBuildDamageBreakdown(
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const tags: ResolvedBuildTag[] = [];
getSceneMonsterCombatTags(monster).forEach(tag => pushTag(tags, tag, 'monster', 90));
const resolvedWorldType = worldType
?? (monster.attributeProfile?.schemaId?.includes('xianxia') ? WorldType.XIANXIA : customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
getSceneMonsterCombatTags(monster).forEach((tag) =>
pushTag(tags, tag, 'monster', 90),
);
const resolvedWorldType =
worldType ??
(monster.attributeProfile?.schemaId?.includes('xianxia')
? WorldType.XIANXIA
: customWorldProfile
? WorldType.CUSTOM
: WorldType.WUXIA);
return buildBreakdownFromTags(
dedupeAndLimitTags(tags),
monster.attributeProfile ?? null,
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
);
}
@@ -482,25 +710,61 @@ export function calculateOutgoingDamage(
functionMultiplier?: number;
equipmentMultiplier?: number;
buildMultiplier?: number;
attackPowerMultiplier?: number;
} = {},
) {
return Math.max(
1,
Math.round(
baseDamage
* (options.functionMultiplier ?? 1)
* (options.equipmentMultiplier ?? 1)
* (options.buildMultiplier ?? 1),
baseDamage *
(options.functionMultiplier ?? 1) *
(options.equipmentMultiplier ?? 1) *
(options.buildMultiplier ?? 1) *
(options.attackPowerMultiplier ?? 1),
),
);
}
export function calculateOutgoingDamageResult(
baseDamage: number,
options: {
functionMultiplier?: number;
equipmentMultiplier?: number;
buildMultiplier?: number;
attackPowerMultiplier?: number;
criticalHit?: boolean;
critDamageMultiplier?: number;
critChance?: number;
} = {},
): OutgoingDamageResult {
const baseResolvedDamage = calculateOutgoingDamage(baseDamage, options);
const isCritical = options.criticalHit ?? false;
const critDamageMultiplier = options.critDamageMultiplier ?? 1;
return {
damage: Math.max(
1,
Math.round(baseResolvedDamage * (isCritical ? critDamageMultiplier : 1)),
),
isCritical,
critChance: options.critChance ?? 0,
critDamageMultiplier,
attackPowerMultiplier: options.attackPowerMultiplier ?? 1,
};
}
export function resolvePlayerOutgoingDamage(
gameState: GameState,
character: Character,
baseDamage: number,
functionMultiplier = 1,
) {
const attributeProfile = resolveCharacterAttributeProfile(
character,
gameState.worldType,
gameState.customWorldProfile,
);
const combatStats = resolveRoleCombatStats(attributeProfile);
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
@@ -508,6 +772,37 @@ export function resolvePlayerOutgoingDamage(
functionMultiplier,
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
});
}
export function resolvePlayerOutgoingDamageResult(
gameState: GameState,
character: Character,
baseDamage: number,
functionMultiplier = 1,
critRollSeed?: string,
) {
const attributeProfile = resolveCharacterAttributeProfile(
character,
gameState.worldType,
gameState.customWorldProfile,
);
const combatStats = resolveRoleCombatStats(attributeProfile);
const criticalStrike = critRollSeed
? resolveCriticalStrike(attributeProfile, critRollSeed)
: null;
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
return calculateOutgoingDamageResult(baseDamage, {
functionMultiplier,
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
criticalHit: criticalStrike?.isCritical ?? false,
critChance: combatStats.critChance,
critDamageMultiplier: combatStats.critDamageMultiplier,
});
}
@@ -518,11 +813,55 @@ export function resolveCompanionOutgoingDamage(
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const buildBreakdown = getCompanionBuildDamageBreakdown(character, worldType, customWorldProfile);
const attributeProfile = resolveCharacterAttributeProfile(
character,
worldType,
customWorldProfile,
);
const combatStats = resolveRoleCombatStats(attributeProfile);
const buildBreakdown = getCompanionBuildDamageBreakdown(
character,
worldType,
customWorldProfile,
);
return calculateOutgoingDamage(baseDamage, {
functionMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
});
}
export function resolveCompanionOutgoingDamageResult(
character: Character,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
critRollSeed?: string,
) {
const attributeProfile = resolveCharacterAttributeProfile(
character,
worldType,
customWorldProfile,
);
const combatStats = resolveRoleCombatStats(attributeProfile);
const criticalStrike = critRollSeed
? resolveCriticalStrike(attributeProfile, critRollSeed)
: null;
const buildBreakdown = getCompanionBuildDamageBreakdown(
character,
worldType,
customWorldProfile,
);
return calculateOutgoingDamageResult(baseDamage, {
functionMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
criticalHit: criticalStrike?.isCritical ?? false,
critChance: combatStats.critChance,
critDamageMultiplier: combatStats.critDamageMultiplier,
});
}
@@ -533,10 +872,44 @@ export function resolveMonsterOutgoingDamage(
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const buildBreakdown = getMonsterBuildDamageBreakdown(monster, worldType, customWorldProfile);
const combatStats = resolveRoleCombatStats(monster.attributeProfile);
const buildBreakdown = getMonsterBuildDamageBreakdown(
monster,
worldType,
customWorldProfile,
);
return calculateOutgoingDamage(baseDamage, {
functionMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
});
}
export function resolveMonsterOutgoingDamageResult(
monster: SceneMonster,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
critRollSeed?: string,
) {
const combatStats = resolveRoleCombatStats(monster.attributeProfile);
const criticalStrike = critRollSeed
? resolveCriticalStrike(monster.attributeProfile, critRollSeed)
: null;
const buildBreakdown = getMonsterBuildDamageBreakdown(
monster,
worldType,
customWorldProfile,
);
return calculateOutgoingDamageResult(baseDamage, {
functionMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
criticalHit: criticalStrike?.isCritical ?? false,
critChance: combatStats.critChance,
critDamageMultiplier: combatStats.critDamageMultiplier,
});
}

View File

@@ -20,6 +20,11 @@ import {
WorldTemplateType,
WorldType,
} from '../types';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
} from './affinityLevels';
import { resolveRoleCombatStats } from './attributeCombat';
import {
buildCharacterAttributeProfile,
buildCustomWorldPlayableNpcAttributeProfile,
@@ -40,6 +45,13 @@ function defineSkill(skill: CharacterSkillDefinition): CharacterSkillDefinition
return skill;
}
const [
BACKSTORY_UNLOCK_AFFINITY_EASED = 15,
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY = 30,
BACKSTORY_UNLOCK_AFFINITY_TRUSTED = 60,
BACKSTORY_UNLOCK_AFFINITY_CLOSE = 90,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
function effect(definition: CharacterSkillEffectDefinition) {
return definition;
}
@@ -144,16 +156,35 @@ export type CharacterPresetOverride = Partial<Omit<Character, 'attributes' | 'sk
const CHARACTER_OVERRIDES = characterOverridesJson as Record<string, CharacterPresetOverride>;
export const UNIVERSAL_MAX_MANA = 999;
function getLegacyCharacterMaxHp(character: Character) {
function getLegacyCharacterBaseMaxHp(character: Character) {
return Math.max(120, 90 + character.attributes.strength * 10 + character.attributes.spirit * 4);
}
function getCharacterBaseResourceProfile(character: Character) {
return character.resourceProfile ?? buildCharacterResourceProfile(character);
}
export function getCharacterMaxMana(character: Character) {
return character.resourceProfile?.maxMana ?? UNIVERSAL_MAX_MANA;
}
export function getCharacterMaxHp(character: Character) {
return character.resourceProfile?.maxHp ?? getLegacyCharacterMaxHp(character);
export function getCharacterCombatStats(
character: Character,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
return resolveRoleCombatStats(
resolveCharacterAttributeProfile(character, worldType, customWorldProfile) ?? character.attributeProfile,
);
}
export function getCharacterMaxHp(
character: Character,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
return getCharacterBaseResourceProfile(character).maxHp
+ getCharacterCombatStats(character, worldType, customWorldProfile).maxHpBonus;
}
export function createCharacterSkillCooldowns(character: Character) {
@@ -175,7 +206,10 @@ function buildCharacterResourceProfile(character: Character) {
: 188;
return {
maxHp: baseHp + Math.min(18, character.skills.length * 4),
maxHp: Math.max(
getLegacyCharacterBaseMaxHp(character),
baseHp + Math.min(18, character.skills.length * 4),
),
maxMana: UNIVERSAL_MAX_MANA,
};
}
@@ -206,6 +240,50 @@ function hydrateCharacterRoleData(
initialAffinity: 18,
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
tags: character.combatTags ?? [],
backstoryReveal: {
publicSummary: character.description,
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: character.description,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: character.backstory,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: character.personality,
content: character.personality,
contextSnippet: character.personality,
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: character.skills[0]?.name ?? character.title,
content: character.backstory,
contextSnippet: character.backstory,
},
],
},
skills: character.skills.slice(0, 3).map((skill, index) => ({
id: `preset-skill-${index + 1}`,
name: skill.name,
summary: skill.name,
style: skill.style,
})),
initialItems: [],
},
options.customWorldProfile.attributeSchema,
character.attributes,
@@ -232,8 +310,10 @@ export function buildCompanionState(
npcId: string,
character: Character,
joinedAtAffinity: number,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
): CompanionState {
const maxHp = Math.max(180, getCharacterMaxHp(character));
const maxHp = Math.max(180, getCharacterMaxHp(character, worldType, customWorldProfile));
const maxMana = getCharacterMaxMana(character);
return {
@@ -1434,6 +1514,8 @@ function buildCustomWorldSkillVariant(
index: number,
) {
const themeMode = detectCustomWorldThemeMode(profile);
const generatedSkill =
role.skills[index % Math.max(1, role.skills.length)] ?? null;
const contextText = [
profile.name,
profile.settingText,
@@ -1445,7 +1527,13 @@ function buildCustomWorldSkillVariant(
role.backstory,
role.personality,
role.combatStyle,
role.backstoryReveal.publicSummary,
role.skills.map((item) => `${item.name} ${item.summary} ${item.style}`).join(' '),
role.initialItems.map((item) => `${item.name} ${item.category} ${item.description}`).join(' '),
role.tags.join(' '),
generatedSkill?.name ?? '',
generatedSkill?.summary ?? '',
generatedSkill?.style ?? '',
].join(' ');
const seed = hashText(`${contextText}:${baseCharacter.id}:${skill.id}:${index}`);
const isRangedSkill = skill.delivery === 'ranged' || skill.style === 'projectile';
@@ -1469,7 +1557,9 @@ function buildCustomWorldSkillVariant(
return {
...skill,
name: buildThemedSkillName(profile, baseCharacter, skill, index, role),
name:
generatedSkill?.name?.trim()
|| buildThemedSkillName(profile, baseCharacter, skill, index, role),
damage: clampInteger(skill.damage + damageBoost, Math.max(6, skill.damage - 4), skill.damage + 12),
manaCost: clampInteger(skill.manaCost + manaShift, 0, skill.manaCost + 5),
cooldownTurns: clampInteger(skill.cooldownTurns + cooldownShift, 1, skill.cooldownTurns + 2),
@@ -1523,12 +1613,16 @@ function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorl
title: role.title,
description: role.description,
backstory: role.backstory,
backstoryReveal: role.backstoryReveal,
personality: role.personality,
conversationStyle: inferConversationStyleFromText([
role.personality,
role.description,
role.backstory,
role.combatStyle,
role.backstoryReveal.publicSummary,
role.skills.map((skill) => `${skill.name} ${skill.summary}`).join('、'),
role.initialItems.map((item) => `${item.name} ${item.description}`).join('、'),
role.tags.join('、'),
].join(' ')),
combatTags,
@@ -1604,8 +1698,6 @@ export function getCharacterAdventureOpening(character: Character, worldType: Wo
return character.adventureOpenings?.[worldType] ?? null;
}
const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 70;
function truncateText(text: string, maxLength = 26) {
const normalized = text.trim().replace(/\s+/g, ' ');
if (normalized.length <= maxLength) {
@@ -1640,7 +1732,7 @@ function buildFallbackBackstoryRevealConfig(
{
id: 'surface-hook',
title: '表层来意',
affinityRequired: 20,
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: truncateText(opening?.surfaceHook ?? opening?.guardedMotive ?? backstoryLead),
content: [
opening?.surfaceHook ? `最先能看出来的,只是:${opening.surfaceHook}` : null,
@@ -1655,7 +1747,7 @@ function buildFallbackBackstoryRevealConfig(
{
id: 'old-scars',
title: '旧事残痕',
affinityRequired: 40,
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: truncateText(backstoryLead),
content: backstoryDetail,
contextSnippet: `${character.name}的旧事里埋着一段尚未完全说开的经历:${truncateText(backstoryLead, 36)}`,
@@ -1663,7 +1755,7 @@ function buildFallbackBackstoryRevealConfig(
{
id: 'real-reason',
title: '真正来由',
affinityRequired: 65,
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: truncateText(opening?.reason ?? backstoryDetail),
content: opening?.reason
? `${character.name}来到此地真正的原因是:${opening.reason}`
@@ -1675,7 +1767,7 @@ function buildFallbackBackstoryRevealConfig(
{
id: 'current-goal',
title: '当前执念',
affinityRequired: 85,
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: truncateText(opening?.goal ?? normalizedBackstory),
content: opening?.goal
? [
@@ -1705,17 +1797,23 @@ export function getCharacterBackstoryRevealConfig(
publicSummary: configured.publicSummary?.trim() || fallback.publicSummary,
privateChatUnlockAffinity:
configured.privateChatUnlockAffinity ?? fallback.privateChatUnlockAffinity,
chapters:
configured.chapters?.map((chapter, index) => ({
...chapter,
id: chapter.id?.trim() || `chapter-${index + 1}`,
title: chapter.title?.trim() || `背景片段 ${index + 1}`,
teaser: chapter.teaser?.trim() || truncateText(chapter.content),
content: chapter.content?.trim() || fallback.chapters[index]?.content || '',
chapters: fallback.chapters.map((fallbackChapter, index) => {
const chapter = configured.chapters?.[index];
const content = chapter?.content?.trim() || fallbackChapter.content || '';
return {
...fallbackChapter,
id: chapter?.id?.trim() || fallbackChapter.id || `chapter-${index + 1}`,
title:
chapter?.title?.trim() ||
fallbackChapter.title ||
`背景片段 ${index + 1}`,
affinityRequired: fallbackChapter.affinityRequired,
teaser: chapter?.teaser?.trim() || truncateText(content),
content,
contextSnippet:
chapter.contextSnippet?.trim()
|| truncateText(chapter.content || fallback.chapters[index]?.content || '', 48),
})) ?? fallback.chapters,
chapter?.contextSnippet?.trim() || truncateText(content, 48),
};
}),
};
}

View File

@@ -1,4 +1,11 @@
import { Character, CustomWorldPlayableNpc, CustomWorldProfile, EquipmentSlotId, InventoryItem } from '../types';
import {
Character,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
EquipmentSlotId,
InventoryItem,
} from '../types';
import {
buildRuntimeCustomWorldInventoryItems,
getRuntimeCustomWorldProfile,
@@ -30,11 +37,11 @@ const STOP_PHRASES = new Set([
'剧情关键',
'后续冒险',
'完整角色',
'当å‰<EFBFBD>å±€åŠ?',
'当前局<EFBFBD>?',
'进入世界',
'核心目标',
'å<EFBFBD>¯æ‰®æ¼?',
'主角候�',
'可扮<EFBFBD>?',
'主角候<EFBFBD>?',
'主要角色',
'当前角色',
'这趟旅程',
@@ -55,6 +62,65 @@ const THEME_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
{ pattern: /flora|seed|bloom|vine|root/i, tags: ['herb', 'alchemy', 'material'] },
];
function normalizeExplicitItemCategory(category: string) {
const normalized = category.trim();
return normalized === '专属物' ? '专属物品' : normalized;
}
function inferEquipmentSlotFromCategory(category: string): EquipmentSlotId | null {
const normalized = normalizeExplicitItemCategory(category);
if (normalized === '武器') return 'weapon';
if (normalized === '护甲') return 'armor';
if (
normalized === '饰品'
|| normalized === '稀有品'
|| normalized === '专属物品'
) {
return 'relic';
}
return null;
}
function buildExplicitRoleInventoryItem(
role: CustomWorldPlayableNpc,
item: CustomWorldRoleInitialItem,
index: number,
): InventoryItem {
const category = normalizeExplicitItemCategory(item.category);
return {
id: `custom-role-item:${role.id}:${index + 1}`,
category,
name: item.name,
quantity: Math.max(1, item.quantity),
rarity: item.rarity,
tags: [...item.tags],
description: item.description,
equipmentSlotId: inferEquipmentSlotFromCategory(category),
runtimeMetadata: {
origin: 'ai_compiled',
generationChannel: 'discovery',
seedKey: `${role.id}:${index + 1}`,
relationAnchor: {
type: 'npc',
npcId: role.id,
npcName: role.name,
roleText: role.role,
},
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
},
};
}
function buildExplicitRoleInventoryItems(role: CustomWorldPlayableNpc | null) {
if (!role) {
return [] as InventoryItem[];
}
return role.initialItems.map((item, index) =>
buildExplicitRoleInventoryItem(role, item, index),
);
}
function resolveCustomWorldPlayableRole(profile: CustomWorldProfile, character: Character) {
return profile.playableNpcs.find(role => role.id === character.id)
?? profile.playableNpcs.find(role => role.templateCharacterId === character.id)
@@ -79,7 +145,7 @@ function sortInventoryByCategory(items: InventoryItem[]) {
function collectPhrases(sourceTexts: string[]) {
return sourceTexts.flatMap(text =>
text
.split(/[[\]\s,ãã<EFBFBD>âœâ<EFBFBD>â˜âï¼ï¼šï¼Ÿï¼?.!?:()(ï¼ã<EFBFBD>ã?]+/u)
.split(/[[\]\s<EFBFBD>?.!?:()<EFBFBD>?]+/u)
.map(segment => segment.trim())
.filter(segment => segment.length >= 2 && segment.length <= 12)
.filter(segment => !STOP_PHRASES.has(segment)),
@@ -111,7 +177,10 @@ function buildKeywordBundle(profile: CustomWorldProfile, character: Character, r
role?.title ?? '',
role?.description ?? '',
role?.backstory ?? '',
role?.backstoryReveal.publicSummary ?? '',
role?.combatStyle ?? '',
...(role?.skills.map(skill => `${skill.name} ${skill.summary} ${skill.style}`) ?? []),
...(role?.initialItems.map(item => `${item.name} ${item.category} ${item.description}`) ?? []),
...(role?.tags ?? []),
];
const characterTexts = [
@@ -140,8 +209,19 @@ function buildKeywordBundle(profile: CustomWorldProfile, character: Character, r
.flatMap(rule => rule.tags);
return {
preferredTags: dedupeStrings([...(role?.tags ?? []), ...(character.combatTags ?? []), ...heuristics], 18),
keywords: dedupeStrings([...phrases, ...ngrams, ...heuristics], 36),
preferredTags: dedupeStrings([
...(role?.tags ?? []),
...(role?.initialItems.flatMap(item => item.tags) ?? []),
...(character.combatTags ?? []),
...heuristics,
], 18),
keywords: dedupeStrings([
...phrases,
...ngrams,
...(role?.skills.map(skill => skill.name) ?? []),
...(role?.initialItems.map(item => item.name) ?? []),
...heuristics,
], 36),
};
}
@@ -192,6 +272,13 @@ export function buildCustomWorldStarterEquipmentItems(
}
const role = resolveCustomWorldPlayableRole(profile, character);
const explicitItems = buildExplicitRoleInventoryItems(role);
const explicitWeapon =
explicitItems.find(item => item.equipmentSlotId === 'weapon') ?? null;
const explicitArmor =
explicitItems.find(item => item.equipmentSlotId === 'armor') ?? null;
const explicitRelic =
explicitItems.find(item => item.equipmentSlotId === 'relic') ?? null;
const bundle = buildKeywordBundle(profile, character, role);
const baseTextKeywords = bundle.keywords;
const baseTags = bundle.preferredTags;
@@ -225,9 +312,9 @@ export function buildCustomWorldStarterEquipmentItems(
});
return {
weapon: weapon ?? null,
armor: armor ?? null,
relic: relic ?? null,
weapon: explicitWeapon ?? weapon ?? null,
armor: explicitArmor ?? armor ?? null,
relic: explicitRelic ?? relic ?? null,
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
}
@@ -241,13 +328,20 @@ export function buildCustomWorldStarterInventoryItems(
}
const role = resolveCustomWorldPlayableRole(profile, character);
const explicitItems = buildExplicitRoleInventoryItems(role);
const bundle = buildKeywordBundle(profile, character, role);
const consumables = queryItems(`inventory:${character.id}:consumables`, {
count: 2,
quantity: 2,
categories: ['消耗品'],
preferredTags: dedupeStrings([...bundle.preferredTags, 'healing', 'mana', '补给', '探索']),
keywords: dedupeStrings([...bundle.keywords, role?.combatStyle ?? '', 'è°ƒæ<C692>¯', '续战']),
keywords: dedupeStrings([
...bundle.keywords,
role?.combatStyle ?? '',
...explicitItems.map(item => item.name),
'调息',
'续战',
]),
});
const materials = queryItems(`inventory:${character.id}:materials`, {
count: 1,
@@ -271,7 +365,7 @@ export function buildCustomWorldStarterInventoryItems(
keywords: dedupeStrings([...bundle.keywords, profile.playerGoal, profile.name, '信物', '关键']),
});
const merged = mergeUniqueItems(consumables, materials, rareUtility, signature);
const merged = mergeUniqueItems(explicitItems, consumables, materials, rareUtility, signature);
if (merged.length >= 5) {
return sortInventoryByCategory(merged.slice(0, 5));
}

View File

@@ -1,6 +1,12 @@
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
import {
normalizeCustomWorldLandmarks,
type CustomWorldLandmarkDraft,
} from './customWorldSceneGraph';
import {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
@@ -10,12 +16,18 @@ import {
CustomWorldNpcVisualRace,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
CustomWorldRoleSkill,
EquipmentSlotId,
ItemRarity,
ItemStatProfile,
ItemUseProfile,
WorldType,
} from '../types';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
} from './affinityLevels';
import {coerceWorldAttributeSchema} from './attributeValidation';
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
@@ -29,6 +41,32 @@ const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic',
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
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([
'武器',
'护甲',
'饰品',
'消耗品',
'材料',
'稀有品',
'专属物品',
'专属物',
]);
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'] as const;
type CustomWorldRoleFallbackSource = {
name: string;
title: string;
role: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
relationshipHooks: string[];
tags: string[];
};
type StoredCustomWorldLibrary = {
version: number;
@@ -63,6 +101,211 @@ function normalizeInitialAffinity(value: unknown, fallback: number) {
return Math.max(MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved));
}
function truncateText(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 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);
}
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
const category = toText(value);
if (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES.has(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 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() || truncateText(normalizedBackstory, 42);
const fallbackContents = [
source.description.trim() || backstoryLead,
backstoryDetail,
source.motivation.trim()
? `${source.name}真正挂念的,是:${source.motivation.trim()}`
: `${source.name}的决定与“${truncateText(backstoryLead, 24)}”直接相关。`,
source.personality.trim()
? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}`
: `${source.name}仍把最深的筹码藏在过去里。`,
];
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),
};
}
function normalizeBackstoryReveal(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const fallback = buildFallbackBackstoryReveal(fallbackSource);
if (!isRecord(value)) {
return fallback;
}
const rawChapters = Array.isArray(value.chapters)
? value.chapters.filter(isRecord)
: [];
return {
publicSummary: toText(value.publicSummary, fallback.publicSummary),
privateChatUnlockAffinity:
typeof value.privateChatUnlockAffinity === 'number' && Number.isFinite(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 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;
}
function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
const nameSeed = source.title || source.role || source.name || '角色';
return [
{
id: 'saved-role-skill-1',
name: `${nameSeed}起手`,
summary: truncateText(source.combatStyle || `${source.name}擅长稳住局面。`, 36),
style: '起手压制',
},
{
id: 'saved-role-skill-2',
name: `${nameSeed}变招`,
summary: truncateText(source.personality || `${source.name}习惯在周旋中找破绽。`, 36),
style: '机动周旋',
},
{
id: 'saved-role-skill-3',
name: `${nameSeed}底牌`,
summary: truncateText(source.motivation || `${source.name}会在关键时刻亮出压箱手段。`, 36),
style: '爆发终结',
},
] satisfies CustomWorldRoleSkill[];
}
function normalizeRoleSkills(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
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)),
style: toText(entry.style, toText(entry.category, '常用')),
} satisfies CustomWorldRoleSkill))
.filter(entry => entry.name)
.slice(0, 3)
: [];
return normalized.length > 0 ? normalized : buildFallbackRoleSkills(fallbackSource);
}
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
const itemSeed = source.title || source.role || source.name || '角色';
return [
{
id: 'saved-role-item-1',
name: `${itemSeed}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: truncateText(source.combatStyle || `${source.name}随身携带的主要作战物件。`, 36),
tags: source.tags.slice(0, 2),
},
{
id: 'saved-role-item-2',
name: `${itemSeed}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: truncateText(source.personality || `${source.name}为长期行动准备的基础补给。`, 36),
tags: source.relationshipHooks.slice(0, 2),
},
{
id: 'saved-role-item-3',
name: `${itemSeed}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: truncateText(source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`, 36),
tags: [...source.tags, ...source.relationshipHooks].slice(0, 3),
},
] satisfies CustomWorldRoleInitialItem[];
}
function normalizeRoleInitialItems(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
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.quantity)
? 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
? normalized
: buildFallbackRoleInitialItems(fallbackSource);
}
function normalizeEquipmentSlot(value: unknown) {
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
? value as EquipmentSlotId
@@ -144,9 +387,7 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
const role = toText(value.role, title);
const relationshipHooks = toStringArray(value.relationshipHooks);
const tags = toStringArray(value.tags);
return {
id: toText(value.id, `saved-playable-${index + 1}`),
const fallbackSource = {
name,
title,
role,
@@ -155,9 +396,26 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
personality: toText(value.personality),
motivation: toText(value.motivation, toText(value.description)),
combatStyle: toText(value.combatStyle),
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
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,
title,
role,
description: fallbackSource.description,
backstory: fallbackSource.backstory,
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),
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
@@ -171,9 +429,7 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
const role = toText(value.role, title);
const relationshipHooks = toStringArray(value.relationshipHooks);
const tags = toStringArray(value.tags);
return {
id: toText(value.id, `saved-story-${index + 1}`),
const fallbackSource = {
name,
title,
role,
@@ -182,9 +438,26 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
personality: toText(value.personality),
motivation: toText(value.motivation),
combatStyle: toText(value.combatStyle),
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
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,
title,
role,
description: fallbackSource.description,
backstory: fallbackSource.backstory,
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,
visual: normalizeCustomWorldNpcVisual(value.visual),
};
@@ -229,6 +502,49 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark |
description: toText(value.description),
dangerLevel: toText(value.dangerLevel),
imageSrc: toText(value.imageSrc) || undefined,
sceneNpcIds: [],
connections: [],
};
}
function normalizeLandmarkDraft(
value: unknown,
index: number,
): CustomWorldLandmarkDraft | null {
if (!isRecord(value)) return null;
const normalizedLandmark = normalizeLandmark(value, index);
if (!normalizedLandmark) {
return null;
}
const rawConnections = Array.isArray(value.connections)
? value.connections.filter(isRecord)
: [];
return {
...normalizedLandmark,
sceneNpcIds: toStringArray(value.sceneNpcIds),
sceneNpcNames: [
...toStringArray(value.sceneNpcNames),
...toStringArray(value.npcNames),
...(Array.isArray(value.npcs)
? value.npcs
.filter(isRecord)
.map((item) => toText(item.name))
.filter(Boolean)
: []),
],
connections: rawConnections.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),
})),
};
}
@@ -256,6 +572,16 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
majorFactions: [],
coreConflicts: [summary || playerGoal || settingText || name],
});
const storyNpcs = Array.isArray(value.storyNpcs)
? value.storyNpcs
.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))
: [];
return {
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
@@ -272,21 +598,16 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
: [],
storyNpcs: Array.isArray(value.storyNpcs)
? value.storyNpcs
.map((entry, index) => normalizeStoryNpc(entry, index))
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
: [],
storyNpcs,
items: Array.isArray(value.items)
? value.items
.map((entry, index) => normalizeItem(entry, index))
.filter((entry): entry is CustomWorldItem => Boolean(entry))
: [],
landmarks: Array.isArray(value.landmarks)
? value.landmarks
.map((entry, index) => normalizeLandmark(entry, index))
.filter((entry): entry is CustomWorldLandmark => Boolean(entry))
: [],
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
}),
};
}

View File

@@ -0,0 +1,422 @@
import type {
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldSceneConnection,
CustomWorldSceneRelativePosition,
} from '../types';
export type CustomWorldSceneConnectionDraft = {
targetLandmarkId?: string;
targetLandmarkName?: string;
relativePosition?: unknown;
summary?: string;
};
export type CustomWorldLandmarkDraft = Omit<
CustomWorldLandmark,
'sceneNpcIds' | 'connections'
> & {
sceneNpcIds?: string[];
sceneNpcNames?: string[];
connections?: CustomWorldSceneConnectionDraft[];
};
export const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS: Array<{
value: CustomWorldSceneRelativePosition;
label: string;
}> = [
{ value: 'forward', label: '前方' },
{ value: 'back', label: '后方' },
{ value: 'left', label: '左侧' },
{ value: 'right', label: '右侧' },
{ value: 'north', label: '北侧' },
{ value: 'south', label: '南侧' },
{ value: 'east', label: '东侧' },
{ value: 'west', label: '西侧' },
{ value: 'up', label: '上方' },
{ value: 'down', label: '下方' },
{ value: 'inside', label: '内部' },
{ value: 'outside', label: '外部' },
{ value: 'portal', label: '传送节点' },
] as const;
const RELATIVE_POSITION_ALIASES: Record<
CustomWorldSceneRelativePosition,
string[]
> = {
forward: ['forward', 'front', 'ahead', '前方', '前面', '前侧', '向前'],
back: ['back', 'rear', 'behind', '后方', '后面', '后侧', '回程'],
left: ['left', '左侧', '左边', '左方'],
right: ['right', '右侧', '右边', '右方'],
north: ['north', '北侧', '北边', '北方', '上北'],
south: ['south', '南侧', '南边', '南方', '下南'],
east: ['east', '东侧', '东边', '东方'],
west: ['west', '西侧', '西边', '西方'],
up: ['up', 'upper', 'above', '上方', '上层', '高处', '顶部'],
down: ['down', 'lower', 'below', '下方', '下层', '低处', '底部'],
inside: ['inside', 'inner', 'indoors', '内部', '内侧', '内里', '室内'],
outside: ['outside', 'outer', 'outdoors', '外部', '外侧', '外围', '室外'],
portal: ['portal', 'gate', 'path', 'junction', '传送', '门', '入口', '通道'],
};
const RELATIVE_POSITION_LABELS = Object.fromEntries(
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map((option) => [
option.value,
option.label,
]),
) as Record<CustomWorldSceneRelativePosition, string>;
const RELATIVE_POSITION_DISPLAY_ORDER: CustomWorldSceneRelativePosition[] = [
'forward',
'north',
'east',
'right',
'up',
'outside',
'portal',
'left',
'west',
'south',
'down',
'inside',
'back',
];
function normalizeKey(value: string) {
return value.trim().toLowerCase();
}
function buildSceneNpcLookup(storyNpcs: CustomWorldNpc[]) {
const lookup = new Map<string, string>();
storyNpcs.forEach((npc) => {
const normalizedId = normalizeKey(npc.id);
const normalizedName = normalizeKey(npc.name);
if (normalizedId) {
lookup.set(normalizedId, npc.id);
}
if (normalizedName) {
lookup.set(normalizedName, npc.id);
}
});
return lookup;
}
function buildLandmarkLookup(landmarks: Array<Pick<CustomWorldLandmarkDraft, 'id' | 'name'>>) {
const lookup = new Map<string, string>();
landmarks.forEach((landmark) => {
const normalizedId = normalizeKey(landmark.id);
const normalizedName = normalizeKey(landmark.name);
if (normalizedId) {
lookup.set(normalizedId, landmark.id);
}
if (normalizedName) {
lookup.set(normalizedName, landmark.id);
}
});
return lookup;
}
function compactUnique(values: string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
function sortConnections(connections: CustomWorldSceneConnection[]) {
return [...connections].sort((left, right) => {
const leftOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
left.relativePosition,
);
const rightOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
right.relativePosition,
);
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.targetLandmarkId.localeCompare(right.targetLandmarkId);
});
}
function dedupeConnections(connections: CustomWorldSceneConnection[]) {
const deduped = new Map<string, CustomWorldSceneConnection>();
connections.forEach((connection) => {
const key = [
connection.targetLandmarkId.trim(),
connection.relativePosition,
connection.summary.trim(),
].join('::');
if (!deduped.has(key)) {
deduped.set(key, {
targetLandmarkId: connection.targetLandmarkId,
relativePosition: connection.relativePosition,
summary: connection.summary,
});
}
});
return [...deduped.values()];
}
export function getCustomWorldSceneRelativePositionLabel(
value: CustomWorldSceneRelativePosition,
) {
return RELATIVE_POSITION_LABELS[value] ?? value;
}
export function normalizeCustomWorldSceneRelativePosition(
value: unknown,
): CustomWorldSceneRelativePosition {
const normalizedValue =
typeof value === 'string' ? normalizeKey(value) : '';
for (const option of CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS) {
if (option.value === normalizedValue) {
return option.value;
}
if (RELATIVE_POSITION_ALIASES[option.value].includes(normalizedValue)) {
return option.value;
}
}
return 'forward';
}
export function invertCustomWorldSceneRelativePosition(
value: CustomWorldSceneRelativePosition,
): CustomWorldSceneRelativePosition {
switch (value) {
case 'forward':
return 'back';
case 'back':
return 'forward';
case 'left':
return 'right';
case 'right':
return 'left';
case 'north':
return 'south';
case 'south':
return 'north';
case 'east':
return 'west';
case 'west':
return 'east';
case 'up':
return 'down';
case 'down':
return 'up';
case 'inside':
return 'outside';
case 'outside':
return 'inside';
default:
return 'portal';
}
}
function buildFallbackSceneNpcIds(
storyNpcs: CustomWorldNpc[],
currentNpcIds: string[],
landmarkIndex: number,
) {
const targetCount = Math.min(3, storyNpcs.length);
if (targetCount <= currentNpcIds.length) {
return currentNpcIds.slice(0, targetCount);
}
const resolved = [...currentNpcIds];
for (
let offset = 0;
offset < storyNpcs.length && resolved.length < targetCount;
offset += 1
) {
const nextNpc = storyNpcs[(landmarkIndex + offset) % storyNpcs.length];
if (!nextNpc || resolved.includes(nextNpc.id)) {
continue;
}
resolved.push(nextNpc.id);
}
return resolved;
}
function resolveSceneNpcIdsForLandmark(
landmark: CustomWorldLandmarkDraft,
storyNpcs: CustomWorldNpc[],
lookup: Map<string, string>,
landmarkIndex: number,
) {
const references = compactUnique([
...(landmark.sceneNpcIds ?? []),
...(landmark.sceneNpcNames ?? []),
]);
const resolvedIds = compactUnique(
references
.map((reference) => lookup.get(normalizeKey(reference)) ?? '')
.filter(Boolean),
);
return buildFallbackSceneNpcIds(storyNpcs, resolvedIds, landmarkIndex);
}
function resolveConnectionsForLandmark(
landmark: CustomWorldLandmarkDraft,
landmarkLookup: Map<string, string>,
) {
return (landmark.connections ?? [])
.map((connection) => {
const targetReference =
connection.targetLandmarkId ?? connection.targetLandmarkName ?? '';
const targetLandmarkId =
landmarkLookup.get(normalizeKey(targetReference)) ?? '';
if (!targetLandmarkId || targetLandmarkId === landmark.id) {
return null;
}
return {
targetLandmarkId,
relativePosition: normalizeCustomWorldSceneRelativePosition(
connection.relativePosition,
),
summary: typeof connection.summary === 'string'
? connection.summary.trim()
: '',
} satisfies CustomWorldSceneConnection;
})
.filter((connection): connection is CustomWorldSceneConnection =>
Boolean(connection),
);
}
function ensureReverseConnections(landmarks: CustomWorldLandmark[]) {
const connectionMap = new Map(
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
);
const nameMap = new Map(landmarks.map((landmark) => [landmark.id, landmark.name]));
landmarks.forEach((landmark) => {
landmark.connections.forEach((connection) => {
const reverseConnections = connectionMap.get(connection.targetLandmarkId);
if (!reverseConnections) {
return;
}
const hasReverseConnection = reverseConnections.some(
(item) => item.targetLandmarkId === landmark.id,
);
if (hasReverseConnection) {
return;
}
reverseConnections.push({
targetLandmarkId: landmark.id,
relativePosition: invertCustomWorldSceneRelativePosition(
connection.relativePosition,
),
summary: nameMap.get(landmark.id)
? `可通往${nameMap.get(landmark.id)}`
: '',
});
});
});
return landmarks.map((landmark) => ({
...landmark,
connections: sortConnections(
dedupeConnections(connectionMap.get(landmark.id) ?? []),
),
}));
}
function ensureFallbackLandmarkConnections(landmarks: CustomWorldLandmark[]) {
if (landmarks.length <= 1) {
return landmarks;
}
const connectionMap = new Map(
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
);
landmarks.forEach((landmark, index) => {
const nextLandmark = landmarks[(index + 1) % landmarks.length];
if (!nextLandmark || nextLandmark.id === landmark.id) {
return;
}
const existingConnections = connectionMap.get(landmark.id) ?? [];
if (
existingConnections.some(
(connection) => connection.targetLandmarkId === nextLandmark.id,
)
) {
return;
}
existingConnections.push({
targetLandmarkId: nextLandmark.id,
relativePosition: 'forward',
summary: `沿主路可继续前往${nextLandmark.name}`,
});
connectionMap.set(landmark.id, existingConnections);
});
return landmarks.map((landmark) => ({
...landmark,
connections: sortConnections(connectionMap.get(landmark.id) ?? []),
}));
}
export function normalizeCustomWorldLandmarks(params: {
landmarks: CustomWorldLandmarkDraft[];
storyNpcs: CustomWorldNpc[];
}) {
const { landmarks, storyNpcs } = params;
const npcLookup = buildSceneNpcLookup(storyNpcs);
const landmarkLookup = buildLandmarkLookup(landmarks);
const resolvedLandmarks = landmarks.map((landmark, index) => ({
id: landmark.id,
name: landmark.name,
description: landmark.description,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
sceneNpcIds: resolveSceneNpcIdsForLandmark(
landmark,
storyNpcs,
npcLookup,
index,
),
connections: sortConnections(
resolveConnectionsForLandmark(landmark, landmarkLookup),
),
}));
return ensureReverseConnections(
ensureFallbackLandmarkConnections(resolvedLandmarks),
);
}
export function syncCustomWorldLandmarkConnections(
landmarks: CustomWorldLandmark[],
) {
return normalizeCustomWorldLandmarks({
landmarks: landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds,
connections: landmark.connections.map((connection) => ({
targetLandmarkId: connection.targetLandmarkId,
relativePosition: connection.relativePosition,
summary: connection.summary,
})),
})),
storyNpcs: [],
}).map((landmark, index) => ({
...landmark,
sceneNpcIds: landmarks[index]?.sceneNpcIds ?? [],
}));
}

View File

@@ -1,7 +1,7 @@
import { Character, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
import { normalizeBuildRole, normalizeBuildTags } from './buildTags';
import type { CharacterEquipmentItem } from './characterPresets';
import { getCharacterEquipment, getCharacterMaxMana } from './characterPresets';
import { getCharacterEquipment, getCharacterMaxHp, getCharacterMaxMana } from './characterPresets';
export type EquipmentBonuses = {
maxHpBonus: number;
@@ -285,9 +285,14 @@ export function applyEquipmentLoadoutToState(
state: GameState,
nextEquipment: EquipmentLoadout,
): GameState {
const previousBonuses = getEquipmentBonuses(state.playerEquipment ?? createEmptyEquipmentLoadout());
const nextBonuses = getEquipmentBonuses(nextEquipment);
const baseMaxHp = Math.max(1, state.playerMaxHp - previousBonuses.maxHpBonus);
const baseMaxHp = state.playerCharacter
? getCharacterMaxHp(
state.playerCharacter,
state.worldType,
state.customWorldProfile,
)
: Math.max(1, state.playerMaxHp);
const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus;
const nextMaxMana = state.playerCharacter ? getCharacterMaxMana(state.playerCharacter) : state.playerMaxMana;

View File

@@ -8,6 +8,13 @@ import type { FunctionDocumentationEntry } from '../types';
* 向眼前 NPC 送礼的入口 function。
* 这里直接提供 gift modal 的默认构造逻辑。
*/
export function buildNpcGiftModalIntroText(encounter: Encounter) {
return [
'你:我想送你一样东西。',
`${encounter.npcName}:先让我看看你带了什么,我再决定该怎么收下。`,
].join('\n');
}
export function buildNpcGiftModalState(
state: GameState,
encounter: Encounter,
@@ -17,6 +24,7 @@ export function buildNpcGiftModalState(
return {
encounter,
actionText,
introText: buildNpcGiftModalIntroText(encounter),
selectedItemId,
};
}

View File

@@ -8,6 +8,13 @@ import type { FunctionDocumentationEntry } from '../types';
* 邀请眼前 NPC 加入队伍的 function。
* 这里直接收口了“队伍已满时弹窗,否则立即进入招募序列”的分流逻辑。
*/
export function buildNpcRecruitModalIntroText(encounter: Encounter) {
return [
'你:我想认真谈谈同行的事。',
`${encounter.npcName}:先把你队伍里的位置理顺,再给我一个明确答复。`,
].join('\n');
}
export function buildNpcRecruitModalState(
state: GameState,
encounter: Encounter,
@@ -16,6 +23,7 @@ export function buildNpcRecruitModalState(
return {
encounter,
actionText,
introText: buildNpcRecruitModalIntroText(encounter),
selectedReleaseNpcId: state.companions[0]?.npcId ?? null,
};
}

View File

@@ -8,6 +8,13 @@ import type { FunctionDocumentationEntry } from '../types';
* 与眼前 NPC 发起交易的入口 function。
* 这里直接提供 trade modal 的默认构造逻辑,避免窗口初始化散落在别处。
*/
export function buildNpcTradeModalIntroText(encounter: Encounter) {
return [
'你:我想先看看你手里有什么能换。',
`${encounter.npcName}:先看货吧,买卖和回收的价都写得清楚。`,
].join('\n');
}
export function buildNpcTradeModalState(
state: GameState,
encounter: Encounter,
@@ -17,6 +24,7 @@ export function buildNpcTradeModalState(
return {
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId: npcInventory[0]?.id ?? null,
selectedPlayerItemId: state.playerInventory[0]?.id ?? null,

View File

@@ -10,6 +10,7 @@ import {
StoryOption,
WorldType,
} from '../types';
import { resolveRoleCombatStats } from './attributeCombat';
import { resolveRuleWorldType } from './customWorldRuntime';
import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets';
@@ -193,6 +194,10 @@ export function createSceneHostileNpc(
): SceneHostileNpc | null {
const preset = getHostileNpcPresetById(worldType, monsterId);
if (!preset) return null;
const combatStats = resolveRoleCombatStats(preset.attributeProfile, {
baseSpeed: preset.baseStats.speed,
});
const maxHp = preset.baseStats.maxHp + combatStats.maxHpBonus;
const formationSlots = getHostileNpcFormationSlots(
worldType,
@@ -213,9 +218,9 @@ export function createSceneHostileNpc(
yOffset: position.yOffset,
facing: getFacingTowardPlayer(position.xMeters, playerX),
attackRange: preset.baseStats.attackRange,
speed: preset.baseStats.speed,
hp: preset.baseStats.hp,
maxHp: preset.baseStats.maxHp,
speed: combatStats.turnSpeed,
hp: maxHp,
maxHp,
renderKind: 'npc',
combatTags: preset.combatTags,
attributeProfile: preset.attributeProfile,

View File

@@ -19,6 +19,7 @@ import {
StoryOption,
WorldType,
} from '../types';
import { resolveRoleCombatStats } from './attributeCombat';
import {
buildRelationState,
resolveAttributeSchema,
@@ -26,6 +27,7 @@ import {
} from './attributeResolver';
import {
getCharacterById,
getCharacterCombatStats,
getCharacterEquipment,
getCharacterMaxHp,
getInventoryItems,
@@ -1572,29 +1574,50 @@ export function checkTradeItem(
};
}
export function getNpcSparMaxHp(character: Character | null) {
export function getNpcSparMaxHp(
character: Character | null,
worldType: WorldType | null = null,
customWorldProfile: GameState['customWorldProfile'] = getRuntimeCustomWorldProfile(),
) {
if (!character) return 8;
const values = character.attributeProfile?.values ?? {};
const sparScore =
((values.axis_a ?? 0) + (values.axis_b ?? 0) + (values.axis_f ?? 0)) / 10;
return Math.max(7, Math.min(12, Math.round(sparScore / 3)));
const sparStats = getCharacterCombatStats(
character,
worldType,
customWorldProfile,
);
return Math.max(7, Math.min(12, Math.round(sparStats.maxHpBonus / 4)));
}
export function createNpcBattleMonster(
encounter: Encounter,
npcState: NpcPersistentState,
mode: NpcBattleMode = 'fight',
options: {
worldType?: WorldType | null;
customWorldProfile?: GameState['customWorldProfile'];
} = {},
) {
const monsterPreset = getMonsterPresetForEncounter(encounter);
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
const resolvedWorldType = options.worldType ?? null;
const resolvedCustomWorldProfile =
options.customWorldProfile ?? getRuntimeCustomWorldProfile();
if (monsterPreset) {
const monsterCombatStats = resolveRoleCombatStats(
monsterPreset.attributeProfile,
{
baseSpeed: monsterPreset.baseStats.speed,
},
);
const resolvedMonsterMaxHp =
monsterPreset.baseStats.maxHp + monsterCombatStats.maxHpBonus;
const hostileMaxHp =
mode === 'spar'
? Math.max(
8,
Math.min(14, Math.round(monsterPreset.baseStats.maxHp / 18)),
Math.min(14, Math.round(resolvedMonsterMaxHp / 18)),
)
: monsterPreset.baseStats.maxHp;
: resolvedMonsterMaxHp;
return {
id: monsterPreset.id,
@@ -1609,7 +1632,7 @@ export function createNpcBattleMonster(
yOffset: 0,
facing: 'left' as const,
attackRange: monsterPreset.baseStats.attackRange,
speed: monsterPreset.baseStats.speed,
speed: monsterCombatStats.turnSpeed,
hp: hostileMaxHp,
maxHp: hostileMaxHp,
renderKind: 'npc' as const,
@@ -1624,18 +1647,33 @@ export function createNpcBattleMonster(
} satisfies SceneMonster;
}
const baseHp = recruitCharacter ? getCharacterMaxHp(recruitCharacter) : 120;
const recruitCombatStats = recruitCharacter
? getCharacterCombatStats(
recruitCharacter,
resolvedWorldType,
resolvedCustomWorldProfile,
)
: null;
const baseHp = recruitCharacter
? getCharacterMaxHp(
recruitCharacter,
resolvedWorldType,
resolvedCustomWorldProfile,
)
: 120;
const baseSpeed = recruitCharacter
? Math.max(
6,
Math.round(
(recruitCharacter.attributeProfile?.values.axis_b ?? 48) / 12 + 1,
),
5,
Math.round((recruitCombatStats?.turnSpeed ?? 4.5) + 1.5),
)
: 7;
const maxHp =
mode === 'spar'
? getNpcSparMaxHp(recruitCharacter)
? getNpcSparMaxHp(
recruitCharacter,
resolvedWorldType,
resolvedCustomWorldProfile,
)
: Math.max(baseHp, 80 + npcState.affinity);
if (mode === 'spar') {
@@ -1930,16 +1968,17 @@ export function buildNpcChatResultText(
}
export function buildNpcSparResultText(
npcName: string,
affinityGain: number,
nextAffinity: number,
) {
const sparEncounter = {
npcName: '对方',
npcName,
npcDescription: '',
npcAvatar: '',
context: '',
} satisfies Encounter;
return `点到为止地切磋了一场,彼此都更认可对方的身手。${describeAffinityShift(affinityGain)}${describeNpcAffinityInWords(sparEncounter, nextAffinity)}`;
return `${npcName}点到为止地切磋了一场,彼此都更认可对方的身手。${describeAffinityShift(affinityGain)}${describeNpcAffinityInWords(sparEncounter, nextAffinity)}`;
}
export function buildNpcGiftResultText(
@@ -2019,6 +2058,22 @@ export function buildNpcTradeTransactionActionText({
return `${encounter.npcName}手里买下${quantityText}`;
}
export function buildNpcHelpCommitActionText(
encounter: Encounter,
reward: NpcHelpReward,
) {
const goals: string[] = [];
if ((reward.hp ?? 0) > 0) goals.push('疗伤');
if ((reward.mana ?? 0) > 0) goals.push('回气');
if ((reward.cooldownBonus ?? 0) > 0) goals.push('调整招式节奏');
if (reward.items.length > 0) goals.push('补给');
return goals.length > 0
? `${encounter.npcName}请求${goals.join('、')}`
: `${encounter.npcName}寻求支援`;
}
export function buildNpcHelpResultText(
encounter: Encounter,
reward: NpcHelpReward,

View File

@@ -137,17 +137,23 @@ function buildQuestReward(params: {
rewardTheme,
narrativeType,
});
const runtimeScene = scene
? {
...scene,
description: scene.description ?? '',
}
: null;
const runtimeContext = context
? buildQuestRuntimeItemGenerationContext({
context,
issuerNpcId,
issuerNpcName,
roleText,
scene,
scene: runtimeScene,
})
: buildLooseRuntimeItemGenerationContext({
worldType,
scene,
scene: runtimeScene,
encounter: {
id: issuerNpcId,
kind: 'npc',

View File

@@ -42,7 +42,12 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
return {
...state,
sceneMonsters: [createNpcBattleMonster(encounter, npcState, 'fight')],
sceneMonsters: [
createNpcBattleMonster(encounter, npcState, 'fight', {
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
}),
],
currentEncounter: null,
npcInteractionActive: false,
playerX: 0,

View File

@@ -1,6 +1,12 @@
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
import { CustomWorldProfile, Encounter, SceneNpc, WorldType } from '../types';
import {
CustomWorldProfile,
Encounter,
SceneConnectionInfo,
SceneNpc,
WorldType,
} from '../types';
import { buildRoleAttributeProfileFromLegacyData } from './attributeProfileGenerator';
import { resolveAttributeSchema } from './attributeResolver';
import {
@@ -24,6 +30,7 @@ export interface ScenePreset {
worldType: WorldType;
forwardSceneId?: string;
connectedSceneIds: string[];
connections: SceneConnectionInfo[];
monsterIds: string[];
npcs: SceneNpc[];
treasureHints: string[];
@@ -121,6 +128,83 @@ function collectAllImagePool() {
return refs;
}
function uniqueStrings(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
function buildDefaultSceneConnections(
connectedSceneIds: string[],
forwardSceneId?: string,
): SceneConnectionInfo[] {
const uniqueSceneIds = uniqueStrings(connectedSceneIds);
const branchPositions: Array<SceneConnectionInfo['relativePosition']> = [
'left',
'right',
'back',
'portal',
];
const resolvedForwardSceneId =
forwardSceneId && uniqueSceneIds.includes(forwardSceneId)
? forwardSceneId
: uniqueSceneIds[0];
const branchSceneIds = uniqueSceneIds.filter(
(sceneId) => sceneId !== resolvedForwardSceneId,
);
const connections: SceneConnectionInfo[] = [];
if (resolvedForwardSceneId) {
connections.push({
sceneId: resolvedForwardSceneId,
relativePosition: 'forward',
summary: '沿主路继续深入前方区域',
});
}
branchSceneIds.forEach((sceneId, index) => {
connections.push({
sceneId,
relativePosition: branchPositions[index] ?? 'portal',
summary:
index === 0
? '这里分出一条支路'
: index === 1
? '这里还能转向另一条路'
: '这里还有额外通路',
});
});
return connections;
}
function pickForwardSceneIdFromConnections(connections: SceneConnectionInfo[]) {
const preferredOrder: Array<SceneConnectionInfo['relativePosition']> = [
'forward',
'north',
'east',
'right',
'up',
'outside',
'portal',
'left',
'west',
'south',
'down',
'inside',
'back',
];
for (const relativePosition of preferredOrder) {
const matchedConnection = connections.find(
(connection) => connection.relativePosition === relativePosition,
);
if (matchedConnection?.sceneId) {
return matchedConnection.sceneId;
}
}
return connections[0]?.sceneId;
}
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -225,7 +309,23 @@ function buildCustomSceneNpc(
name: npc.name,
role: npc.role,
avatar: npc.name.slice(0, 1) || '?',
description: `${npc.description} 动机:${npc.motivation}`,
description: [
npc.description,
npc.backstoryReveal.publicSummary
? `公开背景:${npc.backstoryReveal.publicSummary}`
: '',
npc.motivation ? `动机:${npc.motivation}` : '',
npc.skills.length > 0
? `技能:${npc.skills.map((skill) => skill.name).join('、')}`
: '',
npc.initialItems.length > 0
? `随身物:${npc.initialItems
.map((item) => `${item.name}x${item.quantity}`)
.join('、')}`
: '',
]
.filter(Boolean)
.join(' '),
gender: inferCustomNpcGender(npc.id, npc.name),
monsterPresetId: monsterPreset?.id,
hostileNpcPresetId: monsterPreset?.id,
@@ -254,6 +354,18 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
const campSceneId = buildCustomSceneId('camp');
const landmarkSceneIds = profile.landmarks.map((_, index) => buildCustomSceneId('landmark', index));
const landmarkSceneIdByLandmarkId = new Map(
profile.landmarks.map((landmark, index) => [
landmark.id,
buildCustomSceneId('landmark', index),
]),
);
const landmarkById = new Map(
profile.landmarks.map((landmark) => [landmark.id, landmark]),
);
const customStoryNpcById = new Map(
profile.storyNpcs.map((npc) => [npc.id, npc]),
);
const campNpcs = playableCharacters.slice(1).map(character => {
const npc = buildCharacterNpc(character.id, WorldType.CUSTOM, profile);
return npc
@@ -265,10 +377,15 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
: null;
}).filter(Boolean) as SceneNpc[];
const customStoryNpcs = profile.storyNpcs.map(npc =>
buildCustomSceneNpc(npc, profile, anchorWorldType),
);
const chunkSize = Math.max(4, Math.ceil(customStoryNpcs.length / Math.max(1, profile.landmarks.length)));
const campConnections = profile.landmarks
.slice(0, 3)
.map((landmark, index) => ({
sceneId: landmarkSceneIds[index] ?? '',
relativePosition:
index === 0 ? 'forward' : index === 1 ? 'left' : 'right',
summary: `从营地可直接通往${landmark.name}`,
}))
.filter((connection) => connection.sceneId) as SceneConnectionInfo[];
const customScenes: ScenePreset[] = [
{
id: campSceneId,
@@ -276,8 +393,9 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
description: `你在${profile.name}的临时营地整备行装。${profile.summary}`,
worldType: WorldType.CUSTOM,
imageSrc: profile.landmarks[0]?.imageSrc ?? allImages[imageOffset] ?? '',
connectedSceneIds: landmarkSceneIds.slice(0, 3),
forwardSceneId: landmarkSceneIds[0],
connectedSceneIds: campConnections.map((connection) => connection.sceneId),
connections: campConnections,
forwardSceneId: pickForwardSceneIdFromConnections(campConnections),
monsterIds: [],
treasureHints: [
`${profile.name}地图残页`,
@@ -286,14 +404,57 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
npcs: campNpcs,
},
...profile.landmarks.map((landmark, index): ScenePreset => {
const sceneNpcs = customStoryNpcs.slice(index * chunkSize, (index + 1) * chunkSize);
const connectedSceneIds: string[] = [
campSceneId,
landmarkSceneIds[(index - 1 + landmarkSceneIds.length) % landmarkSceneIds.length],
landmarkSceneIds[(index + 1) % landmarkSceneIds.length],
]
.filter((sceneId): sceneId is string => Boolean(sceneId))
.filter((sceneId, sceneIndex, array) => array.indexOf(sceneId) === sceneIndex);
const sceneNpcs = landmark.sceneNpcIds
.map((npcId) => customStoryNpcById.get(npcId))
.filter(Boolean)
.map((npc) =>
buildCustomSceneNpc(npc!, profile, anchorWorldType),
);
if (sceneNpcs.length < 3) {
profile.storyNpcs
.filter(
(npc) => !sceneNpcs.some((sceneNpc) => sceneNpc.id === npc.id),
)
.slice(0, 3 - sceneNpcs.length)
.forEach((npc) =>
sceneNpcs.push(buildCustomSceneNpc(npc, profile, anchorWorldType)),
);
}
const landmarkConnections = landmark.connections
.map((connection) => {
const targetSceneId = landmarkSceneIdByLandmarkId.get(
connection.targetLandmarkId,
);
const targetLandmark = landmarkById.get(connection.targetLandmarkId);
if (!targetSceneId || !targetLandmark) {
return null;
}
return {
sceneId: targetSceneId,
relativePosition: connection.relativePosition,
summary:
connection.summary || `可通往${targetLandmark.name}`,
} satisfies SceneConnectionInfo;
})
.filter((connection): connection is SceneConnectionInfo =>
Boolean(connection),
);
const shouldLinkCamp = index < 3;
const extraCampConnection = shouldLinkCamp
? ({
sceneId: campSceneId,
relativePosition: 'back',
summary: '可回到临时营地整备',
} satisfies SceneConnectionInfo)
: null;
const connections = [
...landmarkConnections,
...(extraCampConnection ? [extraCampConnection] : []),
];
const connectedSceneIds = uniqueStrings(
connections.map((connection) => connection.sceneId),
);
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
const monsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
const hostileNpcs = monsterIds
@@ -307,7 +468,8 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
worldType: WorldType.CUSTOM,
imageSrc: landmark.imageSrc ?? allImages[(imageOffset + index + 1) % Math.max(1, allImages.length)] ?? '',
connectedSceneIds,
forwardSceneId: connectedSceneIds.find(sceneId => sceneId !== campSceneId) ?? campSceneId,
connections,
forwardSceneId: pickForwardSceneIdFromConnections(connections),
monsterIds,
treasureHints: [
`${landmark.name}的旧线索`,
@@ -745,6 +907,10 @@ function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[]
...template,
...sceneOverride,
imageSrc: sceneOverride.imageSrc ?? imagePool[index] ?? imagePool[0] ?? '',
connections: buildDefaultSceneConnections(
sceneOverride.connectedSceneIds ?? template.connectedSceneIds,
sceneOverride.forwardSceneId ?? template.forwardSceneId,
),
npcs: mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType),
} satisfies ScenePreset;
});

59
src/data/storyRecovery.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { CompanionState, GameState } from '../types';
import {
getCharacterById,
getCharacterCombatStats,
getCharacterMaxHp,
} from './characterPresets';
function recoverCompanion(
companion: CompanionState,
state: Pick<GameState, 'worldType' | 'customWorldProfile'>,
) {
if (companion.hp <= 0) {
return companion;
}
const character = getCharacterById(companion.characterId);
if (!character) {
return companion;
}
const recovery = getCharacterCombatStats(
character,
state.worldType,
state.customWorldProfile,
).storyRecovery;
const maxHp = Math.max(
companion.maxHp,
getCharacterMaxHp(character, state.worldType, state.customWorldProfile),
);
return {
...companion,
maxHp,
hp: Math.min(maxHp, companion.hp + recovery),
};
}
export function applyStoryReasoningRecovery(state: GameState) {
if (!state.playerCharacter) {
return state;
}
const playerRecovery = state.playerHp > 0
? getCharacterCombatStats(
state.playerCharacter,
state.worldType,
state.customWorldProfile,
).storyRecovery
: 0;
return {
...state,
playerHp: state.playerHp > 0
? Math.min(state.playerMaxHp, state.playerHp + playerRecovery)
: state.playerHp,
companions: state.companions.map(companion => recoverCompanion(companion, state)),
roster: state.roster.map(companion => recoverCompanion(companion, state)),
};
}