feat: add visual green board activation presets
Publish GitHub Pages output / publish (push) Successful in 31s

This commit is contained in:
2026-09-21 10:24:36 +09:00
parent 4c049b0860
commit 3b1c1aeb6f
4 changed files with 101 additions and 13 deletions
+5 -1
View File
@@ -1,9 +1,13 @@
--- ---
updatedAt: 2026-09-21T00:42:09Z updatedAt: 2026-09-21T01:10:33Z
--- ---
## 2026년 9월 21일 ## 2026년 9월 21일
### 초록 보드 간편 활성화 확장
`IMPROVED` 모든 멤버의 초록 보드를 3칸·5칸·7칸 중 선택해 활성화할 수 있습니다. 5칸은 기존 세로 3칸의 가운데 양옆을, 7칸은 그 아래 2칸을 추가합니다. 이미 활성화한 다른 노드는 유지됩니다. 모든 멤버 간편 설정의 3칸·5칸·7칸 버튼에서 적용 범위를 작은 보드 그림으로 확인할 수 있습니다.
### 사이트 주소 변경 사전 안내 ### 사이트 주소 변경 사전 안내
`NOTICE` 모든 페이지 상단에 2026년 9월 28일 00:00(한국 시간) doricalc.cc 이전 예정 안내를 추가했습니다. 주소 변경 후 기존 저장 데이터를 사용하려면 JSON 백업 내보내기·가져오기가 필요합니다. 안내에서 사용자 데이터로 바로 이동할 수 있으며, 이전 직전 최신 데이터로 다시 백업해 주세요. `NOTICE` 모든 페이지 상단에 2026년 9월 28일 00:00(한국 시간) doricalc.cc 이전 예정 안내를 추가했습니다. 주소 변경 후 기존 저장 데이터를 사용하려면 JSON 백업 내보내기·가져오기가 필요합니다. 안내에서 사용자 데이터로 바로 이동할 수 있으며, 이전 직전 최신 데이터로 다시 백업해 주세요.
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { activateGreenNodePreset, type GreenNodePreset } from './board-green-presets.ts';
const summary = JSON.parse(readFileSync(new URL('../data/boards-summary.json', import.meta.url), 'utf8'));
const expected: Record<GreenNodePreset, string[]> = {
3: ['G-001', 'G-002', 'G-005'],
5: ['G-001', 'G-002', 'G-003', 'G-004', 'G-005'],
7: ['G-001', 'G-002', 'G-003', 'G-004', 'G-005', 'G-006', 'G-007']
};
for (const count of [3, 5, 7] as const) {
test(`${count}칸은 모든 멤버의 지정 초록 노드만 활성화한다`, () => {
const result = activateGreenNodePreset({}, summary.members, summary.models, count);
assert.equal(Object.keys(result).length, summary.members.length);
for (const member of summary.members) {
assert.deepEqual(result[member.id].toSorted(), expected[count], member.id);
}
});
}
test('7칸 적용 후 3칸·5칸을 반복 적용해도 기존 활성 상태를 보존하며 원본을 변경하지 않는다', () => {
const memberId = summary.members[0].id;
const original = { [memberId]: ['B-001', 'Y-001', 'G-001', 'G-008'], other: ['R-001'] };
const snapshot = structuredClone(original);
const seven = activateGreenNodePreset(original, summary.members, summary.models, 7);
const three = activateGreenNodePreset(seven, summary.members, summary.models, 3);
const five = activateGreenNodePreset(three, summary.members, summary.models, 5);
assert.deepEqual(original, snapshot);
assert.deepEqual(five, seven);
assert.deepEqual(five[memberId].toSorted(), ['B-001', ...expected[7], 'G-008', 'Y-001']);
assert.deepEqual(five.other, ['R-001']);
});
test('선택 좌표에 다른 색 노드가 있으면 활성화하지 않는다', () => {
const models = { model: [{ groupId: 'B-001', x: 0, y: -1 }, { groupId: 'Y-001', x: -1, y: -2 }] };
assert.deepEqual(activateGreenNodePreset({}, [{ id: 'a', positionGroupId: 'model' }], models, 7), { a: [] });
});
+29
View File
@@ -0,0 +1,29 @@
export type GreenNodePreset = 3 | 5 | 7;
type BoardPosition = { groupId: string; x: number; y: number };
type BoardMember = { id: string; positionGroupId: string };
// Ordered so the first 3, 5 or 7 coordinates define each preset and its preview.
export const greenNodePresetPositions = [
{ x: 0, y: -1 }, { x: 0, y: -2 }, { x: 0, y: -3 },
{ x: -1, y: -2 }, { x: 1, y: -2 },
{ x: 0, y: -4 }, { x: 0, y: -5 }
] as const;
/** Add the central green branch without resetting any existing board progress. */
export function activateGreenNodePreset(
memberBoards: Readonly<Record<string, readonly string[]>>,
members: readonly BoardMember[],
models: Readonly<Record<string, readonly BoardPosition[]>>,
count: GreenNodePreset
): Record<string, string[]> {
const result = Object.fromEntries(Object.entries(memberBoards).map(([id, nodes]) => [id, [...nodes]]));
const coordinates = greenNodePresetPositions.slice(0, count);
for (const member of members) {
const nodes = (models[member.positionGroupId] ?? []).filter(({ groupId, x, y }) =>
groupId.startsWith('G-') && coordinates.some((position) => position.x === x && position.y === y)
);
result[member.id] = [...new Set([...(result[member.id] ?? []), ...nodes.map((node) => node.groupId)])];
}
return result;
}
+28 -12
View File
@@ -7,6 +7,7 @@
import memorySummary from '$lib/data/memory-summary.json'; import memorySummary from '$lib/data/memory-summary.json';
import MemberBoardEffectSummary from '$lib/components/MemberBoardEffectSummary.svelte'; import MemberBoardEffectSummary from '$lib/components/MemberBoardEffectSummary.svelte';
import { summarizeMemberBoardEffects } from '$lib/engine/board-effects'; import { summarizeMemberBoardEffects } from '$lib/engine/board-effects';
import { activateGreenNodePreset, greenNodePresetPositions, type GreenNodePreset } from '$lib/engine/board-green-presets.ts';
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';
@@ -539,16 +540,10 @@
}); });
} }
function activateCommonGreenNodesForAllMembers() { function activateCommonGreenNodesForAllMembers(count: GreenNodePreset) {
const memberBoards = { ...userData.memberBoards }; const memberBoards = activateGreenNodePreset(userData.memberBoards, boardMembers, boardModels, count);
for (const member of boardMembers) {
const commonGreenNodes = boardModels[member.positionGroupId]
.filter((position) => position.x === 0 && [-1, -2, -3].includes(position.y) && boardColor(position.groupId) === 'green')
.map((position) => position.groupId);
memberBoards[member.id] = [...new Set([...(memberBoards[member.id] ?? []), ...commonGreenNodes])];
}
persist({ ...userData, memberBoards }); persist({ ...userData, memberBoards });
showMessage('모든 멤버의 중심 아래 초록 노드 3개를 활성화했습니다.'); showMessage(`모든 멤버의 초록 ${count}칸을 활성화했습니다. 기존 활성 노드는 유지됩니다.`);
} }
function toggleBoardNode(groupId: string) { function toggleBoardNode(groupId: string) {
@@ -1204,11 +1199,25 @@
</aside> </aside>
<div class="automatic-placement"> <div class="automatic-placement">
<div> <div>
<strong>간편 설정</strong> <strong>모든 멤버 간편 설정</strong>
<small>모든 멤버의 기본 초록 노드를 활성화하거나, 기존 배치를 유지한 채 중앙 연결 노드의 빈 슬롯에 보유 카드를 자동 배치합니다.</small> <small>모든 멤버의 기본 초록 노드를 활성화하거나, 기존 배치를 유지한 채 중앙 연결 노드의 빈 슬롯에 보유 카드를 자동 배치합니다.</small>
<small>초록색으로 채워진 노드가 적용 범위입니다. 맨 위 마름모는 중앙 연결 노드이며, 기존 활성 노드는 유지됩니다.</small>
</div> </div>
<div class="automatic-placement-actions"> <div class="automatic-placement-actions">
<button type="button" on:click={activateCommonGreenNodesForAllMembers}>모든 멤버 초록 3칸 활성화</button> <div class="green-presets" role="group" aria-label="모든 멤버 초록 노드 활성화">
{#each [3, 5, 7] as count}
<button type="button" class="green-preset" aria-label={`모든 멤버 초록 ${count}칸 활성화`} on:click={() => activateCommonGreenNodesForAllMembers(count as GreenNodePreset)}>
<svg viewBox="0 0 76 126" aria-hidden="true">
<path class="preset-edge" d="M38 12V112 M18 52H58" />
<path class="preset-center" d="M38 5L45 12L38 19L31 12Z" />
{#each greenNodePresetPositions as position, index}
<circle class="preset-node" class:included={index < count} cx={38 + position.x * 20} cy={12 - position.y * 20} r="6" />
{/each}
</svg>
<span>{count}칸</span>
</button>
{/each}
</div>
{#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if} {#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if}
<button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button> <button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button>
</div> </div>
@@ -1616,7 +1625,14 @@
.automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; } .automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; }
.automatic-placement strong { color: #b7dec7; font-size: 14px; } .automatic-placement strong { color: #b7dec7; font-size: 14px; }
.automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; } .automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; }
.automatic-placement-actions { display: flex; flex: 0 0 auto; gap: 7px; } .automatic-placement-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.green-presets { display: flex; gap: 7px; }
.automatic-placement button.green-preset { display: grid; justify-items: center; gap: 4px; width: 78px; padding: 8px; }
.green-preset svg { display: block; width: 60px; height: 100px; }
.preset-edge { fill: none; stroke: #4e6658; stroke-width: 2; }
.preset-center { fill: #bec8d5; stroke: #e4ebf2; stroke-width: 1.5; }
.preset-node { fill: #202d26; stroke: #71867a; stroke-width: 1.5; }
.preset-node.included { fill: #86e3a6; stroke: #b5f5ca; stroke-width: 2; }
.automatic-placement button { min-height: 34px; padding: 7px 11px; border: 1px solid #466151; border-radius: 8px; color: #b9d7c5; background: #202d26; font-size: 12px; font-weight: 800; cursor: pointer; } .automatic-placement button { min-height: 34px; padding: 7px 11px; border: 1px solid #466151; border-radius: 8px; color: #b9d7c5; background: #202d26; font-size: 12px; font-weight: 800; cursor: pointer; }
.automatic-placement button.primary { border-color: #62aa7e; color: #101913; background: #78c493; } .automatic-placement button.primary { border-color: #62aa7e; color: #101913; background: #78c493; }
.automatic-placement button.undo { color: #c4ccd5; background: transparent; } .automatic-placement button.undo { color: #c4ccd5; background: transparent; }