diff --git a/Dockerfile b/Dockerfile index c9e47a7..5d26c62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,11 +11,12 @@ RUN apt-get update \ COPY package.json package-lock.json ./ RUN npm ci -FROM base AS dev +FROM deps AS dev ENV NODE_ENV=development -COPY --from=deps /app/node_modules ./node_modules COPY . . +COPY docker/dev-entrypoint.sh /usr/local/bin/holodream-dev-entrypoint EXPOSE 5173 +ENTRYPOINT ["sh", "/usr/local/bin/holodream-dev-entrypoint"] CMD ["npm", "run", "dev"] FROM base AS build diff --git a/README.md b/README.md index d63c053..9a868b0 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ docker compose up --build - 웹: - 상태 확인: +개발 컨테이너는 시작할 때 패키지 설정·잠금 파일과 Node.js 버전을 확인하고, 변경되었으면 의존성 볼륨에 `npm ci`를 실행합니다. 실행 중 의존성이 변경되면 `docker compose restart app`으로 동기화할 수 있습니다. Dockerfile 변경 후에는 `docker compose up --build -d`로 컨테이너를 다시 생성하세요. + 종료할 때 `docker compose down`은 SQLite 볼륨을 보존합니다. `docker compose down --volumes`는 저장된 볼륨까지 제거하므로 주의하세요. ### 호스트에서 실행 diff --git a/UPDATES.md b/UPDATES.md index 5f33ecd..4aaa6cb 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-09-02T10:13:09+09:00 +updatedAt: 2026-09-02T12:21:05+09:00 --- ## 2026년 9월 2일 +### 중간 확률 카드 장수 제한 + +`NEW` 조합 탐색과 이벤트 Pt 최적화에서 액티브 발동 확률이 ‘중간’인 카드의 최대 장수를 지정할 수 있습니다. 고정 카드를 포함한 전체 편성에 적용되며, 제한 없음 또는 0~4장 이하를 선택할 수 있습니다. 설정은 프리셋에도 저장됩니다. + ### 멤버별 보드 효과 요약 `NEW` 보드 데이터에서 선택한 멤버가 받는 기본 파라미터·액티브 발동률과 해당 보드의 색상별 효과 합계를 확인할 수 있습니다. 파라미터 증가율과 파랑·초록 고정 증가분을 분리해 계산 순서를 안내합니다. 연결 카드 증폭을 반영하고 리더·가창·holo워크 등 적용 조건도 함께 표시합니다. diff --git a/docker/dev-entrypoint.sh b/docker/dev-entrypoint.sh new file mode 100644 index 0000000..71523bd --- /dev/null +++ b/docker/dev-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +cd /app + +# Reused dependency volumes can lag behind the bind-mounted source tree. +# Include the Node runtime so native modules are refreshed after image upgrades. +dependency_hash="$( + { + sha256sum package.json package-lock.json + node -p 'process.version + ":" + process.platform + ":" + process.arch' + } | sha256sum | cut -d ' ' -f 1 +)" +stamp_path="node_modules/.holodream-dependencies" +installed_hash="$(cat "$stamp_path" 2>/dev/null || true)" + +if [ "$dependency_hash" != "$installed_hash" ]; then + echo 'Synchronizing development dependencies with package-lock.json...' + npm ci --include=dev + printf '%s\n' "$dependency_hash" > "$stamp_path" +fi + +exec "$@" diff --git a/src/lib/engine/combination-search.test.ts b/src/lib/engine/combination-search.test.ts index 7bb1164..33f1aee 100644 --- a/src/lib/engine/combination-search.test.ts +++ b/src/lib/engine/combination-search.test.ts @@ -5,7 +5,8 @@ import { generateAnchoredCombinationCandidates, generateCombinationCandidates, resolveCandidateAwakening, - selectMemberDiverseCandidates + selectMemberDiverseCandidates, + withinMediumProbabilityCardLimit } from './index.ts'; const cards = [ @@ -15,6 +16,57 @@ const cards = [ { id: 'c-1', characterId: 'c', score: 40 } ]; +const probabilityCards = [46, 46, 55, 55, 55, 55].map((probabilityPercent, index) => ({ + id: String(index), + characterId: String(index), + score: 100 - index, + details: { active: [{ probabilityPercent }, { probabilityPercent }] } +})); + +test('중간 확률은 스킬 레벨 수와 무관하게 카드당 한 장으로 세고 낮음은 제외한다', () => { + assert.equal(withinMediumProbabilityCardLimit([probabilityCards[0]], 1), true); + assert.equal(withinMediumProbabilityCardLimit([probabilityCards[0]], 0), false); + assert.equal(withinMediumProbabilityCardLimit([ + probabilityCards[2], + { details: { active: [{ probabilityPercent: 37 }] } }, + { details: { active: [] } } + ], 0), true); + assert.equal(withinMediumProbabilityCardLimit(probabilityCards, null), true); +}); + +test('높은 점수의 중간 확률 카드가 빔을 독점하기 전에 제한을 적용한다', () => { + const result = generateCombinationCandidates([], probabilityCards, 5, (card) => card.score, 1, + undefined, (selected) => withinMediumProbabilityCardLimit(selected, 1)); + assert.equal(result.length, 1); + assert.deepEqual(result[0].map((card) => card.id).sort(), ['0', '2', '3', '4', '5']); +}); + +test('고정 카드도 중간 확률 제한에 포함하고 초과하거나 완성할 수 없으면 후보를 반환하지 않는다', () => { + const accepts = (selected: readonly (typeof probabilityCards)[number][]) => withinMediumProbabilityCardLimit(selected, 1); + const result = generateCombinationCandidates([probabilityCards[1]], probabilityCards, 5, (card) => card.score, 10, undefined, accepts); + assert.equal(result.length, 1); + assert.ok(result[0].some((card) => card.id === '1')); + assert.ok(result[0].every((card) => card.id !== '0')); + assert.deepEqual(generateCombinationCandidates(probabilityCards.slice(0, 2), probabilityCards, 5, + (card) => card.score, 10, undefined, accepts), []); + assert.deepEqual(generateCombinationCandidates([], probabilityCards, 5, + (card) => card.score, 10, undefined, (selected) => withinMediumProbabilityCardLimit(selected, 0)), []); +}); + +test('임시 축 후보에도 제한을 적용하고 중간 확률 카드를 모두 제외할 수 있다', () => { + const result = generateAnchoredCombinationCandidates([], probabilityCards, 3, + [{ scoreCard: (card) => card.score }], 1, + (selected) => withinMediumProbabilityCardLimit(selected, 0)); + assert.ok(result.length > 0); + assert.ok(result.every((selected) => selected.every((card) => card.details.active[0].probabilityPercent === 55))); +}); + +test('제한 없음은 기존 조합 후보를 그대로 유지한다', () => { + const expected = generateCombinationCandidates([], probabilityCards, 5, (card) => card.score, 10); + assert.deepEqual(generateCombinationCandidates([], probabilityCards, 5, (card) => card.score, 10, + undefined, (selected) => withinMediumProbabilityCardLimit(selected, null)), expected); +}); + test('고정 카드를 유지하고 같은 멤버의 이격 카드를 제외한다', () => { const result = generateCombinationCandidates([cards[0]], cards, 3, (card) => card.score, 20); assert.equal(result.length, 1); diff --git a/src/lib/engine/combination-search.ts b/src/lib/engine/combination-search.ts index 0b5680b..551351f 100644 --- a/src/lib/engine/combination-search.ts +++ b/src/lib/engine/combination-search.ts @@ -23,6 +23,15 @@ export type CombinationScoringStrategy = { const SCREENING_LIMIT_PER_OPEN_SLOT = 5_000; const EXACT_FINALIST_LIMIT_PER_OPEN_SLOT = 56; +export function withinMediumProbabilityCardLimit( + cards: readonly { details: { active: readonly { probabilityPercent?: number | null }[] } }[], + maximum: number | null +): boolean { + return maximum === null || cards.filter((card) => + card.details.active.some((level) => level.probabilityPercent === 46) + ).length <= maximum; +} + export function combinationSearchLimits(fixedCardCount: number, targetSize = 5): CombinationSearchLimits { const normalizedTargetSize = Math.max(1, Math.floor(targetSize)); const normalizedFixedCardCount = Math.min(normalizedTargetSize, Math.max(0, Math.floor(fixedCardCount))); @@ -49,12 +58,15 @@ export function generateCombinationCandidates( targetSize: number, scoreCard: (card: T) => number, beamWidth = 800, - scoreCombination?: (cards: readonly T[]) => number + scoreCombination?: (cards: readonly T[]) => number, + // Must accept valid partial combinations, as it is checked at every expansion. + acceptsCombination?: (cards: readonly T[]) => boolean ): T[][] { if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.'); if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) { throw new Error('같은 멤버를 두 번 고정할 수 없습니다.'); } + if (acceptsCombination && !acceptsCombination(fixedCards)) return []; const fixedIds = new Set(fixedCards.map((card) => card.id)); const fixedCharacters = new Set(fixedCards.map((card) => card.characterId)); const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId)); @@ -72,6 +84,7 @@ export function generateCombinationCandidates( if (usedIds.has(card.id)) continue; if (usedCharacters.has(card.characterId)) continue; const cards = [...state.cards, card]; + if (acceptsCombination && !acceptsCombination(cards)) continue; const nextState = { cards, score: scoreCombination?.(cards) ?? state.score + scoreCard(card) @@ -96,7 +109,8 @@ export function generateAnchoredCombinationCandidates[], - perAnchorLimit: number + perAnchorLimit: number, + acceptsCombination?: (cards: readonly T[]) => boolean ): T[][] { if (strategies.length === 0 || perAnchorLimit <= 0 || fixedCards.length >= targetSize) return []; const fixedCharacters = new Set(fixedCards.map((card) => card.characterId)); @@ -113,7 +127,8 @@ export function generateAnchoredCombinationCandidates card.id).sort().join('|'), cards); } diff --git a/src/routes/combination/+page.svelte b/src/routes/combination/+page.svelte index 97fdd31..a7ab8b4 100644 --- a/src/routes/combination/+page.svelte +++ b/src/routes/combination/+page.svelte @@ -29,7 +29,8 @@ perfectNoteScoreFromPower, resolveCandidateAwakening, selectMemberDiverseCandidates, - toOptimizerCard + toOptimizerCard, + withinMediumProbabilityCardLimit } from '$lib/engine/index.ts'; import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/index.ts'; import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage'; @@ -77,6 +78,7 @@ fixedGenerationBonusOverrides?: (number | null)[]; songScoreBonusOverride?: number | null; ownedCardsOnly?: boolean; + maxMediumProbabilityCards?: number | null; maximizeCompute?: boolean; leaderCharacterId: string; leaderOutfitCardId: string; @@ -136,6 +138,7 @@ let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null]; let songScoreBonusOverride: number | null = 10; let ownedCardsOnly = false; + let maxMediumProbabilityCards: number | null = null; let maximizeCompute = false; let leaderCharacterId = leaderMembers[0]?.characterId ?? ''; let leaderOutfitCardId = leaderMembers[0]?.id ?? ''; @@ -220,6 +223,7 @@ fixedGenerationBonusOverrides, songScoreBonusOverride, ownedCardsOnly, + maxMediumProbabilityCards, maximizeCompute, leaderCharacterId, leaderOutfitCardId @@ -237,6 +241,7 @@ fixedGenerationBonusOverrides, songScoreBonusOverride, ownedCardsOnly, + maxMediumProbabilityCards, maximizeCompute, leaderCharacterId, leaderOutfitCardId @@ -382,6 +387,9 @@ ? null : normalizeSongScoreBonusOverride(state.songScoreBonusOverride) ?? 10; ownedCardsOnly = state.ownedCardsOnly === true; + maxMediumProbabilityCards = typeof state.maxMediumProbabilityCards === 'number' && + Number.isInteger(state.maxMediumProbabilityCards) && state.maxMediumProbabilityCards >= 0 && state.maxMediumProbabilityCards < 5 + ? state.maxMediumProbabilityCards : null; if (ownedCardsOnly) toggleOwnedCardsOnly(true); maximizeCompute = state.maximizeCompute === true; if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) { @@ -598,11 +606,15 @@ return boards; } + function acceptsCombination(cards: readonly CardRow[]): boolean { + return withinMediumProbabilityCardLimit(cards, maxMediumProbabilityCards); + } + function screeningCombinations(): CardRow[][] { const limits = combinationSearchLimits(fixedCards.length); const screeningLimit = eventMode ? Math.min(limits.screeningLimit, 9_000) : limits.screeningLimit; if (fixedCards.length === 4) { - return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit); + return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit, undefined, acceptsCombination); } const globalBudget = eventMode ? screeningLimit : Math.ceil(screeningLimit * 0.8); const anchoredBudget = screeningLimit - globalBudget; @@ -627,7 +639,8 @@ 5, strategy.scoreCard, perStrategy, - strategy.scoreCombination + strategy.scoreCombination, + acceptsCombination )) { combined.set(combinationKey(cards), cards); } @@ -641,7 +654,8 @@ candidateCards, 5, strategies, - perAnchorLimit + perAnchorLimit, + acceptsCombination )) { combined.set(combinationKey(cards), cards); } @@ -699,6 +713,7 @@ for (const replacement of candidateCards) { if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue; const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card); + if (!acceptsCombination(cards)) continue; const key = combinationKey(cards); if (key === sourceKey) continue; const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) }; @@ -1001,6 +1016,10 @@ errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.'; return; } + if (!acceptsCombination(fixedCards)) { + errorMessage = `고정 카드의 액티브 발동 확률 ‘중간’ 카드가 최대 ${maxMediumProbabilityCards}장 제한을 초과합니다. 고정 카드나 제한을 변경해 주세요.`; + return; + } running = true; searchPhase = 'screening'; exactPass = 'primary'; @@ -1158,6 +1177,15 @@ +
+ +

고정 카드를 포함한 5장 전체에 적용합니다. 보드의 발동률 증가와 관계없이 카드의 기본 확률을 기준으로 셉니다.

+