feat: add collapsible member board effect summaries
Publish GitHub Pages output / publish (push) Successful in 46s

This commit is contained in:
2026-09-02 10:30:51 +09:00
parent a920d65a21
commit 7308428945
6 changed files with 326 additions and 3 deletions
+74 -1
View File
@@ -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<UnitStat, number>;
flatByArea: Record<'B' | 'G', Record<UnitStat, number>>;
rate: Record<UnitStat, number>;
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<Record<string, readonly string[]>>,
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<UnitStat, number> => {
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<string, { template: string; value: number; nodeCount: number; boosted: boolean }>();
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<Record<string, readonly string[]>>,
+107
View File
@@ -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 });
});
+13
View File
@@ -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 })),