Compare commits
2
Commits
7308428945
...
ed21888eaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed21888eaf | ||
|
|
1e3d1d0b08 |
+3
-2
@@ -11,11 +11,12 @@ RUN apt-get update \
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM base AS dev
|
FROM deps AS dev
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY docker/dev-entrypoint.sh /usr/local/bin/holodream-dev-entrypoint
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
ENTRYPOINT ["sh", "/usr/local/bin/holodream-dev-entrypoint"]
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ docker compose up --build
|
|||||||
- 웹: <http://localhost:5173>
|
- 웹: <http://localhost:5173>
|
||||||
- 상태 확인: <http://localhost:5173/api/health>
|
- 상태 확인: <http://localhost:5173/api/health>
|
||||||
|
|
||||||
|
개발 컨테이너는 시작할 때 패키지 설정·잠금 파일과 Node.js 버전을 확인하고, 변경되었으면 의존성 볼륨에 `npm ci`를 실행합니다. 실행 중 의존성이 변경되면 `docker compose restart app`으로 동기화할 수 있습니다. Dockerfile 변경 후에는 `docker compose up --build -d`로 컨테이너를 다시 생성하세요.
|
||||||
|
|
||||||
종료할 때 `docker compose down`은 SQLite 볼륨을 보존합니다. `docker compose down --volumes`는 저장된 볼륨까지 제거하므로 주의하세요.
|
종료할 때 `docker compose down`은 SQLite 볼륨을 보존합니다. `docker compose down --volumes`는 저장된 볼륨까지 제거하므로 주의하세요.
|
||||||
|
|
||||||
### 호스트에서 실행
|
### 호스트에서 실행
|
||||||
|
|||||||
+5
-1
@@ -1,9 +1,13 @@
|
|||||||
---
|
---
|
||||||
updatedAt: 2026-09-02T10:13:09+09:00
|
updatedAt: 2026-09-02T12:21:05+09:00
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026년 9월 2일
|
## 2026년 9월 2일
|
||||||
|
|
||||||
|
### 중간 확률 카드 장수 제한
|
||||||
|
|
||||||
|
`NEW` 조합 탐색과 이벤트 Pt 최적화에서 액티브 발동 확률이 ‘중간’인 카드의 최대 장수를 지정할 수 있습니다. 고정 카드를 포함한 전체 편성에 적용되며, 제한 없음 또는 0~4장 이하를 선택할 수 있습니다. 설정은 프리셋에도 저장됩니다.
|
||||||
|
|
||||||
### 멤버별 보드 효과 요약
|
### 멤버별 보드 효과 요약
|
||||||
|
|
||||||
`NEW` 보드 데이터에서 선택한 멤버가 받는 기본 파라미터·액티브 발동률과 해당 보드의 색상별 효과 합계를 확인할 수 있습니다. 파라미터 증가율과 파랑·초록 고정 증가분을 분리해 계산 순서를 안내합니다. 연결 카드 증폭을 반영하고 리더·가창·holo워크 등 적용 조건도 함께 표시합니다.
|
`NEW` 보드 데이터에서 선택한 멤버가 받는 기본 파라미터·액티브 발동률과 해당 보드의 색상별 효과 합계를 확인할 수 있습니다. 파라미터 증가율과 파랑·초록 고정 증가분을 분리해 계산 순서를 안내합니다. 연결 카드 증폭을 반영하고 리더·가창·holo워크 등 적용 조건도 함께 표시합니다.
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import updateHistoryMarkdown from '../../../UPDATES.md?raw';
|
import updateHistoryMarkdown from '../../../UPDATES.md?raw';
|
||||||
import { parseUpdateHistoryDocument } from '$lib/update-history-document.ts';
|
import { parseUpdateHistoryDocument, recentUpdateEntries } from '$lib/update-history-document.ts';
|
||||||
|
|
||||||
const updateHistoryDocument = parseUpdateHistoryDocument(updateHistoryMarkdown);
|
const updateHistoryDocument = parseUpdateHistoryDocument(updateHistoryMarkdown);
|
||||||
|
const recentUpdates = recentUpdateEntries(updateHistoryDocument.body);
|
||||||
|
|
||||||
function updatedDateTime(value: string | null): string {
|
function updatedDateTime(value: string | null): string {
|
||||||
if (!value) return '시각 미등록';
|
if (!value) return '시각 미등록';
|
||||||
@@ -29,6 +30,13 @@
|
|||||||
|
|
||||||
<a class="action" href="/updates">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
|
<a class="action" href="/updates">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
{#if recentUpdates.length > 0}
|
||||||
|
<ul class="recent-updates" aria-label="최신 업데이트 3개">
|
||||||
|
{#each recentUpdates as update}
|
||||||
|
<li><span class="update-title">{update.title}</span><span class="update-date">{update.date}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -41,9 +49,15 @@
|
|||||||
.action span { margin-left: 4px; }
|
.action span { margin-left: 4px; }
|
||||||
.action:hover { color: #ffd0e2; }
|
.action:hover { color: #ffd0e2; }
|
||||||
.action:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 3px; }
|
.action:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 3px; }
|
||||||
|
.recent-updates { display: grid; gap: 9px; margin: 0 18px; padding: 13px 0 16px; border-top: 1px solid #2c3440; list-style: none; }
|
||||||
|
.recent-updates li { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; font-size: 14px; line-height: 1.5; }
|
||||||
|
.update-title { min-width: 0; color: #cbd2dc; overflow-wrap: anywhere; }
|
||||||
|
.update-date { flex: 0 0 auto; color: #7f8998; font-size: 12px; }
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.notice-header { align-items: flex-start; flex-direction: column; gap: 8px; }
|
.notice-header { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||||
.version-summary { align-items: flex-start; flex-wrap: wrap; }
|
.version-summary { align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.recent-updates li { gap: 10px; font-size: 13px; }
|
||||||
|
.update-date { font-size: 11px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
generateAnchoredCombinationCandidates,
|
generateAnchoredCombinationCandidates,
|
||||||
generateCombinationCandidates,
|
generateCombinationCandidates,
|
||||||
resolveCandidateAwakening,
|
resolveCandidateAwakening,
|
||||||
selectMemberDiverseCandidates
|
selectMemberDiverseCandidates,
|
||||||
|
withinMediumProbabilityCardLimit
|
||||||
} from './index.ts';
|
} from './index.ts';
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
@@ -15,6 +16,57 @@ const cards = [
|
|||||||
{ id: 'c-1', characterId: 'c', score: 40 }
|
{ 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('고정 카드를 유지하고 같은 멤버의 이격 카드를 제외한다', () => {
|
test('고정 카드를 유지하고 같은 멤버의 이격 카드를 제외한다', () => {
|
||||||
const result = generateCombinationCandidates([cards[0]], cards, 3, (card) => card.score, 20);
|
const result = generateCombinationCandidates([cards[0]], cards, 3, (card) => card.score, 20);
|
||||||
assert.equal(result.length, 1);
|
assert.equal(result.length, 1);
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ export type CombinationScoringStrategy<T extends CombinationSearchCard> = {
|
|||||||
const SCREENING_LIMIT_PER_OPEN_SLOT = 5_000;
|
const SCREENING_LIMIT_PER_OPEN_SLOT = 5_000;
|
||||||
const EXACT_FINALIST_LIMIT_PER_OPEN_SLOT = 56;
|
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 {
|
export function combinationSearchLimits(fixedCardCount: number, targetSize = 5): CombinationSearchLimits {
|
||||||
const normalizedTargetSize = Math.max(1, Math.floor(targetSize));
|
const normalizedTargetSize = Math.max(1, Math.floor(targetSize));
|
||||||
const normalizedFixedCardCount = Math.min(normalizedTargetSize, Math.max(0, Math.floor(fixedCardCount)));
|
const normalizedFixedCardCount = Math.min(normalizedTargetSize, Math.max(0, Math.floor(fixedCardCount)));
|
||||||
@@ -49,12 +58,15 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
|||||||
targetSize: number,
|
targetSize: number,
|
||||||
scoreCard: (card: T) => number,
|
scoreCard: (card: T) => number,
|
||||||
beamWidth = 800,
|
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[][] {
|
): T[][] {
|
||||||
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
|
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
|
||||||
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
|
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
|
||||||
throw new Error('같은 멤버를 두 번 고정할 수 없습니다.');
|
throw new Error('같은 멤버를 두 번 고정할 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
if (acceptsCombination && !acceptsCombination(fixedCards)) return [];
|
||||||
const fixedIds = new Set(fixedCards.map((card) => card.id));
|
const fixedIds = new Set(fixedCards.map((card) => card.id));
|
||||||
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
||||||
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId));
|
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId));
|
||||||
@@ -72,6 +84,7 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
|||||||
if (usedIds.has(card.id)) continue;
|
if (usedIds.has(card.id)) continue;
|
||||||
if (usedCharacters.has(card.characterId)) continue;
|
if (usedCharacters.has(card.characterId)) continue;
|
||||||
const cards = [...state.cards, card];
|
const cards = [...state.cards, card];
|
||||||
|
if (acceptsCombination && !acceptsCombination(cards)) continue;
|
||||||
const nextState = {
|
const nextState = {
|
||||||
cards,
|
cards,
|
||||||
score: scoreCombination?.(cards) ?? state.score + scoreCard(card)
|
score: scoreCombination?.(cards) ?? state.score + scoreCard(card)
|
||||||
@@ -96,7 +109,8 @@ export function generateAnchoredCombinationCandidates<T extends CombinationSearc
|
|||||||
candidateCards: readonly T[],
|
candidateCards: readonly T[],
|
||||||
targetSize: number,
|
targetSize: number,
|
||||||
strategies: readonly CombinationScoringStrategy<T>[],
|
strategies: readonly CombinationScoringStrategy<T>[],
|
||||||
perAnchorLimit: number
|
perAnchorLimit: number,
|
||||||
|
acceptsCombination?: (cards: readonly T[]) => boolean
|
||||||
): T[][] {
|
): T[][] {
|
||||||
if (strategies.length === 0 || perAnchorLimit <= 0 || fixedCards.length >= targetSize) return [];
|
if (strategies.length === 0 || perAnchorLimit <= 0 || fixedCards.length >= targetSize) return [];
|
||||||
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
||||||
@@ -113,7 +127,8 @@ export function generateAnchoredCombinationCandidates<T extends CombinationSearc
|
|||||||
targetSize,
|
targetSize,
|
||||||
strategy.scoreCard,
|
strategy.scoreCard,
|
||||||
perStrategyLimit,
|
perStrategyLimit,
|
||||||
strategy.scoreCombination
|
strategy.scoreCombination,
|
||||||
|
acceptsCombination
|
||||||
)) {
|
)) {
|
||||||
anchored.set(cards.map((card) => card.id).sort().join('|'), cards);
|
anchored.set(cards.map((card) => card.id).sort().join('|'), cards);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { parseUpdateHistoryDocument } from '../update-history-document.ts';
|
import { parseUpdateHistoryDocument, recentUpdateEntries } from '../update-history-document.ts';
|
||||||
|
|
||||||
test('업데이트 Markdown의 최근 업데이트 시각을 본문과 분리한다', () => {
|
test('업데이트 Markdown의 최근 업데이트 시각을 본문과 분리한다', () => {
|
||||||
const document = parseUpdateHistoryDocument('---\nupdatedAt: 2026-08-31T14:15:17+09:00\n---\n\n## 업데이트');
|
const document = parseUpdateHistoryDocument('---\nupdatedAt: 2026-08-31T14:15:17+09:00\n---\n\n## 업데이트');
|
||||||
@@ -13,3 +13,20 @@ test('메타데이터가 없는 Markdown도 그대로 표시한다', () => {
|
|||||||
const markdown = '## 업데이트';
|
const markdown = '## 업데이트';
|
||||||
assert.deepEqual(parseUpdateHistoryDocument(markdown), { updatedAt: null, body: markdown });
|
assert.deepEqual(parseUpdateHistoryDocument(markdown), { updatedAt: null, body: markdown });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('최근 업데이트 3개를 날짜 경계를 넘어 문서 순서대로 가져온다', () => {
|
||||||
|
const body = '## 2026년 9월 2일\r\n\r\n### 첫 업데이트\r\n설명\r\n### 다음 업데이트\r\n\r\n---\r\n## 2026년 9월 1일\r\n### 이전 업데이트\r\n### 네 번째 업데이트';
|
||||||
|
assert.deepEqual(recentUpdateEntries(body), [
|
||||||
|
{ date: '2026년 9월 2일', title: '첫 업데이트' },
|
||||||
|
{ date: '2026년 9월 2일', title: '다음 업데이트' },
|
||||||
|
{ date: '2026년 9월 1일', title: '이전 업데이트' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('업데이트가 부족하거나 없으면 있는 항목만 표시한다', () => {
|
||||||
|
assert.deepEqual(recentUpdateEntries('### 날짜 없는 제목'), []);
|
||||||
|
assert.deepEqual(recentUpdateEntries(''), []);
|
||||||
|
assert.deepEqual(recentUpdateEntries('## 2026년 9월 2일\n### 한 항목'), [
|
||||||
|
{ date: '2026년 9월 2일', title: '한 항목' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ export type UpdateHistoryDocument = {
|
|||||||
body: string;
|
body: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function recentUpdateEntries(body: string, limit = 3): { date: string; title: string }[] {
|
||||||
|
const entries: { date: string; title: string }[] = [];
|
||||||
|
let date = '';
|
||||||
|
for (const line of body.split(/\r?\n/)) {
|
||||||
|
if (entries.length >= limit) break;
|
||||||
|
const dateHeading = line.match(/^##\s+(\d{4}년 \d{1,2}월 \d{1,2}일)\s*$/);
|
||||||
|
if (dateHeading) date = dateHeading[1];
|
||||||
|
const titleHeading = line.match(/^###\s+(.+?)\s*$/);
|
||||||
|
if (date && titleHeading) entries.push({ date, title: titleHeading[1] });
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseUpdateHistoryDocument(markdown: string): UpdateHistoryDocument {
|
export function parseUpdateHistoryDocument(markdown: string): UpdateHistoryDocument {
|
||||||
const frontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
const frontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||||
if (!frontmatter) return { updatedAt: null, body: markdown };
|
if (!frontmatter) return { updatedAt: null, body: markdown };
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
perfectNoteScoreFromPower,
|
perfectNoteScoreFromPower,
|
||||||
resolveCandidateAwakening,
|
resolveCandidateAwakening,
|
||||||
selectMemberDiverseCandidates,
|
selectMemberDiverseCandidates,
|
||||||
toOptimizerCard
|
toOptimizerCard,
|
||||||
|
withinMediumProbabilityCardLimit
|
||||||
} from '$lib/engine/index.ts';
|
} from '$lib/engine/index.ts';
|
||||||
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } 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';
|
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
fixedGenerationBonusOverrides?: (number | null)[];
|
fixedGenerationBonusOverrides?: (number | null)[];
|
||||||
songScoreBonusOverride?: number | null;
|
songScoreBonusOverride?: number | null;
|
||||||
ownedCardsOnly?: boolean;
|
ownedCardsOnly?: boolean;
|
||||||
|
maxMediumProbabilityCards?: number | null;
|
||||||
maximizeCompute?: boolean;
|
maximizeCompute?: boolean;
|
||||||
leaderCharacterId: string;
|
leaderCharacterId: string;
|
||||||
leaderOutfitCardId: string;
|
leaderOutfitCardId: string;
|
||||||
@@ -136,6 +138,7 @@
|
|||||||
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
|
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
|
||||||
let songScoreBonusOverride: number | null = 10;
|
let songScoreBonusOverride: number | null = 10;
|
||||||
let ownedCardsOnly = false;
|
let ownedCardsOnly = false;
|
||||||
|
let maxMediumProbabilityCards: number | null = null;
|
||||||
let maximizeCompute = false;
|
let maximizeCompute = false;
|
||||||
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
|
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
|
||||||
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
|
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
|
||||||
@@ -220,6 +223,7 @@
|
|||||||
fixedGenerationBonusOverrides,
|
fixedGenerationBonusOverrides,
|
||||||
songScoreBonusOverride,
|
songScoreBonusOverride,
|
||||||
ownedCardsOnly,
|
ownedCardsOnly,
|
||||||
|
maxMediumProbabilityCards,
|
||||||
maximizeCompute,
|
maximizeCompute,
|
||||||
leaderCharacterId,
|
leaderCharacterId,
|
||||||
leaderOutfitCardId
|
leaderOutfitCardId
|
||||||
@@ -237,6 +241,7 @@
|
|||||||
fixedGenerationBonusOverrides,
|
fixedGenerationBonusOverrides,
|
||||||
songScoreBonusOverride,
|
songScoreBonusOverride,
|
||||||
ownedCardsOnly,
|
ownedCardsOnly,
|
||||||
|
maxMediumProbabilityCards,
|
||||||
maximizeCompute,
|
maximizeCompute,
|
||||||
leaderCharacterId,
|
leaderCharacterId,
|
||||||
leaderOutfitCardId
|
leaderOutfitCardId
|
||||||
@@ -382,6 +387,9 @@
|
|||||||
? null
|
? null
|
||||||
: normalizeSongScoreBonusOverride(state.songScoreBonusOverride) ?? 10;
|
: normalizeSongScoreBonusOverride(state.songScoreBonusOverride) ?? 10;
|
||||||
ownedCardsOnly = state.ownedCardsOnly === true;
|
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);
|
if (ownedCardsOnly) toggleOwnedCardsOnly(true);
|
||||||
maximizeCompute = state.maximizeCompute === true;
|
maximizeCompute = state.maximizeCompute === true;
|
||||||
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
|
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
|
||||||
@@ -598,11 +606,15 @@
|
|||||||
return boards;
|
return boards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function acceptsCombination(cards: readonly CardRow[]): boolean {
|
||||||
|
return withinMediumProbabilityCardLimit(cards, maxMediumProbabilityCards);
|
||||||
|
}
|
||||||
|
|
||||||
function screeningCombinations(): CardRow[][] {
|
function screeningCombinations(): CardRow[][] {
|
||||||
const limits = combinationSearchLimits(fixedCards.length);
|
const limits = combinationSearchLimits(fixedCards.length);
|
||||||
const screeningLimit = eventMode ? Math.min(limits.screeningLimit, 9_000) : limits.screeningLimit;
|
const screeningLimit = eventMode ? Math.min(limits.screeningLimit, 9_000) : limits.screeningLimit;
|
||||||
if (fixedCards.length === 4) {
|
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 globalBudget = eventMode ? screeningLimit : Math.ceil(screeningLimit * 0.8);
|
||||||
const anchoredBudget = screeningLimit - globalBudget;
|
const anchoredBudget = screeningLimit - globalBudget;
|
||||||
@@ -627,7 +639,8 @@
|
|||||||
5,
|
5,
|
||||||
strategy.scoreCard,
|
strategy.scoreCard,
|
||||||
perStrategy,
|
perStrategy,
|
||||||
strategy.scoreCombination
|
strategy.scoreCombination,
|
||||||
|
acceptsCombination
|
||||||
)) {
|
)) {
|
||||||
combined.set(combinationKey(cards), cards);
|
combined.set(combinationKey(cards), cards);
|
||||||
}
|
}
|
||||||
@@ -641,7 +654,8 @@
|
|||||||
candidateCards,
|
candidateCards,
|
||||||
5,
|
5,
|
||||||
strategies,
|
strategies,
|
||||||
perAnchorLimit
|
perAnchorLimit,
|
||||||
|
acceptsCombination
|
||||||
)) {
|
)) {
|
||||||
combined.set(combinationKey(cards), cards);
|
combined.set(combinationKey(cards), cards);
|
||||||
}
|
}
|
||||||
@@ -699,6 +713,7 @@
|
|||||||
for (const replacement of candidateCards) {
|
for (const replacement of candidateCards) {
|
||||||
if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue;
|
if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue;
|
||||||
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
|
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
|
||||||
|
if (!acceptsCombination(cards)) continue;
|
||||||
const key = combinationKey(cards);
|
const key = combinationKey(cards);
|
||||||
if (key === sourceKey) continue;
|
if (key === sourceKey) continue;
|
||||||
const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) };
|
const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) };
|
||||||
@@ -1001,6 +1016,10 @@
|
|||||||
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
|
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!acceptsCombination(fixedCards)) {
|
||||||
|
errorMessage = `고정 카드의 액티브 발동 확률 ‘중간’ 카드가 최대 ${maxMediumProbabilityCards}장 제한을 초과합니다. 고정 카드나 제한을 변경해 주세요.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
running = true;
|
running = true;
|
||||||
searchPhase = 'screening';
|
searchPhase = 'screening';
|
||||||
exactPass = 'primary';
|
exactPass = 'primary';
|
||||||
@@ -1158,6 +1177,15 @@
|
|||||||
<label>자동 후보 기수 보너스 덮어쓰기 (0~900)<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:input={(event) => updateCandidateGenerationBonus(sanitizeGenerationBonusInput(event.currentTarget, candidateGenerationBonus))}></label>
|
<label>자동 후보 기수 보너스 덮어쓰기 (0~900)<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:input={(event) => updateCandidateGenerationBonus(sanitizeGenerationBonusInput(event.currentTarget, candidateGenerationBonus))}></label>
|
||||||
<label>가창 보너스 덮어쓰기 (0~10%)<input class="number-input" type="number" min="0" max="10" step="any" value={songScoreBonusOverride ?? ''} placeholder="실제 보드값" on:input={(event) => updateSongScoreBonusOverride(event.currentTarget)}></label>
|
<label>가창 보너스 덮어쓰기 (0~10%)<input class="number-input" type="number" min="0" max="10" step="any" value={songScoreBonusOverride ?? ''} placeholder="실제 보드값" on:input={(event) => updateSongScoreBonusOverride(event.currentTarget)}></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="probability-limit-field">
|
||||||
|
<label>액티브 발동 확률 ‘중간’ 카드 최대 장수
|
||||||
|
<select bind:value={maxMediumProbabilityCards} disabled={running} aria-describedby="probability-limit-guide" on:change={() => { result = null; errorMessage = ''; }}>
|
||||||
|
<option value={null}>제한 없음</option>
|
||||||
|
{#each [0, 1, 2, 3, 4] as count}<option value={count}>{count}장{count === 0 ? ' (사용 안 함)' : ' 이하'}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p id="probability-limit-guide" class="candidate-guide">고정 카드를 포함한 5장 전체에 적용합니다. 보드의 발동률 증가와 관계없이 카드의 기본 확률을 기준으로 셉니다.</p>
|
||||||
|
</div>
|
||||||
<label class="owned-cards-option">
|
<label class="owned-cards-option">
|
||||||
<input type="checkbox" checked={ownedCardsOnly} disabled={running} on:change={(event) => toggleOwnedCardsOnly(event.currentTarget.checked)}>
|
<input type="checkbox" checked={ownedCardsOnly} disabled={running} on:change={(event) => toggleOwnedCardsOnly(event.currentTarget.checked)}>
|
||||||
<span aria-hidden="true"></span>
|
<span aria-hidden="true"></span>
|
||||||
@@ -1321,6 +1349,8 @@
|
|||||||
.candidate-fields { display: grid; grid-template-columns: clamp(130px, 12vw, 180px) repeat(2, clamp(230px, 21vw, 340px)); gap: 9px; margin-top: 18px; }
|
.candidate-fields { display: grid; grid-template-columns: clamp(130px, 12vw, 180px) repeat(2, clamp(230px, 21vw, 340px)); gap: 9px; margin-top: 18px; }
|
||||||
.candidate-awakening { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
|
.candidate-awakening { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
|
||||||
.candidate-guide { margin: 7px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
|
.candidate-guide { margin: 7px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
|
||||||
|
.probability-limit-field { margin-top: 16px; }
|
||||||
|
.probability-limit-field > label { max-width: 340px; }
|
||||||
.fixed-fields { display: grid; gap: 9px; margin-top: 16px; }
|
.fixed-fields { display: grid; gap: 9px; margin-top: 16px; }
|
||||||
.fixed-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
|
.fixed-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
|
||||||
.fixed-column-headings { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px) clamp(180px, 16vw, 240px); gap: 9px; padding: 0 2px; }
|
.fixed-column-headings { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px) clamp(180px, 16vw, 240px); gap: 9px; padding: 0 2px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user