diff --git a/client/public/version.js b/client/public/version.js index d552c91..24715d1 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.25 R259"; +window.CHGRID_WEB_VERSION = "2026.02.25 R260"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/input/mainCommandRouter.ts b/client/src/input/mainCommandRouter.ts index d2a5477..e7b3e6d 100644 --- a/client/src/input/mainCommandRouter.ts +++ b/client/src/input/mainCommandRouter.ts @@ -19,6 +19,7 @@ export type MainModeCommand = | 'openMicGainEdit' | 'calibrateMicrophone' | 'useItem' + | 'secondaryUseItem' | 'speakUsers' | 'addItem' | 'locateOrListItems' @@ -51,7 +52,7 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod if (code === 'NumpadSubtract') return 'masterVolumeDown'; if (code === 'KeyC') return shiftKey ? null : 'speakCoordinates'; if (code === 'KeyV') return shiftKey ? 'calibrateMicrophone' : 'openMicGainEdit'; - if (code === 'Enter') return 'useItem'; + if (code === 'Enter') return shiftKey ? 'secondaryUseItem' : 'useItem'; if (code === 'KeyU') return shiftKey ? null : 'speakUsers'; if (code === 'KeyA') return shiftKey ? null : 'addItem'; if (code === 'KeyI') return 'locateOrListItems'; diff --git a/client/src/main.ts b/client/src/main.ts index 7172e6d..f243791 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1021,6 +1021,11 @@ function useItem(item: WorldItem): void { signaling.send({ type: 'item_use', itemId: item.id }); } +/** Sends an item secondary-use request for the selected item. */ +function secondaryUseItem(item: WorldItem): void { + signaling.send({ type: 'item_secondary_use', itemId: item.id }); +} + /** Opens option-list selection mode for list-based item properties. */ function openItemPropertyOptionSelect(item: WorldItem, key: string): void { const options = getItemPropertyOptionValues(item.type, key); @@ -1825,6 +1830,26 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { beginItemSelection('use', usable); return; } + case 'secondaryUseItem': { + const carried = getCarriedItem(); + if (carried) { + secondaryUseItem(carried); + return; + } + const squareItems = getItemsAtPosition(state.player.x, state.player.y); + const usable = squareItems.filter((item) => item.capabilities.includes('usable')); + if (usable.length === 0) { + updateStatus('No usable items here.'); + audio.sfxUiCancel(); + return; + } + if (usable.length === 1) { + secondaryUseItem(usable[0]); + return; + } + beginItemSelection('secondaryUse', usable); + return; + } case 'speakUsers': { const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((p) => p.nickname)]; const label = allUsers.length === 1 ? 'user' : 'users'; @@ -2383,6 +2408,10 @@ function handleSelectItemModeInput(code: string, key: string): void { useItem(selected); return; } + if (context === 'secondaryUse') { + secondaryUseItem(selected); + return; + } if (context === 'inspect') { beginItemProperties(selected, true); return; diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 9b1454f..ba37f0d 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -256,10 +256,10 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco break; } if (message.ok) { - if (message.action === 'use') { + if (message.action === 'use' || message.action === 'secondary_use') { deps.pushChatMessage(message.message); const item = message.itemId ? deps.getItemById(message.itemId) : null; - if (!item?.useSound && item && item.type !== 'piano') { + if (message.action === 'use' && !item?.useSound && item && item.type !== 'piano') { deps.playLocateToneAt(item.x, item.y); } } else if (message.action !== 'update') { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index d68f2bb..b432f1e 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -202,7 +202,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', 'use', 'update']), + action: z.enum(['add', 'pickup', 'drop', 'delete', 'use', 'secondary_use', 'update']), message: z.string(), itemId: z.string().optional(), }); @@ -287,6 +287,7 @@ export type OutgoingMessage = | { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_delete'; itemId: string } | { type: 'item_use'; itemId: string } + | { type: 'item_secondary_use'; itemId: string } | { 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' } | { diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index 152aea6..c936be7 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -21,7 +21,7 @@ export type WorldItem = { carrierId?: string | null; }; -export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | 'inspect' | null; +export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | 'secondaryUse' | 'inspect' | null; export type GameMode = | 'normal' diff --git a/docs/controls.md b/docs/controls.md index f8c6ef8..713ecf6 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -28,6 +28,7 @@ This document is the authoritative keymap for the client. - `D`: Pick up/drop item - `Shift+D`: Delete item - `Enter`: Use item +- `Shift+Enter`: Secondary item action ### Audio - `P`: Ping server diff --git a/docs/item-types.md b/docs/item-types.md index 2a47d84..9725b4c 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -38,6 +38,7 @@ This is behavior-focused documentation for item types and their defaults. ### Use - `use` toggles `enabled` on/off and broadcasts chat status. +- `secondary use` reports now-playing metadata (`Playing from `), or ` is off` when disabled. ### Validation - `mediaChannel`: `stereo | mono | left | right` diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 725bcf4..8783fd4 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -20,6 +20,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `chat_message`: player chat. - `ping`: latency measurement. - `item_add`, `item_pickup`, `item_drop`, `item_delete`, `item_use`, `item_update`: item actions. +- `item_secondary_use`: trigger type-specific secondary action when implemented. - `item_piano_note`: realtime piano note on/off for active piano use mode. - `item_piano_recording`: piano record/playback control (`toggle_record`, `playback`, `stop_playback`). diff --git a/server/app/item_type_handlers.py b/server/app/item_type_handlers.py index a0f2fc9..1684595 100644 --- a/server/app/item_type_handlers.py +++ b/server/app/item_type_handlers.py @@ -7,7 +7,11 @@ from .items.registry import ITEM_MODULES from .item_types import ItemTypeHandler ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = { - item_type: ItemTypeHandler(validate_update=module.validate_update, use=module.use_item) + item_type: ItemTypeHandler( + validate_update=module.validate_update, + use=module.use_item, + secondary_use=getattr(module, "secondary_use_item", None), + ) for item_type, module in ITEM_MODULES.items() } diff --git a/server/app/item_types.py b/server/app/item_types.py index 3ba0549..7fac715 100644 --- a/server/app/item_types.py +++ b/server/app/item_types.py @@ -25,3 +25,4 @@ 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 diff --git a/server/app/items/registry.py b/server/app/items/registry.py index 6279a9a..e413fd6 100644 --- a/server/app/items/registry.py +++ b/server/app/items/registry.py @@ -28,6 +28,7 @@ class ItemModule(Protocol): PROPERTY_METADATA: dict[str, dict[str, object]] validate_update: Callable[[WorldItem, dict], dict] use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] + secondary_use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None @dataclass(frozen=True) diff --git a/server/app/items/types/plugin_helpers.py b/server/app/items/types/plugin_helpers.py index fa01d17..01960d4 100644 --- a/server/app/items/types/plugin_helpers.py +++ b/server/app/items/types/plugin_helpers.py @@ -6,7 +6,7 @@ from types import SimpleNamespace from typing import Any -def build_item_module(definition: Any, *, validate_update: Any, use_item: Any) -> 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] = { @@ -16,4 +16,6 @@ def build_item_module(definition: Any, *, validate_update: Any, use_item: Any) - } exports["validate_update"] = validate_update exports["use_item"] = use_item + if secondary_use_item is not None: + exports["secondary_use_item"] = secondary_use_item return SimpleNamespace(**exports) diff --git a/server/app/items/types/radio_station/actions.py b/server/app/items/types/radio_station/actions.py index 0c5079e..edf5cb7 100644 --- a/server/app/items/types/radio_station/actions.py +++ b/server/app/items/types/radio_station/actions.py @@ -19,3 +19,25 @@ def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], others_message=f"{nickname} turns {state_text} {item.title}.", updated_params={**item.params, "enabled": next_enabled}, ) + + +def secondary_use_item(item: WorldItem, _nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Speak now-playing metadata for this radio.""" + + if item.params.get("enabled") is False: + return ItemUseResult( + self_message=f"{item.title} is off.", + others_message=f"{item.title} is off.", + ) + + station_name = str(item.params.get("stationName", "")).strip() + now_playing = str(item.params.get("nowPlaying", "")).strip() + if now_playing and station_name: + message = f"Playing {now_playing} from {station_name}." + elif now_playing: + message = f"Playing {now_playing}." + elif station_name: + message = f"Playing from {station_name}." + else: + message = "No now playing data." + return ItemUseResult(self_message=message, others_message=message) diff --git a/server/app/items/types/radio_station/plugin.py b/server/app/items/types/radio_station/plugin.py index 24f714d..8b42c25 100644 --- a/server/app/items/types/radio_station/plugin.py +++ b/server/app/items/types/radio_station/plugin.py @@ -8,5 +8,10 @@ from . import actions, definition, validator ITEM_TYPE_PLUGIN = { "type": "radio_station", "order": 40, - "module": build_item_module(definition, validate_update=validator.validate_update, use_item=actions.use_item), + "module": build_item_module( + definition, + validate_update=validator.validate_update, + use_item=actions.use_item, + secondary_use_item=actions.secondary_use_item, + ), } diff --git a/server/app/models.py b/server/app/models.py index 5bb0aa6..aa26609 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -95,6 +95,11 @@ class ItemUsePacket(BasePacket): itemId: str +class ItemSecondaryUsePacket(BasePacket): + type: Literal["item_secondary_use"] + itemId: str + + class ItemPianoNotePacket(BasePacket): type: Literal["item_piano_note"] itemId: str @@ -132,6 +137,7 @@ ClientPacket = ( | ItemDropPacket | ItemDeletePacket | ItemUsePacket + | ItemSecondaryUsePacket | ItemPianoNotePacket | ItemPianoRecordingPacket | ItemUpdatePacket @@ -275,7 +281,7 @@ class ItemRemovePacket(BasePacket): class ItemActionResultPacket(BasePacket): type: Literal["item_action_result"] ok: bool - action: Literal["add", "pickup", "drop", "delete", "use", "update"] + action: Literal["add", "pickup", "drop", "delete", "use", "secondary_use", "update"] message: str itemId: str | None = None diff --git a/server/app/server.py b/server/app/server.py index 18e60e9..cf0042e 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -68,6 +68,7 @@ from .models import ( ItemPianoStatusPacket, ItemPickupPacket, ItemRemovePacket, + ItemSecondaryUsePacket, ItemUpdatePacket, ItemUpsertPacket, ItemUsePacket, @@ -876,7 +877,7 @@ class SignalingServer: self, client: ClientConnection, ok: bool, - action: Literal["add", "pickup", "drop", "delete", "use", "update"], + action: Literal["add", "pickup", "drop", "delete", "use", "secondary_use", "update"], message: str, item_id: str | None = None, ) -> None: @@ -1671,6 +1672,49 @@ class SignalingServer: ) return + if isinstance(packet, ItemSecondaryUsePacket): + item = self.items.get(packet.itemId) + if not item: + await self._send_item_result(client, False, "secondary_use", "Item not found.") + return + if item.carrierId not in (None, client.id): + await self._send_item_result(client, False, "secondary_use", "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, "secondary_use", "Item is not on your square.", item.id) + return + handler = get_item_type_handler(item.type) + if handler.secondary_use is None: + await self._send_item_result( + client, + False, + "secondary_use", + f"No secondary action for {item.title}.", + item.id, + ) + return + try: + secondary_result = handler.secondary_use(item, client.nickname, self._format_clock_display_time) + except ValueError as exc: + await self._send_item_result(client, False, "secondary_use", str(exc), item.id) + return + if secondary_result.updated_params is not None: + try: + item.params = handler.validate_update(item, {**item.params, **secondary_result.updated_params}) + except ValueError as exc: + await self._send_item_result(client, False, "secondary_use", str(exc), item.id) + return + item.updatedAt = self.item_service.now_ms() + item.version += 1 + self._request_state_save() + await self._broadcast_item(item) + await self._broadcast( + BroadcastChatMessagePacket(type="chat_message", message=secondary_result.others_message, system=True), + exclude=client.websocket, + ) + await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id) + return + if isinstance(packet, ItemPianoNotePacket): item = self.items.get(packet.itemId) if not item or item.type != "piano": diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index 970aba5..9970e0f 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -102,6 +102,69 @@ async def test_radio_metadata_refresh_skips_when_no_listener_in_range(monkeypatc assert called is False +@pytest.mark.asyncio +async def test_item_secondary_use_radio_reports_now_playing(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=5) + server.clients[ws] = client + + radio = server.item_service.default_item(client, "radio_station") + radio.x = 5 + radio.y = 5 + radio.params["enabled"] = True + radio.params["stationName"] = "Station X" + radio.params["nowPlaying"] = "Song Y" + server.item_service.add_item(radio) + + send_payloads: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + return None + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + + await server._handle_message(client, json.dumps({"type": "item_secondary_use", "itemId": radio.id})) + + results = [packet for packet in send_payloads if getattr(packet, "type", "") == "item_action_result"] + assert results + assert results[-1].ok is True + assert results[-1].action == "secondary_use" + assert "Playing Song Y from Station X." in results[-1].message + + +@pytest.mark.asyncio +async def test_item_secondary_use_missing_handler_returns_message(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=5) + server.clients[ws] = client + + dice = server.item_service.default_item(client, "dice") + dice.x = 5 + dice.y = 5 + server.item_service.add_item(dice) + + send_payloads: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + + await server._handle_message(client, json.dumps({"type": "item_secondary_use", "itemId": dice.id})) + + results = [packet for packet in send_payloads if getattr(packet, "type", "") == "item_action_result"] + assert results + assert results[-1].ok is False + assert results[-1].action == "secondary_use" + assert "No secondary action" in results[-1].message + + @pytest.mark.asyncio async def test_auth_login_uses_hash_offload(monkeypatch: pytest.MonkeyPatch) -> None: server = SignalingServer("127.0.0.1", 8765, None, None)