feat: release version 1.0.7

This commit is contained in:
2026-08-21 19:06:55 +09:00
parent 5d29b63f3a
commit 71dfe11bc4
406 changed files with 3086 additions and 1172 deletions
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { effectiveActiveProbability } from './active-probability.ts';
import type { OptimizerCard } from './types.ts';
function card(probabilityPercent: number, activationRateUpPercent = 0): OptimizerCard {
return {
id: 'card',
name: '카드',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent,
activationRateUpPercent
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
}
test('블루 보드와 스페셜 발동률 UP은 배율을 합산한 뒤 기본 발동률에 곱한다', () => {
assert.ok(Math.abs(effectiveActiveProbability(card(55, 30), 40) - 0.935) < 1e-12);
});
test('합산한 발동률은 100%를 넘지 않는다', () => {
assert.equal(effectiveActiveProbability(card(55, 50), 40), 1);
});
+13
View File
@@ -0,0 +1,13 @@
import type { OptimizerCard } from './types.ts';
/**
* 액티브 기본 발동률에 상시 보너스와 발동 시점의 스페셜 보너스를 합산해 적용한다.
*/
export function effectiveActiveProbability(
card: OptimizerCard,
timedActivationRateUpPercent = 0
): number {
const baseProbability = (card.active.probabilityPercent ?? 100) / 100;
const totalRateUpPercent = (card.active.activationRateUpPercent ?? 0) + timedActivationRateUpPercent;
return Math.max(0, Math.min(1, baseProbability * (1 + totalRateUpPercent / 100)));
}
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { optimizeBoardConnectionCards, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
const source: BoardConnectionOptimizerSource = {
members: [{ id: 'member', positionGroupId: 'model' }],
models: {
model: [
{ groupId: 'S-center', x: 0, y: 0 },
{ groupId: 'G-left', x: -1, y: 0 },
{ groupId: 'G-right', x: 1, y: 0 },
{ groupId: 'S-right', x: 3, y: 0 },
{ groupId: 'G-far', x: 4, y: 0 }
]
},
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-right': [{ characterIds: [], type: 'connection', effect: null }],
'G-left': [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 100 } }],
'G-right': [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 300 } }],
'G-far': [{ characterIds: [], type: 'all_member', effect: { type: 'all_parameter_up', value: 200 } }]
}
};
const leftCard = {
id: 'left-card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -1, y: 0 }] }]
};
const rightCard = {
id: 'right-card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
};
test('활성 연결 노드 전체에서 보유 카드의 최대 가중치 배치를 찾는다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard]);
assert.deepEqual(result.placements, {
member: { 'S-center': 'left-card', 'S-right': 'right-card' }
});
assert.equal(result.assignments.length, 2);
});
test('비활성 연결 노드와 비활성 효과 노드는 배치 점수에 포함하지 않는다', () => {
const memberBoards = { member: ['G-left'] };
const before = structuredClone(memberBoards);
const result = optimizeBoardConnectionCards(source, memberBoards, [leftCard, rightCard]);
assert.deepEqual(result.placements, { member: { 'S-center': 'left-card' } });
assert.deepEqual(memberBoards, before);
});
test('5개화 카드는 2레벨 연결 범위와 배율을 사용한다', () => {
const awakened = {
id: 'awakened', awakening: 5,
connect: [
{ level: 1, effectPermilUp: 100, extent: [{ x: -1, y: 0 }] },
{ level: 2, effectPermilUp: 200, extent: [{ x: 1, y: 0 }] }
]
};
const result = optimizeBoardConnectionCards(source, { member: ['G-left', 'G-right'] }, [awakened]);
assert.deepEqual(result.placements, { member: { 'S-center': 'awakened' } });
assert.equal(result.totalWeight, 60_000);
});
test('솔로와 단체 10%를 각각 우선하며 단체는 다른 멤버 보너스도 합산한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [
{ id: 'target', positionGroupId: 'model' },
{ id: 'helper', positionGroupId: 'model' }
],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [
{
characterIds: ['target'], type: 'content',
effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
},
{
characterIds: ['helper'], type: 'content',
effect: {
id: 'music_singer_type-group',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
}
]
}
};
const cards = ['a', 'b'].map((id) => ({
id, awakening: 0,
connect: [{ level: 1, effectPermilUp: 4000, extent: [{ x: 1, y: 0 }] }]
}));
const result = optimizeBoardConnectionCards(
bonusSource,
{ target: ['Y'], helper: ['Y'] },
cards,
{ targetMemberId: 'target', requireSoloSongBonus: true, requireGroupSongBonus: true }
);
assert.equal(result.soloSongBonusPercent, 10);
assert.equal(result.groupSongBonusPercent, 10);
assert.equal(result.assignments.length, 2);
});
test('10%에 부족한 활성 노드만 있으면 가능한 보너스까지만 최적화한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [{ id: 'target', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [{
characterIds: [], type: 'content',
effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
}]
}
};
const result = optimizeBoardConnectionCards(bonusSource, { target: ['Y'] }, [{
id: 'card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}], { targetMemberId: 'target', requireSoloSongBonus: true });
assert.equal(result.soloSongBonusPercent, 4);
assert.deepEqual(result.placements, { target: { S: 'card' } });
});
test('편성 기반 배치 가중치를 제공하면 해당 효율을 우선한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard], {
placementWeight: (card, _memberId, socketId) => card.id === 'right-card' && socketId === 'S-right' ? 10_000 : 1
});
assert.equal(result.placements.member['S-right'], 'right-card');
});
test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard], {
placementWeight: (card, _memberId, socketId) => card.id === 'right-card' && socketId === 'S-right' ? 10_000 : 1,
placementPriorityWeight: (card, _memberId, socketId) => card.id === 'left-card' && socketId === 'S-right' ? 1 : 0
});
assert.equal(result.placements.member['S-right'], 'left-card');
});
test('리더와 보너스 대상이 같으면 악곡 보너스보다 편성 효율을 우선한다', () => {
const prioritySource: BoardConnectionOptimizerSource = {
members: [{ id: 'leader', positionGroupId: 'model' }],
models: { model: [
{ groupId: 'S-center', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 },
{ groupId: 'S-top', x: 0, y: 3 }, { groupId: 'R', x: 1, y: 3 }
] },
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-top': [{ characterIds: [], type: 'connection', effect: null }],
Y: [{ characterIds: [], type: 'content', effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 50
} }],
R: [{ characterIds: [], type: 'leader', effect: { type: 'performance_up', value: 100 } }]
}
};
const singleCard = [{
id: 'card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}];
const result = optimizeBoardConnectionCards(prioritySource, { leader: ['Y', 'S-top', 'R'] }, singleCard, {
targetMemberId: 'leader',
requireSoloSongBonus: true,
prioritizePlacementWeight: true,
placementWeight: (_card, _memberId, socketId) => socketId === 'S-top' ? 100 : 0
});
assert.deepEqual(result.placements, { leader: { 'S-top': 'card' } });
assert.equal(result.soloSongBonusPercent, 5);
});
test('선택한 단체 악곡의 가창자 보드만 단체 보너스에 합산한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [{ id: 'singer', positionGroupId: 'model' }, { id: 'outsider', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [
{ characterIds: ['singer'], type: 'content', effect: { id: 'music_singer_type-group', type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20 } },
{ characterIds: ['outsider'], type: 'content', effect: { id: 'music_singer_type-group', type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 80 } }
]
}
};
const result = optimizeBoardConnectionCards(bonusSource, { singer: ['Y'], outsider: ['Y'] }, [], {
targetMemberId: 'singer', requireGroupSongBonus: true, groupSingerMemberIds: ['singer']
});
assert.equal(result.groupSongBonusPercent, 2);
});
@@ -0,0 +1,226 @@
import { maximumWeightMatching } from './maximum-weight-matching.ts';
type OptimizableBoardEffect = {
id?: string;
type: string;
value: number | null;
trigger?: string | null;
};
type OptimizableBoardNode = {
characterIds: readonly string[];
type: string;
effect: OptimizableBoardEffect | null;
};
type OptimizableBoardPosition = { groupId: string; x: number; y: number };
export type BoardConnectionOptimizerSource = {
members: readonly { id: string; positionGroupId: string }[];
models: Readonly<Record<string, readonly OptimizableBoardPosition[]>>;
nodeGroups: Readonly<Record<string, readonly OptimizableBoardNode[]>>;
};
export type OptimizableConnectionCard = {
id: string;
awakening: number;
connect: readonly {
level: number;
effectPermilUp: number;
extent: readonly { x: number; y: number }[];
}[];
};
export type BoardConnectionAssignment = {
memberId: string;
socketId: string;
cardId: string;
weight: number;
};
export type BoardConnectionOptimizationResult = {
placements: Record<string, Record<string, string>>;
assignments: BoardConnectionAssignment[];
totalWeight: number;
soloSongBonusPercent: number;
groupSongBonusPercent: number;
};
export type BoardConnectionOptimizationOptions = {
targetMemberId?: string;
requireSoloSongBonus?: boolean;
requireGroupSongBonus?: boolean;
placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
prioritizePlacementWeight?: boolean;
groupSingerMemberIds?: readonly string[];
};
function nodeForMember(
source: BoardConnectionOptimizerSource,
groupId: string,
memberId: string
): OptimizableBoardNode | null {
const nodes = source.nodeGroups[groupId] ?? [];
return nodes.find((node) => node.characterIds.includes(memberId))
?? nodes.find((node) => node.characterIds.length === 0)
?? null;
}
function effectValue(effect: OptimizableBoardEffect | null): number {
if (!effect || effect.value === null || effect.trigger === 'work') return 0;
if (effect.type === 'live_active_skill_cool_time_shorten_permil_up') return 0;
// Song bonuses are contextual and are valued only when the user requests
// the corresponding solo/group target below.
if (effect.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return 0;
const value = Math.max(0, Number(effect.value) || 0);
return effect.type.startsWith('all_parameter_up') ? value * 3 : value;
}
function selectedEffect(card: OptimizableConnectionCard) {
const level = card.awakening >= 5 ? 2 : 1;
return card.connect.find((effect) => effect.level === level) ?? card.connect.at(-1) ?? null;
}
function songBonusModes(
effect: OptimizableBoardEffect | null,
ownerId: string,
targetMemberId: string,
groupSingerMemberIds?: readonly string[]
): ('solo' | 'group')[] {
if (!effect?.id || effect.type !== 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return [];
const mode = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
if (mode === 'all') return ['solo', 'group'];
if (mode === 'solo' && ownerId === targetMemberId) return ['solo'];
// A unit song bonus may be supplied by another participating singer's board.
if (mode === 'group' && (!groupSingerMemberIds || groupSingerMemberIds.includes(ownerId))) return ['group'];
return [];
}
/**
* Reassigns owned connection cards across active connection sockets without
* changing the supplied node activation state.
*/
export function optimizeBoardConnectionCards(
source: BoardConnectionOptimizerSource,
memberBoards: Readonly<Record<string, readonly string[]>>,
ownedCards: readonly OptimizableConnectionCard[],
options: BoardConnectionOptimizationOptions = {}
): BoardConnectionOptimizationResult {
const targetMemberId = options.targetMemberId ?? '';
let baseSoloBonus = 0;
let baseGroupBonus = 0;
const slots = source.members.flatMap((member) => {
const positions = source.models[member.positionGroupId] ?? [];
const centerId = positions.find((position) => position.x === 0 && position.y === 0)?.groupId;
const active = new Set(memberBoards[member.id] ?? []);
if (centerId) active.add(centerId);
for (const position of positions) {
if (!active.has(position.groupId)) continue;
const node = nodeForMember(source, position.groupId, member.id);
const value = Math.max(0, Number(node?.effect?.value) || 0);
for (const mode of songBonusModes(node?.effect ?? null, member.id, targetMemberId, options.groupSingerMemberIds)) {
if (mode === 'solo') baseSoloBonus += value;
else baseGroupBonus += value;
}
}
return positions
.filter((position) => active.has(position.groupId) && nodeForMember(source, position.groupId, member.id)?.type === 'connection')
.map((socket) => ({ member, socket, positions, active }));
});
const cards = ownedCards.filter((card) => selectedEffect(card));
const emptyResult = {
placements: {}, assignments: [], totalWeight: 0,
soloSongBonusPercent: Math.min(10, baseSoloBonus / 10),
groupSongBonusPercent: Math.min(10, baseGroupBonus / 10)
};
if (cards.length === 0 || slots.length === 0) return emptyResult;
const soloBoosts = cards.map(() => slots.map(() => 0));
const groupBoosts = cards.map(() => slots.map(() => 0));
const genericWeights = cards.map((card, cardIndex) => slots.map(({ member, socket, positions, active }, slotIndex) => {
const effect = selectedEffect(card);
if (!effect) return 0;
const positionByCoordinate = new Map(positions.map((position) => [`${position.x},${position.y}`, position]));
const coveredValue = effect.extent.reduce((total, offset) => {
const position = positionByCoordinate.get(`${socket.x + offset.x},${socket.y + offset.y}`);
if (!position || !active.has(position.groupId)) return total;
const nodeEffect = nodeForMember(source, position.groupId, member.id)?.effect ?? null;
const value = effectValue(nodeEffect);
const boost = Math.max(0, Number(nodeEffect?.value) || 0) * Math.max(0, effect.effectPermilUp);
for (const mode of songBonusModes(nodeEffect, member.id, targetMemberId, options.groupSingerMemberIds)) {
if (mode === 'solo') soloBoosts[cardIndex][slotIndex] += boost;
else groupBoosts[cardIndex][slotIndex] += boost;
}
return total + value;
}, 0);
const defaultWeight = coveredValue * Math.max(0, effect.effectPermilUp);
return Math.max(0, options.placementWeight?.(card, member.id, socket.groupId) ?? defaultWeight);
}));
const priorityUnits = cards.map((card) => slots.map(({ member, socket }) =>
Math.max(0, Math.round((options.placementPriorityWeight?.(card, member.id, socket.groupId) ?? 0) * 1000))
));
const maximumAssignments = Math.min(cards.length, slots.length);
const genericBudget = Math.max(1, ...genericWeights.flatMap((row) => row)) * maximumAssignments;
const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) =>
priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight
));
const priorityScale = Math.max(1, ...weights.flatMap((row) => row)) * Math.max(cards.length, slots.length) + 1;
const ratios = options.requireSoloSongBonus && options.requireGroupSongBonus
? [1 / 256, 1 / 16, 1 / 4, 1, 4, 16, 256]
: [1];
const priorityMultipliers = options.requireSoloSongBonus || options.requireGroupSongBonus
? [0, 1 / 1_000_000, 1 / 10_000, 1 / 100, 1, 100]
: [0];
const candidates = priorityMultipliers.flatMap((priorityMultiplier) => ratios.map((ratio) => {
const prioritized = weights.map((row, cardIndex) => row.map((weight, slotIndex) => weight + priorityScale * (
(options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0)
+ (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0)
) * priorityMultiplier));
const matches = maximumWeightMatching(prioritized);
const genericWeight = matches.reduce((total, match) => total + weights[match.rowIndex][match.columnIndex], 0);
const soloBonus = baseSoloBonus + matches.reduce((total, match) => total + soloBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const groupBonus = baseGroupBonus + matches.reduce((total, match) => total + groupBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const feasible = (!options.requireSoloSongBonus || soloBonus >= 100)
&& (!options.requireGroupSongBonus || groupBonus >= 100);
const fulfillment = Math.min(
options.requireSoloSongBonus ? soloBonus / 100 : Number.POSITIVE_INFINITY,
options.requireGroupSongBonus ? groupBonus / 100 : Number.POSITIVE_INFINITY
);
const totalFulfillment = (options.requireSoloSongBonus ? Math.min(1, soloBonus / 100) : 0)
+ (options.requireGroupSongBonus ? Math.min(1, groupBonus / 100) : 0);
return { matches, genericWeight, soloBonus, groupBonus, feasible, fulfillment, totalFulfillment };
}));
const best = candidates.sort((left, right) => options.prioritizePlacementWeight
? right.genericWeight - left.genericWeight
|| Number(right.feasible) - Number(left.feasible)
|| right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment
: Number(right.feasible) - Number(left.feasible)
|| right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment
|| right.genericWeight - left.genericWeight)[0];
const matches = best.matches;
const placements: Record<string, Record<string, string>> = {};
const assignments = matches.map((match) => {
const card = cards[match.rowIndex];
const slot = slots[match.columnIndex];
(placements[slot.member.id] ??= {})[slot.socket.groupId] = card.id;
return {
memberId: slot.member.id,
socketId: slot.socket.groupId,
cardId: card.id,
weight: genericWeights[match.rowIndex][match.columnIndex]
};
});
return {
placements,
assignments,
totalWeight: assignments.reduce((total, assignment) => total + assignment.weight, 0),
soloSongBonusPercent: Math.min(10, best.soloBonus / 10),
groupSongBonusPercent: Math.min(10, best.groupBonus / 10)
};
}
+6 -2
View File
@@ -104,7 +104,7 @@ test('같은 기수의 보드 보너스는 카드별로 최대 900까지만 적
assert.equal(result.flatByCardId['card-b'].performance, 0);
});
test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다', () => {
test('복수 소속 카드도 모든 기수 보너스를 합쳐 카드별 900까지만 적용한다', () => {
const multiGroupingSummary: BoardSummarySource = {
nodeGroups: {
'G-20': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }],
@@ -114,7 +114,11 @@ test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다',
};
const multiGroupingCards = [{ id: 'fubuki', characterId: 'fubuki', type: '퓨어', generation: 'hololive 1기생 / GAMERS' }];
const firstGenerationMaxed = calculateBoardEffects(multiGroupingSummary, { account: ['G-20'] }, multiGroupingCards, 'fubuki');
const gamersAdded = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21'] }, multiGroupingCards, 'fubuki');
const result = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21', 'G-22'] }, multiGroupingCards, 'fubuki');
assert.equal(result.flatByCardId.fubuki.performance, 1800);
assert.equal(firstGenerationMaxed.flatByCardId.fubuki.performance, 900);
assert.equal(gamersAdded.flatByCardId.fubuki.performance, 900);
assert.equal(result.flatByCardId.fubuki.performance, 900);
});
+3 -5
View File
@@ -141,7 +141,7 @@ export function calculateBoardEffects(
let scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections);
const generationBonusByCardAndGrouping: Record<string, Record<string, number>> = {};
const generationBonusByCardId: Record<string, number> = {};
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
for (const groupId of activeGroupIds) {
@@ -176,13 +176,11 @@ export function calculateBoardEffects(
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
const effectTargets = targets(effect, ownerId, cards);
if (effect.type === 'all_parameter_up_for_character_grouping') {
const groupingId = effect.targetId ?? 'unknown-grouping';
for (const card of effectTargets) {
if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue;
const cardBonuses = generationBonusByCardAndGrouping[card.id] ??= {};
const currentBonus = cardBonuses[groupingId] ?? 0;
const currentBonus = generationBonusByCardId[card.id] ?? 0;
const appliedValue = Math.max(0, Math.min(normalizedValue, generationBonusCap - currentBonus));
cardBonuses[groupingId] = currentBonus + appliedValue;
generationBonusByCardId[card.id] = currentBonus + appliedValue;
for (const stat of stats) destination[card.id][stat] += appliedValue;
}
continue;
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
type SpecialLevel = {
description: string;
effect: string;
};
type CardSummary = {
cards: Array<{
name: string;
details: { special: SpecialLevel[] };
}>;
};
test('스페셜 설명의 추가 효과가 요약 데이터에서 누락되지 않는다', async () => {
const path = new URL('../data/cards-summary.json', import.meta.url);
const summary = JSON.parse(await readFile(path, 'utf8')) as CardSummary;
const additionalEffectMarkers = ['스킬 발동률', 'GOOD 이상이 PERFECT가 된다', '라이프가'];
const missing = summary.cards.flatMap((card) => card.details.special.flatMap((level) =>
additionalEffectMarkers
.filter((marker) => level.description.includes(marker) && !level.effect.includes(marker))
.map((marker) => `${card.name} (${marker}): ${level.description}`)));
assert.deepEqual(missing, []);
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { calculateExactScore } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
@@ -85,7 +86,7 @@ function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): M
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));
effectiveProbability = effectiveActiveProbability(card, activationBonus);
probabilityByAttempt.set(attempt, effectiveProbability);
}
boost[noteIndex] = card.active.scoreUpPercent / 100 *
+2
View File
@@ -1,7 +1,9 @@
export * from './active-probability.ts';
export * from './card-adapter.ts';
export * from './combination-search.ts';
export * from './awakening-requirements.ts';
export * from './board-effects.ts';
export * from './board-connection-optimizer.ts';
export * from './exact-optimizer.ts';
export * from './memory.ts';
export * from './maximum-weight-matching.ts';
+16
View File
@@ -74,6 +74,22 @@ test('스페셜의 액티브 발동률 증가를 발동 시점에 적용한다',
assert.equal(result.maximum, 0.75);
});
test('블루 보드와 스페셜의 발동률 증가 배율을 합산한다', () => {
const main = card('main', 100, 55);
main.active.activationRateUpPercent = 30;
const support: OptimizerCard = {
...card('support', 0, 0),
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
};
const result = estimateScoreAchievementProbabilities(
[main, support],
{ main: 0, support: 0 },
chart([1.1], [null, 0]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 6 }
);
assert.ok(Math.abs(result.maximum - 0.935) < 1e-12);
});
test('확정 발동이면 모든 달성 확률이 100%다', () => {
const result = estimateScoreAchievementProbabilities(
[card('main', 100, 100)],
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import type {
@@ -84,7 +85,7 @@ function prepareModel(
let attemptIndex = attemptIndexes.get(key);
if (attemptIndex === undefined) {
const activationBonus = specialRate(cards, chart, activationTime, 'activationRateUpPercent');
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
const probability = effectiveActiveProbability(card, activationBonus);
attemptIndex = attempts.length;
attemptIndexes.set(key, attemptIndex);
attempts.push({ key, probability });
+47
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' };
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
@@ -74,3 +75,49 @@ test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수
assert.equal(result.expectedScore, 1_200);
assert.equal(result.theoreticalMaxScore, 1_800);
});
test('블루 보드와 스페셜의 발동률 증가 배율을 합산해 기대 점수에 반영한다', () => {
const main: OptimizerCard = {
id: 'main',
name: '메인',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent: 55,
activationRateUpPercent: 30
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
const support: OptimizerCard = {
id: 'support',
name: '서포트',
active: { cooldownSeconds: 999, durationSeconds: 0, scoreUpPercent: 0, probabilityPercent: 0 },
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
};
const result = calculateExactScore([main, support], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [null, 0]
}, { perfectNoteScore: 100 });
assert.equal(result.expectedScore, 194);
assert.equal(result.theoreticalMaxScore, 200);
});
test('등록된 실측 ratio가 모든 1노트 측정값을 재현한다', () => {
for (const calibration of scoreCalibrations.charts) {
for (const measurement of calibration.measurements) {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), {
perfectNoteScore: measurement.overallPower / calibration.scoreRatio,
directSongScoreBonusPercent: measurement.directSongScoreBonusPercent
});
assert.equal(
result.baselineScore,
measurement.normalPerfectScore,
`${calibration.songId} ${calibration.difficulty}`
);
}
}
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import { passiveScoreSupportAt } from './passive-support.ts';
import type {
@@ -86,7 +87,7 @@ function activeCandidates(
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 probability = effectiveActiveProbability(card, activationRateBonus);
const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex);
return [{
memberIndex,
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { songOptions } from '../data/song-catalog.ts';
const charts = [
{ songId: 'm2', title: 'Zeta Song', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'm1', title: 'Alpha Song', difficulty: 'expert', difficultyLevel: 30 },
{ songId: 'm1', title: 'Alpha Song', difficulty: 'hard', difficultyLevel: 18 },
{ songId: 'm3', title: 'Another Song', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'm4', title: 'Unknown Song', difficulty: 'expert' }
];
test('악곡 목록은 기본적으로 영문 제목 알파벳순으로 정렬된다', () => {
assert.deepEqual(songOptions(charts, 'expert').map((song) => song.id), ['m1', 'm3', 'm4', 'm2']);
});
test('영문 제목은 대소문자 구분 없이 검색하고 곡 ID도 검색한다', () => {
assert.deepEqual(songOptions(charts, 'expert', 'ALPHA').map((song) => song.id), ['m1']);
assert.deepEqual(songOptions(charts, 'expert', 'm2').map((song) => song.id), ['m2']);
});
test('선택한 채보의 레벨로 난이도 낮은순과 높은순 정렬을 지원한다', () => {
assert.deepEqual(songOptions(charts, 'expert', '', 'difficulty-asc').map((song) => song.id), ['m3', 'm2', 'm1', 'm4']);
assert.deepEqual(songOptions(charts, 'expert', '', 'difficulty-desc').map((song) => song.id), ['m1', 'm3', 'm2', 'm4']);
assert.equal(songOptions(charts, 'hard')[0].difficultyLevel, 18);
});
+3
View File
@@ -14,6 +14,8 @@ export type OptimizerCard = {
durationSeconds: number;
scoreUpPercent: number;
probabilityPercent?: number;
/** 블루 보드처럼 액티브 기본 발동률에 더해지는 상시 발동률 UP 배율. */
activationRateUpPercent?: number;
};
special: {
durationSeconds: number;
@@ -90,6 +92,7 @@ export type ExactSongChart = {
difficulty: string;
durationSeconds: number;
scoreRatio: number | null;
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
notes: readonly ExactChartNote[];
specialStartSeconds: readonly (number | null)[];
singerType?: string | null;