feat: add Holo-work tracking, teams and board bonuses
Publish GitHub Pages output / publish (push) Successful in 30s
Publish GitHub Pages output / publish (push) Successful in 30s
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Repository instructions
|
# 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
|
## 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.
|
- When the user says "마무리 해" (or "마무리해"), treat it as authorization to verify the completed task, commit its changes, and push to the configured upstream.
|
||||||
|
|||||||
+11
-1
@@ -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일
|
## 2026년 9월 10일
|
||||||
|
|
||||||
### 악곡 랜덤 선택
|
### 악곡 랜덤 선택
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- node_modules:/app/node_modules
|
- node_modules:/app/node_modules
|
||||||
|
- svelte_kit:/app/.svelte-kit
|
||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
healthcheck:
|
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))"]
|
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:
|
volumes:
|
||||||
node_modules:
|
node_modules:
|
||||||
|
svelte_kit:
|
||||||
sqlite_data:
|
sqlite_data:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": "2026-09-11T01:18:17.159Z",
|
"generatedAt": "2026-09-12T03:46:50.302Z",
|
||||||
"sources": {
|
"sources": {
|
||||||
"eng": {
|
"eng": {
|
||||||
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
|
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": "2026-09-11T01:18:17.159Z",
|
"generatedAt": "2026-09-12T03:46:50.302Z",
|
||||||
"sources": {
|
"sources": {
|
||||||
"eng": {
|
"eng": {
|
||||||
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
|
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": "2026-09-11T01:18:17.159Z",
|
"generatedAt": "2026-09-12T03:46:50.302Z",
|
||||||
"source": "HolodoriDB community master-data diff repositories",
|
"source": "HolodoriDB community master-data diff repositories",
|
||||||
"sourceLicense": null,
|
"sourceLicense": null,
|
||||||
"sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.",
|
"sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import AnalysisPage from '../src/routes/analysis/+page.svelte';
|
import AnalysisPage from '../src/routes/analysis/+page.svelte';
|
||||||
import RatingPage from '../src/routes/rating/+page.svelte';
|
import RatingPage from '../src/routes/rating/+page.svelte';
|
||||||
import CombinationPage from '../src/routes/combination/+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 ToolsPage from '../src/routes/tools/+page.svelte';
|
||||||
import EventPage from '../src/routes/event/+page.svelte';
|
import EventPage from '../src/routes/event/+page.svelte';
|
||||||
import DataPage from '../src/routes/data/+page.svelte';
|
import DataPage from '../src/routes/data/+page.svelte';
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
['/combination', CombinationPage],
|
['/combination', CombinationPage],
|
||||||
['/tools', ToolsPage],
|
['/tools', ToolsPage],
|
||||||
['/tools/random', ToolsPage],
|
['/tools/random', ToolsPage],
|
||||||
|
['/tools/holo-work', HoloWorkPage],
|
||||||
['/random', ToolsPage],
|
['/random', ToolsPage],
|
||||||
['/event', EventPage],
|
['/event', EventPage],
|
||||||
['/data', DataPage],
|
['/data', DataPage],
|
||||||
|
|||||||
@@ -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}.`);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HoloWorkBoardBonus } from '$lib/engine/board-effects';
|
||||||
|
export let bonus: HoloWorkBoardBonus | undefined;
|
||||||
|
export let showEmpty = false;
|
||||||
|
const labels = { lesson: '레슨 Pt', cube: '큐브', training: '특훈 아이템' };
|
||||||
|
$: entries = (Object.keys(labels) as (keyof HoloWorkBoardBonus)[]).filter((key) => (bonus?.[key] ?? 0) > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if entries.length}
|
||||||
|
<div class="bonuses" aria-label="홀로워크 보드 보너스">
|
||||||
|
{#each entries as key}<span>{labels[key]} <strong>+{bonus![key].toLocaleString('ko-KR', { maximumFractionDigits: 3 })}%</strong></span>{/each}
|
||||||
|
</div>
|
||||||
|
{:else if showEmpty}<p class="empty">활성 보드 보너스 없음</p>{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bonuses { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.bonuses span { padding: 5px 8px; border: 1px solid #65552c; border-radius: 7px; background: rgb(226 190 95 / 8%); color: #d9c890; font-size: 12px; line-height: 1.5; }
|
||||||
|
strong { color: #f1d583; white-space: nowrap; }
|
||||||
|
.empty { margin: 0; color: #a4adba; font-size: 13px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import achievements from '$lib/data/holo-work-achievements.json';
|
||||||
|
import { workAchievement } from '$lib/storage/holo-work';
|
||||||
|
export let memberId: string;
|
||||||
|
export let count: number;
|
||||||
|
$: progress = workAchievement(count, (achievements.thresholdsByMember as Record<string, number[]>)[memberId] ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="progress" class:complete={progress.complete}>
|
||||||
|
<strong>{count.toLocaleString('ko-KR')}</strong> / {progress.target?.toLocaleString('ko-KR') ?? '—'}회
|
||||||
|
{#if progress.complete}<small>완료</small>{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.progress { white-space: nowrap; color: #a4adba; font-variant-numeric: tabular-nums; font-size: 14px; }
|
||||||
|
strong { color: #f3b4d0; font-size: 17px; }
|
||||||
|
.complete strong, small { color: #98dbbc; }
|
||||||
|
small { margin-left: 6px; font-size: 12px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let active: 'random' | 'holo-work';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav aria-label="도구 메뉴">
|
||||||
|
<a href="/tools/random" aria-current={active === 'random' ? 'page' : undefined}>악곡 랜덤</a>
|
||||||
|
<a href="/tools/holo-work" aria-current={active === 'holo-work' ? 'page' : undefined}>홀로워크 관리</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
|
||||||
|
a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
|
||||||
|
a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
|
||||||
|
a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
|
||||||
|
a:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
|
||||||
|
</style>
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Record<string, readonly string[]>>,
|
||||||
|
connections?: BoardConnections | null
|
||||||
|
): Record<string, HoloWorkBoardBonus> {
|
||||||
|
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<Record<string, HoloWorkBoardBonus>>, 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. */
|
/** Saved-board overview, without assuming a leader or a song. Conditional effects stay in the detail rows. */
|
||||||
export function summarizeMemberBoardEffects(
|
export function summarizeMemberBoardEffects(
|
||||||
summary: BoardSummarySource,
|
summary: BoardSummarySource,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,9 +31,13 @@ export type CardListPreferences = {
|
|||||||
|
|
||||||
export type BoardCardPlacements = Record<string, Record<string, string>>;
|
export type BoardCardPlacements = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
|
export type HoloWorkTeam = { id: string; name: string; memberIds: string[] };
|
||||||
|
|
||||||
export type UserDataV1 = {
|
export type UserDataV1 = {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
holoWorkCounts: Record<string, number>;
|
||||||
|
holoWorkTeams: HoloWorkTeam[];
|
||||||
memoryCount: number;
|
memoryCount: number;
|
||||||
memberPowerUpPercent: number;
|
memberPowerUpPercent: number;
|
||||||
collection: Record<string, CollectionCardState>;
|
collection: Record<string, CollectionCardState>;
|
||||||
@@ -49,6 +53,8 @@ export function createUserData(preferences: CardListPreferences): UserDataV1 {
|
|||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
holoWorkCounts: {},
|
||||||
|
holoWorkTeams: [],
|
||||||
memoryCount: 0,
|
memoryCount: 0,
|
||||||
memberPowerUpPercent: 0,
|
memberPowerUpPercent: 0,
|
||||||
collection: {},
|
collection: {},
|
||||||
|
|||||||
@@ -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 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';
|
export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1';
|
||||||
|
|
||||||
@@ -80,6 +81,8 @@ export function parseUserData(text: string, fallbackPreferences: CardListPrefere
|
|||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : defaults.updatedAt,
|
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))),
|
memoryCount: Math.max(0, Math.round(finiteNumber(value.memoryCount))),
|
||||||
memberPowerUpPercent: Math.max(0, Math.min(50, finiteNumber(value.memberPowerUpPercent))),
|
memberPowerUpPercent: Math.max(0, Math.min(50, finiteNumber(value.memberPowerUpPercent))),
|
||||||
collection: parseCollection(value.collection),
|
collection: parseCollection(value.collection),
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { HoloWorkTeam } from '../models/user-data.ts';
|
||||||
|
|
||||||
|
export type HoloWorkCounts = Record<string, number>;
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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) };
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a class="feature-card" href="/tools">
|
<a class="feature-card" href="/tools">
|
||||||
<span class="feature-number">06</span>
|
<span class="feature-number">06</span>
|
||||||
<div><h3>도구</h3><p>악곡 랜덤 등 플레이에 유용한 도구를 이용합니다. 원하는 레벨 범위에서 악곡과 난이도를 뽑을 수 있습니다.</p></div>
|
<div><h3>도구</h3><p>악곡과 난이도를 랜덤으로 뽑거나, 홀로워크에 보낸 횟수를 멤버별로 기록하고 확인합니다.</p></div>
|
||||||
<span class="arrow" aria-hidden="true">→</span>
|
<span class="arrow" aria-hidden="true">→</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
$: currentUserData = {
|
$: currentUserData = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
holoWorkCounts: {},
|
||||||
|
holoWorkTeams: [],
|
||||||
memoryCount,
|
memoryCount,
|
||||||
memberPowerUpPercent,
|
memberPowerUpPercent,
|
||||||
collection,
|
collection,
|
||||||
@@ -56,7 +58,8 @@
|
|||||||
|
|
||||||
$: if (storageReady) {
|
$: if (storageReady) {
|
||||||
try {
|
try {
|
||||||
saveBrowserData(currentUserData);
|
const { holoWorkCounts, holoWorkTeams } = loadBrowserData(defaultPreferences());
|
||||||
|
saveBrowserData({ ...currentUserData, holoWorkCounts, holoWorkTeams });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { calculateBoardEffects, calculateUnitPower, cardMatchesSearch, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts';
|
import { calculateBoardEffects, calculateUnitPower, cardMatchesSearch, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts';
|
||||||
import { memoryParameterPermil } from '$lib/engine/memory.ts';
|
import { memoryParameterPermil } from '$lib/engine/memory.ts';
|
||||||
import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data';
|
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 { createUserData } from '$lib/models/user-data';
|
||||||
import { clearBrowserData, loadBrowserData, parseUserData, saveBrowserData } from '$lib/storage/browser-storage';
|
import { clearBrowserData, loadBrowserData, parseUserData, saveBrowserData } from '$lib/storage/browser-storage';
|
||||||
|
|
||||||
@@ -100,21 +101,6 @@
|
|||||||
const boardNodeGroups = boardSummary.nodeGroups as Record<string, BoardNode[]>;
|
const boardNodeGroups = boardSummary.nodeGroups as Record<string, BoardNode[]>;
|
||||||
const emptyCardState: CollectionCardState = { owned: false, awakening: 0 };
|
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 userData = createUserData(defaultPreferences());
|
||||||
let message = '';
|
let message = '';
|
||||||
let isError = false;
|
let isError = false;
|
||||||
@@ -379,7 +365,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function persist(next = userData) {
|
function persist(next = userData) {
|
||||||
userData = saveBrowserData(next);
|
const { holoWorkCounts, holoWorkTeams } = loadBrowserData(defaultPreferences());
|
||||||
|
userData = saveBrowserData({ ...next, holoWorkCounts, holoWorkTeams });
|
||||||
greenEfficiencyResult = null;
|
greenEfficiencyResult = null;
|
||||||
greenEfficiencyError = '';
|
greenEfficiencyError = '';
|
||||||
showMessage('변경 내용을 이 브라우저에 저장했습니다.');
|
showMessage('변경 내용을 이 브라우저에 저장했습니다.');
|
||||||
@@ -1004,7 +991,8 @@
|
|||||||
|
|
||||||
function exportUserData() {
|
function exportUserData() {
|
||||||
try {
|
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 url = URL.createObjectURL(new Blob([JSON.stringify(userData, null, 2)], { type: 'application/json' }));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
import ToolsNav from '$lib/components/ToolsNav.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
||||||
import { difficultyLabel } from '$lib/data/difficulty.ts';
|
import { difficultyLabel } from '$lib/data/difficulty.ts';
|
||||||
@@ -93,9 +94,7 @@
|
|||||||
<p>플레이할 때 유용한 도구를 모았습니다.</p>
|
<p>플레이할 때 유용한 도구를 모았습니다.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tools-nav" aria-label="도구 메뉴">
|
<ToolsNav active="random" />
|
||||||
<a href="/tools/random" aria-current="page">악곡 랜덤</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="tool-heading">
|
<div class="tool-heading">
|
||||||
<h2>악곡 랜덤</h2>
|
<h2>악곡 랜덤</h2>
|
||||||
@@ -165,11 +164,6 @@
|
|||||||
main { width: min(760px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
|
main { width: min(760px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
|
||||||
h1 { margin: 0 0 14px; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.04em; }
|
h1 { margin: 0 0 14px; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.04em; }
|
||||||
header > p:last-child { color: #a4adba; line-height: 1.7; }
|
header > p:last-child { color: #a4adba; line-height: 1.7; }
|
||||||
.tools-nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
|
|
||||||
.tools-nav a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
|
|
||||||
.tools-nav a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
|
|
||||||
.tools-nav a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
|
|
||||||
.tools-nav a:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
|
|
||||||
.tool-heading p { color: #a4adba; line-height: 1.7; margin-bottom: 0; }
|
.tool-heading p { color: #a4adba; line-height: 1.7; margin-bottom: 0; }
|
||||||
.panel, .result { margin-top: 26px; padding: 26px; border: 1px solid #343c49; border-radius: 16px; background: #151a22; }
|
.panel, .result { margin-top: 26px; padding: 26px; border: 1px solid #343c49; border-radius: 16px; background: #151a22; }
|
||||||
.toggle { display: flex; align-items: center; gap: 10px; font-weight: 800; }
|
.toggle { display: flex; align-items: center; gap: 10px; font-weight: 800; }
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import ToolsNav from '$lib/components/ToolsNav.svelte';
|
||||||
|
import HoloWorkBonuses from '$lib/components/HoloWorkBonuses.svelte';
|
||||||
|
import cardSummary from '$lib/data/cards-summary.json';
|
||||||
|
import { calculateHoloWorkBoardBonuses, sumHoloWorkBoardBonuses, type HoloWorkBoardBonus } from '$lib/engine/board-effects';
|
||||||
|
import HoloWorkProgress from '$lib/components/HoloWorkProgress.svelte';
|
||||||
|
import boardSummary from '$lib/data/boards-summary.json';
|
||||||
|
import { adjustWorkTeam, isWorkCount, parseWorkTeams, type HoloWorkCounts } from '$lib/storage/holo-work';
|
||||||
|
import type { HoloWorkTeam } from '$lib/models/user-data';
|
||||||
|
import { USER_DATA_STORAGE_KEY, loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
|
||||||
|
import { defaultPreferences } from '$lib/models/default-preferences';
|
||||||
|
|
||||||
|
const members = [...boardSummary.members].sort((a, b) => a.order - b.order);
|
||||||
|
let bonuses: Record<string, HoloWorkBoardBonus> = {};
|
||||||
|
let counts: HoloWorkCounts = {};
|
||||||
|
let teams: HoloWorkTeam[] = [];
|
||||||
|
let teamName = '';
|
||||||
|
let selectedMembers = ['', '', '', '', ''];
|
||||||
|
let editingId = '';
|
||||||
|
let deletingId = '';
|
||||||
|
$: validTeam = Boolean(teamName.trim()) && selectedMembers.every((id) => members.some((member) => member.id === id)) && new Set(selectedMembers).size === 5;
|
||||||
|
let ready = false;
|
||||||
|
let search = '';
|
||||||
|
let sort = 'member';
|
||||||
|
let status = '';
|
||||||
|
let error = '';
|
||||||
|
$: visibleMembers = members.filter((member) => member.name.replace(/\s/g, '').toLocaleLowerCase().includes(search.replace(/\s/g, '').toLocaleLowerCase()))
|
||||||
|
.sort((a, b) => (sort === 'asc' ? (counts[a.id] ?? 0) - (counts[b.id] ?? 0) : sort === 'desc' ? (counts[b.id] ?? 0) - (counts[a.id] ?? 0) : 0) || a.order - b.order);
|
||||||
|
$: total = members.reduce((sum, member) => sum + BigInt(counts[member.id] ?? 0), 0n);
|
||||||
|
$: recorded = members.filter((member) => (counts[member.id] ?? 0) > 0).length;
|
||||||
|
|
||||||
|
function loadLatestData() {
|
||||||
|
const data = loadBrowserData(defaultPreferences());
|
||||||
|
bonuses = calculateHoloWorkBoardBonuses(boardSummary, data.memberBoards, {
|
||||||
|
placements: data.boardCards,
|
||||||
|
cards: cardSummary.cards.filter((card) => card.details.connect.length > 0).map((card) => ({
|
||||||
|
id: card.id, awakening: data.collection[card.id]?.awakening ?? 0, connect: card.details.connect
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
try {
|
||||||
|
const data = loadLatestData();
|
||||||
|
counts = data.holoWorkCounts;
|
||||||
|
teams = data.holoWorkTeams;
|
||||||
|
ready = true;
|
||||||
|
error = '';
|
||||||
|
} catch {
|
||||||
|
ready = false;
|
||||||
|
error = '저장된 기록을 읽을 수 없습니다. 브라우저 저장소 설정이나 기록을 확인한 뒤 다시 불러와 주세요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(id: string, value: number) {
|
||||||
|
if (!ready) return;
|
||||||
|
if (!isWorkCount(value)) {
|
||||||
|
error = '횟수는 0 이상의 정수로 입력해 주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Read the latest document before changing one member to preserve other tabs' edits.
|
||||||
|
const data = loadLatestData();
|
||||||
|
const next = { ...data.holoWorkCounts, [id]: value };
|
||||||
|
saveBrowserData({ ...data, holoWorkCounts: next });
|
||||||
|
counts = next;
|
||||||
|
error = '';
|
||||||
|
status = `${members.find((member) => member.id === id)?.name}: ${value.toLocaleString('ko-KR')}회 저장했습니다.`;
|
||||||
|
} catch {
|
||||||
|
error = '기록을 저장하지 못했습니다. 브라우저 저장소 설정을 확인한 뒤 다시 시도해 주세요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInput(id: string, input: HTMLInputElement) {
|
||||||
|
update(id, input.value === '' ? NaN : input.valueAsNumber);
|
||||||
|
input.value = String(counts[id] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjust(id: string, amount: number) {
|
||||||
|
try {
|
||||||
|
const latest = loadLatestData().holoWorkCounts;
|
||||||
|
update(id, (latest[id] ?? 0) + amount);
|
||||||
|
} catch { read(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEditor() {
|
||||||
|
editingId = '';
|
||||||
|
teamName = '';
|
||||||
|
selectedMembers = ['', '', '', '', ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTeam(team: HoloWorkTeam) {
|
||||||
|
editingId = team.id;
|
||||||
|
teamName = team.name;
|
||||||
|
selectedMembers = [...team.memberIds];
|
||||||
|
document.getElementById('team-name')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTeam() {
|
||||||
|
if (!ready || !validTeam) return;
|
||||||
|
try {
|
||||||
|
const data = loadLatestData();
|
||||||
|
if (editingId && !data.holoWorkTeams.some((team) => team.id === editingId)) throw new Error('다른 탭에서 삭제된 조합입니다. 새 조합으로 저장해 주세요.');
|
||||||
|
const team = { id: editingId || crypto.randomUUID(), name: teamName.trim(), memberIds: [...selectedMembers] };
|
||||||
|
const next = parseWorkTeams(editingId ? data.holoWorkTeams.map((item) => item.id === editingId ? team : item) : [...data.holoWorkTeams, team]);
|
||||||
|
saveBrowserData({ ...data, holoWorkTeams: next });
|
||||||
|
counts = data.holoWorkCounts;
|
||||||
|
teams = next;
|
||||||
|
clearEditor();
|
||||||
|
error = '';
|
||||||
|
status = `${team.name} 조합을 저장했습니다.`;
|
||||||
|
} catch (cause) { error = cause instanceof Error ? cause.message : '조합을 저장하지 못했습니다.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTeam(id: string) {
|
||||||
|
try {
|
||||||
|
const data = loadLatestData();
|
||||||
|
const next = data.holoWorkTeams.filter((team) => team.id !== id);
|
||||||
|
saveBrowserData({ ...data, holoWorkTeams: next });
|
||||||
|
counts = data.holoWorkCounts;
|
||||||
|
teams = next;
|
||||||
|
deletingId = '';
|
||||||
|
if (editingId === id) clearEditor();
|
||||||
|
error = '';
|
||||||
|
status = '조합을 삭제했습니다. 멤버별 횟수는 유지됩니다.';
|
||||||
|
} catch { error = '조합을 삭제하지 못했습니다. 다시 시도해 주세요.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustTeam(id: string, amount: 1 | -1) {
|
||||||
|
try {
|
||||||
|
const data = loadLatestData();
|
||||||
|
const team = data.holoWorkTeams.find((item) => item.id === id);
|
||||||
|
if (!team || team.memberIds.some((id) => !members.some((member) => member.id === id))) throw new Error('조합의 멤버를 확인해 주세요.');
|
||||||
|
const next = adjustWorkTeam(data.holoWorkCounts, team.memberIds, amount);
|
||||||
|
saveBrowserData({ ...data, holoWorkCounts: next });
|
||||||
|
counts = next;
|
||||||
|
teams = data.holoWorkTeams;
|
||||||
|
error = '';
|
||||||
|
status = `${team.name}: 5명 모두 ${amount === 1 ? '1회 추가' : '1회 감소'}했습니다.`;
|
||||||
|
} catch (cause) { error = cause instanceof Error ? cause.message : '횟수를 저장하지 못했습니다.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
read();
|
||||||
|
const sync = (event: StorageEvent) => {
|
||||||
|
if (event.storageArea === localStorage && (event.key === USER_DATA_STORAGE_KEY || event.key === null)) {
|
||||||
|
read();
|
||||||
|
status = '다른 탭의 기록을 반영했습니다.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', sync);
|
||||||
|
return () => window.removeEventListener('storage', sync);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>홀로워크 관리 · 도구 · 홀로도리 계산기</title>
|
||||||
|
<meta name="description" content="멤버별 Holo워크 파견 횟수를 기록하고 확인하세요." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header><h1>도구</h1><p>플레이할 때 유용한 도구를 모았습니다.</p></header>
|
||||||
|
<ToolsNav active="holo-work" />
|
||||||
|
<h2>홀로워크 관리</h2>
|
||||||
|
<p class="description">각 멤버를 Holo워크에 보낸 횟수를 기록하세요. 기존 횟수를 직접 입력하거나, 보낼 때마다 +1을 누르면 됩니다.</p>
|
||||||
|
<p class="hint">횟수 / 다음 업적 요구량으로 표시합니다. 업적은 Holo워크 <strong>완료 횟수</strong> 기준이며, 400회 달성 후에는 최종 목표와 완료 표시를 유지합니다.</p>
|
||||||
|
<p class="hint">보드 보너스는 <a href="/data/boards">사용자 데이터의 멤버 보드</a>에 저장된 활성 노드와 연결 카드 증폭을 반영합니다. 조합 합계는 보상 종류별 획득량 증가율의 합입니다.</p>
|
||||||
|
<section class="summary" aria-label="파견 기록 요약">
|
||||||
|
<div><span>전체 파견 횟수</span><strong>{ready ? total.toLocaleString('ko-KR') : '—'}<small>회</small></strong></div>
|
||||||
|
<div><span>파견한 멤버</span><strong>{ready ? recorded : '—'}<small>/ {members.length}명</small></strong></div>
|
||||||
|
</section>
|
||||||
|
<p role="status" class="status">{status}</p>
|
||||||
|
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||||
|
{#if !ready}<button class="reload" on:click={read}>기록 다시 불러오기</button>{/if}
|
||||||
|
<section class="team-panel" aria-labelledby="team-editor-title">
|
||||||
|
<h2 id="team-editor-title">{editingId ? '조합 수정' : '5명 조합 저장'}</h2>
|
||||||
|
<form on:submit|preventDefault={saveTeam}>
|
||||||
|
<label for="team-name">조합 이름</label>
|
||||||
|
<input id="team-name" bind:value={teamName} maxlength="80" placeholder="예: 매일 보내는 조합" required disabled={!ready} />
|
||||||
|
<div class="member-selects">
|
||||||
|
{#each selectedMembers as memberId, index}
|
||||||
|
<label>멤버 {index + 1}
|
||||||
|
<select bind:value={selectedMembers[index]} required disabled={!ready}>
|
||||||
|
<option value="" disabled>멤버 선택</option>
|
||||||
|
{#each members as member}
|
||||||
|
<option value={member.id} disabled={selectedMembers.includes(member.id) && memberId !== member.id}>{member.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="team-actions">
|
||||||
|
<button class="plus" type="submit" disabled={!ready || !validTeam}>{editingId ? '변경 저장' : '조합 저장'}</button>
|
||||||
|
{#if editingId}<button type="button" on:click={clearEditor}>수정 취소</button>{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="saved-teams" aria-labelledby="saved-teams-title">
|
||||||
|
<h2 id="saved-teams-title">저장한 조합 <small>{teams.length}개</small></h2>
|
||||||
|
<p class="hint">같은 멤버의 횟수는 모든 조합에 함께 반영됩니다. 조합을 삭제해도 횟수는 유지됩니다.</p>
|
||||||
|
{#each teams as team (team.id)}
|
||||||
|
<article class="team-panel" aria-label={`${team.name} 조합`}>
|
||||||
|
<h3>{team.name}</h3>
|
||||||
|
<ul class="team-members">
|
||||||
|
{#each team.memberIds as id}
|
||||||
|
<li><div class="member-info"><span>{members.find((member) => member.id === id)?.name ?? '알 수 없는 멤버'}</span><HoloWorkBonuses bonus={bonuses[id]} /></div><HoloWorkProgress memberId={id} count={counts[id] ?? 0} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="team-bonus-total"><h4>조합 보드 보너스 합계</h4><HoloWorkBonuses bonus={sumHoloWorkBoardBonuses(bonuses, team.memberIds)} showEmpty /></div>
|
||||||
|
<div class="team-actions">
|
||||||
|
<button class="plus" on:click={() => adjustTeam(team.id, 1)} disabled={!ready || team.memberIds.some((id) => (counts[id] ?? 0) >= Number.MAX_SAFE_INTEGER)}>5명 모두 +1</button>
|
||||||
|
<button on:click={() => adjustTeam(team.id, -1)} disabled={!ready || team.memberIds.some((id) => !(counts[id] > 0))}>5명 모두 −1</button>
|
||||||
|
<button on:click={() => editTeam(team)} disabled={!ready}>수정</button>
|
||||||
|
<button on:click={() => deletingId = team.id} disabled={!ready}>삭제</button>
|
||||||
|
</div>
|
||||||
|
{#if deletingId === team.id}
|
||||||
|
<div class="delete-confirm"><p>‘{team.name}’ 조합을 삭제할까요? 횟수는 유지됩니다.</p><button on:click={() => deleteTeam(team.id)}>삭제 확인</button> <button on:click={() => deletingId = ''}>취소</button></div>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{:else}<p class="hint">저장한 조합이 없습니다. 서로 다른 멤버 5명을 선택해 저장하세요.</p>{/each}
|
||||||
|
</section>
|
||||||
|
<h2 class="all-members-title">멤버별 기록</h2>
|
||||||
|
<div class="filters">
|
||||||
|
<label>멤버 검색<input type="search" bind:value={search} placeholder="멤버 이름 입력" /></label>
|
||||||
|
<label>정렬<select bind:value={sort}><option value="member">멤버순</option><option value="asc">횟수 적은 순</option><option value="desc">횟수 많은 순</option></select></label>
|
||||||
|
</div>
|
||||||
|
<ul aria-label="멤버별 Holo워크 횟수">
|
||||||
|
{#each visibleMembers as member (member.id)}
|
||||||
|
<li>
|
||||||
|
<div class="member-info"><strong>{member.name}</strong><HoloWorkProgress memberId={member.id} count={counts[member.id] ?? 0} /><HoloWorkBonuses bonus={bonuses[member.id]} /></div>
|
||||||
|
<div class="counter">
|
||||||
|
<button on:click={() => adjust(member.id, -1)} disabled={!ready || !(counts[member.id] > 0)} aria-label={`${member.name} 횟수 1 감소`}>−1</button>
|
||||||
|
<input type="number" min="0" max={Number.MAX_SAFE_INTEGER} step="1" value={counts[member.id] ?? 0} disabled={!ready} on:change={(event) => changeInput(member.id, event.currentTarget)} aria-label={`${member.name} 파견 횟수`} />
|
||||||
|
<span>회</span>
|
||||||
|
<button class="plus" on:click={() => adjust(member.id, 1)} disabled={!ready || counts[member.id] === Number.MAX_SAFE_INTEGER} aria-label={`${member.name} 횟수 1 증가`}>+1</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{:else}<li class="empty">검색에 맞는 멤버가 없습니다.</li>{/each}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main { width: min(760px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
|
||||||
|
h1 { margin: 0 0 14px; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.04em; }
|
||||||
|
h2 { margin: 0; font-size: 22px; }
|
||||||
|
header p, .description { color: #a4adba; line-height: 1.7; }
|
||||||
|
.hint { color: #a4adba; font-size: 13px; line-height: 1.7; }
|
||||||
|
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
|
||||||
|
.summary div { padding: 20px; border: 1px solid #694254; border-radius: 12px; background: rgb(237 139 181 / 6%); }
|
||||||
|
.summary span { color: #a4adba; font-size: 13px; }
|
||||||
|
.summary strong { display: block; margin-top: 10px; color: #f3b4d0; font-size: 26px; overflow-wrap: anywhere; }
|
||||||
|
small { margin-left: 7px; color: #a4adba; font-size: 13px; font-weight: 400; }
|
||||||
|
.filters { display: grid; grid-template-columns: 1fr 150px; gap: 12px; }
|
||||||
|
label { display: grid; gap: 8px; color: #a4adba; font-size: 13px; }
|
||||||
|
input, select { box-sizing: border-box; min-width: 0; width: 100%; height: 44px; padding: 0 12px; border: 1px solid #414b5b; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; }
|
||||||
|
.status { min-height: 20px; color: #f3b4d0; font-size: 13px; }
|
||||||
|
.error { color: #f3a1a1; line-height: 1.7; }
|
||||||
|
ul { padding: 0; list-style: none; border: 1px solid #343c49; border-radius: 14px; background: #151a22; overflow: hidden; }
|
||||||
|
li { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px 20px; border-bottom: 1px solid #343c49; }
|
||||||
|
li:last-child { border-bottom: 0; }
|
||||||
|
.member-info > strong { line-height: 1.6; }
|
||||||
|
.counter { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||||
|
.counter input { width: 96px; text-align: center; font-variant-numeric: tabular-nums; }
|
||||||
|
.counter span { color: #a4adba; font-size: 13px; }
|
||||||
|
button { min-width: 44px; min-height: 44px; border: 1px solid #414b5b; border-radius: 9px; background: #222936; color: #e8ebf1; font: inherit; font-weight: 700; cursor: pointer; }
|
||||||
|
button.plus { background: #ed8bb5; border-color: #ed8bb5; color: #21121a; }
|
||||||
|
button:hover:not(:disabled) { filter: brightness(1.15); }
|
||||||
|
button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
|
||||||
|
.reload { padding: 0 14px; }
|
||||||
|
.empty { color: #a4adba; }
|
||||||
|
.hint a { color: #f3b4d0; }
|
||||||
|
.team-bonus-total { padding: 14px; margin: 14px 0; background: rgb(226 190 95 / 5%); border: 1px solid #65552c; border-radius: 10px; }
|
||||||
|
.team-bonus-total h4 { margin: 0 0 10px; color: #e8d69d; font-size: 13px; }
|
||||||
|
.member-info { display: grid; gap: 6px; }
|
||||||
|
.team-panel { margin: 20px 0; padding: 20px; border: 1px solid #343c49; border-radius: 14px; background: #151a22; }
|
||||||
|
form { display: grid; gap: 12px; margin-top: 18px; }
|
||||||
|
.member-selects { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.team-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
|
.team-actions button, .delete-confirm button { padding: 8px 12px; }
|
||||||
|
.saved-teams { margin-top: 30px; }
|
||||||
|
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
|
||||||
|
.team-members { border: 0; margin: 12px 0; }
|
||||||
|
.team-members li { padding: 10px 0; }
|
||||||
|
.delete-confirm { color: #f3b4d0; font-size: 14px; }
|
||||||
|
.all-members-title { margin: 32px 0 18px; }
|
||||||
|
@media (max-width: 540px) { li { align-items: flex-start; flex-direction: column; gap: 10px; padding: 16px; } .counter { align-self: flex-end; } .summary { gap: 10px; } .summary div { padding: 16px; } .filters { grid-template-columns: minmax(0, 1fr) 130px; } }
|
||||||
|
</style>
|
||||||
+2
-1
@@ -10,7 +10,8 @@ export default defineConfig({
|
|||||||
// Ignoring all of .svelte-kit leaves cached page imports pointing at old route IDs.
|
// Ignoring all of .svelte-kit leaves cached page imports pointing at old route IDs.
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: process.env.WATCH_USE_POLLING === 'true',
|
usePolling: process.env.WATCH_USE_POLLING === 'true',
|
||||||
interval: 1000
|
interval: 1000,
|
||||||
|
ignored: ['**/output/**', '**/pages-dist/**', '**/offline-dist/**', '**/release/**']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user