diff --git a/UPDATES.md b/UPDATES.md index 2323b44..f107f6a 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-10-06T02:36:54.977Z +updatedAt: 2026-10-06T03:11:36.779Z --- ## 2026년 10월 6일 +### 파란 보드 개방 가정으로 연결 카드 추천 + +`NEW` 배치 분석의 연결 카드 최적화에 ‘파란 보드 개방 가정’을 추가했습니다. 편성 5명의 전체 파란 노드와 파란 영역 연결 소켓을 임시 개방한 조건으로 추천하며, 전후 점수는 동일한 개방 가정으로 비교합니다. 필요한 미개방 노드·소켓을 표시하고 실제 보드를 갱신하기 전에는 배치 적용을 차단합니다. 저장된 보드를 자동 변경하지 않으며 기본값은 기존 성장 기준입니다. + ### 서비스 이름 변경 `IMPROVED` 서비스 이름을 ‘홀로도리 계산기’에서 ‘갤산기’로 변경했습니다. 상단 서비스 이름과 각 페이지의 브라우저 제목, 서비스 안내에 새 이름을 반영하고, 상단 이름의 글자 크기를 키웠습니다. @@ -14,6 +18,12 @@ updatedAt: 2026-10-06T02:36:54.977Z ## 2026년 10월 5일 +### 보드 데이터 확인 및 분석 진행 안내 개선 + +`IMPROVED` 사용자 데이터의 보드 멤버 목록에 개방 수를 표시하고, 개방 수가 많거나 적은 순으로 정렬할 수 있습니다. 이전·다음 멤버 이동도 선택한 정렬을 따릅니다. 연결 카드 선택에 미배치 보유 카드만 보는 필터를 추가했습니다. + +`IMPROVED` 조합 탐색의 진행 상태를 후보 선별·선별 후보 평가·확장 후보 검증·달성 확률 계산으로 구분하고, 단계별 퍼센트와 별도로 현재 단계를 n/4로 표시합니다. 확장 후보가 없으면 3단계를 건너뜁니다. + ### KONKON Beats 악곡 추가 `NEW` KONKON Beats의 Easy·Normal·Hard·Expert 채보를 추가했습니다. 각 난이도의 점수 계산과 채보 표시를 지원합니다. diff --git a/src/lib/engine/board-blue-assumption.test.ts b/src/lib/engine/board-blue-assumption.test.ts new file mode 100644 index 0000000..e8b4654 --- /dev/null +++ b/src/lib/engine/board-blue-assumption.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { assumeTeamBlueBoards, missingBoardOpenings } from './board-blue-assumption.ts'; +import { optimizeBoardConnectionCards, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts'; + +function fixture(side: number): BoardConnectionOptimizerSource { + return { + members: [{ id: 'team', positionGroupId: 'model' }, { id: 'other', positionGroupId: 'model' }], + models: { model: [ + { groupId: 'S-center', x: 0, y: 0 }, { groupId: 'B-one', x: side, y: 0 }, + { groupId: 'S-blue', x: 2 * side, y: 0 }, { groupId: 'B-two', x: 3 * side, y: 0 }, + { groupId: 'S-yellow', x: -2 * side, y: 0 }, { groupId: 'Y-one', x: -side, y: 0 } + ] }, + nodeGroups: Object.fromEntries(['S-center', 'S-blue', 'S-yellow', 'B-one', 'B-two', 'Y-one'].map((id) => [id, [{ characterIds: [], type: id.startsWith('S') ? 'connection' : 'card', effect: id.startsWith('S') ? null : { type: 'performance_up', value: 100 } }]])) + }; +} + +for (const side of [-1, 1]) test(`파란 방향 ${side}: 편성 멤버의 파란 노드·소켓만 임시 개방`, () => { + const source = fixture(side); + const saved = { team: ['Y-one'], other: ['B-one'] }; + const before = JSON.stringify(saved); + const assumed = assumeTeamBlueBoards(source, saved, ['team'], true); + assert.deepEqual(assumed.team, ['Y-one', 'B-one', 'B-two', 'S-blue']); + assert.deepEqual(assumed.other, ['B-one']); + assert.equal(JSON.stringify(saved), before); + assert.deepEqual(assumeTeamBlueBoards(source, saved, ['team'], false), saved); + assert.deepEqual(missingBoardOpenings(source, saved, assumed), [{ memberId: 'team', nodeIds: ['B-one', 'B-two'], socketIds: ['S-blue'] }]); + assert.deepEqual(missingBoardOpenings(source, assumed, assumed), []); + const cards = [{ id: 'card', awakening: 0, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: side, y: 0 }] }] }]; + const options = { placementWeight: (_card: unknown, member: string, socket: string) => member === 'team' && socket === 'S-blue' ? 100 : 0 }; + assert.equal(optimizeBoardConnectionCards(source, saved, cards, options).placements.team?.['S-blue'], undefined); + assert.equal(optimizeBoardConnectionCards(source, assumed, cards, options).placements.team?.['S-blue'], 'card'); +}); + +test('실제 모든 보드 모델에서 파란 소켓을 찾고 다른 색상은 개방하지 않는다', () => { + const source = JSON.parse(readFileSync(new URL('../data/boards-summary.json', import.meta.url), 'utf8')); + for (const member of source.members) { + const assumed = assumeTeamBlueBoards(source, {}, [member.id], true); + assert.equal(Object.keys(assumed).length, 1); + assert.equal(assumed[member.id].filter((id) => id.startsWith('S-')).length, 1); + assert.ok(assumed[member.id].every((id) => id.startsWith('B-') || id.startsWith('S-'))); + } +}); + +test('가정 결과는 같은 보드로 전후 비교하고 미개방 상태의 적용을 차단한다', () => { + const source = readFileSync(new URL('../../routes/analysis/+page.svelte', import.meta.url), 'utf8'); + assert.match(source, /let assumeBlueForConnections = false/); + assert.match(source, /const beforeContext = scoreContext\(boardCards\)/); + assert.match(source, /const afterContext = scoreContext\(optimized.placements\)/); + assert.match(source, /beforeExpected: baseline.expectedScore/); + const apply = source.slice(source.indexOf('async function applyConnectionOptimization'), source.indexOf('async function undoConnectionOptimization')); + assert.match(apply, /openingRequirements.length > 0\) return/); + assert.doesNotMatch(apply, /memberBoards\s*=/); +}); diff --git a/src/lib/engine/board-blue-assumption.ts b/src/lib/engine/board-blue-assumption.ts new file mode 100644 index 0000000..f4d6f7b --- /dev/null +++ b/src/lib/engine/board-blue-assumption.ts @@ -0,0 +1,33 @@ +import type { BoardConnectionOptimizerSource } from './board-connection-optimizer.ts'; + +export type BoardOpeningRequirement = { memberId: string; nodeIds: string[]; socketIds: string[] }; + +/** Temporary optimization input only; never mutates the saved board. */ +export function assumeTeamBlueBoards( + source: BoardConnectionOptimizerSource, + boards: Record, memberIds: readonly string[], enabled: boolean +): Record { + const result = Object.fromEntries(Object.entries(boards).map(([id, nodes]) => [id, [...nodes]])); + if (!enabled) return result; + for (const member of source.members.filter((member) => memberIds.includes(member.id))) { + const positions = source.models[member.positionGroupId] ?? []; + const blue = positions.filter((position) => position.groupId.startsWith('B-')); + // Board layouts may mirror the blue branch; identify its side from coordinates. + const sockets = positions.filter((position) => position.groupId.startsWith('S-') + && position.x !== 0 && blue.some((node) => node.x * position.x > 0)); + result[member.id] = [...new Set([...(result[member.id] ?? []), ...blue.map((node) => node.groupId), ...sockets.map((node) => node.groupId)])]; + } + return result; +} + +export function missingBoardOpenings( + source: BoardConnectionOptimizerSource, saved: Record, assumed: Record +): BoardOpeningRequirement[] { + return source.members.flatMap((member) => { + const positions = source.models[member.positionGroupId] ?? []; + const center = positions.find((node) => node.x === 0 && node.y === 0)?.groupId; + const active = new Set(saved[member.id] ?? []); + const missing = [...new Set(assumed[member.id] ?? [])].filter((id) => id !== center && !active.has(id)); + return missing.length ? [{ memberId: member.id, nodeIds: missing.filter((id) => !id.startsWith('S-')), socketIds: missing.filter((id) => id.startsWith('S-')) }] : []; + }); +} diff --git a/src/lib/engine/board-editor-options.test.ts b/src/lib/engine/board-editor-options.test.ts new file mode 100644 index 0000000..43f0b5d --- /dev/null +++ b/src/lib/engine/board-editor-options.test.ts @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { compile } from 'svelte/compiler'; +import { boardMemberOptions, unplacedOwnedCards } from './board-editor-options.ts'; + +test('보드 개방 수는 중앙·중복·없는 노드를 제외하고 정렬하며 입력을 보존한다', () => { + const members = ['a', 'b', 'c'].map((id) => ({ id, positionGroupId: 'model' })); + const models = { model: [{ groupId: 'center', x: 0, y: 0 }, { groupId: 'one', x: 1, y: 0 }, { groupId: 'two', x: 2, y: 0 }] }; + const boards = { a: ['one'], b: ['center', 'one', 'one', 'two', 'missing'], c: ['two'] }; + const before = JSON.stringify({ members, models, boards }); + const options = (sort: 'default' | 'most' | 'least') => boardMemberOptions(members, models, boards, sort); + assert.deepEqual(options('default').map((m) => m.openedCount), [1, 2, 1]); + assert.deepEqual(options('most').map((m) => m.id), ['b', 'a', 'c']); + assert.deepEqual(options('least').map((m) => m.id), ['a', 'c', 'b']); + assert.equal(JSON.stringify({ members, models, boards }), before); + assert.equal(boardMemberOptions(members, {}, {}, 'most')[0].openedCount, 0); +}); + +test('미배치 보유 필터는 모든 멤버의 배치를 제외하고 배치 해제·보유 변경을 반영한다', () => { + const cards = ['a', 'b', 'c', 'd'].map((id) => ({ id })); + const collection = { a: { owned: true }, b: { owned: true }, c: { owned: false } }; + const placements = { member: { socket: 'a' } }; + assert.deepEqual(unplacedOwnedCards(cards, collection, placements, true), [{ id: 'b' }]); + assert.deepEqual(unplacedOwnedCards(cards, collection, {}, true), [{ id: 'a' }, { id: 'b' }]); + assert.deepEqual(unplacedOwnedCards(cards, { b: { owned: false } }, {}, true), []); + assert.equal(unplacedOwnedCards(cards, collection, placements, false), cards); +}); + +test('보드 정렬·카드 필터는 사용자 데이터와 선택값을 반응형 의존성으로 추적한다', () => { + const filename = new URL('../../routes/data/+page.svelte', import.meta.url); + const source = readFileSync(filename, 'utf8'); + const code = compile(source, { filename: filename.pathname, generate: 'client' }).js.code; + for (const [target, dependencies] of [ + ['sortedBoardMembers', ['userData', 'boardMemberSort']], + ['filteredConnectCards', ['userData', 'unplacedOwnedOnly', 'boardCardSearch']] + ] as const) { + const effect = code.split('$.legacy_pre_effect(').find((part) => part.includes(`$.set(${target},`)); + assert.ok(effect); + for (const dependency of dependencies) assert.ok(effect.split('() => {')[0].includes(`$.get(${dependency})`), dependency); + } + assert.match(source, /selectedMemberId = sortedBoardMembers\[nextIndex\]\.id/); +}); + +test('조합 탐색은 정밀 분석 문구 없이 네 단계와 단계별 진행률을 구분한다', () => { + const source = readFileSync(new URL('../../routes/combination/+page.svelte', import.meta.url), 'utf8'); + assert.match(source, /exactPass === 'refinement' \? '확장 후보 검증' : '선별 후보 평가'/); + assert.match(source, /searchPhase === 'screening' \? 1 : searchPhase === 'probability' \? 4 : exactPass === 'refinement' \? 3 : 2/); + assert.match(source, /현재 단계 \$\{searchStep\}\/4/); + assert.match(source, /class="search-step-badge"/); + assert.match(source, /현재 진행 단계/); + assert.doesNotMatch(source, /정밀 분석/); +}); diff --git a/src/lib/engine/board-editor-options.ts b/src/lib/engine/board-editor-options.ts new file mode 100644 index 0000000..a929ef6 --- /dev/null +++ b/src/lib/engine/board-editor-options.ts @@ -0,0 +1,25 @@ +type Member = { id: string; positionGroupId: string }; +type Position = { groupId: string; x: number; y: number }; + +export function boardMemberOptions( + members: readonly T[], models: Record, + activeBoards: Record, sort: 'default' | 'most' | 'least' +): (T & { openedCount: number })[] { + const options = members.map((member) => { + const active = new Set(activeBoards[member.id] ?? []); + const valid = new Set((models[member.positionGroupId] ?? []) + .filter((node) => node.x !== 0 || node.y !== 0).map((node) => node.groupId)); + return { ...member, openedCount: [...active].filter((id) => valid.has(id)).length }; + }); + return sort === 'default' ? options : options.sort((a, b) => + sort === 'most' ? b.openedCount - a.openedCount : a.openedCount - b.openedCount); +} + +export function unplacedOwnedCards( + cards: readonly T[], collection: Record, + placements: Record>, enabled: boolean +): readonly T[] { + if (!enabled) return cards; + const placed = new Set(Object.values(placements).flatMap((sockets) => Object.values(sockets))); + return cards.filter((card) => collection[card.id]?.owned && !placed.has(card.id)); +} diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 3c93335..51ec412 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -1,5 +1,6 @@