feat: add browser AI connection guide and calculator instructions
Publish GitHub Pages output / publish (push) Successful in 43s

This commit is contained in:
2026-10-03 15:45:37 +09:00
parent 1b7889e574
commit 2b26bbd94f
28 changed files with 1411 additions and 4 deletions
+161
View File
@@ -0,0 +1,161 @@
import { writable } from 'svelte/store';
import cards from '$lib/data/cards-summary.json';
import boards from '$lib/data/boards-summary.json';
import memories from '$lib/data/memory-summary.json';
import catalog from '$lib/data/exact-chart-catalog.json';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import { synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { defaultPreferences } from '$lib/models/default-preferences';
import { loadBrowserData } from '$lib/storage/browser-storage.ts';
import { parseBackup, parseRequest, type Dataset, type runAnalysis } from '$lib/engine/assistant-analysis.ts';
import AnalysisWorker from '$lib/workers/assistant-analysis.worker.ts?worker&inline';
import { startWorkerJob } from './worker-job.ts';
import { registerSiteTools, type ModelContext, type SiteTool } from './site-tools.ts';
import { analysisSchema, catalogSchema } from './tool-schema.ts';
type Result = ReturnType<typeof runAnalysis>;
type State = { ready: boolean; supported: boolean; enabled: boolean; busy: boolean; error: string; lastResult: Result | null; elapsedMs: number | null };
let state: State = { ready: false, supported: false, enabled: false, busy: false, error: '', lastResult: null, elapsedMs: null };
export const assistantState = writable(state);
const update = (patch: Partial<State>) => { state = { ...state, ...patch }; assistantState.set(state); };
let context: ModelContext | undefined;
let dispose: (() => void) | undefined;
let generation = 0;
let job: ReturnType<typeof startWorkerJob<Result>> | undefined;
async function dataset(): Promise<Dataset> {
const clock = await synchronizeReleaseClock();
return { cards: cards.cards, boards, memoryEffects: memories.effects, charts: releasedSongCharts(catalog.charts, clock.now()),
provenance: { masterData: catalog.masterDataSource, chartSource: catalog.source, execution: 'calculator-browser-worker', releaseClockSource: clock.source },
loadChart: () => { throw new Error('Worker에서만 계산합니다.'); } } as Dataset;
}
function currentBackup() { return loadBrowserData(defaultPreferences()); }
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('객체 입력이 필요합니다.');
return value as Record<string, unknown>;
}
function pageNumber(value: unknown, fallback: number, min: number, max: number) {
if (value === undefined) return fallback;
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) throw new Error('잘못된 조회 범위입니다.');
return value;
}
function strictKeys(value: Record<string, unknown>, allowed: string[]) {
if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('지원하지 않는 입력 필드입니다.');
}
const tools: SiteTool[] = [
{
name: 'holodori_context', description: '현재 계산기 브라우저에 저장된 보유·성장 요약과 저장 편성을 읽습니다. 다른 브라우저의 데이터는 읽지 않습니다. 저장 상태를 변경하지 않습니다.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false }, annotations: { readOnlyHint: true },
execute: async input => {
strictKeys(record(input), []);
const data = await dataset();
const backup = currentBackup();
const growth = parseBackup(backup, data);
return { schemaVersion: 1, dataUpdatedAt: backup.updatedAt, provenance: data.provenance,
ownedCards: Object.values(growth.collection).filter(card => card.owned).length,
ownedFiveStarCards: data.cards.filter(card => card.rarity === 5 && growth.collection[card.id]?.owned).length,
memoryCount: growth.memoryCount, memberPowerUpPercent: growth.memberPowerUpPercent,
memberBoards: growth.memberBoards, boardCards: growth.boardCards,
savedDecks: backup.savedDecks.map(deck => ({ name: deck.name, cardIds: deck.cardIds, settings: deck.settings })),
limits: { charts: 3, teams: 8, timeoutSeconds: 30, beamWidth: 64, probabilitySamples: 5000 },
note: '데이터가 비어 있으면 사용자 데이터 화면에서 백업을 가져오세요. 조합 탐색은 후보 탐색이며 전역 최적을 보장하지 않습니다.' };
}
},
{
name: 'holodori_catalog', description: '카드 이름·멤버·ID와 보유·개화 상태 또는 공개된 악곡·난이도·ID를 검색합니다. query 부분 일치, 페이지당 최대 50개. 데이터 변경 없음.',
inputSchema: catalogSchema, annotations: { readOnlyHint: true },
execute: async input => {
const args = record(input);
strictKeys(args, ['kind', 'query', 'ownedOnly', 'offset', 'limit']);
if (args.kind !== 'cards' && args.kind !== 'charts') throw new Error('kind는 cards 또는 charts여야 합니다.');
if (args.query !== undefined && (typeof args.query !== 'string' || args.query.length > 200)) throw new Error('검색어는 200자 이하 문자열이어야 합니다.');
if (args.ownedOnly !== undefined && typeof args.ownedOnly !== 'boolean') throw new Error('ownedOnly는 true 또는 false여야 합니다.');
const offset = pageNumber(args.offset, 0, 0, 10000), limit = pageNumber(args.limit, 30, 1, 50);
const query = String(args.query ?? '').normalize('NFKC').toLowerCase();
const data = await dataset(), growth = parseBackup(currentBackup(), data);
const all = args.kind === 'cards' ? data.cards.filter(card => !args.ownedOnly || growth.collection[card.id]?.owned).map(card => ({
id: card.id, name: card.name, characterId: card.characterId, rarity: card.rarity,
versionName: card.versionName, costume: card.details.costume?.name ?? null,
owned: growth.collection[card.id]?.owned ?? false, awakening: growth.collection[card.id]?.awakening ?? 0
})) : data.charts.map(chart => ({ songId: chart.songId, title: chart.title, difficulty: chart.difficulty, durationSeconds: chart.durationSeconds, scoreCalibrationSource: chart.scoreCalibrationSource }));
const matches = all.filter(item => JSON.stringify(item).normalize('NFKC').toLowerCase().includes(query));
return { kind: args.kind, total: matches.length, offset, items: matches.slice(offset, offset + limit), nextOffset: offset + limit < matches.length ? offset + limit : null };
}
},
{
name: 'holodori_analyze', description: '현재 저장 데이터로 편성 점수·성장 가정 비교·제한된 후보 탐색을 계산기 Worker에서 수행합니다. analyze/compare는 서로 다른 멤버의 5성 카드 5장씩. search 기본 보유 카드, maxTeams 기본 8. 30초 제한. 보유·성장·편성 저장 상태 변경 없음. 완료된 점수와 순위를 반환합니다.',
inputSchema: analysisSchema, annotations: { readOnlyHint: true },
execute: async (input, options) => {
if (state.busy) throw new Error('분석이 이미 실행 중입니다. 완료하거나 취소한 뒤 다시 시도하세요.');
// Snapshot before any await: a concurrent UI edit cannot alter this calculation.
const backup = structuredClone(currentBackup());
const requestInput = structuredClone(input);
const started = performance.now();
update({ busy: true, error: '', lastResult: null, elapsedMs: null });
const currentJob = startWorkerJob<Result>({ createWorker: () => new AnalysisWorker(), prepare: async () => {
const data = await dataset();
const request = parseRequest(requestInput, data);
if (request.charts.length > 3 || (request.teams?.length ?? 0) > 8 || (request.probabilitySamples ?? 0) > 5000 || (request.search?.beamWidth ?? 0) > 64 || (request.search?.maxTeams ?? 0) > 8) throw new Error('AI 연결은 채보 3개·편성 8개·beamWidth 64·확률 표본 5000 이하를 지원합니다.');
parseBackup(backup, data);
const charts = await Promise.all(request.charts.map(selected => loadExactSongChart(data.charts.find(chart => chart.songId === selected.songId && chart.difficulty === selected.difficulty)!.path)));
const { loadChart: _, ...serializableData } = data;
return { backup, request, data: serializableData, charts };
} });
job = currentJob;
const cancel = () => currentJob.cancel();
options?.signal?.addEventListener('abort', cancel, { once: true });
if (options?.signal?.aborted) cancel();
try {
const result = await currentJob.result;
update({ lastResult: result, elapsedMs: Math.round(performance.now() - started) });
return { ...result, elapsedMs: state.elapsedMs, dataUpdatedAt: backup.updatedAt };
} catch (error) {
const message = error instanceof Error ? error.message : '분석에 실패했습니다.';
update({ error: message });
return { status: 'error', message, results: [], note: '완료되지 않은 작업의 점수나 순위를 추정하지 마세요.' };
} finally { options?.signal?.removeEventListener('abort', cancel); job = undefined; update({ busy: false }); }
}
}
];
export function initializeAssistant(document: Document) {
// Some desktop browser builds still expose the earlier Navigator entry point.
const current = (document as Document & { modelContext?: ModelContext }).modelContext;
const legacy = (document.defaultView?.navigator as (Navigator & { modelContext?: ModelContext }) | undefined)?.modelContext;
context = typeof current?.registerTool === 'function' ? current : legacy;
update({ ready: true, supported: typeof context?.registerTool === 'function' });
return () => { void setAssistantEnabled(false); context = undefined; };
}
export function cancelAssistantAnalysis() { job?.cancel(); }
/** A visible, deterministic smoke check also usable before an AI connection is available. */
export async function checkAssistantCalculation() {
if (state.busy) return;
try {
const data = await dataset(), growth = parseBackup(currentBackup(), data);
if (state.busy) return;
const seen = new Set<string>();
const team = data.cards.filter(card => {
if (card.rarity !== 5 || !card.details.costume || !growth.collection[card.id]?.owned || seen.has(card.characterId)) return false;
seen.add(card.characterId); return true;
}).slice(0, 5);
if (team.length !== 5) throw new Error('계산 확인에는 서로 다른 멤버의 보유 5성 카드 5장이 필요합니다. 사용자 데이터에서 보유 상태를 확인하세요.');
const chart = data.charts.find(chart => chart.songId === 'm0001' && chart.difficulty === 'easy');
if (!chart) throw new Error('계산 확인용 채보를 찾지 못했습니다.');
await tools[2].execute({ schemaVersion: 1, operation: 'analyze', charts: [{ songId: chart.songId, difficulty: chart.difficulty }],
teams: [{ name: '연결 계산 확인', cardIds: team.map(card => card.id), leaderCharacterId: team[0].characterId, leaderOutfitCardId: team[0].id }] });
} catch (error) { update({ error: error instanceof Error ? error.message : '계산 확인에 실패했습니다.' }); }
}
export async function setAssistantEnabled(enabled: boolean) {
const thisGeneration = ++generation;
dispose?.(); dispose = undefined;
if (!enabled) { cancelAssistantAnalysis(); update({ enabled: false }); return; }
if (!context || !state.supported) { update({ error: '이 브라우저에서는 AI 연결을 지원하지 않습니다.' }); return; }
update({ enabled: true, error: '' });
try {
const cleanup = await registerSiteTools(context, tools, () => state.enabled && generation === thisGeneration);
if (generation !== thisGeneration) cleanup(); else dispose = cleanup;
} catch {
if (generation === thisGeneration) update({ enabled: false, error: 'AI 도구 연결에 실패했습니다. 페이지를 새로고침해 주세요.' });
}
}
+34
View File
@@ -0,0 +1,34 @@
export type SiteTool = {
name: string; description: string; inputSchema: Record<string, unknown>;
annotations: { readOnlyHint: true };
execute: (input: unknown, options?: { signal?: AbortSignal }) => Promise<unknown>;
};
export type ModelContext = {
registerTool: (tool: SiteTool, options?: { signal?: AbortSignal }) => void | Promise<void>;
unregisterTool?: (name: string) => void;
};
/** Roll back partial registration and clean up even if disabled while registration is awaiting. */
export async function registerSiteTools(context: ModelContext, tools: SiteTool[], active: () => boolean) {
const registered: string[] = [];
const controller = new AbortController();
const dispose = () => {
controller.abort();
for (const name of registered.splice(0)) {
try { context.unregisterTool?.(name); } catch { /* A navigated document may have already removed it. */ }
}
};
try {
for (const tool of tools) {
if (!active()) break;
await context.registerTool({ ...tool, execute: async (input, options) => {
if (!active()) throw new Error('계산기 AI 연결이 꺼져 있습니다.');
if (options?.signal?.aborted) throw new Error('도구 호출이 취소되었습니다.');
return tool.execute(input, options);
} }, { signal: controller.signal });
registered.push(tool.name);
}
if (!active()) dispose();
return dispose;
} catch (error) { dispose(); throw error; }
}
+31
View File
@@ -0,0 +1,31 @@
const id = { type: 'string', minLength: 1, maxLength: 200 };
const idList = { type: 'array', items: id, uniqueItems: true, maxItems: 200 };
const object = (properties: Record<string, unknown>, required: string[] = []) => ({ type: 'object', properties, required, additionalProperties: false });
export const catalogSchema = object({
kind: { type: 'string', enum: ['cards', 'charts'] },
query: { type: 'string', maxLength: 200 },
ownedOnly: { type: 'boolean', description: 'cards만 적용. 현재 브라우저에 저장된 보유 카드만 조회.' },
offset: { type: 'integer', minimum: 0, maximum: 10000 },
limit: { type: 'integer', minimum: 1, maximum: 50 }
}, ['kind']);
const scenario = object({
awakeningByCardId: { type: 'object', additionalProperties: { type: 'integer', minimum: 0, maximum: 5 } },
memoryCount: { type: 'integer', minimum: 0, maximum: 10000 },
memberPowerUpPercent: { type: 'number', minimum: 0, maximum: 50 },
memberBoards: { type: 'object', description: '멤버별 활성 노드 ID 전체 목록으로 대체. 모르면 생략.', additionalProperties: { ...idList, maxItems: 500 } },
boardCards: { type: 'object', description: '멤버별 소켓 ID와 연결 카드 ID 배치 전체로 대체. 모르면 생략.', additionalProperties: { type: 'object', additionalProperties: id } }
});
export const analysisSchema = object({
schemaVersion: { type: 'integer', const: 1 },
operation: { type: 'string', enum: ['analyze', 'compare', 'search'] },
charts: { type: 'array', minItems: 1, maxItems: 3, items: object({ songId: id, difficulty: { type: 'string', enum: ['easy', 'normal', 'hard', 'expert'] } }, ['songId', 'difficulty']) },
objective: { type: 'string', enum: ['expected', 'maximum'], description: '기본 expected. maximum은 이론 최고점.' },
ownedOnly: { type: 'boolean', description: '기본 true. 미보유 가정은 사용자 요청이 있을 때 false.' },
probabilitySamples: { type: 'integer', minimum: 0, maximum: 5000 },
teams: { type: 'array', minItems: 1, maxItems: 8, description: 'analyze/compare만 지정. 첫 편성이 비교 기준.', items: object({
name: id, cardIds: { ...idList, minItems: 5, maxItems: 5 }, leaderCharacterId: id, leaderOutfitCardId: id,
cooldownReductions: { type: 'object', additionalProperties: { type: 'integer', enum: [0, 4, 8, 12] } }, scenario
}, ['cardIds']) },
search: object({ candidateCardIds: idList, fixedCardIds: { ...idList, maxItems: 5 }, leaderCharacterId: id, leaderOutfitCardId: id,
beamWidth: { type: 'integer', minimum: 1, maximum: 64 }, maxTeams: { type: 'integer', minimum: 1, maximum: 8 } })
}, ['schemaVersion', 'operation', 'charts']);
+30
View File
@@ -0,0 +1,30 @@
/** One bounded, read-only calculation; cancellation also covers asynchronous chart loading. */
export function startWorkerJob<T>(options: {
createWorker: () => Worker;
prepare: () => Promise<unknown>;
timeoutMs?: number;
}): { result: Promise<T>; cancel: () => void } {
let worker: Worker | undefined;
let done = false;
let timer: ReturnType<typeof setTimeout>;
let rejectJob: (error: Error) => void;
const finish = () => { done = true; clearTimeout(timer); worker?.terminate(); };
const result = new Promise<T>((resolve, reject) => {
rejectJob = error => { if (!done) { finish(); reject(error); } };
timer = setTimeout(() => rejectJob(new Error('30초 제한에 도달했습니다. 채보나 후보 편성을 줄여 다시 시도해 주세요.')), options.timeoutMs ?? 30_000);
void options.prepare().then(payload => {
if (done) return;
worker = options.createWorker();
worker.onmessage = event => {
if (done) return;
if (event.data.status !== 'complete') { rejectJob(new Error(event.data.message ?? '분석에 실패했습니다.')); return; }
finish();
resolve(event.data.result as T);
};
worker.onerror = () => rejectJob(new Error('계산 Worker를 실행하지 못했습니다.'));
worker.onmessageerror = () => rejectJob(new Error('계산 결과를 읽지 못했습니다.'));
worker.postMessage(payload);
}).catch(error => rejectJob(error instanceof Error ? error : new Error('분석 준비에 실패했습니다.')));
});
return { result, cancel: () => rejectJob(new Error('분석이 취소되었습니다.')) };
}
@@ -0,0 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initializeAssistant } from '$lib/assistant/service.ts';
onMount(() => initializeAssistant(document));
</script>
+3 -2
View File
@@ -1,15 +1,16 @@
<script lang="ts">
export let active: 'random' | 'holo-work' | 'board-awakening';
export let active: 'random' | 'holo-work' | 'board-awakening' | 'ai';
</script>
<nav aria-label="도구 메뉴">
<a href="/tools/random" aria-current={active === 'random' ? 'page' : undefined}>악곡 랜덤</a>
<a href="/tools/holo-work" aria-current={active === 'holo-work' ? 'page' : undefined}>홀로워크 관리</a>
<a href="/tools/board-awakening" aria-current={active === 'board-awakening' ? 'page' : undefined}>연결노드 개화 추천</a>
<a href="/tools/ai" aria-current={active === 'ai' ? 'page' : undefined}>AI 연결</a>
</nav>
<style>
nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
nav { display: flex; flex-wrap: wrap; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
+91
View File
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { parseBackup, parseRequest, prepareTeam, runAnalysis, type Dataset } from './assistant-analysis.ts';
import { calculateBoardEffects } from './board-effects.ts';
import { calculateUnitPower } from './unit-power.ts';
import { toOptimizerCard } from './card-adapter.ts';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import { memoryParameterPermil } from './memory.ts';
import { perfectNoteScoreFromPower } from './score.ts';
const root = fileURLToPath(new URL('../../../', import.meta.url));
const read = (file: string) => JSON.parse(readFileSync(path.join(root, file), 'utf8'));
const catalog = read('src/lib/data/exact-chart-catalog.json');
const data: Dataset = { cards: read('src/lib/data/cards-summary.json').cards, boards: read('src/lib/data/boards-summary.json'),
charts: catalog.charts, memoryEffects: read('src/lib/data/memory-summary.json').effects, provenance: { masterData: catalog.masterDataSource },
loadChart: summary => read('static' + summary.path) };
const members = data.cards.filter(card => card.rarity === 5 && card.details.costume).filter((card, index, cards) => cards.findIndex(other => other.characterId === card.characterId) === index).slice(0, 6);
const cardIds = members.slice(0, 5).map(card => card.id);
const backup = { schemaVersion: 1, updatedAt: '2026-10-03T00:00:00Z', preferences: {}, savedDecks: [],
collection: Object.fromEntries(data.cards.map(card => [card.id, { owned: true, awakening: 0 }])),
memoryCount: 10, memberPowerUpPercent: 3.2, memberBoards: {}, boardCards: {} };
const team = { cardIds, cooldownReductions: Object.fromEntries(cardIds.map(id => [id, 0 as const])) };
const request = { schemaVersion: 1, operation: 'analyze', objective: 'expected', charts: [{ songId: 'm0001', difficulty: 'easy' }], teams: [team] };
test('backup state produces exactly the same power and optimized score as the web engine', () => {
const before = JSON.stringify(backup);
const growth = parseBackup(backup, data);
const context = prepareTeam(team, growth, data);
const summary = data.charts.find(chart => chart.songId === 'm0001' && chart.difficulty === 'easy')!;
const effects = calculateBoardEffects(data.boards, growth.memberBoards, context.members, context.leaderCharacterId, summary, context.connections);
const power = calculateUnitPower(context.members.map(card => ({ card, awakening: 0 })), context.outfit.details.costume,
effects, memoryParameterPermil(10, data.memoryEffects), 3.2);
const cards = context.members.map(card => {
const adapted = toOptimizerCard(card, 0, [0], context.members);
return { ...adapted, active: { ...adapted.active, activationRateUpPercent: effects.activationRateUpByCardId[card.id] ?? 0 },
scoreSupportPercent: power.outfitScoreSupportPercent + effects.scoreSupportPercent };
});
const expected = optimizeExactOrderAndCooldowns(cards, data.loadChart(summary), { objective: 'expected',
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, summary.scoreRatio), directSongScoreBonusPercent: effects.directSongScoreBonusPercent });
const result = runAnalysis(backup, request, data);
assert.deepEqual(result.results[0].power, power);
assert.equal(result.results[0].expectedScore, expected.expectedScore);
assert.equal(result.results[0].theoreticalMaxScore, expected.theoreticalMaxScore);
assert.deepEqual(result.results[0].order, expected.order);
assert.equal(JSON.stringify(backup), before);
});
test('growth comparison uses the requested awakening only and returns baseline deltas', () => {
const result = runAnalysis(backup, { ...request, operation: 'compare', teams: [team, { ...team, name: '4개화', scenario: { awakeningByCardId: { [cardIds[0]]: 4 } } }] }, data);
assert.deepEqual(result.results[1].awakenings, [4, 0, 0, 0, 0]);
assert.equal(result.results[1].hypothetical, true);
assert.ok(result.results[1].power.overallPower > result.results[0].power.overallPower);
assert.equal(result.comparisons[0].expectedScore.difference, result.results[1].expectedScore - result.results[0].expectedScore);
assert.equal(backup.collection[cardIds[0]].awakening, 0);
});
test('actual board unlocks affect results without injecting standardized comparison bonuses', () => {
const member = data.boards.members!.find(member => member.id === members[0].characterId)!;
const blue = data.boards.models![member.positionGroupId].filter(position => position.groupId.startsWith('B')).map(position => position.groupId);
const result = runAnalysis(backup, { ...request, operation: 'compare', teams: [team, { ...team, scenario: { memberBoards: { [member.id]: blue } } }] }, data);
assert.equal(result.results[0].power.boardTotal, 0);
assert.equal(result.results[0].directSongScoreBonusPercent, 0);
assert.ok(result.results[1].power.boardTotal > 0);
});
test('strict validation rejects corrupt backups, unknown fields, stale IDs, and impossible teams', () => {
assert.throws(() => parseBackup({ ...backup, collection: { missing: { owned: true, awakening: 0 } } }, data), /최신 패키지/);
assert.throws(() => parseBackup({ ...backup, memberBoards: { [members[0].characterId]: ['not-a-node'] } }, data), /알 수 없는 보드 노드/);
assert.throws(() => parseBackup({ ...backup, memoryCount: '10' }, data), /숫자|정수/);
assert.throws(() => parseRequest({ ...request, arbitraryConstraint: true }, data), /지원하지 않는 필드/);
assert.throws(() => parseRequest({ ...request, teams: [{ ...team, cooldownReductions: { [cardIds[0]]: 7 } }] }, data), /쿨타임/);
assert.throws(() => parseRequest({ ...request, charts: [request.charts[0], request.charts[0]] }, data), /중복/);
const unowned = structuredClone(backup);
unowned.collection[cardIds[0]].owned = false;
assert.throws(() => runAnalysis(unowned, request, data), /보유하지 않은/);
assert.throws(() => runAnalysis(backup, { ...request, teams: [{ ...team, cardIds: [cardIds[0], cardIds[0], ...cardIds.slice(2)] }] }, data), /중복/);
assert.throws(() => parseRequest({ ...request, teams: [{ ...team, scenario: { awakeningByCardId: { [cardIds[0]]: 6 } } }] }, data), /0~5/);
});
test('search preserves fixed-card constraints and discloses its approximate scope', () => {
const result = runAnalysis(backup, { schemaVersion: 1, operation: 'search', charts: request.charts,
search: { candidateCardIds: members.map(card => card.id), fixedCardIds: cardIds.slice(0, 4), beamWidth: 2, maxTeams: 1 } }, data);
assert.equal(result.search?.globallyOptimal, false);
assert.equal(result.search?.evaluatedTeams, 1);
assert.ok(cardIds.slice(0, 4).every(id => (result.results[0].cardIds as string[]).includes(id)));
assert.throws(() => parseRequest({ schemaVersion: 1, operation: 'search', charts: [...request.charts, { songId: 'm0001', difficulty: 'hard' }], search: { maxTeams: 64 } }, data), /64/);
});
+321
View File
@@ -0,0 +1,321 @@
import { calculateBoardEffects, type BoardSummarySource } from './board-effects.ts';
import { toOptimizerCard } from './card-adapter.ts';
import { generateCombinationCandidates } from './combination-search.ts';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import { memoryParameterPermil, type MemoryCollectionEffect } from './memory.ts';
import { finalNoteScore, perfectNoteScoreFromPower } from './score.ts';
import { estimateScoreAchievementProbabilities } from './score-probability.ts';
import { calculateUnitPower } from './unit-power.ts';
import type { ComparisonCard } from './team-comparison.ts';
import type { CooldownReduction, ExactSongChart } from './types.ts';
import type { GrowthSnapshot } from '../models/analysis-history.ts';
export type ChartSummary = Omit<ExactSongChart, 'notes' | 'specialStartSeconds'> & {
title: string; path: string; releaseTime: number;
};
export type Dataset = {
cards: ComparisonCard[];
boards: BoardSummarySource;
memoryEffects: MemoryCollectionEffect[];
charts: ChartSummary[];
provenance: Record<string, unknown>;
loadChart: (summary: ChartSummary) => ExactSongChart;
};
type Scenario = Partial<Pick<GrowthSnapshot, 'memoryCount' | 'memberPowerUpPercent' | 'memberBoards' | 'boardCards'>> & {
awakeningByCardId?: Record<string, number>;
};
export type TeamRequest = {
name?: string;
cardIds: string[];
leaderCharacterId?: string;
leaderOutfitCardId?: string;
cooldownReductions?: Record<string, CooldownReduction>;
scenario?: Scenario;
};
export type AnalysisRequest = {
schemaVersion: 1;
operation: 'analyze' | 'compare' | 'search';
charts: { songId: string; difficulty: string }[];
objective?: 'maximum' | 'expected';
ownedOnly?: boolean;
probabilitySamples?: number;
teams?: TeamRequest[];
search?: {
candidateCardIds?: string[];
fixedCardIds?: string[];
leaderCharacterId?: string;
leaderOutfitCardId?: string;
beamWidth?: number;
maxTeams?: number;
};
};
function fail(message: string): never { throw new Error(message); }
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`${label}: 객체가 필요합니다.`);
return value as Record<string, unknown>;
}
function keys(value: Record<string, unknown>, allowed: string[], label: string) {
const unknown = Object.keys(value).filter(key => !allowed.includes(key));
if (unknown.length) fail(`${label}: 지원하지 않는 필드 ${unknown.join(', ')}`);
}
function numeric(value: unknown, min: number, max: number, label: string, integer = false): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) {
fail(`${label}: ${min}~${max} 사이의 ${integer ? '정수' : '숫자'}가 필요합니다.`);
}
return value as number;
}
function string(value: unknown, label: string): string {
if (typeof value !== 'string' || !value.trim() || value.length > 200) fail(`${label}: 비어 있지 않은 문자열이 필요합니다.`);
return value as string;
}
function ids(value: unknown, label: string, max = 200): string[] {
if (!Array.isArray(value) || value.length > max) fail(`${label}: 최대 ${max}개의 ID 배열이 필요합니다.`);
const result = (value as unknown[]).map(v => string(v, label));
if (new Set(result).size !== result.length) fail(`${label}: 중복 ID가 있습니다.`);
return result;
}
function memberBoards(value: unknown, data: Dataset): Record<string, string[]> {
return Object.fromEntries(Object.entries(record(value, 'memberBoards')).map(([memberId, nodes]) => {
const member = data.boards.members?.find(member => member.id === memberId);
if (!member) fail(`알 수 없는 보드 멤버: ${memberId}`);
const positions = data.boards.models?.[member.positionGroupId] ?? [];
const selected = ids(nodes, `memberBoards.${memberId}`, 500);
if (selected.some(id => !positions.some(position => position.groupId === id))) fail(`${memberId}: 알 수 없는 보드 노드가 있습니다.`);
return [memberId, selected];
}));
}
function boardCards(value: unknown, data: Dataset): Record<string, Record<string, string>> {
const used = new Set<string>();
return Object.fromEntries(Object.entries(record(value, 'boardCards')).map(([memberId, placements]) => {
const member = data.boards.members?.find(member => member.id === memberId);
if (!member) fail(`알 수 없는 보드 멤버: ${memberId}`);
const positions = data.boards.models?.[member.positionGroupId] ?? [];
const mapped = Object.fromEntries(Object.entries(record(placements, `boardCards.${memberId}`)).map(([socketId, rawId]) => {
const id = string(rawId, '연결 카드 ID');
const card = data.cards.find(card => card.id === id);
if (!positions.some(position => position.groupId === socketId) || !card?.details.connect.length) fail(`잘못된 연결 카드 배치: ${memberId}/${socketId}/${id}`);
if (used.has(id)) fail(`연결 카드를 두 번 배치할 수 없습니다: ${id}`);
used.add(id);
return [socketId, id];
}));
return [memberId, mapped];
}));
}
export function parseBackup(value: unknown, data: Dataset): GrowthSnapshot {
const backup = record(value, '백업');
if (backup.schemaVersion !== 1 || !Array.isArray(backup.savedDecks) || !backup.preferences) fail('계산기의 schemaVersion 1 백업 JSON이 필요합니다.');
const known = new Set(data.cards.map(card => card.id));
const collection = Object.fromEntries(Object.entries(record(backup.collection, 'collection')).map(([id, raw]) => {
if (!known.has(id)) fail(`계산기 데이터에 없는 카드입니다: ${id}. 최신 패키지 또는 계산기 데이터가 필요합니다.`);
const state = record(raw, `collection.${id}`);
if (typeof state.owned !== 'boolean') fail(`${id}: owned는 true 또는 false여야 합니다.`);
return [id, { owned: state.owned as boolean, awakening: numeric(state.awakening, 0, 5, `${id} 개화`, true) }];
}));
const growth = {
collection,
memberBoards: memberBoards(backup.memberBoards ?? {}, data),
boardCards: boardCards(backup.boardCards ?? {}, data),
memoryCount: numeric(backup.memoryCount ?? 0, 0, 10_000, '메모리 수', true),
memberPowerUpPercent: numeric(backup.memberPowerUpPercent ?? 0, 0, 50, '멤버 종합력 UP')
};
for (const placements of Object.values(growth.boardCards)) {
for (const id of Object.values(placements)) if (!collection[id]?.owned) fail(`보유하지 않은 연결 카드가 배치되어 있습니다: ${id}`);
}
return growth;
}
function parseScenario(value: unknown, data: Dataset): Scenario {
const raw = record(value, 'scenario');
keys(raw, ['awakeningByCardId', 'memoryCount', 'memberPowerUpPercent', 'memberBoards', 'boardCards'], 'scenario');
const scenario: Scenario = {};
if (raw.awakeningByCardId !== undefined) scenario.awakeningByCardId = Object.fromEntries(Object.entries(record(raw.awakeningByCardId, 'awakeningByCardId')).map(([id, v]) => {
if (!data.cards.some(card => card.id === id)) fail(`알 수 없는 개화 대상: ${id}`);
return [id, numeric(v, 0, 5, `${id} 개화`, true)];
}));
if (raw.memoryCount !== undefined) scenario.memoryCount = numeric(raw.memoryCount, 0, 10_000, '메모리 수', true);
if (raw.memberPowerUpPercent !== undefined) scenario.memberPowerUpPercent = numeric(raw.memberPowerUpPercent, 0, 50, '멤버 종합력 UP');
if (raw.memberBoards !== undefined) scenario.memberBoards = memberBoards(raw.memberBoards, data);
if (raw.boardCards !== undefined) scenario.boardCards = boardCards(raw.boardCards, data);
return scenario;
}
export function parseRequest(value: unknown, data: Dataset): AnalysisRequest {
const raw = record(value, 'request');
keys(raw, ['schemaVersion', 'operation', 'charts', 'objective', 'ownedOnly', 'probabilitySamples', 'teams', 'search'], 'request');
if (raw.schemaVersion !== 1 || !['analyze', 'compare', 'search'].includes(String(raw.operation))) fail('schemaVersion 1과 analyze/compare/search 작업이 필요합니다.');
if (!Array.isArray(raw.charts) || raw.charts.length < 1 || raw.charts.length > 8) fail('채보를 1~8개 지정해 주세요.');
const charts = (raw.charts as unknown[]).map(value => {
const chart = record(value, 'charts');
keys(chart, ['songId', 'difficulty'], 'charts');
const result = { songId: string(chart.songId, 'songId'), difficulty: string(chart.difficulty, 'difficulty') };
if (!data.charts.some(chart => chart.songId === result.songId && chart.difficulty === result.difficulty)) fail(`이 패키지에 없는 채보입니다: ${result.songId}/${result.difficulty}`);
return result;
});
if (new Set(charts.map(chart => `${chart.songId}/${chart.difficulty}`)).size !== charts.length) fail('같은 채보를 중복 지정할 수 없습니다.');
if (raw.objective !== undefined && raw.objective !== 'maximum' && raw.objective !== 'expected') fail('objective는 maximum 또는 expected여야 합니다.');
if (raw.ownedOnly !== undefined && typeof raw.ownedOnly !== 'boolean') fail('ownedOnly는 true 또는 false여야 합니다.');
const request: AnalysisRequest = { schemaVersion: 1, operation: raw.operation as AnalysisRequest['operation'], charts,
objective: raw.objective as AnalysisRequest['objective'] ?? 'expected', ownedOnly: raw.ownedOnly as boolean ?? true };
if (raw.probabilitySamples !== undefined) request.probabilitySamples = numeric(raw.probabilitySamples, 0, 25_000, 'probabilitySamples', true);
if (request.operation === 'search') {
if (raw.teams !== undefined) fail('search 작업에는 teams를 지정하지 않습니다.');
const search = record(raw.search ?? {}, 'search');
keys(search, ['candidateCardIds', 'fixedCardIds', 'leaderCharacterId', 'leaderOutfitCardId', 'beamWidth', 'maxTeams'], 'search');
request.search = {
...(search.candidateCardIds === undefined ? {} : { candidateCardIds: ids(search.candidateCardIds, 'candidateCardIds') }),
fixedCardIds: ids(search.fixedCardIds ?? [], 'fixedCardIds', 5),
...(search.leaderCharacterId === undefined ? {} : { leaderCharacterId: string(search.leaderCharacterId, 'leaderCharacterId') }),
...(search.leaderOutfitCardId === undefined ? {} : { leaderOutfitCardId: string(search.leaderOutfitCardId, 'leaderOutfitCardId') }),
beamWidth: numeric(search.beamWidth ?? 32, 1, 200, 'beamWidth', true),
maxTeams: numeric(search.maxTeams ?? 8, 1, 64, 'maxTeams', true)
};
if (request.search.maxTeams! * charts.length > 64) fail('maxTeams × 채보 수는 64 이하여야 합니다.');
} else {
if (raw.search !== undefined) fail('analyze/compare 작업에는 search를 지정하지 않습니다.');
if (!Array.isArray(raw.teams) || raw.teams.length < (request.operation === 'compare' ? 2 : 1) || raw.teams.length > 12) fail('analyze는 1~12개, compare는 2~12개 편성이 필요합니다.');
request.teams = (raw.teams as unknown[]).map(value => {
const team = record(value, 'team');
keys(team, ['name', 'cardIds', 'leaderCharacterId', 'leaderOutfitCardId', 'cooldownReductions', 'scenario'], 'team');
const cardIds = ids(team.cardIds, 'cardIds', 5);
if (cardIds.length !== 5) fail('각 편성에는 카드 5장이 필요합니다.');
const parsed: TeamRequest = { cardIds };
for (const key of ['name', 'leaderCharacterId', 'leaderOutfitCardId'] as const) if (team[key] !== undefined) parsed[key] = string(team[key], key);
if (team.cooldownReductions !== undefined) parsed.cooldownReductions = Object.fromEntries(Object.entries(record(team.cooldownReductions, 'cooldownReductions')).map(([id, v]) => {
if (!cardIds.includes(id) || ![0, 4, 8, 12].includes(v as number)) fail('쿨타임 감소는 편성 카드 ID별 0/4/8/12여야 합니다.');
return [id, v as CooldownReduction];
}));
if (team.scenario !== undefined) parsed.scenario = parseScenario(team.scenario, data);
return parsed;
});
if (request.teams.length * charts.length > 64) fail('편성 수 × 채보 수는 64 이하여야 합니다.');
}
return request;
}
function scenarioGrowth(growth: GrowthSnapshot, scenario: Scenario | undefined): GrowthSnapshot {
const copy = structuredClone(growth);
if (!scenario) return copy;
for (const [id, awakening] of Object.entries(scenario.awakeningByCardId ?? {})) copy.collection[id] = { ...copy.collection[id], awakening };
if (scenario.memoryCount !== undefined) copy.memoryCount = scenario.memoryCount;
if (scenario.memberPowerUpPercent !== undefined) copy.memberPowerUpPercent = scenario.memberPowerUpPercent;
if (scenario.memberBoards !== undefined) copy.memberBoards = structuredClone(scenario.memberBoards);
if (scenario.boardCards !== undefined) copy.boardCards = structuredClone(scenario.boardCards);
return copy;
}
export function prepareTeam(team: TeamRequest, growth: GrowthSnapshot, data: Dataset, ownedOnly = true) {
const current = scenarioGrowth(growth, team.scenario);
const members = team.cardIds.map(id => data.cards.find(card => card.id === id) ?? fail(`알 수 없는 카드: ${id}`));
if (members.length !== 5 || members.some(card => card.rarity !== 5) || new Set(members.map(card => card.characterId)).size !== 5) fail('서로 다른 멤버의 5성 카드 5장이 필요합니다.');
if (ownedOnly && members.some(card => !current.collection[card.id]?.owned)) fail('보유하지 않은 편성 카드가 있습니다. 가정 분석은 ownedOnly: false를 명시하세요.');
const leaderCharacterId = team.leaderCharacterId ?? members[0].characterId;
if (!data.boards.members?.some(member => member.id === leaderCharacterId)) fail('알 수 없는 리더입니다.');
const outfitId = team.leaderOutfitCardId ?? members.find(card => card.characterId === leaderCharacterId && card.details.costume)?.id;
const outfit = data.cards.find(card => card.id === outfitId);
if (!outfit?.details.costume || outfit.characterId !== leaderCharacterId) fail('리더에 맞는 leaderOutfitCardId를 지정해 주세요.');
if (ownedOnly && !current.collection[outfit.id]?.owned) fail('보유하지 않은 리더 의상입니다.');
for (const placements of Object.values(current.boardCards)) for (const id of Object.values(placements)) {
if (ownedOnly && !current.collection[id]?.owned) fail(`보유하지 않은 연결 카드: ${id}`);
}
const connections = { placements: current.boardCards, cards: data.cards.filter(card => card.details.connect.length > 0)
.map(card => ({ id: card.id, awakening: current.collection[card.id]?.awakening ?? 0, connect: card.details.connect })) };
return { members, growth: current, leaderCharacterId, outfit, connections };
}
function heuristic(card: ComparisonCard, growth: GrowthSnapshot): number {
const awakening = growth.collection[card.id]?.awakening ?? 0;
const adapted = toOptimizerCard(card, awakening, [0], [card]);
return (adapted.naturalParameterTotal ?? 0) + 100 * (adapted.active.scoreUpPercent * adapted.active.durationSeconds / Math.max(1, adapted.active.cooldownSeconds) + adapted.special.scoreUpPercent);
}
function searchTeams(request: AnalysisRequest, growth: GrowthSnapshot, data: Dataset): TeamRequest[] {
const search = request.search!;
const candidateIds = search.candidateCardIds ?? data.cards.filter(card => card.rarity === 5 && (!request.ownedOnly || growth.collection[card.id]?.owned)).map(card => card.id);
if (candidateIds.length > 200) fail('후보 카드는 200장 이하로 좁혀 주세요.');
const pool = candidateIds.map(id => {
const card = data.cards.find(card => card.id === id);
if (!card || card.rarity !== 5 || (request.ownedOnly && !growth.collection[id]?.owned)) fail(`사용할 수 없는 후보 카드: ${id}`);
return card;
});
const fixed = search.fixedCardIds!.map(id => pool.find(card => card.id === id) ?? fail(`고정 카드는 후보에 포함되어야 합니다: ${id}`));
const candidates = generateCombinationCandidates(fixed, pool, 5, card => heuristic(card, growth), search.beamWidth);
if (!candidates.length) fail('조건에 맞는 5인 편성을 만들 수 없습니다. 후보와 고정 카드를 확인하세요.');
return candidates.slice(0, search.maxTeams).map((members, i) => ({
name: `탐색 후보 ${i + 1}`, cardIds: members.map(card => card.id),
...(search.leaderCharacterId ? { leaderCharacterId: search.leaderCharacterId } : {}),
...(search.leaderOutfitCardId ? { leaderOutfitCardId: search.leaderOutfitCardId } : {})
}));
}
export function runAnalysis(backup: unknown, rawRequest: unknown, data: Dataset, onResult?: (result: AnalysisRow) => void) {
const growth = parseBackup(backup, data);
const request = parseRequest(rawRequest, data);
const teams = request.operation === 'search' ? searchTeams(request, growth, data) : request.teams!;
// Validate every team before doing expensive work, including hypothetical state.
const contexts = teams.map(team => prepareTeam(team, growth, data, request.ownedOnly));
const charts = request.charts.map(selected => data.charts.find(chart => chart.songId === selected.songId && chart.difficulty === selected.difficulty)!);
const results: AnalysisRow[] = [];
for (const summary of charts) {
if (!Number.isFinite(summary.scoreRatio) || summary.scoreRatio! <= 0) fail(`점수 환산 데이터가 없는 채보: ${summary.songId}/${summary.difficulty}`);
const chart = data.loadChart(summary);
if (chart.songId !== summary.songId || chart.difficulty !== summary.difficulty || chart.scoreRatio !== summary.scoreRatio || !chart.notes.length) fail('채보와 카탈로그가 일치하지 않습니다. 패키지를 다시 빌드하세요.');
for (const [index, context] of contexts.entries()) {
const team = teams[index];
const effects = calculateBoardEffects(data.boards, context.growth.memberBoards, context.members, context.leaderCharacterId, summary, context.connections);
const power = calculateUnitPower(context.members.map(card => ({ card, awakening: context.growth.collection[card.id]?.awakening ?? 0 })),
context.outfit.details.costume, effects, memoryParameterPermil(context.growth.memoryCount, data.memoryEffects), context.growth.memberPowerUpPercent);
const cards = context.members.map(card => {
const fixed = team.cooldownReductions?.[card.id];
const adapted = toOptimizerCard(card, context.growth.collection[card.id]?.awakening ?? 0,
fixed === undefined ? effects.allowedCooldownReductionsByCardId[card.id] : [fixed], context.members);
return { ...adapted, active: { ...adapted.active, activationRateUpPercent: effects.activationRateUpByCardId[card.id] ?? 0 },
scoreSupportPercent: power.outfitScoreSupportPercent + effects.scoreSupportPercent };
});
const options = { perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, summary.scoreRatio),
directSongScoreBonusPercent: effects.directSongScoreBonusPercent, objective: request.objective };
const result = optimizeExactOrderAndCooldowns(cards, chart, options);
const row = {
teamIndex: index, name: team.name ?? `편성 ${index + 1}`, songId: summary.songId, title: summary.title, difficulty: summary.difficulty,
cardIds: team.cardIds, awakenings: team.cardIds.map(id => context.growth.collection[id]?.awakening ?? 0),
leaderCharacterId: context.leaderCharacterId, leaderOutfitCardId: context.outfit.id,
hypothetical: !request.ownedOnly || Boolean(team.scenario), scenario: team.scenario ?? null,
power, directSongScoreBonusPercent: effects.directSongScoreBonusPercent,
scoreCalibrationSource: summary.scoreCalibrationSource ?? 'unknown',
expectedScore: result.expectedScore, theoreticalMaxScore: result.theoreticalMaxScore, baselineScore: result.baselineScore,
perfectNoteScore: result.perfectNoteScore, inGamePerfectNoteScore: finalNoteScore(result.perfectNoteScore, effects.directSongScoreBonusPercent),
order: result.order, cooldownReductions: result.cooldownReductions, evaluatedCandidates: result.evaluatedCandidates,
...(request.probabilitySamples ? { achievementProbabilities: estimateScoreAchievementProbabilities(result.order.map(id => cards.find(card => card.id === id)!),
result.cooldownReductions, chart, { ...options, sampleCount: request.probabilitySamples, seed: 20261003 }) } : {})
};
results.push(row);
onResult?.(row);
}
}
const ranking = teams.map((team, teamIndex) => {
const rows = results.filter(row => row.teamIndex === teamIndex);
return { teamIndex, name: team.name ?? `편성 ${teamIndex + 1}`, totalExpectedScore: rows.reduce((sum, row) => sum + row.expectedScore, 0),
totalTheoreticalMaxScore: rows.reduce((sum, row) => sum + row.theoreticalMaxScore, 0) };
}).sort((a, b) => request.objective === 'maximum' ? b.totalTheoreticalMaxScore - a.totalTheoreticalMaxScore : b.totalExpectedScore - a.totalExpectedScore);
const comparisons = results.filter(row => row.teamIndex !== 0).map(row => {
const base = results.find(base => base.teamIndex === 0 && base.songId === row.songId && base.difficulty === row.difficulty)!;
const delta = (a: number, b: number) => ({ difference: b - a, percent: a === 0 ? null : (b - a) / a * 100 });
return { teamIndex: row.teamIndex, baselineTeamIndex: 0, songId: row.songId, difficulty: row.difficulty,
expectedScore: delta(base.expectedScore, row.expectedScore), theoreticalMaxScore: delta(base.theoreticalMaxScore, row.theoreticalMaxScore),
overallPower: delta(base.power.overallPower, row.power.overallPower) };
});
return { schemaVersion: 1, status: 'complete', operation: request.operation, objective: request.objective, provenance: data.provenance,
search: request.operation === 'search' ? { method: 'heuristic-beam-shortlist', globallyOptimal: false, evaluatedTeams: teams.length, ...request.search } : null,
assumptions: ['카드는 최대 레벨 기준입니다.', '보드·연결 배치·개화·성장 정보는 입력에 저장된 상태를 사용합니다.',
'미지정 쿨타임 감소는 0/4/8/12를 탐색합니다.', '점수는 엔진의 올 퍼펙트 모델 기준입니다.',
'여러 채보의 순위는 채보마다 최적화한 점수의 단순 합계이며 공통 고정 배치의 순위가 아닙니다.',
'기존 백업에는 마스터 데이터 버전이 없어 백업과 패키지의 버전 일치를 증명할 수 없습니다.'],
probabilityMethod: request.probabilitySamples ? '최고점 확률은 엔진 계산, 99.5/99/95% 이상 확률은 고정 시드 표본 추정' : null,
ranking, results, comparisons };
}
export type AnalysisRow = {
teamIndex: number; name: string; songId: string; title: string; difficulty: string;
expectedScore: number; theoreticalMaxScore: number; baselineScore: number;
power: ReturnType<typeof calculateUnitPower>;
[key: string]: unknown;
};
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { startWorkerJob } from '../assistant/worker-job.ts';
import { registerSiteTools, type ModelContext, type SiteTool } from '../assistant/site-tools.ts';
function fakeWorker() {
return { onmessage: null as ((event: { data: unknown }) => void) | null, onerror: null, onmessageerror: null,
payload: undefined as unknown, terminated: 0,
postMessage(payload: unknown) { this.payload = payload; }, terminate() { this.terminated++; } };
}
test('completed calculation returns the result and terminates its Worker', async () => {
const worker = fakeWorker();
const job = startWorkerJob({ createWorker: () => worker as unknown as Worker, prepare: async () => ({ snapshot: 1 }) });
await Promise.resolve();
assert.deepEqual(worker.payload, { snapshot: 1 });
worker.onmessage!({ data: { status: 'complete', result: { score: 1024124 } } });
assert.deepEqual(await job.result, { score: 1024124 });
job.cancel();
assert.equal(worker.terminated, 1);
});
test('cancellation while charts load prevents a late Worker from starting', async () => {
let prepared!: (payload: unknown) => void;
let started = false;
const job = startWorkerJob({ createWorker: () => { started = true; return fakeWorker() as unknown as Worker; },
prepare: () => new Promise(resolve => prepared = resolve) });
job.cancel();
await assert.rejects(job.result, /취소/);
prepared({});
await Promise.resolve();
assert.equal(started, false);
});
test('timeout and Worker failures return no partial score and release resources', async () => {
const worker = fakeWorker();
const job = startWorkerJob({ createWorker: () => worker as unknown as Worker, prepare: async () => ({}), timeoutMs: 5 });
await assert.rejects(job.result, /제한/);
assert.equal(worker.terminated, 1);
const failed = fakeWorker();
const next = startWorkerJob({ createWorker: () => failed as unknown as Worker, prepare: async () => ({}) });
await Promise.resolve();
failed.onmessage!({ data: { status: 'error', message: 'invalid team' } });
await assert.rejects(next.result, /invalid team/);
assert.equal(failed.terminated, 1);
});
const tool: SiteTool = { name: 'test', description: 'test', inputSchema: {}, annotations: { readOnlyHint: true }, execute: async () => 42 };
test('partial site tool registration rolls back and disabled stale handles cannot execute', async () => {
const registered = new Map<string, SiteTool>();
let active = true;
const context: ModelContext = { registerTool: value => { registered.set(value.name, value); }, unregisterTool: name => { registered.delete(name); } };
const dispose = await registerSiteTools(context, [tool], () => active);
const handle = registered.get('test')!;
assert.equal(await handle.execute({}), 42);
active = false; dispose();
assert.equal(registered.size, 0);
await assert.rejects(handle.execute({}), /꺼져/);
const broken = { ...context, registerTool: (value: SiteTool) => { if (value.name === 'broken') throw new Error('registration failed'); context.registerTool(value); } };
await assert.rejects(registerSiteTools(broken, [tool, { ...tool, name: 'broken' }], () => true), /registration failed/);
assert.equal(registered.size, 0);
});
test('disable during asynchronous registration cleans up the newly registered tool', async () => {
let release!: () => void;
let active = true;
const removed: string[] = [];
const pending = registerSiteTools({ registerTool: () => new Promise<void>(resolve => release = resolve), unregisterTool: name => { removed.push(name); } }, [tool], () => active);
active = false; release();
await pending;
assert.deepEqual(removed, ['test']);
});
test('current registration API is cleaned up through its AbortSignal without unregisterTool', async () => {
let registered: SiteTool | undefined;
const context: ModelContext = { registerTool: (value, options) => {
registered = value;
options!.signal!.addEventListener('abort', () => { registered = undefined; });
} };
const dispose = await registerSiteTools(context, [tool], () => true);
assert.ok(registered);
const controller = new AbortController(); controller.abort();
await assert.rejects(registered!.execute({}, { signal: controller.signal }), /취소/);
dispose(); assert.equal(registered, undefined);
});
@@ -0,0 +1,16 @@
import { runAnalysis, type Dataset } from '../engine/assistant-analysis.ts';
import type { ExactSongChart } from '../engine/types.ts';
self.onmessage = (event: MessageEvent<{ backup: unknown; request: unknown; data: Omit<Dataset, 'loadChart'>; charts: ExactSongChart[] }>) => {
try {
const { backup, request, data, charts } = event.data;
const result = runAnalysis(backup, request, { ...data, loadChart: summary => {
const chart = charts.find(chart => chart.songId === summary.songId && chart.difficulty === summary.difficulty);
if (!chart) throw new Error('채보를 불러오지 못했습니다.');
return chart;
} });
self.postMessage({ status: 'complete', result });
} catch (error) {
self.postMessage({ status: 'error', message: error instanceof Error ? error.message : '분석에 실패했습니다.' });
}
};
+2
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import AnalyticsConsent from '$lib/components/AnalyticsConsent.svelte';
import AssistantConnection from '$lib/components/AssistantConnection.svelte';
import { events } from '$lib/data/events.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { activeEventAt } from '$lib/engine/event-optimization.ts';
@@ -60,6 +61,7 @@
</div>
</header>
<AssistantConnection />
<slot />
<AnalyticsConsent pagePath={`${$page.url.pathname}${$page.url.search}`} />
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import ToolsNav from '$lib/components/ToolsNav.svelte';
</script>
<svelte:head><title>AI 연결 · 홀로도리 계산기</title></svelte:head>
<main>
<h1>도구</h1>
<ToolsNav active="ai" />
<section class="panel">
<h2>AI 연결</h2>
<p>ChatGPT의 대화에서 편성 탐색과 성장 효과 비교를 요청할 수 있습니다. 계산은 이 계산기에서 수행하고 AI가 결과를 설명합니다.</p>
<ol>
<li><a href="https://get.microsoft.com/installer/download/9PLM9XGG6VKS?cid=website_cta_psi" target="_blank" rel="noreferrer">ChatGPT Windows 설치 파일</a>을 받아 실행하고 안내에 따라 설치합니다. 설치한 앱을 열어 ChatGPT 계정으로 로그인합니다.</li>
<li>평소 계산기를 사용하던 브라우저(Edge·Chrome 등)에서 <a href="https://chromewebstore.google.com/detail/chatgpt/hehggadaopoacecdllhhajmbjkdcmajg?hl=ko" target="_blank" rel="noreferrer">공식 ChatGPT 확장 설치 페이지</a>를 열어 확장을 설치합니다.</li>
<li>ChatGPT 앱의 설정 → Computer Use(컴퓨터 사용)에서 해당 브라우저가 연결됐는지 확인합니다. 연결 설정이 필요하면 <a href="https://learn.chatgpt.com/docs/chrome-extension" target="_blank" rel="noreferrer">공식 연결 안내</a>를 따라 주세요.</li>
<li>계산기를 연 상태에서 브라우저 도구 모음의 ChatGPT 아이콘을 눌러 대화창을 엽니다. 이 브라우저에 저장된 보유 카드와 성장 정보를 그대로 사용하므로 데이터를 옮길 필요가 없습니다.</li>
<li>GPT에게 <a href="/ai/SKILL.html" target="_blank" rel="noreferrer">이 파일(SKILL.md)</a>을 먼저 읽게 하세요. 계산기의 조작 방법과 결과 해석 지침이 담겨 있으며, 스킬을 설치하지 않아도 사용할 수 있습니다.</li>
</ol>
<p class="example">요청 예시: “이 계산기의 AI 연결 페이지에 있는 SKILL.md 사용 안내를 먼저 읽고, 현재 편성의 점수를 계산해줘.”</p>
<p class="hint">결과는 배치 분석이나 조합 탐색 화면에 표시됩니다. 여러 조건을 비교하면 화면 조작과 반복 계산에 시간이 걸릴 수 있습니다.</p>
<p class="hint">확장은 ChatGPT 데스크톱 앱과 연결해 사용하는 기능입니다. 사용 가능 여부는 앱·모델·계정 설정에 따라 달라지며 일반 웹·모바일 ChatGPT 채팅에서의 사용은 보장하지 않습니다.</p>
</section>
</main>
<style>
main { width: min(1100px, calc(100% - 32px)); margin: 32px auto; }
.panel { padding: 24px; margin: 20px 0; border: 1px solid #303949; border-radius: 14px; background: #171c24; }
h2 { margin-top: 0; } p, li { line-height: 1.7; } a { color: #f3a5c5; }
.hint { color: #a5aebd; font-size: 14px; } .example { background: #242d3b; padding: 14px; border-radius: 8px; }
</style>