import assert from 'node:assert/strict'; import test from 'node:test'; import { searchBoardConnectionChain } from './board-connection-chain.ts'; import { readFileSync } from 'node:fs'; import { boardInvestments, recommendBoardAwakening, type BoardAwakeningData, type BoardAwakeningRequest } from './board-awakening.ts'; const catalog = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')) as { cards: BoardAwakeningData['cards'] }; const memory = JSON.parse(readFileSync(new URL('../data/memory-summary.json', import.meta.url), 'utf8')); test('최소 개화는 투자 후보에만 적용하고 미보유 포함은 별도로 선택한다', () => { const cards = ['zero', 'three', 'four', 'five', 'new'].map((id) => ({ id, rarity: 5 })); const collection = Object.fromEntries([['zero', 0], ['three', 3], ['four', 4], ['five', 5]].map(([id, awakening]) => [id, { owned: true, awakening: Number(awakening) }])); assert.deepEqual(boardInvestments(cards, collection, 4, false).map((r) => r.cardId), ['four']); assert.deepEqual(boardInvestments(cards, collection, 3, false).map((r) => [r.cardId, r.copies]), [['three', 2], ['four', 1]]); assert.deepEqual(boardInvestments(cards, collection, 4, true).map((r) => [r.cardId, r.to]), [['four', 5], ['new', 0], ['new', 5]]); assert.equal(boardInvestments(cards, collection, 0, false).length, 3); assert.throws(() => boardInvestments(cards, collection, -1), /최소 개화/); }); test('대상 카드와 밀려난 두 장의 3단계 연쇄를 찾으며 중복 배치·입력 변경을 하지 않는다', () => { const source = { members: [{ id: 'm', positionGroupId: 'model' }], models: { model: [0, 4, 8].map((x, i) => ({ groupId: `S${i}`, x, y: 0 })) }, nodeGroups: Object.fromEntries([0, 1, 2].map((i) => [`S${i}`, [{ characterIds: [], type: 'connection', effect: null }]])) }; const cards = ['target', 'a', 'b', 'c'].map((id) => ({ id, awakening: 0, connect: [{ level: 1, effectPermilUp: 1000, extent: [] }] })); const initial = { m: { S0: 'a', S1: 'b', S2: 'c' } }; const result = searchBoardConnectionChain(source, { m: ['S1', 'S2'] }, cards, [], initial, 'target', (placements) => { const ids = Object.values(placements).flatMap(Object.values); assert.equal(new Set(ids).size, ids.length); const maximum = (placements.m?.S0 === 'target' ? 10 : 0) + (placements.m?.S1 === 'a' ? 5 : 0) + (placements.m?.S2 === 'b' ? 2 : 0); return { maximum, expected: maximum, placements }; }); assert.equal(result.maximum, 17); assert.deepEqual(initial, { m: { S0: 'a', S1: 'b', S2: 'c' } }); }); test('연쇄 배치에서도 편성 멤버의 큰 파란 발동률 노드 범위를 지킨다', () => { const source = { members: [{ id: 'm', positionGroupId: 'model' }], models: { model: [{ groupId: 'S', x: 7, y: 0 }, { groupId: 'B', x: 5, y: 0 }] }, nodeGroups: { S: [{ characterIds: [], type: 'connection', effect: null }], B: [{ characterIds: [], type: 'card', grade: 2, effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 60 } }] } }; const cards = [{ id: 'a', awakening: 0, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -2, y: 0 }] }] }, { id: 'target', awakening: 5, connect: [{ level: 2, effectPermilUp: 2000, extent: [{ x: 2, y: 0 }] }] }]; const result = searchBoardConnectionChain(source, { m: ['S', 'B'] }, cards, ['m'], { m: { S: 'a' } }, 'target', (placements) => ({ maximum: placements.m?.S === 'target' ? 100 : 1, expected: 1, placements })); assert.equal(result.placements.m.S, 'a'); }); function fixture() { const cards = ['a', 'b', 'c', 'd', 'e', 'new'].map((id) => { const card = structuredClone(catalog.cards.find((card) => card.rarity === 5)!); card.id = id; card.characterId = id; card.name = id; card.details.connect = card.details.connect.map((effect, i) => ({ ...effect, level: i + 1, effectPermilUp: i ? 2000 : 1000, extent: [{ x: 1, y: 0 }] })); return card; }); const boards = { members: [{ id: 'a', positionGroupId: 'model' }], models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'G', x: 1, y: 0 }] }, nodeGroups: { S: [{ characterIds: [], type: 'connection', effect: null }], G: [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 10000, targetId: 'skill_tree_effect_target-skill_tree_character' } }] } }; const data: BoardAwakeningData = { cards, boards, memoryEffects: memory.effects }; const ids = ['a', 'b', 'c', 'd', 'e']; const request: BoardAwakeningRequest = { settings: { songId: 'test', difficulty: 'expert', cardIds: ids, awakenings: [0, 0, 0, 0, 0], leaderCharacterId: 'a', leaderOutfitCardId: 'a' }, growth: { collection: {}, memberBoards: { a: ['G'] }, boardCards: {}, memoryCount: 0, memberPowerUpPercent: 0 }, chart: { songId: 'test', difficulty: 'expert', durationSeconds: 8, scoreRatio: 30, notes: Array.from({ length: 8 }, (_, i) => ({ timeSeconds: i + 1, type: 'regular' as const, isFlick: false })), specialStartSeconds: [1, 2, 3, 4, 5] }, song: { scoreRatio: 30 }, order: ids, cooldownReductions: Object.fromEntries(ids.map((id) => [id, 0])) }; return { data, request }; } test('5성만 대상으로 보유 개화 잔여 수와 미보유 첫 획득·5개화 비용을 비교한다', () => { const rows = boardInvestments([{ id: 'a', rarity: 5 }, { id: 'b', rarity: 5 }, { id: 'c', rarity: 5 }, { id: 'd', rarity: 4 }], { a: { owned: true, awakening: 3 }, b: { owned: true, awakening: 5 } }); assert.deepEqual(rows, [{ cardId: 'a', from: 3, to: 5, copies: 2 }, { cardId: 'c', from: null, to: 0, copies: 1 }, { cardId: 'c', from: null, to: 5, copies: 6 }]); }); test('미보유 카드가 연결에 들어가 이득을 내지만 편성·입력은 바뀌지 않는다', () => { const { data, request } = fixture(); const before = structuredClone(request); const result = recommendBoardAwakening(data, request); const acquire = result.rows.find((row) => row.cardId === 'new' && row.to === 0)!; const max = result.rows.find((row) => row.cardId === 'new' && row.to === 5)!; assert.ok(acquire.maximumGain > 0); assert.ok(max.maximumGain > acquire.maximumGain); assert.equal(acquire.best.placements.a.S, 'new'); assert.deepEqual(request, before); }); test('활성 효과가 없으면 편성 카드의 가상 5개화가 자체 능력·스킬을 올리지 않는다', () => { const { data, request } = fixture(); request.growth.memberBoards = {}; request.growth.collection.a = { owned: true, awakening: 0 }; const result = recommendBoardAwakening(data, request); assert.ok(result.rows.every((row) => row.maximumGain === 0 && row.expectedGain === 0 && row.best.power === result.current.power)); }); test('무료 재배치는 투자 전 기준에 포함하며 이미 같은 연결 효과가 있으면 획득 이득은 0이다', () => { const { data, request } = fixture(); request.growth.collection.a = { owned: true, awakening: 0 }; const result = recommendBoardAwakening(data, request); assert.ok(result.baseline.maximum > result.current.maximum); assert.equal(result.rows.find((row) => row.cardId === 'new' && row.to === 0)!.maximumGain, 0); const row = result.rows.find((row) => row.cardId === 'a')!; assert.ok(row.maximumGain > 0); assert.equal(row.copies, 5); }); test('가창 보너스 수동 보정과 비활성 보드 효과를 유지한다', () => { const { data, request } = fixture(); request.settings.songScoreBonusOverride = 10; const result = recommendBoardAwakening(data, request); const plain = fixture(); const previous = recommendBoardAwakening(plain.data, plain.request); assert.ok(result.baseline.maximum > previous.baseline.maximum); assert.ok(result.rows.every((row) => Number.isFinite(row.gainPerCopy))); });