Add Shift+Enter secondary item action with radio handler

This commit is contained in:
Jage9
2026-02-25 01:11:47 -05:00
parent 6fa588c684
commit 08d74b8e2c
18 changed files with 193 additions and 11 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -19,6 +19,7 @@ export type MainModeCommand =
| 'openMicGainEdit' | 'openMicGainEdit'
| 'calibrateMicrophone' | 'calibrateMicrophone'
| 'useItem' | 'useItem'
| 'secondaryUseItem'
| 'speakUsers' | 'speakUsers'
| 'addItem' | 'addItem'
| 'locateOrListItems' | 'locateOrListItems'
@@ -51,7 +52,7 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod
if (code === 'NumpadSubtract') return 'masterVolumeDown'; if (code === 'NumpadSubtract') return 'masterVolumeDown';
if (code === 'KeyC') return shiftKey ? null : 'speakCoordinates'; if (code === 'KeyC') return shiftKey ? null : 'speakCoordinates';
if (code === 'KeyV') return shiftKey ? 'calibrateMicrophone' : 'openMicGainEdit'; 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 === 'KeyU') return shiftKey ? null : 'speakUsers';
if (code === 'KeyA') return shiftKey ? null : 'addItem'; if (code === 'KeyA') return shiftKey ? null : 'addItem';
if (code === 'KeyI') return 'locateOrListItems'; if (code === 'KeyI') return 'locateOrListItems';

View File

