diff --git a/offline/OfflineApp.svelte b/offline/OfflineApp.svelte index 5263063..0234a1a 100644 --- a/offline/OfflineApp.svelte +++ b/offline/OfflineApp.svelte @@ -4,16 +4,20 @@ import CardsPage from '../src/routes/cards/+page.svelte'; import AnalysisPage from '../src/routes/analysis/+page.svelte'; import CombinationPage from '../src/routes/combination/+page.svelte'; + import EventPage from '../src/routes/event/+page.svelte'; import DataPage from '../src/routes/data/+page.svelte'; import DataCardsPage from '../src/routes/data/cards/+page.svelte'; import DataBoardsPage from '../src/routes/data/boards/+page.svelte'; import { setOfflinePage } from './page-store'; + import { events } from '../src/lib/data/events'; + import { activeEventAt } from '../src/lib/engine/event-optimization'; const routes = new Map([ ['/', HomePage], ['/cards', CardsPage], ['/analysis', AnalysisPage], ['/combination', CombinationPage], + ['/event', EventPage], ['/data', DataPage], ['/data/cards', DataCardsPage], ['/data/boards', DataBoardsPage] @@ -26,6 +30,8 @@ let currentPath = pathFromHash(); let CurrentPage = routes.get(currentPath) ?? HomePage; + let eventNow = Date.now(); + $: showEventTab = Boolean(activeEventAt(events, eventNow)); setOfflinePage(currentPath); function syncRoute() { @@ -36,6 +42,7 @@ } onMount(() => { + const eventTimer = window.setInterval(() => eventNow = Date.now(), 1_000); const rewriteInternalLinks = () => { for (const anchor of document.querySelectorAll('a[href^="/"]')) { anchor.href = `#${anchor.getAttribute('href')}`; @@ -57,6 +64,7 @@ window.removeEventListener('hashchange', syncRoute); document.removeEventListener('click', handleClick); linkObserver.disconnect(); + window.clearInterval(eventTimer); }; }); @@ -68,6 +76,7 @@ 카드 목록 배치 분석 조합 탐색 + {#if showEventTab}이벤트 Pt{/if} 사용자 데이터 { + 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); +}); diff --git a/src/lib/engine/event-optimization.ts b/src/lib/engine/event-optimization.ts new file mode 100644 index 0000000..d5a7e71 --- /dev/null +++ b/src/lib/engine/event-optimization.ts @@ -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>; + formationBonusPercent: number; + featuredCardBonusPercent: number; + challengeSongScoreBonusPercent: number; + awakeningBonusPercentByRarity: Readonly>; +}; + +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)); +} diff --git a/src/lib/engine/exact-optimizer.test.ts b/src/lib/engine/exact-optimizer.test.ts index 507fda9..636dfd6 100644 --- a/src/lib/engine/exact-optimizer.test.ts +++ b/src/lib/engine/exact-optimizer.test.ts @@ -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); +}); diff --git a/src/lib/engine/exact-optimizer.ts b/src/lib/engine/exact-optimizer.ts index 23fbea9..26650bb 100644 --- a/src/lib/engine/exact-optimizer.ts +++ b/src/lib/engine/exact-optimizer.ts @@ -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((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; diff --git a/src/lib/engine/index.ts b/src/lib/engine/index.ts index 0b22ac0..e043403 100644 --- a/src/lib/engine/index.ts +++ b/src/lib/engine/index.ts @@ -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'; diff --git a/src/lib/engine/types.ts b/src/lib/engine/types.ts index 12a6df5..5914c25 100644 --- a/src/lib/engine/types.ts +++ b/src/lib/engine/types.ts @@ -140,5 +140,6 @@ export type ExactOptimizationResult = ExactScoreResult & { }; export type ExactOptimizeOptions = ExactScoreOptions & { + objective?: 'maximum' | 'expected'; onProgress?: (progress: OptimizationProgress) => void; }; diff --git a/src/lib/workers/score-optimizer.worker.ts b/src/lib/workers/score-optimizer.worker.ts index 9cfb987..c371c23 100644 --- a/src/lib/workers/score-optimizer.worker.ts +++ b/src/lib/workers/score-optimizer.worker.ts @@ -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 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({ diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 600eba1..cc53797 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,28 @@