feat: release version 1.0.9

This commit is contained in:
2026-08-31 12:33:52 +09:00
parent 2998278353
commit 89169c5c0d
15 changed files with 421 additions and 67 deletions
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { events } from '../data/events.ts';
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventPointIndex } from './event-optimization.ts';
const currentEvent = events[0];
test('이벤트 기간 안에서만 진행 중 이벤트를 반환한다', () => {
assert.equal(activeEventAt(events, Date.parse('2026-08-29T02:59:59.999Z')), null);
assert.equal(activeEventAt(events, Date.parse('2026-08-31T00:00:00.000Z'))?.id, currentEvent.id);
assert.equal(activeEventAt(events, Date.parse('2026-09-06T11:00:00.000Z')), null);
});
test('편성·신규 카드·개화 보너스를 카드별로 합산한다', () => {
const cards = [
{ id: currentEvent.featuredCardIds[0], characterId: currentEvent.bonusCharacterIds[0], rarity: 5 },
{ id: 'regular-event-member', characterId: currentEvent.bonusCharacterIds[1], rarity: 4 },
{ id: 'other-member', characterId: 'chr-other', rarity: 3 }
];
const awakening = new Map([[cards[0].id, 5], [cards[1].id, 2], [cards[2].id, 1]]);
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakening.get(card.id) ?? 0);
assert.deepEqual(bonus, {
formationPercent: 60,
featuredCardPercent: 30,
awakeningPercent: 38,
totalPercent: 128
});
});
test('과제곡에 대응하는 신규 카드가 있을 때만 스코어 보너스를 적용한다', () => {
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0338', [currentEvent.featuredCardIds[0]]), 10);
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0338', [currentEvent.featuredCardIds[1]]), 0);
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0342', [currentEvent.featuredCardIds[3]]), 10);
});
test('예상 점수에 이벤트 획득 배율을 적용해 비교 지수를 만든다', () => {
assert.equal(eventPointIndex(1_000_000, 128), 2_280_000);
});
+69
View File
@@ -0,0 +1,69 @@
export type EventCard = {
id: string;
characterId: string;
rarity: number;
};
export type EventDefinition = {
id: string;
name: string;
startsAt: string;
endsAt: string;
bonusCharacterIds: readonly string[];
featuredCardIds: readonly string[];
challengeSongBonusCardIds: Readonly<Record<string, readonly string[]>>;
formationBonusPercent: number;
featuredCardBonusPercent: number;
challengeSongScoreBonusPercent: number;
awakeningBonusPercentByRarity: Readonly<Record<number, readonly number[]>>;
};
export type EventBonusBreakdown = {
formationPercent: number;
featuredCardPercent: number;
awakeningPercent: number;
totalPercent: number;
};
export function activeEventAt(events: readonly EventDefinition[], now: number): EventDefinition | null {
return events.find((event) => Date.parse(event.startsAt) <= now && now <= Date.parse(event.endsAt)) ?? null;
}
export function eventBonusBreakdown(
event: EventDefinition,
cards: readonly EventCard[],
awakeningForCard: (card: EventCard) => number
): EventBonusBreakdown {
const bonusCharacters = new Set(event.bonusCharacterIds);
const featuredCards = new Set(event.featuredCardIds);
let formationPercent = 0;
let featuredCardPercent = 0;
let awakeningPercent = 0;
for (const card of cards) {
if (bonusCharacters.has(card.characterId)) formationPercent += event.formationBonusPercent;
if (featuredCards.has(card.id)) featuredCardPercent += event.featuredCardBonusPercent;
const awakening = Math.max(0, Math.min(5, Math.trunc(awakeningForCard(card))));
awakeningPercent += event.awakeningBonusPercentByRarity[card.rarity]?.[awakening] ?? 0;
}
return {
formationPercent,
featuredCardPercent,
awakeningPercent,
totalPercent: formationPercent + featuredCardPercent + awakeningPercent
};
}
export function eventChallengeSongScoreBonusPercent(
event: EventDefinition,
songId: string,
cardIds: readonly string[]
): number {
const bonusCards = new Set(event.challengeSongBonusCardIds[songId] ?? []);
return cardIds.some((cardId) => bonusCards.has(cardId)) ? event.challengeSongScoreBonusPercent : 0;
}
export function eventPointIndex(score: number, eventBonusPercent: number): number {
return Math.round(score * (1 + eventBonusPercent / 100));
}
+15
View File
@@ -130,3 +130,18 @@ test('예상 점수보다 이론상 고점을 우선해 스페셜 순서를 선
assert.equal(result.theoreticalMaxScore, 400);
assert.equal(result.expectedScore, 130);
});
test('이벤트 효율 탐색에서는 예상 점수를 우선해 스페셜 순서를 선택할 수 있다', () => {
const probabilistic = cards.map((card, index) => ({
...card,
active: {
...card.active,
probabilityPercent: index === 0 ? 5 : 100,
scoreUpPercent: index === 0 ? 500 : card.active.scoreUpPercent
}
}));
const maximum = optimizeExactOrderAndCooldowns(probabilistic, chart, { perfectNoteScore: 100 });
const expected = optimizeExactOrderAndCooldowns(probabilistic, chart, { perfectNoteScore: 100, objective: 'expected' });
assert.ok(expected.expectedScore >= maximum.expectedScore);
});
+8 -4
View File
@@ -243,12 +243,16 @@ export function optimizeExactOrderAndCooldowns(
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);
const isHigherMaximum = score.theoreticalMaxScore > bestTheoreticalMaxScore;
const isHigherExpectedTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
score.expectedScore > bestExpectedScore;
const expectedFirst = options.objective === 'expected';
const isBetterPrimary = expectedFirst
? score.expectedScore > bestExpectedScore
: score.theoreticalMaxScore > bestTheoreticalMaxScore;
const isBetterSecondary = expectedFirst
? score.expectedScore === bestExpectedScore && score.theoreticalMaxScore > bestTheoreticalMaxScore
: score.theoreticalMaxScore === bestTheoreticalMaxScore && score.expectedScore > bestExpectedScore;
const isCheaperTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
score.expectedScore === bestExpectedScore && reductionTotal < bestReductionTotal;
if (isHigherMaximum || isHigherExpectedTie || isCheaperTie) {
if (isBetterPrimary || isBetterSecondary || isCheaperTie) {
bestTheoreticalMaxScore = score.theoreticalMaxScore;
bestExpectedScore = score.expectedScore;
bestOrder = order;
+1
View File
@@ -5,6 +5,7 @@ export * from './awakening-requirements.ts';
export * from './board-effects.ts';
export * from './board-connection-optimizer.ts';
export * from './exact-optimizer.ts';
export * from './event-optimization.ts';
export * from './memory.ts';
export * from './maximum-weight-matching.ts';
export * from './optimizer.ts';
+1
View File
@@ -140,5 +140,6 @@ export type ExactOptimizationResult = ExactScoreResult & {
};
export type ExactOptimizeOptions = ExactScoreOptions & {
objective?: 'maximum' | 'expected';
onProgress?: (progress: OptimizationProgress) => void;
};