diff --git a/client/public/sounds/card_draw.ogg b/client/public/sounds/card_draw.ogg new file mode 100644 index 0000000..cbeabd5 Binary files /dev/null and b/client/public/sounds/card_draw.ogg differ diff --git a/client/public/sounds/card_shuffle.ogg b/client/public/sounds/card_shuffle.ogg new file mode 100644 index 0000000..7f83277 Binary files /dev/null and b/client/public/sounds/card_shuffle.ogg differ diff --git a/server/app/items/types/card_deck/__init__.py b/server/app/items/types/card_deck/__init__.py new file mode 100644 index 0000000..4e9c7c4 --- /dev/null +++ b/server/app/items/types/card_deck/__init__.py @@ -0,0 +1 @@ +"""Card deck item type plugin package.""" diff --git a/server/app/items/types/card_deck/actions.py b/server/app/items/types/card_deck/actions.py new file mode 100644 index 0000000..55814b0 --- /dev/null +++ b/server/app/items/types/card_deck/actions.py @@ -0,0 +1,96 @@ +"""Card deck item use actions.""" + +from __future__ import annotations + +import random +from typing import Callable + +from ....item_types import ItemUseResult +from ....models import WorldItem + +RANK_NAMES: dict[str, str] = { + "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", +} +SUIT_NAMES: dict[str, str] = { + "S": "Spades", + "H": "Hearts", + "D": "Diamonds", + "C": "Clubs", +} + + +def _card_name(code: str) -> str: + """Return the display name for a card code, e.g. '10H' → 'Ten of Hearts'.""" + if code in ("JO1", "JO2"): + return "Joker" + suit = code[-1] + rank = code[:-1] + return f"{RANK_NAMES[rank]} of {SUIT_NAMES[suit]}" + + +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: + """Draw one or more cards from the deck.""" + try: + draw_count = max(1, min(10, int(item.params.get("draw_count", 1)))) + except (TypeError, ValueError): + draw_count = 1 + + deck = item.params.get("deck", []) + if not isinstance(deck, list): + deck = [] + + if not deck: + return ItemUseResult( + self_message=f"{item.title} is empty. Shift+Use to shuffle.", + others_message="", + ) + + count = min(draw_count, len(deck)) + drawn = deck[:count] + remaining = deck[count:] + + card_names = ", ".join(_card_name(c) for c in drawn) + cards_left = len(remaining) + left_text = f"{cards_left} card{'s' if cards_left != 1 else ''} left" + + return ItemUseResult( + self_message=f"You draw from {item.title}: {card_names}. ({left_text})", + others_message=f"{nickname} draws {count} card{'s' if count != 1 else ''} from {item.title}. ({left_text})", + updated_params={"deck": remaining, "useSound": "sounds/card_draw.ogg"}, + ) + + +def secondary_use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Shuffle the deck.""" + 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 shuffle {item.title}. {total} cards ready.", + others_message=f"{nickname} shuffles {item.title}.", + updated_params={"deck": deck, "useSound": "sounds/card_shuffle.ogg"}, + ) diff --git a/server/app/items/types/card_deck/definition.py b/server/app/items/types/card_deck/definition.py new file mode 100644 index 0000000..8ce05b5 --- /dev/null +++ b/server/app/items/types/card_deck/definition.py @@ -0,0 +1,43 @@ +"""Card deck item static metadata and defaults.""" + +from __future__ import annotations + +LABEL = "card deck" +TOOLTIP = "A standard 52-card deck. Use to draw cards, Shift+Use to shuffle." +EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "draw_count", "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 Deck" +PARAM_KEYS: tuple[str, ...] = ("deck", "draw_count", "include_jokers", "useSound") + +_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 = { + "deck": list(_FULL_DECK), + "draw_count": 1, + "include_jokers": False, + "useSound": "sounds/card_draw.ogg", +} + +PROPERTY_METADATA: dict[str, dict[str, object]] = { + "title": { + "valueType": "text", + "tooltip": "Display name spoken and shown for this item.", + "maxLength": 80, + }, + "draw_count": { + "valueType": "number", + "tooltip": "How many cards to draw per use.", + "range": {"min": 1, "max": 10, "step": 1}, + }, + "include_jokers": { + "valueType": "boolean", + "tooltip": "Include two Jokers when shuffled.", + }, +} diff --git a/server/app/items/types/card_deck/plugin.py b/server/app/items/types/card_deck/plugin.py new file mode 100644 index 0000000..b8f8a4e --- /dev/null +++ b/server/app/items/types/card_deck/plugin.py @@ -0,0 +1,17 @@ +"""Plugin registration for card deck item type.""" + +from __future__ import annotations + +from ..plugin_helpers import build_item_module +from . import actions, definition, validator + +ITEM_TYPE_PLUGIN = { + "type": "card_deck", + "order": 25, + "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_deck/validator.py b/server/app/items/types/card_deck/validator.py new file mode 100644 index 0000000..6833123 --- /dev/null +++ b/server/app/items/types/card_deck/validator.py @@ -0,0 +1,53 @@ +"""Card deck 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"]) +_ALLOWED_SOUNDS = frozenset(["sounds/card_draw.ogg", "sounds/card_shuffle.ogg", ""]) + + +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 deck params.""" + + try: + draw_count = int(next_params.get("draw_count", 1)) + except (TypeError, ValueError) as exc: + raise ValueError("draw_count must be a number.") from exc + if not (1 <= draw_count <= 10): + raise ValueError("draw_count must be between 1 and 10.") + next_params["draw_count"] = draw_count + + deck = next_params.get("deck", []) + if not isinstance(deck, list): + raise ValueError("deck must be a list.") + for card in deck: + if not _is_valid_card(card): + raise ValueError(f"Invalid card code: {card!r}") + next_params["deck"] = deck + + next_params["include_jokers"] = parse_bool_like(next_params.get("include_jokers", False), default=False) + + use_sound = str(next_params.get("useSound", "")).strip() + if use_sound not in _ALLOWED_SOUNDS: + use_sound = "sounds/card_draw.ogg" + next_params["useSound"] = use_sound + + return keep_only_known_params(next_params, PARAM_KEYS) diff --git a/server/app/server.py b/server/app/server.py index 42db18f..d4f7d6a 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -2955,6 +2955,20 @@ class SignalingServer: BroadcastChatMessagePacket(type="chat_message", message=secondary_result.others_message, system=True), exclude=client.websocket, ) + use_sound = self._resolve_item_use_sound(item) + if use_sound: + sound_x, sound_y = self._get_item_sound_source_position(item) + sound_range = self._get_item_emit_range(item) + await self._broadcast( + ItemUseSoundPacket( + type="item_use_sound", + itemId=item.id, + sound=use_sound, + x=sound_x, + y=sound_y, + range=sound_range, + ) + ) await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id) return