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}{error}
{/if}
+ {#if !ready}{/if}
+
+ {editingId ? '조합 수정' : '5명 조합 저장'}
+
+
+
+ 저장한 조합 {teams.length}개
+ 같은 멤버의 횟수는 모든 조합에 함께 반영됩니다. 조합을 삭제해도 횟수는 유지됩니다.
+ {#each teams as team (team.id)}
+
+ {team.name}
+
+ 조합 보드 보너스 합계
+
+
+
+
+
+
+ {#if deletingId === team.id}
+ ‘{team.name}’ 조합을 삭제할까요? 횟수는 유지됩니다.
+ {/if}
+
+ {:else}저장한 조합이 없습니다. 서로 다른 멤버 5명을 선택해 저장하세요.
{/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/**']
}
}
});