feat: release version 1.0.7
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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 *
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user