diff --git a/UPDATES.md b/UPDATES.md index fff565d..5f33ecd 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,5 +1,13 @@ --- -updatedAt: 2026-09-01T22:16:07+09:00 +updatedAt: 2026-09-02T10:13:09+09:00 +--- + +## 2026년 9월 2일 + +### 멤버별 보드 효과 요약 + +`NEW` 보드 데이터에서 선택한 멤버가 받는 기본 파라미터·액티브 발동률과 해당 보드의 색상별 효과 합계를 확인할 수 있습니다. 파라미터 증가율과 파랑·초록 고정 증가분을 분리해 계산 순서를 안내합니다. 연결 카드 증폭을 반영하고 리더·가창·holo워크 등 적용 조건도 함께 표시합니다. + --- ## 2026년 9월 1일 diff --git a/src/lib/components/MemberBoardEffectSummary.svelte b/src/lib/components/MemberBoardEffectSummary.svelte new file mode 100644 index 0000000..9a34239 --- /dev/null +++ b/src/lib/components/MemberBoardEffectSummary.svelte @@ -0,0 +1,106 @@ + + +
+

{memberName} · 보드 효과 요약

+

현재 활성 노드와 연결 카드 기준입니다. 멤버를 바꾸면 해당 멤버의 요약을 확인할 수 있습니다.

