4 Commits

Author SHA1 Message Date
49a9ca473d Add media proxy to docker container 2026-04-17 11:09:20 +02:00
f3efec5c97 Fix https stream proxies 2026-04-17 11:09:20 +02:00
ceb693778c Fix auth flow for livekit 2026-04-17 11:09:20 +02:00
ae301db3bb Add docker setup and switch voice chat backend to use livekit 2026-04-17 11:09:04 +02:00
39 changed files with 7 additions and 2004 deletions

4
.gitignore vendored
View File

@@ -25,7 +25,3 @@ plans/
# Host-local notes # Host-local notes
local/ local/
# Ignore actual sounds in sounds/widgets/
sounds/widgets/*.ogg
sounds/widgets/**/*.ogg

View File

@@ -10,7 +10,6 @@ RUN apk add --no-cache php83 php83-fpm php83-curl php83-ctype
RUN echo 'clear_env = no' >> /etc/php83/php-fpm.d/www.conf RUN echo 'clear_env = no' >> /etc/php83/php-fpm.d/www.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY deploy/php/media_proxy.php /usr/share/nginx/html/media_proxy.php COPY deploy/php/media_proxy.php /usr/share/nginx/html/media_proxy.php
COPY deploy/php/sounds_list.php /usr/share/nginx/html/sounds_list.php
COPY client/nginx.conf /etc/nginx/conf.d/default.conf COPY client/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["sh", "-c", "find /usr/share/nginx/html/sounds/widgets -type d -exec chmod 755 {} \\; 2>/dev/null; find /usr/share/nginx/html/sounds/widgets -type f -exec chmod 644 {} \\; 2>/dev/null; php-fpm83 && exec nginx -g 'daemon off;'"] CMD ["sh", "-c", "php-fpm83 && exec nginx -g 'daemon off;'"]

View File

@@ -95,27 +95,6 @@
<button id="closeSettingsButton">Close</button> <button id="closeSettingsButton">Close</button>
</div> </div>
</div> </div>
<div id="mobileControls" class="mobile-controls" data-expanded="false" aria-label="Mobile game controls">
<button id="mobileControlsToggle" type="button" class="mobile-toggle-btn"
aria-expanded="false" aria-controls="mobileControlsBody">&#9776; Controls</button>
<div id="mobileControlsBody" class="mobile-controls-body">
<div class="dpad" role="group" aria-label="Movement">
<button id="dpadUp" type="button" class="dpad-btn dpad-up" aria-label="Move up">&#9650;</button>
<button id="dpadLeft" type="button" class="dpad-btn dpad-left" aria-label="Move left">&#9664;</button>
<div class="dpad-center" aria-hidden="true"></div>
<button id="dpadRight" type="button" class="dpad-btn dpad-right" aria-label="Move right">&#9654;</button>
<button id="dpadDown" type="button" class="dpad-btn dpad-down" aria-label="Move down">&#9660;</button>
</div>
<div class="mobile-actions" role="group" aria-label="Actions">
<button id="mobileBtnChat" type="button" class="mobile-action-btn">Chat</button>
<button id="mobileBtnUse" type="button" class="mobile-action-btn">Use</button>
<button id="mobileBtnLocateUser" type="button" class="mobile-action-btn">Find User</button>
<button id="mobileBtnLocateItem" type="button" class="mobile-action-btn">Find Item</button>
<button id="mobileBtnCommands" type="button" class="mobile-action-btn mobile-action-btn--wide">Commands</button>
</div>
</div>
</div>
</main> </main>
<script src="%BASE_URL%version.js"></script> <script src="%BASE_URL%version.js"></script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>

Binary file not shown.

View File

@@ -48,9 +48,6 @@ export class AudioEngine {
private readonly sampleLoaders = new Map<string, Promise<AudioBuffer>>(); private readonly sampleLoaders = new Map<string, Promise<AudioBuffer>>();
private readonly activeSpatialSamples = new Set<ActiveSpatialSampleRuntime>(); private readonly activeSpatialSamples = new Set<ActiveSpatialSampleRuntime>();
private previewSourceNode: AudioBufferSourceNode | null = null;
private previewGainNode: GainNode | null = null;
private outboundSource: MediaStreamAudioSourceNode | null = null; private outboundSource: MediaStreamAudioSourceNode | null = null;
private outboundInputGain: GainNode | null = null; private outboundInputGain: GainNode | null = null;
private outboundInputGainValue = 1; private outboundInputGainValue = 1;
@@ -494,47 +491,6 @@ export class AudioEngine {
} }
} }
stopPreviewSample(): void {
if (this.previewSourceNode) {
try { this.previewSourceNode.stop(); } catch { /* already ended */ }
try { this.previewSourceNode.disconnect(); } catch { /* ignore */ }
this.previewSourceNode = null;
}
if (this.previewGainNode) {
try { this.previewGainNode.disconnect(); } catch { /* ignore */ }
this.previewGainNode = null;
}
}
async playPreviewSample(url: string, gain = 1): Promise<void> {
this.stopPreviewSample();
await this.ensureContext();
const { audioCtx, sfxGainNode } = this;
if (!audioCtx || !sfxGainNode) return;
if (gain <= 0) return;
try {
const buffer = await this.getSampleBuffer(url);
this.stopPreviewSample();
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const gainNode = audioCtx.createGain();
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
gainNode.gain.setTargetAtTime(gain, audioCtx.currentTime, ONE_SHOT_ATTACK_SECONDS);
source.connect(gainNode).connect(sfxGainNode);
this.previewSourceNode = source;
this.previewGainNode = gainNode;
source.onended = () => {
if (this.previewSourceNode === source) {
this.previewSourceNode = null;
this.previewGainNode = null;
}
};
source.start();
} catch {
// Ignore decode/load errors.
}
}
/** Starts a looping sample and returns a stop callback for explicit teardown. */ /** Starts a looping sample and returns a stop callback for explicit teardown. */
async startLoopingSample(url: string, gain = 1): Promise<(() => void) | null> { async startLoopingSample(url: string, gain = 1): Promise<(() => void) | null> {
await this.ensureContext(); await this.ensureContext();

View File

@@ -1,91 +0,0 @@
import type { ModeInput } from './commandTypes';
type MobileControllerDeps = {
dom: {
canvas: HTMLCanvasElement;
mobileControls: HTMLDivElement;
toggleButton: HTMLButtonElement;
dpadUp: HTMLButtonElement;
dpadDown: HTMLButtonElement;
dpadLeft: HTMLButtonElement;
dpadRight: HTMLButtonElement;
btnChat: HTMLButtonElement;
btnUse: HTMLButtonElement;
btnLocateUser: HTMLButtonElement;
btnLocateItem: HTMLButtonElement;
btnCommandPalette: HTMLButtonElement;
};
state: {
running: boolean;
keysPressed: Record<string, boolean>;
};
handleModeInput: (input: ModeInput) => void;
openCommandPalette: () => void;
};
/**
* Wires touch handlers for the on-screen mobile controls panel.
* Movement uses hold-to-walk by writing directly into keysPressed (same as the
* keyboard controller). Action buttons dispatch through handleModeInput so they
* travel the same code path as their keyboard equivalents.
*/
export function setupMobileControls(deps: MobileControllerDeps): void {
const { dom, state } = deps;
// ── Toggle ───────────────────────────────────────────────────────────────
dom.toggleButton.addEventListener('click', () => {
const expanded = dom.mobileControls.dataset['expanded'] === 'true';
const next = String(!expanded);
dom.mobileControls.dataset['expanded'] = next;
dom.toggleButton.setAttribute('aria-expanded', next);
});
// ── D-pad movement ───────────────────────────────────────────────────────
const dpadMap: Array<[HTMLButtonElement, string]> = [
[dom.dpadUp, 'ArrowUp'],
[dom.dpadDown, 'ArrowDown'],
[dom.dpadLeft, 'ArrowLeft'],
[dom.dpadRight, 'ArrowRight'],
];
for (const [btn, arrowCode] of dpadMap) {
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
if (!state.running) return;
state.keysPressed[arrowCode] = true;
}, { passive: false });
btn.addEventListener('touchend', () => {
state.keysPressed[arrowCode] = false;
});
btn.addEventListener('touchcancel', () => {
state.keysPressed[arrowCode] = false;
});
}
// ── Action buttons ───────────────────────────────────────────────────────
type ActionDef = [HTMLButtonElement, string, string];
const actionMap: ActionDef[] = [
[dom.btnChat, 'Slash', '/'],
[dom.btnUse, 'Enter', 'Enter'],
[dom.btnLocateUser, 'KeyL', 'l'],
[dom.btnLocateItem, 'KeyI', 'i'],
];
for (const [btn, code, key] of actionMap) {
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
if (!state.running) return;
dom.canvas.focus();
deps.handleModeInput({ code, key, ctrlKey: false, shiftKey: false });
}, { passive: false });
}
dom.btnCommandPalette.addEventListener('touchstart', (e) => {
e.preventDefault();
if (!state.running) return;
dom.canvas.focus();
deps.openCommandPalette();
}, { passive: false });
}

