From 272a9834ac85e5730159cac43d97c18525637cad Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sat, 27 Jun 2026 13:45:24 +0300 Subject: [PATCH] Add auction question mechanic --- src/lib/components/AuctionModal.svelte | 301 +++++++++++++++++++++++++ src/lib/question-sets/index.ts | 51 +++++ src/lib/stores/game.ts | 211 ++++++++++++++++- src/lib/types.ts | 39 ++++ src/routes/+page.svelte | 94 +++++++- 5 files changed, 674 insertions(+), 22 deletions(-) create mode 100644 src/lib/components/AuctionModal.svelte diff --git a/src/lib/components/AuctionModal.svelte b/src/lib/components/AuctionModal.svelte new file mode 100644 index 0000000..b163067 --- /dev/null +++ b/src/lib/components/AuctionModal.svelte @@ -0,0 +1,301 @@ + + + + + + diff --git a/src/lib/question-sets/index.ts b/src/lib/question-sets/index.ts index 6339d6c..493d41f 100644 --- a/src/lib/question-sets/index.ts +++ b/src/lib/question-sets/index.ts @@ -55,3 +55,54 @@ export function findQuestion( if (!question) return undefined; 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)]; +} diff --git a/src/lib/stores/game.ts b/src/lib/stores/game.ts index cf23ef2..5c0063e 100644 --- a/src/lib/stores/game.ts +++ b/src/lib/stores/game.ts @@ -1,7 +1,12 @@ import { writable } from 'svelte/store'; import { browser } from '$app/environment'; -import type { GameState, QuestionRef } from '$lib/types'; -import { findQuestion, getQuestionSetById, getTotalQuestions } from '$lib/question-sets'; +import type { AuctionState, GameState, QuestionRef } from '$lib/types'; +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'; +/** + * Шанс, что открытие вопроса запустит аукцион вместо обычного вопроса. + * Именованная константа — баланс аукционов удобно менять в одном месте. + */ +export const AUCTION_CHANCE = 0.2; + /** Начальное (пустое) состояние — экран выбора набора. */ function initialState(): GameState { return { @@ -22,7 +33,8 @@ function initialState(): GameState { score: 0, played: [], 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 { questionSetId: setId, score: parsed.score, played, - current: safeCurrent, - hintUsed + // Во время аукциона обычный открытый вопрос отсутствует. + current: auction ? null : safeCurrent, + hintUsed, + auction }; } catch { // Повреждённый 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() { const { subscribe, update, set } = writable(loadState()); @@ -124,12 +221,33 @@ function createGameStore() { /** Выбрать набор и перейти к табло. */ 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) { - 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() { - 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]) => { if (!$set) return 'select' as const; if ($finished) return 'result' as const; + if ($g.auction) return 'question' as const; if ($g.current) return 'question' as const; return 'board' as const; }); diff --git a/src/lib/types.ts b/src/lib/types.ts index ceb876d..315fcdc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -86,6 +86,39 @@ export interface QuestionSet { */ 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 и восстанавливается после перезагрузки. @@ -109,4 +142,10 @@ export interface GameState { * Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора. */ hintUsed: QuestionRef[]; + /** + * Активный аукцион или null. Пока аукцион идёт, обычный «текущий открытый + * вопрос» (`current`) равен null: на экране показан вопрос аукциона. + * Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора. + */ + auction: AuctionState | null; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8a04f75..8b49d3f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,6 @@