Files
HolodoriCalc/src/lib/engine/board-effects.ts
T
dal4segno ba21e8f1c1
Publish GitHub Pages output / publish (push) Successful in 32s
fix: restore fast connection matching with yellow bonus correction
2026-09-17 20:38:18 +09:00

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)
};
}