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; models?: Record; 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>>>; cards: readonly BoardConnectionCard[]; }; export type BoardEffectOverrides = { generationBonusByCardId?: Readonly>; directSongScoreBonusPercent?: number; }; export type BoardEffectsResult = { flatByCardId: Record>; rateByCardId: Record>; activationRateUpByCardId: Record; allowedCooldownReductionsByCardId: Record; scoreSupportPercent: number; directSongScoreBonusPercent: number; }; const stats: readonly UnitStat[] = ['performance', 'technique', 'sense']; const generationBonusCap = 900; const emptyStats = (): Record => ({ performance: 0, technique: 0, sense: 0 }); const groupingLabels: Record = { '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>, connections?: BoardConnections | null ): Record> { if (!connections || !summary.models || !summary.members) return {}; const cards = new Map(connections.cards.map((card) => [card.id, card])); const usedCards = new Set(); const result: Record> = {}; for (const member of summary.members) { const positions: readonly BoardPosition[] = summary.models[member.positionGroupId] ?? []; const positionById = new Map(positions.map((position) => [position.groupId, position])); const groupIdByCoordinate = new Map(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; flatByArea: Record<'B' | 'G', Record>; rate: Record; 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>, connections?: BoardConnections | null ): Record { 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>, 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>, 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 => { 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(); 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>, 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 = {}; 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) }; }