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 }); });