Files
HolodoriCalc/src/lib/engine/board-awakening.test.ts
T
dal4segno 1b7889e574
Publish GitHub Pages output / publish (push) Canceled after 37s
feat: filter awakening candidates and search connection chains
2026-10-01 22:39:28 +09:00

98 lines
7.6 KiB
TypeScript

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