feat: filter awakening candidates and search connection chains
Publish GitHub Pages output / publish (push) Canceled after 37s
Publish GitHub Pages output / publish (push) Canceled after 37s
This commit is contained in:
@@ -4,6 +4,10 @@ updatedAt: 2026-09-30T15:11:08.161Z
|
|||||||
|
|
||||||
## 2026년 10월 1일
|
## 2026년 10월 1일
|
||||||
|
|
||||||
|
### 연결노드 개화 추천 범위 선택
|
||||||
|
|
||||||
|
`IMPROVED` 추천 후보의 최소 개화 단계를 선택할 수 있습니다. 기본값은 4개화이며 미보유 카드는 별도로 포함할 수 있습니다. 대상 카드 교체 후 밀려난 카드도 최대 3단계까지 재배치해 비교하며, 단계별 상위 8개 배치를 탐색합니다.
|
||||||
|
|
||||||
### Wonky Monkey 악곡 추가
|
### Wonky Monkey 악곡 추가
|
||||||
|
|
||||||
`NEW` Wonky Monkey의 Easy·Normal·Hard·Expert 채보를 추가했습니다. 네 난이도의 점수 계산과 채보 표시를 지원합니다.
|
`NEW` Wonky Monkey의 Easy·Normal·Hard·Expert 채보를 추가했습니다. 네 난이도의 점수 계산과 채보 표시를 지원합니다.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
export let cardName: (id: string) => string;
|
export let cardName: (id: string) => string;
|
||||||
export let socketName: (member: string, socket: string) => string;
|
export let socketName: (member: string, socket: string) => string;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
|
let minimumAwakening = 4;
|
||||||
|
let includeUnowned = false;
|
||||||
let sortBy: 'maximumGain' | 'gainPerCopy' = 'maximumGain';
|
let sortBy: 'maximumGain' | 'gainPerCopy' = 'maximumGain';
|
||||||
let worker: Worker | null = null;
|
let worker: Worker | null = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
let snapshotKey = '';
|
let snapshotKey = '';
|
||||||
let result: BoardAwakeningResult | null = null;
|
let result: BoardAwakeningResult | null = null;
|
||||||
let progress: BoardAwakeningProgress = { stage: '', completed: 0, total: 1 };
|
let progress: BoardAwakeningProgress = { stage: '', completed: 0, total: 1 };
|
||||||
$: inputKey = JSON.stringify({ settings, growth, order, cooldownReductions, excludedActiveWindows });
|
$: inputKey = JSON.stringify({ settings, growth, order, cooldownReductions, excludedActiveWindows, minimumAwakening, includeUnowned });
|
||||||
$: stale = Boolean(snapshotKey) && snapshotKey !== inputKey;
|
$: stale = Boolean(snapshotKey) && snapshotKey !== inputKey;
|
||||||
$: rows = result ? [...result.rows].sort((a, b) => b[sortBy] - a[sortBy] || b.maximumGain - a.maximumGain || a.copies - b.copies || a.cardId.localeCompare(b.cardId)) : [];
|
$: rows = result ? [...result.rows].sort((a, b) => b[sortBy] - a[sortBy] || b.maximumGain - a.maximumGain || a.copies - b.copies || a.cardId.localeCompare(b.cardId)) : [];
|
||||||
const number = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 1 });
|
const number = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 1 });
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
stop(); const id = generation;
|
stop(); const id = generation;
|
||||||
running = true; error = ''; result = null; snapshotKey = inputKey;
|
running = true; error = ''; result = null; snapshotKey = inputKey;
|
||||||
progress = { stage: '채보 준비', completed: 0, total: 1 };
|
progress = { stage: '채보 준비', completed: 0, total: 1 };
|
||||||
const snapshot = structuredClone({ settings, growth, order, cooldownReductions, excludedActiveWindows, song: chartMetadata });
|
const snapshot = structuredClone({ settings, growth, order, cooldownReductions, excludedActiveWindows, minimumAwakening, includeUnowned, song: chartMetadata });
|
||||||
try {
|
try {
|
||||||
const chart = await loadExactSongChart(snapshot.song.path);
|
const chart = await loadExactSongChart(snapshot.song.path);
|
||||||
if (id !== generation) return;
|
if (id !== generation) return;
|
||||||
@@ -64,8 +66,10 @@
|
|||||||
<h3 id="board-awakening-title">보드 연결노드용 개화 추천</h3>
|
<h3 id="board-awakening-title">보드 연결노드용 개화 추천</h3>
|
||||||
<p><strong>편성 5장·편성 개화·스페셜 순서·쿨타임을 고정</strong>하고, 5성 카드의 연결 효과만 비교합니다.</p>
|
<p><strong>편성 5장·편성 개화·스페셜 순서·쿨타임을 고정</strong>하고, 5성 카드의 연결 효과만 비교합니다.</p>
|
||||||
<p>보유 카드는 5개화까지, 미보유 카드는 첫 획득과 5개화를 각각 비교합니다. 추가 획득 수는 첫 획득 1장, 미보유 → 5개화 6장입니다. 0~4개화의 연결 스킬은 같으며, 이미 5개화인 카드는 후보에서 제외합니다.</p>
|
<p>보유 카드는 5개화까지, 미보유 카드는 첫 획득과 5개화를 각각 비교합니다. 추가 획득 수는 첫 획득 1장, 미보유 → 5개화 6장입니다. 0~4개화의 연결 스킬은 같으며, 이미 5개화인 카드는 후보에서 제외합니다.</p>
|
||||||
<p>활성 노드를 유지한 채 연결 배치를 빠르게 배정하고 전체 보드 점수로 비교합니다. 비교 기준도 현재 보유 연결 카드로 배치를 개선한 점수이므로, 단순 재배치 이득을 개화 효과에 섞지 않습니다. 연결 배치를 전수 탐색하는 기능은 아닙니다. 사용자 데이터는 변경하지 않습니다.</p>
|
<p>선택한 최소 개화 이상의 보유 카드를 비교하며, 미보유 카드는 체크한 경우에만 별도로 포함합니다. 활성 노드를 유지한 채 빠른 배정과 최대 3단계 연쇄 재배치를 비교합니다. 단계마다 상위 8개 배치를 유지하며, 후보가 많을수록 오래 걸립니다. 비교 기준도 현재 보유 연결 카드로 배치를 개선한 점수이므로, 단순 재배치 이득을 개화 효과에 섞지 않습니다. 연결 배치를 전수 탐색하는 기능은 아닙니다. 사용자 데이터는 변경하지 않습니다.</p>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
<label>최소 개화 단계 <select bind:value={minimumAwakening} disabled={running}>{#each [4, 3, 2, 1, 0] as level}<option value={level}>{level === 4 ? '4개화만' : `${level}개화 이상`}</option>{/each}</select></label>
|
||||||
|
<label><input type="checkbox" bind:checked={includeUnowned} disabled={running} /> 미보유 카드도 비교</label>
|
||||||
<button class="primary" disabled={disabled || running || !chartMetadata} on:click={recommend}>연결노드용 획득·개화 비교</button>
|
<button class="primary" disabled={disabled || running || !chartMetadata} on:click={recommend}>연결노드용 획득·개화 비교</button>
|
||||||
{#if running}<button on:click={() => { stop(); error = '계산을 취소했습니다.'; }}>취소</button>{/if}
|
{#if running}<button on:click={() => { stop(); error = '계산을 취소했습니다.'; }}>취소</button>{/if}
|
||||||
<label>정렬 <select bind:value={sortBy}><option value="maximumGain">최고 점수 증가량</option><option value="gainPerCopy">추가 획득 1장당 증가량</option></select></label>
|
<label>정렬 <select bind:value={sortBy}><option value="maximumGain">최고 점수 증가량</option><option value="gainPerCopy">추가 획득 1장당 증가량</option></select></label>
|
||||||
|
|||||||
@@ -1,9 +1,41 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { searchBoardConnectionChain } from './board-connection-chain.ts';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { boardInvestments, recommendBoardAwakening, type BoardAwakeningData, type BoardAwakeningRequest } from './board-awakening.ts';
|
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 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'));
|
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() {
|
function fixture() {
|
||||||
const cards = ['a', 'b', 'c', 'd', 'e', 'new'].map((id) => {
|
const cards = ['a', 'b', 'c', 'd', 'e', 'new'].map((id) => {
|
||||||
const card = structuredClone(catalog.cards.find((card) => card.rarity === 5)!);
|
const card = structuredClone(catalog.cards.find((card) => card.rarity === 5)!);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type cardSummary from '../data/cards-summary.json';
|
import type cardSummary from '../data/cards-summary.json';
|
||||||
|
import { searchBoardConnectionChain } from './board-connection-chain.ts';
|
||||||
import type memorySummary from '../data/memory-summary.json';
|
import type memorySummary from '../data/memory-summary.json';
|
||||||
import type { AnalysisSettings, GrowthSnapshot } from '../models/analysis-history.ts';
|
import type { AnalysisSettings, GrowthSnapshot } from '../models/analysis-history.ts';
|
||||||
import { calculateBoardEffects, type BoardEffectSong, type BoardSummarySource } from './board-effects.ts';
|
import { calculateBoardEffects, type BoardEffectSong, type BoardSummarySource } from './board-effects.ts';
|
||||||
@@ -16,6 +17,8 @@ export type BoardAwakeningData = {
|
|||||||
memoryEffects: typeof memorySummary.effects;
|
memoryEffects: typeof memorySummary.effects;
|
||||||
};
|
};
|
||||||
export type BoardAwakeningRequest = {
|
export type BoardAwakeningRequest = {
|
||||||
|
minimumAwakening?: number;
|
||||||
|
includeUnowned?: boolean;
|
||||||
settings: AnalysisSettings; growth: GrowthSnapshot; chart: ExactSongChart;
|
settings: AnalysisSettings; growth: GrowthSnapshot; chart: ExactSongChart;
|
||||||
song: BoardEffectSong & { scoreRatio: number | null };
|
song: BoardEffectSong & { scoreRatio: number | null };
|
||||||
order: readonly string[]; cooldownReductions: Readonly<Record<string, CooldownReduction>>;
|
order: readonly string[]; cooldownReductions: Readonly<Record<string, CooldownReduction>>;
|
||||||
@@ -28,12 +31,14 @@ export type BoardGrowthRow = BoardInvestment & { best: BoardGrowthBest; maximumG
|
|||||||
export type BoardAwakeningResult = { current: BoardGrowthBest; baseline: BoardGrowthBest; rows: BoardGrowthRow[]; scoreChecks: number };
|
export type BoardAwakeningResult = { current: BoardGrowthBest; baseline: BoardGrowthBest; rows: BoardGrowthRow[]; scoreChecks: number };
|
||||||
export type BoardAwakeningProgress = { stage: string; completed: number; total: number };
|
export type BoardAwakeningProgress = { stage: string; completed: number; total: number };
|
||||||
|
|
||||||
export function boardInvestments(cards: readonly { id: string; rarity: number }[], collection: GrowthSnapshot['collection']): BoardInvestment[] {
|
export function boardInvestments(cards: readonly { id: string; rarity: number }[], collection: GrowthSnapshot['collection'], minimumAwakening = 0, includeUnowned = true): BoardInvestment[] {
|
||||||
|
if (!Number.isInteger(minimumAwakening) || minimumAwakening < 0 || minimumAwakening > 4) throw new Error('최소 개화는 0~4여야 합니다.');
|
||||||
return cards.filter((card) => card.rarity === 5).flatMap((card): BoardInvestment[] => {
|
return cards.filter((card) => card.rarity === 5).flatMap((card): BoardInvestment[] => {
|
||||||
const owned = collection[card.id]?.owned;
|
const owned = collection[card.id]?.owned;
|
||||||
const from = owned ? collection[card.id]?.awakening ?? 0 : null;
|
const from = owned ? collection[card.id]?.awakening ?? 0 : null;
|
||||||
if (from !== null && (!Number.isInteger(from) || from < 0 || from > 5)) throw new Error('보유 카드의 개화는 0~5여야 합니다.');
|
if (from !== null && (!Number.isInteger(from) || from < 0 || from > 5)) throw new Error('보유 카드의 개화는 0~5여야 합니다.');
|
||||||
if (from === 5) return [];
|
if (from === 5) return [];
|
||||||
|
if (from === null ? !includeUnowned : from < minimumAwakening) return [];
|
||||||
return from === null
|
return from === null
|
||||||
? [{ cardId: card.id, from, to: 0, copies: 1 }, { cardId: card.id, from, to: 5, copies: 6 }]
|
? [{ cardId: card.id, from, to: 0, copies: 1 }, { cardId: card.id, from, to: 5, copies: 6 }]
|
||||||
: [{ cardId: card.id, from, to: 5, copies: 5 - from }];
|
: [{ cardId: card.id, from, to: 5, copies: 5 - from }];
|
||||||
@@ -59,7 +64,7 @@ export function recommendBoardAwakening(data: BoardAwakeningData, request: Board
|
|||||||
const outfit = byId.get(settings.leaderOutfitCardId)?.details.costume;
|
const outfit = byId.get(settings.leaderOutfitCardId)?.details.costume;
|
||||||
const connections = data.cards.filter((card) => card.details.connect.length && growth.collection[card.id]?.owned)
|
const connections = data.cards.filter((card) => card.details.connect.length && growth.collection[card.id]?.owned)
|
||||||
.map((card) => ({ id: card.id, awakening: growth.collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
|
.map((card) => ({ id: card.id, awakening: growth.collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
|
||||||
const candidates = boardInvestments(data.cards.filter((card) => card.details.connect.length), growth.collection);
|
const candidates = boardInvestments(data.cards.filter((card) => card.details.connect.length), growth.collection, request.minimumAwakening ?? 0, request.includeUnowned ?? true);
|
||||||
const boards = data.boards;
|
const boards = data.boards;
|
||||||
const clean = (placements: Placements, cards: readonly OptimizableConnectionCard[]): Placements => {
|
const clean = (placements: Placements, cards: readonly OptimizableConnectionCard[]): Placements => {
|
||||||
const ids = new Set(cards.map((card) => card.id)); const used = new Set<string>(); const result: Placements = {};
|
const ids = new Set(cards.map((card) => card.id)); const used = new Set<string>(); const result: Placements = {};
|
||||||
@@ -137,10 +142,12 @@ export function recommendBoardAwakening(data: BoardAwakeningData, request: Board
|
|||||||
const seed = baseline.placements;
|
const seed = baseline.placements;
|
||||||
const prepared: { candidate: BoardInvestment; cards: OptimizableConnectionCard[]; best: BoardGrowthBest }[] = [];
|
const prepared: { candidate: BoardInvestment; cards: OptimizableConnectionCard[]; best: BoardGrowthBest }[] = [];
|
||||||
for (const [i, candidate] of candidates.entries()) {
|
for (const [i, candidate] of candidates.entries()) {
|
||||||
onProgress({ stage: '획득·5개화 연결 효과 비교', completed: i, total: candidates.length });
|
onProgress({ stage: '획득·5개화 및 3단계 연쇄 배치 비교', completed: i, total: candidates.length });
|
||||||
const card = byId.get(candidate.cardId)!;
|
const card = byId.get(candidate.cardId)!;
|
||||||
const cards = connections.filter((entry) => entry.id !== card.id).concat({ id: card.id, awakening: candidate.to, connect: card.details.connect });
|
const cards = connections.filter((entry) => entry.id !== card.id).concat({ id: card.id, awakening: candidate.to, connect: card.details.connect });
|
||||||
const best = optimize(cards, seed);
|
let best = optimize(cards, seed);
|
||||||
|
const chained = searchBoardConnectionChain(boards, growth.memberBoards, cards, formation.map((card) => card.characterId), seed, card.id, (placements) => evaluate(placements, cards));
|
||||||
|
if (better(chained, best)) best = chained;
|
||||||
prepared.push({ candidate, cards, best });
|
prepared.push({ candidate, cards, best });
|
||||||
// Do not credit free rearrangements of existing cards to acquiring/awakening a card.
|
// Do not credit free rearrangements of existing cards to acquiring/awakening a card.
|
||||||
const before = evaluate(clean(best.placements, connections), connections);
|
const before = evaluate(clean(best.placements, connections), connections);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { BoardConnectionOptimizerSource, OptimizableConnectionCard } from './board-connection-optimizer.ts';
|
||||||
|
|
||||||
|
type Placements = Record<string, Record<string, string>>;
|
||||||
|
/** Three placements deep, retaining eight promising unfinished displacement chains. */
|
||||||
|
export function searchBoardConnectionChain<T extends { maximum: number; expected: number; placements: Placements }>(
|
||||||
|
source: BoardConnectionOptimizerSource, activeBoards: Record<string, string[]>,
|
||||||
|
cards: readonly OptimizableConnectionCard[], formationMembers: readonly string[],
|
||||||
|
initial: Placements, targetId: string, evaluate: (placements: Placements) => T
|
||||||
|
): T {
|
||||||
|
const byId = new Map(cards.map((card) => [card.id, card]));
|
||||||
|
const slots = source.members.flatMap((member) => {
|
||||||
|
const positions = source.models[member.positionGroupId] ?? [];
|
||||||
|
const node = (id: string) => source.nodeGroups[id]?.find((n) => n.characterIds.includes(member.id)) ?? source.nodeGroups[id]?.find((n) => !n.characterIds.length);
|
||||||
|
const active = new Set(activeBoards[member.id] ?? []);
|
||||||
|
return positions.filter((p) => node(p.groupId)?.type === 'connection' && (active.has(p.groupId) || p.x === 0 && p.y === 0)).map((socket) => ({
|
||||||
|
member: member.id, socket: socket.groupId, x: socket.x, y: socket.y,
|
||||||
|
required: formationMembers.includes(member.id) && socket.x !== 0 ? positions.filter((p) => p.groupId.startsWith('B') && p.x * socket.x > 0 && (node(p.groupId)?.grade ?? 0) >= 2 && node(p.groupId)?.effect?.type === 'live_active_skill_activation_probability_up_permil_up') : []
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const allowed = (id: string | undefined, slot: typeof slots[number]) => {
|
||||||
|
if (!slot.required.length) return true;
|
||||||
|
const card = id ? byId.get(id) : undefined;
|
||||||
|
const effect = card?.connect.find((e) => e.level === (card.awakening >= 5 ? 2 : 1)) ?? card?.connect.at(-1);
|
||||||
|
return Boolean(effect && slot.required.every((p) => effect.extent.some((o) => slot.x + o.x === p.x && slot.y + o.y === p.y)));
|
||||||
|
};
|
||||||
|
const valid = (placements: Placements) => slots.every((s) => allowed(placements[s.member]?.[s.socket], s));
|
||||||
|
const better = (a: T, b: T) => a.maximum > b.maximum || a.maximum === b.maximum && a.expected > b.expected;
|
||||||
|
let best = evaluate(initial);
|
||||||
|
const seed = structuredClone(initial);
|
||||||
|
for (const entries of Object.values(seed)) for (const [socket, id] of Object.entries(entries)) if (id === targetId) delete entries[socket];
|
||||||
|
type State = { placements: Placements; carried: string; used: number[]; score: T };
|
||||||
|
let states: State[] = [{ placements: seed, carried: targetId, used: [], score: best }];
|
||||||
|
for (let depth = 0; depth < 3 && states.length; depth++) {
|
||||||
|
const next: State[] = [];
|
||||||
|
for (const state of states) for (const [index, slot] of slots.entries()) {
|
||||||
|
if (state.used.includes(index) || !allowed(state.carried, slot)) continue;
|
||||||
|
const placements = structuredClone(state.placements);
|
||||||
|
const displaced = placements[slot.member]?.[slot.socket];
|
||||||
|
(placements[slot.member] ??= {})[slot.socket] = state.carried;
|
||||||
|
const score = evaluate(placements);
|
||||||
|
if (valid(placements) && better(score, best)) best = score;
|
||||||
|
if (displaced) next.push({ placements, carried: displaced, used: [...state.used, index], score });
|
||||||
|
}
|
||||||
|
next.sort((a, b) => b.score.maximum - a.score.maximum || b.score.expected - a.score.expected);
|
||||||
|
states = next.slice(0, 8);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user