feat: recommend card investments for board connections
Publish GitHub Pages output / publish (push) Successful in 35s

This commit is contained in:
2026-09-30 19:02:43 +09:00
parent 115dc33937
commit de165bb53d
8 changed files with 428 additions and 1 deletions
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
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'));
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)));
});