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
-7
View File
@@ -1,7 +0,0 @@
# Calculation engine
기존 스킬 로스 계산기의 계산 로직을 UI와 분리해 둘 위치입니다.
- Svelte 컴포넌트나 SvelteKit 서버 모듈을 import하지 않습니다.
- 브라우저, Web Worker, Node.js, 단위 테스트에서 공유할 수 있는 순수 TypeScript로 작성합니다.
- 공용 데이터 타입은 `src/lib/models`에서 가져옵니다.
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateBoardEffects, type BoardSummarySource } from './board-effects.ts';
const summary: BoardSummarySource = {
members: [{ id: 'a', positionGroupId: 'model-a' }],
models: { 'model-a': [{ groupId: 'S-1', x: 0, y: 0 }, { groupId: 'B-1', x: 1, y: 0 }] },
nodeGroups: {
'B-1': [{ characterIds: [], effect: { type: 'performance_up', value: 100, targetId: 'skill_tree_effect_target-skill_tree_character', trigger: 'always' } }],
'B-2': [{ characterIds: [], effect: { type: 'live_active_skill_cool_time_shorten_permil_up', value: 40, targetId: 'skill_tree_effect_target-skill_tree_character', trigger: 'always' } }],
'R-1': [{ characterIds: [], effect: { type: 'all_parameter_up_permil_up', value: 30, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader' } }],
'R-2': [{ characterIds: [], effect: {
id: 'skill_tree_effect-live_active_skill_effect_up_permil_up-100-skill_tree_effect_target-all-skill_tree_effect_passive_trigger-music_skill_tree_character-set_live_leader',
type: 'live_active_skill_effect_up_permil_up', value: 100, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader'
} }],
'G-1': [{ characterIds: [], effect: { type: 'all_parameter_up', value: 5, targetId: 'skill_tree_effect_target-all', trigger: 'always' } }],
'Y-1': [{ characterIds: [], effect: {
id: 'skill_tree_effect-live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type-5-skill_tree_effect_passive_trigger-music_skill_tree_character_and_music_singer_type-solo-always-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 5, trigger: 'always'
} }]
}
};
const cards = [
{ id: 'card-a', characterId: 'a', type: '큐트', generation: '0기생' },
{ id: 'card-b', characterId: 'b', type: '퓨어', generation: '1기생' }
];
test('편성 멤버의 파란 보드, 리더의 빨간 보드와 공통 초록 보드를 합산한다', () => {
const result = calculateBoardEffects(summary, {
a: ['B-1', 'B-2', 'Y-1'],
leader: ['R-1', 'R-2'],
account: ['G-1']
}, cards, 'leader', { singerType: 'solo', singerCharacterIds: ['a'] });
assert.equal(result.flatByCardId['card-a'].performance, 105);
assert.equal(result.flatByCardId['card-b'].performance, 5);
assert.equal(result.rateByCardId['card-a'].sense, 3);
assert.deepEqual(result.allowedCooldownReductionsByCardId['card-a'], [0, 4, 8, 12]);
assert.deepEqual(result.allowedCooldownReductionsByCardId['card-b'], [0, 4, 8, 12]);
assert.equal(result.directSongScoreBonusPercent, 0.5);
assert.equal(result.scoreSupportPercent, 0);
});
test('리더가 악곡 가창자이면 조건부 빨간 스코어 서포트를 적용한다', () => {
const result = calculateBoardEffects(summary, { leader: ['R-2'] }, cards, 'leader', {
singerType: 'group',
singerCharacterIds: ['leader']
});
assert.equal(result.scoreSupportPercent, 10);
});
test('연결 카드가 범위 안의 활성 노드 효과를 증폭한다', () => {
const result = calculateBoardEffects(summary, { a: ['B-1'] }, cards, 'a', null, {
placements: { a: { 'S-1': 'connect-card' } },
cards: [{
id: 'connect-card',
awakening: 0,
connect: [{ level: 1, effectPermilUp: 2000, extent: [{ x: 1, y: 0 }] }]
}]
});
assert.equal(result.flatByCardId['card-a'].performance, 300);
});
+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)
};
}
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
const source: OptimizerSourceCard = {
id: 'card',
name: '카드',
details: {
active: [
{ level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' },
{ level: 2, coolTimeSeconds: 8, durationSeconds: 2, effect: '스코어가 20% UP' }
],
special: [
{ level: 1, coolTimeSeconds: null, durationSeconds: 1, effect: '스코어가 30% UP' },
{ level: 2, coolTimeSeconds: null, durationSeconds: 2, effect: '스코어가 40% UP' }
]
}
};
test('액티브는 1개화, 스페셜은 3개화부터 2레벨을 적용한다', () => {
const base = toOptimizerCard(source, 0);
const activeAwakened = toOptimizerCard(source, 1);
const specialAwakened = toOptimizerCard(source, 3);
assert.equal(base.active.scoreUpPercent, 10);
assert.equal(base.special.scoreUpPercent, 30);
assert.equal(activeAwakened.active.scoreUpPercent, 20);
assert.equal(activeAwakened.special.scoreUpPercent, 30);
assert.equal(specialAwakened.special.scoreUpPercent, 40);
});
test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => {
const passiveSource: OptimizerSourceCard = {
...source,
type: '퓨어',
generation: 'Promise',
details: {
...source.details,
passive: [
{ level: 1, description: '[attribute=pure]퓨어 타입[/attribute] 2명의 스코어 서포트 효과 8%' },
{ level: 2, description: '[attribute=pure]퓨어 타입[/attribute] 2명의 스코어 서포트 효과 11%' }
]
}
};
assert.deepEqual(toOptimizerCard(passiveSource, 4, undefined, [{ type: '퓨어', generation: 'Promise' }]).passiveScoreSupportEffects, [{
percent: 11,
target: 'group',
targetGroup: '퓨어 타입',
targetCount: 2
}]);
});
+117
View File
@@ -0,0 +1,117 @@
import type { CooldownReduction, OptimizerCard } from './types.ts';
import { plainSkillText, skillConditionSatisfied, type SkillConditionMember } from './skill-condition.ts';
type SkillLevel = {
level: number;
coolTimeSeconds: number | null;
durationSeconds: number | null;
effect?: string;
probabilityPercent?: number | null;
condition?: string;
};
export type OptimizerSourceCard = {
id: string;
name: string;
type?: string;
generation?: string;
versionName?: string;
details: {
passive?: readonly { level: number; description: string }[];
active: readonly SkillLevel[];
special: readonly SkillLevel[];
};
};
function selectedLevel(
levels: readonly SkillLevel[],
awakening: number,
levelUpAwakening: number
): SkillLevel | undefined {
const targetLevel = awakening >= levelUpAwakening ? 2 : 1;
return levels.find((skill) => skill.level === targetLevel) ?? levels.at(-1);
}
function passiveScoreSupportEffects(
source: OptimizerSourceCard,
awakening: number,
unitMembers: readonly SkillConditionMember[]
): NonNullable<OptimizerCard['passiveScoreSupportEffects']> {
const level = awakening >= 4 ? 2 : 1;
const description = plainSkillText(source.details.passive?.find((entry) => entry.level === level)?.description ?? '');
const marker = '스코어 서포트 효과';
const markerIndex = description.indexOf(marker);
if (markerIndex < 0) return [];
const before = description.slice(0, markerIndex).trim();
const percent = Number(description.slice(markerIndex + marker.length).match(/(\d+(?:\.\d+)?)%/)?.[1] ?? 0);
if (percent <= 0) return [];
const conditionSource = before.split('%').at(-1)?.trim() ?? before;
const condition = conditionSource.match(/(.+?\d+명 이상이면)/)?.[1] ?? '';
if (condition && !skillConditionSatisfied(condition, unitMembers)) return [];
const targetText = (before.includes('이면') ? before.slice(before.lastIndexOf('이면') + 2) : before).trim();
if (/전원의?$/.test(targetText)) return [{ percent, target: 'all' }];
if (/자신의?$/.test(targetText)) return [{ percent, target: 'self' }];
const groupTarget = targetText.match(/(.+?)\s*(\d+)명의?$/);
if (!groupTarget) return [];
return [{
percent,
target: 'group',
targetGroup: groupTarget[1].trim(),
targetCount: Number(groupTarget[2])
}];
}
function scorePercent(effect: string | undefined): number | null {
const match = effect?.match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : null;
}
function activationRatePercent(effect: string | undefined): number {
const match = effect?.match(/스킬\s*발동률이?\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : 0;
}
export function toOptimizerCard(
source: OptimizerSourceCard,
awakening: number,
allowedCooldownReductions?: readonly CooldownReduction[],
unitMembers: readonly SkillConditionMember[] = []
): OptimizerCard {
const active = selectedLevel(source.details.active, awakening, 1);
const special = selectedLevel(source.details.special, awakening, 3);
const activeConditionMet = skillConditionSatisfied(active?.condition ?? '', unitMembers);
const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers);
const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0;
const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0;
if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) {
throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`);
}
if (!special || special.durationSeconds === null || specialScore === null) {
throw new Error(`${source.name}의 스페셜 스코어 정보를 계산에 사용할 수 없습니다.`);
}
return {
id: source.id,
name: source.versionName ? `${source.name} (${source.versionName})` : source.name,
type: source.type,
generation: source.generation,
active: {
cooldownSeconds: active.coolTimeSeconds,
durationSeconds: active.durationSeconds,
scoreUpPercent: activeScore,
probabilityPercent: active.probabilityPercent ?? 100
},
special: {
durationSeconds: special.durationSeconds,
scoreUpPercent: specialScore,
activationRateUpPercent: specialConditionMet ? activationRatePercent(special.effect) : 0
},
passiveScoreSupportEffects: passiveScoreSupportEffects(source, awakening, unitMembers),
allowedCooldownReductions
};
}
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({
id,
name: id,
active: { cooldownSeconds: 2 + index, durationSeconds: 0.5, scoreUpPercent: 10 + index },
special: { durationSeconds: 1, scoreUpPercent: 20 + index },
allowedCooldownReductions: [0]
}));
const chart: ExactSongChart = {
songId: 'timeline-test',
difficulty: 'expert',
durationSeconds: 8,
scoreRatio: 1,
notes: Array.from({ length: 8 }, (_, timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
specialStartSeconds: [1, 2, 3, 4, 5]
};
test('정확 분석 결과에 최적 조합의 액티브와 스페셜 지속 구간을 포함한다', () => {
const result = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
assert.ok(result.activeWindows.length > 0);
assert.ok(result.effectiveActiveWindows.length > 0);
assert.ok(result.effectiveActiveWindows.every((window) => window.baseScoreUpPercent > 0 && window.scoreUpPercent >= window.baseScoreUpPercent));
assert.equal(result.specialWindows.length, 5);
assert.deepEqual(new Set(result.specialWindows.map((window) => window.cardId)), new Set(result.order));
assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds));
});
test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정 인원에게 적용한다', () => {
const supported = cards.map((card, index) => ({
...card,
type: index < 3 ? '퓨어' : '해피',
passiveScoreSupportEffects: index === 4
? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: []
}));
const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 });
assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore);
});
+280
View File
@@ -0,0 +1,280 @@
import { calculateExactScore } from './score.ts';
import { skillTargetBelongsTo } from './skill-condition.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import { COOLDOWN_REDUCTIONS } from './types.ts';
import type {
CooldownReduction,
EffectiveSkillWindow,
ExactOptimizationResult,
ExactOptimizeOptions,
ExactSongChart,
OptimizerCard,
SkillWindow
} from './types.ts';
type CandidateProfile = {
boost: Float64Array;
probability: Float64Array;
};
function passiveScoreSupportByCard(order: readonly OptimizerCard[]): number[] {
const totals = order.map(() => 0);
order.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = order.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = order
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? order.length)
.map(({ index }) => index);
for (const index of indexes) totals[index] += effect.percent;
}
});
return totals;
}
function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)];
const result: T[][] = [];
for (let index = 0; index < items.length; index += 1) {
const rest = [...items.slice(0, index), ...items.slice(index + 1)];
for (const permutation of permutations(rest)) result.push([items[index], ...permutation]);
}
return result;
}
function assignments(cards: readonly OptimizerCard[]): Array<Record<string, CooldownReduction>> {
let result: Array<Record<string, CooldownReduction>> = [{}];
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
result = result.flatMap((current) => allowed.map((reduction) => ({ ...current, [card.id]: reduction })));
}
return result;
}
function specialActive(card: OptimizerCard, start: number | null | undefined, time: number): boolean {
return start !== null && start !== undefined && start <= time && time < start + card.special.durationSeconds;
}
function specialRate(
order: readonly OptimizerCard[],
chart: ExactSongChart,
time: number,
field: 'scoreUpPercent' | 'activationRateUpPercent'
): number {
let total = 0;
for (let slot = 0; slot < order.length; slot += 1) {
const card = order[slot];
if (specialActive(card, chart.specialStartSeconds[slot], time)) total += card.special[field] ?? 0;
}
return total;
}
function comboBonus(combo: number): number {
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
}
function baseScores(chart: ExactSongChart, perfectNoteScore: number): Float64Array {
return Float64Array.from(chart.notes, (note, index) => {
const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1);
return base * (1 + comboBonus(index + 1));
});
}
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map<string, CandidateProfile> {
const profiles = new Map<string, CandidateProfile>();
const noteSupport = Float64Array.from(chart.notes, (note) => specialRate(order, chart, note.timeSeconds, 'scoreUpPercent'));
const passiveSupport = passiveScoreSupportByCard(order);
order.forEach((card, cardIndex) => {
const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
reductions.forEach((reduction) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const boost = new Float64Array(chart.notes.length);
const probability = new Float64Array(chart.notes.length);
const probabilityByAttempt = new Map<number, number>();
chart.notes.forEach((note, noteIndex) => {
const attempt = Math.floor((note.timeSeconds + 1e-9) / interval);
if (attempt <= 0) return;
const activationTime = attempt * interval;
if (note.timeSeconds >= activationTime + card.active.durationSeconds) return;
let effectiveProbability = probabilityByAttempt.get(attempt);
if (effectiveProbability === undefined) {
const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent');
effectiveProbability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
probabilityByAttempt.set(attempt, effectiveProbability);
}
boost[noteIndex] = card.active.scoreUpPercent / 100 *
(1 + (noteSupport[noteIndex] + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100);
probability[noteIndex] = effectiveProbability;
});
profiles.set(`${card.id}:${reduction}`, { boost, probability });
});
});
return profiles;
}
function resultWindows(
order: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart
): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } {
const activeWindows = order.flatMap((card) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0);
const windows: SkillWindow[] = [];
for (let activation = 1; activation * interval < chart.durationSeconds - 1e-9; activation += 1) {
const startSeconds = activation * interval;
windows.push({
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.active.durationSeconds),
scoreUpPercent: card.active.scoreUpPercent
});
}
return windows;
});
const specialWindows = order.flatMap((card, slot) => {
const startSeconds = chart.specialStartSeconds[slot];
if (startSeconds === null || startSeconds === undefined) return [];
return [{
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds),
scoreUpPercent: card.special.scoreUpPercent
}];
});
const passiveSupport = passiveScoreSupportByCard(order);
const orderIndex = new Map(order.map((card, index) => [card.id, index]));
const boundaries = Array.from(new Set([
...activeWindows.flatMap((window) => [window.startSeconds, window.endSeconds]),
...specialWindows.flatMap((window) => [window.startSeconds, window.endSeconds])
])).sort((left, right) => left - right);
const effectiveActiveWindows: EffectiveSkillWindow[] = [];
for (let index = 0; index < boundaries.length - 1; index += 1) {
const startSeconds = boundaries[index];
const endSeconds = boundaries[index + 1];
if (endSeconds - startSeconds < 0.01) continue;
const midpoint = (startSeconds + endSeconds) / 2;
const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent');
const active = activeWindows
.filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds)
.map((window) => {
const cardIndex = orderIndex.get(window.cardId) ?? 0;
const card = order[cardIndex];
return {
window,
effectivePercent: window.scoreUpPercent *
(1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100)
};
})
.sort((left, right) => right.effectivePercent - left.effectivePercent || (orderIndex.get(left.window.cardId) ?? 99) - (orderIndex.get(right.window.cardId) ?? 99));
const winner = active[0];
if (!winner) continue;
const previous = effectiveActiveWindows.at(-1);
if (previous && previous.cardId === winner.window.cardId && Math.abs(previous.scoreUpPercent - winner.effectivePercent) < 1e-9 && Math.abs(previous.endSeconds - startSeconds) < 0.01) {
previous.endSeconds = endSeconds;
previous.overlapCount = Math.max(previous.overlapCount, active.length);
} else {
effectiveActiveWindows.push({
...winner.window,
startSeconds,
endSeconds,
baseScoreUpPercent: winner.window.scoreUpPercent,
scoreUpPercent: winner.effectivePercent,
overlapCount: active.length
});
}
}
return { activeWindows, effectiveActiveWindows, specialWindows };
}
function scoreAssignment(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array,
directBonus: number
): number {
const selected = cards.map((card) => {
const reduction = reductions[card.id] ?? 0;
const profile = profiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`);
return profile;
});
const boosts = new Float64Array(cards.length);
const probabilities = new Float64Array(cards.length);
let rawScore = 0;
for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) {
let count = 0;
for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) {
const boost = selected[memberIndex].boost[noteIndex];
if (boost <= 0) continue;
let position = count;
while (position > 0 && boosts[position - 1] < boost) {
boosts[position] = boosts[position - 1];
probabilities[position] = probabilities[position - 1];
position -= 1;
}
boosts[position] = boost;
probabilities[position] = selected[memberIndex].probability[noteIndex];
count += 1;
}
let expectedBoost = 0;
let previousFailed = 1;
for (let position = 0; position < count; position += 1) {
expectedBoost += previousFailed * probabilities[position] * boosts[position];
previousFailed *= 1 - probabilities[position];
}
rawScore += base[noteIndex] * (1 + expectedBoost);
}
return Math.floor(Math.floor(rawScore) * (1 + directBonus / 100) + Number.EPSILON * Math.max(1, rawScore) * 4);
}
export function optimizeExactOrderAndCooldowns(
cards: readonly OptimizerCard[],
chart: ExactSongChart,
options: ExactOptimizeOptions
): ExactOptimizationResult {
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.');
}
const orders = permutations(cards);
const reductionAssignments = assignments(cards);
const base = baseScores(chart, options.perfectNoteScore);
const totalCandidates = orders.length * reductionAssignments.length;
let evaluatedCandidates = 0;
let bestScore = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const order of orders) {
const profiles = makeProfiles(order, chart);
for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
if (score > bestScore || (score === bestScore && reductionTotal < bestReductionTotal)) {
bestScore = score;
bestOrder = order;
bestReductions = reductions;
bestReductionTotal = reductionTotal;
}
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.');
const windows = resultWindows(bestOrder, bestReductions, chart);
return {
...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id),
cooldownReductions: { ...bestReductions },
...windows,
evaluatedCandidates
};
}
+11
View File
@@ -0,0 +1,11 @@
export * from './card-adapter.ts';
export * from './board-effects.ts';
export * from './exact-optimizer.ts';
export * from './memory.ts';
export * from './maximum-weight-matching.ts';
export * from './optimizer.ts';
export * from './score.ts';
export * from './skill-condition.ts';
export * from './timeline.ts';
export * from './types.ts';
export * from './unit-power.ts';
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { maximumWeightMatching } from './maximum-weight-matching.ts';
test('전체 합이 가장 큰 일대일 배치를 선택한다', () => {
const matches = maximumWeightMatching([
[9, 8, 1],
[8, 1, 1]
]);
assert.deepEqual(matches.map(({ rowIndex, columnIndex }) => [rowIndex, columnIndex]).sort(), [[0, 1], [1, 0]]);
assert.equal(matches.reduce((sum, match) => sum + match.weight, 0), 16);
});
test('카드 수가 슬롯보다 많아도 각 슬롯과 카드를 한 번만 사용한다', () => {
const matches = maximumWeightMatching([
[7, 0],
[6, 5],
[0, 9]
]);
assert.equal(matches.length, 2);
assert.equal(new Set(matches.map((match) => match.rowIndex)).size, 2);
assert.equal(new Set(matches.map((match) => match.columnIndex)).size, 2);
assert.equal(matches.reduce((sum, match) => sum + match.weight, 0), 16);
});
test('효과가 없는 배치는 결과에서 제외한다', () => {
assert.deepEqual(maximumWeightMatching([[0, 0]]), []);
});
+84
View File
@@ -0,0 +1,84 @@
export type WeightedMatch = {
rowIndex: number;
columnIndex: number;
weight: number;
};
/**
* Finds a maximum-weight one-to-one assignment for a rectangular matrix.
* Rows or columns that cannot improve the result may remain unmatched.
*/
export function maximumWeightMatching(weights: readonly (readonly number[])[]): WeightedMatch[] {
const rowCount = weights.length;
const columnCount = weights.reduce((maximum, row) => Math.max(maximum, row.length), 0);
if (rowCount === 0 || columnCount === 0) return [];
if (rowCount > columnCount) {
const transposed = Array.from({ length: columnCount }, (_, columnIndex) =>
Array.from({ length: rowCount }, (_, rowIndex) => weights[rowIndex]?.[columnIndex] ?? 0)
);
return maximumWeightMatching(transposed).map((match) => ({
rowIndex: match.columnIndex,
columnIndex: match.rowIndex,
weight: match.weight
}));
}
const maximum = Math.max(0, ...weights.flatMap((row) => [...row]));
const rowPotential = new Array(rowCount + 1).fill(0);
const columnPotential = new Array(columnCount + 1).fill(0);
const matchedRowByColumn = new Array(columnCount + 1).fill(0);
const previousColumn = new Array(columnCount + 1).fill(0);
for (let row = 1; row <= rowCount; row += 1) {
matchedRowByColumn[0] = row;
let currentColumn = 0;
const minimum = new Array(columnCount + 1).fill(Number.POSITIVE_INFINITY);
const used = new Array(columnCount + 1).fill(false);
do {
used[currentColumn] = true;
const currentRow = matchedRowByColumn[currentColumn];
let delta = Number.POSITIVE_INFINITY;
let nextColumn = 0;
for (let column = 1; column <= columnCount; column += 1) {
if (used[column]) continue;
const weight = weights[currentRow - 1]?.[column - 1] ?? 0;
const cost = maximum - Math.max(0, weight);
const reducedCost = cost - rowPotential[currentRow] - columnPotential[column];
if (reducedCost < minimum[column]) {
minimum[column] = reducedCost;
previousColumn[column] = currentColumn;
}
if (minimum[column] < delta) {
delta = minimum[column];
nextColumn = column;
}
}
for (let column = 0; column <= columnCount; column += 1) {
if (used[column]) {
rowPotential[matchedRowByColumn[column]] += delta;
columnPotential[column] -= delta;
} else {
minimum[column] -= delta;
}
}
currentColumn = nextColumn;
} while (matchedRowByColumn[currentColumn] !== 0);
do {
const nextColumn = previousColumn[currentColumn];
matchedRowByColumn[currentColumn] = matchedRowByColumn[nextColumn];
currentColumn = nextColumn;
} while (currentColumn !== 0);
}
const matches: WeightedMatch[] = [];
for (let column = 1; column <= columnCount; column += 1) {
const row = matchedRowByColumn[column];
if (row === 0) continue;
const weight = weights[row - 1]?.[column - 1] ?? 0;
if (weight > 0) matches.push({ rowIndex: row - 1, columnIndex: column - 1, weight });
}
return matches;
}
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { memoryParameterPermil } from './memory.ts';
const effects = [
{ count: 1, permil: 2 },
{ count: 2, permil: 4 },
{ count: 3, permil: 6 }
];
test('보유 메모리 수에 해당하는 누적 파라미터 보너스를 찾는다', () => {
assert.equal(memoryParameterPermil(0, effects), 0);
assert.equal(memoryParameterPermil(2, effects), 4);
assert.equal(memoryParameterPermil(10, effects), 6);
});
test('메모리 수는 음수를 0으로, 소수는 버림해 판정한다', () => {
assert.equal(memoryParameterPermil(-1, effects), 0);
assert.equal(memoryParameterPermil(2.9, effects), 4);
});
+15
View File
@@ -0,0 +1,15 @@
export type MemoryCollectionEffect = {
count: number;
permil: number;
};
export function memoryParameterPermil(
count: number,
effects: readonly MemoryCollectionEffect[]
): number {
const owned = Math.max(0, Math.floor(Number.isFinite(count) ? count : 0));
return effects.reduce(
(rate, effect) => effect.count <= owned && effect.permil > rate ? effect.permil : rate,
0
);
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { countOptimizationCandidates, effectiveCooldownSeconds, optimizeOrderAndCooldowns } from './index.ts';
import type { OptimizerCard, SongChartDensity } from './types.ts';
const cards: OptimizerCard[] = [
{ id: 'a', name: 'A', active: { cooldownSeconds: 2, durationSeconds: 0.8, scoreUpPercent: 100 }, special: { durationSeconds: 1, scoreUpPercent: 200 } },
{ id: 'b', name: 'B', active: { cooldownSeconds: 3, durationSeconds: 0.8, scoreUpPercent: 90 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'c', name: 'C', active: { cooldownSeconds: 4, durationSeconds: 0.8, scoreUpPercent: 80 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'd', name: 'D', active: { cooldownSeconds: 5, durationSeconds: 0.8, scoreUpPercent: 70 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'e', name: 'E', active: { cooldownSeconds: 6, durationSeconds: 0.8, scoreUpPercent: 60 }, special: { durationSeconds: 1, scoreUpPercent: 20 } }
];
const chart: SongChartDensity = {
songId: 'test-song',
difficulty: 'expert',
durationSeconds: 8,
notesPerSecond: [0, 0, 1, 1, 2, 2, 12, 12],
specialStartSeconds: [6, 0, 1, 2, 3]
};
test('쿨타임 감소는 기존 계산기의 나눗셈 규칙을 따른다', () => {
assert.equal(effectiveCooldownSeconds(20, 0), 20);
assert.equal(effectiveCooldownSeconds(20, 4), 20 / 1.04);
assert.equal(effectiveCooldownSeconds(20, 12), 20 / 1.12);
});
test('5장과 네 가지 감소율은 122,880개 후보를 만든다', () => {
assert.equal(countOptimizationCandidates(cards), 122_880);
});
test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => {
const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const }));
const result = optimizeOrderAndCooldowns(fixedReductionCards, chart);
assert.equal(result.order[0], 'a');
assert.ok(result.scoreWeight > 0);
});
+148
View File
@@ -0,0 +1,148 @@
import { COOLDOWN_REDUCTIONS } from './types.ts';
import type {
CooldownReduction,
OptimizeOrderOptions,
OptimizerCard,
SimulationResult,
SongChartDensity
} from './types.ts';
import { buildActiveWindows, buildSpecialWindows, simulateOrder } from './timeline.ts';
function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)];
const result: T[][] = [];
for (let index = 0; index < items.length; index += 1) {
const rest = [...items.slice(0, index), ...items.slice(index + 1)];
for (const permutation of permutations(rest)) result.push([items[index], ...permutation]);
}
return result;
}
function reductionAssignments(cards: readonly OptimizerCard[]): Array<Record<string, CooldownReduction>> {
let assignments: Array<Record<string, CooldownReduction>> = [{}];
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
assignments = assignments.flatMap((assignment) =>
allowed.map((reduction) => ({ ...assignment, [card.id]: reduction }))
);
}
return assignments;
}
function reductionTotal(reductions: Readonly<Record<string, CooldownReduction>>): number {
return Object.values(reductions).reduce<number>((sum, reduction) => sum + reduction, 0);
}
function fillWindowProfile(
profile: Float64Array,
windows: ReturnType<typeof buildActiveWindows>,
stepSeconds: number
): void {
for (const window of windows) {
const startTick = Math.max(0, Math.ceil(window.startSeconds / stepSeconds - 1e-9));
const endTick = Math.min(profile.length, Math.ceil(window.endSeconds / stepSeconds - 1e-9));
for (let tick = startTick; tick < endTick; tick += 1) {
profile[tick] = Math.max(profile[tick], window.scoreUpPercent);
}
}
}
function makeNoteWeights(chart: SongChartDensity, stepSeconds: number, tickCount: number): Float64Array {
const weights = new Float64Array(tickCount);
for (let tick = 0; tick < tickCount; tick += 1) {
const timeSeconds = tick * stepSeconds;
const second = Math.floor(timeSeconds);
weights[tick] =
(chart.notesPerSecond[second] ?? 0) * Math.min(stepSeconds, chart.durationSeconds - timeSeconds);
}
return weights;
}
export function optimizeOrderAndCooldowns(
cards: readonly OptimizerCard[],
chart: SongChartDensity,
options: OptimizeOrderOptions = {}
): SimulationResult {
if (cards.length !== 5) throw new Error('최고점 순서 분석에는 정확히 5장의 카드가 필요합니다.');
if (new Set(cards.map((card) => card.id)).size !== cards.length) throw new Error('카드 ID는 서로 달라야 합니다.');
const stepSeconds = options.stepSeconds ?? 0.1;
if (!Number.isFinite(stepSeconds) || stepSeconds <= 0) throw new Error('계산 간격은 0보다 커야 합니다.');
const orders = permutations(cards);
const assignments = reductionAssignments(cards);
const totalCandidates = orders.length * assignments.length;
const tickCount = Math.ceil(chart.durationSeconds / stepSeconds);
const noteWeights = makeNoteWeights(chart, stepSeconds, tickCount);
const activeProfiles = new Map<string, Float64Array>();
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
for (const reduction of allowed) {
const profile = new Float64Array(tickCount);
fillWindowProfile(
profile,
buildActiveWindows(card, reduction, chart.durationSeconds, stepSeconds),
stepSeconds
);
activeProfiles.set(`${card.id}:${reduction}`, profile);
}
}
const specialMultipliers = orders.map((order) => {
const profile = new Float64Array(tickCount);
fillWindowProfile(profile, buildSpecialWindows(order, chart), stepSeconds);
for (let tick = 0; tick < tickCount; tick += 1) profile[tick] = 1 + profile[tick] / 100;
return profile;
});
let evaluatedCandidates = 0;
let bestScoreWeight = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const reductions of assignments) {
const activeScore = new Float64Array(tickCount);
for (const card of cards) {
const reduction = reductions[card.id] ?? 0;
const profile = activeProfiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 쿨타임 감소 ${reduction}% 계산값이 없습니다.`);
for (let tick = 0; tick < tickCount; tick += 1) {
activeScore[tick] = Math.max(activeScore[tick], profile[tick]);
}
}
const currentReductionTotal = reductionTotal(reductions);
for (let orderIndex = 0; orderIndex < orders.length; orderIndex += 1) {
const specialMultiplier = specialMultipliers[orderIndex];
let scoreWeight = 0;
for (let tick = 0; tick < tickCount; tick += 1) {
scoreWeight += noteWeights[tick] * activeScore[tick] * specialMultiplier[tick];
}
const isBetterScore = scoreWeight > bestScoreWeight + 1e-9;
const isCheaperTie =
Math.abs(scoreWeight - bestScoreWeight) <= 1e-9 && currentReductionTotal < bestReductionTotal;
if (isBetterScore || isCheaperTie) {
bestScoreWeight = scoreWeight;
bestOrder = orders[orderIndex];
bestReductions = reductions;
bestReductionTotal = currentReductionTotal;
}
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
if (!bestOrder || !bestReductions) throw new Error('분석할 수 있는 후보가 없습니다.');
return simulateOrder(bestOrder, bestReductions, chart, stepSeconds);
}
export function countOptimizationCandidates(cards: readonly OptimizerCard[]): number {
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const reductionCount = cards.reduce(
(value, card) => value * (card.allowedCooldownReductions?.length || COOLDOWN_REDUCTIONS.length),
1
);
return orderCount * reductionCount;
}
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const inactiveCard: OptimizerCard = {
id: 'card',
name: '카드',
active: { cooldownSeconds: 999, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 100 },
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
function chart(notes: ExactSongChart['notes']): ExactSongChart {
return { songId: 'test', difficulty: 'expert', durationSeconds: 120, scoreRatio: 100, notes, specialStartSeconds: [] };
}
test('종합력과 악곡 비율로 PERFECT 1노트 기본점수를 계산한다', () => {
assert.equal(perfectNoteScoreFromPower(120_000, 200), 600);
assert.equal(perfectNoteScoreFromPower(120_100, 200), 600.5);
assert.equal(perfectNoteScoreFromPower(0, 200, 777), 777);
});
test('플릭 1.05배와 같은 시각 홀드 중간 판정의 단일 가중치로 환산비율을 계산한다', () => {
const ratio = scoreRatioFromNotes([
{ timeSeconds: 1, type: 'regular', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true },
{ timeSeconds: 3, type: 'mid', isFlick: false },
{ timeSeconds: 3, type: 'mid', isFlick: false }
]);
assert.ok(Math.abs(ratio - 2.15 * 0.3713416563352296) < 1e-12);
});
test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노트별로 올림한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), { perfectNoteScore: 1_354.507, directSongScoreBonusPercent: 10 });
assert.equal(result.baselineScore, 1_490);
});
test('100콤보부터 1% 콤보 보너스를 적용한다', () => {
const notes = Array.from({ length: 100 }, (_, index) => ({ timeSeconds: index, type: 'regular' as const, isFlick: false }));
const result = calculateExactScore([inactiveCard], {}, chart(notes), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 99 * 600 + 606);
});
test('홀드 중간 노트는 10%, 플릭은 1.05배로 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true }
]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60 + 630);
});
test('같은 시각에 중복된 홀드 중간 판정은 한 번만 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 1, type: 'mid', isFlick: false }
]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60);
assert.equal(result.notes.length, 1);
});
test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수에 반영한다', () => {
const card: OptimizerCard = {
id: 'score',
name: '스코어 카드',
active: { cooldownSeconds: 1, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 50 },
special: { durationSeconds: 2, scoreUpPercent: 100 }
};
const result = calculateExactScore([card], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [0]
}, { perfectNoteScore: 600 });
assert.equal(result.expectedScore, 1_200);
assert.equal(result.theoreticalMaxScore, 1_800);
});
+187
View File
@@ -0,0 +1,187 @@
import { effectiveCooldownSeconds } from './timeline.ts';
import { skillTargetBelongsTo } from './skill-condition.ts';
import type {
CooldownReduction,
ExactScoreOptions,
ExactScoreResult,
ExactSongChart,
OptimizerCard
} from './types.ts';
type ActiveCandidate = {
memberIndex: number;
probability: number;
boost: number;
};
// QUEEN 및 Our Promise Expert의 인게임 실측값으로 보정한 전역 환산 상수다.
export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296;
export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
const countedMidTimes = new Set<number>();
let weight = 0;
for (const note of notes) {
if (note.type === 'mid') {
if (countedMidTimes.has(note.timeSeconds)) continue;
countedMidTimes.add(note.timeSeconds);
weight += 0.1;
continue;
}
weight += note.isFlick ? 1.05 : 1;
}
return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
}
function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
let total = 0;
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
if (indexes.includes(targetIndex)) total += effect.percent;
}
});
return total;
}
function comboBonus(combo: number): number {
if (combo >= 1_000) return 0.1;
if (combo >= 100) return Math.floor(combo / 100) * 0.01;
return 0;
}
function activeAttempt(card: OptimizerCard, reduction: CooldownReduction, timeSeconds: number): number {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
return Math.floor((timeSeconds + 1e-9) / interval);
}
function specialIsActive(
card: OptimizerCard,
startSeconds: number | null | undefined,
timeSeconds: number
): boolean {
return startSeconds !== null && startSeconds !== undefined &&
startSeconds <= timeSeconds && timeSeconds < startSeconds + card.special.durationSeconds;
}
function specialSupportAt(
cardsInOrder: readonly OptimizerCard[],
chart: ExactSongChart,
timeSeconds: number
): number {
return cardsInOrder.reduce((sum, card, slot) =>
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds) ? card.special.scoreUpPercent : 0), 0);
}
function activationRateBonusAt(
cardsInOrder: readonly OptimizerCard[],
chart: ExactSongChart,
timeSeconds: number
): number {
return cardsInOrder.reduce((sum, card, slot) =>
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds)
? card.special.activationRateUpPercent ?? 0
: 0), 0);
}
function activeCandidates(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
timeSeconds: number
): ActiveCandidate[] {
const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds);
const candidates = cardsInOrder.flatMap((card, memberIndex) => {
const reduction = reductions[card.id] ?? 0;
const attempt = activeAttempt(card, reduction, timeSeconds);
if (attempt <= 0) return [];
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const activationTime = attempt * interval;
if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime);
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationRateBonus / 100));
const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex);
return [{
memberIndex,
probability,
boost: card.active.scoreUpPercent / 100 * (1 + support / 100)
}];
});
return candidates.sort((left, right) => right.boost - left.boost || left.memberIndex - right.memberIndex);
}
function expectedStrongestBoost(candidates: readonly ActiveCandidate[]): number {
let expectedBoost = 0;
let previousFailed = 1;
for (const candidate of candidates) {
expectedBoost += previousFailed * candidate.probability * candidate.boost;
previousFailed *= 1 - candidate.probability;
}
return expectedBoost;
}
function finalNoteScore(score: number, percent: number): number {
const scaled = score * (1 + percent / 100);
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
}
export function perfectNoteScoreFromPower(overallPower: number, scoreRatio: number | null, fallback = 600): number {
if (overallPower > 0 && scoreRatio !== null && scoreRatio > 0) {
return Math.max(1, overallPower / scoreRatio);
}
return fallback;
}
export function calculateExactScore(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions
): ExactScoreResult {
const directBonus = options.directSongScoreBonusPercent ?? 0;
const countedMidTimes = new Set<number>();
const scoreNotes = chart.notes.filter((note) => {
if (note.type !== 'mid') return true;
if (countedMidTimes.has(note.timeSeconds)) return false;
countedMidTimes.add(note.timeSeconds);
return true;
});
const notePoints = scoreNotes.map((note, index) => {
const combo = index + 1;
const baseNoteScore = note.type === 'mid'
? Math.ceil(options.perfectNoteScore * 0.1)
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo));
const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds);
const baselineScore = finalNoteScore(rawBaselineScore, directBonus);
const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus);
const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus);
return { timeSeconds: note.timeSeconds, combo, expectedScore, theoreticalMaxScore, baselineScore };
});
const rawExpected = notePoints.reduce((sum, note) => sum + note.expectedScore, 0);
const rawMaximum = notePoints.reduce((sum, note) => sum + note.theoreticalMaxScore, 0);
const rawBaseline = notePoints.reduce((sum, note) => sum + note.baselineScore, 0);
const scoreBySecond = Array.from({ length: Math.ceil(chart.durationSeconds) }, () => 0);
notePoints.forEach((note) => {
const second = Math.floor(note.timeSeconds);
if (second >= 0 && second < scoreBySecond.length) scoreBySecond[second] += note.expectedScore;
});
return {
expectedScore: rawExpected,
theoreticalMaxScore: rawMaximum,
baselineScore: rawBaseline,
perfectNoteScore: options.perfectNoteScore,
scoreBySecond,
notes: notePoints
};
}
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts';
const members = [
{ type: '큐트', generation: 'hololive 0기생' },
{ type: '큐트', generation: 'hololive 0기생' },
{ type: '퓨어', generation: 'Myth' },
{ type: '해피', generation: 'GAMERS' },
{ type: '해피', generation: 'holoX' }
];
test('타입과 기수 조건은 현재 멤버 조합으로 판정한다', () => {
assert.equal(skillConditionSatisfied('[attribute=cute]큐트 타입[/attribute] 2명 이상이면', members), true);
assert.equal(skillConditionSatisfied('Myth(이)가 2명 이상이면', members), false);
assert.equal(skillConditionSatisfied('0기생(이)가 2명 이상이면', members), true);
});
test('콤보와 라이프 조건은 항상 충족한 것으로 판정한다', () => {
assert.equal(skillConditionSatisfied('100콤보 이상이면', members), true);
assert.equal(skillConditionSatisfied('라이프 1000 이상이면', members), true);
});
test('복수 소속 멤버는 표기된 각 기수에 모두 속한다', () => {
const fubuki = { type: '퓨어', generation: 'hololive 1기생 / GAMERS' };
assert.equal(skillTargetBelongsTo(fubuki, '1기생'), true);
assert.equal(skillTargetBelongsTo(fubuki, 'GAMERS'), true);
assert.equal(skillConditionSatisfied('1기생(이)가 1명 이상이면', [fubuki]), true);
});
+51
View File
@@ -0,0 +1,51 @@
export type SkillConditionMember = {
type: string;
generation: string;
};
export function plainSkillText(value: string): string {
return value.replace(/\[[^\]]+\]/g, '').replace(/\s+/g, ' ').trim();
}
function normalizedGroup(value: string): string {
return value
.replace(/^hololive\s+/i, '')
.replace(/\(이\)가$/, '')
.replace(/\s*타입$/, '')
.trim()
.toLowerCase();
}
function memberBelongsTo(member: SkillConditionMember, group: string): boolean {
const normalized = normalizedGroup(group);
const type = normalizedGroup(member.type);
const generation = normalizedGroup(member.generation);
const generations = member.generation
.split('/')
.map((value) => normalizedGroup(value))
.filter(Boolean);
return type === normalized
|| generation === normalized
|| generation.endsWith(normalized)
|| generations.some((value) => value === normalized || value.endsWith(normalized));
}
export function skillConditionSatisfied(
condition: string,
members: readonly SkillConditionMember[]
): boolean {
const description = plainSkillText(condition);
if (!description) return true;
// 콤보와 라이프는 분석에서 최적 플레이를 전제로 항상 충족한다.
if (/\d+콤보 이상이면/.test(description) || /라이프 \d+ 이상이면/.test(description)) return true;
const deckCondition = description.match(/^(.+?)\s*(\d+)명 이상이면$/);
if (!deckCondition) return true;
const requiredCount = Number(deckCondition[2]);
return members.filter((member) => memberBelongsTo(member, deckCondition[1])).length >= requiredCount;
}
export function skillTargetBelongsTo(member: SkillConditionMember, group: string): boolean {
return memberBelongsTo(member, group);
}
+107
View File
@@ -0,0 +1,107 @@
import type {
CooldownReduction,
OptimizerCard,
SimulationResult,
SkillWindow,
SongChartDensity,
TimelinePoint
} from './types.ts';
const EPSILON = 1e-9;
export function ceilToStep(value: number, stepSeconds: number): number {
return Math.ceil(value / stepSeconds - EPSILON) * stepSeconds;
}
export function effectiveCooldownSeconds(baseCooldownSeconds: number, reduction: CooldownReduction): number {
return baseCooldownSeconds / (1 + reduction / 100);
}
export function buildActiveWindows(
card: OptimizerCard,
reduction: CooldownReduction,
durationSeconds: number,
stepSeconds = 0.1
): SkillWindow[] {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const windows: SkillWindow[] = [];
for (let activation = 1; ; activation += 1) {
const startSeconds = ceilToStep(activation * interval, stepSeconds);
if (startSeconds > durationSeconds + EPSILON) break;
windows.push({
cardId: card.id,
startSeconds,
endSeconds: Math.min(durationSeconds, startSeconds + card.active.durationSeconds),
scoreUpPercent: card.active.scoreUpPercent
});
}
return windows;
}
export function buildSpecialWindows(cardsInOrder: readonly OptimizerCard[], chart: SongChartDensity): SkillWindow[] {
return cardsInOrder.slice(0, chart.specialStartSeconds.length).flatMap((card, slot) => {
const startSeconds = chart.specialStartSeconds[slot];
if (startSeconds === null || startSeconds === undefined) return [];
return [{
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds),
scoreUpPercent: card.special.scoreUpPercent
}];
});
}
function strongestWindowAt(windows: readonly SkillWindow[], timeSeconds: number): SkillWindow | null {
let strongest: SkillWindow | null = null;
for (const window of windows) {
if (window.startSeconds <= timeSeconds && timeSeconds < window.endSeconds) {
if (!strongest || window.scoreUpPercent > strongest.scoreUpPercent) strongest = window;
}
}
return strongest;
}
export function simulateOrder(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: SongChartDensity,
stepSeconds = 0.1
): SimulationResult {
const activeWindows = cardsInOrder.flatMap((card) =>
buildActiveWindows(card, reductions[card.id] ?? 0, chart.durationSeconds, stepSeconds)
);
const specialWindows = buildSpecialWindows(cardsInOrder, chart);
const timeline: TimelinePoint[] = [];
let scoreWeight = 0;
const tickCount = Math.ceil(chart.durationSeconds / stepSeconds);
for (let tick = 0; tick < tickCount; tick += 1) {
const timeSeconds = tick * stepSeconds;
const second = Math.floor(timeSeconds);
const noteWeight = (chart.notesPerSecond[second] ?? 0) * Math.min(stepSeconds, chart.durationSeconds - timeSeconds);
const active = strongestWindowAt(activeWindows, timeSeconds);
const special = strongestWindowAt(specialWindows, timeSeconds);
const activeScoreUpPercent = active?.scoreUpPercent ?? 0;
const specialScoreUpPercent = special?.scoreUpPercent ?? 0;
const pointScoreWeight = noteWeight * activeScoreUpPercent * (1 + specialScoreUpPercent / 100);
scoreWeight += pointScoreWeight;
timeline.push({
timeSeconds,
noteWeight,
activeCardId: active?.cardId ?? null,
activeScoreUpPercent,
specialCardId: special?.cardId ?? null,
specialScoreUpPercent,
scoreWeight: pointScoreWeight
});
}
return {
scoreWeight,
order: cardsInOrder.map((card) => card.id),
cooldownReductions: { ...reductions },
activeWindows,
specialWindows,
timeline
};
}
+130
View File
@@ -0,0 +1,130 @@
export const COOLDOWN_REDUCTIONS = [0, 4, 8, 12] as const;
export type CooldownReduction = (typeof COOLDOWN_REDUCTIONS)[number];
export type OptimizerCard = {
id: string;
name: string;
type?: string;
generation?: string;
active: {
cooldownSeconds: number;
durationSeconds: number;
scoreUpPercent: number;
probabilityPercent?: number;
};
special: {
durationSeconds: number;
scoreUpPercent: number;
activationRateUpPercent?: number;
};
scoreSupportPercent?: number;
passiveScoreSupportEffects?: readonly {
percent: number;
target: 'all' | 'self' | 'group';
targetGroup?: string;
targetCount?: number;
}[];
allowedCooldownReductions?: readonly CooldownReduction[];
};
export type SongChartDensity = {
songId: string;
difficulty: string;
durationSeconds: number;
notesPerSecond: readonly number[];
specialStartSeconds: readonly (number | null)[];
};
export type SkillWindow = {
cardId: string;
startSeconds: number;
endSeconds: number;
scoreUpPercent: number;
};
export type EffectiveSkillWindow = SkillWindow & {
baseScoreUpPercent: number;
overlapCount: number;
};
export type TimelinePoint = {
timeSeconds: number;
noteWeight: number;
activeCardId: string | null;
activeScoreUpPercent: number;
specialCardId: string | null;
specialScoreUpPercent: number;
scoreWeight: number;
};
export type SimulationResult = {
scoreWeight: number;
order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[];
specialWindows: readonly SkillWindow[];
timeline: readonly TimelinePoint[];
};
export type OptimizationProgress = {
evaluatedCandidates: number;
totalCandidates: number;
};
export type OptimizeOrderOptions = {
stepSeconds?: number;
onProgress?: (progress: OptimizationProgress) => void;
};
export type ExactChartNote = {
timeSeconds: number;
type: 'regular' | 'mid';
isFlick: boolean;
};
export type ExactSongChart = {
songId: string;
difficulty: string;
durationSeconds: number;
scoreRatio: number | null;
notes: readonly ExactChartNote[];
specialStartSeconds: readonly (number | null)[];
singerType?: string | null;
singerCharacterIds?: readonly string[];
};
export type NoteScorePoint = {
timeSeconds: number;
combo: number;
expectedScore: number;
theoreticalMaxScore: number;
baselineScore: number;
};
export type ExactScoreResult = {
expectedScore: number;
theoreticalMaxScore: number;
baselineScore: number;
perfectNoteScore: number;
scoreBySecond: readonly number[];
notes: readonly NoteScorePoint[];
};
export type ExactScoreOptions = {
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};
export type ExactOptimizationResult = ExactScoreResult & {
order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[];
effectiveActiveWindows: readonly EffectiveSkillWindow[];
specialWindows: readonly SkillWindow[];
evaluatedCandidates: number;
};
export type ExactOptimizeOptions = ExactScoreOptions & {
onProgress?: (progress: OptimizationProgress) => void;
};
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateUnitPower, type UnitPowerCard } from './unit-power.ts';
function card(id: string, passive: string, awakenedPassive = passive): UnitPowerCard {
return {
id,
characterId: id,
name: id,
type: '큐트',
generation: '0기생',
power: { maxLevel: 80, statsAtMaxLevel: { performance: 100, technique: 100, sense: 100 } },
details: { passive: [{ level: 1, description: passive }, { level: 2, description: awakenedPassive }] }
};
}
test('최대 레벨 기본 스탯, 개화 보너스와 패시브로 종합력을 계산한다', () => {
const result = calculateUnitPower([
{ card: card('a', '0기생(이)가 2명 이상이면 자신의 전체 파라미터가 20% UP'), awakening: 5 },
...['b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 5 }))
]);
assert.equal(result.baseParameterTotal, 1500);
assert.equal(result.potentialTotal, 150);
assert.equal(result.passiveTotal, 66);
assert.equal(result.overallPower, 1716);
});
test('패시브 2레벨은 4개화부터 적용한다', () => {
const passiveCard = card('a', '자신의 전체 파라미터가 10% UP', '자신의 전체 파라미터가 20% UP');
const otherCards = ['b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 }));
const before = calculateUnitPower([{ card: passiveCard, awakening: 3 }, ...otherCards]);
const after = calculateUnitPower([{ card: passiveCard, awakening: 4 }, ...otherCards]);
assert.equal(before.passiveTotal, 33);
assert.equal(after.passiveTotal, 66);
});
test('편성 조건을 만족한 리더 의상 효과를 종합력에 더한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 })),
{ name: '테스트 의상', description: '0기생(이)가 2명 이상이면 전원의 전체 파라미터가 50% UP' }
);
assert.equal(result.outfitTotal, 750);
assert.equal(result.overallPower, 2250);
});
test('보유 메모리 효과는 개화 적용 후 각 멤버의 능력치마다 올림한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 2 })),
null,
null,
46
);
assert.equal(result.potentialTotal, 150);
assert.equal(result.memoryTotal, 90);
assert.equal(result.overallPower, 1740);
});
test('멤버 강화는 메모리를 제외한 파라미터·패시브·의상·보드 합계에 적용한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 })),
null,
null,
100,
10
);
assert.equal(result.memoryTotal, 150);
assert.equal(result.memberPowerUpTotal, 150);
assert.equal(result.overallPower, 1800);
});
test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는다', () => {
const fubuki = {
...card('fubuki', '1기생(이)가 2명 이상이면 1기생2명의 퍼포먼스가 10% UP'),
generation: 'hololive 1기생 / GAMERS'
};
const matsuri = {
...card('matsuri', ''),
generation: 'hololive 1기생'
};
const result = calculateUnitPower([
{ card: fubuki, awakening: 0 },
{ card: matsuri, awakening: 0 },
...['c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 }))
]);
assert.equal(result.passiveTotal, 20);
});
+169
View File
@@ -0,0 +1,169 @@
import { plainSkillText, skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts';
export type UnitStat = 'performance' | 'technique' | 'sense';
export type UnitPowerCard = {
id: string;
characterId: string;
name: string;
type: string;
generation: string;
power: {
maxLevel: number;
statsAtMaxLevel: Record<UnitStat, number>;
};
details: {
passive: readonly { level: number; description: string }[];
};
};
export type UnitPowerMember = {
card: UnitPowerCard;
awakening: number;
};
export type UnitPowerResult = {
overallPower: number;
baseParameterTotal: number;
potentialTotal: number;
passiveTotal: number;
outfitTotal: number;
boardTotal: number;
memoryTotal: number;
memberPowerUpTotal: number;
outfitScoreSupportPercent: number;
};
export type UnitPowerOutfit = {
name: string;
description: string;
};
export type UnitPowerBoardEffects = {
flatByCardId: Record<string, Record<UnitStat, number>>;
rateByCardId: Record<string, Record<UnitStat, number>>;
};
const stats: readonly UnitStat[] = ['performance', 'technique', 'sense'];
function selectedPassive(member: UnitPowerMember): string {
const level = member.awakening >= 4 ? 2 : 1;
return plainSkillText(member.card.details.passive.find((entry) => entry.level === level)?.description ?? '');
}
function conditionSatisfied(description: string, members: readonly UnitPowerMember[]): boolean {
const match = description.match(/^(.+?\d+명 이상이면)/);
if (!match) return true;
return skillConditionSatisfied(match[1], members.map(({ card }) => card));
}
function affectedStats(description: string): readonly UnitStat[] {
if (description.includes('전체 파라미터')) return stats;
if (description.includes('퍼포먼스')) return ['performance'];
if (description.includes('테크닉')) return ['technique'];
if (description.includes('센스')) return ['sense'];
return [];
}
function effectRate(description: string): number {
const matches = [...description.matchAll(/(\d+(?:\.\d+)?)%\s*UP/g)];
return Number(matches.at(-1)?.[1] ?? 0);
}
function targetIndexes(
description: string,
sourceIndex: number,
stat: UnitStat,
members: readonly UnitPowerMember[],
parameters: readonly Record<UnitStat, number>[]
): number[] {
if (description.includes('자신의')) return [sourceIndex];
if (description.includes('전원의')) return members.map((_, index) => index);
const effectText = description.includes('이면')
? description.slice(description.lastIndexOf('이면') + 2).trim()
: description;
const target = effectText.match(/^(.+?)\s*(\d+)명의\s*(?:전체 파라미터|퍼포먼스|테크닉|센스)/);
if (!target) return [];
const count = Number(target[2]);
return members
.map((member, index) => ({ member, index }))
.filter(({ member }) => skillTargetBelongsTo(member.card, target[1]))
.sort((left, right) => parameters[right.index][stat] - parameters[left.index][stat] || left.index - right.index)
.slice(0, count)
.map(({ index }) => index);
}
export function calculateUnitPower(
members: readonly UnitPowerMember[],
outfit?: UnitPowerOutfit | null,
board?: UnitPowerBoardEffects | null,
memoryPermil = 0,
memberPowerUpPercent = 0
): UnitPowerResult {
if (members.length !== 5) throw new Error('유닛 종합력 계산에는 카드 5장이 필요합니다.');
const baseParameters = members.map(({ card }) => ({ ...card.power.statsAtMaxLevel }));
const parameters = baseParameters.map((base, index) => {
const potentialRate = members[index].awakening >= 2 ? 10 : 0;
return Object.fromEntries(stats.map((stat) => [stat, Math.ceil(base[stat] * (100 + potentialRate) / 100)])) as Record<UnitStat, number>;
});
const passiveRates = members.map(() => ({ performance: 0, technique: 0, sense: 0 }));
members.forEach((member, sourceIndex) => {
const description = selectedPassive(member);
if (!conditionSatisfied(description, members)) return;
const rate = effectRate(description);
for (const stat of affectedStats(description)) {
for (const targetIndex of targetIndexes(description, sourceIndex, stat, members, parameters)) {
passiveRates[targetIndex][stat] += rate;
}
}
});
const baseParameterTotal = baseParameters.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const parameterTotal = parameters.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const passiveBonuses = parameters.map((member, index) => Object.fromEntries(stats.map((stat) => [
stat,
Math.ceil(member[stat] * passiveRates[index][stat] / 100)
])) as Record<UnitStat, number>);
const passiveTotal = passiveBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const outfitDescription = plainSkillText(outfit?.description ?? '');
const outfitActive = conditionSatisfied(outfitDescription, members);
const outfitRate = outfitActive ? effectRate(outfitDescription) : 0;
const outfitStats = outfitActive ? affectedStats(outfitDescription) : [];
const outfitBonuses = parameters.map((member) => Object.fromEntries(stats.map((stat) => [
stat,
outfitStats.includes(stat) ? Math.ceil(member[stat] * outfitRate / 100) : 0
])) as Record<UnitStat, number>);
const outfitTotal = outfitBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const supportMatch = outfitDescription.match(/스코어 서포트 효과\s*(\d+(?:\.\d+)?)%/);
const outfitScoreSupportPercent = outfitActive ? Number(supportMatch?.[1] ?? 0) : 0;
const boardBonuses = parameters.map((member, index) => Object.fromEntries(stats.map((stat) => {
const cardId = members[index].card.id;
const flat = board?.flatByCardId[cardId]?.[stat] ?? 0;
const rate = board?.rateByCardId[cardId]?.[stat] ?? 0;
return [stat, flat + Math.ceil(member[stat] * rate / 100)];
})) as Record<UnitStat, number>);
const boardTotal = boardBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const memoryTotal = parameters.reduce((total, member) => total + stats.reduce(
(sum, stat) => sum + Math.ceil(member[stat] * memoryPermil / 1000),
0
), 0);
const memberPowerUpTotal = parameters.reduce((total, member, index) => {
const memberBase = stats.reduce((sum, stat) => sum + member[stat] + passiveBonuses[index][stat] + outfitBonuses[index][stat] + boardBonuses[index][stat], 0);
return total + Math.ceil(memberBase * memberPowerUpPercent / 100);
}, 0);
return {
overallPower: parameterTotal + passiveTotal + outfitTotal + boardTotal + memoryTotal + memberPowerUpTotal,
baseParameterTotal,
potentialTotal: parameterTotal - baseParameterTotal,
passiveTotal,
outfitTotal,
boardTotal,
memoryTotal,
memberPowerUpTotal,
outfitScoreSupportPercent
};
}