From 1b94c781bef375b049ae97e683004ef02bdbc240 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Wed, 7 Oct 2026 12:30:17 +0900 Subject: [PATCH] data: configure Ocean Chase live and score bonuses --- UPDATES.md | 8 ++- src/lib/data/events-notice.json | 37 ++++++++++++ src/lib/data/events.ts | 6 +- src/lib/engine/event-api.test.ts | 2 +- src/lib/engine/event-score-notice.test.ts | 71 +++++++++++++++++++++++ 5 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 src/lib/data/events-notice.json create mode 100644 src/lib/engine/event-score-notice.test.ts diff --git a/UPDATES.md b/UPDATES.md index a041ad2..24568c6 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,15 @@ --- -updatedAt: 2026-10-07T03:00:00.000Z +updatedAt: 2026-10-07T03:27:00.000Z --- ## 2026년 10월 7일 +### 오션 체이스 이벤트 계산 + +`NEW` 이벤트 기간 동안 HOLOGRAM CIRCUS는 폴카, Lamy's Baribari Workout은 라미, Tokyo Shandy Rendez-vous는 리리카의 신규 수영복 ★5 카드를 편성하면 최종 스코어에 10% 보너스를 적용합니다. + +`NEW` 이벤트 PT 비교에 라미·폴카·리리카 멤버 보너스 +30%, 신규 수영복 카드 보너스 +30%, 등급별 개화 보너스를 반영합니다. 신규 수영복 카드는 미개화 시 +60%, 5회 개화 시 +90%를 적용합니다. + ### 신규 악곡과 메모리 추가 `NEW` HOLOGRAM CIRCUS, Lamy's Baribari Workout, Tokyo Shandy Rendez-vous의 Easy·Normal·Hard·Expert 채보를 추가했습니다. 각 난이도의 점수 계산과 채보 표시를 지원합니다. diff --git a/src/lib/data/events-notice.json b/src/lib/data/events-notice.json new file mode 100644 index 0000000..03467a1 --- /dev/null +++ b/src/lib/data/events-notice.json @@ -0,0 +1,37 @@ +{ + "source": { + "kind": "in-game-notice-screenshots-and-user-confirmation", + "notice": "https://x.com/hololive_dreams/status/2107078362217029679", + "noticePublishedAt": "2026-10-06T19:00:00+09:00", + "configuredAt": "2026-10-07T12:27:00+09:00", + "scope": "Event period confirmed by the in-game notice and live portal response. Live acquisition member, featured-card and awakening bonuses confirmed by in-game screenshots. Challenge-song score bonuses and new swimsuit card targets explicitly confirmed by the user. This is manual configuration, not a captured full Event/ListEventInfo response; minigame rules are not included." + }, + "events": [ + { + "id": "marathon_event-normal-006", + "name": "양보할 수 없는 오션 체이스", + "startsAt": "2026-10-07T03:00:00.000Z", + "endsAt": "2026-10-13T10:59:59.000Z", + "exchangeEndsAt": "2026-10-19T10:59:59.000Z", + "bonusCharacterIds": ["chr-00030", "chr-00034", "chr-06003"], + "featuredCardIds": [ + "card-00030-5-uniq-0089-00", + "card-00034-5-uniq-0090-00", + "card-06003-5-uniq-0091-00" + ], + "formationBonusPercent": 30, + "featuredCardBonusPercent": 30, + "awakeningBonusPercentByRarity": { + "3": [0, 1, 1, 2, 2, 3], + "4": [0, 6, 7, 8, 9, 10], + "5": [0, 15, 18, 22, 26, 30] + }, + "challengeSongBonusCardIds": { + "m0360": ["card-00034-5-uniq-0090-00"], + "m0361": ["card-00030-5-uniq-0089-00"], + "m0362": ["card-06003-5-uniq-0091-00"] + }, + "challengeSongScoreBonusPercent": 10 + } + ] +} diff --git a/src/lib/data/events.ts b/src/lib/data/events.ts index 8f9cc7d..efe58ce 100644 --- a/src/lib/data/events.ts +++ b/src/lib/data/events.ts @@ -1,5 +1,6 @@ import type { EventDefinition } from '$lib/engine/event-optimization.ts'; import apiEvents from './events-api.json' with { type: 'json' }; +import noticeEvents from './events-notice.json' with { type: 'json' }; const gamersFeaturedCards = [ 'card-00006-5-uniq-0074-00', @@ -70,5 +71,8 @@ export const events: readonly EventDefinition[] = [ challengeSongBonusCardIds: { [songId]: [summerFeaturedCards[index]] } })) }, - ...apiEvents.events + ...apiEvents.events, + // Live acquisition and score settings confirmed by in-game screenshots and the + // user. A subsequently captured full API configuration takes precedence. + ...noticeEvents.events.filter(event => !apiEvents.events.some(apiEvent => apiEvent.id === event.id)) ]; diff --git a/src/lib/engine/event-api.test.ts b/src/lib/engine/event-api.test.ts index ec4a477..9b55c1a 100644 --- a/src/lib/engine/event-api.test.ts +++ b/src/lib/engine/event-api.test.ts @@ -26,7 +26,7 @@ test('이벤트 원본 해시와 생성된 일정이 확보한 API 설정과 일 assert.equal(activeEventAt(events, raw.startTime)?.id, raw.eventId); assert.equal(activeEventAt(events, raw.endTime)?.id, raw.eventId); assert.equal(activeEventAt(events, raw.endTime + 1), null); - assert.equal(activeEventAt(events, raw.exchangeEndTime), null); + assert.notEqual(activeEventAt(events, raw.exchangeEndTime)?.id, raw.eventId); }); test('전체 카드와 개화 0~5의 라이브 보너스가 API 조건별 합계와 일치한다', () => { diff --git a/src/lib/engine/event-score-notice.test.ts b/src/lib/engine/event-score-notice.test.ts new file mode 100644 index 0000000..2c52fad --- /dev/null +++ b/src/lib/engine/event-score-notice.test.ts @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { events } from '../data/events.ts'; +import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent } from './event-optimization.ts'; + +const event = events.find(event => event.id === 'marathon_event-normal-006')!; +const cards = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')).cards as { + id: string; characterId: string; versionName: string; rarity: number +}[]; +const targets = [ + ['m0360', 'card-00034-5-uniq-0090-00', 'chr-00034'], + ['m0361', 'card-00030-5-uniq-0089-00', 'chr-00030'], + ['m0362', 'card-06003-5-uniq-0091-00', 'chr-06003'] +] as const; + +test('오션 체이스는 확인된 기간에만 활성화하고 다른 이벤트를 중복 추가하지 않는다', () => { + const start = Date.parse('2026-10-07T03:00:00.000Z'); + const end = Date.parse('2026-10-13T10:59:59.000Z'); + assert.equal(event.startsAt, new Date(start).toISOString()); + assert.equal(event.endsAt, new Date(end).toISOString()); + assert.equal(events.filter(entry => entry.id === event.id).length, 1); + assert.equal(activeEventAt(events, start - 1), null); + assert.equal(activeEventAt(events, start)?.id, event.id); + assert.equal(activeEventAt(events, end)?.id, event.id); + assert.equal(activeEventAt(events, end + 1), null); +}); + +test('각 신규 악곡은 같은 가창자의 신규 수영복 카드 한 장에만 스코어 10%를 적용한다', () => { + assert.deepEqual(Object.keys(event.challengeSongBonusCardIds).sort(), targets.map(([song]) => song).sort()); + for (const [song, target, characterId] of targets) { + const targetCard = cards.find(card => card.id === target)!; + assert.equal(targetCard.characterId, characterId); + assert.equal(targetCard.versionName, '수영복'); + for (const card of cards) { + assert.equal(eventChallengeSongScoreBonusPercent(event, song, [card.id]), card.id === target ? 10 : 0, `${song}, ${card.id}`); + } + assert.equal(eventChallengeSongScoreBonusPercent(event, song, targets.map(([, card]) => card)), 10); + assert.equal(eventChallengeSongScoreBonusPercent(event, song, []), 0); + } + assert.equal(eventChallengeSongScoreBonusPercent(event, 'm0358', targets.map(([, card]) => card)), 0); +}); + +test('전체 카드와 개화 단계의 획득 보너스는 인게임에서 확인한 비누적 합계와 일치한다', () => { + const awakeningBonuses: Record = { + 3: [0, 1, 1, 2, 2, 3], + 4: [0, 6, 7, 8, 9, 10], + 5: [0, 15, 18, 22, 26, 30] + }; + const bonusCharacters = new Set(targets.map(([, , characterId]) => characterId) as readonly string[]); + const featuredIds = new Set(targets.map(([, cardId]) => cardId) as readonly string[]); + assert.deepEqual([...event.bonusCharacterIds].sort(), [...bonusCharacters].sort()); + assert.deepEqual([...event.featuredCardIds].sort(), [...featuredIds].sort()); + for (const card of cards) { + for (let awakening = 0; awakening <= 5; awakening++) { + const formation = bonusCharacters.has(card.characterId) ? 30 : 0; + const featured = featuredIds.has(card.id) ? 30 : 0; + const upgrade = awakeningBonuses[card.rarity]?.[awakening] ?? 0; + assert.deepEqual(eventBonusBreakdown(event, [card], () => awakening), { + formationPercent: formation, + featuredCardPercent: featured, + awakeningPercent: upgrade, + totalPercent: formation + featured + upgrade + }, `${card.id}, awakening ${awakening}`); + } + } + const featured = cards.filter(card => featuredIds.has(card.id)); + assert.equal(featured.length, 3); + assert.equal(eventBonusBreakdown(event, featured, () => 0).totalPercent, 180); + assert.equal(eventBonusBreakdown(event, featured, () => 5).totalPercent, 270); +});