Add auction question mechanic
This commit is contained in:
@@ -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>
|
||||||
@@ -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
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-11
@@ -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,16 +289,34 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ===================== Модальный экран вопроса ===================== -->
|
<!-- ===================== Модальный экран вопроса ===================== -->
|
||||||
{#if effectiveScreen === 'question' && currentQuestionData}
|
{#if effectiveScreen === 'question'}
|
||||||
<QuestionModal
|
{#if $game.auction && currentAuctionData}
|
||||||
category={currentQuestionData.category}
|
<!-- Аукцион: случайный вопрос из любого набора + ставка ведущего. -->
|
||||||
question={currentQuestionData.question}
|
<AuctionModal
|
||||||
hintUsed={hintUsedForCurrent}
|
originalNominal={currentAuctionData.originalNominal}
|
||||||
onMarkHintUsed={markCurrentHintUsed}
|
sourceSetTitle={currentAuctionData.sourceSetTitle}
|
||||||
onAward={awardQuestion}
|
category={currentAuctionData.category}
|
||||||
onReject={rejectQuestion}
|
question={currentAuctionData.question}
|
||||||
onClose={closeQuestion}
|
score={$game.score}
|
||||||
/>
|
wager={currentAuctionData.wager}
|
||||||
|
hintUsed={currentAuctionData.hintUsed}
|
||||||
|
onSetWager={setAuctionWager}
|
||||||
|
onMarkHintUsed={markAuctionHintUsed}
|
||||||
|
onAward={resolveAuctionAward}
|
||||||
|
onReject={resolveAuctionReject}
|
||||||
|
onCancel={cancelAuction}
|
||||||
|
/>
|
||||||
|
{:else if currentQuestionData}
|
||||||
|
<QuestionModal
|
||||||
|
category={currentQuestionData.category}
|
||||||
|
question={currentQuestionData.question}
|
||||||
|
hintUsed={hintUsedForCurrent}
|
||||||
|
onMarkHintUsed={markCurrentHintUsed}
|
||||||
|
onAward={awardQuestion}
|
||||||
|
onReject={rejectQuestion}
|
||||||
|
onClose={closeQuestion}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ===================== Диалог подтверждения ===================== -->
|
<!-- ===================== Диалог подтверждения ===================== -->
|
||||||
|
|||||||
Reference in New Issue
Block a user