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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
{
"schemaVersion": 1,
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c",
"maxCount": 28,
"effects": [
{
"count": 1,
"permil": 2
},
{
"count": 2,
"permil": 4
},
{
"count": 3,
"permil": 6
},
{
"count": 4,
"permil": 8
},
{
"count": 5,
"permil": 10
},
{
"count": 6,
"permil": 12
},
{
"count": 7,
"permil": 14
},
{
"count": 8,
"permil": 16
},
{
"count": 9,
"permil": 18
},
{
"count": 10,
"permil": 20
},
{
"count": 11,
"permil": 22
},
{
"count": 12,
"permil": 24
},
{
"count": 13,
"permil": 26
},
{
"count": 14,
"permil": 28
},
{
"count": 15,
"permil": 30
},
{
"count": 16,
"permil": 32
},
{
"count": 17,
"permil": 34
},
{
"count": 18,
"permil": 36
},
{
"count": 19,
"permil": 38
},
{
"count": 20,
"permil": 40
},
{
"count": 21,
"permil": 42
},
{
"count": 22,
"permil": 44
},
{
"count": 23,
"permil": 46
},
{
"count": 24,
"permil": 48
},
{
"count": 25,
"permil": 50
},
{
"count": 26,
"permil": 52
},
{
"count": 27,
"permil": 54
},
{
"count": 28,
"permil": 56
},
{
"count": 29,
"permil": 58
},
{
"count": 30,
"permil": 60
},
{
"count": 31,
"permil": 61
},
{
"count": 32,
"permil": 62
},
{
"count": 33,
"permil": 63
},
{
"count": 34,
"permil": 64
},
{
"count": 35,
"permil": 65
},
{
"count": 36,
"permil": 66
},
{
"count": 37,
"permil": 67
},
{
"count": 38,
"permil": 68
},
{
"count": 39,
"permil": 69
},
{
"count": 40,
"permil": 70
},
{
"count": 41,
"permil": 71
},
{
"count": 42,
"permil": 72
},
{
"count": 43,
"permil": 73
},
{
"count": 44,
"permil": 74
},
{
"count": 45,
"permil": 75
},
{
"count": 46,
"permil": 76
},
{
"count": 47,
"permil": 77
},
{
"count": 48,
"permil": 78
},
{
"count": 49,
"permil": 79
},
{
"count": 50,
"permil": 80
}
]
}
File diff suppressed because it is too large Load Diff
-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
};
}
+53
View File
@@ -0,0 +1,53 @@
export type CollectionCardState = {
owned: boolean;
awakening: number;
};
export type SavedDeck = {
id: string;
name: string;
cardIds: string[];
settings: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
export type CardListPreferences = {
types: string[];
stats: string[];
activeConditions: string[];
specialConditions: string[];
activeProbabilities: string[];
cardVariants: string[];
generations: string[];
};
export type BoardCardPlacements = Record<string, Record<string, string>>;
export type UserDataV1 = {
schemaVersion: 1;
updatedAt: string;
memoryCount: number;
memberPowerUpPercent: number;
collection: Record<string, CollectionCardState>;
memberBoards: Record<string, string[]>;
boardCards: BoardCardPlacements;
savedDecks: SavedDeck[];
preferences: CardListPreferences;
};
export type UserData = UserDataV1;
export function createUserData(preferences: CardListPreferences): UserDataV1 {
return {
schemaVersion: 1,
updatedAt: new Date().toISOString(),
memoryCount: 0,
memberPowerUpPercent: 0,
collection: {},
memberBoards: {},
boardCards: {},
savedDecks: [],
preferences
};
}
+100
View File
@@ -0,0 +1,100 @@
import type { CardListPreferences, CollectionCardState, UserData } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data';
export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stringArray(value: unknown, fallback: string[]): string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
? [...value]
: [...fallback];
}
function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function parseCollection(value: Record<string, unknown>): UserData['collection'] {
return Object.fromEntries(
Object.entries(value).flatMap(([cardId, state]) => {
if (!isRecord(state)) return [];
const parsed: CollectionCardState = {
owned: state.owned === true,
awakening: Math.max(0, Math.min(5, Math.round(finiteNumber(state.awakening))))
};
return [[cardId, parsed]];
})
);
}
function parseMemberBoards(value: unknown): UserData['memberBoards'] {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string[]] =>
Array.isArray(entry[1]) && entry[1].every((nodeId) => typeof nodeId === 'string')
)
);
}
function parseBoardCards(value: unknown): UserData['boardCards'] {
if (!isRecord(value)) return {};
return Object.fromEntries(Object.entries(value).flatMap(([memberId, placements]) => {
if (!isRecord(placements)) return [];
return [[memberId, Object.fromEntries(
Object.entries(placements).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)]];
}));
}
export function parseUserData(text: string, fallbackPreferences: CardListPreferences): UserData {
const value: unknown = JSON.parse(text);
if (!isRecord(value) || value.schemaVersion !== 1) {
throw new Error('지원하지 않는 백업 파일입니다.');
}
if (!isRecord(value.preferences) || !isRecord(value.collection) || !Array.isArray(value.savedDecks)) {
throw new Error('올바른 홀로도리 백업 파일이 아닙니다.');
}
const preferences = value.preferences;
const defaults = createUserData(fallbackPreferences);
return {
schemaVersion: 1,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : defaults.updatedAt,
memoryCount: Math.max(0, Math.round(finiteNumber(value.memoryCount))),
memberPowerUpPercent: Math.max(0, Math.min(50, finiteNumber(value.memberPowerUpPercent))),
collection: parseCollection(value.collection),
memberBoards: parseMemberBoards(value.memberBoards),
boardCards: parseBoardCards(value.boardCards),
savedDecks: value.savedDecks as UserData['savedDecks'],
preferences: {
types: stringArray(preferences.types, fallbackPreferences.types),
stats: stringArray(preferences.stats, fallbackPreferences.stats),
activeConditions: stringArray(preferences.activeConditions, fallbackPreferences.activeConditions),
specialConditions: stringArray(preferences.specialConditions, fallbackPreferences.specialConditions),
activeProbabilities: stringArray(preferences.activeProbabilities, fallbackPreferences.activeProbabilities),
cardVariants: stringArray(preferences.cardVariants, fallbackPreferences.cardVariants),
generations: stringArray(preferences.generations, fallbackPreferences.generations)
}
};
}
export function loadBrowserData(fallbackPreferences: CardListPreferences): UserData {
const stored = localStorage.getItem(USER_DATA_STORAGE_KEY);
return stored ? parseUserData(stored, fallbackPreferences) : createUserData(fallbackPreferences);
}
export function saveBrowserData(data: UserData): UserData {
const saved = { ...data, updatedAt: new Date().toISOString() } satisfies UserData;
localStorage.setItem(USER_DATA_STORAGE_KEY, JSON.stringify(saved));
return saved;
}
export function clearBrowserData(): void {
localStorage.removeItem(USER_DATA_STORAGE_KEY);
}
+33
View File
@@ -0,0 +1,33 @@
/// <reference lib="webworker" />
import { optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
type OptimizeRequest = {
type: 'optimize-exact';
cards: OptimizerCard[];
chart: ExactSongChart;
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};
self.onmessage = (event: MessageEvent<OptimizeRequest>) => {
if (event.data.type !== 'optimize-exact') return;
try {
const options: ExactOptimizeOptions = {
perfectNoteScore: event.data.perfectNoteScore,
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
};
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
self.postMessage({ type: 'result', result });
} catch (error) {
self.postMessage({
type: 'error',
message: error instanceof Error ? error.message : '분석 중 알 수 없는 오류가 발생했습니다.'
});
}
};
export {};