Add auction question mechanic

This commit is contained in:
2026-06-27 13:45:24 +03:00
parent 85168b8d1e
commit 272a9834ac
5 changed files with 674 additions and 22 deletions
+301
View File
@@ -0,0 +1,301 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { Category, Question } from '$lib/types';
interface Props {
/** Номинал оригинальной карточки табло, по которой запустили аукцион. */
originalNominal: number;
/** Название набора, откуда пришёл случайный вопрос аукциона. */
sourceSetTitle: string;
/** Категория случайного вопроса аукциона. */
category: Category;
/** Случайный вопрос аукциона (не с табло). */
question: Question;
/** Текущий счёт команды. */
score: number;
/** Текущая ставка — эффективная стоимость аукциона. */
wager: number;
/** Показывал ли ведущий подсказку аукциона. */
hintUsed: boolean;
onSetWager: (wager: number) => void;
onMarkHintUsed: () => void;
onAward: (points: number) => void;
onReject: () => void;
onCancel: () => void;
}
let {
originalNominal,
sourceSetTitle,
category,
question,
score,
wager,
hintUsed,
onSetWager,
onMarkHintUsed,
onAward,
onReject,
onCancel
}: Props = $props();
let answerShown = $state(false);
// При смене вопроса аукциона — сбрасываем локальное состояние показа ответа.
$effect(() => {
question.id;
answerShown = false;
});
// Ставку можно редактировать только при ненулевом счёте.
const wagerEditable = $derived(score > 0);
// Локальное зеркало поля ставки: даём свободно печатать, не сражаясь с
// пользователем, и синхронизируемся со значением из состояния, когда оно
// поменялось извне (кнопка «Ва-банк» или восстановление после перезагрузки).
let wagerInput = $state(untrack(() => String(wager)));
let lastWager = untrack(() => wager);
$effect(() => {
if (wager !== lastWager) {
lastWager = wager;
wagerInput = String(wager);
}
});
function onWagerInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
wagerInput = target.value;
const n = parseInt(target.value, 10);
// Пустое/нечисловое поле коммитим в состояние только когда оно валидно —
// даём ведущему спокойно стереть и перепечатать ставку.
if (Number.isFinite(n)) {
onSetWager(n);
}
}
function vaBank() {
onSetWager(score);
}
// Половина ставки — для ответа по подсказке или частично правильного ответа.
const halfValue = $derived(Math.round(wager / 2));
// Сколько очков даёт основное «Засчитать»: с подсказкой — половину, иначе ставку.
const awardPoints = $derived(hintUsed ? halfValue : wager);
// Управление с клавиатуры: Esc — отменить аукцион (если ответ скрыт).
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && !answerShown) {
onCancel();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Затемнённый фон -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-night-950/80 p-4 backdrop-blur-md animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="auction-modal-title"
aria-describedby="auction-modal-value"
>
<div
class="relative w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-gradient-to-br from-night-700/95 to-night-900/95 shadow-2xl shadow-night-950/60 animate-pop-in"
>
<!-- верхняя подсветка — тёплая, «аукционная» -->
<div
class="pointer-events-none absolute -top-24 left-1/2 h-48 w-[120%] -translate-x-1/2 rounded-full bg-gradient-to-r from-ember-400/25 via-gold-400/25 to-ember-400/25 blur-3xl"
></div>
<div class="relative p-6 sm:p-10">
<!-- Аукцион + источник + категория -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<span
class="inline-flex items-center rounded-full bg-ember-400/15 px-4 py-1.5 text-sm font-semibold text-ember-300"
>
🔨 Аукцион
</span>
<span class="truncate text-xs text-night-50/50">из «{sourceSetTitle}»</span>
</div>
<span
id="auction-modal-value"
class="inline-flex items-center rounded-full bg-plum-400/15 px-4 py-1.5 text-sm font-semibold text-plum-300"
>
{category.title}
</span>
</div>
<!-- Сам вопрос -->
<p
id="auction-modal-title"
class="mb-2 text-center text-2xl font-bold leading-snug text-white sm:text-4xl"
style="text-shadow: 0 0 20px rgba(255,255,255,0.08)"
>
{question.question}
</p>
<!-- Подсказка (необязательная) -->
{#if question.optionalHint}
<div class="mt-4 flex justify-center">
{#if !hintUsed}
<button
type="button"
onclick={onMarkHintUsed}
class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm text-night-50/70 transition-colors hover:bg-white/10"
>
💡 Показать подсказку
</button>
{:else}
<p class="animate-fade-in max-w-lg rounded-2xl bg-white/5 px-4 py-3 text-center text-sm italic text-night-50/70">
💡 {question.optionalHint}
</p>
{/if}
</div>
{/if}
<!-- Управление ставкой -->
<div class="mt-6 rounded-2xl border border-white/10 bg-white/5 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-sm text-night-50/70">
<div class="text-[10px] uppercase tracking-widest text-night-50/50">
Карточка табло
</div>
<div class="font-bold tabular-nums text-gold-300">
{originalNominal.toLocaleString('ru-RU')} очков
</div>
</div>
<div class="flex items-center gap-3">
{#if wagerEditable}
<label for="auction-wager" class="text-[10px] uppercase tracking-widest text-night-50/50">
Ставка
</label>
<input
id="auction-wager"
type="number"
inputmode="numeric"
min="1"
max={score}
value={wagerInput}
oninput={onWagerInput}
aria-label="Ставка аукциона"
class="w-28 rounded-xl border border-white/15 bg-night-900/60 px-3 py-2 text-center text-lg font-bold tabular-nums text-white focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/40"
/>
<button
type="button"
onclick={vaBank}
class="inline-flex items-center gap-1 rounded-xl border border-ember-400/50 bg-ember-400/15 px-4 py-2 text-sm font-bold text-ember-300 transition-all duration-200 hover:scale-105 hover:bg-ember-400/25 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ember-400/40 active:scale-95"
>
🔥 Ва-банк<span class="tabular-nums"> {score.toLocaleString('ru-RU')}</span>
</button>
{:else}
<div class="text-sm text-night-50/70">
<div class="text-[10px] uppercase tracking-widest text-night-50/50">
Ставка
</div>
<div class="font-bold tabular-nums text-gold-300">
{wager.toLocaleString('ru-RU')} очков
</div>
</div>
{/if}
</div>
</div>
{#if !wagerEditable}
<p class="mt-2 text-center text-xs text-night-50/50">
Счёт 0 — ставка равна номиналу карточки и не редактируется.
</p>
{/if}
</div>
<!-- Ответ -->
{#if answerShown}
<div class="mt-6 animate-reveal-answer">
<div class="rounded-2xl border border-mint-400/30 bg-mint-400/10 p-5 text-center">
<span class="mb-1 block text-xs font-semibold uppercase tracking-widest text-mint-300/80">
Ответ
</span>
<p class="text-xl font-bold text-mint-300 sm:text-2xl">{question.answer}</p>
</div>
<!-- Действия ведущего -->
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onclick={() => onAward(awardPoints)}
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-mint-400 to-mint-300 px-6 py-3.5 text-base font-bold text-night-900 shadow-lg shadow-mint-400/30 transition-all duration-200 hover:scale-105 hover:shadow-mint-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/50 active:scale-95"
>
✓ Засчитать
<span class="tabular-nums">+{awardPoints.toLocaleString('ru-RU')}</span>
</button>
<!-- Частично правильный ответ: половина ставки. Доступно только если
подсказка НЕ показывалась (иначе основная кнопка уже даёт половину). -->
{#if !hintUsed}
<button
type="button"
onclick={() => onAward(halfValue)}
class="inline-flex items-center gap-2 rounded-2xl border border-mint-400/40 bg-mint-400/10 px-5 py-3.5 text-base font-semibold text-mint-300 transition-all duration-200 hover:scale-105 hover:border-mint-400/60 hover:bg-mint-400/20 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/30 active:scale-95"
>
≈ Засчитать частично
<span class="tabular-nums">+{halfValue.toLocaleString('ru-RU')}</span>
</button>
{/if}
<button
type="button"
onclick={onReject}
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-6 py-3.5 text-base font-semibold text-night-50/70 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95"
>
✕ Не засчитывать
{#if wagerEditable}
<span class="tabular-nums text-night-50/50">{wager.toLocaleString('ru-RU')}</span>
{/if}
</button>
</div>
{#if hintUsed}
<p class="mt-3 text-center text-xs text-night-50/50">
💡 Подсказка показана — ответ засчитывается за половину ставки.
</p>
{:else if !wagerEditable}
<p class="mt-3 text-center text-xs text-night-50/50">
Счёт 0 — неправильный ответ не меняет очки.
</p>
{/if}
<div class="mt-4 text-center">
<button
type="button"
onclick={onCancel}
class="text-sm text-night-50/50 underline-offset-4 transition-colors hover:text-night-50/80 hover:underline"
>
← Вернуться к табло (отменить аукцион)
</button>
</div>
</div>
{:else}
<div class="mt-6 flex justify-center">
<button
type="button"
onclick={() => (answerShown = true)}
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-gold-400 to-ember-500 px-8 py-4 text-lg font-bold text-night-900 shadow-lg shadow-gold-400/30 transition-all duration-200 hover:scale-105 hover:shadow-gold-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/50 active:scale-95"
>
👁️ Показать ответ
</button>
</div>
<div class="mt-4 text-center">
<button
type="button"
onclick={onCancel}
class="text-sm text-night-50/50 underline-offset-4 transition-colors hover:text-night-50/80 hover:underline"
>
← Вернуться к табло (отменить аукцион)
</button>
</div>
{/if}
</div>
</div>
</div>
+51
View File
@@ -55,3 +55,54 @@ export function findQuestion(
if (!question) return undefined; if (!question) return undefined;
return { category, question }; return { category, question };
} }
/**
* Находит вопрос по `(setId, ref)` в любом зарегистрированном наборе.
* Используется для отображения случайного вопроса аукциона.
*/
export function findQuestionAcrossSets(
setId: string,
ref: QuestionRef
): { set: QuestionSet; category: Category; question: Question } | undefined {
const set = getQuestionSetById(setId);
if (!set) return undefined;
const found = findQuestion(set, ref);
if (!found) return undefined;
return { set, category: found.category, question: found.question };
}
/**
* Локатор вопроса в реестре: набор + ключ + сам вопрос (с категорией).
*/
export interface QuestionLocator {
setId: string;
ref: QuestionRef;
category: Category;
question: Question;
}
/**
* Случайный вопрос из всех зарегистрированных наборов — источник для аукциона.
* Опционально исключает конкретную карточку (оригинальную с табло), чтобы
* аукцион не выдал ту же самую карточку.
*/
export function pickRandomAuctionQuestion(
exclude?: { setId: string; ref: QuestionRef }
): QuestionLocator | undefined {
const all: QuestionLocator[] = [];
for (const set of questionSets) {
for (const category of set.categories) {
for (const question of category.questions) {
all.push({ setId: set.id, ref: `${category.id}:${question.id}`, category, question });
}
}
}
if (all.length === 0) return undefined;
let pool = all;
if (exclude) {
const filtered = all.filter((q) => !(q.setId === exclude.setId && q.ref === exclude.ref));
if (filtered.length > 0) pool = filtered;
}
return pool[Math.floor(Math.random() * pool.length)];
}
+200 -11
View File
@@ -1,7 +1,12 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import type { GameState, QuestionRef } from '$lib/types'; import type { AuctionState, GameState, QuestionRef } from '$lib/types';
import { findQuestion, getQuestionSetById, getTotalQuestions } from '$lib/question-sets'; import {
findQuestion,
getQuestionSetById,
getTotalQuestions,
pickRandomAuctionQuestion
} from '$lib/question-sets';
/** /**
* Игровое состояние полностью отделено от компонентов. * Игровое состояние полностью отделено от компонентов.
@@ -15,6 +20,12 @@ import { findQuestion, getQuestionSetById, getTotalQuestions } from '$lib/questi
const STORAGE_KEY = 'my-calm-game:state:v1'; const STORAGE_KEY = 'my-calm-game:state:v1';
/**
* Шанс, что открытие вопроса запустит аукцион вместо обычного вопроса.
* Именованная константа — баланс аукционов удобно менять в одном месте.
*/
export const AUCTION_CHANCE = 0.2;
/** Начальное (пустое) состояние — экран выбора набора. */ /** Начальное (пустое) состояние — экран выбора набора. */
function initialState(): GameState { function initialState(): GameState {
return { return {
@@ -22,7 +33,8 @@ function initialState(): GameState {
score: 0, score: 0,
played: [], played: [],
current: null, current: null,
hintUsed: [] hintUsed: [],
auction: null
}; };
} }
@@ -87,12 +99,53 @@ function loadState(): GameState {
} }
} }
// Аукцион: восстанавливаем только при корректной структуре и валидных
// ссылках. Старые сохранения без `auction` (или с мусором) → null.
let auction: AuctionState | null = null;
const parsedAuction = parsed.auction;
if (parsedAuction !== null && parsedAuction !== undefined) {
const a = parsedAuction;
if (
a &&
typeof a === 'object' &&
typeof a.originalRef === 'string' &&
typeof a.sourceQuestionSetId === 'string' &&
typeof a.sourceRef === 'string' &&
typeof a.originalNominal === 'number' &&
typeof a.wager === 'number' &&
typeof a.hintUsed === 'boolean'
) {
const originalFound = findQuestion(set, a.originalRef);
const originalPlayed = seen.has(a.originalRef);
const sourceSet = getQuestionSetById(a.sourceQuestionSetId);
const sourceFound = sourceSet ? findQuestion(sourceSet, a.sourceRef) : undefined;
if (originalFound && !originalPlayed && sourceSet && sourceFound) {
// Номинал берём авторитетно из данных, а не из сохранения.
const originalNominal = originalFound.question.points;
// При ненулевом счёте ставку ограничиваем [1, счёт];
// при счёте 0 ставка равна номиналу (не редактируется).
const wager =
parsed.score > 0 ? clampInt(a.wager, 1, parsed.score) : originalNominal;
auction = {
originalRef: a.originalRef,
sourceQuestionSetId: a.sourceQuestionSetId,
sourceRef: a.sourceRef,
originalNominal,
wager,
hintUsed: a.hintUsed
};
}
}
}
return { return {
questionSetId: setId, questionSetId: setId,
score: parsed.score, score: parsed.score,
played, played,
current: safeCurrent, // Во время аукциона обычный открытый вопрос отсутствует.
hintUsed current: auction ? null : safeCurrent,
hintUsed,
auction
}; };
} catch { } catch {
// Повреждённый JSON — просто начинаем заново. // Повреждённый JSON — просто начинаем заново.
@@ -111,6 +164,50 @@ function saveState(state: GameState): void {
} }
} }
/** Ограничить целое число диапазоном [min, max]; NaN/Infinity → min. */
function clampInt(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, Math.trunc(value)));
}
/**
* Создаёт состояние запуска аукциона по оригинальной карточке табло.
*
* Чистая функция: выбирает случайный вопрос из всех зарегистрированных наборов
* и считает стартовую ставку. Если набор/вопрос не найдены или нет вопросов для
* аукциона — возвращаем состояние обычного открытия вопроса (мягкий фолбэк,
* чтобы игра никогда не падала из-за аукциона).
*/
function startAuctionState(state: GameState, originalRef: QuestionRef): GameState {
const setId = state.questionSetId;
if (!setId) return { ...state, current: originalRef };
const set = getQuestionSetById(setId);
if (!set) return { ...state, current: originalRef };
const found = findQuestion(set, originalRef);
if (!found) return { ...state, current: originalRef };
const originalNominal = found.question.points;
const pick = pickRandomAuctionQuestion({ setId, ref: originalRef });
if (!pick) return { ...state, current: originalRef };
// При ненулевом счёте ставка по умолчанию = min(номинал, счёт).
// При счёте 0 — равна номиналу карточки и не редактируется.
const wager = state.score > 0 ? Math.min(originalNominal, state.score) : originalNominal;
return {
...state,
current: null,
auction: {
originalRef,
sourceQuestionSetId: pick.setId,
sourceRef: pick.ref,
originalNominal,
wager,
hintUsed: false
}
};
}
function createGameStore() { function createGameStore() {
const { subscribe, update, set } = writable<GameState>(loadState()); const { subscribe, update, set } = writable<GameState>(loadState());
@@ -124,12 +221,33 @@ function createGameStore() {
/** Выбрать набор и перейти к табло. */ /** Выбрать набор и перейти к табло. */
selectQuestionSet(id: string) { selectQuestionSet(id: string) {
update((s) => ({ ...s, questionSetId: id, score: 0, played: [], current: null, hintUsed: [] })); update((s) => ({
...s,
questionSetId: id,
score: 0,
played: [],
current: null,
hintUsed: [],
auction: null
}));
}, },
/** Открыть вопрос по ключу `categoryId:questionId`. */ /**
* Открыть вопрос по ключу `categoryId:questionId`.
*
* С шансом {@link AUCTION_CHANCE} вместо обычного вопроса запускается
* аукцион: случайный вопрос из любого зарегистрированного набора +
* ставка ведущего. Иначе — обычное поведение.
*/
openQuestion(ref: QuestionRef) { openQuestion(ref: QuestionRef) {
update((s) => ({ ...s, current: ref })); update((s) => {
// Аукцион уже идёт — не открываем новый вопрос поверх.
if (s.auction) return s;
if (Math.random() < AUCTION_CHANCE) {
return startAuctionState(s, ref);
}
return { ...s, current: ref };
});
}, },
/** Закрыть текущий вопрос без решения (вернуться к табло). */ /** Закрыть текущий вопрос без решения (вернуться к табло). */
@@ -173,13 +291,83 @@ function createGameStore() {
})); }));
}, },
/* ------------------------------ Аукцион ------------------------------ */
/**
* Изменить ставку аукциона (ведущий ввёл число или нажал «Ва-банк»).
* Менять ставку можно только при ненулевом счёте; значение ограничено
* диапазоном [1, текущий счёт].
*/
setAuctionWager(wager: number) {
update((s) => {
if (!s.auction || s.score <= 0) return s;
return { ...s, auction: { ...s.auction, wager: clampInt(wager, 1, s.score) } };
});
},
/** Отметить, что ведущий раскрыл подсказку аукциона. */
markAuctionHintUsed() {
update((s) => {
if (!s.auction || s.auction.hintUsed) return s;
return { ...s, auction: { ...s.auction, hintUsed: true } };
});
},
/**
* Засчитать ответ в аукционе: добавить очки и пометить оригинальную
* карточку табло сыгранной. Случайный вопрос-источник НЕ помечается.
*/
resolveAuctionAward(points: number) {
update((s) => {
if (!s.auction) return s;
const ref = s.auction.originalRef;
return {
...s,
score: s.score + points,
played: s.played.includes(ref) ? s.played : [...s.played, ref],
current: null,
auction: null
};
});
},
/**
* Не засчитать ответ в аукционе: вычесть ставку (не ниже нуля) и пометить
* оригинальную карточку сыгранной. При счёте 0 вычитать нечего — очки
* не меняются (в минус не уходим).
*/
resolveAuctionReject() {
update((s) => {
if (!s.auction) return s;
const ref = s.auction.originalRef;
const wager = s.auction.wager;
return {
...s,
score: Math.max(0, s.score - wager),
played: s.played.includes(ref) ? s.played : [...s.played, ref],
current: null,
auction: null
};
});
},
/**
* Отменить аукцион и вернуться к табло, не разрешая его:
* очки не меняются, оригинальная карточка НЕ помечается сыгранной.
* Мягкий выход для ведущего (как «Вернуться к табло» у обычного вопроса).
*/
cancelAuction() {
update((s) => (s.auction ? { ...s, auction: null } : s));
},
/** /**
* Новая игра: сбросить прогресс по ТЕКУЩЕМУ набору (счёт, сыгранные, * Новая игра: сбросить прогресс по ТЕКУЩЕМУ набору (счёт, сыгранные,
* текущий вопрос, использованные подсказки). Набор остаётся выбранным. * текущий вопрос, использованные подсказки, активный аукцион). Набор
* Сохранение очищается соответствующим образом (через автосохранение). * остаётся выбранным. Сохранение очищается соответствующим образом
* (через автосохранение).
*/ */
newGame() { newGame() {
update((s) => ({ ...s, score: 0, played: [], current: null, hintUsed: [] })); update((s) => ({ ...s, score: 0, played: [], current: null, hintUsed: [], auction: null }));
}, },
/** /**
@@ -239,6 +427,7 @@ export const isFinished = derived(
export const screen = derived([game, currentSet, isFinished], ([$g, $set, $finished]) => { export const screen = derived([game, currentSet, isFinished], ([$g, $set, $finished]) => {
if (!$set) return 'select' as const; if (!$set) return 'select' as const;
if ($finished) return 'result' as const; if ($finished) return 'result' as const;
if ($g.auction) return 'question' as const;
if ($g.current) return 'question' as const; if ($g.current) return 'question' as const;
return 'board' as const; return 'board' as const;
}); });
+39
View File
@@ -86,6 +86,39 @@ export interface QuestionSet {
*/ */
export type QuestionRef = string; export type QuestionRef = string;
/**
* Активный аукцион — спецвопрос в стиле «Своей игры»: вместо вопроса с табло
* открывается случайный вопрос из любого зарегистрированного набора, а ведущий
* назначает ставку. Когда аукцион разрешается, сыгранной помечается именно
* оригинальная карточка табло (не источник случайного вопроса).
*
* Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора.
*/
export interface AuctionState {
/**
* Оригинальная карточка табло, по которой запустили аукцион
* (`categoryId:questionId` в пределах текущего набора). Помечается сыгранной
* при разрешении аукциона.
*/
originalRef: QuestionRef;
/** id набора, откуда взят случайный вопрос аукциона. */
sourceQuestionSetId: string;
/**
* Случайный вопрос аукциона (`categoryId:questionId` в пределах
* `sourceQuestionSetId`). НЕ помечается сыгранным.
*/
sourceRef: QuestionRef;
/** Номинал оригинальной карточки табло. */
originalNominal: PointValue;
/**
* Текущая ставка ведущего — эффективная стоимость аукциона.
* При счёте 0 равна `originalNominal` и не редактируется.
*/
wager: number;
/** Показывал ли ведущий подсказку аукциона. */
hintUsed: boolean;
}
/** /**
* Полное состояние одной партии. * Полное состояние одной партии.
* Сериализуется в localStorage и восстанавливается после перезагрузки. * Сериализуется в localStorage и восстанавливается после перезагрузки.
@@ -109,4 +142,10 @@ export interface GameState {
* Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора. * Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора.
*/ */
hintUsed: QuestionRef[]; hintUsed: QuestionRef[];
/**
* Активный аукцион или null. Пока аукцион идёт, обычный «текущий открытый
* вопрос» (`current`) равен null: на экране показан вопрос аукциона.
* Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора.
*/
auction: AuctionState | null;
} }
+74 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { game, currentSet, totalQuestions, playedCount, isFinished, screen } from '$lib/stores/game'; import { game, currentSet, totalQuestions, playedCount, isFinished, screen } from '$lib/stores/game';
import { questionSets, findQuestion } from '$lib/question-sets'; import { questionSets, findQuestion, findQuestionAcrossSets } from '$lib/question-sets';
import { celebrate } from '$lib/confetti'; import { celebrate } from '$lib/confetti';
import type { QuestionRef } from '$lib/types'; import type { QuestionRef } from '$lib/types';
import QuestionSetSelector from '$lib/components/QuestionSetSelector.svelte'; import QuestionSetSelector from '$lib/components/QuestionSetSelector.svelte';
@@ -8,6 +8,7 @@
import ScorePanel from '$lib/components/ScorePanel.svelte'; import ScorePanel from '$lib/components/ScorePanel.svelte';
import HostControls from '$lib/components/HostControls.svelte'; import HostControls from '$lib/components/HostControls.svelte';
import QuestionModal from '$lib/components/QuestionModal.svelte'; import QuestionModal from '$lib/components/QuestionModal.svelte';
import AuctionModal from '$lib/components/AuctionModal.svelte';
import GameResult from '$lib/components/GameResult.svelte'; import GameResult from '$lib/components/GameResult.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -41,6 +42,27 @@
return $game.hintUsed.includes(ref); return $game.hintUsed.includes(ref);
}); });
/**
* Данные активного аукциона: случайный вопрос-источник (из любого набора)
* + стейт ставки. Пока аукцион идёт, показываем AuctionModal вместо обычного
* QuestionModal.
*/
const currentAuctionData = $derived.by(() => {
const a = $game.auction;
if (!a) return null;
const found = findQuestionAcrossSets(a.sourceQuestionSetId, a.sourceRef);
if (!found) return null;
return {
originalRef: a.originalRef,
originalNominal: a.originalNominal,
wager: a.wager,
hintUsed: a.hintUsed,
sourceSetTitle: found.set.title,
category: found.category,
question: found.question
};
});
// ---- Действия ведущего ---- // ---- Действия ведущего ----
function selectSet(id: string) { function selectSet(id: string) {
@@ -94,6 +116,38 @@
game.markHintUsed(ref); game.markHintUsed(ref);
} }
// ---- Действия ведущего: аукцион ----
/** Ведущий изменил ставку аукциона (ввод или «Ва-банк»). */
function setAuctionWager(wager: number) {
game.setAuctionWager(wager);
}
/** Ведущий раскрыл подсказку аукциона. */
function markAuctionHintUsed() {
game.markAuctionHintUsed();
}
/** Засчитать ответ в аукционе: добавить очки, карточку табло пометить сыгранной. */
function resolveAuctionAward(points: number) {
if (!$game.auction) return;
game.resolveAuctionAward(points);
bumpSignal++;
// Праздничное конфетти — засчитанный ответ в аукционе!
celebrate();
}
/** Не засчитать ответ в аукционе: вычесть ставку (не ниже 0), карточку пометить сыгранной. */
function resolveAuctionReject() {
if (!$game.auction) return;
game.resolveAuctionReject();
}
/** Отменить аукцион: вернуться к табло, не разрешая его (карточка не помечается). */
function cancelAuction() {
game.cancelAuction();
}
function randomQuestion() { function randomQuestion() {
const set = $currentSet; const set = $currentSet;
if (!set) return; if (!set) return;
@@ -235,7 +289,24 @@
{/if} {/if}
<!-- ===================== Модальный экран вопроса ===================== --> <!-- ===================== Модальный экран вопроса ===================== -->
{#if effectiveScreen === 'question' && currentQuestionData} {#if effectiveScreen === 'question'}
{#if $game.auction && currentAuctionData}
<!-- Аукцион: случайный вопрос из любого набора + ставка ведущего. -->
<AuctionModal
originalNominal={currentAuctionData.originalNominal}
sourceSetTitle={currentAuctionData.sourceSetTitle}
category={currentAuctionData.category}
question={currentAuctionData.question}
score={$game.score}
wager={currentAuctionData.wager}
hintUsed={currentAuctionData.hintUsed}
onSetWager={setAuctionWager}
onMarkHintUsed={markAuctionHintUsed}
onAward={resolveAuctionAward}
onReject={resolveAuctionReject}
onCancel={cancelAuction}
/>
{:else if currentQuestionData}
<QuestionModal <QuestionModal
category={currentQuestionData.category} category={currentQuestionData.category}
question={currentQuestionData.question} question={currentQuestionData.question}
@@ -246,6 +317,7 @@
onClose={closeQuestion} onClose={closeQuestion}
/> />
{/if} {/if}
{/if}
<!-- ===================== Диалог подтверждения ===================== --> <!-- ===================== Диалог подтверждения ===================== -->
<ConfirmDialog <ConfirmDialog