329 lines
16 KiB
TypeScript
329 lines
16 KiB
TypeScript
import { skillTargetBelongsTo } from './skill-condition.ts';
|
|
import type { CooldownReduction } from './types.ts';
|
|
import type { UnitStat } from './unit-power.ts';
|
|
|
|
type BoardEffect = { id?: string; description?: string; type: string; value: number | null; targetId?: string | null; trigger?: string | null };
|
|
type BoardNode = { characterIds: string[]; effect: BoardEffect | null };
|
|
type BoardPosition = { groupId: string; x: number; y: number };
|
|
type BoardMember = { id: string; positionGroupId: string };
|
|
export type BoardSummarySource = {
|
|
nodeGroups: Record<string, readonly BoardNode[]>;
|
|
models?: Record<string, readonly BoardPosition[]>;
|
|
members?: readonly BoardMember[];
|
|
};
|
|
export type BoardEffectCard = { id: string; characterId: string; type: string; generation: string };
|
|
export type BoardEffectSong = { singerType?: string | null; singerCharacterIds?: readonly string[] };
|
|
export type BoardConnectionCard = {
|
|
id: string;
|
|
awakening: number;
|
|
connect: readonly {
|
|
level: number;
|
|
effectPermilUp: number;
|
|
extent: readonly { x: number; y: number }[];
|
|
}[];
|
|
};
|
|
export type BoardConnections = {
|
|
placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
cards: readonly BoardConnectionCard[];
|
|
};
|
|
export type BoardEffectOverrides = {
|
|
generationBonusByCardId?: Readonly<Record<string, number>>;
|
|
directSongScoreBonusPercent?: number;
|
|
};
|
|
export type BoardEffectsResult = {
|
|
flatByCardId: Record<string, Record<UnitStat, number>>;
|
|
rateByCardId: Record<string, Record<UnitStat, number>>;
|
|
activationRateUpByCardId: Record<string, number>;
|
|
allowedCooldownReductionsByCardId: Record<string, readonly CooldownReduction[]>;
|
|
scoreSupportPercent: number;
|
|
directSongScoreBonusPercent: number;
|
|
};
|
|
|
|
const stats: readonly UnitStat[] = ['performance', 'technique', 'sense'];
|
|
const generationBonusCap = 900;
|
|
const emptyStats = (): Record<UnitStat, number> => ({ performance: 0, technique: 0, sense: 0 });
|
|
const groupingLabels: Record<string, string> = {
|
|
'grp-gen_0': '0기생', 'grp-gen_1': '1기생', 'grp-gen_2': '2기생', 'grp-gen_3': '3기생',
|
|
'grp-gen_4': '4기생', 'grp-gen_5': '5기생', 'grp-gamers': 'GAMERS', 'grp-holox': 'holoX',
|
|
'grp-myth': 'Myth', 'grp-promise': 'Promise', 'grp-advent': 'Advent', 'grp-regloss': 'ReGLOSS',
|
|
'grp-indonesia-gen_1': 'ID 1기생', 'grp-indonesia-gen_2': 'ID 2기생', 'grp-indonesia-gen_3': 'ID 3기생'
|
|
};
|
|
|
|
function nodeForMember(summary: BoardSummarySource, groupId: string, memberId: string): BoardNode | null {
|
|
const nodes = summary.nodeGroups[groupId] ?? [];
|
|
return nodes.find((node) => node.characterIds.includes(memberId))
|
|
?? nodes.find((node) => node.characterIds.length === 0)
|
|
?? null;
|
|
}
|
|
|
|
function targets(effect: BoardEffect, ownerId: string, cards: readonly BoardEffectCard[]): BoardEffectCard[] {
|
|
if (effect.targetId === 'skill_tree_effect_target-skill_tree_character') return cards.filter((card) => card.characterId === ownerId);
|
|
const grouping = effect.targetId?.replace('skill_tree_effect_target-character_grouping-', '');
|
|
if (grouping && groupingLabels[grouping]) return cards.filter((card) => skillTargetBelongsTo(card, groupingLabels[grouping]));
|
|
return [...cards];
|
|
}
|
|
|
|
function affectedStats(type: string): readonly UnitStat[] {
|
|
if (type.startsWith('all_parameter_up')) return stats;
|
|
if (type.startsWith('performance_up')) return ['performance'];
|
|
if (type.startsWith('technique_up')) return ['technique'];
|
|
if (type.startsWith('sense_up')) return ['sense'];
|
|
return [];
|
|
}
|
|
|
|
export function songBonusApplies(effect: BoardEffect, ownerId: string, song?: BoardEffectSong | null): boolean {
|
|
if (!effect.id) return false;
|
|
const expectedType = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
|
|
if (expectedType === 'all') return true;
|
|
if (!song) return false;
|
|
const singers = song.singerCharacterIds ?? [];
|
|
// FUWAMOCO-only songs count as solo songs for both sisters.
|
|
const isFuwamoco = singers.length === 2 && singers.includes('chr-04016') && singers.includes('chr-04017');
|
|
const singerType = isFuwamoco ? 'solo' : song.singerType;
|
|
if (expectedType !== singerType) return false;
|
|
if (effect.id.endsWith('-fuwamoko')) {
|
|
return isFuwamoco && singers.includes(ownerId);
|
|
}
|
|
return singers.includes(ownerId);
|
|
}
|
|
|
|
function singerConditionApplies(effect: BoardEffect, ownerId: string, song?: BoardEffectSong | null): boolean {
|
|
if (!effect.id?.includes('passive_trigger-music_skill_tree_character')) return true;
|
|
return Boolean(song?.singerCharacterIds?.includes(ownerId));
|
|
}
|
|
|
|
function connectionMultipliers(
|
|
summary: BoardSummarySource,
|
|
memberBoards: Readonly<Record<string, readonly string[]>>,
|
|
connections?: BoardConnections | null
|
|
): Record<string, Record<string, number>> {
|
|
if (!connections || !summary.models || !summary.members) return {};
|
|
const cards = new Map(connections.cards.map((card) => [card.id, card]));
|
|
const usedCards = new Set<string>();
|
|
const result: Record<string, Record<string, number>> = {};
|
|
|
|
for (const member of summary.members) {
|
|
const positions: readonly BoardPosition[] = summary.models[member.positionGroupId] ?? [];
|
|
const positionById = new Map<string, BoardPosition>(positions.map((position) => [position.groupId, position]));
|
|
const groupIdByCoordinate = new Map<string, string>(positions.map((position) => [`${position.x},${position.y}`, position.groupId]));
|
|
const active = new Set(memberBoards[member.id] ?? []);
|
|
const center = positions.find((position) => position.x === 0 && position.y === 0)?.groupId;
|
|
if (center) active.add(center);
|
|
|
|
for (const [socketId, cardId] of Object.entries(connections.placements[member.id] ?? {})) {
|
|
if (usedCards.has(cardId) || !active.has(socketId)) continue;
|
|
const socket = positionById.get(socketId);
|
|
const card = cards.get(cardId);
|
|
if (!socket || !card) continue;
|
|
const level = card.awakening >= 5 ? 2 : 1;
|
|
const effect = card.connect.find((entry) => entry.level === level) ?? card.connect.at(-1);
|
|
if (!effect) continue;
|
|
usedCards.add(cardId);
|
|
const memberResult = result[member.id] ??= {};
|
|
for (const offset of effect.extent) {
|
|
const groupId = groupIdByCoordinate.get(`${socket.x + offset.x},${socket.y + offset.y}`);
|
|
if (groupId) memberResult[groupId] = 1 + effect.effectPermilUp / 1000;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export type MemberBoardEffectSummary = {
|
|
received: {
|
|
flat: Record<UnitStat, number>;
|
|
flatByArea: Record<'B' | 'G', Record<UnitStat, number>>;
|
|
rate: Record<UnitStat, number>;
|
|
activationRateUp: number;
|
|
} | null;
|
|
groups: {
|
|
area: string;
|
|
rows: { description: string; nodeCount: number; boosted: boolean }[];
|
|
}[];
|
|
};
|
|
|
|
export type HoloWorkBoardBonus = { lesson: number; cube: number; training: number };
|
|
const emptyWorkBonus = (): HoloWorkBoardBonus => ({ lesson: 0, cube: 0, training: 0 });
|
|
|
|
/** Percent bonuses provided when each board's owner participates in Holo-work. */
|
|
export function calculateHoloWorkBoardBonuses(
|
|
summary: BoardSummarySource,
|
|
memberBoards: Readonly<Record<string, readonly string[]>>,
|
|
connections?: BoardConnections | null
|
|
): Record<string, HoloWorkBoardBonus> {
|
|
const multipliers = connectionMultipliers(summary, memberBoards, connections);
|
|
return Object.fromEntries(Object.entries(memberBoards).map(([memberId, nodes]) => {
|
|
const bonus = emptyWorkBonus();
|
|
for (const groupId of new Set(nodes)) {
|
|
const effect = nodeForMember(summary, groupId, memberId)?.effect;
|
|
if (effect?.trigger !== 'work' || effect.type !== 'work_reward_quantity_up_permil_up'
|
|
|| effect.targetId !== 'skill_tree_effect_target-skill_tree_character' || effect.value === null) continue;
|
|
// Stable resource suffixes from the official SkillTreeEffect IDs distinguish reward categories.
|
|
const category = effect.id?.endsWith('-work-item-item-card_level_up') ? 'lesson'
|
|
: effect.id?.endsWith('-work-item-item-skill_tree_card-1') ? 'cube'
|
|
: effect.id?.endsWith('-work-item-item-card_limit_break-attribute_1-1') ? 'training' : null;
|
|
if (category) bonus[category] += effect.value * (multipliers[memberId]?.[groupId] ?? 1) / 10;
|
|
}
|
|
return [memberId, bonus];
|
|
}));
|
|
}
|
|
|
|
export function sumHoloWorkBoardBonuses(bonuses: Readonly<Record<string, HoloWorkBoardBonus>>, memberIds: readonly string[]): HoloWorkBoardBonus {
|
|
const total = emptyWorkBonus();
|
|
for (const id of new Set(memberIds)) {
|
|
const bonus = bonuses[id];
|
|
if (!bonus) continue;
|
|
total.lesson += bonus.lesson;
|
|
total.cube += bonus.cube;
|
|
total.training += bonus.training;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/** Saved-board overview, without assuming a leader or a song. Conditional effects stay in the detail rows. */
|
|
export function summarizeMemberBoardEffects(
|
|
summary: BoardSummarySource,
|
|
memberBoards: Readonly<Record<string, readonly string[]>>,
|
|
member: { id: string; name: string },
|
|
card: BoardEffectCard | null,
|
|
connections?: BoardConnections | null
|
|
): MemberBoardEffectSummary {
|
|
const uniqueBoards = Object.fromEntries(Object.entries(memberBoards).map(([id, nodes]) => [id, [...new Set(nodes)]]));
|
|
const effects = card ? calculateBoardEffects(summary, uniqueBoards, [card], '', null, connections) : null;
|
|
const flatForArea = (area: 'B' | 'G'): Record<UnitStat, number> => {
|
|
if (!card) return emptyStats();
|
|
// Filter effects, not active nodes: connection sockets and duplicate-card handling must stay unchanged.
|
|
const areaSummary = { ...summary, nodeGroups: Object.fromEntries(
|
|
Object.entries(summary.nodeGroups).filter(([groupId]) => groupId.startsWith(area))
|
|
) };
|
|
return calculateBoardEffects(areaSummary, uniqueBoards, [card], '', null, connections).flatByCardId[card.id];
|
|
};
|
|
const multipliers = connectionMultipliers(summary, uniqueBoards, connections)[member.id] ?? {};
|
|
const active = new Set(memberBoards[member.id] ?? []);
|
|
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 3 });
|
|
const groups = ['B', 'G', 'R', 'Y'].map((area) => {
|
|
const rows = new Map<string, { template: string; value: number; nodeCount: number; boosted: boolean }>();
|
|
for (const groupId of Object.keys(summary.nodeGroups)) {
|
|
if (groupId[0] !== area || !active.has(groupId)) continue;
|
|
const effect = nodeForMember(summary, groupId, member.id)?.effect;
|
|
if (!effect?.description) continue;
|
|
const template = effect.description.replaceAll('[character]', member.name).replace(/\s+/g, ' ').trim();
|
|
const numeric = effect.value !== null && /\[value(?:\/10)?\]/.test(template);
|
|
// Keep targets, conditions and non-additive skill unlocks separate.
|
|
const key = JSON.stringify([effect.type, effect.targetId, effect.trigger, template, numeric ? null : effect.id ?? groupId]);
|
|
const multiplier = numeric ? multipliers[groupId] ?? 1 : 1;
|
|
const rawValue = (effect.value ?? 0) * multiplier;
|
|
const value = affectedStats(effect.type).length > 0 ? Math.ceil(rawValue) : rawValue;
|
|
const row = rows.get(key) ?? { template, value: 0, nodeCount: 0, boosted: false };
|
|
row.value += value;
|
|
row.nodeCount += 1;
|
|
row.boosted ||= multiplier > 1;
|
|
rows.set(key, row);
|
|
}
|
|
return {
|
|
area,
|
|
rows: [...rows.values()].map((row) => ({
|
|
description: row.template.replaceAll('[value/10]', format(row.value / 10)).replaceAll('[value]', format(row.value)),
|
|
nodeCount: row.nodeCount,
|
|
boosted: row.boosted
|
|
}))
|
|
};
|
|
});
|
|
return {
|
|
received: effects && card ? {
|
|
flat: effects.flatByCardId[card.id],
|
|
flatByArea: { B: flatForArea('B'), G: flatForArea('G') },
|
|
rate: effects.rateByCardId[card.id],
|
|
activationRateUp: effects.activationRateUpByCardId[card.id]
|
|
} : null,
|
|
groups
|
|
};
|
|
}
|
|
|
|
export function calculateBoardEffects(
|
|
summary: BoardSummarySource,
|
|
memberBoards: Readonly<Record<string, readonly string[]>>,
|
|
cards: readonly BoardEffectCard[],
|
|
leaderCharacterId: string,
|
|
song?: BoardEffectSong | null,
|
|
connections?: BoardConnections | null,
|
|
overrides?: BoardEffectOverrides | null
|
|
): BoardEffectsResult {
|
|
const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
|
|
const rateByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
|
|
const activationRateUpByCardId = Object.fromEntries(cards.map((card) => [card.id, 0]));
|
|
const unitCharacterIds = new Set(cards.map((card) => card.characterId));
|
|
let scoreSupportPercent = 0;
|
|
let directSongScoreBonusPercent = 0;
|
|
const multipliers = connectionMultipliers(summary, memberBoards, connections);
|
|
const generationBonusByCardId: Record<string, number> = {};
|
|
|
|
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
|
|
for (const groupId of activeGroupIds) {
|
|
const area = groupId[0];
|
|
if (area === 'B' && !unitCharacterIds.has(ownerId)) continue;
|
|
if (area === 'R' && ownerId !== leaderCharacterId) continue;
|
|
if (area !== 'B' && area !== 'R' && area !== 'G' && area !== 'Y') continue;
|
|
const effect = nodeForMember(summary, groupId, ownerId)?.effect;
|
|
if (!effect || effect.trigger === 'work' || effect.value === null) continue;
|
|
if (!singerConditionApplies(effect, ownerId, song)) continue;
|
|
const rawValue = Number(effect.value) * (multipliers[ownerId]?.[groupId] ?? 1);
|
|
const value = affectedStats(effect.type).length > 0 ? Math.ceil(rawValue) : rawValue;
|
|
|
|
if (effect.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') {
|
|
if (songBonusApplies(effect, ownerId, song)) directSongScoreBonusPercent += value / 10;
|
|
continue;
|
|
}
|
|
|
|
if (effect.type === 'live_active_skill_cool_time_shorten_permil_up') {
|
|
continue;
|
|
}
|
|
if (effect.type === 'live_active_skill_activation_probability_up_permil_up') {
|
|
for (const card of targets(effect, ownerId, cards)) activationRateUpByCardId[card.id] += value / 10;
|
|
continue;
|
|
}
|
|
if (effect.type === 'live_active_skill_effect_up_permil_up') {
|
|
scoreSupportPercent += value / 10;
|
|
continue;
|
|
}
|
|
|
|
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
|
|
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
|
|
const effectTargets = targets(effect, ownerId, cards);
|
|
if (effect.type === 'all_parameter_up_for_character_grouping') {
|
|
for (const card of effectTargets) {
|
|
if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue;
|
|
const currentBonus = generationBonusByCardId[card.id] ?? 0;
|
|
const appliedValue = Math.max(0, Math.min(normalizedValue, generationBonusCap - currentBonus));
|
|
generationBonusByCardId[card.id] = currentBonus + appliedValue;
|
|
for (const stat of stats) destination[card.id][stat] += appliedValue;
|
|
}
|
|
continue;
|
|
}
|
|
for (const card of effectTargets) {
|
|
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [cardId, value] of Object.entries(overrides?.generationBonusByCardId ?? {})) {
|
|
if (!flatByCardId[cardId]) continue;
|
|
const cappedValue = Math.max(0, Math.min(generationBonusCap, value));
|
|
for (const stat of stats) flatByCardId[cardId][stat] += cappedValue;
|
|
}
|
|
|
|
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
|
|
const allowedCooldownReductionsByCardId = Object.fromEntries(cards.map((card) => [
|
|
card.id,
|
|
[0, 4, 8, 12] satisfies CooldownReduction[]
|
|
]));
|
|
return {
|
|
flatByCardId,
|
|
rateByCardId,
|
|
activationRateUpByCardId,
|
|
allowedCooldownReductionsByCardId,
|
|
scoreSupportPercent,
|
|
directSongScoreBonusPercent: overrides?.directSongScoreBonusPercent ?? Math.min(10, directSongScoreBonusPercent)
|
|
};
|
|
}
|