View File

@@ -1,466 +0,0 @@
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;
}
deps.signalingSend({ type: 'item_interact', itemId: item.id, action: 'draw' });
deps.updateStatus('Drawing a card.');
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;
if (actionIdx === 0) {
// Discard
deps.signalingSend({
type: 'item_interact',
itemId: item.id,
action: 'discard',
params: { card_index: cardIndex },
});
deps.state.mode = 'cardTableHand';
deps.updateStatus('Discarding card.');
deps.sfxUiBlip();
return;
}
if (actionIdx === 1) {
// Return to draw pile
deps.signalingSend({
type: 'item_interact',
itemId: item.id,
action: 'return_to_pile',
params: { card_index: cardIndex },
});
deps.state.mode = 'cardTableHand';
deps.updateStatus('Returning card 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;
deps.signalingSend({
type: 'item_interact',
itemId: item.id,
action: 'draw_from_discard',
params: { card_index: cardIdx },
});
deps.state.mode = 'cardTableMenu';
deps.state.cardTableMenuIndex = 0;
deps.updateStatus('Taking 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,
};
}

View File

@@ -47,9 +47,6 @@ type EditorDeps = {
updateStatus: (message: string) => void; updateStatus: (message: string) => void;
sfxUiBlip: () => void; sfxUiBlip: () => void;
sfxUiCancel: () => void; sfxUiCancel: () => void;
openSoundPropertyPicker?: (item: WorldItem, key: string) => void;
previewSound?: (soundPath: string) => void;
stopPreviewSound?: () => void;
}; };
/** /**
@@ -190,13 +187,9 @@ export function createItemPropertyEditor(deps: EditorDeps): {
deps.openItemPropertyOptionSelect(item, selectedKey); deps.openItemPropertyOptionSelect(item, selectedKey);
return; return;
} }
if (metadata?.valueType === 'sound' && deps.openSoundPropertyPicker) {
deps.openSoundPropertyPicker(item, selectedKey);
return;
}
deps.state.mode = 'itemPropertyEdit'; deps.state.mode = 'itemPropertyEdit';
deps.state.editingPropertyKey = selectedKey; deps.state.editingPropertyKey = selectedKey;
const selectedMetadata = metadata; const selectedMetadata = deps.getItemPropertyMetadata(item.type, selectedKey);
deps.state.nicknameInput = deps.state.nicknameInput =
selectedKey === 'title' selectedKey === 'title'
? item.title ? item.title
@@ -376,13 +369,6 @@ export function createItemPropertyEditor(deps: EditorDeps): {
const nextIndex = (deps.state.itemPropertyOptionIndex + delta + length * 1000) % length; const nextIndex = (deps.state.itemPropertyOptionIndex + delta + length * 1000) % length;
deps.state.itemPropertyOptionIndex = nextIndex; deps.state.itemPropertyOptionIndex = nextIndex;
deps.updateStatus(deps.state.itemPropertyOptionValues[nextIndex]); deps.updateStatus(deps.state.itemPropertyOptionValues[nextIndex]);
const pageItem = deps.state.items.get(itemId!);
if (pageItem) {
const pageMeta = deps.getItemPropertyMetadata(pageItem.type, propertyKey);
if (pageMeta?.valueType === 'sound') {
deps.previewSound?.(deps.state.itemPropertyOptionValues[nextIndex]);
}
}
deps.sfxUiBlip(); deps.sfxUiBlip();
return; return;
} }
@@ -397,19 +383,11 @@ export function createItemPropertyEditor(deps: EditorDeps): {
if (control.type === 'move') { if (control.type === 'move') {
deps.state.itemPropertyOptionIndex = control.index; deps.state.itemPropertyOptionIndex = control.index;
deps.updateStatus(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]); deps.updateStatus(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]);
const moveItem = deps.state.items.get(itemId!);
if (moveItem) {
const moveMeta = deps.getItemPropertyMetadata(moveItem.type, propertyKey);
if (moveMeta?.valueType === 'sound') {
deps.previewSound?.(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]);
}
}
deps.sfxUiBlip(); deps.sfxUiBlip();
return; return;
} }
if (control.type === 'select') { if (control.type === 'select') {
deps.stopPreviewSound?.();
const selectedValue = deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]; const selectedValue = deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex];
deps.signalingSend({ type: 'item_update', itemId, params: { [propertyKey]: selectedValue } }); deps.signalingSend({ type: 'item_update', itemId, params: { [propertyKey]: selectedValue } });
const item = deps.state.items.get(itemId); const item = deps.state.items.get(itemId);
@@ -424,7 +402,6 @@ export function createItemPropertyEditor(deps: EditorDeps): {
} }
if (control.type === 'cancel') { if (control.type === 'cancel') {
deps.stopPreviewSound?.();
deps.state.mode = 'itemProperties'; deps.state.mode = 'itemProperties';
deps.state.editingPropertyKey = null; deps.state.editingPropertyKey = null;
deps.state.itemPropertyOptionValues = []; deps.state.itemPropertyOptionValues = [];

View File

@@ -1,265 +0,0 @@
import { handleListControlKey } from '../input/listController';
import type { OutgoingMessage } from '../network/protocol';
import type { GameMode, WorldItem } from '../state/gameState';
const LINE_ACTIONS = ['Edit', 'Delete'] as const;
type WhiteboardControllerDeps = {
state: {
mode: GameMode;
nicknameInput: string;
cursorPos: number;
items: Map<string, WorldItem>;
whiteboardItemId: string | null;
whiteboardLineIndex: number;
whiteboardLineActionIndex: number;
whiteboardEditingLineIndex: number | null;
};
signalingSend: (message: OutgoingMessage) => void;
updateStatus: (message: string) => void;
sfxUiBlip: () => void;
sfxUiCancel: () => void;
applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean) => void;
setReplaceTextOnNextType: (value: boolean) => void;
};
function getLines(item: WorldItem): string[] {
const raw = item.params['lines'];
if (!Array.isArray(raw)) return [];
return raw.filter((l): l is string => typeof l === 'string');
}
export function createWhiteboardController(deps: WhiteboardControllerDeps): {
beginWhiteboardLines: (item: WorldItem) => void;
handleWhiteboardLinesModeInput: (code: string, key: string) => void;
handleWhiteboardLineActionsModeInput: (code: string, key: string) => void;
handleWhiteboardLineEditModeInput: (code: string, key: string, ctrlKey: boolean) => void;
refreshWhiteboardStatus: () => void;
} {
function beginWhiteboardLines(item: WorldItem): void {
deps.state.whiteboardItemId = item.id;
deps.state.whiteboardLineIndex = 0;
deps.state.whiteboardLineActionIndex = 0;
deps.state.whiteboardEditingLineIndex = null;
deps.state.mode = 'whiteboardLines';
const lines = getLines(item);
const n = lines.length;
const countText = `${n} line${n !== 1 ? 's' : ''}`;
const firstEntry = n > 0 ? lines[0] : 'Add line';
deps.updateStatus(`${item.title}. ${countText}. ${firstEntry}.`);
deps.sfxUiBlip();
}
function handleWhiteboardLinesModeInput(code: string, key: string): void {
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
if (!item) {
deps.state.mode = 'normal';
deps.state.whiteboardItemId = null;
return;
}
const lines = getLines(item);
const entries = [...lines, 'Add line'];
const control = handleListControlKey(code, key, entries, deps.state.whiteboardLineIndex, (e) => e);
if (control.type === 'move') {
deps.state.whiteboardLineIndex = control.index;
deps.updateStatus(entries[control.index]);
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
if (deps.state.whiteboardLineIndex === lines.length) {
// "Add line" selected
deps.state.whiteboardEditingLineIndex = null;
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.state.mode = 'whiteboardLineEdit';
deps.updateStatus('Add line. Type and press Enter.');
deps.sfxUiBlip();
} else {
// Select a line → actions submenu
deps.state.whiteboardLineActionIndex = 0;
deps.state.mode = 'whiteboardLineActions';
deps.updateStatus(`${lines[deps.state.whiteboardLineIndex]}. Edit or Delete.`);
deps.sfxUiBlip();
}
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'normal';
deps.state.whiteboardItemId = null;
deps.updateStatus('Cancelled.');
deps.sfxUiCancel();
}
}
function handleWhiteboardLineActionsModeInput(code: string, key: string): void {
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
if (!item) {
deps.state.mode = 'normal';
deps.state.whiteboardItemId = null;
return;
}
const lines = getLines(item);
const lineIndex = deps.state.whiteboardLineIndex;
if (lineIndex >= lines.length) {
deps.state.whiteboardLineIndex = Math.max(0, lines.length);
deps.state.mode = 'whiteboardLines';
const n = lines.length;
const countText = `${n} line${n !== 1 ? 's' : ''}`;
const firstEntry = n > 0 ? lines[0] : 'Add line';
deps.updateStatus(`${item.title}. ${countText}. ${firstEntry}.`);
deps.sfxUiCancel();
return;
}
const control = handleListControlKey(code, key, LINE_ACTIONS, deps.state.whiteboardLineActionIndex, (e) => e);
if (control.type === 'move') {
deps.state.whiteboardLineActionIndex = control.index;
deps.updateStatus(LINE_ACTIONS[control.index]);
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
if (deps.state.whiteboardLineActionIndex === 0) {
// Edit
deps.state.whiteboardEditingLineIndex = lineIndex;
deps.state.nicknameInput = lines[lineIndex];
deps.state.cursorPos = lines[lineIndex].length;
deps.setReplaceTextOnNextType(true);
deps.state.mode = 'whiteboardLineEdit';
deps.updateStatus(`Edit line. ${lines[lineIndex]}`);
deps.sfxUiBlip();
} else {
// Delete
deps.signalingSend({
type: 'item_interact',
itemId: item.id,
action: 'delete_line',
params: { line_index: lineIndex },
});
deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, Math.max(0, lines.length - 1));
deps.state.mode = 'whiteboardLines';
deps.updateStatus('Line deleted.');
deps.sfxUiBlip();
}
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'whiteboardLines';
deps.updateStatus(lines[lineIndex]);
deps.sfxUiCancel();
}
}
function handleWhiteboardLineEditModeInput(code: string, key: string, ctrlKey: boolean): void {
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
if (!item) {
deps.state.mode = 'normal';
deps.state.whiteboardItemId = null;
return;
}
if (code === 'Enter') {
const text = deps.state.nicknameInput.trim();
if (!text) {
deps.updateStatus('Cannot add empty line.');
deps.sfxUiCancel();
return;
}
const editIndex = deps.state.whiteboardEditingLineIndex;
if (editIndex !== null) {
deps.signalingSend({
type: 'item_interact',
itemId: item.id,
action: 'edit_line',
params: { line_index: editIndex, text },
});
} else {
deps.signalingSend({
type: 'item_interact',
itemId: item.id,
action: 'add_line',
params: { text },
});
}
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.state.whiteboardEditingLineIndex = null;
deps.state.mode = 'whiteboardLines';
deps.updateStatus(editIndex !== null ? 'Line updated.' : 'Line added.');
deps.sfxUiBlip();
return;
}
if (code === 'Escape') {
const wasEditing = deps.state.whiteboardEditingLineIndex !== null;
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.state.whiteboardEditingLineIndex = null;
if (wasEditing) {
deps.state.mode = 'whiteboardLineActions';
const lines = getLines(item);
const line = lines[deps.state.whiteboardLineIndex] ?? '';
deps.updateStatus(`${line}. Edit or Delete.`);
deps.sfxUiCancel();
} else {
deps.state.mode = 'whiteboardLines';
deps.updateStatus('Cancelled.');
deps.sfxUiCancel();
}
return;
}
deps.applyTextInputEdit(code, key, 200, ctrlKey);
}
function refreshWhiteboardStatus(): void {
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
if (!item) return;
const lines = getLines(item);
const n = lines.length;
// Clamp index if lines shrank
deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, n);
if (deps.state.mode === 'whiteboardLines') {
const entries = [...lines, 'Add line'];
const current = entries[deps.state.whiteboardLineIndex] ?? 'Add line';
deps.updateStatus(`Updated. ${current}.`);
} else if (deps.state.mode === 'whiteboardLineActions') {
if (deps.state.whiteboardLineIndex < lines.length) {
deps.updateStatus(`Updated. ${lines[deps.state.whiteboardLineIndex]}. Edit or Delete.`);
} else {
deps.state.mode = 'whiteboardLines';
deps.state.whiteboardLineIndex = Math.max(0, n);
const countText = `${n} line${n !== 1 ? 's' : ''}`;
deps.updateStatus(`Updated. ${countText}.`);
}
}
}
return {
beginWhiteboardLines,
handleWhiteboardLinesModeInput,
handleWhiteboardLineActionsModeInput,
handleWhiteboardLineEditModeInput,
refreshWhiteboardStatus,
};
}

View File

@@ -31,7 +31,6 @@ import { dispatchModeInput } from './input/modeDispatcher';
import { handleListControlKey } from './input/listController'; import { handleListControlKey } from './input/listController';
import { createAdminController, type AdminMenuAction } from './input/adminController'; import { createAdminController, type AdminMenuAction } from './input/adminController';
import { setupKeyboardInputHandlers } from './input/keyboardController'; import { setupKeyboardInputHandlers } from './input/keyboardController';
import { setupMobileControls } from './input/mobileController';
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu'; import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
import { getEditSessionAction } from './input/editSession'; import { getEditSessionAction } from './input/editSession';
import { formatSteppedNumber, snapNumberToStep } from './input/numeric'; import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
@@ -64,8 +63,6 @@ import {
itemTypeLabel, itemTypeLabel,
} from './items/itemRegistry'; } from './items/itemRegistry';
import { createItemInteractionController } from './items/itemInteractionController'; import { createItemInteractionController } from './items/itemInteractionController';
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';
@@ -463,24 +460,6 @@ const itemInteractionController = createItemInteractionController({
useItem: (item) => useItem(item), useItem: (item) => useItem(item),
secondaryUseItem: (item) => secondaryUseItem(item), secondaryUseItem: (item) => secondaryUseItem(item),
}); });
const whiteboardController = createWhiteboardController({
state,
signalingSend: (message) => signaling.send(message),
updateStatus,
sfxUiBlip: () => audio.sfxUiBlip(),
sfxUiCancel: () => audio.sfxUiCancel(),
applyTextInputEdit,
setReplaceTextOnNextType: (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 {
@@ -990,14 +969,6 @@ function recomputeActiveItemPropertyKeys(itemId: string): void {
/** Sends an item-use request for the selected item. */ /** Sends an item-use request for the selected item. */
function useItem(item: WorldItem): void { function useItem(item: WorldItem): void {
if (item.type === 'whiteboard') {
whiteboardController.beginWhiteboardLines(item);
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 });
} }
@@ -1022,70 +993,6 @@ function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Fetches the list of widget sounds from the server. Returns [] on error. */
async function fetchWidgetSounds(): Promise<string[]> {
try {
const response = await fetch(withBase('sounds_list.php'));
if (!response.ok) return [];
const names = (await response.json()) as string[];
if (!Array.isArray(names)) return [];
return names.map((name: string) => `sounds/widgets/${name}`);
} catch {
return [];
}
}
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null;
/** Plays a sound preview with debounce, for use while navigating sound picker. */
function previewSound(soundPath: string): void {
audio.stopPreviewSample();
if (previewDebounceTimer !== null) {
clearTimeout(previewDebounceTimer);
}
previewDebounceTimer = setTimeout(() => {
previewDebounceTimer = null;
if (soundPath) {
void audio.playPreviewSample(soundPath, 0.7);
}
}, 200);
}
/** Stops any in-progress preview sound and clears the debounce timer. */
function stopPreviewSound(): void {
if (previewDebounceTimer !== null) {
clearTimeout(previewDebounceTimer);
previewDebounceTimer = null;
}
audio.stopPreviewSample();
}
/** Opens the sound picker for a sound-typed item property, falling back to text edit if no sounds are found. */
async function openSoundPropertyPicker(item: WorldItem, key: string): Promise<void> {
updateStatus('Loading sounds...');
const sounds = await fetchWidgetSounds();
if (sounds.length === 0) {
state.mode = 'itemPropertyEdit';
state.editingPropertyKey = key;
const currentValue = String(item.params[key] ?? '');
state.nicknameInput = currentValue;
state.cursorPos = currentValue.length;
replaceTextOnNextType = true;
updateStatus(`Edit ${itemPropertyLabel(key)}: ${currentValue}`);
audio.sfxUiBlip();
return;
}
const options = ['', ...sounds];
state.mode = 'itemPropertyOptionSelect';
state.editingPropertyKey = key;
state.itemPropertyOptionValues = options;
const currentValue = String(item.params[key] ?? '').trim();
const currentIndex = options.indexOf(currentValue);
state.itemPropertyOptionIndex = currentIndex >= 0 ? currentIndex : 0;
updateStatus(`Select ${itemPropertyLabel(key)}: ${options[state.itemPropertyOptionIndex] || 'none'}`);
audio.sfxUiBlip();
}
/** Returns the active text-input max length for the current UI mode, if applicable. */ /** Returns the active text-input max length for the current UI mode, if applicable. */
function textInputMaxLengthForMode(mode: typeof state.mode): number | null { function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
if (mode === 'nickname') return NICKNAME_MAX_LENGTH; if (mode === 'nickname') return NICKNAME_MAX_LENGTH;
@@ -1093,7 +1000,6 @@ function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
if (mode === 'itemPropertyEdit') return 500; if (mode === 'itemPropertyEdit') return 500;
if (mode === 'micGainEdit') return 8; if (mode === 'micGainEdit') return 8;
if (mode === 'adminRoleNameEdit') return 32; if (mode === 'adminRoleNameEdit') return 32;
if (mode === 'whiteboardLineEdit') return 200;
return null; return null;
} }
@@ -1118,8 +1024,7 @@ function isTextEditingMode(mode: typeof state.mode): boolean {
mode === 'chat' || mode === 'chat' ||
mode === 'itemPropertyEdit' || mode === 'itemPropertyEdit' ||
mode === 'micGainEdit' || mode === 'micGainEdit' ||
mode === 'adminRoleNameEdit' || mode === 'adminRoleNameEdit'
mode === 'whiteboardLineEdit'
); );
} }
@@ -1680,8 +1585,6 @@ const onAppMessage = createOnMessageHandler({
connectToLiveKit: (url, token) => { connectToLiveKit: (url, token) => {
void connectLiveKit(url, token); void connectLiveKit(url, token);
}, },
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. */
@@ -2650,9 +2553,6 @@ const itemPropertyEditor = createItemPropertyEditor({
updateStatus, updateStatus,
sfxUiBlip: () => audio.sfxUiBlip(), sfxUiBlip: () => audio.sfxUiBlip(),
sfxUiCancel: () => audio.sfxUiCancel(), sfxUiCancel: () => audio.sfxUiCancel(),
openSoundPropertyPicker: (item, key) => { void openSoundPropertyPicker(item, key); },
previewSound,
stopPreviewSound,
}); });
/** Handles nickname edit mode submission/cancel and text editing keys. */ /** Handles nickname edit mode submission/cancel and text editing keys. */
@@ -2726,22 +2626,6 @@ function handleModeInput(input: ModeInput): void {
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey), itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) => itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) =>
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey), itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey),
whiteboardLines: ({ code: currentCode, key: currentKey }) =>
whiteboardController.handleWhiteboardLinesModeInput(currentCode, currentKey),
whiteboardLineActions: ({ code: currentCode, key: currentKey }) =>
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, onNormalMode: handleNormalModeInput,
}); });
@@ -2795,25 +2679,6 @@ setupKeyboardInputHandlers({
replaceTextOnNextType = value; replaceTextOnNextType = value;
}, },
}); });
setupMobileControls({
dom: {
canvas: dom.canvas,
mobileControls: requiredById('mobileControls') as HTMLDivElement,
toggleButton: requiredById('mobileControlsToggle') as HTMLButtonElement,
dpadUp: requiredById('dpadUp') as HTMLButtonElement,
dpadDown: requiredById('dpadDown') as HTMLButtonElement,
dpadLeft: requiredById('dpadLeft') as HTMLButtonElement,
dpadRight: requiredById('dpadRight') as HTMLButtonElement,
btnChat: requiredById('mobileBtnChat') as HTMLButtonElement,
btnUse: requiredById('mobileBtnUse') as HTMLButtonElement,
btnLocateUser: requiredById('mobileBtnLocateUser') as HTMLButtonElement,
btnLocateItem: requiredById('mobileBtnLocateItem') as HTMLButtonElement,
btnCommandPalette: requiredById('mobileBtnCommands') as HTMLButtonElement,
},
state,
handleModeInput,
openCommandPalette,
});
setupDomUiHandlers({ setupDomUiHandlers({
dom, dom,
updateConnectAvailability, updateConnectAvailability,

View File

@@ -22,11 +22,7 @@ type MessageHandlerDeps = {
itemPropertyKeys: string[]; itemPropertyKeys: string[];
itemPropertyIndex: number; itemPropertyIndex: number;
carriedItemId: string | null; carriedItemId: string | null;
whiteboardItemId: string | null;
cardTableItemId: string | null;
}; };
refreshWhiteboardStatus: () => void;
refreshCardTableStatus: () => void;
dom: { dom: {
connectButton: HTMLElement; connectButton: HTMLElement;
disconnectButton: HTMLElement; disconnectButton: HTMLElement;
@@ -262,21 +258,6 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`); deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`);
} }
} }
if (
deps.state.whiteboardItemId === message.item.id &&
(deps.state.mode === 'whiteboardLines' || deps.state.mode === 'whiteboardLineActions')
) {
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;
} }