+ {#if summary.received} +
이 멤버가 받는 기본 효과
+
+ {#each stats as stat} +
+
{stat.label}
+
+ 기본 능력치에+{format(summary.received.rate[stat.key])}% + 파랑 고정 증가+{format(summary.received.flatByArea.B[stat.key])} + 초록 고정 증가+{format(summary.received.flatByArea.G[stat.key])} +
+
+ {/each} +
+
액티브 발동률 증가
+
+{format(summary.received.activationRateUp)}%
+
+
+

계산 순서: 기본 능력치 × (1 + 증가율) → 파랑 고정 증가분 더하기 → 초록 고정 증가분 더하기

+

파랑·초록 고정 증가분에는 위의 % 증가율을 곱하지 않습니다. 기본 능력치는 개화 보너스 적용 후 기준이며, %로 생기는 증가분은 올림합니다.

+

초록 고정 증가는 모든 멤버 보드의 공통·기수 효과이며, 기수 보너스는 최대 +900을 반영합니다. 리더·가창 조건 효과, 의상·메모리 등은 포함하지 않습니다.

+

발동률 증가는 기본 발동 확률에 곱하는 비율입니다. 예: 기본 50%에 +30% 적용 시 65%.

+ {/if} +
+ 이 멤버 보드의 효과 상세 색상별 합계 · 적용 조건 +

아래는 이 멤버 보드가 제공하는 효과만 합산한 값입니다. 연결 증폭을 포함하며, 기수·가창 보너스의 전체 보드 합산 상한 적용 전 수치입니다.

+
+ {#each summary.groups as group} +
+

{areas[group.area].label}

+

{areas[group.area].condition}

+ {#if group.rows.length} +
    + {#each group.rows as row} +
  • {row.description}{row.nodeCount}개 노드{row.boosted ? ' · 연결 증폭 포함' : ''}
  • + {/each} +
+ {:else} +

활성화된 효과가 없습니다.

+ {/if} +
+ {/each} +
+

발동 빈도는 저장된 노드 효과입니다. 배치 분석·조합 탐색에서는 쿨타임 감소를 별도로 설정하거나 최적화합니다.

+
+
+ + diff --git a/src/lib/engine/board-effects.ts b/src/lib/engine/board-effects.ts index 90993e9..bdf9f6b 100644 --- a/src/lib/engine/board-effects.ts +++ b/src/lib/engine/board-effects.ts @@ -2,7 +2,7 @@ import { skillTargetBelongsTo } from './skill-condition.ts'; import type { CooldownReduction } from './types.ts'; import type { UnitStat } from './unit-power.ts'; -type BoardEffect = { id?: string; type: string; value: number | null; targetId?: string | null; trigger?: string | null }; +type BoardEffect = { id?: string; description?: string; type: string; value: number | null; targetId?: string | null; trigger?: string | null }; type BoardNode = { characterIds: string[]; effect: BoardEffect | null }; type BoardPosition = { groupId: string; x: number; y: number }; type BoardMember = { id: string; positionGroupId: string }; @@ -125,6 +125,79 @@ function connectionMultipliers( return result; } +export type MemberBoardEffectSummary = { + received: { + flat: Record; + flatByArea: Record<'B' | 'G', Record>; + rate: Record; + activationRateUp: number; + } | null; + groups: { + area: string; + rows: { description: string; nodeCount: number; boosted: boolean }[]; + }[]; +}; + +/** Saved-board overview, without assuming a leader or a song. Conditional effects stay in the detail rows. */ +export function summarizeMemberBoardEffects( + summary: BoardSummarySource, + memberBoards: Readonly>, + member: { id: string; name: string }, + card: BoardEffectCard | null, + connections?: BoardConnections | null +): MemberBoardEffectSummary { + const uniqueBoards = Object.fromEntries(Object.entries(memberBoards).map(([id, nodes]) => [id, [...new Set(nodes)]])); + const effects = card ? calculateBoardEffects(summary, uniqueBoards, [card], '', null, connections) : null; + const flatForArea = (area: 'B' | 'G'): Record => { + if (!card) return emptyStats(); + // Filter effects, not active nodes: connection sockets and duplicate-card handling must stay unchanged. + const areaSummary = { ...summary, nodeGroups: Object.fromEntries( + Object.entries(summary.nodeGroups).filter(([groupId]) => groupId.startsWith(area)) + ) }; + return calculateBoardEffects(areaSummary, uniqueBoards, [card], '', null, connections).flatByCardId[card.id]; + }; + const multipliers = connectionMultipliers(summary, uniqueBoards, connections)[member.id] ?? {}; + const active = new Set(memberBoards[member.id] ?? []); + const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 3 }); + const groups = ['B', 'G', 'R', 'Y'].map((area) => { + const rows = new Map(); + for (const groupId of Object.keys(summary.nodeGroups)) { + if (groupId[0] !== area || !active.has(groupId)) continue; + const effect = nodeForMember(summary, groupId, member.id)?.effect; + if (!effect?.description) continue; + const template = effect.description.replaceAll('[character]', member.name).replace(/\s+/g, ' ').trim(); + const numeric = effect.value !== null && /\[value(?:\/10)?\]/.test(template); + // Keep targets, conditions and non-additive skill unlocks separate. + const key = JSON.stringify([effect.type, effect.targetId, effect.trigger, template, numeric ? null : effect.id ?? groupId]); + const multiplier = numeric ? multipliers[groupId] ?? 1 : 1; + const rawValue = (effect.value ?? 0) * multiplier; + const value = affectedStats(effect.type).length > 0 ? Math.ceil(rawValue) : rawValue; + const row = rows.get(key) ?? { template, value: 0, nodeCount: 0, boosted: false }; + row.value += value; + row.nodeCount += 1; + row.boosted ||= multiplier > 1; + rows.set(key, row); + } + return { + area, + rows: [...rows.values()].map((row) => ({ + description: row.template.replaceAll('[value/10]', format(row.value / 10)).replaceAll('[value]', format(row.value)), + nodeCount: row.nodeCount, + boosted: row.boosted + })) + }; + }); + return { + received: effects && card ? { + flat: effects.flatByCardId[card.id], + flatByArea: { B: flatForArea('B'), G: flatForArea('G') }, + rate: effects.rateByCardId[card.id], + activationRateUp: effects.activationRateUpByCardId[card.id] + } : null, + groups + }; +} + export function calculateBoardEffects( summary: BoardSummarySource, memberBoards: Readonly>, diff --git a/src/lib/engine/member-board-summary.test.ts b/src/lib/engine/member-board-summary.test.ts new file mode 100644 index 0000000..975fc10 --- /dev/null +++ b/src/lib/engine/member-board-summary.test.ts @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { calculateBoardEffects, summarizeMemberBoardEffects, type BoardSummarySource, type BoardConnections } from './board-effects.ts'; + +const member = { id: 'a', name: '멤버 A' }; +const card = { id: 'card-a', characterId: 'a', type: '큐트', generation: '0기생' }; +const summary: BoardSummarySource = { + members: [{ id: 'a', positionGroupId: 'model' }], + models: { model: [{ groupId: 'S-1', x: 0, y: 0 }, { groupId: 'B-1', x: 1, y: 0 }] }, + nodeGroups: { + 'B-1': [{ characterIds: [], effect: { type: 'performance_up', value: 5, targetId: 'skill_tree_effect_target-skill_tree_character', description: '[character]의 퍼포먼스 [value] UP' } }], + 'B-2': [{ characterIds: [], effect: { type: 'performance_up', value: 5, targetId: 'skill_tree_effect_target-skill_tree_character', description: '[character]의 퍼포먼스 [value] UP' } }], + 'B-3': [{ characterIds: [], effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 30, description: '발동률 [value/10]% UP' } }], + 'G-1': [{ characterIds: [], effect: { type: 'all_parameter_up', value: 10, description: '전원 전체 파라미터 [value] UP' } }], + 'G-2': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 600, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0', description: '0기생 전체 파라미터 [value] UP' } }], + 'R-1': [{ characterIds: [], effect: { type: 'performance_up_permil_up', value: 40, trigger: 'set_live_leader', description: '전원 퍼포먼스 [value/10]% UP' } }], + 'R-2': [{ characterIds: [], effect: { id: 'conditional-passive_trigger-music_skill_tree_character', type: 'performance_up_permil_up', value: 100, trigger: 'set_live_leader', description: '[character] 가창 시 전원 퍼포먼스 [value/10]% UP' } }], + 'R-3': [{ characterIds: [], effect: { type: 'live_deck_leader_active_skill_addition', value: null, description: 'holo멤버 스킬 습득\n20초마다 라이프 100 회복' } }], + 'Y-1': [{ characterIds: [], effect: { type: 'work_reward_quantity_up_permil_up', value: 50, trigger: 'work', description: '[character] 워크 편성 시 레슨 Pt [value/10]% UP' } }], + 'Y-2': [{ characterIds: [], effect: { type: 'work_reward_quantity_up_permil_up', value: 40, trigger: 'work', description: '[character] 워크 편성 시 큐브 [value/10]% UP' } }], + 'S-1': [{ characterIds: [], effect: null }] + } +}; +const connections: BoardConnections = { + placements: { a: { 'S-1': 'connect' } }, + cards: [{ id: 'connect', awakening: 0, connect: [ + { level: 1, effectPermilUp: 1500, extent: [{ x: 1, y: 0 }] }, + { level: 2, effectPermilUp: 2000, extent: [{ x: 1, y: 0 }] } + ] }] +}; + +test('기본 합계는 다른 보드의 공통 효과와 기수 상한을 포함하고 리더·워크 효과는 제외한다', () => { + const result = summarizeMemberBoardEffects(summary, { + a: ['B-1', 'B-2', 'B-3', 'R-1', 'R-2', 'Y-1'], b: ['G-1', 'G-2'], c: ['G-2'] + }, member, card, connections); + assert.deepEqual(result.received, { + flat: { performance: 928, technique: 910, sense: 910 }, + flatByArea: { + B: { performance: 18, technique: 0, sense: 0 }, + G: { performance: 910, technique: 910, sense: 910 } + }, + rate: { performance: 0, technique: 0, sense: 0 }, activationRateUp: 3 + }); + assert.equal(result.groups.find((group) => group.area === 'G')?.rows.length, 0); +}); + +test('같은 효과는 노드별 증폭·올림 후 합산하며 중복 활성 ID와 연결 노드는 제외한다', () => { + const result = summarizeMemberBoardEffects(summary, { a: ['B-1', 'B-1', 'B-2', 'S-1'] }, member, card, connections); + assert.deepEqual(result.groups[0].rows, [{ description: '멤버 A의 퍼포먼스 18 UP', nodeCount: 2, boosted: true }]); + assert.equal(result.received?.flat.performance, 18); +}); + +test('멤버 전용 노드 변형을 우선하고 서로 다른 조건·보상·비수치 스킬을 유지한다', () => { + const source: BoardSummarySource = { + ...summary, + nodeGroups: { ...summary.nodeGroups, 'B-1': [ + ...summary.nodeGroups['B-1'], + { characterIds: ['a'], effect: { type: 'performance_up', value: 20, description: '[character]의 퍼포먼스 [value] UP' } } + ] } + }; + const result = summarizeMemberBoardEffects(source, { a: ['B-1', 'R-1', 'R-2', 'R-3', 'Y-1', 'Y-2'] }, member, card); + assert.equal(result.groups[0].rows[0].description, '멤버 A의 퍼포먼스 20 UP'); + assert.deepEqual(result.groups[2].rows.map((row) => row.description), [ + '전원 퍼포먼스 4% UP', '멤버 A 가창 시 전원 퍼포먼스 10% UP', 'holo멤버 스킬 습득 20초마다 라이프 100 회복' + ]); + assert.equal(result.groups[3].rows.length, 2); +}); + +test('개화 변경·노드 해제·연결 제거를 새 요약에 반영한다', () => { + const boards = { a: ['B-1'] }; + const awakened = { ...connections, cards: connections.cards.map((entry) => ({ ...entry, awakening: 5 })) }; + assert.equal(summarizeMemberBoardEffects(summary, boards, member, card, awakened).received?.flat.performance, 15); + assert.equal(summarizeMemberBoardEffects(summary, boards, member, card).received?.flat.performance, 5); + assert.deepEqual(summarizeMemberBoardEffects(summary, {}, member, card, awakened).groups.map((group) => group.rows), [[], [], [], []]); +}); + +test('실제 모든 멤버의 전체 활성 보드는 조건 없는 기존 계산과 일치하고 설명 토큰이 남지 않는다', () => { + const source = JSON.parse(readFileSync(new URL('../data/boards-summary.json', import.meta.url), 'utf8')); + const cards = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')).cards; + const boards = Object.fromEntries(source.members.map((entry: { id: string; positionGroupId: string }) => [ + entry.id, source.models[entry.positionGroupId].map((position: { groupId: string }) => position.groupId) + ])); + for (const entry of source.members) { + const memberCard = cards.find((candidate: typeof card) => candidate.characterId === entry.id); + assert.ok(memberCard, `카드 정보 누락: ${entry.name}`); + const result = summarizeMemberBoardEffects(source, boards, entry, memberCard); + const expected = calculateBoardEffects(source, boards, [memberCard], '', null); + assert.deepEqual(result.received?.flat, expected.flatByCardId[memberCard.id]); + assert.deepEqual(result.received?.rate, expected.rateByCardId[memberCard.id]); + for (const stat of ['performance', 'technique', 'sense'] as const) { + assert.equal(result.received!.flatByArea.B[stat] + result.received!.flatByArea.G[stat], result.received!.flat[stat]); + } + assert.ok(result.groups.every((group) => group.rows.length > 0)); + assert.ok(result.groups.flatMap((group) => group.rows).every((row) => !/\[(?:value|character)/.test(row.description))); + } +}); + +test('초록 증가분을 분리해도 연결 소켓 활성 상태와 증폭 올림을 유지한다', () => { + const source: BoardSummarySource = { ...summary, models: { model: [ + { groupId: 'B-socket', x: 0, y: 1 }, { groupId: 'G-1', x: 1, y: 1 }, { groupId: 'S-1', x: 0, y: 0 } + ] } }; + const placed = { ...connections, placements: { a: { 'B-socket': 'connect' } } }; + const result = summarizeMemberBoardEffects(source, { a: ['B-socket', 'G-1'] }, member, card, placed); + assert.deepEqual(result.received?.flatByArea.G, { performance: 25, technique: 25, sense: 25 }); + assert.deepEqual(result.received?.flatByArea.B, { performance: 0, technique: 0, sense: 0 }); +}); diff --git a/src/lib/engine/unit-power.test.ts b/src/lib/engine/unit-power.test.ts index ea19ed2..86b6f00 100644 --- a/src/lib/engine/unit-power.test.ts +++ b/src/lib/engine/unit-power.test.ts @@ -60,6 +60,19 @@ test('보유 메모리 효과는 개화 적용 후 각 멤버의 능력치마다 assert.equal(result.overallPower, 1740); }); +test('보드 고정 증가분에는 보드 퍼센트 증가율을 다시 곱하지 않는다', () => { + const result = calculateUnitPower( + ['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 })), + null, + { + flatByCardId: { a: { performance: 355, technique: 0, sense: 0 } }, + rateByCardId: { a: { performance: 5, technique: 0, sense: 0 } } + } + ); + assert.equal(result.boardTotal, 5 + 355); + assert.equal(result.overallPower, 1500 + 5 + 355); +}); + test('멤버 강화는 메모리를 제외한 파라미터·패시브·의상·보드 합계에 적용한다', () => { const result = calculateUnitPower( ['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 })), diff --git a/src/routes/data/+page.svelte b/src/routes/data/+page.svelte index 8b750ba..158605b 100644 --- a/src/routes/data/+page.svelte +++ b/src/routes/data/+page.svelte @@ -4,6 +4,8 @@ import cardSummary from '$lib/data/cards-summary.json'; import boardSummary from '$lib/data/boards-summary.json'; import memorySummary from '$lib/data/memory-summary.json'; + import MemberBoardEffectSummary from '$lib/components/MemberBoardEffectSummary.svelte'; + import { summarizeMemberBoardEffects } from '$lib/engine/board-effects'; import { calculateBoardEffects, calculateUnitPower, cardMatchesSearch, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts'; import { memoryParameterPermil } from '$lib/engine/memory.ts'; import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data'; @@ -172,6 +174,17 @@ && new Set(efficiencyFormation.cardIds.map((id) => cardById.get(id)?.characterId)).size === 5 && leaderMembers.some((member) => member.characterId === efficiencyFormation.leaderCharacterId); $: dataView = $page.url.pathname.endsWith('/cards') ? 'cards' : $page.url.pathname.endsWith('/boards') ? 'boards' : 'home'; + $: selectedEffectSummary = dataView === 'boards' && selectedBoardMember + ? summarizeMemberBoardEffects(boardSummary, userData.memberBoards, selectedBoardMember, + allCards.find((card) => card.characterId === selectedMemberId) ?? null, { + placements: userData.boardCards, + cards: connectCards.map((card) => ({ + id: card.id, + awakening: userData.collection[card.id]?.awakening ?? 0, + connect: card.details.connect + })) + }) + : null; onMount(() => { try { userData = loadBrowserData(defaultPreferences()); @@ -1475,6 +1488,9 @@ {/if} {/if} + {#if selectedBoardMember && selectedEffectSummary} + + {/if}

전체 보드 재료 합계

노드 포인트{allBoardPointTotal.toLocaleString('ko-KR')} @@ -1602,7 +1618,7 @@ .board-toolbar p strong { color: #f0a1c2; font-size: 16px; } .board-toolbar div { display: flex; gap: 6px; } .board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; } - .board-material-summary { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0 0; padding: 10px; border: 1px solid #303a47; border-radius: 10px; background: #131922; } + .board-material-summary { display: flex; flex-wrap: wrap; gap: 7px; margin: 10px 0 0; padding: 10px; border: 1px solid #303a47; border-radius: 10px; background: #131922; } .board-material-summary h3 { width: 100%; margin: 0 0 3px; color: #dce3eb; font-size: 14px; } .board-material-summary span { display: grid; gap: 2px; min-width: 108px; padding: 7px 9px; border-radius: 7px; background: #1c2430; } .board-material-summary small { color: #8490a0; font-size: 11px; }