diff --git a/UPDATES.md b/UPDATES.md index 5785d6d..262ef70 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,13 @@ --- -updatedAt: 2026-10-08T10:07:05.000Z +updatedAt: 2026-10-09T00:49:57.362Z --- +## 2026년 10월 9일 + +### 마을 유닛 스코어 빈도 최적화 + +`NEW` 도구에 마을 유닛 스코어를 추가했습니다. 직접 지정한 5인 편성과 개화·리더 의상에 저장된 보드·연결 카드·메모리·멤버 강화를 반영하고, 빈도 감소 1,024개 조합을 비교합니다. 200초 균일 기준의 추정 모델이며 가창 보너스는 제외합니다. 편성과 종합력 입력은 자동 저장되어 페이지 이동·새로고침 후에도 유지됩니다. 실제 빈도 설정 가능 여부는 게임에서 확인해야 하며 사용자 성장 데이터를 자동 변경하지 않습니다. + ## 2026년 10월 8일 ### 악곡 랜덤 결과 표시·복사 통일 diff --git a/offline/OfflineApp.svelte b/offline/OfflineApp.svelte index cf39ddb..31f1a67 100644 --- a/offline/OfflineApp.svelte +++ b/offline/OfflineApp.svelte @@ -13,6 +13,7 @@ import BoardAwakeningPage from '../src/routes/tools/board-awakening/+page.svelte'; import ToolsPage from '../src/routes/tools/+page.svelte'; import SongListPage from '../src/routes/tools/song-list/+page.svelte'; + import UnitScorePage from '../src/routes/tools/unit-score/+page.svelte'; import ComparePage from '../src/routes/compare/+page.svelte'; import EventPage from '../src/routes/event/+page.svelte'; import DataPage from '../src/routes/data/+page.svelte'; @@ -35,6 +36,7 @@ ['/compare', ComparePage], ['/tools/random', ToolsPage], ['/tools/song-list', SongListPage], + ['/tools/unit-score', UnitScorePage], ['/tools/holo-work', HoloWorkPage], ['/tools/board-awakening', BoardAwakeningPage], ['/random', ToolsPage], diff --git a/src/lib/components/ToolsNav.svelte b/src/lib/components/ToolsNav.svelte index df1319b..c59537a 100644 --- a/src/lib/components/ToolsNav.svelte +++ b/src/lib/components/ToolsNav.svelte @@ -1,10 +1,11 @@ 악곡 랜덤 악곡 목록 + 마을 유닛 스코어 홀로워크 관리 연결노드 개화 추천 AI 연결 diff --git a/src/lib/engine/town-unit-score.test.ts b/src/lib/engine/town-unit-score.test.ts new file mode 100644 index 0000000..1fec8ea --- /dev/null +++ b/src/lib/engine/town-unit-score.test.ts @@ -0,0 +1,115 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import type catalog from '../data/cards-summary.json'; +import { prepareTownUnitScore, searchTownUnitScore, townActiveMean } from './town-unit-score.ts'; +import { parseTownForm, TOWN_FORM_STORAGE_KEY } from '../storage/town-unit-score.ts'; + +const data = JSON.parse(readFileSync('src/lib/data/cards-summary.json', 'utf8')) as typeof catalog; +const ids = ['card-00001-5-uniq-0000-00', 'card-00002-5-uniq-0001-00', 'card-00013-5-uniq-0002-00', 'card-00015-5-uniq-0003-00', 'card-00018-5-uniq-0004-00']; +const cards = ids.map(id => data.cards.find(c => c.id === id)!); + +test('마을 편성은 개화·리더 의상·종합력 입력과 함께 저장 후 복원된다', () => { + const form = { ids, awakenings: [5, 4, 3, 2, 1], leaderCharacterId: cards[0].characterId, + outfitId: cards[0].id, overridePower: 345678 }; + assert.deepEqual(parseTownForm(JSON.stringify(form), data.cards), form); + assert.deepEqual(parseTownForm(JSON.stringify({ ...form, overridePower: undefined }), data.cards).overridePower, undefined); + const partial = parseTownForm(JSON.stringify({ ...form, ids: [ids[0], '', ids[2], '', ''] }), data.cards); + assert.deepEqual(partial.ids, [ids[0], '', ids[2], '', '']); + assert.equal(TOWN_FORM_STORAGE_KEY, 'holodori.town-unit-score.v1'); +}); + +test('잘못되거나 오래된 저장 편성은 안전하게 정리한다', () => { + for (const raw of [null, '{', 'null', '[]', '42']) { + assert.deepEqual(parseTownForm(raw, data.cards).ids, Array(5).fill('')); + } + const saved = parseTownForm(JSON.stringify({ ids: [ids[0], ids[0], 'removed', ids[3]], + awakenings: [9, 2, 3, -1], leaderCharacterId: cards[0].characterId, outfitId: cards[1].id, + overridePower: -1 }), data.cards); + assert.deepEqual(saved.ids, [ids[0], '', '', ids[3], '']); + assert.deepEqual(saved.awakenings, [0, 0, 0, 0, 0]); + assert.equal(saved.outfitId, cards[0].id); + assert.equal(saved.overridePower, undefined); +}); + +test('초기 편성이 저장된 값을 덮어쓰지 않도록 복원 이후에만 자동 저장한다', () => { + const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8'); + assert.match(page, /if \(formReady\) saveForm\(JSON.stringify\(\{ ids, awakenings, leaderCharacterId, outfitId, overridePower \}\)\)/); + assert.ok(page.indexOf('const saved = parseTownForm') < page.indexOf('formReady = true')); + assert.match(page, /localStorage.setItem\(TOWN_FORM_STORAGE_KEY, value\)/); +}); + +test('200초 개화 0·5 기준값 및 1024개 전수 정렬을 재현한다', () => { + for (const [bloom, maximum, zero] of [[0, 1381928, 1329976], [5, 1617241, 1553065]]) { + const model = prepareTownUnitScore(cards, Array(5).fill(bloom), 300000, 0, 10, [10, 20, 30, 40, 50]); + const before = JSON.stringify(model); + const rows = searchTownUnitScore(model); + assert.equal(rows.length, 1024); + assert.equal(new Set(rows.map(r => r.reductions.join())).size, 1024); + assert.equal(rows[0].unitScore, maximum); + assert.deepEqual(rows[0].reductions, [12, 8, 12, 4, 8]); + assert.equal(rows.find(r => r.reductions.every(v => v === 0))?.unitScore, zero); + assert.ok(rows.every((r, i) => i === 0 || r.unitScore <= rows[i - 1].unitScore)); + assert.equal(JSON.stringify(model), before); + } +}); +test('200秒 integer sampling excludes end boundary and normalizes overlapping probability', () => { + const model = prepareTownUnitScore(cards, Array(5).fill(0), 300000, 0, 0, Array(5).fill(0)); + model.cards = [model.cards[0]]; + Object.assign(model.cards[0], { cooldown: 20, duration: 10, probability: 1000, mag: 1000, base: 1000, onset: 0 }); + // Nine complete 10s windows (20..29 through 180..189), plus second 200. + assert.equal(townActiveMean(model, [0], false), 91000 / 200); + model.cards.push({ ...model.cards[0] }); + assert.equal(townActiveMean(model, [0, 0], false), 91000 / 200); +}); +test('중복 멤버·잘못된 입력·미지원 스킬을 조용히 계산하지 않는다', () => { + const make = (c = cards, power = 300000) => prepareTownUnitScore(c, Array(5).fill(0), power, 0, 0, Array(5).fill(0)); + assert.throws(() => make([cards[0], cards[0], ...cards.slice(2)])); + assert.throws(() => make(cards, NaN)); + const unsupported = structuredClone(cards); + unsupported[0].details.active[0].effect = '라이프 회복'; + assert.throws(() => make(unsupported)); +}); +test('Pages route and tool navigation expose the online unit-score page', () => { + assert.match(readFileSync('offline/OfflineApp.svelte', 'utf8'), /\['\/tools\/unit-score', UnitScorePage\]/); + assert.match(readFileSync('src/lib/components/ToolsNav.svelte', 'utf8'), /href="\/tools\/unit-score"/); +}); + +test('개화 입력은 반복문 인덱스 양방향 바인딩 대신 명시적인 불변 업데이트를 사용한다', () => { + const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8'); + assert.doesNotMatch(page, /bind:value=\{awakenings\[i\]\}/); + assert.match(page, /setAwakening\(i, awakenings\[i\] \+ 1\)/); + assert.match(page, /setAwakening\(i, awakenings\[i\] - 1\)/); + assert.match(page, /awakenings = awakenings.map/); +}); + +test('성장 데이터는 자동으로 읽으며 수동 불러오기 버튼을 표시하지 않는다', () => { + const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8'); + assert.match(page, /onMount\(\(\) => \{\s*readGrowth\(\)/); + assert.match(page, /window.addEventListener\('storage', refresh\)/); + assert.doesNotMatch(page, /on:click=\{readGrowth\}/); +}); + +test('리더별 의상과 효과를 표시하고 상시 주의·안내 문구는 표시하지 않는다', () => { + const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8'); + assert.match(page, /outfits = leaderOutfitCards\(cards, leaderCharacterId\)/); + assert.match(page, /outfitId = leaderOutfitCards\(cards, id\)\[0\]\?\.id/); + assert.match(page, /리더 의상 효과/); + assert.doesNotMatch(page, /