View File

@@ -241,7 +241,7 @@ export const itemRemoveSchema = z.object({
export const itemActionResultSchema = z.object({ export const itemActionResultSchema = z.object({
type: z.literal('item_action_result'), type: z.literal('item_action_result'),
ok: z.boolean(), ok: z.boolean(),
action: z.enum(['add', 'pickup', 'drop', 'delete', 'transfer', 'use', 'secondary_use', 'update', 'interact']), action: z.enum(['add', 'pickup', 'drop', 'delete', 'transfer', 'use', 'secondary_use', 'update']),
message: z.string(), message: z.string(),
itemId: z.string().optional(), itemId: z.string().optional(),
}); });
@@ -415,7 +415,6 @@ export type OutgoingMessage =
| { type: 'item_transfer'; itemId: string; targetUserId: string } | { type: 'item_transfer'; itemId: string; targetUserId: string }
| { type: 'item_use'; itemId: string } | { type: 'item_use'; itemId: string }
| { type: 'item_secondary_use'; itemId: string } | { type: 'item_secondary_use'; itemId: string }
| { type: 'item_interact'; itemId: string; action: string; params?: Record<string, unknown> }
| { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean } | { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean }
| { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' } | { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' }
| { | {

View File

@@ -51,15 +51,7 @@ export type GameMode =
| 'adminUserRoleSelect' | 'adminUserRoleSelect'
| 'adminUserDeleteConfirm' | 'adminUserDeleteConfirm'
| 'adminRoleNameEdit' | 'adminRoleNameEdit'
| 'pianoUse' | 'pianoUse';
| 'whiteboardLines'
| 'whiteboardLineActions'
| 'whiteboardLineEdit'
| 'cardTableMenu'
| 'cardTableHand'
| 'cardTableCardAction'
| 'cardTableDiscard'
| 'cardTableConfirmReset';
export type Player = { export type Player = {
id: string | null; id: string | null;
@@ -104,16 +96,6 @@ export type GameState = {
peers: Map<string, PeerState>; peers: Map<string, PeerState>;
items: Map<string, WorldItem>; items: Map<string, WorldItem>;
carriedItemId: string | null; carriedItemId: string | null;
whiteboardItemId: string | null;
whiteboardLineIndex: number;
whiteboardLineActionIndex: number;
whiteboardEditingLineIndex: number | null;
cardTableItemId: string | null;
cardTableMenuIndex: number;
cardTableHandIndex: number;
cardTableCardActionIndex: number;
cardTableDiscardIndex: number;
cardTableConfirmIndex: number;
}; };
export function createInitialState(): GameState { export function createInitialState(): GameState {
@@ -150,16 +132,6 @@ export function createInitialState(): GameState {
peers: new Map(), peers: new Map(),
items: new Map(), items: new Map(),
carriedItemId: null, carriedItemId: null,
whiteboardItemId: null,
whiteboardLineIndex: 0,
whiteboardLineActionIndex: 0,
whiteboardEditingLineIndex: null,
cardTableItemId: null,
cardTableMenuIndex: 0,
cardTableHandIndex: 0,
cardTableCardActionIndex: 0,
cardTableDiscardIndex: 0,
cardTableConfirmIndex: 0,
}; };
} }

View File

@@ -234,124 +234,3 @@ canvas {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
} }
/* ── Mobile controls panel ─────────────────────────────────────────────── */
/* Hide on pointer:fine (mouse) devices; show on touch screens */
@media (hover: hover) and (pointer: fine) {
.mobile-controls {
display: none;
}
}
/* Extra bottom padding so the fixed panel doesn't cover footer content */
@media not all and (hover: hover) and (pointer: fine) {
.app {
padding-bottom: 3rem;
}
}
.mobile-controls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgb(15 23 42 / 95%);
border-top: 1px solid #334155;
z-index: 100;
}
.mobile-toggle-btn {
display: block;
width: 100%;
padding: 0.5rem 1rem;
background: none;
border: none;
color: #e5e7eb;
font-family: inherit;
font-size: 1rem;
text-align: left;
cursor: pointer;
touch-action: manipulation;
}
.mobile-controls-body {
display: none;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1rem 1rem;
align-items: flex-start;
justify-content: center;
}
.mobile-controls[data-expanded="true"] .mobile-controls-body {
display: flex;
}
/* D-pad: 3×3 cross layout */
.dpad {
display: grid;
grid-template-areas:
". up ."
"left mid right"
". down .";
grid-template-columns: repeat(3, 52px);
grid-template-rows: repeat(3, 52px);
gap: 2px;
}
.dpad-btn {
background: #1e293b;
border: 1px solid #475569;
border-radius: 6px;
color: #e5e7eb;
font-size: 1.1rem;
cursor: pointer;
touch-action: none;
-webkit-user-select: none;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
}
.dpad-btn:active {
background: #334155;
}
.dpad-up { grid-area: up; }
.dpad-left { grid-area: left; }
.dpad-center { grid-area: mid; }
.dpad-right { grid-area: right; }
.dpad-down { grid-area: down; }
/* Action buttons row */
.mobile-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.mobile-action-btn {
min-height: 48px;
padding: 0.25rem 0.75rem;
background: #1e293b;
border: 1px solid #475569;
border-radius: 6px;
color: #e5e7eb;
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
}
.mobile-action-btn:active {
background: #334155;
}
.mobile-action-btn--wide {
flex: 1 0 100%;
}

View File

@@ -1,41 +0,0 @@
<?php
/*
* Chat Grid sounds list endpoint.
*
* Returns a JSON array of filenames found in sounds/widgets/ relative to
* the document root. Filters to .ogg, .mp3, and .wav files.
* Returns [] if the directory does not exist or contains no audio files.
*/
header('Content-Type: application/json');
header('Cache-Control: no-store');
$dir = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/') . '/sounds/widgets';
if (!is_dir($dir)) {
echo '[]';
exit;
}
$files = scandir($dir);
if ($files === false) {
echo '[]';
exit;
}
$allowed = ['ogg', 'mp3', 'wav'];
$results = [];
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (in_array($ext, $allowed, true)) {
$results[] = $file;
}
}
sort($results);
echo json_encode($results, JSON_UNESCAPED_SLASHES);

View File

@@ -35,8 +35,6 @@ services:
- CHGRID_MEDIA_PROXY_SESSION_CHECK_URL=http://server:4474/auth/session/check - CHGRID_MEDIA_PROXY_SESSION_CHECK_URL=http://server:4474/auth/session/check
ports: ports:
- "127.0.0.1:4474:80" - "127.0.0.1:4474:80"
volumes:
- ./sounds/widgets:/usr/share/nginx/html/sounds/widgets
depends_on: depends_on:
- server - server

View File

@@ -11,7 +11,6 @@ ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = {
validate_update=module.validate_update, validate_update=module.validate_update,
use=module.use_item, use=module.use_item,
secondary_use=getattr(module, "secondary_use_item", None), secondary_use=getattr(module, "secondary_use_item", None),
interact=getattr(module, "interact_item", None),
) )
for item_type, module in ITEM_MODULES.items() for item_type, module in ITEM_MODULES.items()
} }

