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
+36
View File
@@ -0,0 +1,36 @@
import type { EventDefinition } from '$lib/engine/event-optimization.ts';
const gamersFeaturedCards = [
'card-00006-5-uniq-0074-00',
'card-00014-5-uniq-0075-00',
'card-00016-5-uniq-0076-00',
'card-00017-5-uniq-0077-00'
] as const;
export const events = [
{
// HolodoriDB supplies the card/song IDs; the public master-data diff does not include
// the live-service schedule, so the official event period and bonus rules live here.
id: 'marathon_event-normal-003',
name: '무인도 잭! 한여름의 서바이벌 데이즈',
startsAt: '2026-08-29T03:00:00.000Z',
endsAt: '2026-09-06T10:59:59.999Z',
bonusCharacterIds: ['chr-00006', 'chr-00014', 'chr-00016', 'chr-00017'],
featuredCardIds: gamersFeaturedCards,
challengeSongBonusCardIds: {
m0338: [gamersFeaturedCards[0]],
m0339: [gamersFeaturedCards[1]],
m0340: [gamersFeaturedCards[2]],
m0341: [gamersFeaturedCards[3]],
m0342: gamersFeaturedCards
},
formationBonusPercent: 30,
featuredCardBonusPercent: 30,
challengeSongScoreBonusPercent: 10,
awakeningBonusPercentByRarity: {
3: [0, 1, 1, 2, 2, 3],
4: [0, 6, 7, 8, 9, 10],
5: [0, 15, 18, 22, 26, 30]
}
}
] as const satisfies readonly EventDefinition[];
+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;
};
+27 -14
View File
@@ -17,6 +17,9 @@ type CombinationCandidate = {
overallPower: number;
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
optimizationObjective?: 'maximum' | 'expected';
};
type CombinationRequest = {
@@ -30,9 +33,24 @@ type RankedCombination = {
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
function rankingScore(ranking: RankedCombination): number {
const score = ranking.rankingMultiplier === undefined
? ranking.result.theoreticalMaxScore
: ranking.result.expectedScore;
return score * (ranking.rankingMultiplier ?? 1);
}
function compareRankings(left: RankedCombination, right: RankedCombination): number {
return rankingScore(right) - rankingScore(left) ||
right.result.expectedScore - left.result.expectedScore ||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
}
type AchievementRequest = {
type: 'estimate-achievement';
rankings: RankedCombination[];
@@ -106,31 +124,26 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
objective: candidate.optimizationObjective,
onProgress: (progress) => self.postMessage({
type: 'progress',
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
})
});
completedCandidates += countOptimizationCandidates(candidate.cards);
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
best = {
result,
overallPower: candidate.overallPower,
cards: candidate.cards,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
};
}
rankings.push({
const ranking: RankedCombination = {
result,
overallPower: candidate.overallPower,
cards: candidate.cards,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
});
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
eventBonusPercent: candidate.eventBonusPercent,
rankingMultiplier: candidate.rankingMultiplier
};
if (!best || compareRankings(ranking, best) < 0) best = ranking;
rankings.push(ranking);
}
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore);
rankings.sort(compareRankings);
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
} catch (error) {
self.postMessage({