From 1d5723788e0d4a2be91b42f2ac8a925a59a86ca0 Mon Sep 17 00:00:00 2001 From: Talon Date: Thu, 12 Mar 2026 20:44:28 +0100 Subject: [PATCH] Add card table item --- .gitignore | 4 + client/src/items/cardTableController.ts | 521 ++++++++++++++++++ client/src/main.ts | 23 + client/src/network/messageHandlers.ts | 11 + client/src/state/gameState.ts | 19 +- server/app/items/types/card_table/__init__.py | 1 + server/app/items/types/card_table/actions.py | 64 +++ .../app/items/types/card_table/definition.py | 39 ++ server/app/items/types/card_table/plugin.py | 17 + .../app/items/types/card_table/validator.py | 64 +++ 10 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 client/src/items/cardTableController.ts create mode 100644 server/app/items/types/card_table/__init__.py create mode 100644 server/app/items/types/card_table/actions.py create mode 100644 server/app/items/types/card_table/definition.py create mode 100644 server/app/items/types/card_table/plugin.py create mode 100644 server/app/items/types/card_table/validator.py diff --git a/.gitignore b/.gitignore index feb9f22..40bd3fe 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ plans/ # Host-local notes local/ + +# Ignore actual sounds in sounds/widgets/ +sounds/widgets/*.ogg +sounds/widgets/**/*.ogg \ No newline at end of file diff --git a/client/src/items/cardTableController.ts b/client/src/items/cardTableController.ts new file mode 100644 index 0000000..cda083c --- /dev/null +++ b/client/src/items/cardTableController.ts @@ -0,0 +1,521 @@ +import { handleListControlKey } from '../input/listController'; +import { handleYesNoMenuInput, YES_NO_OPTIONS } from '../input/yesNoMenu'; +import type { OutgoingMessage } from '../network/protocol'; +import type { GameMode, WorldItem } from '../state/gameState'; + +const CARD_ACTIONS = ['Discard', 'Return to draw pile', 'Cancel'] as const; + +const RANK_NAMES: Record = { + A: 'Ace', + '2': 'Two', + '3': 'Three', + '4': 'Four', + '5': 'Five', + '6': 'Six', + '7': 'Seven', + '8': 'Eight', + '9': 'Nine', + '10': 'Ten', + J: 'Jack', + Q: 'Queen', + K: 'King', +}; + +const SUIT_NAMES: Record = { + S: 'Spades', + H: 'Hearts', + D: 'Diamonds', + C: 'Clubs', +}; + +function cardName(code: string): string { + if (code === 'JO1' || code === 'JO2') return 'Joker'; + const suit = code.slice(-1); + const rank = code.slice(0, -1); + return `${RANK_NAMES[rank] ?? rank} of ${SUIT_NAMES[suit] ?? suit}`; +} + +type CardTableControllerDeps = { + state: { + mode: GameMode; + items: Map; + player: { nickname: string }; + cardTableItemId: string | null; + cardTableMenuIndex: number; + cardTableHandIndex: number; + cardTableCardActionIndex: number; + cardTableDiscardIndex: number; + cardTableConfirmIndex: number; + }; + signalingSend: (message: OutgoingMessage) => void; + updateStatus: (message: string) => void; + sfxUiBlip: () => void; + sfxUiCancel: () => void; +}; + +function getDrawPile(item: WorldItem): string[] { + const raw = item.params['draw_pile']; + if (!Array.isArray(raw)) return []; + return raw.filter((c): c is string => typeof c === 'string'); +} + +function getDiscardPile(item: WorldItem): string[] { + const raw = item.params['discard_pile']; + if (!Array.isArray(raw)) return []; + return raw.filter((c): c is string => typeof c === 'string'); +} + +function getHand(item: WorldItem, nickname: string): string[] { + const hands = item.params['hands']; + if (!hands || typeof hands !== 'object' || Array.isArray(hands)) return []; + const hand = (hands as Record)[nickname]; + if (!Array.isArray(hand)) return []; + return hand.filter((c): c is string => typeof c === 'string'); +} + +function buildMainMenuEntries(item: WorldItem, nickname: string): string[] { + const drawPile = getDrawPile(item); + const discardPile = getDiscardPile(item); + const hand = getHand(item, nickname); + return [ + drawPile.length > 0 ? `Draw a card (${drawPile.length} in pile)` : 'Draw a card (pile empty)', + discardPile.length > 0 ? `Draw from discard (${discardPile.length})` : 'Draw from discard (none)', + hand.length > 0 ? `View hand (${hand.length} cards)` : 'View hand (empty)', + 'Shuffle and reset', + 'Close', + ]; +} + +export function createCardTableController(deps: CardTableControllerDeps): { + beginCardTableMenu: (item: WorldItem) => void; + handleCardTableMenuModeInput: (code: string, key: string) => void; + handleCardTableHandModeInput: (code: string, key: string) => void; + handleCardTableCardActionModeInput: (code: string, key: string) => void; + handleCardTableDiscardModeInput: (code: string, key: string) => void; + handleCardTableConfirmResetModeInput: (code: string, key: string) => void; + refreshCardTableStatus: () => void; +} { + function getActiveItem(): WorldItem | null { + return deps.state.cardTableItemId ? (deps.state.items.get(deps.state.cardTableItemId) ?? null) : null; + } + + function exitMenu(): void { + deps.state.mode = 'normal'; + deps.state.cardTableItemId = null; + } + + function beginCardTableMenu(item: WorldItem): void { + deps.state.cardTableItemId = item.id; + deps.state.cardTableMenuIndex = 0; + deps.state.mode = 'cardTableMenu'; + const entries = buildMainMenuEntries(item, deps.state.player.nickname); + deps.updateStatus(`${item.title}. ${entries[0]}.`); + deps.sfxUiBlip(); + } + + function handleCardTableMenuModeInput(code: string, key: string): void { + const item = getActiveItem(); + if (!item) { + exitMenu(); + return; + } + + const nickname = deps.state.player.nickname; + const entries = buildMainMenuEntries(item, nickname); + const control = handleListControlKey(code, key, entries, deps.state.cardTableMenuIndex, (e) => e); + + if (control.type === 'move') { + deps.state.cardTableMenuIndex = control.index; + deps.updateStatus(entries[control.index]); + deps.sfxUiBlip(); + return; + } + + if (control.type === 'select') { + const idx = deps.state.cardTableMenuIndex; + + if (idx === 0) { + // Draw a card + const drawPile = getDrawPile(item); + if (drawPile.length === 0) { + deps.updateStatus('Draw pile is empty.'); + deps.sfxUiCancel(); + return; + } + const card = drawPile[0]; + const newDrawPile = drawPile.slice(1); + const hands = item.params['hands']; + const handsObj: Record = + hands && typeof hands === 'object' && !Array.isArray(hands) + ? (hands as Record) + : {}; + const currentHand = Array.isArray(handsObj[nickname]) ? [...handsObj[nickname]] : []; + currentHand.push(card); + const newHands = { ...handsObj, [nickname]: currentHand }; + deps.signalingSend({ + type: 'item_update', + itemId: item.id, + params: { draw_pile: newDrawPile, hands: newHands }, + }); + deps.updateStatus(`Drew ${cardName(card)}. ${newDrawPile.length} cards remaining in pile.`); + deps.sfxUiBlip(); + return; + } + + if (idx === 1) { + // Draw from discard + const discardPile = getDiscardPile(item); + if (discardPile.length === 0) { + deps.updateStatus('Discard pile is empty.'); + deps.sfxUiCancel(); + return; + } + deps.state.cardTableDiscardIndex = 0; + deps.state.mode = 'cardTableDiscard'; + deps.updateStatus(`Discard pile. ${cardName(discardPile[0])}.`); + deps.sfxUiBlip(); + return; + } + + if (idx === 2) { + // View hand + const hand = getHand(item, nickname); + if (hand.length === 0) { + deps.updateStatus('Your hand is empty.'); + deps.sfxUiCancel(); + return; + } + deps.state.cardTableHandIndex = 0; + deps.state.mode = 'cardTableHand'; + deps.updateStatus(`Your hand. ${cardName(hand[0])}.`); + deps.sfxUiBlip(); + return; + } + + if (idx === 3) { + // Shuffle and reset — confirm first + deps.state.cardTableConfirmIndex = 0; + deps.state.mode = 'cardTableConfirmReset'; + deps.updateStatus(`Shuffle and reset ${item.title}? ${YES_NO_OPTIONS[0].label}.`); + deps.sfxUiBlip(); + return; + } + + // Close + exitMenu(); + deps.updateStatus('Closed.'); + deps.sfxUiCancel(); + return; + } + + if (control.type === 'cancel') { + exitMenu(); + deps.updateStatus('Closed.'); + deps.sfxUiCancel(); + } + } + + function handleCardTableHandModeInput(code: string, key: string): void { + const item = getActiveItem(); + if (!item) { + exitMenu(); + return; + } + + const nickname = deps.state.player.nickname; + const hand = getHand(item, nickname); + const entries = [...hand.map(cardName), 'Back']; + const control = handleListControlKey(code, key, entries, deps.state.cardTableHandIndex, (e) => e); + + if (control.type === 'move') { + deps.state.cardTableHandIndex = control.index; + deps.updateStatus(entries[control.index]); + deps.sfxUiBlip(); + return; + } + + if (control.type === 'select') { + if (deps.state.cardTableHandIndex === hand.length) { + // Back + deps.state.mode = 'cardTableMenu'; + const menuEntries = buildMainMenuEntries(item, nickname); + deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]); + deps.sfxUiCancel(); + return; + } + // Select a card → card action submenu + deps.state.cardTableCardActionIndex = 0; + deps.state.mode = 'cardTableCardAction'; + const selectedCard = hand[deps.state.cardTableHandIndex]; + deps.updateStatus(`${cardName(selectedCard)}. ${CARD_ACTIONS[0]}.`); + deps.sfxUiBlip(); + return; + } + + if (control.type === 'cancel') { + deps.state.mode = 'cardTableMenu'; + const menuEntries = buildMainMenuEntries(item, nickname); + deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]); + deps.sfxUiCancel(); + } + } + + function handleCardTableCardActionModeInput(code: string, key: string): void { + const item = getActiveItem(); + if (!item) { + exitMenu(); + return; + } + + const nickname = deps.state.player.nickname; + const hand = getHand(item, nickname); + const cardIndex = deps.state.cardTableHandIndex; + + if (cardIndex >= hand.length) { + // Card no longer exists, go back to hand + deps.state.cardTableHandIndex = Math.max(0, hand.length - 1); + deps.state.mode = 'cardTableHand'; + const entries = [...hand.map(cardName), 'Back']; + deps.updateStatus(entries[deps.state.cardTableHandIndex] ?? 'Back'); + deps.sfxUiCancel(); + return; + } + + const control = handleListControlKey(code, key, CARD_ACTIONS, deps.state.cardTableCardActionIndex, (e) => e); + + if (control.type === 'move') { + deps.state.cardTableCardActionIndex = control.index; + deps.updateStatus(CARD_ACTIONS[control.index]); + deps.sfxUiBlip(); + return; + } + + if (control.type === 'select') { + const actionIdx = deps.state.cardTableCardActionIndex; + const card = hand[cardIndex]; + const newHand = [...hand]; + newHand.splice(cardIndex, 1); + + const hands = item.params['hands']; + const handsObj: Record = + hands && typeof hands === 'object' && !Array.isArray(hands) + ? (hands as Record) + : {}; + + if (actionIdx === 0) { + // Discard + const discardPile = getDiscardPile(item); + const newDiscard = [card, ...discardPile]; + const newHands = { ...handsObj, [nickname]: newHand }; + deps.signalingSend({ + type: 'item_update', + itemId: item.id, + params: { discard_pile: newDiscard, hands: newHands }, + }); + deps.state.cardTableHandIndex = Math.min(cardIndex, Math.max(0, newHand.length - 1)); + if (newHand.length === 0) { + deps.state.mode = 'cardTableMenu'; + deps.state.cardTableMenuIndex = 2; // view hand entry + deps.updateStatus(`${cardName(card)} discarded. Hand is empty.`); + } else { + deps.state.mode = 'cardTableHand'; + deps.updateStatus(`${cardName(card)} discarded.`); + } + deps.sfxUiBlip(); + return; + } + + if (actionIdx === 1) { + // Return to draw pile + const drawPile = getDrawPile(item); + const newDrawPile = [...drawPile, card]; + const newHands = { ...handsObj, [nickname]: newHand }; + deps.signalingSend({ + type: 'item_update', + itemId: item.id, + params: { draw_pile: newDrawPile, hands: newHands }, + }); + deps.state.cardTableHandIndex = Math.min(cardIndex, Math.max(0, newHand.length - 1)); + if (newHand.length === 0) { + deps.state.mode = 'cardTableMenu'; + deps.state.cardTableMenuIndex = 2; + deps.updateStatus(`${cardName(card)} returned to draw pile. Hand is empty.`); + } else { + deps.state.mode = 'cardTableHand'; + deps.updateStatus(`${cardName(card)} returned to draw pile.`); + } + deps.sfxUiBlip(); + return; + } + + // Cancel + deps.state.mode = 'cardTableHand'; + const entries = [...hand.map(cardName), 'Back']; + deps.updateStatus(entries[cardIndex] ?? 'Back'); + deps.sfxUiCancel(); + return; + } + + if (control.type === 'cancel') { + deps.state.mode = 'cardTableHand'; + const entries = [...hand.map(cardName), 'Back']; + deps.updateStatus(entries[cardIndex] ?? 'Back'); + deps.sfxUiCancel(); + } + } + + function handleCardTableDiscardModeInput(code: string, key: string): void { + const item = getActiveItem(); + if (!item) { + exitMenu(); + return; + } + + const nickname = deps.state.player.nickname; + const discardPile = getDiscardPile(item); + const entries = [...discardPile.map(cardName), 'Back']; + const control = handleListControlKey(code, key, entries, deps.state.cardTableDiscardIndex, (e) => e); + + if (control.type === 'move') { + deps.state.cardTableDiscardIndex = control.index; + deps.updateStatus(entries[control.index]); + deps.sfxUiBlip(); + return; + } + + if (control.type === 'select') { + if (deps.state.cardTableDiscardIndex === discardPile.length) { + // Back + deps.state.mode = 'cardTableMenu'; + const menuEntries = buildMainMenuEntries(item, nickname); + deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]); + deps.sfxUiCancel(); + return; + } + + // Take card from discard into hand + const cardIdx = deps.state.cardTableDiscardIndex; + const card = discardPile[cardIdx]; + const newDiscard = [...discardPile]; + newDiscard.splice(cardIdx, 1); + + const hands = item.params['hands']; + const handsObj: Record = + hands && typeof hands === 'object' && !Array.isArray(hands) + ? (hands as Record) + : {}; + const currentHand = Array.isArray(handsObj[nickname]) ? [...handsObj[nickname]] : []; + currentHand.push(card); + const newHands = { ...handsObj, [nickname]: currentHand }; + + deps.signalingSend({ + type: 'item_update', + itemId: item.id, + params: { discard_pile: newDiscard, hands: newHands }, + }); + + deps.state.mode = 'cardTableMenu'; + deps.state.cardTableMenuIndex = 0; + deps.updateStatus(`Took ${cardName(card)} from discard pile.`); + deps.sfxUiBlip(); + return; + } + + if (control.type === 'cancel') { + deps.state.mode = 'cardTableMenu'; + const menuEntries = buildMainMenuEntries(item, nickname); + deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]); + deps.sfxUiCancel(); + } + } + + function handleCardTableConfirmResetModeInput(code: string, key: string): void { + const item = getActiveItem(); + if (!item) { + exitMenu(); + return; + } + + const control = handleYesNoMenuInput(code, key, deps.state.cardTableConfirmIndex); + + if (control.type === 'move') { + deps.state.cardTableConfirmIndex = control.index; + deps.updateStatus(YES_NO_OPTIONS[control.index].label); + deps.sfxUiBlip(); + return; + } + + if (control.type === 'select') { + if (YES_NO_OPTIONS[deps.state.cardTableConfirmIndex].id === 'yes') { + deps.signalingSend({ type: 'item_secondary_use', itemId: item.id }); + exitMenu(); + deps.updateStatus('Shuffling and resetting card table.'); + deps.sfxUiBlip(); + } else { + deps.state.mode = 'cardTableMenu'; + const menuEntries = buildMainMenuEntries(item, deps.state.player.nickname); + deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]); + deps.sfxUiCancel(); + } + return; + } + + if (control.type === 'cancel') { + deps.state.mode = 'cardTableMenu'; + const menuEntries = buildMainMenuEntries(item, deps.state.player.nickname); + deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]); + deps.sfxUiCancel(); + } + } + + function refreshCardTableStatus(): void { + const item = getActiveItem(); + if (!item) return; + + const nickname = deps.state.player.nickname; + const mode = deps.state.mode; + + if (mode === 'cardTableMenu') { + const entries = buildMainMenuEntries(item, nickname); + deps.state.cardTableMenuIndex = Math.min(deps.state.cardTableMenuIndex, entries.length - 1); + deps.updateStatus(`Updated. ${entries[deps.state.cardTableMenuIndex]}.`); + } else if (mode === 'cardTableHand') { + const hand = getHand(item, nickname); + const entries = [...hand.map(cardName), 'Back']; + deps.state.cardTableHandIndex = Math.min(deps.state.cardTableHandIndex, entries.length - 1); + deps.updateStatus(`Updated. ${entries[deps.state.cardTableHandIndex]}.`); + } else if (mode === 'cardTableCardAction') { + const hand = getHand(item, nickname); + if (deps.state.cardTableHandIndex >= hand.length) { + deps.state.cardTableHandIndex = Math.max(0, hand.length - 1); + if (hand.length === 0) { + deps.state.mode = 'cardTableMenu'; + const menuEntries = buildMainMenuEntries(item, nickname); + deps.updateStatus(`Updated. ${menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]}.`); + } else { + deps.state.mode = 'cardTableHand'; + const entries = [...hand.map(cardName), 'Back']; + deps.updateStatus(`Updated. ${entries[deps.state.cardTableHandIndex]}.`); + } + } else { + const card = hand[deps.state.cardTableHandIndex]; + deps.updateStatus(`Updated. ${cardName(card)}. ${CARD_ACTIONS[deps.state.cardTableCardActionIndex]}.`); + } + } else if (mode === 'cardTableDiscard') { + const discardPile = getDiscardPile(item); + const entries = [...discardPile.map(cardName), 'Back']; + deps.state.cardTableDiscardIndex = Math.min(deps.state.cardTableDiscardIndex, entries.length - 1); + deps.updateStatus(`Updated. ${entries[deps.state.cardTableDiscardIndex]}.`); + } + } + + return { + beginCardTableMenu, + handleCardTableMenuModeInput, + handleCardTableHandModeInput, + handleCardTableCardActionModeInput, + handleCardTableDiscardModeInput, + handleCardTableConfirmResetModeInput, + refreshCardTableStatus, + }; +} diff --git a/client/src/main.ts b/client/src/main.ts index 78a9642..028ab69 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -65,6 +65,7 @@ import { } from './items/itemRegistry'; import { createItemInteractionController } from './items/itemInteractionController'; import { createWhiteboardController } from './items/whiteboardController'; +import { createCardTableController } from './items/cardTableController'; import { createItemPropertyEditor } from './items/itemPropertyEditor'; import { createItemPropertyPresentation } from './items/itemPropertyPresentation'; import { ItemBehaviorRegistry } from './items/types/behaviorRegistry'; @@ -473,6 +474,13 @@ const whiteboardController = createWhiteboardController({ replaceTextOnNextType = value; }, }); +const cardTableController = createCardTableController({ + state, + signalingSend: (message) => signaling.send(message), + updateStatus, + sfxUiBlip: () => audio.sfxUiBlip(), + sfxUiCancel: () => audio.sfxUiCancel(), +}); /** Toggles updates panel visibility and syncs associated ARIA state. */ function setUpdatesExpanded(expanded: boolean): void { @@ -986,6 +994,10 @@ function useItem(item: WorldItem): void { whiteboardController.beginWhiteboardLines(item); return; } + if (item.type === 'card_table') { + cardTableController.beginCardTableMenu(item); + return; + } signaling.send({ type: 'item_use', itemId: item.id }); } @@ -1669,6 +1681,7 @@ const onAppMessage = createOnMessageHandler({ void connectLiveKit(url, token); }, refreshWhiteboardStatus: () => whiteboardController.refreshWhiteboardStatus(), + refreshCardTableStatus: () => cardTableController.refreshCardTableStatus(), }); /** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */ @@ -2719,6 +2732,16 @@ function handleModeInput(input: ModeInput): void { whiteboardController.handleWhiteboardLineActionsModeInput(currentCode, currentKey), whiteboardLineEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => whiteboardController.handleWhiteboardLineEditModeInput(currentCode, currentKey, currentCtrlKey), + cardTableMenu: ({ code: currentCode, key: currentKey }) => + cardTableController.handleCardTableMenuModeInput(currentCode, currentKey), + cardTableHand: ({ code: currentCode, key: currentKey }) => + cardTableController.handleCardTableHandModeInput(currentCode, currentKey), + cardTableCardAction: ({ code: currentCode, key: currentKey }) => + cardTableController.handleCardTableCardActionModeInput(currentCode, currentKey), + cardTableDiscard: ({ code: currentCode, key: currentKey }) => + cardTableController.handleCardTableDiscardModeInput(currentCode, currentKey), + cardTableConfirmReset: ({ code: currentCode, key: currentKey }) => + cardTableController.handleCardTableConfirmResetModeInput(currentCode, currentKey), }, onNormalMode: handleNormalModeInput, }); diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 271e7b2..b98410f 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -23,8 +23,10 @@ type MessageHandlerDeps = { itemPropertyIndex: number; carriedItemId: string | null; whiteboardItemId: string | null; + cardTableItemId: string | null; }; refreshWhiteboardStatus: () => void; + refreshCardTableStatus: () => void; dom: { connectButton: HTMLElement; disconnectButton: HTMLElement; @@ -266,6 +268,15 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco ) { deps.refreshWhiteboardStatus(); } + if ( + deps.state.cardTableItemId === message.item.id && + (deps.state.mode === 'cardTableMenu' || + deps.state.mode === 'cardTableHand' || + deps.state.mode === 'cardTableCardAction' || + deps.state.mode === 'cardTableDiscard') + ) { + deps.refreshCardTableStatus(); + } await deps.refreshAudioSubscriptions(true); break; } diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index d72e667..a991d4b 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -54,7 +54,12 @@ export type GameMode = | 'pianoUse' | 'whiteboardLines' | 'whiteboardLineActions' - | 'whiteboardLineEdit'; + | 'whiteboardLineEdit' + | 'cardTableMenu' + | 'cardTableHand' + | 'cardTableCardAction' + | 'cardTableDiscard' + | 'cardTableConfirmReset'; export type Player = { id: string | null; @@ -103,6 +108,12 @@ export type GameState = { whiteboardLineIndex: number; whiteboardLineActionIndex: number; whiteboardEditingLineIndex: number | null; + cardTableItemId: string | null; + cardTableMenuIndex: number; + cardTableHandIndex: number; + cardTableCardActionIndex: number; + cardTableDiscardIndex: number; + cardTableConfirmIndex: number; }; export function createInitialState(): GameState { @@ -143,6 +154,12 @@ export function createInitialState(): GameState { whiteboardLineIndex: 0, whiteboardLineActionIndex: 0, whiteboardEditingLineIndex: null, + cardTableItemId: null, + cardTableMenuIndex: 0, + cardTableHandIndex: 0, + cardTableCardActionIndex: 0, + cardTableDiscardIndex: 0, + cardTableConfirmIndex: 0, }; } diff --git a/server/app/items/types/card_table/__init__.py b/server/app/items/types/card_table/__init__.py new file mode 100644 index 0000000..a6aa919 --- /dev/null +++ b/server/app/items/types/card_table/__init__.py @@ -0,0 +1 @@ +"""Card table item type plugin package.""" diff --git a/server/app/items/types/card_table/actions.py b/server/app/items/types/card_table/actions.py new file mode 100644 index 0000000..fb47910 --- /dev/null +++ b/server/app/items/types/card_table/actions.py @@ -0,0 +1,64 @@ +"""Card table item use actions.""" + +from __future__ import annotations + +import random +from typing import Callable + +from ....item_types import ItemUseResult +from ....models import WorldItem + + +def _build_deck(include_jokers: bool) -> list[str]: + """Return a sorted list of 52 (or 54) card codes.""" + ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] + suits = ["S", "H", "D", "C"] + deck = [f"{r}{s}" for s in suits for r in ranks] + if include_jokers: + deck += ["JO1", "JO2"] + return deck + + +def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Return status message; client opens menu from existing state.""" + draw_pile = item.params.get("draw_pile", []) + discard_pile = item.params.get("discard_pile", []) + hands = item.params.get("hands", {}) + if not isinstance(draw_pile, list): + draw_pile = [] + if not isinstance(discard_pile, list): + discard_pile = [] + if not isinstance(hands, dict): + hands = {} + hand = hands.get(nickname, []) + if not isinstance(hand, list): + hand = [] + draw_count = len(draw_pile) + discard_count = len(discard_pile) + hand_count = len(hand) + + return ItemUseResult( + self_message=( + f"{item.title}: {draw_count} in draw pile, " + f"{discard_count} in discard, {hand_count} in your hand." + ), + others_message="", + ) + + +def secondary_use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Shuffle and reset the card table.""" + include_jokers = bool(item.params.get("include_jokers", False)) + deck = _build_deck(include_jokers) + random.shuffle(deck) + total = len(deck) + + return ItemUseResult( + self_message=f"You reset {item.title}. {total} cards shuffled into draw pile.", + others_message=f"{nickname} resets {item.title}.", + updated_params={ + "draw_pile": deck, + "discard_pile": [], + "hands": {}, + }, + ) diff --git a/server/app/items/types/card_table/definition.py b/server/app/items/types/card_table/definition.py new file mode 100644 index 0000000..d47ce94 --- /dev/null +++ b/server/app/items/types/card_table/definition.py @@ -0,0 +1,39 @@ +"""Card table item static metadata and defaults.""" + +from __future__ import annotations + +TYPE = "card_table" +LABEL = "Card Table" +TOOLTIP = "A shared card table with draw pile, discard pile, and per-player hands." +EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "include_jokers") +CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable") +USE_SOUND = None +EMIT_SOUND: str | None = None +EMIT_RANGE = 15 +DIRECTIONAL = False +USE_COOLDOWN_MS = 500 +DEFAULT_TITLE = "Card Table" +PARAM_KEYS: tuple[str, ...] = ("draw_pile", "discard_pile", "hands", "include_jokers") + +_RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] +_SUITS = ["S", "H", "D", "C"] +_FULL_DECK: list[str] = [f"{r}{s}" for s in _SUITS for r in _RANKS] + +DEFAULT_PARAMS: dict = { + "draw_pile": list(_FULL_DECK), + "discard_pile": [], + "hands": {}, + "include_jokers": False, +} + +PROPERTY_METADATA: dict[str, dict[str, object]] = { + "title": { + "valueType": "text", + "tooltip": "Display name spoken and shown for this item.", + "maxLength": 80, + }, + "include_jokers": { + "valueType": "boolean", + "tooltip": "Include two Jokers when shuffled and reset.", + }, +} diff --git a/server/app/items/types/card_table/plugin.py b/server/app/items/types/card_table/plugin.py new file mode 100644 index 0000000..26a470e --- /dev/null +++ b/server/app/items/types/card_table/plugin.py @@ -0,0 +1,17 @@ +"""Plugin registration for card table item type.""" + +from __future__ import annotations + +from ..plugin_helpers import build_item_module +from . import actions, definition, validator + +ITEM_TYPE_PLUGIN = { + "type": "card_table", + "order": 26, + "module": build_item_module( + definition, + validate_update=validator.validate_update, + use_item=actions.use_item, + secondary_use_item=actions.secondary_use_item, + ), +} diff --git a/server/app/items/types/card_table/validator.py b/server/app/items/types/card_table/validator.py new file mode 100644 index 0000000..dea4581 --- /dev/null +++ b/server/app/items/types/card_table/validator.py @@ -0,0 +1,64 @@ +"""Card table item validation/normalization.""" + +from __future__ import annotations + +from ....models import WorldItem +from ...helpers import keep_only_known_params, parse_bool_like +from .definition import PARAM_KEYS + +_VALID_RANKS = frozenset(["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]) +_VALID_SUITS = frozenset(["S", "H", "D", "C"]) +_VALID_JOKERS = frozenset(["JO1", "JO2"]) + + +def _is_valid_card(code: object) -> bool: + if not isinstance(code, str): + return False + if code in _VALID_JOKERS: + return True + if len(code) < 2: + return False + suit = code[-1] + rank = code[:-1] + return rank in _VALID_RANKS and suit in _VALID_SUITS + + +def validate_update(_item: WorldItem, next_params: dict) -> dict: + """Validate and normalize card table params.""" + + draw_pile = next_params.get("draw_pile", []) + if not isinstance(draw_pile, list): + raise ValueError("draw_pile must be a list.") + for card in draw_pile: + if not _is_valid_card(card): + raise ValueError(f"Invalid card code in draw_pile: {card!r}") + next_params["draw_pile"] = draw_pile + + discard_pile = next_params.get("discard_pile", []) + if not isinstance(discard_pile, list): + raise ValueError("discard_pile must be a list.") + for card in discard_pile: + if not _is_valid_card(card): + raise ValueError(f"Invalid card code in discard_pile: {card!r}") + next_params["discard_pile"] = discard_pile + + hands = next_params.get("hands", {}) + if not isinstance(hands, dict): + raise ValueError("hands must be a dict.") + if len(hands) > 20: + raise ValueError("Too many hands (max 20).") + for player, hand in hands.items(): + if not isinstance(player, str): + raise ValueError("Hand keys must be strings.") + if not isinstance(hand, list): + raise ValueError(f"Hand for {player!r} must be a list.") + if len(hand) > 60: + raise ValueError(f"Too many cards in hand for {player!r} (max 60).") + for card in hand: + if not _is_valid_card(card): + raise ValueError(f"Invalid card code in hand for {player!r}: {card!r}") + next_params["hands"] = hands + + next_params["include_jokers"] = parse_bool_like(next_params.get("include_jokers", False), default=False) + + return keep_only_known_params(next_params, PARAM_KEYS)