portals-mcp 1.3.1 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/resources/index/items.json +3 -3
- package/dist/resources/index/triggers.json +2 -2
- package/dist/resources/python/README.md +11 -4
- package/dist/resources/python/lib/portals_core.py +5 -1
- package/dist/resources/python/lib/portals_trigger_zone_input.py +293 -0
- package/dist/resources/python/tools/simulate_trigger_zone_input.py +81 -0
- package/dist/resources/python/tools/validate_room.py +14 -1
- package/dist/resources/ref/items/trigger-zone.json +59 -56
- package/dist/resources/ref/pitfalls.json +5 -0
- package/dist/resources/ref/room-data-skeleton.json +67 -67
- package/dist/resources/reference/api-cheatsheet.md +3 -1
- package/dist/resources/reference/gotchas.md +2 -0
- package/dist/resources/reference/interactions.md +2 -0
- package/dist/resources/reference/items/gameplay.md +364 -362
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +82 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ npx portals-mcp@latest
|
|
|
53
53
|
| `get_room_data` | Download room snapshot to temp JSON |
|
|
54
54
|
| `inspect_room_data` | Summarize room-data counts, item types, variables, triggers/actions, and warnings without dumping full JSON |
|
|
55
55
|
| `simulate_key_input` | Audit and simulate `OnKeyPressedEvent` / `OnKeyReleasedEvent` mappings against a snapshot |
|
|
56
|
+
| `simulate_trigger_zone_input` | Audit and simulate Trigger `pressBtn` / `keyCode` press-inside-zone behavior |
|
|
56
57
|
| `query_room` | Query room data for specific items, logic, or structure |
|
|
57
58
|
| `update_room_settings` | Modify name, description, image, privacy, loading screens |
|
|
58
59
|
|
|
@@ -113,13 +113,13 @@
|
|
|
113
113
|
"category": "gameplay",
|
|
114
114
|
"key_fields": {
|
|
115
115
|
"events": "array — legacy events (usually [])",
|
|
116
|
-
"pressBtn": "bool — require key press",
|
|
117
|
-
"keyCode": "string — key to press (X, H, E)",
|
|
116
|
+
"pressBtn": "bool — require key press while inside before firing OnEnterEvent tasks",
|
|
117
|
+
"keyCode": "string — InputHandler key to press (X, H, E, Alpha1)",
|
|
118
118
|
"cm": "string — custom press message",
|
|
119
119
|
"opacity": "float — editor opacity"
|
|
120
120
|
},
|
|
121
121
|
"valid_triggers": ["OnEnterEvent", "OnExitEvent", "OnKeyPressedEvent", "OnKeyReleasedEvent", "OnPlayerLoggedIn", "ScoreTrigger", "OnTimerStopped", "OnCountdownTimerFinished", "OnAnimationStoppedEvent", "OnPlayerDied", "OnPlayerRevived", "PlayerLeave"],
|
|
122
|
-
"notes": "Invisible during play. NEVER use OnClickEvent/OnHoverStart/OnHoverEnd on triggers.",
|
|
122
|
+
"notes": "Invisible during play. NEVER use OnClickEvent/OnHoverStart/OnHoverEnd on triggers. pressBtn:true gates OnEnterEvent until keyCode is pressed while inside; it is not global OnKeyPressedEvent.",
|
|
123
123
|
"spec_uri": "docs://ref/items/trigger-zone"
|
|
124
124
|
},
|
|
125
125
|
{
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
"$type": "OnEnterEvent",
|
|
13
|
-
"description": "Player enters trigger zone",
|
|
13
|
+
"description": "Player enters trigger zone. If the Trigger item has pressBtn:true, this fires only after the player enters and presses the Trigger keyCode.",
|
|
14
14
|
"applicable_items": ["Trigger"],
|
|
15
|
-
"gotchas": ["ONLY works on Trigger cubes, not ResizableCubes or GLBs"]
|
|
15
|
+
"gotchas": ["ONLY works on Trigger cubes, not ResizableCubes or GLBs", "pressBtn:true gates this trigger by keyCode while inside the zone; use simulate_trigger_zone_input to audit."]
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"$type": "OnExitEvent",
|
|
@@ -11,6 +11,7 @@ The Python toolkit provides validation, room analysis, and query tools. It is bu
|
|
|
11
11
|
**Via dedicated MCP tools**:
|
|
12
12
|
- `query_room` — search items by type, position, triggers, text, parent, or quest reference
|
|
13
13
|
- `simulate_key_input` — audit and simulate keyboard triggers against a snapshot
|
|
14
|
+
- `simulate_trigger_zone_input` — audit and simulate Trigger `pressBtn` / `keyCode` press-inside-zone behavior
|
|
14
15
|
- `apply_operations` — add, modify, remove items/logic/quests (auto-pulls fresh data, auto-validates)
|
|
15
16
|
- `get_room_data` — download room data with auto-generated index
|
|
16
17
|
|
|
@@ -33,6 +34,11 @@ For bulk builds, pass hundreds of operations in a single `apply_operations` call
|
|
|
33
34
|
|
|
34
35
|
Use the `query_room` MCP tool to search items without loading the full room JSON:
|
|
35
36
|
```
|
|
37
|
+
query_room(roomId: "...", types: ["GLB"], hasTriggers: true)
|
|
38
|
+
query_room(roomId: "...", near: {x:10, y:0, z:15}, radius: 5)
|
|
39
|
+
query_room(roomId: "...", search: "door")
|
|
40
|
+
query_room(roomId: "...", parent: "12")
|
|
41
|
+
```
|
|
36
42
|
|
|
37
43
|
## Auditing Keyboard Triggers
|
|
38
44
|
|
|
@@ -43,10 +49,11 @@ simulate_key_input(filePath: ".../snapshot.json", actions: [{type: "press", key:
|
|
|
43
49
|
```
|
|
44
50
|
|
|
45
51
|
Keyboard trigger keys must use Portals/Unity key-code names such as `Alpha1`, `Space`, `Return`, `LeftShift`, and `LeftArrow`.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
|
|
53
|
+
Use `simulate_trigger_zone_input` for Trigger cube `pressBtn` behavior. This is not a global `OnKeyPressedEvent`; it gates the Trigger's `OnEnterEvent` tasks until the player enters the zone and presses `keyCode`.
|
|
54
|
+
```
|
|
55
|
+
simulate_trigger_zone_input(filePath: ".../snapshot.json", actions: [{type: "enter", itemId: "12"}, {type: "press", key: "X"}])
|
|
56
|
+
simulate_trigger_zone_input(filePath: ".../snapshot.json", actions: [{type: "press", key: "X"}, {type: "enter", itemId: "12"}])
|
|
50
57
|
```
|
|
51
58
|
|
|
52
59
|
## Scripts (manual / Blender)
|
|
@@ -16,6 +16,8 @@ Usage:
|
|
|
16
16
|
|
|
17
17
|
from typing import Dict, Tuple, Optional
|
|
18
18
|
|
|
19
|
+
from portals_key_input import validate_key_code
|
|
20
|
+
|
|
19
21
|
|
|
20
22
|
def create_base_item(
|
|
21
23
|
prefab_name: str,
|
|
@@ -350,12 +352,14 @@ def create_trigger(
|
|
|
350
352
|
Notes:
|
|
351
353
|
- Invisible during play (visible in build mode)
|
|
352
354
|
- Two trigger types: User Enter and User Exit
|
|
355
|
+
- If press_button=True, OnEnterEvent tasks fire after the player enters
|
|
356
|
+
the zone and presses key_code; this is separate from OnKeyPressedEvent.
|
|
353
357
|
- Add effects via Tasks array (see portals_effects.py)
|
|
354
358
|
"""
|
|
355
359
|
logic = {
|
|
356
360
|
"events": [],
|
|
357
361
|
"cm": message,
|
|
358
|
-
"keyCode": key_code,
|
|
362
|
+
"keyCode": validate_key_code(key_code, "key_code"),
|
|
359
363
|
"Tasks": [],
|
|
360
364
|
"ViewNodes": []
|
|
361
365
|
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Portals trigger-zone press-to-activate auditing and simulation helpers.
|
|
3
|
+
|
|
4
|
+
Mirrors Core/Assets/Core/Scripts/MultiplayerBridgeTrigger.cs:
|
|
5
|
+
- On enter, Trigger zones fire OnEnterEvent immediately unless pressBtn is true.
|
|
6
|
+
- With pressBtn true, the player must be inside the zone and press keyCode.
|
|
7
|
+
- Empty keyCode falls back to X in the client.
|
|
8
|
+
- OnExitEvent still fires when the player exits the zone.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
|
12
|
+
|
|
13
|
+
from portals_key_input import (
|
|
14
|
+
VALID_KEY_CODES,
|
|
15
|
+
VALID_KEY_CODE_SET,
|
|
16
|
+
canonical_key_code,
|
|
17
|
+
load_snapshot_logic,
|
|
18
|
+
validate_key_code,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ENTER_TRIGGER_TYPES = {"OnEnterEvent"}
|
|
23
|
+
EXIT_TRIGGER_TYPES = {"OnExitEvent", "UserExitTrigger"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _task_summary(item_id: str, task_index: int, task: Dict[str, Any], trigger: Dict[str, Any]) -> Dict[str, Any]:
|
|
27
|
+
direct = task.get("DirectEffector")
|
|
28
|
+
effector = direct.get("Effector") if isinstance(direct, dict) else None
|
|
29
|
+
return {
|
|
30
|
+
"item_id": item_id,
|
|
31
|
+
"task_index": task_index,
|
|
32
|
+
"task_id": task.get("Id"),
|
|
33
|
+
"task_name": task.get("Name", ""),
|
|
34
|
+
"task_trigger_id": task.get("TaskTriggerId"),
|
|
35
|
+
"target_state": task.get("TargetState"),
|
|
36
|
+
"trigger": trigger,
|
|
37
|
+
"direct_effector_id": direct.get("Id") if isinstance(direct, dict) else None,
|
|
38
|
+
"direct_effector_type": effector.get("$type") if isinstance(effector, dict) else None,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _collect_zone_tasks(item_id: str, entry: Dict[str, Any], trigger_types: Set[str]) -> List[Dict[str, Any]]:
|
|
43
|
+
tasks = entry.get("Tasks", [])
|
|
44
|
+
if not isinstance(tasks, list):
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
matches: List[Dict[str, Any]] = []
|
|
48
|
+
for task_index, task in enumerate(tasks):
|
|
49
|
+
if not isinstance(task, dict) or task.get("$type") != "TaskTriggerSubscription":
|
|
50
|
+
continue
|
|
51
|
+
trigger = task.get("Trigger")
|
|
52
|
+
if not isinstance(trigger, dict):
|
|
53
|
+
continue
|
|
54
|
+
if trigger.get("$type") in trigger_types:
|
|
55
|
+
matches.append(_task_summary(item_id, task_index, task, trigger))
|
|
56
|
+
return matches
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _collect_global_key_tasks(item_id: str, entry: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
60
|
+
tasks = entry.get("Tasks", [])
|
|
61
|
+
if not isinstance(tasks, list):
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
matches: List[Dict[str, Any]] = []
|
|
65
|
+
for task_index, task in enumerate(tasks):
|
|
66
|
+
if not isinstance(task, dict) or task.get("$type") != "TaskTriggerSubscription":
|
|
67
|
+
continue
|
|
68
|
+
trigger = task.get("Trigger")
|
|
69
|
+
if not isinstance(trigger, dict):
|
|
70
|
+
continue
|
|
71
|
+
if trigger.get("$type") in ("OnKeyPressedEvent", "OnKeyReleasedEvent"):
|
|
72
|
+
matches.append(_task_summary(item_id, task_index, task, trigger))
|
|
73
|
+
return matches
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def collect_trigger_zone_audit(
|
|
77
|
+
room_items: Dict[str, Any],
|
|
78
|
+
logic: Dict[str, Dict[str, Any]],
|
|
79
|
+
) -> Dict[str, Any]:
|
|
80
|
+
"""Collect Trigger-zone pressBtn/keyCode configuration and static warnings."""
|
|
81
|
+
zones: List[Dict[str, Any]] = []
|
|
82
|
+
warnings: List[str] = []
|
|
83
|
+
|
|
84
|
+
for item_id, item in room_items.items():
|
|
85
|
+
if not isinstance(item, dict) or item.get("prefabName") != "Trigger":
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
item_key = str(item_id)
|
|
89
|
+
entry = logic.get(item_key, {})
|
|
90
|
+
if not isinstance(entry, dict):
|
|
91
|
+
entry = {}
|
|
92
|
+
|
|
93
|
+
press_button_raw = entry.get("pressBtn", False)
|
|
94
|
+
press_button = press_button_raw is True
|
|
95
|
+
key_code_raw = entry.get("keyCode")
|
|
96
|
+
key_code = "" if key_code_raw is None else str(key_code_raw)
|
|
97
|
+
effective_key_code = "X" if key_code == "" else key_code
|
|
98
|
+
activation_tasks = _collect_zone_tasks(item_key, entry, ENTER_TRIGGER_TYPES)
|
|
99
|
+
exit_tasks = _collect_zone_tasks(item_key, entry, EXIT_TRIGGER_TYPES)
|
|
100
|
+
global_key_tasks = _collect_global_key_tasks(item_key, entry)
|
|
101
|
+
legacy_events = entry.get("events", [])
|
|
102
|
+
legacy_event_count = len(legacy_events) if isinstance(legacy_events, list) else 0
|
|
103
|
+
|
|
104
|
+
zone_warnings: List[str] = []
|
|
105
|
+
key_valid = True
|
|
106
|
+
if "pressBtn" in entry and not isinstance(press_button_raw, bool):
|
|
107
|
+
zone_warnings.append(f'pressBtn must be a boolean, got {type(press_button_raw).__name__}.')
|
|
108
|
+
|
|
109
|
+
if key_code_raw is None:
|
|
110
|
+
key_valid = False
|
|
111
|
+
zone_warnings.append('Trigger extraData missing required "keyCode"; upload validation rejects this.')
|
|
112
|
+
elif not isinstance(key_code_raw, str):
|
|
113
|
+
key_valid = False
|
|
114
|
+
zone_warnings.append(f'keyCode must be a string key-code name, got {type(key_code_raw).__name__}.')
|
|
115
|
+
elif key_code and key_code not in VALID_KEY_CODE_SET:
|
|
116
|
+
key_valid = False
|
|
117
|
+
suggestion = canonical_key_code(key_code)
|
|
118
|
+
suffix = f' Did you mean "{suggestion}"?' if suggestion != key_code and suggestion in VALID_KEY_CODE_SET else ""
|
|
119
|
+
zone_warnings.append(f'keyCode "{key_code}" is not in InputHandler.keyCodeMap.{suffix}')
|
|
120
|
+
|
|
121
|
+
if press_button:
|
|
122
|
+
if not activation_tasks and legacy_event_count == 0:
|
|
123
|
+
zone_warnings.append("pressBtn is true but the Trigger has no OnEnterEvent tasks or legacy events to activate.")
|
|
124
|
+
if global_key_tasks:
|
|
125
|
+
zone_warnings.append(
|
|
126
|
+
"This Trigger also has OnKeyPressedEvent/OnKeyReleasedEvent tasks. Those are global key subscriptions, "
|
|
127
|
+
"not zone-gated; use pressBtn + OnEnterEvent for press-inside-zone behavior."
|
|
128
|
+
)
|
|
129
|
+
elif key_code not in ("", "X") and key_valid:
|
|
130
|
+
zone_warnings.append('keyCode is ignored while pressBtn is false; OnEnterEvent fires immediately on zone entry.')
|
|
131
|
+
|
|
132
|
+
zone = {
|
|
133
|
+
"item_id": item_key,
|
|
134
|
+
"title": entry.get("title", ""),
|
|
135
|
+
"press_button": press_button,
|
|
136
|
+
"key_code": key_code,
|
|
137
|
+
"effective_key_code": effective_key_code,
|
|
138
|
+
"key_valid": key_valid,
|
|
139
|
+
"message": entry.get("cm", ""),
|
|
140
|
+
"behavior": "press_key_while_inside" if press_button else "auto_on_enter",
|
|
141
|
+
"activation_tasks": activation_tasks,
|
|
142
|
+
"activation_task_count": len(activation_tasks),
|
|
143
|
+
"exit_tasks": exit_tasks,
|
|
144
|
+
"exit_task_count": len(exit_tasks),
|
|
145
|
+
"legacy_event_count": legacy_event_count,
|
|
146
|
+
"global_key_tasks": global_key_tasks,
|
|
147
|
+
"global_key_task_count": len(global_key_tasks),
|
|
148
|
+
"warnings": zone_warnings,
|
|
149
|
+
}
|
|
150
|
+
zones.append(zone)
|
|
151
|
+
for warning in zone_warnings:
|
|
152
|
+
warnings.append(f"item {item_key}, Trigger: {warning}")
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"zones": zones,
|
|
156
|
+
"warnings": warnings,
|
|
157
|
+
"valid_key_codes": list(VALID_KEY_CODES),
|
|
158
|
+
"semantics": {
|
|
159
|
+
"auto_on_enter": "pressBtn false or omitted: entering the Trigger calls OnEnterEvent tasks immediately.",
|
|
160
|
+
"press_key_while_inside": "pressBtn true: entering only arms the zone; pressing keyCode while still inside calls OnEnterEvent tasks.",
|
|
161
|
+
"exit": "OnExitEvent tasks fire on exit even when pressBtn is true.",
|
|
162
|
+
"not_global_key": "pressBtn does not create OnKeyPressedEvent subscriptions; OnKeyPressedEvent remains global and is not zone-gated.",
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _normalize_zone_action(action: Any) -> Dict[str, str]:
|
|
168
|
+
if isinstance(action, str):
|
|
169
|
+
if ":" not in action:
|
|
170
|
+
raise ValueError(f'Action string "{action}" must be "enter:<itemId>", "exit:<itemId>", "press:<key>", or "release:<key>"')
|
|
171
|
+
action_type, value = action.split(":", 1)
|
|
172
|
+
key = "key" if action_type.lower() in ("press", "release") else "itemId"
|
|
173
|
+
action = {"type": action_type, key: value}
|
|
174
|
+
if not isinstance(action, dict):
|
|
175
|
+
raise ValueError(f"Action must be a dict or action string, got {type(action).__name__}")
|
|
176
|
+
|
|
177
|
+
action_type = str(action.get("type", "")).lower()
|
|
178
|
+
if action_type in ("enter", "exit"):
|
|
179
|
+
item_id = action.get("itemId", action.get("item_id", action.get("id", "")))
|
|
180
|
+
if item_id in (None, ""):
|
|
181
|
+
raise ValueError(f'{action_type} action requires "itemId"')
|
|
182
|
+
return {"type": action_type, "item_id": str(item_id)}
|
|
183
|
+
if action_type in ("press", "release"):
|
|
184
|
+
key = validate_key_code(str(action.get("key", "")), "action.key")
|
|
185
|
+
return {"type": action_type, "key": key}
|
|
186
|
+
|
|
187
|
+
raise ValueError(f'Action type must be "enter", "exit", "press", or "release", got "{action_type}"')
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def simulate_trigger_zone_sequence(
|
|
191
|
+
room_items: Dict[str, Any],
|
|
192
|
+
logic: Dict[str, Dict[str, Any]],
|
|
193
|
+
actions: Iterable[Any],
|
|
194
|
+
) -> Dict[str, Any]:
|
|
195
|
+
"""Simulate zone enter/exit plus key frames and report matching OnEnter/OnExit tasks."""
|
|
196
|
+
audit = collect_trigger_zone_audit(room_items, logic)
|
|
197
|
+
zones_by_id = {zone["item_id"]: zone for zone in audit["zones"]}
|
|
198
|
+
normalized_actions = [_normalize_zone_action(action) for action in actions]
|
|
199
|
+
|
|
200
|
+
inside: Set[str] = set()
|
|
201
|
+
armed: Set[str] = set()
|
|
202
|
+
held: Set[str] = set()
|
|
203
|
+
frames: List[Dict[str, Any]] = []
|
|
204
|
+
|
|
205
|
+
for index, action in enumerate(normalized_actions):
|
|
206
|
+
fired: List[Dict[str, Any]] = []
|
|
207
|
+
prompts: List[Dict[str, Any]] = []
|
|
208
|
+
ignored: List[str] = []
|
|
209
|
+
key_down: Set[str] = set()
|
|
210
|
+
key_up: Set[str] = set()
|
|
211
|
+
|
|
212
|
+
action_type = action["type"]
|
|
213
|
+
if action_type == "enter":
|
|
214
|
+
item_id = action["item_id"]
|
|
215
|
+
zone = zones_by_id.get(item_id)
|
|
216
|
+
if not zone:
|
|
217
|
+
ignored.append(f'item "{item_id}" is not a Trigger zone in this snapshot')
|
|
218
|
+
else:
|
|
219
|
+
inside.add(item_id)
|
|
220
|
+
if zone["press_button"]:
|
|
221
|
+
armed.add(item_id)
|
|
222
|
+
prompts.append({
|
|
223
|
+
"item_id": item_id,
|
|
224
|
+
"message": zone["message"],
|
|
225
|
+
"key": zone["effective_key_code"],
|
|
226
|
+
})
|
|
227
|
+
else:
|
|
228
|
+
fired.extend(zone["activation_tasks"])
|
|
229
|
+
elif action_type == "exit":
|
|
230
|
+
item_id = action["item_id"]
|
|
231
|
+
zone = zones_by_id.get(item_id)
|
|
232
|
+
if not zone:
|
|
233
|
+
ignored.append(f'item "{item_id}" is not a Trigger zone in this snapshot')
|
|
234
|
+
elif item_id not in inside:
|
|
235
|
+
ignored.append(f'item "{item_id}" exit ignored because the simulated player is not inside it')
|
|
236
|
+
else:
|
|
237
|
+
fired.extend(zone["exit_tasks"])
|
|
238
|
+
inside.remove(item_id)
|
|
239
|
+
armed.discard(item_id)
|
|
240
|
+
elif action_type == "press":
|
|
241
|
+
key = action["key"]
|
|
242
|
+
if key not in held:
|
|
243
|
+
held.add(key)
|
|
244
|
+
key_down.add(key)
|
|
245
|
+
for item_id in sorted(armed):
|
|
246
|
+
zone = zones_by_id[item_id]
|
|
247
|
+
if not zone["key_valid"]:
|
|
248
|
+
continue
|
|
249
|
+
if zone["effective_key_code"] == key:
|
|
250
|
+
fired.extend(zone["activation_tasks"])
|
|
251
|
+
armed.remove(item_id)
|
|
252
|
+
else:
|
|
253
|
+
ignored.append(f'key "{key}" is already held; Unity GetKeyDown would be false')
|
|
254
|
+
elif action_type == "release":
|
|
255
|
+
key = action["key"]
|
|
256
|
+
if key in held:
|
|
257
|
+
held.remove(key)
|
|
258
|
+
key_up.add(key)
|
|
259
|
+
else:
|
|
260
|
+
ignored.append(f'key "{key}" was not held; Unity GetKeyUp would be false')
|
|
261
|
+
|
|
262
|
+
frames.append({
|
|
263
|
+
"index": index,
|
|
264
|
+
"action": action,
|
|
265
|
+
"key_down": sorted(key_down),
|
|
266
|
+
"key_up": sorted(key_up),
|
|
267
|
+
"held_after": sorted(held),
|
|
268
|
+
"inside_after": sorted(inside),
|
|
269
|
+
"armed_after": sorted(armed),
|
|
270
|
+
"prompts": prompts,
|
|
271
|
+
"fired": fired,
|
|
272
|
+
"ignored": ignored,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
fired_count = sum(len(frame["fired"]) for frame in frames)
|
|
276
|
+
return {
|
|
277
|
+
**audit,
|
|
278
|
+
"simulation": {
|
|
279
|
+
"actions": normalized_actions,
|
|
280
|
+
"frames": frames,
|
|
281
|
+
"fired_count": fired_count,
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def audit_snapshot_trigger_zones(snapshot_path: str, actions: Optional[Iterable[Any]] = None) -> Dict[str, Any]:
|
|
287
|
+
room_items, logic = load_snapshot_logic(snapshot_path)
|
|
288
|
+
if actions is None:
|
|
289
|
+
return {
|
|
290
|
+
**collect_trigger_zone_audit(room_items, logic),
|
|
291
|
+
"simulation": None,
|
|
292
|
+
}
|
|
293
|
+
return simulate_trigger_zone_sequence(room_items, logic, actions)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
simulate_trigger_zone_input.py - Audit and simulate Trigger pressBtn/keyCode behavior.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 simulate_trigger_zone_input.py snapshot.json
|
|
7
|
+
python3 simulate_trigger_zone_input.py snapshot.json --action enter:12 --action press:X --action exit:12
|
|
8
|
+
python3 simulate_trigger_zone_input.py snapshot.json --actions actions.json
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, List
|
|
16
|
+
|
|
17
|
+
PROJECT_ROOT = Path(__file__).parent.parent
|
|
18
|
+
sys.path.insert(0, str(PROJECT_ROOT / "lib"))
|
|
19
|
+
|
|
20
|
+
from portals_key_input import VALID_KEY_CODES
|
|
21
|
+
from portals_trigger_zone_input import audit_snapshot_trigger_zones
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_actions(path: str) -> List[Any]:
|
|
25
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
26
|
+
actions = json.load(f)
|
|
27
|
+
if not isinstance(actions, list):
|
|
28
|
+
raise ValueError("--actions file must contain a JSON array")
|
|
29
|
+
return actions
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
description="Audit Portals Trigger pressBtn/keyCode behavior and simulate enter/exit/key input.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("snapshot", nargs="?", help="Path to snapshot.json")
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--action",
|
|
39
|
+
action="append",
|
|
40
|
+
default=[],
|
|
41
|
+
help='Action to simulate, e.g. "enter:12", "press:X", "release:X", or "exit:12". May be repeated.',
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--actions",
|
|
45
|
+
help='Path to a JSON array of actions, e.g. [{"type":"enter","itemId":"12"},{"type":"press","key":"X"}].',
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.")
|
|
48
|
+
parser.add_argument("--list-keys", action="store_true", help="List valid key-code strings and exit.")
|
|
49
|
+
|
|
50
|
+
args = parser.parse_args()
|
|
51
|
+
|
|
52
|
+
if args.list_keys:
|
|
53
|
+
print(json.dumps({"ok": True, "valid_key_codes": list(VALID_KEY_CODES)}, indent=2 if args.pretty else None))
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if not args.snapshot:
|
|
57
|
+
print(json.dumps({"ok": False, "error": "snapshot path is required unless --list-keys is used"}))
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
if not Path(args.snapshot).is_file():
|
|
61
|
+
print(json.dumps({"ok": False, "error": f"Snapshot file not found: {args.snapshot}"}))
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
actions = None
|
|
66
|
+
if args.actions:
|
|
67
|
+
actions = _load_actions(args.actions)
|
|
68
|
+
if args.action:
|
|
69
|
+
actions = (actions or []) + args.action
|
|
70
|
+
|
|
71
|
+
result = audit_snapshot_trigger_zones(args.snapshot, actions)
|
|
72
|
+
result["ok"] = True
|
|
73
|
+
result["snapshot"] = str(Path(args.snapshot).resolve())
|
|
74
|
+
print(json.dumps(result, indent=2 if args.pretty else None))
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -24,7 +24,7 @@ sys.path.insert(0, str(PROJECT_ROOT / "lib"))
|
|
|
24
24
|
|
|
25
25
|
from portals_utils import validate_quest_name, validate_color, parse_extra_data, normalize_snapshot
|
|
26
26
|
from portals_effects import EFFECT_TYPES, TRIGGER_TYPES
|
|
27
|
-
from portals_key_input import VALID_KEY_CODE_SET
|
|
27
|
+
from portals_key_input import VALID_KEY_CODE_SET, canonical_key_code
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
# ============================================================================
|
|
@@ -573,6 +573,19 @@ def validate_item_extra_data(item_key: str, prefab: str, extra: dict, content_st
|
|
|
573
573
|
if key not in extra:
|
|
574
574
|
errors.append(fmt(section, f'extraData missing required key "{key}"'))
|
|
575
575
|
|
|
576
|
+
if prefab == "Trigger":
|
|
577
|
+
if "pressBtn" in extra and not isinstance(extra["pressBtn"], bool):
|
|
578
|
+
errors.append(fmt(section, f'extraData "pressBtn" must be a boolean, got {type(extra["pressBtn"]).__name__}'))
|
|
579
|
+
|
|
580
|
+
key_code = extra.get("keyCode")
|
|
581
|
+
if key_code is not None:
|
|
582
|
+
if not isinstance(key_code, str):
|
|
583
|
+
errors.append(fmt(section, f'extraData "keyCode" must be a string InputHandler key-code name, got {type(key_code).__name__}'))
|
|
584
|
+
elif key_code and key_code not in VALID_KEY_CODE_SET:
|
|
585
|
+
suggestion = canonical_key_code(key_code)
|
|
586
|
+
suffix = f' Did you mean "{suggestion}"?' if suggestion != key_code and suggestion in VALID_KEY_CODE_SET else ""
|
|
587
|
+
errors.append(fmt(section, f'extraData "keyCode" "{key_code}" is not in InputHandler.keyCodeMap.{suffix}'))
|
|
588
|
+
|
|
576
589
|
# Addressable VFX validation
|
|
577
590
|
if prefab == "Addressable":
|
|
578
591
|
if not content_string:
|
|
@@ -1,56 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "item:trigger-zone",
|
|
3
|
-
"name": "Trigger Zone",
|
|
4
|
-
"prefabName": "Trigger",
|
|
5
|
-
"category": "gameplay",
|
|
6
|
-
"description": "Invisible zone that activates
|
|
7
|
-
"placement": {
|
|
8
|
-
"default_y": 0.5,
|
|
9
|
-
"notes": "Same placement as cubes. y=0.5 for a 1x1x1 trigger on the ground. Scale to cover the desired area."
|
|
10
|
-
},
|
|
11
|
-
"fields": {
|
|
12
|
-
"pos": { "type": "Vector3", "desc": "World position", "default": {"x": 0, "y": 0.5, "z": 0} },
|
|
13
|
-
"rot": { "type": "Quaternion", "desc": "Rotation quaternion", "default": {"x": 0, "y": 0, "z": 0, "w": 1} },
|
|
14
|
-
"scale": { "type": "Vector3", "desc": "Zone dimensions. Scale up for larger detection area.", "default": {"x": 1, "y": 1, "z": 1} }
|
|
15
|
-
},
|
|
16
|
-
"extra_data_fields": {
|
|
17
|
-
"events": { "type": "array", "desc": "Array of events. Usually [] when using Tasks.", "default": [] },
|
|
18
|
-
"pressBtn": { "type": "bool", "desc": "true =
|
|
19
|
-
"keyCode": { "type": "string", "desc": "
|
|
20
|
-
"cm": { "type": "string", "desc": "Custom message. Shows 'Press [key] to [message]'.", "default": "" },
|
|
21
|
-
"opacity": { "type": "float", "desc": "Visual opacity in editor only (0.0-1.0). Does NOT affect gameplay -- triggers are always invisible during play.", "default": null }
|
|
22
|
-
},
|
|
23
|
-
"valid_triggers": [
|
|
24
|
-
"OnEnterEvent",
|
|
25
|
-
"OnExitEvent",
|
|
26
|
-
"OnPlayerLoggedIn",
|
|
27
|
-
"OnKeyPressedEvent",
|
|
28
|
-
"OnKeyReleasedEvent"
|
|
29
|
-
],
|
|
30
|
-
"gotchas": [
|
|
31
|
-
"NEVER use OnClickEvent, OnHoverStartEvent, or OnHoverEndEvent -- trigger zones are INVISIBLE during play, so players cannot click or hover over them.",
|
|
32
|
-
"OnEnterEvent and OnExitEvent ONLY work on Trigger items. They do NOT work on ResizableCubes, GLBs, or other item types.",
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
1
|
+
{
|
|
2
|
+
"id": "item:trigger-zone",
|
|
3
|
+
"name": "Trigger Zone",
|
|
4
|
+
"prefabName": "Trigger",
|
|
5
|
+
"category": "gameplay",
|
|
6
|
+
"description": "Invisible zone that activates OnEnterEvent tasks immediately on entry, or with pressBtn:true after the player enters and presses keyCode. Invisible during play -- visible only in build mode.",
|
|
7
|
+
"placement": {
|
|
8
|
+
"default_y": 0.5,
|
|
9
|
+
"notes": "Same placement as cubes. y=0.5 for a 1x1x1 trigger on the ground. Scale to cover the desired area."
|
|
10
|
+
},
|
|
11
|
+
"fields": {
|
|
12
|
+
"pos": { "type": "Vector3", "desc": "World position", "default": {"x": 0, "y": 0.5, "z": 0} },
|
|
13
|
+
"rot": { "type": "Quaternion", "desc": "Rotation quaternion", "default": {"x": 0, "y": 0, "z": 0, "w": 1} },
|
|
14
|
+
"scale": { "type": "Vector3", "desc": "Zone dimensions. Scale up for larger detection area.", "default": {"x": 1, "y": 1, "z": 1} }
|
|
15
|
+
},
|
|
16
|
+
"extra_data_fields": {
|
|
17
|
+
"events": { "type": "array", "desc": "Array of events. Usually [] when using Tasks.", "default": [] },
|
|
18
|
+
"pressBtn": { "type": "bool", "desc": "true = arm OnEnterEvent on zone entry and fire it only when the player presses keyCode while still inside. Omit/false for immediate OnEnterEvent on entry.", "default": false },
|
|
19
|
+
"keyCode": { "type": "string", "desc": "InputHandler key-code string to press (e.g. 'X', 'H', 'E', 'Alpha1'). Only used when pressBtn is true; empty falls back to X in the client.", "default": "X" },
|
|
20
|
+
"cm": { "type": "string", "desc": "Custom message. Shows 'Press [key] to [message]'.", "default": "" },
|
|
21
|
+
"opacity": { "type": "float", "desc": "Visual opacity in editor only (0.0-1.0). Does NOT affect gameplay -- triggers are always invisible during play.", "default": null }
|
|
22
|
+
},
|
|
23
|
+
"valid_triggers": [
|
|
24
|
+
"OnEnterEvent",
|
|
25
|
+
"OnExitEvent",
|
|
26
|
+
"OnPlayerLoggedIn",
|
|
27
|
+
"OnKeyPressedEvent",
|
|
28
|
+
"OnKeyReleasedEvent"
|
|
29
|
+
],
|
|
30
|
+
"gotchas": [
|
|
31
|
+
"NEVER use OnClickEvent, OnHoverStartEvent, or OnHoverEndEvent -- trigger zones are INVISIBLE during play, so players cannot click or hover over them.",
|
|
32
|
+
"OnEnterEvent and OnExitEvent ONLY work on Trigger items. They do NOT work on ResizableCubes, GLBs, or other item types.",
|
|
33
|
+
"pressBtn:true gates the Trigger's OnEnterEvent tasks; it is NOT the same as adding an OnKeyPressedEvent task, which is a global key subscription.",
|
|
34
|
+
"With pressBtn:true, holding the key before entering does not activate the zone; the key must go down while the player is inside.",
|
|
35
|
+
"OnExitEvent still fires on exit when pressBtn is true.",
|
|
36
|
+
"Use ActivateTriggerZoneEffect / DeactivateTriggerZoneEffect to enable/disable the zone dynamically.",
|
|
37
|
+
"For click/hover interactions, use a visible item (ResizableCube, GLB) instead."
|
|
38
|
+
],
|
|
39
|
+
"keyword_map": {
|
|
40
|
+
"_doc": "Mapping from extraData field names to apply_operations Python keywords",
|
|
41
|
+
"pressBtn": "press_button",
|
|
42
|
+
"keyCode": "key_code",
|
|
43
|
+
"cm": "message"
|
|
44
|
+
},
|
|
45
|
+
"create_example": "create_trigger(pos=(0, 0.5, 0), scale=(3, 2, 3), press_button=True, key_code='X', message='activate switch')",
|
|
46
|
+
"json_example": {
|
|
47
|
+
"prefabName": "Trigger",
|
|
48
|
+
"pos": {"x": 0, "y": 0.5, "z": 0},
|
|
49
|
+
"rot": {"x": 0, "y": 0, "z": 0, "w": 1},
|
|
50
|
+
"scale": {"x": 3, "y": 2, "z": 3},
|
|
51
|
+
"extraData": "{\"events\":[],\"pressBtn\":true,\"cm\":\"activate\",\"keyCode\":\"X\",\"Tasks\":[],\"ViewNodes\":[]}",
|
|
52
|
+
"placed": true
|
|
53
|
+
},
|
|
54
|
+
"see_also": [
|
|
55
|
+
"docs://ref/items/cube",
|
|
56
|
+
"docs://ref/interactions/basic",
|
|
57
|
+
"docs://ref/interactions/quest-driven"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
@@ -98,6 +98,11 @@
|
|
|
98
98
|
"title": "OnEnterEvent/OnExitEvent only on Trigger cubes",
|
|
99
99
|
"detail": "OnEnterEvent and OnExitEvent ONLY work on Trigger cubes (prefabName: 'Trigger'). They do NOT work on ResizableCubes, GLBs, or other item types."
|
|
100
100
|
},
|
|
101
|
+
{
|
|
102
|
+
"category": "triggers",
|
|
103
|
+
"title": "Trigger pressBtn gates OnEnterEvent",
|
|
104
|
+
"detail": "Trigger pressBtn:true means entering the zone arms the Trigger and pressing keyCode while still inside runs the Trigger's OnEnterEvent tasks. It is not the same as OnKeyPressedEvent, which is global and not zone-gated."
|
|
105
|
+
},
|
|
101
106
|
{
|
|
102
107
|
"category": "glb",
|
|
103
108
|
"title": "External texture references",
|