feat: complete placement analysis workflow
This commit is contained in:
@@ -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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user