View File

@@ -26,4 +26,3 @@ class ItemTypeHandler:
validate_update: Callable[[WorldItem, dict], dict] validate_update: Callable[[WorldItem, dict], dict]
use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
secondary_use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None = None secondary_use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None = None
interact: Callable[[WorldItem, str, dict | None, str], ItemUseResult] | None = None

View File

@@ -1 +0,0 @@
"""Card deck item type plugin package."""

View File

@@ -1,96 +0,0 @@
"""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"},
)

View File

@@ -1,43 +0,0 @@
"""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.",
},
}

View File

@@ -1,17 +0,0 @@
"""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,
),
}

View File

@@ -1,53 +0,0 @@
"""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)

View File

@@ -1 +0,0 @@
"""Card table item type plugin package."""

View File

@@ -1,165 +0,0 @@
"""Card table item use actions."""
from __future__ import annotations
import random
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
_VALID_RANKS = {"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
_VALID_SUITS = {"S", "H", "D", "C"}
_RANK_NAMES = {
"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 = {"S": "Spades", "H": "Hearts", "D": "Diamonds", "C": "Clubs"}
_CARD_TABLE_ACTIONS = frozenset(["draw", "draw_from_discard", "discard", "return_to_pile"])
def _card_name(code: str) -> str:
"""Human-readable card name."""
if code in ("JO1", "JO2"):
return "Joker"
suit = code[-1]
rank = code[:-1]
return f"{_RANK_NAMES.get(rank, rank)} of {_SUIT_NAMES.get(suit, 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:
"""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": {},
},
)
def interact_item(
item: WorldItem,
action: str,
params: dict | None,
nickname: str,
) -> ItemUseResult:
"""Handle a card table interact action on behalf of any user."""
if action not in _CARD_TABLE_ACTIONS:
raise ValueError(f"Unknown card table action: {action!r}")
draw_pile = list(item.params.get("draw_pile", []))
discard_pile = list(item.params.get("discard_pile", []))
hands_raw = item.params.get("hands", {})
hands: dict[str, list[str]] = dict(hands_raw) if isinstance(hands_raw, dict) else {}
if action == "draw":
if not draw_pile:
raise ValueError("Draw pile is empty.")
card = draw_pile.pop(0)
hand = list(hands.get(nickname, []))
hand.append(card)
hands[nickname] = hand
return ItemUseResult(
self_message=f"You drew {_card_name(card)}. {len(draw_pile)} remaining in draw pile.",
others_message=f"{nickname} draws a card.",
updated_params={"draw_pile": draw_pile, "hands": hands},
)
if action == "draw_from_discard":
if not discard_pile:
raise ValueError("Discard pile is empty.")
if not params or "card_index" not in params:
raise ValueError("draw_from_discard requires params.card_index.")
card_index = params["card_index"]
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(discard_pile):
raise ValueError("Invalid card_index.")
card = discard_pile.pop(card_index)
hand = list(hands.get(nickname, []))
hand.append(card)
hands[nickname] = hand
return ItemUseResult(
self_message=f"You took {_card_name(card)} from the discard pile.",
others_message=f"{nickname} takes a card from the discard pile.",
updated_params={"discard_pile": discard_pile, "hands": hands},
)
if action == "discard":
if not params or "card_index" not in params:
raise ValueError("discard requires params.card_index.")
hand = list(hands.get(nickname, []))
card_index = params["card_index"]
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(hand):
raise ValueError("Invalid card_index.")
card = hand.pop(card_index)
discard_pile.insert(0, card)
hands[nickname] = hand
return ItemUseResult(
self_message=f"You discarded {_card_name(card)}.",
others_message=f"{nickname} discards a card.",
updated_params={"discard_pile": discard_pile, "hands": hands},
)
if action == "return_to_pile":
if not params or "card_index" not in params:
raise ValueError("return_to_pile requires params.card_index.")
hand = list(hands.get(nickname, []))
card_index = params["card_index"]
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(hand):
raise ValueError("Invalid card_index.")
card = hand.pop(card_index)
draw_pile.append(card)
hands[nickname] = hand
return ItemUseResult(
self_message=f"You returned {_card_name(card)} to the draw pile.",
others_message=f"{nickname} returns a card to the draw pile.",
updated_params={"draw_pile": draw_pile, "hands": hands},
)
raise ValueError(f"Unhandled action: {action!r}") # unreachable guard

View File

@@ -1,39 +0,0 @@
"""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.",
},
}

