Compare commits
4 Commits
main
...
pr/livekit
| Author | SHA1 | Date | |
|---|---|---|---|
| 49a9ca473d | |||
| f3efec5c97 | |||
| ceb693778c | |||
| ae301db3bb |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,7 +25,3 @@ plans/
|
||||
|
||||
# Host-local notes
|
||||
local/
|
||||
|
||||
# Ignore actual sounds in sounds/widgets/
|
||||
sounds/widgets/*.ogg
|
||||
sounds/widgets/**/*.ogg
|
||||
@@ -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
|
||||
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/sounds_list.php /usr/share/nginx/html/sounds_list.php
|
||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
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;'"]
|
||||
|
||||
@@ -95,27 +95,6 @@
|
||||
<button id="closeSettingsButton">Close</button>
|
||||
</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">☰ 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">▲</button>
|
||||
<button id="dpadLeft" type="button" class="dpad-btn dpad-left" aria-label="Move left">◀</button>
|
||||
<div class="dpad-center" aria-hidden="true"></div>
|
||||
<button id="dpadRight" type="button" class="dpad-btn dpad-right" aria-label="Move right">▶</button>
|
||||
<button id="dpadDown" type="button" class="dpad-btn dpad-down" aria-label="Move down">▼</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>
|
||||
<script src="%BASE_URL%version.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -48,9 +48,6 @@ export class AudioEngine {
|
||||
private readonly sampleLoaders = new Map<string, Promise<AudioBuffer>>();
|
||||
private readonly activeSpatialSamples = new Set<ActiveSpatialSampleRuntime>();
|
||||
|
||||
private previewSourceNode: AudioBufferSourceNode | null = null;
|
||||
private previewGainNode: GainNode | null = null;
|
||||
|
||||
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
||||
private outboundInputGain: GainNode | null = null;
|
||||
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. */
|
||||
async startLoopingSample(url: string, gain = 1): Promise<(() => void) | null> {
|
||||
await this.ensureContext();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -47,9 +47,6 @@ type EditorDeps = {
|
||||
updateStatus: (message: string) => void;
|
||||
sfxUiBlip: () => 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);
|
||||
return;
|
||||
}
|
||||
if (metadata?.valueType === 'sound' && deps.openSoundPropertyPicker) {
|
||||
deps.openSoundPropertyPicker(item, selectedKey);
|
||||
return;
|
||||
}
|
||||
deps.state.mode = 'itemPropertyEdit';
|
||||
deps.state.editingPropertyKey = selectedKey;
|
||||
const selectedMetadata = metadata;
|
||||
const selectedMetadata = deps.getItemPropertyMetadata(item.type, selectedKey);
|
||||
deps.state.nicknameInput =
|
||||
selectedKey === 'title'
|
||||
? item.title
|
||||
@@ -376,13 +369,6 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
||||
const nextIndex = (deps.state.itemPropertyOptionIndex + delta + length * 1000) % length;
|
||||
deps.state.itemPropertyOptionIndex = 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();
|
||||
return;
|
||||
}
|
||||
@@ -397,19 +383,11 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
||||
if (control.type === 'move') {
|
||||
deps.state.itemPropertyOptionIndex = control.index;
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
if (control.type === 'select') {
|
||||
deps.stopPreviewSound?.();
|
||||
const selectedValue = deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex];
|
||||
deps.signalingSend({ type: 'item_update', itemId, params: { [propertyKey]: selectedValue } });
|
||||
const item = deps.state.items.get(itemId);
|
||||
@@ -424,7 +402,6 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
||||
}
|
||||
|
||||
if (control.type === 'cancel') {
|
||||
deps.stopPreviewSound?.();
|
||||
deps.state.mode = 'itemProperties';
|
||||
deps.state.editingPropertyKey = null;
|
||||
deps.state.itemPropertyOptionValues = [];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { dispatchModeInput } from './input/modeDispatcher';
|
||||
import { handleListControlKey } from './input/listController';
|
||||
import { createAdminController, type AdminMenuAction } from './input/adminController';
|
||||
import { setupKeyboardInputHandlers } from './input/keyboardController';
|
||||
import { setupMobileControls } from './input/mobileController';
|
||||
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
|
||||
import { getEditSessionAction } from './input/editSession';
|
||||
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
||||
@@ -64,8 +63,6 @@ import {
|
||||
itemTypeLabel,
|
||||
} from './items/itemRegistry';
|
||||
import { createItemInteractionController } from './items/itemInteractionController';
|
||||
import { createWhiteboardController } from './items/whiteboardController';
|
||||
import { createCardTableController } from './items/cardTableController';
|
||||
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
||||
import { createItemPropertyPresentation } from './items/itemPropertyPresentation';
|
||||
import { ItemBehaviorRegistry } from './items/types/behaviorRegistry';
|
||||
@@ -463,24 +460,6 @@ const itemInteractionController = createItemInteractionController({
|
||||
useItem: (item) => useItem(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. */
|
||||
function setUpdatesExpanded(expanded: boolean): void {
|
||||
@@ -990,14 +969,6 @@ function recomputeActiveItemPropertyKeys(itemId: string): void {
|
||||
|
||||
/** Sends an item-use request for the selected item. */
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -1022,70 +993,6 @@ function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
||||
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. */
|
||||
function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
|
||||
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 === 'micGainEdit') return 8;
|
||||
if (mode === 'adminRoleNameEdit') return 32;
|
||||
if (mode === 'whiteboardLineEdit') return 200;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1118,8 +1024,7 @@ function isTextEditingMode(mode: typeof state.mode): boolean {
|
||||
mode === 'chat' ||
|
||||
mode === 'itemPropertyEdit' ||
|
||||
mode === 'micGainEdit' ||
|
||||
mode === 'adminRoleNameEdit' ||
|
||||
mode === 'whiteboardLineEdit'
|
||||
mode === 'adminRoleNameEdit'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1680,8 +1585,6 @@ const onAppMessage = createOnMessageHandler({
|
||||
connectToLiveKit: (url, token) => {
|
||||
void connectLiveKit(url, token);
|
||||
},
|
||||
refreshWhiteboardStatus: () => whiteboardController.refreshWhiteboardStatus(),
|
||||
refreshCardTableStatus: () => cardTableController.refreshCardTableStatus(),
|
||||
});
|
||||
|
||||
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
||||
@@ -2650,9 +2553,6 @@ const itemPropertyEditor = createItemPropertyEditor({
|
||||
updateStatus,
|
||||
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||
sfxUiCancel: () => audio.sfxUiCancel(),
|
||||
openSoundPropertyPicker: (item, key) => { void openSoundPropertyPicker(item, key); },
|
||||
previewSound,
|
||||
stopPreviewSound,
|
||||
});
|
||||
|
||||
/** Handles nickname edit mode submission/cancel and text editing keys. */
|
||||
@@ -2726,22 +2626,6 @@ function handleModeInput(input: ModeInput): void {
|
||||
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
|
||||
itemPropertyOptionSelect: ({ code: currentCode, key: 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,
|
||||
});
|
||||
@@ -2795,25 +2679,6 @@ setupKeyboardInputHandlers({
|
||||
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({
|
||||
dom,
|
||||
updateConnectAvailability,
|
||||
|
||||
@@ -22,11 +22,7 @@ type MessageHandlerDeps = {
|
||||
itemPropertyKeys: string[];
|
||||
itemPropertyIndex: number;
|
||||
carriedItemId: string | null;
|
||||
whiteboardItemId: string | null;
|
||||
cardTableItemId: string | null;
|
||||
};
|
||||
refreshWhiteboardStatus: () => void;
|
||||
refreshCardTableStatus: () => void;
|
||||
dom: {
|
||||
connectButton: HTMLElement;
|
||||
disconnectButton: HTMLElement;
|
||||
@@ -262,21 +258,6 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
||||
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);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ export const itemRemoveSchema = z.object({
|
||||
export const itemActionResultSchema = z.object({
|
||||
type: z.literal('item_action_result'),
|
||||
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(),
|
||||
itemId: z.string().optional(),
|
||||
});
|
||||
@@ -415,7 +415,6 @@ export type OutgoingMessage =
|
||||
| { type: 'item_transfer'; itemId: string; targetUserId: string }
|
||||
| { type: 'item_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_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' }
|
||||
| {
|
||||
|
||||
@@ -51,15 +51,7 @@ export type GameMode =
|
||||
| 'adminUserRoleSelect'
|
||||
| 'adminUserDeleteConfirm'
|
||||
| 'adminRoleNameEdit'
|
||||
| 'pianoUse'
|
||||
| 'whiteboardLines'
|
||||
| 'whiteboardLineActions'
|
||||
| 'whiteboardLineEdit'
|
||||
| 'cardTableMenu'
|
||||
| 'cardTableHand'
|
||||
| 'cardTableCardAction'
|
||||
| 'cardTableDiscard'
|
||||
| 'cardTableConfirmReset';
|
||||
| 'pianoUse';
|
||||
|
||||
export type Player = {
|
||||
id: string | null;
|
||||
@@ -104,16 +96,6 @@ export type GameState = {
|
||||
peers: Map<string, PeerState>;
|
||||
items: Map<string, WorldItem>;
|
||||
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 {
|
||||
@@ -150,16 +132,6 @@ export function createInitialState(): GameState {
|
||||
peers: new Map(),
|
||||
items: new Map(),
|
||||
carriedItemId: null,
|
||||
whiteboardItemId: null,
|
||||
whiteboardLineIndex: 0,
|
||||
whiteboardLineActionIndex: 0,
|
||||
whiteboardEditingLineIndex: null,
|
||||
cardTableItemId: null,
|
||||
cardTableMenuIndex: 0,
|
||||
cardTableHandIndex: 0,
|
||||
cardTableCardActionIndex: 0,
|
||||
cardTableDiscardIndex: 0,
|
||||
cardTableConfirmIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -234,124 +234,3 @@ canvas {
|
||||
display: grid;
|
||||
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%;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -35,8 +35,6 @@ services:
|
||||
- CHGRID_MEDIA_PROXY_SESSION_CHECK_URL=http://server:4474/auth/session/check
|
||||
ports:
|
||||
- "127.0.0.1:4474:80"
|
||||
volumes:
|
||||
- ./sounds/widgets:/usr/share/nginx/html/sounds/widgets
|
||||
depends_on:
|
||||
- server
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = {
|
||||
validate_update=module.validate_update,
|
||||
use=module.use_item,
|
||||
secondary_use=getattr(module, "secondary_use_item", None),
|
||||
interact=getattr(module, "interact_item", None),
|
||||
)
|
||||
for item_type, module in ITEM_MODULES.items()
|
||||
}
|
||||
|
||||
@@ -26,4 +26,3 @@ class ItemTypeHandler:
|
||||
validate_update: Callable[[WorldItem, dict], dict]
|
||||
use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
|
||||
secondary_use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None = None
|
||||
interact: Callable[[WorldItem, str, dict | None, str], ItemUseResult] | None = None
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Card deck item type plugin package."""
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
"""Card table item type plugin package."""
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -6,14 +6,7 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_item_module(
|
||||
definition: Any,
|
||||
*,
|
||||
validate_update: Any,
|
||||
use_item: Any,
|
||||
secondary_use_item: Any = None,
|
||||
interact_item: Any = None,
|
||||
) -> Any:
|
||||
def build_item_module(definition: Any, *, validate_update: Any, use_item: Any, secondary_use_item: Any = None) -> Any:
|
||||
"""Compose a plugin module-like object from split definition/validator/actions files."""
|
||||
|
||||
exports: dict[str, Any] = {
|
||||
@@ -25,6 +18,4 @@ def build_item_module(
|
||||
exports["use_item"] = use_item
|
||||
if secondary_use_item is not None:
|
||||
exports["secondary_use_item"] = secondary_use_item
|
||||
if interact_item is not None:
|
||||
exports["interact_item"] = interact_item
|
||||
return SimpleNamespace(**exports)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Whiteboard item type package."""
|
||||
@@ -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
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -176,13 +176,6 @@ class ItemUpdatePacket(BasePacket):
|
||||
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 = (
|
||||
UpdatePositionPacket
|
||||
| TeleportCompletePacket
|
||||
@@ -211,7 +204,6 @@ ClientPacket = (
|
||||
| ItemTransferTargetsPacket
|
||||
| ItemUsePacket
|
||||
| ItemSecondaryUsePacket
|
||||
| ItemInteractPacket
|
||||
| ItemPianoNotePacket
|
||||
| ItemPianoRecordingPacket
|
||||
| ItemUpdatePacket
|
||||
@@ -374,7 +366,7 @@ class ItemRemovePacket(BasePacket):
|
||||
class ItemActionResultPacket(BasePacket):
|
||||
type: Literal["item_action_result"]
|
||||
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
|
||||
itemId: str | None = None
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ from .models import (
|
||||
ItemClockAnnouncePacket,
|
||||
ItemDeletePacket,
|
||||
ItemDropPacket,
|
||||
ItemInteractPacket,
|
||||
ItemPianoNoteBroadcastPacket,
|
||||
ItemPianoNotePacket,
|
||||
ItemPianoRecordingPacket,
|
||||
@@ -2956,69 +2955,9 @@ class SignalingServer:
|
||||
BroadcastChatMessagePacket(type="chat_message", message=secondary_result.others_message, system=True),
|
||||
exclude=client.websocket,
|
||||
)
|
||||
use_sound = self._resolve_item_use_sound(item)
|
||||
if use_sound:
|
||||
sound_x, sound_y = self._get_item_sound_source_position(item)
|
||||
sound_range = self._get_item_emit_range(item)
|
||||
await self._broadcast(
|
||||
ItemUseSoundPacket(
|
||||
type="item_use_sound",
|
||||
itemId=item.id,
|
||||
sound=use_sound,
|
||||
x=sound_x,
|
||||
y=sound_y,
|
||||
range=sound_range,
|
||||
)
|
||||
)
|
||||
await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id)
|
||||
return
|
||||
|
||||
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 not self._client_has_permission(client, "item.use"):
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user