feat: release version 1.0.9
This commit is contained in:
@@ -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[];
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -140,5 +140,6 @@ export type ExactOptimizationResult = ExactScoreResult & {
|
||||
};
|
||||
|
||||
export type ExactOptimizeOptions = ExactScoreOptions & {
|
||||
objective?: 'maximum' | 'expected';
|
||||
onProgress?: (progress: OptimizationProgress) => void;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user