Add standard deck of cards
This commit is contained in:
BIN
client/public/sounds/card_draw.ogg
Normal file
BIN
client/public/sounds/card_draw.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/card_shuffle.ogg
Normal file
BIN
client/public/sounds/card_shuffle.ogg
Normal file
Binary file not shown.
1
server/app/items/types/card_deck/__init__.py
Normal file
1
server/app/items/types/card_deck/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Card deck item type plugin package."""
|
||||||
96
server/app/items/types/card_deck/actions.py
Normal file
96
server/app/items/types/card_deck/actions.py
Normal file
@@ -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"},
|
||||||
|
)
|
||||||
43
server/app/items/types/card_deck/definition.py
Normal file
43
server/app/items/types/card_deck/definition.py
Normal file
@@ -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.",
|
||||||
|
},
|
||||||
|
}
|
||||||
17
server/app/items/types/card_deck/plugin.py
Normal file
17
server/app/items/types/card_deck/plugin.py
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
}
|
||||||
53
server/app/items/types/card_deck/validator.py
Normal file
53
server/app/items/types/card_deck/validator.py
Normal file
@@ -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)
|
||||||
@@ -2955,6 +2955,20 @@ class SignalingServer:
|
|||||||
BroadcastChatMessagePacket(type="chat_message", message=secondary_result.others_message, system=True),
|
BroadcastChatMessagePacket(type="chat_message", message=secondary_result.others_message, system=True),
|
||||||
exclude=client.websocket,
|
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)
|
await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user