feat: complete placement analysis workflow
This commit is contained in:
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
@@ -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
@@ -1,7 +0,0 @@
|
||||
# Calculation engine
|
||||
|
||||
기존 스킬 로스 계산기의 계산 로직을 UI와 분리해 둘 위치입니다.
|
||||
|
||||
- Svelte 컴포넌트나 SvelteKit 서버 모듈을 import하지 않습니다.
|
||||
- 브라우저, Web Worker, Node.js, 단위 테스트에서 공유할 수 있는 순수 TypeScript로 작성합니다.
|
||||
- 공용 데이터 타입은 `src/lib/models`에서 가져옵니다.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}]);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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]]), []);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<header class="site-nav">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="/" aria-label="홀로도리 홈">홀로도리</a>
|
||||
<nav aria-label="주요 메뉴">
|
||||
<a href="/" aria-current={$page.url.pathname === '/' ? 'page' : undefined}>카드 목록</a>
|
||||
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
|
||||
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<slot />
|
||||
|
||||
<style>
|
||||
:global(*) { box-sizing: border-box; }
|
||||
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
|
||||
|
||||
.site-nav {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
||||
background: rgb(16 19 25 / 88%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: min(1120px, calc(100% - 40px));
|
||||
min-height: 58px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: #f3f5f8;
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
letter-spacing: -.03em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
nav a {
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
color: #929cad;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav a:hover { color: #e2e6ed; background: rgb(255 255 255 / 5%); }
|
||||
nav a[aria-current='page'] { color: #f1a1c3; background: rgb(239 139 182 / 12%); }
|
||||
a:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.nav-inner { width: calc(100% - 24px); min-height: 54px; }
|
||||
.brand { font-size: 15px; }
|
||||
nav a { padding: 7px 9px; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
+102
-10
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import cardSummary from '$lib/data/cards-summary.json';
|
||||
import type { CardListPreferences, UserData } from '$lib/models/user-data';
|
||||
import { loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
|
||||
|
||||
type CardType = '큐트' | '퓨어' | '해피';
|
||||
type HighestStat = '퍼포먼스' | '테크닉' | '센스';
|
||||
@@ -58,6 +61,39 @@
|
||||
let selectedGenerations = [...generationOptions];
|
||||
let expandedCardId: string | null = null;
|
||||
let selectedSkillLevel = 1;
|
||||
let collection: UserData['collection'] = {};
|
||||
let memberBoards: UserData['memberBoards'] = {};
|
||||
let boardCards: UserData['boardCards'] = {};
|
||||
let savedDecks: UserData['savedDecks'] = [];
|
||||
let memoryCount = 0;
|
||||
let memberPowerUpPercent = 0;
|
||||
let storageReady = false;
|
||||
let currentUserData: UserData;
|
||||
|
||||
$: currentUserData = {
|
||||
schemaVersion: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
memoryCount,
|
||||
memberPowerUpPercent,
|
||||
collection,
|
||||
memberBoards,
|
||||
boardCards,
|
||||
savedDecks,
|
||||
preferences: currentPreferences()
|
||||
};
|
||||
|
||||
$: if (storageReady) {
|
||||
try {
|
||||
saveBrowserData(currentUserData);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
applyUserData(loadBrowserData(defaultPreferences()));
|
||||
storageReady = true;
|
||||
} catch {}
|
||||
});
|
||||
|
||||
$: filteredCards = cards.filter((card) =>
|
||||
selectedTypes.includes(card.type)
|
||||
@@ -91,6 +127,46 @@
|
||||
selectedGenerations = [...generationOptions];
|
||||
}
|
||||
|
||||
function defaultPreferences(): CardListPreferences {
|
||||
return {
|
||||
types: [...typeOptions],
|
||||
stats: [...statOptions],
|
||||
activeConditions: [...skillConditionOptions],
|
||||
specialConditions: [...skillConditionOptions],
|
||||
activeProbabilities: [...activeProbabilityOptions],
|
||||
cardVariants: [...cardVariantOptions],
|
||||
generations: [...generationOptions]
|
||||
};
|
||||
}
|
||||
|
||||
function currentPreferences(): CardListPreferences {
|
||||
return {
|
||||
types: [...selectedTypes],
|
||||
stats: [...selectedStats],
|
||||
activeConditions: [...selectedActiveConditions],
|
||||
specialConditions: [...selectedSpecialConditions],
|
||||
activeProbabilities: [...selectedActiveProbabilities],
|
||||
cardVariants: [...selectedCardVariants],
|
||||
generations: [...selectedGenerations]
|
||||
};
|
||||
}
|
||||
|
||||
function applyUserData(data: UserData) {
|
||||
selectedTypes = data.preferences.types.filter((value): value is CardType => typeOptions.includes(value as CardType));
|
||||
selectedStats = data.preferences.stats.filter((value): value is HighestStat => statOptions.includes(value as HighestStat));
|
||||
selectedActiveConditions = data.preferences.activeConditions.filter((value): value is SkillConditionStatus => skillConditionOptions.includes(value as SkillConditionStatus));
|
||||
selectedSpecialConditions = data.preferences.specialConditions.filter((value): value is SkillConditionStatus => skillConditionOptions.includes(value as SkillConditionStatus));
|
||||
selectedActiveProbabilities = data.preferences.activeProbabilities.filter((value): value is ActiveProbability => activeProbabilityOptions.includes(value as ActiveProbability));
|
||||
selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value));
|
||||
selectedGenerations = data.preferences.generations.filter((value) => generationOptions.includes(value));
|
||||
collection = data.collection;
|
||||
memberBoards = data.memberBoards;
|
||||
boardCards = data.boardCards;
|
||||
savedDecks = data.savedDecks;
|
||||
memoryCount = data.memoryCount;
|
||||
memberPowerUpPercent = data.memberPowerUpPercent;
|
||||
}
|
||||
|
||||
function cardVariantStatus(card: CardSummary): string {
|
||||
return card.versionName || '일반';
|
||||
}
|
||||
@@ -192,14 +268,16 @@
|
||||
<h1>카드 목록</h1>
|
||||
<p class="description">카드의 기본 정보를 한눈에 확인하세요.</p>
|
||||
</div>
|
||||
<p class="total">
|
||||
<strong>{filteredCards.length}</strong>
|
||||
{#if filteredCards.length === cards.length}
|
||||
장
|
||||
{:else}
|
||||
<span>/ {cards.length}장</span>
|
||||
{/if}
|
||||
</p>
|
||||
<div class="header-side">
|
||||
<p class="total">
|
||||
<strong>{filteredCards.length}</strong>
|
||||
{#if filteredCards.length === cards.length}
|
||||
장
|
||||
{:else}
|
||||
<span>/ {cards.length}장</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filters" aria-labelledby="filter-heading">
|
||||
@@ -547,7 +625,7 @@
|
||||
main {
|
||||
width: min(1120px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 64px 0 80px;
|
||||
padding: 36px 0 80px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -601,6 +679,13 @@
|
||||
color: #8a92a2;
|
||||
}
|
||||
|
||||
.header-side {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
padding: 22px 24px 24px;
|
||||
@@ -1321,6 +1406,7 @@
|
||||
color: #747e90;
|
||||
}
|
||||
|
||||
|
||||
.filters {
|
||||
border-color: rgb(255 255 255 / 8%);
|
||||
background: rgb(22 28 39 / 84%);
|
||||
@@ -1511,6 +1597,7 @@
|
||||
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -1518,7 +1605,12 @@
|
||||
}
|
||||
|
||||
.total {
|
||||
margin-top: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-side {
|
||||
width: 100%;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.filters {
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import cardSummary from '$lib/data/cards-summary.json';
|
||||
import boardSummary from '$lib/data/boards-summary.json';
|
||||
import memorySummary from '$lib/data/memory-summary.json';
|
||||
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
||||
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
|
||||
type CardRow = (typeof cardSummary.cards)[number];
|
||||
type DifficultyKey = 'hard' | 'expert';
|
||||
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
||||
type MemberBoards = Record<string, string[]>;
|
||||
type BoardCards = Record<string, Record<string, string>>;
|
||||
type DisplayEffectiveWindow = SkillWindow & {
|
||||
baseScoreUpPercent: number;
|
||||
minScoreUpPercent: number;
|
||||
maxScoreUpPercent: number;
|
||||
maxOverlapCount: number;
|
||||
};
|
||||
type SavedAnalysisState = {
|
||||
schemaVersion: 2;
|
||||
songId: string;
|
||||
difficulty: DifficultyKey;
|
||||
cardIds: string[];
|
||||
awakenings: number[];
|
||||
fixedCooldownReductions?: (CooldownReduction | null)[];
|
||||
leaderCharacterId: string;
|
||||
leaderOutfitCardId: string;
|
||||
};
|
||||
|
||||
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||
const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
|
||||
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
|
||||
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
|
||||
|
||||
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
|
||||
const charts = exactChartCatalog.charts;
|
||||
const songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
|
||||
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
|
||||
|
||||
function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] {
|
||||
const memberIds = new Set<string>();
|
||||
return cards.filter((card) => {
|
||||
if (memberIds.has(card.characterId)) return false;
|
||||
memberIds.add(card.characterId);
|
||||
return true;
|
||||
}).slice(0, limit);
|
||||
}
|
||||
|
||||
const leaderMembers = uniqueMemberCards(fiveStarCards);
|
||||
|
||||
let collection: CollectionState = {};
|
||||
let memberBoards: MemberBoards = {};
|
||||
let boardCards: BoardCards = {};
|
||||
let memoryCount = 0;
|
||||
let memberPowerUpPercent = 0;
|
||||
let selectedSongId = songs[0]?.id ?? '';
|
||||
let difficulty: DifficultyKey = 'expert';
|
||||
let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id);
|
||||
let selectedAwakenings = selectedCardIds.map(() => 5);
|
||||
let fixedCooldownReductions: (CooldownReduction | null)[] = selectedCardIds.map(() => null);
|
||||
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
|
||||
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
|
||||
let worker: Worker | null = null;
|
||||
let running = false;
|
||||
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||
let result: ExactOptimizationResult | null = null;
|
||||
let errorMessage = '';
|
||||
let analysisReady = false;
|
||||
|
||||
$: selectedSong = songs.find((song) => song.id === selectedSongId) ?? songs[0];
|
||||
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
|
||||
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
|
||||
$: leaderOutfitOptions = leaderMember
|
||||
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
|
||||
.filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index)
|
||||
: [];
|
||||
$: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null;
|
||||
$: leaderOutfitDescription = (leaderOutfit?.description ?? '')
|
||||
.replace(/\[[^\]]+\]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
$: selectedSourceCards = selectedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
||||
$: connectionCards = cardSummary.cards
|
||||
.filter((card) => card.details.connect.length > 0)
|
||||
.map((card) => ({
|
||||
id: card.id,
|
||||
awakening: collection[card.id]?.awakening ?? 0,
|
||||
connect: card.details.connect
|
||||
}));
|
||||
$: boardEffects = calculateBoardEffects(boardSummary, memberBoards, selectedSourceCards, leaderCharacterId, selectedChart, {
|
||||
placements: boardCards,
|
||||
cards: connectionCards
|
||||
});
|
||||
$: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects);
|
||||
$: unitPower = (() => {
|
||||
try {
|
||||
return calculateUnitPower(selectedCardIds.map((id, index) => ({
|
||||
card: cardById.get(id)!,
|
||||
awakening: selectedAwakenings[index]
|
||||
})), leaderOutfit, boardEffects, memoryPermil, memberPowerUpPercent);
|
||||
} catch {
|
||||
return { overallPower: 0, baseParameterTotal: 0, potentialTotal: 0, passiveTotal: 0, outfitTotal: 0, boardTotal: 0, memoryTotal: 0, memberPowerUpTotal: 0, outfitScoreSupportPercent: 0 };
|
||||
}
|
||||
})();
|
||||
$: overallPower = unitPower.overallPower;
|
||||
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
|
||||
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
|
||||
$: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : [];
|
||||
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
|
||||
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
|
||||
$: timelineTicks = buildTimelineTicks(timelineDuration);
|
||||
$: timelineWidth = Math.max(720, timelineDuration * 6);
|
||||
$: candidateCount = (() => {
|
||||
fixedCooldownReductions;
|
||||
try {
|
||||
const sourceCards = selectedSourceCards;
|
||||
if (new Set(sourceCards.map((card) => card.characterId)).size !== 5) return 0;
|
||||
const cards = sourceCards.map((card, index) => optimizerCardWithBoard(card, index, sourceCards));
|
||||
return cards.length === 5 ? countOptimizationCandidates(cards) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
$: if (analysisReady) saveAnalysisState({
|
||||
schemaVersion: 2,
|
||||
songId: selectedSongId,
|
||||
difficulty,
|
||||
cardIds: selectedCardIds,
|
||||
awakenings: selectedAwakenings,
|
||||
fixedCooldownReductions,
|
||||
leaderCharacterId,
|
||||
leaderOutfitCardId
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const applyStoredUserData = (stored: string | null) => {
|
||||
const parsed = stored ? JSON.parse(stored) : null;
|
||||
collection = parsed?.collection && typeof parsed.collection === 'object' ? parsed.collection : {};
|
||||
memberBoards = parsed?.memberBoards && typeof parsed.memberBoards === 'object' ? parsed.memberBoards : {};
|
||||
boardCards = parsed?.boardCards && typeof parsed.boardCards === 'object' ? parsed.boardCards : {};
|
||||
memoryCount = typeof parsed?.memoryCount === 'number' && Number.isFinite(parsed.memoryCount)
|
||||
? Math.max(0, Math.min(memorySummary.maxCount, Math.round(parsed.memoryCount)))
|
||||
: 0;
|
||||
memberPowerUpPercent = typeof parsed?.memberPowerUpPercent === 'number' && Number.isFinite(parsed.memberPowerUpPercent)
|
||||
? Math.max(0, Math.min(50, parsed.memberPowerUpPercent))
|
||||
: 0;
|
||||
return parsed;
|
||||
};
|
||||
try {
|
||||
const parsed = applyStoredUserData(localStorage.getItem(USER_DATA_STORAGE_KEY));
|
||||
const owned = uniqueMemberCards(fiveStarCards.filter((card) => collection[card.id]?.owned), 5);
|
||||
if (owned.length === 5) {
|
||||
selectedCardIds = owned.map((card) => card.id);
|
||||
selectedAwakenings = owned.map((card) => collection[card.id]?.awakening ?? 0);
|
||||
}
|
||||
} catch {
|
||||
collection = {};
|
||||
memberBoards = {};
|
||||
boardCards = {};
|
||||
memoryCount = 0;
|
||||
memberPowerUpPercent = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentState = localStorage.getItem(ANALYSIS_STORAGE_KEY);
|
||||
const legacyState = currentState ? null : localStorage.getItem(LEGACY_ANALYSIS_STORAGE_KEY);
|
||||
restoreAnalysisState(currentState ?? legacyState, Boolean(legacyState));
|
||||
if (legacyState) localStorage.removeItem(LEGACY_ANALYSIS_STORAGE_KEY);
|
||||
} catch {
|
||||
localStorage.removeItem(ANALYSIS_STORAGE_KEY);
|
||||
} finally {
|
||||
analysisReady = true;
|
||||
}
|
||||
|
||||
worker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.type === 'progress') progress = event.data.progress;
|
||||
if (event.data.type === 'result') {
|
||||
result = event.data.result;
|
||||
running = false;
|
||||
}
|
||||
if (event.data.type === 'error') {
|
||||
errorMessage = event.data.message;
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
const syncStorage = (event: StorageEvent) => {
|
||||
if (event.key !== USER_DATA_STORAGE_KEY) return;
|
||||
try {
|
||||
applyStoredUserData(event.newValue);
|
||||
result = null;
|
||||
} catch {
|
||||
// 다른 탭에서 쓰기가 끝나지 않은 순간에는 기존 상태를 유지한다.
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', syncStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', syncStorage);
|
||||
worker?.terminate();
|
||||
};
|
||||
});
|
||||
|
||||
function cardLabel(card: CardRow, awakening?: number): string {
|
||||
const variant = card.versionName ? ` · ${card.versionName}` : '';
|
||||
const growth = awakening === undefined ? '' : ` · ${awakening === 0 ? '명함' : `${awakening}개화`}`;
|
||||
return `${card.name}${variant}${growth}`;
|
||||
}
|
||||
|
||||
function saveAnalysisState(state: SavedAnalysisState): void {
|
||||
try {
|
||||
localStorage.setItem(ANALYSIS_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// 저장소 사용이 차단된 환경에서도 분석 기능은 계속 동작한다.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAwakening(value: unknown): number {
|
||||
const awakening = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0;
|
||||
return Math.max(0, Math.min(5, awakening));
|
||||
}
|
||||
|
||||
function restoreAnalysisState(stored: string | null, legacy = false): void {
|
||||
if (!stored) return;
|
||||
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
|
||||
if (!legacy && state.schemaVersion !== 2) return;
|
||||
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
|
||||
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
|
||||
|
||||
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
|
||||
const restoredCards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
||||
if (restoredCards.length === 5 && new Set(restoredCards.map((card) => card.characterId)).size === 5) {
|
||||
selectedCardIds = restoredCards.map((card) => card.id);
|
||||
selectedAwakenings = restoredCards.map((card, index) => {
|
||||
if (legacy && collection[card.id]?.owned) return normalizeAwakening(collection[card.id]?.awakening);
|
||||
return normalizeAwakening(state.awakenings?.[index]);
|
||||
});
|
||||
fixedCooldownReductions = restoredCards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
|
||||
leaderCharacterId = state.leaderCharacterId;
|
||||
const validOutfit = typeof state.leaderOutfitCardId === 'string'
|
||||
? cardById.get(state.leaderOutfitCardId)
|
||||
: undefined;
|
||||
leaderOutfitCardId = validOutfit?.characterId === leaderCharacterId && validOutfit.details.costume
|
||||
? validOutfit.id
|
||||
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateAwakening(slot: number, awakening: number): void {
|
||||
const normalized = normalizeAwakening(awakening);
|
||||
selectedAwakenings = selectedAwakenings.map((value, index) => index === slot ? normalized : value);
|
||||
result = null;
|
||||
}
|
||||
|
||||
function normalizeFixedReduction(value: unknown): CooldownReduction | null {
|
||||
return value === 0 || value === 4 || value === 8 || value === 12 ? value : null;
|
||||
}
|
||||
|
||||
function updateFixedReduction(slot: number, reduction: CooldownReduction | null): void {
|
||||
fixedCooldownReductions = fixedCooldownReductions.map((value, index) => index === slot ? reduction : value);
|
||||
result = null;
|
||||
}
|
||||
|
||||
function memberSelectedInAnotherSlot(card: CardRow, slot: number): boolean {
|
||||
return selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId);
|
||||
}
|
||||
|
||||
function updateCard(slot: number, cardId: string): void {
|
||||
selectedCardIds = selectedCardIds.map((id, index) => (index === slot ? cardId : id));
|
||||
if (collection[cardId]?.owned) updateAwakening(slot, normalizeAwakening(collection[cardId]?.awakening));
|
||||
result = null;
|
||||
}
|
||||
|
||||
function updateLeader(characterId: string): void {
|
||||
leaderCharacterId = characterId;
|
||||
const outfit = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume);
|
||||
leaderOutfitCardId = outfit?.id ?? '';
|
||||
result = null;
|
||||
}
|
||||
|
||||
function updateOutfit(cardId: string): void {
|
||||
leaderOutfitCardId = cardId;
|
||||
result = null;
|
||||
}
|
||||
|
||||
function optimizerCardWithBoard(card: CardRow, index: number, unitMembers: readonly CardRow[]) {
|
||||
const fixedReduction = fixedCooldownReductions[index];
|
||||
const adapted = toOptimizerCard(
|
||||
card,
|
||||
selectedAwakenings[index] ?? 0,
|
||||
fixedReduction === null || fixedReduction === undefined
|
||||
? boardEffects.allowedCooldownReductionsByCardId[card.id]
|
||||
: [fixedReduction],
|
||||
unitMembers
|
||||
);
|
||||
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
|
||||
return {
|
||||
...adapted,
|
||||
active: {
|
||||
...adapted.active,
|
||||
probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100)
|
||||
},
|
||||
scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
||||
};
|
||||
}
|
||||
|
||||
async function analyze(): Promise<void> {
|
||||
errorMessage = '';
|
||||
result = null;
|
||||
if (!worker || !selectedSong || !selectedChart) return;
|
||||
if (new Set(selectedCardIds).size !== 5) {
|
||||
errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
const selectedCards = selectedCardIds.map((id) => cardById.get(id));
|
||||
if (new Set(selectedCards.map((card) => card?.characterId)).size !== 5) {
|
||||
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 편성할 수 없습니다.';
|
||||
return;
|
||||
}
|
||||
const unitMembers = selectedCards.filter((card): card is CardRow => Boolean(card));
|
||||
|
||||
try {
|
||||
const cards = selectedCardIds.map((id, index) => {
|
||||
const card = cardById.get(id);
|
||||
if (!card) throw new Error('선택한 카드 정보를 찾을 수 없습니다.');
|
||||
return optimizerCardWithBoard(card, index, unitMembers);
|
||||
});
|
||||
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
|
||||
const response = await fetch(selectedChart.path);
|
||||
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
|
||||
const chart = await response.json() as ExactSongChart;
|
||||
running = true;
|
||||
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
|
||||
worker.postMessage({
|
||||
type: 'optimize-exact',
|
||||
cards,
|
||||
chart,
|
||||
perfectNoteScore,
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
|
||||
});
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
function resultCardName(cardId: string): string {
|
||||
const card = cardById.get(cardId);
|
||||
return card ? cardLabel(card) : cardId;
|
||||
}
|
||||
|
||||
function countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] {
|
||||
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0);
|
||||
for (const note of scoreResult.notes) {
|
||||
const second = Math.floor(note.timeSeconds);
|
||||
if (second >= 0 && second < counts.length) counts[second] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function buildTimelineTicks(durationSeconds: number): number[] {
|
||||
if (durationSeconds <= 0) return [];
|
||||
const interval = 10;
|
||||
const ticks = Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval);
|
||||
if (ticks.at(-1) !== durationSeconds) ticks.push(durationSeconds);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function timelinePosition(seconds: number): number {
|
||||
return timelineDuration > 0 ? Math.max(0, Math.min(100, seconds / timelineDuration * 100)) : 0;
|
||||
}
|
||||
|
||||
function skillWindowStyle(startSeconds: number, endSeconds: number): string {
|
||||
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`;
|
||||
}
|
||||
|
||||
function timelineTrackStyle(): string {
|
||||
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
|
||||
}
|
||||
|
||||
function memberColor(cardId: string): string {
|
||||
const index = result?.order.indexOf(cardId) ?? -1;
|
||||
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
|
||||
}
|
||||
|
||||
function coloredWindowStyle(window: SkillWindow): string {
|
||||
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`;
|
||||
}
|
||||
|
||||
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
|
||||
const merged: DisplayEffectiveWindow[] = [];
|
||||
for (const window of windows) {
|
||||
const previous = merged.at(-1);
|
||||
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
|
||||
previous.endSeconds = window.endSeconds;
|
||||
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
|
||||
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
|
||||
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
|
||||
continue;
|
||||
}
|
||||
merged.push({
|
||||
...window,
|
||||
minScoreUpPercent: window.scoreUpPercent,
|
||||
maxScoreUpPercent: window.scoreUpPercent,
|
||||
maxOverlapCount: window.overlapCount
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
|
||||
const minimum = Number(window.minScoreUpPercent.toFixed(1));
|
||||
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
|
||||
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
|
||||
}
|
||||
|
||||
function formatSeconds(seconds: number): string {
|
||||
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>배치 분석 · 홀로도리</title></svelte:head>
|
||||
|
||||
<main>
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>배치 분석</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel inputs" aria-labelledby="input-title">
|
||||
<div class="section-title">
|
||||
<div><span>01</span><h2 id="input-title">분석 조건</h2></div>
|
||||
<strong>{candidateCount.toLocaleString('ko-KR')}개 후보</strong>
|
||||
</div>
|
||||
|
||||
<div class="song-fields">
|
||||
<label>악곡
|
||||
<select bind:value={selectedSongId} on:change={() => result = null}>
|
||||
{#each songs as song}<option value={song.id}>{song.title}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>난이도
|
||||
<select bind:value={difficulty} on:change={() => result = null}>
|
||||
<option value="expert">Expert</option>
|
||||
<option value="hard">Hard</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점</span>{#if boardEffects.directSongScoreBonusPercent > 0}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%</span>{/if}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-fields">
|
||||
{#each selectedCardIds as selectedId, slot}
|
||||
<div class="card-field">
|
||||
<label><span>{slot + 1}번 선택</span>
|
||||
<select value={selectedId} on:change={(event) => updateCard(slot, event.currentTarget.value)}>
|
||||
{#each fiveStarCards as card}
|
||||
<option value={card.id} disabled={memberSelectedInAnotherSlot(card, slot)}>{collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="awakening">
|
||||
<span>개화</span>
|
||||
<div class="awakening-stepper">
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedAwakenings[slot] <= 0}
|
||||
aria-label={`${slot + 1}번 카드 개화 단계 낮추기`}
|
||||
on:click={() => updateAwakening(slot, selectedAwakenings[slot] - 1)}
|
||||
>‹</button>
|
||||
<strong aria-live="polite">{selectedAwakenings[slot]}</strong>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedAwakenings[slot] >= 5}
|
||||
aria-label={`${slot + 1}번 카드 개화 단계 높이기`}
|
||||
on:click={() => updateAwakening(slot, selectedAwakenings[slot] + 1)}
|
||||
>›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cooldown-lock">
|
||||
<span>빈도 감소 고정</span>
|
||||
<div class="reduction-options">
|
||||
{#each REDUCTION_LOCK_OPTIONS as reduction}
|
||||
<button
|
||||
type="button"
|
||||
class:active={fixedCooldownReductions[slot] === reduction}
|
||||
aria-pressed={fixedCooldownReductions[slot] === reduction}
|
||||
on:click={() => updateFixedReduction(slot, reduction)}
|
||||
>{reduction === null ? '미사용' : `${reduction}%`}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="leader-fields">
|
||||
<label>리더
|
||||
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
|
||||
{#each leaderMembers as member}<option value={member.characterId}>{member.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>리더 의상
|
||||
<select value={leaderOutfitCardId} on:change={(event) => updateOutfit(event.currentTarget.value)}>
|
||||
{#each leaderOutfitOptions as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="outfit-effect">
|
||||
<span>리더 의상 효과</span>
|
||||
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
|
||||
</div>
|
||||
<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>
|
||||
|
||||
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
|
||||
<button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}>
|
||||
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
|
||||
</button>
|
||||
{#if running}<progress value={progress.evaluatedCandidates} max={progress.totalCandidates}></progress>{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel result" aria-labelledby="result-title">
|
||||
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
|
||||
{#if result}
|
||||
<ol class="order-list">
|
||||
{#each result.order as cardId, slot}
|
||||
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="score-grid">
|
||||
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
|
||||
</div>
|
||||
|
||||
<section class="skill-timeline" aria-labelledby="timeline-title">
|
||||
<div class="timeline-heading">
|
||||
<div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
|
||||
<div class="timeline-legend" aria-label="멤버 색상 범례">
|
||||
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{resultCardName(cardId)}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
|
||||
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
|
||||
<div class="timeline-row note-row">
|
||||
<strong class="lane-label">초당 노트</strong>
|
||||
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
|
||||
{#each noteCountsBySecond as count, second}
|
||||
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#each result.order as cardId, slot}
|
||||
<div class="timeline-row member-row">
|
||||
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
|
||||
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
|
||||
<span
|
||||
class="skill-window active-window"
|
||||
style={coloredWindowStyle(window)}
|
||||
title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
|
||||
role="img"
|
||||
aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="timeline-row summary-row">
|
||||
<strong class="lane-label summary-label">최종 액티브</strong>
|
||||
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||
{#each displayedEffectiveActiveWindows as window}
|
||||
<span
|
||||
class="skill-window effective-window"
|
||||
style={coloredWindowStyle(window)}
|
||||
title={`${resultCardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`}
|
||||
role="img"
|
||||
aria-label={`${resultCardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-row summary-row">
|
||||
<strong class="lane-label summary-label">스페셜</strong>
|
||||
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||
{#each result.specialWindows as window}
|
||||
<span
|
||||
class="skill-window special-window"
|
||||
style={coloredWindowStyle(window)}
|
||||
title={`${resultCardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
|
||||
role="img"
|
||||
aria-label={`${resultCardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-row axis-row" aria-hidden="true">
|
||||
<span class="lane-label axis-label"></span>
|
||||
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
|
||||
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main { width: min(1080px, calc(100% - 40px)); margin: 0 auto; padding: 46px 0 80px; }
|
||||
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
|
||||
h1 { margin: 0; color: #f3f5f8; font-size: clamp(28px, 5vw, 42px); letter-spacing: -.045em; }
|
||||
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
|
||||
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.section-title > div { justify-content: flex-start; }
|
||||
.section-title span { color: #ef8cb6; font-size: 11px; font-weight: 900; }
|
||||
h2 { margin: 0; font-size: 18px; }
|
||||
.section-title > strong { color: #aeb6c4; font-size: 12px; }
|
||||
label { display: grid; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
|
||||
select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
|
||||
select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
|
||||
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) minmax(110px, .55fr) minmax(140px, .7fr); align-items: end; gap: 12px; margin-top: 22px; }
|
||||
.power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
|
||||
.power-summary span { color: #929cab; font-size: 10px; font-weight: 750; }
|
||||
.power-summary strong { color: #f2a3c4; font-size: 16px; }
|
||||
.chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; }
|
||||
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 11px; white-space: nowrap; }
|
||||
.card-fields { display: grid; gap: 9px; margin-top: 18px; }
|
||||
.card-field { display: grid; grid-template-columns: minmax(0, 1fr) 130px 304px; gap: 9px; }
|
||||
.card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
|
||||
.card-field > .awakening { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
|
||||
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
|
||||
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
|
||||
.awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; }
|
||||
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
|
||||
.awakening-stepper button:disabled { color: #525b69; background: #171c24; cursor: default; }
|
||||
.awakening-stepper strong { color: #e8ebf1; font-size: 12px; text-align: center; white-space: nowrap; }
|
||||
.cooldown-lock { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
|
||||
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
|
||||
.reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 11px; font-weight: 750; cursor: pointer; }
|
||||
.reduction-options button:first-child { border-left: 0; }
|
||||
.reduction-options button:hover { color: #e9edf3; background: #202630; }
|
||||
.reduction-options button.active { color: #171019; background: #ed8bb5; }
|
||||
.reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; }
|
||||
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; }
|
||||
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
|
||||
.outfit-effect span { color: #d993b1; font-size: 11px; font-weight: 850; }
|
||||
.outfit-effect p { margin: 0; color: #b9c1cd; font-size: 12px; line-height: 1.55; }
|
||||
.power-detail { margin: 10px 0 0; color: #858f9f; font-size: 11px; text-align: right; }
|
||||
.analyze { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
|
||||
.analyze:hover:not(:disabled) { background: #f2a0c2; }
|
||||
.analyze:disabled { opacity: .55; cursor: default; }
|
||||
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
|
||||
.error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; }
|
||||
.order-list { display: grid; gap: 8px; padding: 0; margin: 22px 0 0; list-style: none; }
|
||||
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
|
||||
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
|
||||
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 11px; }
|
||||
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
|
||||
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
|
||||
.score-summary span { color: #a9b1be; font-size: 12px; }
|
||||
.score-summary strong { color: #f2a3c4; font-size: 20px; }
|
||||
.empty { color: #858f9f; font-size: 12px; line-height: 1.65; }
|
||||
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
|
||||
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
|
||||
.timeline-heading h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
|
||||
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 11px; }
|
||||
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 10px; }
|
||||
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
|
||||
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
|
||||
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
|
||||
.timeline-row { display: flex; min-height: 42px; }
|
||||
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
|
||||
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 9px; }
|
||||
.timeline-track { position: relative; flex: none; }
|
||||
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
|
||||
.note-row { min-height: 78px; }
|
||||
.note-track { height: 62px; align-self: center; border-bottom: 1px solid #465062; }
|
||||
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
|
||||
.skill-track { height: 42px; }
|
||||
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
||||
.active-window { top: 14px; height: 13px; opacity: .72; }
|
||||
.summary-row { background: rgb(255 255 255 / 1.5%); }
|
||||
.summary-label { color: #e1e5ec; font-weight: 800; }
|
||||
.effective-window { top: 11px; height: 20px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
|
||||
.special-window { top: 13px; height: 16px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
|
||||
.axis-row { min-height: 28px; border-top: 1px solid #343c49 !important; }
|
||||
.axis-label { color: #778293; font-size: 10px; font-weight: 750; }
|
||||
.time-axis { height: 28px; }
|
||||
.time-axis::before { display: none; }
|
||||
.time-axis span { position: absolute; top: 7px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; }
|
||||
.time-axis span:first-child { transform: none; }
|
||||
.time-axis span:last-child { transform: translateX(-100%); }
|
||||
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; }
|
||||
@media (max-width: 700px) {
|
||||
main { width: calc(100% - 24px); padding-top: 28px; }
|
||||
.page-header { align-items: flex-start; }
|
||||
.panel { padding: 17px; }
|
||||
.song-fields { grid-template-columns: 1fr 100px; }
|
||||
.power-summary { grid-column: 1 / -1; }
|
||||
.chart-meta { grid-column: 1 / -1; height: auto; }
|
||||
.card-field { grid-template-columns: minmax(0, 1fr) 102px; }
|
||||
.card-field > label { grid-template-columns: 58px minmax(0, 1fr); }
|
||||
.card-field > .awakening { grid-template-columns: 1fr; }
|
||||
.awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; }
|
||||
.cooldown-lock { grid-column: 1 / -1; grid-template-columns: 88px minmax(0, 1fr); }
|
||||
.leader-fields { grid-template-columns: 1fr; }
|
||||
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
|
||||
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.reduction { grid-column: 2; justify-self: start; }
|
||||
.score-grid { grid-template-columns: 1fr; }
|
||||
.timeline-heading { align-items: flex-start; flex-direction: column; }
|
||||
.timeline-legend { justify-content: flex-start; }
|
||||
.lane-label { flex-basis: 118px; min-width: 118px; }
|
||||
.timeline-canvas { padding-right: 8px; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import UserDataPage from '../+page.svelte';
|
||||
</script>
|
||||
|
||||
<UserDataPage />
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import UserDataPage from '../+page.svelte';
|
||||
</script>
|
||||
|
||||
<UserDataPage />
|
||||
Reference in New Issue
Block a user