feat: complete placement analysis workflow

This commit is contained in:
2026-08-18 14:22:04 +09:00
parent dd3ea911f6
commit 7b713bffe3
452 changed files with 97115 additions and 24 deletions
+189
View File
@@ -0,0 +1,189 @@
import { skillTargetBelongsTo } from './skill-condition.ts';
import type { CooldownReduction } from './types.ts';
import type { UnitStat } from './unit-power.ts';
type BoardEffect = { id?: 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 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 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 [];
}
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 || expectedType !== song.singerType) return false;
const singers = song.singerCharacterIds ?? [];
if (effect.id.endsWith('-fuwamoko')) {
return singers.length === 2 && singers.includes('chr-04016') && singers.includes('chr-04017');
}
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 function calculateBoardEffects(
summary: BoardSummarySource,
memberBoards: Readonly<Record<string, readonly string[]>>,
cards: readonly BoardEffectCard[],
leaderCharacterId: string,
song?: BoardEffectSong | null,
connections?: BoardConnections | 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);
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;
for (const card of targets(effect, ownerId, cards)) {
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
}
}
}
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
const allowedCooldownReductionsByCardId = Object.fromEntries(cards.map((card) => [
card.id,
[0, 4, 8, 12] satisfies CooldownReduction[]
]));
return {
flatByCardId,
rateByCardId,
activationRateUpByCardId,
allowedCooldownReductionsByCardId,
scoreSupportPercent,
directSongScoreBonusPercent: Math.min(10, directSongScoreBonusPercent)
};
}