View File

@@ -1,18 +0,0 @@
"""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,
interact_item=actions.interact_item,
),
}

View File

@@ -1,64 +0,0 @@
"""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)

View File

@@ -6,14 +6,7 @@ from types import SimpleNamespace
from typing import Any from typing import Any
def build_item_module( def build_item_module(definition: Any, *, validate_update: Any, use_item: Any, secondary_use_item: Any = None) -> Any:
definition: Any,
*,
validate_update: Any,
use_item: Any,
secondary_use_item: Any = None,
interact_item: Any = None,
) -> Any:
"""Compose a plugin module-like object from split definition/validator/actions files.""" """Compose a plugin module-like object from split definition/validator/actions files."""
exports: dict[str, Any] = { exports: dict[str, Any] = {
@@ -25,6 +18,4 @@ def build_item_module(
exports["use_item"] = use_item exports["use_item"] = use_item
if secondary_use_item is not None: if secondary_use_item is not None:
exports["secondary_use_item"] = secondary_use_item exports["secondary_use_item"] = secondary_use_item
if interact_item is not None:
exports["interact_item"] = interact_item
return SimpleNamespace(**exports) return SimpleNamespace(**exports)

View File

@@ -1 +0,0 @@
"""Whiteboard item type package."""

View File

@@ -1,89 +0,0 @@
"""Whiteboard item use actions."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
_WHITEBOARD_ACTIONS = frozenset(["add_line", "edit_line", "delete_line"])
_MAX_LINES = 20
_MAX_LINE_LENGTH = 200
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
"""Report whiteboard contents to the user who used it."""
lines = item.params.get("lines", [])
if not isinstance(lines, list):
lines = []
n = len(lines)
line_text = f"{n} line{'s' if n != 1 else ''}"
return ItemUseResult(
self_message=f"You open {item.title}. {line_text}.",
others_message=f"{nickname} opens {item.title}.",
)
def interact_item(
item: WorldItem,
action: str,
params: dict | None,
nickname: str,
) -> ItemUseResult:
"""Handle a whiteboard interact action on behalf of any user."""
if action not in _WHITEBOARD_ACTIONS:
raise ValueError(f"Unknown whiteboard action: {action!r}")
lines = list(item.params.get("lines", []))
if action == "add_line":
if not params or not isinstance(params.get("text"), str):
raise ValueError("add_line requires params.text.")
text = params["text"].strip()
if not text:
raise ValueError("Line text cannot be empty.")
if len(text) > _MAX_LINE_LENGTH:
raise ValueError(f"Line text is too long (max {_MAX_LINE_LENGTH} characters).")
if len(lines) >= _MAX_LINES:
raise ValueError(f"Whiteboard is full (max {_MAX_LINES} lines).")
lines.append(text)
return ItemUseResult(
self_message=f"Line added to {item.title}.",
others_message=f"{nickname} adds a line to {item.title}.",
updated_params={"lines": lines},
)
if action == "edit_line":
if not params or "line_index" not in params or not isinstance(params.get("text"), str):
raise ValueError("edit_line requires params.line_index and params.text.")
line_index = params["line_index"]
if not isinstance(line_index, int) or line_index < 0 or line_index >= len(lines):
raise ValueError("Invalid line_index.")
text = params["text"].strip()
if not text:
raise ValueError("Line text cannot be empty.")
if len(text) > _MAX_LINE_LENGTH:
raise ValueError(f"Line text is too long (max {_MAX_LINE_LENGTH} characters).")
lines[line_index] = text
return ItemUseResult(
self_message=f"Line updated on {item.title}.",
others_message=f"{nickname} updates a line on {item.title}.",
updated_params={"lines": lines},
)
if action == "delete_line":
if not params or "line_index" not in params:
raise ValueError("delete_line requires params.line_index.")
line_index = params["line_index"]
if not isinstance(line_index, int) or line_index < 0 or line_index >= len(lines):
raise ValueError("Invalid line_index.")
lines.pop(line_index)
return ItemUseResult(
self_message=f"Line deleted from {item.title}.",
others_message=f"{nickname} deletes a line from {item.title}.",
updated_params={"lines": lines},
)
raise ValueError(f"Unhandled action: {action!r}") # unreachable guard

View File

@@ -1,19 +0,0 @@
"""Whiteboard item static metadata and defaults."""
from __future__ import annotations
LABEL = "whiteboard"
TOOLTIP = "A shared text board. Use to read and edit lines."
EDITABLE_PROPERTIES: tuple[str, ...] = ("title",)
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
USE_SOUND: str | None = None
EMIT_SOUND: str | None = None
USE_COOLDOWN_MS = 500
EMIT_RANGE = 15
DIRECTIONAL = False
DEFAULT_TITLE = "whiteboard"
DEFAULT_PARAMS: dict = {"lines": []}
PARAM_KEYS: tuple[str, ...] = ("lines",)
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name.", "maxLength": 80},
}

View File

@@ -1,17 +0,0 @@
"""Plugin registration for whiteboard item type."""
from __future__ import annotations
from ..plugin_helpers import build_item_module
from . import actions, definition, validator
ITEM_TYPE_PLUGIN = {
"type": "whiteboard",
"order": 70,
"module": build_item_module(
definition,
validate_update=validator.validate_update,
use_item=actions.use_item,
interact_item=actions.interact_item,
),
}

View File

@@ -1,32 +0,0 @@
"""Whiteboard item validation/normalization."""
from __future__ import annotations
from ....models import WorldItem
from ...helpers import keep_only_known_params
from .definition import PARAM_KEYS
_MAX_LINES = 20
_MAX_LINE_LENGTH = 200
def validate_update(_item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize whiteboard params."""
lines = next_params.get("lines", [])
if not isinstance(lines, list):
raise ValueError("lines must be a list.")
if len(lines) > _MAX_LINES:
raise ValueError(f"A whiteboard can have at most {_MAX_LINES} lines.")
cleaned: list[str] = []
for line in lines:
if not isinstance(line, str):
raise ValueError("Each line must be a string.")
stripped = line.strip()
if len(stripped) > _MAX_LINE_LENGTH:
raise ValueError(f"Each line must be at most {_MAX_LINE_LENGTH} characters.")
cleaned.append(stripped)
next_params["lines"] = cleaned
return keep_only_known_params(next_params, PARAM_KEYS)

View File

@@ -176,13 +176,6 @@ class ItemUpdatePacket(BasePacket):
params: dict | None = None params: dict | None = None
class ItemInteractPacket(BasePacket):
type: Literal["item_interact"]
itemId: str
action: str = Field(min_length=1, max_length=64)
params: dict | None = None
ClientPacket = ( ClientPacket = (
UpdatePositionPacket UpdatePositionPacket
| TeleportCompletePacket | TeleportCompletePacket
@@ -211,7 +204,6 @@ ClientPacket = (
| ItemTransferTargetsPacket | ItemTransferTargetsPacket
| ItemUsePacket | ItemUsePacket
| ItemSecondaryUsePacket | ItemSecondaryUsePacket
| ItemInteractPacket
| ItemPianoNotePacket | ItemPianoNotePacket
| ItemPianoRecordingPacket | ItemPianoRecordingPacket
| ItemUpdatePacket | ItemUpdatePacket
@@ -374,7 +366,7 @@ class ItemRemovePacket(BasePacket):
class ItemActionResultPacket(BasePacket): class ItemActionResultPacket(BasePacket):
type: Literal["item_action_result"] type: Literal["item_action_result"]
ok: bool ok: bool
action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update", "interact"] action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update"]
message: str message: str
itemId: str | None = None itemId: str | None = None

View File

@@ -80,7 +80,6 @@ from .models import (
ItemClockAnnouncePacket, ItemClockAnnouncePacket,
ItemDeletePacket, ItemDeletePacket,
ItemDropPacket, ItemDropPacket,
ItemInteractPacket,
ItemPianoNoteBroadcastPacket, ItemPianoNoteBroadcastPacket,
ItemPianoNotePacket, ItemPianoNotePacket,
ItemPianoRecordingPacket, ItemPianoRecordingPacket,
@@ -2956,69 +2955,9 @@ 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
if isinstance(packet, ItemInteractPacket):
if not self._client_has_permission(client, "item.use"):
await self._send_item_result(client, False, "interact", "Not authorized to use items.")
return
item = self.items.get(packet.itemId)
if not item:
await self._send_item_result(client, False, "interact", "Item not found.")
return
if item.carrierId not in (None, client.id):
await self._send_item_result(client, False, "interact", "Item is not available.", item.id)
return
if item.carrierId is None and (item.x != client.x or item.y != client.y):
await self._send_item_result(client, False, "interact", "Item is not on your square.", item.id)
return
handler = get_item_type_handler(item.type)
if handler.interact is None:
await self._send_item_result(
client, False, "interact", f"{item.title} does not support interact actions.", item.id
)
return
try:
interact_result = handler.interact(item, packet.action, packet.params, client.nickname)
except ValueError as exc:
await self._send_item_result(client, False, "interact", str(exc), item.id)
return
if interact_result.updated_params is not None:
try:
item.params = handler.validate_update(item, {**item.params, **interact_result.updated_params})
except ValueError as exc:
await self._send_item_result(client, False, "interact", str(exc), item.id)
return
item.updatedAt = self.item_service.now_ms()
actor_id, actor_name = self._item_updated_actor(client)
item.updatedBy = actor_id
item.updatedByName = actor_name
item.version += 1
self._request_state_save()
await self._broadcast_item(item)
if interact_result.others_message.strip():
await self._broadcast(
BroadcastChatMessagePacket(type="chat_message", message=interact_result.others_message, system=True),
exclude=client.websocket,
)
await self._send_item_result(client, True, "interact", interact_result.self_message, item.id)
return
if isinstance(packet, ItemPianoNotePacket): if isinstance(packet, ItemPianoNotePacket):
if not self._client_has_permission(client, "item.use"): if not self._client_has_permission(client, "item.use"):
return return