Add card table item
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,3 +25,7 @@ plans/
|
|||||||
|
|
||||||
# Host-local notes
|
# Host-local notes
|
||||||
local/
|
local/
|
||||||
|
|
||||||
|
# Ignore actual sounds in sounds/widgets/
|
||||||
|
sounds/widgets/*.ogg
|
||||||
|
sounds/widgets/**/*.ogg
|
||||||
521
client/src/items/cardTableController.ts
Normal file
521
client/src/items/cardTableController.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, WorldItem>;
|
||||||
|
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<string, unknown>)[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<string, string[]> =
|
||||||
|
hands && typeof hands === 'object' && !Array.isArray(hands)
|
||||||
|
? (hands as Record<string, string[]>)
|
||||||
|
: {};
|
||||||
|
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<string, string[]> =
|
||||||
|
hands && typeof hands === 'object' && !Array.isArray(hands)
|
||||||
|
? (hands as Record<string, string[]>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
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<string, string[]> =
|
||||||
|
hands && typeof hands === 'object' && !Array.isArray(hands)
|
||||||
|
? (hands as Record<string, string[]>)
|
||||||
|
: {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
} from './items/itemRegistry';
|
} from './items/itemRegistry';
|
||||||
import { createItemInteractionController } from './items/itemInteractionController';
|
import { createItemInteractionController } from './items/itemInteractionController';
|
||||||
import { createWhiteboardController } from './items/whiteboardController';
|
import { createWhiteboardController } from './items/whiteboardController';
|
||||||
|
import { createCardTableController } from './items/cardTableController';
|
||||||
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
||||||
import { createItemPropertyPresentation } from './items/itemPropertyPresentation';
|
import { createItemPropertyPresentation } from './items/itemPropertyPresentation';
|
||||||
import { ItemBehaviorRegistry } from './items/types/behaviorRegistry';
|
import { ItemBehaviorRegistry } from './items/types/behaviorRegistry';
|
||||||
@@ -473,6 +474,13 @@ const whiteboardController = createWhiteboardController({
|
|||||||
replaceTextOnNextType = value;
|
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. */
|
/** Toggles updates panel visibility and syncs associated ARIA state. */
|
||||||
function setUpdatesExpanded(expanded: boolean): void {
|
function setUpdatesExpanded(expanded: boolean): void {
|
||||||
@@ -986,6 +994,10 @@ function useItem(item: WorldItem): void {
|
|||||||
whiteboardController.beginWhiteboardLines(item);
|
whiteboardController.beginWhiteboardLines(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.type === 'card_table') {
|
||||||
|
cardTableController.beginCardTableMenu(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
signaling.send({ type: 'item_use', itemId: item.id });
|
signaling.send({ type: 'item_use', itemId: item.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1669,6 +1681,7 @@ const onAppMessage = createOnMessageHandler({
|
|||||||
void connectLiveKit(url, token);
|
void connectLiveKit(url, token);
|
||||||
},
|
},
|
||||||
refreshWhiteboardStatus: () => whiteboardController.refreshWhiteboardStatus(),
|
refreshWhiteboardStatus: () => whiteboardController.refreshWhiteboardStatus(),
|
||||||
|
refreshCardTableStatus: () => cardTableController.refreshCardTableStatus(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
||||||
@@ -2719,6 +2732,16 @@ function handleModeInput(input: ModeInput): void {
|
|||||||
whiteboardController.handleWhiteboardLineActionsModeInput(currentCode, currentKey),
|
whiteboardController.handleWhiteboardLineActionsModeInput(currentCode, currentKey),
|
||||||
whiteboardLineEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
|
whiteboardLineEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
|
||||||
whiteboardController.handleWhiteboardLineEditModeInput(currentCode, currentKey, 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,
|
onNormalMode: handleNormalModeInput,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ type MessageHandlerDeps = {
|
|||||||
itemPropertyIndex: number;
|
itemPropertyIndex: number;
|
||||||
carriedItemId: string | null;
|
carriedItemId: string | null;
|
||||||
whiteboardItemId: string | null;
|
whiteboardItemId: string | null;
|
||||||
|
cardTableItemId: string | null;
|
||||||
};
|
};
|
||||||
refreshWhiteboardStatus: () => void;
|
refreshWhiteboardStatus: () => void;
|
||||||
|
refreshCardTableStatus: () => void;
|
||||||
dom: {
|
dom: {
|
||||||
connectButton: HTMLElement;
|
connectButton: HTMLElement;
|
||||||
disconnectButton: HTMLElement;
|
disconnectButton: HTMLElement;
|
||||||
@@ -266,6 +268,15 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
|||||||
) {
|
) {
|
||||||
deps.refreshWhiteboardStatus();
|
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);
|
await deps.refreshAudioSubscriptions(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ export type GameMode =
|
|||||||
| 'pianoUse'
|
| 'pianoUse'
|
||||||
| 'whiteboardLines'
|
| 'whiteboardLines'
|
||||||
| 'whiteboardLineActions'
|
| 'whiteboardLineActions'
|
||||||
| 'whiteboardLineEdit';
|
| 'whiteboardLineEdit'
|
||||||
|
| 'cardTableMenu'
|
||||||
|
| 'cardTableHand'
|
||||||
|
| 'cardTableCardAction'
|
||||||
|
| 'cardTableDiscard'
|
||||||
|
| 'cardTableConfirmReset';
|
||||||
|
|
||||||
export type Player = {
|
export type Player = {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -103,6 +108,12 @@ export type GameState = {
|
|||||||
whiteboardLineIndex: number;
|
whiteboardLineIndex: number;
|
||||||
whiteboardLineActionIndex: number;
|
whiteboardLineActionIndex: number;
|
||||||
whiteboardEditingLineIndex: number | null;
|
whiteboardEditingLineIndex: number | null;
|
||||||
|
cardTableItemId: string | null;
|
||||||
|
cardTableMenuIndex: number;
|
||||||
|
cardTableHandIndex: number;
|
||||||
|
cardTableCardActionIndex: number;
|
||||||
|
cardTableDiscardIndex: number;
|
||||||
|
cardTableConfirmIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createInitialState(): GameState {
|
export function createInitialState(): GameState {
|
||||||
@@ -143,6 +154,12 @@ export function createInitialState(): GameState {
|
|||||||
whiteboardLineIndex: 0,
|
whiteboardLineIndex: 0,
|
||||||
whiteboardLineActionIndex: 0,
|
whiteboardLineActionIndex: 0,
|
||||||
whiteboardEditingLineIndex: null,
|
whiteboardEditingLineIndex: null,
|
||||||
|
cardTableItemId: null,
|
||||||
|
cardTableMenuIndex: 0,
|
||||||
|
cardTableHandIndex: 0,
|
||||||
|
cardTableCardActionIndex: 0,
|
||||||
|
cardTableDiscardIndex: 0,
|
||||||
|
cardTableConfirmIndex: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
server/app/items/types/card_table/__init__.py
Normal file
1
server/app/items/types/card_table/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Card table item type plugin package."""
|
||||||
64
server/app/items/types/card_table/actions.py
Normal file
64
server/app/items/types/card_table/actions.py
Normal file
@@ -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": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
39
server/app/items/types/card_table/definition.py
Normal file
39
server/app/items/types/card_table/definition.py
Normal file
@@ -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.",
|
||||||
|
},
|
||||||
|
}
|
||||||
17
server/app/items/types/card_table/plugin.py
Normal file
17
server/app/items/types/card_table/plugin.py
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
}
|
||||||
64
server/app/items/types/card_table/validator.py
Normal file
64
server/app/items/types/card_table/validator.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user