@@ -1021,6 +1021,11 @@ function useItem(item: WorldItem): void {
signaling.send({ type: 'item_use', itemId: item.id }); 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. */ /** Opens option-list selection mode for list-based item properties. */
function openItemPropertyOptionSelect(item: WorldItem, key: string): void { function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
const options = getItemPropertyOptionValues(item.type, key); const options = getItemPropertyOptionValues(item.type, key);
@@ -1825,6 +1830,26 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
beginItemSelection('use', usable); beginItemSelection('use', usable);
return; 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': { case 'speakUsers': {
const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((p) => p.nickname)]; const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((p) => p.nickname)];
const label = allUsers.length === 1 ? 'user' : 'users'; const label = allUsers.length === 1 ? 'user' : 'users';
@@ -2383,6 +2408,10 @@ function handleSelectItemModeInput(code: string, key: string): void {
useItem(selected); useItem(selected);
return; return;
} }
if (context === 'secondaryUse') {
secondaryUseItem(selected);
return;
}
if (context === 'inspect') { if (context === 'inspect') {
beginItemProperties(selected, true); beginItemProperties(selected, true);
return; return;

View File

@@ -256,10 +256,10 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
break; break;
} }
if (message.ok) { if (message.ok) {
if (message.action === 'use') { if (message.action === 'use' || message.action === 'secondary_use') {
deps.pushChatMessage(message.message); deps.pushChatMessage(message.message);
const item = message.itemId ? deps.getItemById(message.itemId) : null; 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); deps.playLocateToneAt(item.x, item.y);
} }
} else if (message.action !== 'update') { } else if (message.action !== 'update') {

View File

@@ -202,7 +202,7 @@ export const itemRemoveSchema = z.object({
export const itemActionResultSchema = z.object({ export const itemActionResultSchema = z.object({
type: z.literal('item_action_result'), type: z.literal('item_action_result'),
ok: z.boolean(), ok: z.boolean(),
action: z.enum(['add', 'pickup', 'drop', 'delete', 'use', 'update']), action: z.enum(['add', 'pickup', 'drop', 'delete', 'use', 'secondary_use', 'update']),
message: z.string(), message: z.string(),
itemId: z.string().optional(), itemId: z.string().optional(),
}); });
@@ -287,6 +287,7 @@ export type OutgoingMessage =
| { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_drop'; itemId: string; x: number; y: number }
| { type: 'item_delete'; itemId: string } | { type: 'item_delete'; itemId: string }
| { type: 'item_use'; 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_note'; itemId: string; keyId: string; midi: number; on: boolean }
| { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' } | { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' }
| { | {

View File

@@ -21,7 +21,7 @@ export type WorldItem = {
carrierId?: string | null; 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 = export type GameMode =
| 'normal' | 'normal'

View File

@@ -28,6 +28,7 @@ This document is the authoritative keymap for the client.
- `D`: Pick up/drop item - `D`: Pick up/drop item
- `Shift+D`: Delete item - `Shift+D`: Delete item
- `Enter`: Use item - `Enter`: Use item
- `Shift+Enter`: Secondary item action
### Audio ### Audio
- `P`: Ping server - `P`: Ping server

View File

@@ -38,6 +38,7 @@ This is behavior-focused documentation for item types and their defaults.
### Use ### Use
- `use` toggles `enabled` on/off and broadcasts chat status. - `use` toggles `enabled` on/off and broadcasts chat status.
- `secondary use` reports now-playing metadata (`Playing <song> from <station>`), or `<title> is off` when disabled.
### Validation ### Validation
- `mediaChannel`: `stereo | mono | left | right` - `mediaChannel`: `stereo | mono | left | right`

View File

@@ -20,6 +20,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `chat_message`: player chat. - `chat_message`: player chat.
- `ping`: latency measurement. - `ping`: latency measurement.
- `item_add`, `item_pickup`, `item_drop`, `item_delete`, `item_use`, `item_update`: item actions. - `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_note`: realtime piano note on/off for active piano use mode.
- `item_piano_recording`: piano record/playback control (`toggle_record`, `playback`, `stop_playback`). - `item_piano_recording`: piano record/playback control (`toggle_record`, `playback`, `stop_playback`).

View File

@@ -7,7 +7,11 @@ from .items.registry import ITEM_MODULES
from .item_types import ItemTypeHandler from .item_types import ItemTypeHandler
ITEM_TYPE_HANDLERS: dict[ItemType, 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() for item_type, module in ITEM_MODULES.items()
} }

View File

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

View File

@@ -28,6 +28,7 @@ class ItemModule(Protocol):
PROPERTY_METADATA: dict[str, dict[str, object]] PROPERTY_METADATA: dict[str, dict[str, object]]
validate_update: Callable[[WorldItem, dict], dict] validate_update: Callable[[WorldItem, dict], dict]
use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
secondary_use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -6,7 +6,7 @@ from types import SimpleNamespace
from typing import Any 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.""" """Compose a plugin module-like object from split definition/validator/actions files."""
exports: dict[str, Any] = { 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["validate_update"] = validate_update
exports["use_item"] = use_item exports["use_item"] = use_item
if secondary_use_item is not None:
exports["secondary_use_item"] = secondary_use_item
return SimpleNamespace(**exports) return SimpleNamespace(**exports)

View File

@@ -19,3 +19,25 @@ def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict],
others_message=f"{nickname} turns {state_text} {item.title}.", others_message=f"{nickname} turns {state_text} {item.title}.",
updated_params={**item.params, "enabled": next_enabled}, 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)

View File

@@ -8,5 +8,10 @@ from . import actions, definition, validator
ITEM_TYPE_PLUGIN = { ITEM_TYPE_PLUGIN = {
"type": "radio_station", "type": "radio_station",
"order": 40, "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,
),
} }

View File

@@ -95,6 +95,11 @@ class ItemUsePacket(BasePacket):
itemId: str itemId: str
class ItemSecondaryUsePacket(BasePacket):
type: Literal["item_secondary_use"]
itemId: str
class ItemPianoNotePacket(BasePacket): class ItemPianoNotePacket(BasePacket):
type: Literal["item_piano_note"] type: Literal["item_piano_note"]
itemId: str itemId: str
@@ -132,6 +137,7 @@ ClientPacket = (
| ItemDropPacket | ItemDropPacket
| ItemDeletePacket | ItemDeletePacket
| ItemUsePacket | ItemUsePacket
| ItemSecondaryUsePacket
| ItemPianoNotePacket | ItemPianoNotePacket
| ItemPianoRecordingPacket | ItemPianoRecordingPacket
| ItemUpdatePacket | ItemUpdatePacket
@@ -275,7 +281,7 @@ class ItemRemovePacket(BasePacket):
class ItemActionResultPacket(BasePacket): class ItemActionResultPacket(BasePacket):
type: Literal["item_action_result"] type: Literal["item_action_result"]
ok: bool ok: bool
action: Literal["add", "pickup", "drop", "delete", "use", "update"] action: Literal["add", "pickup", "drop", "delete", "use", "secondary_use", "update"]
message: str message: str
itemId: str | None = None itemId: str | None = None

View File

@@ -68,6 +68,7 @@ from .models import (
ItemPianoStatusPacket, ItemPianoStatusPacket,
ItemPickupPacket, ItemPickupPacket,
ItemRemovePacket, ItemRemovePacket,
ItemSecondaryUsePacket,
ItemUpdatePacket, ItemUpdatePacket,
ItemUpsertPacket, ItemUpsertPacket,
ItemUsePacket, ItemUsePacket,
@@ -876,7 +877,7 @@ class SignalingServer:
self, self,
client: ClientConnection, client: ClientConnection,
ok: bool, ok: bool,
action: Literal["add", "pickup", "drop", "delete", "use", "update"], action: Literal["add", "pickup", "drop", "delete", "use", "secondary_use", "update"],
message: str, message: str,
item_id: str | None = None, item_id: str | None = None,
) -> None: ) -> None:
@@ -1671,6 +1672,49 @@ class SignalingServer:
) )
return 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): if isinstance(packet, ItemPianoNotePacket):
item = self.items.get(packet.itemId) item = self.items.get(packet.itemId)
if not item or item.type != "piano": if not item or item.type != "piano":

View File

@@ -102,6 +102,69 @@ async def test_radio_metadata_refresh_skips_when_no_listener_in_range(monkeypatc
assert called is False 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 @pytest.mark.asyncio
async def test_auth_login_uses_hash_offload(monkeypatch: pytest.MonkeyPatch) -> None: async def test_auth_login_uses_hash_offload(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None) server = SignalingServer("127.0.0.1", 8765, None, None)