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:
@@ -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. */
|
||||
export function summarizeMemberBoardEffects(
|
||||
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 HoloWorkTeam = { id: string; name: string; memberIds: string[] };
|
||||
|
||||
export type UserDataV1 = {
|
||||
schemaVersion: 1;
|
||||
updatedAt: string;
|
||||
holoWorkCounts: Record<string, number>;
|
||||
holoWorkTeams: HoloWorkTeam[];
|
||||
memoryCount: number;
|
||||
memberPowerUpPercent: number;
|
||||
collection: Record<string, CollectionCardState>;
|
||||
@@ -49,6 +53,8 @@ export function createUserData(preferences: CardListPreferences): UserDataV1 {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
holoWorkCounts: {},
|
||||
holoWorkTeams: [],
|
||||
memoryCount: 0,
|
||||
memberPowerUpPercent: 0,
|
||||
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 { 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),
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user