From dd37f59565f7de25856c3d67bb4734d468f43cc0 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Sat, 12 Sep 2026 15:16:30 +0900 Subject: [PATCH] feat: add Holo-work tracking, teams and board bonuses --- AGENTS.md | 6 + UPDATES.md | 12 +- compose.yaml | 2 + data/holodoridb/card-master.json | 2 +- data/holodoridb/cards.json | 2 +- data/holodoridb/manifest.json | 2 +- offline/OfflineApp.svelte | 2 + scripts/collect-holo-work-achievements.mjs | 22 + src/lib/components/HoloWorkBonuses.svelte | 20 + src/lib/components/HoloWorkProgress.svelte | 19 + src/lib/components/ToolsNav.svelte | 16 + src/lib/data/holo-work-achievements.json | 606 +++++++++++++++++++++ src/lib/engine/board-effects.ts | 38 ++ src/lib/engine/holo-work-board.test.ts | 48 ++ src/lib/engine/holo-work.test.ts | 67 +++ src/lib/models/default-preferences.ts | 17 + src/lib/models/user-data.ts | 6 + src/lib/storage/browser-storage.ts | 5 +- src/lib/storage/holo-work.ts | 46 ++ src/routes/+page.svelte | 2 +- src/routes/cards/+page.svelte | 5 +- src/routes/data/+page.svelte | 22 +- src/routes/tools/+page.svelte | 10 +- src/routes/tools/holo-work/+page.svelte | 290 ++++++++++ vite.config.ts | 3 +- 25 files changed, 1237 insertions(+), 33 deletions(-) create mode 100644 scripts/collect-holo-work-achievements.mjs create mode 100644 src/lib/components/HoloWorkBonuses.svelte create mode 100644 src/lib/components/HoloWorkProgress.svelte create mode 100644 src/lib/components/ToolsNav.svelte create mode 100644 src/lib/data/holo-work-achievements.json create mode 100644 src/lib/engine/holo-work-board.test.ts create mode 100644 src/lib/engine/holo-work.test.ts create mode 100644 src/lib/models/default-preferences.ts create mode 100644 src/lib/storage/holo-work.ts create mode 100644 src/routes/tools/holo-work/+page.svelte diff --git a/AGENTS.md b/AGENTS.md index 18587de..a99fa2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # Repository instructions +## Local development ports + +- Port 5173 is reserved for the user's local Docker container. Never start an agent-run server on port 5173 or stop the container to free that port. +- For local testing, use another available port, for example `npm run dev -- --port 5174 --strictPort`. If it is occupied, choose another port without stopping the existing service. +- Stop agent-started test servers when verification is complete. + ## Commit and push shorthand - When the user says "마무리 해" (or "마무리해"), treat it as authorization to verify the completed task, commit its changes, and push to the configured upstream. diff --git a/UPDATES.md b/UPDATES.md index a487f57..04df312 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,17 @@ --- -updatedAt: 2026-09-10T21:32:35+09:00 +updatedAt: 2026-09-12T00:00:00+09:00 --- +## 2026년 9월 12일 + +### 홀로워크 관리 + +`NEW` 도구에 ‘홀로워크 관리’를 추가했습니다. 멤버별 Holo워크 파견 횟수를 직접 입력하거나 +1·−1 버튼으로 수정하고, 이름 검색과 횟수순 정렬로 확인할 수 있습니다. 기록은 브라우저에 자동 저장되며 사용자 데이터 JSON 백업·불러오기에 포함됩니다. + +원하는 멤버 5명으로 이름을 붙인 조합을 저장·수정·삭제하고, 조합 전체의 횟수를 한 번에 +1·−1 할 수 있습니다. 멤버별 기록은 `현재 횟수 / 다음 업적 요구량`으로 표시되며, 최종 400회 달성 시 완료로 표시합니다. 저장한 조합도 사용자 데이터 JSON 백업에 포함됩니다. + +멤버 보드의 활성 노드와 연결 카드 증폭을 반영해 레슨 Pt·큐브·특훈 아이템 획득량 보너스를 멤버별로 표시하고, 저장한 조합에서는 보상 종류별 보너스 합계를 확인할 수 있습니다. + ## 2026년 9월 10일 ### 악곡 랜덤 선택 diff --git a/compose.yaml b/compose.yaml index 1e8bbd9..eb60f3b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,6 +15,7 @@ services: volumes: - .:/app - node_modules:/app/node_modules + - svelte_kit:/app/.svelte-kit - sqlite_data:/app/data healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5173/api/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] @@ -26,4 +27,5 @@ services: volumes: node_modules: + svelte_kit: sqlite_data: diff --git a/data/holodoridb/card-master.json b/data/holodoridb/card-master.json index 5d2da76..23f18e7 100644 --- a/data/holodoridb/card-master.json +++ b/data/holodoridb/card-master.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-11T01:18:17.159Z", + "generatedAt": "2026-09-12T03:46:50.302Z", "sources": { "eng": { "repository": "https://github.com/HolodoriDB/holodori-db-eng-diff", diff --git a/data/holodoridb/cards.json b/data/holodoridb/cards.json index accd9e5..cc98eb1 100644 --- a/data/holodoridb/cards.json +++ b/data/holodoridb/cards.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-11T01:18:17.159Z", + "generatedAt": "2026-09-12T03:46:50.302Z", "sources": { "eng": { "repository": "https://github.com/HolodoriDB/holodori-db-eng-diff", diff --git a/data/holodoridb/manifest.json b/data/holodoridb/manifest.json index 24b58fa..8501a13 100644 --- a/data/holodoridb/manifest.json +++ b/data/holodoridb/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-11T01:18:17.159Z", + "generatedAt": "2026-09-12T03:46:50.302Z", "source": "HolodoriDB community master-data diff repositories", "sourceLicense": null, "sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.", diff --git a/offline/OfflineApp.svelte b/offline/OfflineApp.svelte index ed4f834..9cec642 100644 --- a/offline/OfflineApp.svelte +++ b/offline/OfflineApp.svelte @@ -6,6 +6,7 @@ import AnalysisPage from '../src/routes/analysis/+page.svelte'; import RatingPage from '../src/routes/rating/+page.svelte'; import CombinationPage from '../src/routes/combination/+page.svelte'; + import HoloWorkPage from '../src/routes/tools/holo-work/+page.svelte'; import ToolsPage from '../src/routes/tools/+page.svelte'; import EventPage from '../src/routes/event/+page.svelte'; import DataPage from '../src/routes/data/+page.svelte'; @@ -24,6 +25,7 @@ ['/combination', CombinationPage], ['/tools', ToolsPage], ['/tools/random', ToolsPage], + ['/tools/holo-work', HoloWorkPage], ['/random', ToolsPage], ['/event', EventPage], ['/data', DataPage], diff --git a/scripts/collect-holo-work-achievements.mjs b/scripts/collect-holo-work-achievements.mjs new file mode 100644 index 0000000..0fca8dc --- /dev/null +++ b/scripts/collect-holo-work-achievements.mjs @@ -0,0 +1,22 @@ +import { writeFileSync } from 'node:fs'; +import { prepareHolodoriDbSource, readHolodoriDbText } from './holodoridb-source.mjs'; + +const source = prepareHolodoriDbSource('kor'); +const missions = JSON.parse(readHolodoriDbText(source, 'Mission.json')).map((row) => row.data) + .filter((mission) => mission.characterId + && mission.categoryType === 'MissionCategoryType_MISSION_CATEGORY_TYPE_ACHIEVEMENT' + && mission.type === 'MissionType_MISSION_TYPE_INCREMENT_WORK_FINISH_COUNT'); +const progress = JSON.parse(readHolodoriDbText(source, 'MissionProgress.json')).map((row) => row.data); +if (!missions.length) throw new Error('No member Holo-work achievements found.'); +const thresholdsByMember = Object.fromEntries(missions.sort((a, b) => a.characterId.localeCompare(b.characterId)).map((mission) => { + const thresholds = [...new Set(progress.filter((row) => row.missionId === mission.id).map((row) => Number(row.threshold)))].sort((a, b) => a - b); + if (!thresholds.length || thresholds.some((value) => !Number.isSafeInteger(value) || value <= 0)) { + throw new Error(`Invalid Holo-work thresholds: ${mission.id}`); + } + return [mission.characterId, thresholds]; +})); +writeFileSync(new URL('../src/lib/data/holo-work-achievements.json', import.meta.url), JSON.stringify({ + source: { repository: source.repository, commit: source.commit, files: ['Mission.json', 'MissionProgress.json'] }, + thresholdsByMember +}, null, 2) + '\n'); +console.log(`Collected Holo-work achievements for ${missions.length} members at ${source.commit}.`); diff --git a/src/lib/components/HoloWorkBonuses.svelte b/src/lib/components/HoloWorkBonuses.svelte new file mode 100644 index 0000000..07f9b7b --- /dev/null +++ b/src/lib/components/HoloWorkBonuses.svelte @@ -0,0 +1,20 @@ + + +{#if entries.length} +
+ {#each entries as key}{labels[key]} +{bonus![key].toLocaleString('ko-KR', { maximumFractionDigits: 3 })}%{/each} +
+{:else if showEmpty}

활성 보드 보너스 없음

{/if} + + diff --git a/src/lib/components/HoloWorkProgress.svelte b/src/lib/components/HoloWorkProgress.svelte new file mode 100644 index 0000000..c66c040 --- /dev/null +++ b/src/lib/components/HoloWorkProgress.svelte @@ -0,0 +1,19 @@ + + + + {count.toLocaleString('ko-KR')} / {progress.target?.toLocaleString('ko-KR') ?? '—'}회 + {#if progress.complete}완료{/if} + + + diff --git a/src/lib/components/ToolsNav.svelte b/src/lib/components/ToolsNav.svelte new file mode 100644 index 0000000..ee11c18 --- /dev/null +++ b/src/lib/components/ToolsNav.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/data/holo-work-achievements.json b/src/lib/data/holo-work-achievements.json new file mode 100644 index 0000000..85fd36a --- /dev/null +++ b/src/lib/data/holo-work-achievements.json @@ -0,0 +1,606 @@ +{ + "source": { + "repository": "https://github.com/HolodoriDB/holodori-db-kor-diff.git", + "commit": "9234a7d99c5cfba9b71803c24a6de4c005c4842f", + "files": [ + "Mission.json", + "MissionProgress.json" + ] + }, + "thresholdsByMember": { + "chr-00001": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00002": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00004": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00005": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00006": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00007": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00010": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00011": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00012": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00013": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00014": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00015": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00016": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00017": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00018": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00019": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00021": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00022": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00023": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00026": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00027": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00028": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00030": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00031": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00032": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00034": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00035": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00036": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00037": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-00039": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03001": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03002": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03003": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03004": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03005": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03006": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03007": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03008": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-03009": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04001": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04002": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04003": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04007": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04010": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04012": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04013": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04014": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04015": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04016": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-04017": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-06002": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-06003": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-06004": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ], + "chr-06005": [ + 1, + 5, + 10, + 30, + 50, + 100, + 200, + 300, + 400 + ] + } +} diff --git a/src/lib/engine/board-effects.ts b/src/lib/engine/board-effects.ts index 41b51d0..bd5a7d9 100644 --- a/src/lib/engine/board-effects.ts +++ b/src/lib/engine/board-effects.ts @@ -142,6 +142,44 @@ export type MemberBoardEffectSummary = { }[]; }; +export type HoloWorkBoardBonus = { lesson: number; cube: number; training: number }; +const emptyWorkBonus = (): HoloWorkBoardBonus => ({ lesson: 0, cube: 0, training: 0 }); + +/** Percent bonuses provided when each board's owner participates in Holo-work. */ +export function calculateHoloWorkBoardBonuses( + summary: BoardSummarySource, + memberBoards: Readonly>, + connections?: BoardConnections | null +): Record { + const multipliers = connectionMultipliers(summary, memberBoards, connections); + return Object.fromEntries(Object.entries(memberBoards).map(([memberId, nodes]) => { + const bonus = emptyWorkBonus(); + for (const groupId of new Set(nodes)) { + const effect = nodeForMember(summary, groupId, memberId)?.effect; + if (effect?.trigger !== 'work' || effect.type !== 'work_reward_quantity_up_permil_up' + || effect.targetId !== 'skill_tree_effect_target-skill_tree_character' || effect.value === null) continue; + // Stable resource suffixes from the official SkillTreeEffect IDs distinguish reward categories. + const category = effect.id?.endsWith('-work-item-item-card_level_up') ? 'lesson' + : effect.id?.endsWith('-work-item-item-skill_tree_card-1') ? 'cube' + : effect.id?.endsWith('-work-item-item-card_limit_break-attribute_1-1') ? 'training' : null; + if (category) bonus[category] += effect.value * (multipliers[memberId]?.[groupId] ?? 1) / 10; + } + return [memberId, bonus]; + })); +} + +export function sumHoloWorkBoardBonuses(bonuses: Readonly>, memberIds: readonly string[]): HoloWorkBoardBonus { + const total = emptyWorkBonus(); + for (const id of new Set(memberIds)) { + const bonus = bonuses[id]; + if (!bonus) continue; + total.lesson += bonus.lesson; + total.cube += bonus.cube; + total.training += bonus.training; + } + return total; +} + /** Saved-board overview, without assuming a leader or a song. Conditional effects stay in the detail rows. */ export function summarizeMemberBoardEffects( summary: BoardSummarySource, diff --git a/src/lib/engine/holo-work-board.test.ts b/src/lib/engine/holo-work-board.test.ts new file mode 100644 index 0000000..a653523 --- /dev/null +++ b/src/lib/engine/holo-work-board.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { calculateHoloWorkBoardBonuses, sumHoloWorkBoardBonuses, type BoardSummarySource, type BoardConnections } from './board-effects.ts'; + +function effect(resource: string, value: number) { + return { id: `effect-work-item-${resource}`, type: 'work_reward_quantity_up_permil_up', value, + trigger: 'work', targetId: 'skill_tree_effect_target-skill_tree_character' }; +} +const summary: BoardSummarySource = { + members: [{ id: 'a', positionGroupId: 'model' }, { id: 'b', positionGroupId: 'model' }], + models: { model: [{ groupId: 'S-1', x: 0, y: 0 }, { groupId: 'Y-1', x: 1, y: 0 }, { groupId: 'S-2', x: 2, y: 0 }] }, + nodeGroups: { + 'Y-1': [{ characterIds: [], effect: effect('item-card_level_up', 30) }, { characterIds: ['b'], effect: effect('item-card_level_up', 60) }], + 'Y-2': [{ characterIds: [], effect: effect('item-skill_tree_card-1', 40) }], + 'Y-3': [{ characterIds: [], effect: effect('item-card_limit_break-attribute_1-1', 80) }], + 'B-1': [{ characterIds: [], effect: { type: 'performance_up', value: 100 } }] + } +}; +const connections: BoardConnections = { + placements: { a: { 'S-1': 'card' } }, + cards: [{ id: 'card', awakening: 0, connect: [ + { level: 1, effectPermilUp: 500, extent: [{ x: 1, y: 0 }, { x: -1, y: 0 }] }, + { level: 2, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }, { x: -1, y: 0 }] } + ] }] +}; + +test('활성 노드만 합산하고 중복 노드·다른 보너스를 제외하며 멤버별 변형을 적용한다', () => { + const bonuses = calculateHoloWorkBoardBonuses(summary, { a: ['Y-1', 'Y-1', 'Y-2', 'B-1'], b: ['Y-1', 'Y-3'] }); + assert.deepEqual(bonuses, { a: { lesson: 3, cube: 4, training: 0 }, b: { lesson: 6, cube: 0, training: 8 } }); + assert.deepEqual(calculateHoloWorkBoardBonuses(summary, {}), {}); +}); + +test('연결 카드 개화와 배치·해제에 따라 소수점 보너스도 반영한다', () => { + const boards = { a: ['Y-1'] }; + assert.equal(calculateHoloWorkBoardBonuses(summary, boards, connections).a.lesson, 4.5); + const awakened = { ...connections, cards: connections.cards.map((card) => ({ ...card, awakening: 5 })) }; + assert.equal(calculateHoloWorkBoardBonuses(summary, boards, awakened).a.lesson, 6); + assert.equal(calculateHoloWorkBoardBonuses(summary, boards).a.lesson, 3); + assert.equal(calculateHoloWorkBoardBonuses(summary, { a: [] }, connections).a.lesson, 0); + assert.equal(calculateHoloWorkBoardBonuses(summary, boards, { ...connections, placements: { a: { 'S-2': 'card' } } }).a.lesson, 3); +}); + +test('조합 내 멤버의 보상 종류별 보너스만 합산하며 중복 멤버는 한 번만 센다', () => { + const bonuses = calculateHoloWorkBoardBonuses(summary, { a: ['Y-1', 'Y-2'], b: ['Y-1', 'Y-3'] }, connections); + assert.deepEqual(sumHoloWorkBoardBonuses(bonuses, ['a', 'b', 'c', 'd', 'e']), { lesson: 10.5, cube: 4, training: 8 }); + assert.deepEqual(sumHoloWorkBoardBonuses(bonuses, ['a', 'a']), bonuses.a); + assert.deepEqual(sumHoloWorkBoardBonuses(bonuses, []), { lesson: 0, cube: 0, training: 0 }); +}); diff --git a/src/lib/engine/holo-work.test.ts b/src/lib/engine/holo-work.test.ts new file mode 100644 index 0000000..0ab55ae --- /dev/null +++ b/src/lib/engine/holo-work.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createUserData, type CardListPreferences } from '../models/user-data.ts'; +import { parseUserData } from '../storage/browser-storage.ts'; +import { adjustWorkTeam, parseWorkTeams, workAchievement } from '../storage/holo-work.ts'; + +const preferences: CardListPreferences = { + types: [], stats: [], activeConditions: [], specialConditions: [], activeProbabilities: [], + cardVariants: [], generations: [], sortKey: null, sortDirection: 'asc', skillLevel: 1 +}; + +test('사용자 데이터 JSON은 홀로워크 기록과 카드·보드 설정을 함께 복원한다', () => { + const data = createUserData(preferences); + data.holoWorkCounts = { memberA: 15, memberB: 0, futureMember: 200 }; + data.holoWorkTeams = [{ id: 'team1', name: '매일 조합', memberIds: ['a', 'b', 'c', 'd', 'e'] }]; + data.collection = { cardA: { owned: true, awakening: 3 } }; + data.memberBoards = { memberA: ['node1'] }; + assert.deepEqual(parseUserData(JSON.stringify(data), preferences), data); +}); + +test('홀로워크 필드가 없는 기존 백업은 빈 기록으로 복원한다', () => { + const { holoWorkCounts, holoWorkTeams, ...oldBackup } = createUserData(preferences); + assert.deepEqual(parseUserData(JSON.stringify(oldBackup), preferences).holoWorkCounts, {}); + assert.deepEqual(parseUserData(JSON.stringify(oldBackup), preferences).holoWorkTeams, []); +}); + +test('업적 달성 즉시 다음 요구량으로 넘어가며 최종 목표는 유지한다', () => { + const thresholds = [1, 5, 10, 30, 50, 100, 200, 300, 400]; + assert.deepEqual(workAchievement(0, thresholds), { target: 1, complete: false }); + assert.deepEqual(workAchievement(12, thresholds), { target: 30, complete: false }); + for (let i = 0; i < thresholds.length; i++) { + assert.deepEqual(workAchievement(thresholds[i] - 1, thresholds), { target: thresholds[i], complete: false }); + assert.deepEqual(workAchievement(thresholds[i], thresholds), { target: thresholds[i + 1] ?? 400, complete: i === thresholds.length - 1 }); + } + assert.deepEqual(workAchievement(500, thresholds), { target: 400, complete: true }); + assert.deepEqual(workAchievement(12, []), { target: null, complete: false }); +}); + +test('조합은 서로 다른 멤버 5명이어야 하고 중복 ID나 빈 이름을 거부한다', () => { + const team = { id: 'a', name: '조합', memberIds: ['a', 'b', 'c', 'd', 'e'] }; + for (const memberIds of [[], ['a', 'b', 'c', 'd'], ['a', 'b', 'c', 'd', 'a'], ['a', 'b', 'c', 'd', 'e', 'f']]) { + assert.throws(() => parseWorkTeams([{ ...team, memberIds }])); + } + assert.throws(() => parseWorkTeams([team, team])); + assert.throws(() => parseWorkTeams([{ ...team, name: ' ' }])); + assert.deepEqual(parseWorkTeams([team]), [team]); +}); + +test('조합 일괄 변경은 다섯 명만 바꾸고 하나라도 범위를 벗어나면 모두 유지한다', () => { + const ids = ['a', 'b', 'c', 'd', 'e']; + const counts = { a: 1, b: 3, c: 5, d: 10, e: 399, other: 99 }; + const incremented = adjustWorkTeam(counts, ids, 1); + assert.deepEqual(incremented, { a: 2, b: 4, c: 6, d: 11, e: 400, other: 99 }); + assert.deepEqual(adjustWorkTeam(incremented, ids, -1), counts); + assert.throws(() => adjustWorkTeam({ ...counts, e: 0 }, ids, -1)); + assert.throws(() => adjustWorkTeam({ ...counts, e: Number.MAX_SAFE_INTEGER }, ids, 1)); + assert.equal(counts.a, 1); +}); + +test('잘못된 홀로워크 횟수가 있는 백업은 거부한다', () => { + for (const value of [-1, 1.5, '3', null, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => parseUserData(JSON.stringify({ ...createUserData(preferences), holoWorkCounts: { a: value } }), preferences)); + } + for (const value of [null, [], 'invalid']) { + assert.throws(() => parseUserData(JSON.stringify({ ...createUserData(preferences), holoWorkCounts: value }), preferences)); + } +}); diff --git a/src/lib/models/default-preferences.ts b/src/lib/models/default-preferences.ts new file mode 100644 index 0000000..fad3268 --- /dev/null +++ b/src/lib/models/default-preferences.ts @@ -0,0 +1,17 @@ +import cardSummary from '$lib/data/cards-summary.json'; +import type { CardListPreferences } from './user-data'; + +export function defaultPreferences(): CardListPreferences { + return { + types: ['큐트', '퓨어', '해피'], + stats: ['퍼포먼스', '테크닉', '센스'], + activeConditions: ['일반', '조건부'], + specialConditions: ['일반', '조건부'], + activeProbabilities: ['높음', '중간'], + cardVariants: ['일반', ...new Set(cardSummary.cards.filter((card) => card.rarity === 5).map((card) => card.versionName).filter(Boolean))], + generations: [...new Set(cardSummary.cards.filter((card) => card.rarity === 5).map((card) => card.generation).filter(Boolean))], + sortKey: null, + sortDirection: 'asc', + skillLevel: 1 + }; +} diff --git a/src/lib/models/user-data.ts b/src/lib/models/user-data.ts index 565ad79..efe428d 100644 --- a/src/lib/models/user-data.ts +++ b/src/lib/models/user-data.ts @@ -31,9 +31,13 @@ export type CardListPreferences = { export type BoardCardPlacements = Record>; +export type HoloWorkTeam = { id: string; name: string; memberIds: string[] }; + export type UserDataV1 = { schemaVersion: 1; updatedAt: string; + holoWorkCounts: Record; + holoWorkTeams: HoloWorkTeam[]; memoryCount: number; memberPowerUpPercent: number; collection: Record; @@ -49,6 +53,8 @@ export function createUserData(preferences: CardListPreferences): UserDataV1 { return { schemaVersion: 1, updatedAt: new Date().toISOString(), + holoWorkCounts: {}, + holoWorkTeams: [], memoryCount: 0, memberPowerUpPercent: 0, collection: {}, diff --git a/src/lib/storage/browser-storage.ts b/src/lib/storage/browser-storage.ts index b812e62..5515c62 100644 --- a/src/lib/storage/browser-storage.ts +++ b/src/lib/storage/browser-storage.ts @@ -1,5 +1,6 @@ +import { parseWorkCounts, parseWorkTeams } from './holo-work.ts'; import type { CardListPreferences, CardListSortKey, CardSkillLevel, CollectionCardState, SortDirection, UserData } from '$lib/models/user-data'; -import { createUserData } from '$lib/models/user-data'; +import { createUserData } from '../models/user-data.ts'; export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1'; @@ -80,6 +81,8 @@ export function parseUserData(text: string, fallbackPreferences: CardListPrefere return { schemaVersion: 1, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : defaults.updatedAt, + holoWorkCounts: parseWorkCounts(value.holoWorkCounts), + holoWorkTeams: parseWorkTeams(value.holoWorkTeams), memoryCount: Math.max(0, Math.round(finiteNumber(value.memoryCount))), memberPowerUpPercent: Math.max(0, Math.min(50, finiteNumber(value.memberPowerUpPercent))), collection: parseCollection(value.collection), diff --git a/src/lib/storage/holo-work.ts b/src/lib/storage/holo-work.ts new file mode 100644 index 0000000..8c28d60 --- /dev/null +++ b/src/lib/storage/holo-work.ts @@ -0,0 +1,46 @@ +import type { HoloWorkTeam } from '../models/user-data.ts'; + +export type HoloWorkCounts = Record; + +export function isWorkCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +export function parseWorkCounts(value: unknown): HoloWorkCounts { + if (value === undefined) return {}; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('올바른 홀로워크 기록이 아닙니다.'); + } + const entries = Object.entries(value); + if (entries.some(([, count]) => !isWorkCount(count))) throw new Error('횟수는 0 이상의 정수여야 합니다.'); + return Object.fromEntries(entries); +} + +export function parseWorkTeams(value: unknown): HoloWorkTeam[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error('올바른 홀로워크 조합 목록이 아닙니다.'); + const ids = new Set(); + return value.map((team) => { + if (!team || typeof team !== 'object' || typeof team.id !== 'string' || !team.id.trim() + || ids.has(team.id) || typeof team.name !== 'string' || !team.name.trim() || team.name.trim().length > 80 + || !Array.isArray(team.memberIds) || team.memberIds.length !== 5 + || team.memberIds.some((id: unknown) => typeof id !== 'string' || !id.trim()) + || new Set(team.memberIds).size !== 5) { + throw new Error('홀로워크 조합은 이름과 서로 다른 멤버 5명이 필요합니다.'); + } + ids.add(team.id); + return { id: team.id, name: team.name.trim(), memberIds: [...team.memberIds] }; + }); +} + +export function workAchievement(count: number, thresholds: readonly number[]) { + const target = thresholds.find((threshold) => threshold > count) ?? thresholds.at(-1) ?? null; + return { target, complete: target !== null && count >= target }; +} + +export function adjustWorkTeam(counts: HoloWorkCounts, memberIds: readonly string[], amount: 1 | -1): HoloWorkCounts { + if (memberIds.length !== 5 || new Set(memberIds).size !== 5) throw new Error('서로 다른 멤버 5명이 필요합니다.'); + const changes = memberIds.map((id) => [id, (counts[id] ?? 0) + amount] as const); + if (changes.some(([, value]) => !isWorkCount(value))) throw new Error('변경할 수 없는 횟수가 포함되어 있습니다.'); + return { ...counts, ...Object.fromEntries(changes) }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 19a9aad..a5d2d77 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -48,7 +48,7 @@ 06 -

도구

악곡 랜덤 등 플레이에 유용한 도구를 이용합니다. 원하는 레벨 범위에서 악곡과 난이도를 뽑을 수 있습니다.

+

도구

악곡과 난이도를 랜덤으로 뽑거나, 홀로워크에 보낸 횟수를 멤버별로 기록하고 확인합니다.

diff --git a/src/routes/cards/+page.svelte b/src/routes/cards/+page.svelte index 92677f0..4d8686c 100644 --- a/src/routes/cards/+page.svelte +++ b/src/routes/cards/+page.svelte @@ -45,6 +45,8 @@ $: currentUserData = { schemaVersion: 1, updatedAt: new Date().toISOString(), + holoWorkCounts: {}, + holoWorkTeams: [], memoryCount, memberPowerUpPercent, collection, @@ -56,7 +58,8 @@ $: if (storageReady) { try { - saveBrowserData(currentUserData); + const { holoWorkCounts, holoWorkTeams } = loadBrowserData(defaultPreferences()); + saveBrowserData({ ...currentUserData, holoWorkCounts, holoWorkTeams }); } catch {} } diff --git a/src/routes/data/+page.svelte b/src/routes/data/+page.svelte index e61d4a5..b9fa8da 100644 --- a/src/routes/data/+page.svelte +++ b/src/routes/data/+page.svelte @@ -10,6 +10,7 @@ import { calculateBoardEffects, calculateUnitPower, cardMatchesSearch, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts'; import { memoryParameterPermil } from '$lib/engine/memory.ts'; import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data'; + import { defaultPreferences } from '$lib/models/default-preferences'; import { createUserData } from '$lib/models/user-data'; import { clearBrowserData, loadBrowserData, parseUserData, saveBrowserData } from '$lib/storage/browser-storage'; @@ -100,21 +101,6 @@ const boardNodeGroups = boardSummary.nodeGroups as Record; const emptyCardState: CollectionCardState = { owned: false, awakening: 0 }; - function defaultPreferences(): CardListPreferences { - return { - types: ['큐트', '퓨어', '해피'], - stats: ['퍼포먼스', '테크닉', '센스'], - activeConditions: ['일반', '조건부'], - specialConditions: ['일반', '조건부'], - activeProbabilities: ['높음', '중간'], - cardVariants: ['일반', ...new Set(fiveStarCards.map((card) => card.versionName).filter(Boolean))], - generations: [...new Set(fiveStarCards.map((card) => card.generation).filter(Boolean))], - sortKey: null, - sortDirection: 'asc', - skillLevel: 1 - }; - } - let userData = createUserData(defaultPreferences()); let message = ''; let isError = false; @@ -379,7 +365,8 @@ } function persist(next = userData) { - userData = saveBrowserData(next); + const { holoWorkCounts, holoWorkTeams } = loadBrowserData(defaultPreferences()); + userData = saveBrowserData({ ...next, holoWorkCounts, holoWorkTeams }); greenEfficiencyResult = null; greenEfficiencyError = ''; showMessage('변경 내용을 이 브라우저에 저장했습니다.'); @@ -1004,7 +991,8 @@ function exportUserData() { try { - userData = saveBrowserData(userData); + const { holoWorkCounts, holoWorkTeams } = loadBrowserData(defaultPreferences()); + userData = saveBrowserData({ ...userData, holoWorkCounts, holoWorkTeams }); const url = URL.createObjectURL(new Blob([JSON.stringify(userData, null, 2)], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; diff --git a/src/routes/tools/+page.svelte b/src/routes/tools/+page.svelte index 4c72649..9011140 100644 --- a/src/routes/tools/+page.svelte +++ b/src/routes/tools/+page.svelte @@ -1,5 +1,6 @@ + + + 홀로워크 관리 · 도구 · 홀로도리 계산기 + + + +
+

도구

플레이할 때 유용한 도구를 모았습니다.

+ +

홀로워크 관리

+

각 멤버를 Holo워크에 보낸 횟수를 기록하세요. 기존 횟수를 직접 입력하거나, 보낼 때마다 +1을 누르면 됩니다.

+

횟수 / 다음 업적 요구량으로 표시합니다. 업적은 Holo워크 완료 횟수 기준이며, 400회 달성 후에는 최종 목표와 완료 표시를 유지합니다.

+

보드 보너스는 사용자 데이터의 멤버 보드에 저장된 활성 노드와 연결 카드 증폭을 반영합니다. 조합 합계는 보상 종류별 획득량 증가율의 합입니다.

+
+
전체 파견 횟수{ready ? total.toLocaleString('ko-KR') : '—'}회
+
파견한 멤버{ready ? recorded : '—'}/ {members.length}명
+
+

{status}

+ {#if error}{/if} + {#if !ready}{/if} +
+

{editingId ? '조합 수정' : '5명 조합 저장'}

+
+ + +
+ {#each selectedMembers as memberId, index} + + {/each} +
+
+ + {#if editingId}{/if} +
+
+
+
+

저장한 조합 {teams.length}개

+

같은 멤버의 횟수는 모든 조합에 함께 반영됩니다. 조합을 삭제해도 횟수는 유지됩니다.

+ {#each teams as team (team.id)} +
+

{team.name}

+
    + {#each team.memberIds as id} +
  • {members.find((member) => member.id === id)?.name ?? '알 수 없는 멤버'}
  • + {/each} +
+

조합 보드 보너스 합계

+
+ + + + +
+ {#if deletingId === team.id} +

‘{team.name}’ 조합을 삭제할까요? 횟수는 유지됩니다.

+ {/if} +
+ {:else}

저장한 조합이 없습니다. 서로 다른 멤버 5명을 선택해 저장하세요.

{/each} +
+

멤버별 기록

+
+ + +
+
    + {#each visibleMembers as member (member.id)} +
  • +
    {member.name}
    +
    + + changeInput(member.id, event.currentTarget)} aria-label={`${member.name} 파견 횟수`} /> + 회 + +
    +
  • + {:else}
  • 검색에 맞는 멤버가 없습니다.
  • {/each} +
+
+ + diff --git a/vite.config.ts b/vite.config.ts index f2c2394..f28f08d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ // Ignoring all of .svelte-kit leaves cached page imports pointing at old route IDs. watch: { usePolling: process.env.WATCH_USE_POLLING === 'true', - interval: 1000 + interval: 1000, + ignored: ['**/output/**', '**/pages-dist/**', '**/offline-dist/**', '**/release/**'] } } });