@hypersoniclabs/helix-mcp 0.2.4 → 0.2.12
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 +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
|
@@ -30,7 +30,7 @@ king-of-the-hill, domination, territory control, capture points, objective races
|
|
|
30
30
|
"slug": "team-control",
|
|
31
31
|
"entry": "index.html",
|
|
32
32
|
"maxPlayers": 8,
|
|
33
|
-
"permissions": ["auth.profile", "multiplayer"],
|
|
33
|
+
"permissions": ["auth.profile", "multiplayer", "voice.proximity"],
|
|
34
34
|
"multiplayer": {
|
|
35
35
|
"authoritative": true,
|
|
36
36
|
"state": {
|
|
@@ -65,7 +65,7 @@ king-of-the-hill, domination, territory control, capture points, objective races
|
|
|
65
65
|
},
|
|
66
66
|
"supportsMobile": true,
|
|
67
67
|
"contentRating": "everyone",
|
|
68
|
-
"systems": { "humanoid-character": "^0.
|
|
68
|
+
"systems": { "humanoid-character": "^0.3" }
|
|
69
69
|
}
|
|
70
70
|
```
|
|
71
71
|
|
|
@@ -74,25 +74,38 @@ no health**. `varReached` fires once on the edge (false→true), so each point s
|
|
|
74
74
|
|
|
75
75
|
## 3. The client — `src/main.ts` (delta from `hangout`)
|
|
76
76
|
|
|
77
|
-
Presence + team selection + a control-meter HUD
|
|
77
|
+
Presence + team selection + a control-meter HUD, all through the typed accessors. Team-join is a
|
|
78
|
+
custom action PAIR on the world-owned input router (character-world §8c) — F/H on the keyboard,
|
|
79
|
+
dpadUp/dpadDown on the pad (paired verbs share one symmetric button group):
|
|
78
80
|
|
|
79
81
|
```ts
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
});
|
|
82
|
+
const input = new InputService();
|
|
83
|
+
input.attach(window as never);
|
|
84
|
+
input.attachPointer(renderer.domElement as never, undefined, { dragLook: true });
|
|
85
|
+
input.registerAction('world.joinRed', { kind: 'button', keys: ['KeyF'], pad: 'dpadUp', label: 'Join red' }, 'world');
|
|
86
|
+
input.registerAction('world.joinBlue', { kind: 'button', keys: ['KeyH'], pad: 'dpadDown', label: 'Join blue' }, 'world');
|
|
87
|
+
const mp = await CharacterMultiplayer.create({ /* …, */ input });
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
const room = mp.room!;
|
|
90
|
+
// each frame, AFTER mp.update(dt): read the INPUT action, send the DECLARED action
|
|
91
|
+
if (input.wasPressed('world.joinRed')) room.sendAction('joinRed');
|
|
92
|
+
else if (input.wasPressed('world.joinBlue')) room.sendAction('joinBlue');
|
|
93
|
+
|
|
94
|
+
// each frame: tint your character by your team, draw the meter off the room vars (live reads, guarded)
|
|
95
|
+
const myTeam = room.me.str('team', 'blue');
|
|
96
|
+
const control = room.vars.num('control'); // -100 (blue) … +100 (red)
|
|
97
|
+
hud.innerHTML = `Hill: ${room.vars.num('redOnHill')}🔴 vs ${room.vars.num('blueOnHill')}🔵 · control ${control} · ${room.vars.num('redScore')}–${room.vars.num('blueScore')}`
|
|
98
|
+
+ `<br/><small>${input.format('{move} to move · {world.joinRed}/{world.joinBlue} to switch team')}</small>`;
|
|
89
99
|
drawControlBar(control);
|
|
90
100
|
```
|
|
91
101
|
|
|
92
|
-
**Footguns:**
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
**Footguns:** team is a server-written `playerVar` (the client sends the `joinRed`/`joinBlue` intent, the server
|
|
103
|
+
sets it); `enum` on `team` makes the validator reject any value other than `red`/`blue` (a hardening ADDITION
|
|
104
|
+
here — the engine-repo template ships without it, and it also predates the typed accessors shown above);
|
|
105
|
+
accessors read live — call them per frame. Never name keys in the HUD — `input.format()` tokens render the
|
|
106
|
+
current binding for the active device (keyboard "F"/"H", pad "D-pad Up"/"D-pad Down") and stay correct after
|
|
107
|
+
rebinds.
|
|
95
108
|
|
|
96
109
|
## 4. Build, validate, publish
|
|
97
110
|
|
|
98
|
-
`npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
|
|
111
|
+
`npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
|
|
@@ -32,7 +32,7 @@ co-op boss fights, round-based battlers. Lifted from the verified `multiplayer-t
|
|
|
32
32
|
"slug": "turn-arena",
|
|
33
33
|
"entry": "index.html",
|
|
34
34
|
"maxPlayers": 8,
|
|
35
|
-
"permissions": ["auth.profile", "multiplayer"],
|
|
35
|
+
"permissions": ["auth.profile", "multiplayer", "voice.proximity"],
|
|
36
36
|
"multiplayer": {
|
|
37
37
|
"authoritative": true,
|
|
38
38
|
"state": {
|
|
@@ -115,7 +115,7 @@ co-op boss fights, round-based battlers. Lifted from the verified `multiplayer-t
|
|
|
115
115
|
},
|
|
116
116
|
"supportsMobile": true,
|
|
117
117
|
"contentRating": "everyone",
|
|
118
|
-
"systems": { "humanoid-character": "^0.
|
|
118
|
+
"systems": { "humanoid-character": "^0.3" }
|
|
119
119
|
}
|
|
120
120
|
```
|
|
121
121
|
|
|
@@ -125,35 +125,44 @@ The turn gate is the key idiom: `if sameRef(self, listAt(turnOrder, turnIndex))`
|
|
|
125
125
|
|
|
126
126
|
## 3. The client — `src/main.ts` (delta from `hangout`)
|
|
127
127
|
|
|
128
|
-
Presence + a turn/phase HUD. Read
|
|
129
|
-
|
|
128
|
+
Presence + a turn/phase HUD. Read the turn machinery through the typed accessors (`room.phase()`,
|
|
129
|
+
`room.currentTurn()`, `room.me.num(…)`) — no casts, no hand-rolled `turnOrder[turnIndex]` plumbing.
|
|
130
|
+
(The engine-repo template predates these accessors and hand-rolls the same reads; write NEW worlds
|
|
131
|
+
the accessor way shown here.)
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const order = (room.state.roomVars as { turnOrder?: { length: number; [i: number]: { toString(): string } } } | undefined)?.turnOrder;
|
|
135
|
-
const idx = numRoomVar('turnIndex');
|
|
136
|
-
return order && order.length ? String(order[idx]) : '';
|
|
137
|
-
}
|
|
138
|
-
const myTurn = () => currentTurnId() === room.sessionId;
|
|
133
|
+
The verbs live on the world-owned input router (character-world §8c): strike is the STANDARD `melee`
|
|
134
|
+
id (V / right-stick click — never mint a private strike action), surrender/reset are custom actions
|
|
135
|
+
on genre-freed face buttons with `label`s:
|
|
139
136
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
137
|
+
```ts
|
|
138
|
+
const input = new InputService();
|
|
139
|
+
input.attach(window as never);
|
|
140
|
+
input.attachPointer(renderer.domElement as never, undefined, { dragLook: true });
|
|
141
|
+
registerStandardActions(input, { only: ['melee'] });
|
|
142
|
+
input.registerAction('world.surrender', { kind: 'button', keys: ['KeyX'], pad: 'faceLeft', label: 'Surrender' }, 'world');
|
|
143
|
+
input.registerAction('world.reset', { kind: 'button', keys: ['KeyR'], pad: 'faceUp', label: 'New round' }, 'world');
|
|
144
|
+
const mp = await CharacterMultiplayer.create({ /* …, */ input });
|
|
145
|
+
const room = mp.room!; // this template assumes a joined room
|
|
146
|
+
|
|
147
|
+
// each frame, AFTER mp.update(dt): input action → declared action
|
|
148
|
+
if (input.wasPressed('melee')) room.sendAction('strike'); // the server turn-gates it (sameRef)
|
|
149
|
+
if (input.wasPressed('world.surrender')) room.sendAction('surrender');
|
|
150
|
+
if (input.wasPressed('world.reset')) room.sendAction('reset'); // after won/defeat
|
|
145
151
|
|
|
146
152
|
room.onMessage('turnChanged', (m) => showBanner(`Turn: ${nameOf(String(m.current))}`));
|
|
147
153
|
room.onMessage('gameOver', (m) => showBanner(m.winner ? `🏆 ${nameOf(String(m.winner))} wins` : 'Defeat'));
|
|
148
154
|
|
|
149
|
-
// each frame
|
|
155
|
+
// each frame (accessors read the LIVE state — call them fresh, never cache):
|
|
156
|
+
hud.textContent = `${room.phase()} · turn: ${nameOf(room.currentTurn())} · hp: ${room.me.num('health')}`
|
|
157
|
+
+ ` · ${input.format('{melee} strike · {world.surrender} surrender · {world.reset} new round')}`;
|
|
150
158
|
```
|
|
151
159
|
|
|
152
|
-
**Footguns:**
|
|
153
|
-
`
|
|
154
|
-
|
|
160
|
+
**Footguns:** you may gate `strike` on `room.isMyTurn()` client-side for UX, but the **server** re-checks
|
|
161
|
+
`sameRef` either way (never trust the client); accessors re-read live state each call — invoke them per frame,
|
|
162
|
+
don't cache results. Control names in the HUD come from `input.format()` tokens (keyboard "V"/"X"/"R", pad
|
|
163
|
+
"RS"/"X"/"Y"), never hardcoded text.
|
|
155
164
|
|
|
156
165
|
## 4. Build, validate, publish
|
|
157
166
|
|
|
158
167
|
`npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` (watch the cascade depth + the
|
|
159
|
-
per-tick budget — `forEachEntity` × monsters) → `whoami` → `publish_world`.
|
|
168
|
+
per-tick budget — `forEachEntity` × monsters) → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Multiplayer template — `voice-radio`
|
|
2
|
+
|
|
3
|
+
**What it is.** A hangout where players TALK: ambient **proximity voice** (hear who's near you, fading with
|
|
4
|
+
distance), **spatial** (directional — on headphones a speaker is heard from where they stand), three
|
|
5
|
+
walkie-talkie **frequencies** (keys 1–3 — flat, room-wide channels), and **private calls** (key C calls the
|
|
6
|
+
nearest player on a dynamic channel id). **Capability: voice chat + programmable audio channels.** Use this
|
|
7
|
+
for social spaces with voice, team radio, squad channels, phone calls, proximity-voice tuning. Lifted from
|
|
8
|
+
the verified `multiplayer-voice-radio` world.
|
|
9
|
+
|
|
10
|
+
> Voice is a PARALLEL media plane — no game-logic DSL is needed for any of this, and the room server is never
|
|
11
|
+
> involved. Channel membership is **exclusive**: a player is in the ambient space OR one channel, never both.
|
|
12
|
+
> The manifest block + permissions: `read_doc({ name: "manifest" })` §multiplayer.voice; the SDK surface:
|
|
13
|
+
> `read_doc({ name: "sdk" })` §Helix.voice.
|
|
14
|
+
|
|
15
|
+
## 1. Voice config used (no game-logic DSL needed)
|
|
16
|
+
|
|
17
|
+
- **The `voice.proximity` permission** — voice chat with distance-attenuated ambient render (use `voice.room`
|
|
18
|
+
instead for flat, room-wide ambient — then set `mode: "global"`).
|
|
19
|
+
- **`multiplayer.voice.mode/refDistance/maxDistance`** — the ambient falloff: full volume within 4 m, silent
|
|
20
|
+
beyond 20 m, equal-power fade between. Tune per world scale (`refDistance` must stay below `maxDistance`).
|
|
21
|
+
- **`multiplayer.voice.spatial`** — directional ambient voice (default `false`): each speaker is HRTF-panned
|
|
22
|
+
to where they stand. Direction only — the falloff above still owns loudness. Channels are exempt by design
|
|
23
|
+
(a radio voice comes from the device, not the body — they stay center-panned). Declare it whenever presence
|
|
24
|
+
matters — realistic hangouts, horror, hide-and-seek, social worlds; the full when-to-use guide lives in the
|
|
25
|
+
multiplayer hub's voice section.
|
|
26
|
+
- **`multiplayer.voice.channels`** — three declared walkie-talkie frequencies. Declared channels default to
|
|
27
|
+
`"render": "global"` (flat — radio/phone semantics); declare `"render": "proximity"` instead to keep the
|
|
28
|
+
falloff inside a channel (a squad channel in a huge map). Max 32 declared channels.
|
|
29
|
+
- **Dynamic channel ids** — the private call uses an UNDECLARED id built at runtime; undeclared ids are always
|
|
30
|
+
allowed and render flat. Both callers derive the same id by sorting the pair of userIds.
|
|
31
|
+
|
|
32
|
+
## 2. The manifest — `public/helix.json`
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"helixVersion": "0.3",
|
|
37
|
+
"title": "Voice Radio",
|
|
38
|
+
"slug": "voice-radio",
|
|
39
|
+
"entry": "index.html",
|
|
40
|
+
"maxPlayers": 8,
|
|
41
|
+
"permissions": ["auth.profile", "multiplayer", "voice.proximity"],
|
|
42
|
+
"multiplayer": {
|
|
43
|
+
"authoritative": true,
|
|
44
|
+
"voice": {
|
|
45
|
+
"mode": "proximity",
|
|
46
|
+
"refDistance": 4,
|
|
47
|
+
"maxDistance": 20,
|
|
48
|
+
"spatial": true,
|
|
49
|
+
"channels": { "freq-1": {}, "freq-2": {}, "freq-3": {} }
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"contentRating": "everyone",
|
|
53
|
+
"systems": { "humanoid-character": "^0.3" }
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Publish gates: a `voice` block requires a `voice.*` permission; `voice.*` requires `multiplayer`; a
|
|
58
|
+
`mode`/permission mismatch (e.g. `"proximity"` with only `voice.room`) warns. The block is CLIENT-render
|
|
59
|
+
config — your code passes it to the facade below; the platform never reads it server-side.
|
|
60
|
+
|
|
61
|
+
## 3. The client — `src/main.ts` (delta from `hangout`)
|
|
62
|
+
|
|
63
|
+
Everything from `hangout` (the one facade call), plus the voice join and the channel switcher. Import your
|
|
64
|
+
own manifest so the voice block has ONE source of truth:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import manifest from '../public/helix.json'; // vite JSON import — tsconfig needs "resolveJsonModule": true
|
|
68
|
+
|
|
69
|
+
const FREQUENCIES = ['freq-1', 'freq-2', 'freq-3'] as const;
|
|
70
|
+
// The JSON import widens string literals — re-narrow to the attachVoice options shape.
|
|
71
|
+
const VOICE_CONFIG = manifest.multiplayer.voice as {
|
|
72
|
+
mode: 'proximity' | 'global'; refDistance: number; maxDistance: number; spatial: boolean;
|
|
73
|
+
channels: Record<string, { render?: 'proximity' | 'global' }>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// After CharacterMultiplayer.create(...) — the voice join is fail-soft: false (voice off for this
|
|
77
|
+
// environment) and errors BOTH leave the world fully playable; channels then no-op.
|
|
78
|
+
let voiceOn = false;
|
|
79
|
+
if (mp.room) {
|
|
80
|
+
try {
|
|
81
|
+
voiceOn = await Helix.voice.join();
|
|
82
|
+
if (voiceOn) mp.attachVoice(Helix.voice, VOICE_CONFIG); // proximity falloff + channel policies + nameplate glyphs
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.info('voice unavailable:', err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Radio controls on the world-owned input router (character-world §8c) — created before the facade
|
|
89
|
+
// and injected via `input`. Digits direct-select (no equip slots ⇒ ability1-9's digits are freed),
|
|
90
|
+
// the bumpers cycle the dial (no equip cycle ⇒ freed bumpers host cycling verbs), dpadUp = ambient/
|
|
91
|
+
// hang-up, and call-nearest is the STANDARD `interact` (E / face-left). Every custom has a `label`.
|
|
92
|
+
registerStandardActions(input, { only: ['interact'] });
|
|
93
|
+
input.registerAction('world.radioAmbient', { kind: 'button', keys: ['Digit0'], pad: 'dpadUp', label: 'Ambient' }, 'world');
|
|
94
|
+
input.registerAction('world.radioChannel1', { kind: 'button', keys: ['Digit1'], label: 'Radio 1' }, 'world');
|
|
95
|
+
input.registerAction('world.radioChannel2', { kind: 'button', keys: ['Digit2'], label: 'Radio 2' }, 'world');
|
|
96
|
+
input.registerAction('world.radioChannel3', { kind: 'button', keys: ['Digit3'], label: 'Radio 3' }, 'world');
|
|
97
|
+
input.registerAction('world.radioPrev', { kind: 'button', pad: 'bumperL', label: 'Radio down' }, 'world');
|
|
98
|
+
input.registerAction('world.radioNext', { kind: 'button', pad: 'bumperR', label: 'Radio up' }, 'world');
|
|
99
|
+
|
|
100
|
+
// Channel switching — EXCLUSIVE semantics: setChannel(id) leaves ambient, setChannel(null) returns.
|
|
101
|
+
let status = '';
|
|
102
|
+
const setChannel = (id: string | null, label: string): void => {
|
|
103
|
+
if (!voiceOn) return;
|
|
104
|
+
Helix.voice.setChannel(id).then(
|
|
105
|
+
() => void (status = label),
|
|
106
|
+
(err: unknown) => void (status = `channel switch failed: ${String(err)}`),
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// each frame, AFTER mp.update(dt) — queries, not listeners; edges are latched by the router:
|
|
111
|
+
if (input.wasPressed('world.radioAmbient')) setChannel(null, 'ambient');
|
|
112
|
+
if (input.wasPressed('world.radioChannel1')) setChannel(FREQUENCIES[0], `radio 1 (${FREQUENCIES[0]})`);
|
|
113
|
+
// …channels 2/3 identical; the bumper dial cycles ambient → 1 → 2 → 3 with wraparound:
|
|
114
|
+
if (input.wasPressed('world.radioPrev')) cycleChannel(-1);
|
|
115
|
+
if (input.wasPressed('world.radioNext')) cycleChannel(1);
|
|
116
|
+
if (input.wasPressed('interact') && mp.room) {
|
|
117
|
+
const selfId = (mp.user as { id?: string } | null)?.id; // the facade keeps user untyped
|
|
118
|
+
const other = selfId ? nearestPlayerUserId(mp.room.state, body.position, selfId) : null;
|
|
119
|
+
if (!selfId || !other) status = 'no one nearby to call';
|
|
120
|
+
// Dynamic channel id: both callers sort the userId pair, so interacting near each other lands both
|
|
121
|
+
// in the SAME private call — no declaration needed.
|
|
122
|
+
else setChannel(`call:${[selfId, other].sort().join(':')}`, 'private call');
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`nearestPlayerUserId(state, localPos, selfId)` is **your own helper**: iterate `state.players` (each seat has
|
|
127
|
+
`userId` + `position`, contract-synced) and return the closest other player's userId. HUD tip: show
|
|
128
|
+
`Helix.voice.channel() ?? 'ambient'` so players always know what they're tuned to; the platform tablet's
|
|
129
|
+
Voice tab shows the same channel chip automatically. Render the control line from live hints, never literals —
|
|
130
|
+
e.g. `` `[${input.hint('world.radioAmbient')}] ambient · [${input.hint('interact')}] call nearest` `` — with ONE
|
|
131
|
+
exception: the keyboard push-to-talk key is the SDK's `pttKey` (default **N**), not a router action, so show the
|
|
132
|
+
key you passed to `join({ pttKey })` (or "N" if you kept the default — never hardcode a different letter) and
|
|
133
|
+
`input.hint('voicePTT')` (the modifier+RB chord) only when
|
|
134
|
+
`input.activeDevice() === 'gamepad' && input.hasAction('voicePTT')` (voicePTT is auto-registered by
|
|
135
|
+
`mp.attachVoice`; if the voice join failed it never registers). Mic behavior (PTT / open / muted, device,
|
|
136
|
+
volumes, per-player mutes) is the PLAYER's via the tablet — render none of it.
|
|
137
|
+
|
|
138
|
+
## 4. Build, validate, publish
|
|
139
|
+
|
|
140
|
+
`npm install` → **`install_world_packages`** (MCP tool) (resolves the `humanoid-character` pin) → `npm run
|
|
141
|
+
build` → `validate_world` on `dist/` (fix every problem) → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`. The build contains no
|
|
142
|
+
`.glb`/`.ktx2` — character assets stream from the CDN.
|
|
143
|
+
|
|
144
|
+
## Appendix — server-driven channels (teams → voice), still no server code
|
|
145
|
+
|
|
146
|
+
Channel switching above is client-initiated. To have the GAME assign channels (team voice, a phase that
|
|
147
|
+
silences everyone), drive it from declared state: a rule sets a `voiceChannel` playerVar, and each client
|
|
148
|
+
follows it — membership becomes server-authoritative without any server code.
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
"state": { "playerVars": { "voiceChannel": { "type": "string", "default": "" } } },
|
|
152
|
+
"actions": { "joinRed": {} },
|
|
153
|
+
"rules": [
|
|
154
|
+
{ "when": { "on": "action", "name": "joinRed" },
|
|
155
|
+
"then": [{ "do": "set", "target": "self.voiceChannel", "to": "team-red" }] }
|
|
156
|
+
]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// Client: follow your own synced playerVar into the voice layer (poll per frame or on change).
|
|
161
|
+
const assigned = mp.room.me.str('voiceChannel') || null;
|
|
162
|
+
if (voiceOn && assigned !== Helix.voice.channel()) void Helix.voice.setChannel(assigned);
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Declare `team-red` under `multiplayer.voice.channels` if it should render with proximity; leave it undeclared
|
|
166
|
+
for flat radio semantics.
|
|
@@ -35,7 +35,7 @@ verified `multiplayer-wave-survival-2` world.
|
|
|
35
35
|
"slug": "wave-survival",
|
|
36
36
|
"entry": "index.html",
|
|
37
37
|
"maxPlayers": 8,
|
|
38
|
-
"permissions": ["auth.profile", "multiplayer"],
|
|
38
|
+
"permissions": ["auth.profile", "multiplayer", "voice.proximity"],
|
|
39
39
|
"multiplayer": {
|
|
40
40
|
"authoritative": true,
|
|
41
41
|
"state": {
|
|
@@ -83,7 +83,7 @@ verified `multiplayer-wave-survival-2` world.
|
|
|
83
83
|
},
|
|
84
84
|
"supportsMobile": true,
|
|
85
85
|
"contentRating": "everyone",
|
|
86
|
-
"systems": { "humanoid-character": "^0.
|
|
86
|
+
"systems": { "humanoid-character": "^0.3" }
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
@@ -97,11 +97,10 @@ host** (`controller === sessionId`), interpolates the rest, and freezes orphans
|
|
|
97
97
|
server re-elects a host. The motion fn is the **AI** — chase the nearest player:
|
|
98
98
|
|
|
99
99
|
```ts
|
|
100
|
-
import { EntityScene } from '@helix/humanoid-character';
|
|
101
100
|
const ENEMY_MAX_SPEED = 6; // MUST mirror the DSL maxSpeed (reconcile clamp)
|
|
101
|
+
const room = mp.room!;
|
|
102
102
|
|
|
103
|
-
const entities =
|
|
104
|
-
room,
|
|
103
|
+
const entities = mp.entities({
|
|
105
104
|
maxSpeed: { enemy: ENEMY_MAX_SPEED },
|
|
106
105
|
motion: {
|
|
107
106
|
// Chase the nearest player. ctx.seekNearest(speed, stopAt) returns the gate-safe next position: it caps the
|
|
@@ -113,12 +112,12 @@ const entities = new EntityScene({
|
|
|
113
112
|
const mesh = makeEnemy(); scene.add(mesh);
|
|
114
113
|
return {
|
|
115
114
|
object3d: mesh,
|
|
116
|
-
onUpdate: (e) => { mesh.visible = true; tintByController(mesh, e.controller, room
|
|
115
|
+
onUpdate: (e) => { mesh.visible = true; tintByController(mesh, e.controller, room.sessionId); }, // host / orphan tint
|
|
117
116
|
dispose: () => scene.remove(mesh),
|
|
118
117
|
};
|
|
119
118
|
},
|
|
120
119
|
});
|
|
121
|
-
//
|
|
120
|
+
// mp.update(dt) drives the sim/interpolation — nothing to add in your frame loop.
|
|
122
121
|
|
|
123
122
|
// Shoot the nearest enemy in front of you — client picks the id, server validates range + destroys:
|
|
124
123
|
function shootNearest() {
|
|
@@ -138,4 +137,4 @@ motion step `≤ maxSpeed × 0.83 × dt`; you won't host every enemy (shared)
|
|
|
138
137
|
|
|
139
138
|
## 4. Build, validate, publish
|
|
140
139
|
|
|
141
|
-
`npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → `whoami` → `publish_world`.
|
|
140
|
+
`npm install` → **`install_world_packages`** (MCP tool) → `npm run build` → `validate_world` on `dist/` → **`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the bundle from a nested path, the way a published world is actually served; preview/validate/inspect all serve from the origin root and cannot see a root-absolute 404) → `whoami` → `publish_world`.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Multiplayer template — `world-shop`
|
|
2
|
+
|
|
3
|
+
**What it is.** A world that **sells things** and honours them afterwards. A coin pack you spend on doors, a
|
|
4
|
+
potion 3-pack you drink, a VIP day pass that lapses on its own, a permanent founder badge, a bundle that hands
|
|
5
|
+
over two things in one charge, a tip jar that hands over nothing, and a free daily claim —
|
|
6
|
+
then the room reacts to every sale and gates play on what the buyer owns. **Capability: in-world purchases
|
|
7
|
+
(IWP)** — the eight product shapes, and (the part that actually matters) **which one to reach for**. Lifted
|
|
8
|
+
from the verified `iwp-shop-playground` (single-player) and `multiplayer-iwp-shop` (the room-side capstone).
|
|
9
|
+
|
|
10
|
+
> A normal character world (presence) **plus** money. Grammar: `read_doc({ name: "purchases" })` for the whole
|
|
11
|
+
> system, `read_doc({ name: "multiplayer-logic" })` §20 for the room-side rules.
|
|
12
|
+
|
|
13
|
+
## 1. Pick the grant shape FIRST — this is the whole template
|
|
14
|
+
|
|
15
|
+
A product's `grants` array is **immutable once registered**, so this is the one decision you cannot walk back.
|
|
16
|
+
Money bugs here are almost never syntax; they are picking a shape that cannot deliver what you promised.
|
|
17
|
+
|
|
18
|
+
| What the player is buying | Grant shape | Why this one |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Soft currency to spend later (coins, gems, credits) | `{ kind: "currency", code, amount, display: "wallet" }` | a server-held balance; spend it with `consume` |
|
|
21
|
+
| A stack of uses (potions, revives, hints) | `{ kind: "currency", code, amount, display: "uses" }` | same mechanism — `display` is only a UI hint |
|
|
22
|
+
| A subscription / day pass / battle pass | `{ kind: "pass", durationSeconds }` | lapses on its own; re-purchase EXTENDS from the current expiry |
|
|
23
|
+
| A permanent unlock (founder badge, ad-free, a character slot) | `{ kind: "pass" }` (no duration) | never lapses — and caps the product at 1 per player |
|
|
24
|
+
| A portable item in universal inventory | **Not a World Product** | publish it, create an ItemDistribution, then request the registered distribution key |
|
|
25
|
+
| A bundle — several things, one charge | several effects in one array (max 8) | one popup, one price, all-or-nothing fulfilment |
|
|
26
|
+
| A tip / donation / "support the dev" | `grants: []` with a price | charges, records the sale, hands over nothing |
|
|
27
|
+
| A daily freebie or a tutorial reward | any shape with `priceLix: 0` | a **Claim** — the popup confirms, nothing is spent |
|
|
28
|
+
|
|
29
|
+
**The two that look interchangeable and are not.** A **currency** balance is arithmetic you spend down; a
|
|
30
|
+
**pass** is a boolean you check. If the player can "run out", it is currency. If the answer is only yes/no, it
|
|
31
|
+
is a pass — modelling a pass as `balanceOf(x) >= 1` works right up until someone consumes it by accident.
|
|
32
|
+
|
|
33
|
+
**A permanent-pass-only product is capped at `maxPerUser: 1`, and registration REFUSES a higher value.** A
|
|
34
|
+
re-buy would charge again and deliver nothing it does not already have. If you want it re-buyable, add a timed
|
|
35
|
+
pass or a currency effect.
|
|
36
|
+
|
|
37
|
+
## 2. Register the products BEFORE writing the code that buys them
|
|
38
|
+
|
|
39
|
+
`helix.json` has **no products block** and world code cannot mint one — a world can only sell what its creator
|
|
40
|
+
registered on it. `purchaseProduct` on an unregistered key returns `ProductNotFound` at runtime, which is a
|
|
41
|
+
silent-looking failure in a UI that just does nothing.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
register_world_product({ worldSlug: "world-shop", key: "coin-pack", title: "Coin Pack", priceLix: 25, grants: [{ kind: "currency", code: "coin", amount: 500, display: "wallet" }] })
|
|
45
|
+
register_world_product({ worldSlug: "world-shop", key: "potion-pack", title: "Potion 3-Pack", priceLix: 10, grants: [{ kind: "currency", code: "potion", amount: 3, display: "uses" }] })
|
|
46
|
+
register_world_product({ worldSlug: "world-shop", key: "vip-pass", title: "VIP Day Pass", priceLix: 40, grants: [{ kind: "pass", durationSeconds: 86400 }] })
|
|
47
|
+
register_world_product({ worldSlug: "world-shop", key: "founder-badge", title: "Founder Badge", priceLix: 100, grants: [{ kind: "pass", passKey: "founder" }] })
|
|
48
|
+
register_world_product({ worldSlug: "world-shop", key: "starter-bundle", title: "Starter Bundle", priceLix: 60, grants: [{ kind: "currency", code: "coin", amount: 100 }, { kind: "pass", passKey: "founder" }] })
|
|
49
|
+
register_world_product({ worldSlug: "world-shop", key: "tip-jar", title: "Tip Jar", priceLix: 5, grants: [] })
|
|
50
|
+
register_world_product({ worldSlug: "world-shop", key: "daily-gift", title: "Daily Gift", priceLix: 0, grants: [{ kind: "currency", code: "coin", amount: 10 }] })
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- Pass `dryRun: true` first — it validates the whole shape locally, with no network and no login.
|
|
54
|
+
- A **pass with no `passKey` defaults to the product key** (`vip-pass` above grants the pass `vip-pass`).
|
|
55
|
+
Name it explicitly when two products grant the SAME pass, as `starter-bundle` and `founder-badge` do.
|
|
56
|
+
- The **key is immutable** and the **price lives server-side only** — a world never sends an amount.
|
|
57
|
+
- Registering works on a **draft** world; **buying** needs it live (Published or Unlisted).
|
|
58
|
+
- `list_world_products` shows what is registered; `update_world_product` changes title/description/price/active
|
|
59
|
+
— never the key or the grants.
|
|
60
|
+
|
|
61
|
+
## 3. DSL used
|
|
62
|
+
|
|
63
|
+
- **`{ "on": "purchase" }`** (§20) — fires on the buyer's seat when a settled sale reaches the room. Reads
|
|
64
|
+
`purchase.productKey` and `purchase.purchaseId`, and nothing else.
|
|
65
|
+
- **`hasPass` / `balanceOf`** (§20) — O(1) reads off the per-seat entitlement snapshot. `of` is REQUIRED.
|
|
66
|
+
- **`consume`** (§20) — the server-side atomic, floor-at-zero spend.
|
|
67
|
+
- **`counters` + `increment`, `save`, `awardAchievement`** (§18/§19) — where a purchase's consequences go, because
|
|
68
|
+
the purchase rule fires **exactly once, ever**.
|
|
69
|
+
- **Zones + actions** — the beats that read the entitlements.
|
|
70
|
+
|
|
71
|
+
## 4. The manifest — `public/helix.json` (the money block)
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"helixVersion": "0.3",
|
|
76
|
+
"title": "World Shop",
|
|
77
|
+
"slug": "world-shop",
|
|
78
|
+
"entry": "index.html",
|
|
79
|
+
"maxPlayers": 8,
|
|
80
|
+
"permissions": ["auth.profile", "multiplayer"],
|
|
81
|
+
"contentRating": "everyone",
|
|
82
|
+
"multiplayer": {
|
|
83
|
+
"authoritative": true,
|
|
84
|
+
"state": {
|
|
85
|
+
"playerVars": {
|
|
86
|
+
"tier": { "type": "string", "default": "free", "enum": ["free", "vip", "founder"], "persistent": true },
|
|
87
|
+
"potionsDrunk": { "type": "number", "default": 0, "min": 0, "max": 9999, "integer": true, "persistent": true }
|
|
88
|
+
},
|
|
89
|
+
"roomVars": {
|
|
90
|
+
"sales": { "type": "number", "default": 0 },
|
|
91
|
+
"lastProduct": { "type": "string", "default": "", "maxLen": 64 },
|
|
92
|
+
"tips": { "type": "number", "default": 0 }
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"counters": { "lifetimeSales": {} },
|
|
96
|
+
"zones": [ { "id": "lounge", "shape": "sphere", "center": [12, 0, 0], "radius": 4 } ],
|
|
97
|
+
"actions": { "openDoor": {}, "drinkPotion": {} },
|
|
98
|
+
"events": { "loungeEntered": { "payload": {} } },
|
|
99
|
+
"rules": [
|
|
100
|
+
{ "when": { "on": "purchase" },
|
|
101
|
+
"then": [
|
|
102
|
+
{ "do": "add", "target": "room.sales", "by": 1 },
|
|
103
|
+
{ "do": "set", "target": "room.lastProduct", "to": { "var": "purchase.productKey" } },
|
|
104
|
+
{ "do": "increment", "counter": "lifetimeSales", "by": 1 }
|
|
105
|
+
] },
|
|
106
|
+
|
|
107
|
+
{ "when": { "on": "purchase" },
|
|
108
|
+
"if": { "op": "==", "a": { "var": "purchase.productKey" }, "b": "tip-jar" },
|
|
109
|
+
"then": [ { "do": "add", "target": "room.tips", "by": 1 } ] },
|
|
110
|
+
|
|
111
|
+
{ "when": { "on": "purchase" },
|
|
112
|
+
"if": { "op": "hasPass", "passKey": "founder", "of": "self" },
|
|
113
|
+
"then": [
|
|
114
|
+
{ "do": "set", "target": "self.tier", "to": "founder" },
|
|
115
|
+
{ "do": "save", "player": "self" },
|
|
116
|
+
{ "do": "awardAchievement", "key": "founder", "player": "self" }
|
|
117
|
+
] },
|
|
118
|
+
|
|
119
|
+
{ "when": { "on": "playerJoin" },
|
|
120
|
+
"if": { "op": "hasPass", "passKey": "vip-pass", "of": "self" },
|
|
121
|
+
"then": [ { "do": "set", "target": "self.tier", "to": "vip" }, { "do": "save", "player": "self" } ] },
|
|
122
|
+
|
|
123
|
+
{ "when": { "on": "zoneEnter", "zone": "lounge" },
|
|
124
|
+
"if": { "op": "hasPass", "passKey": "vip-pass", "of": "self" },
|
|
125
|
+
"then": [ { "do": "broadcast", "event": "loungeEntered", "to": "self", "payload": {} } ] },
|
|
126
|
+
|
|
127
|
+
{ "when": { "on": "action", "name": "openDoor" },
|
|
128
|
+
"if": { "op": ">=", "a": { "op": "balanceOf", "code": "coin", "of": "self" }, "b": 20 },
|
|
129
|
+
"then": [ { "do": "consume", "code": "coin", "amount": 20, "player": "self" } ] },
|
|
130
|
+
|
|
131
|
+
{ "when": { "on": "action", "name": "drinkPotion" },
|
|
132
|
+
"if": { "op": ">=", "a": { "op": "balanceOf", "code": "potion", "of": "self" }, "b": 1 },
|
|
133
|
+
"then": [
|
|
134
|
+
{ "do": "consume", "code": "potion", "amount": 1, "player": "self" },
|
|
135
|
+
{ "do": "add", "target": "self.potionsDrunk", "by": 1 },
|
|
136
|
+
{ "do": "save", "player": "self" }
|
|
137
|
+
] }
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
"systems": { "humanoid-character": "^0.3" }
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**The purchase rule fires once and never again**, so its consequences must be durable — `save`, `increment`,
|
|
145
|
+
`submitScore`, `awardAchievement`. Notice `room.sales` is deliberately NOT the record of a sale: it is a live
|
|
146
|
+
HUD number that dies with the room, while `lifetimeSales` is the counter that actually keeps it.
|
|
147
|
+
|
|
148
|
+
**Branch on `productKey` in `if`, one rule per product** — the event itself takes no params, so every
|
|
149
|
+
`{on:"purchase"}` rule fires for every sale until its own `if` filters it.
|
|
150
|
+
|
|
151
|
+
**The entitlement snapshot is refreshed BEFORE the purchase rule runs**, which is why the founder rule can ask
|
|
152
|
+
`hasPass` about the very pass that sale just granted.
|
|
153
|
+
|
|
154
|
+
## 5. The client — `src/main.ts` (delta from `hangout`)
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { Helix } from '@hypersoniclabs/helix-sdk';
|
|
158
|
+
|
|
159
|
+
// ── buy ────────────────────────────────────────────────────────────────────────
|
|
160
|
+
const result = await Helix.marketplace.purchaseProduct('coin-pack'); // buys pkey:coin-pack
|
|
161
|
+
if (result.completed) refresh(); // Granted | Claimed | AlreadyOwned
|
|
162
|
+
else if (result.status === 'InsufficientFunds') showTopUp();
|
|
163
|
+
else if (result.status === 'PriceChanged') { /* nothing charged; the shell re-prompts */ }
|
|
164
|
+
|
|
165
|
+
// ── read what fulfilment granted ───────────────────────────────────────────────
|
|
166
|
+
const ent = await Helix.purchases.getEntitlements();
|
|
167
|
+
// { v: 1, passes: { 'vip-pass': { since, expiresAt, active } }, balances: { coin: 740, potion: 3 }, owned: {} }
|
|
168
|
+
if (ent.passes['vip-pass']?.active) unlockLounge();
|
|
169
|
+
hud.coins = ent.balances.coin ?? 0;
|
|
170
|
+
|
|
171
|
+
// ── spend (client-side; the room-side twin is the `consume` effect in §4) ───────
|
|
172
|
+
const spent = await Helix.purchases.consume('potion', 1);
|
|
173
|
+
if (spent.applied) drinkAnimation(); else offerShop(spent.balance);
|
|
174
|
+
|
|
175
|
+
// ── stay in sync — fires after the shell settles a sale, no payload ────────────
|
|
176
|
+
Helix.purchases.onEntitlementsChanged(() => refresh());
|
|
177
|
+
|
|
178
|
+
// ── multiplayer: nothing to wire ───────────────────────────────────────────────
|
|
179
|
+
// The SDK forwards the backend receipt to the room automatically, so the {on:purchase}
|
|
180
|
+
// rule above fires with no world code. `result.receipt` is only evidence it was sent.
|
|
181
|
+
room.sendAction('openDoor', {}); // the ROOM decides: balanceOf(coin) >= 20 ⇒ consume 20
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Surviving a reload mid-purchase.** Supply your own `idempotencyKey`, keep it, and re-read on boot — this is
|
|
185
|
+
the difference between a player who got what they paid for and a support ticket:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const key = localStorage.getItem('buy:coin-pack') ?? crypto.randomUUID();
|
|
189
|
+
localStorage.setItem('buy:coin-pack', key);
|
|
190
|
+
const res = await Helix.marketplace.purchaseProduct('coin-pack', { idempotencyKey: key });
|
|
191
|
+
|
|
192
|
+
// …after a reload, before showing the shop:
|
|
193
|
+
const prior = await Helix.purchases.getPurchase(key, 'coin-pack'); // PASS the ref
|
|
194
|
+
if (prior?.completed) { localStorage.removeItem('buy:coin-pack'); refresh(); }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**A custom confirm UI** — product, the player's live balance, eligibility and the `grants` list in one call
|
|
198
|
+
(the built-in popup uses this internally; the popup still settles the sale):
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const ctx = await Helix.marketplace.getPurchaseContext('vip-pass');
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## 6. Footguns — every one of these fails SILENTLY
|
|
205
|
+
|
|
206
|
+
- **Never mirror a pass or a balance into a playerVar, roomVar or client state.** A session copy resets on the
|
|
207
|
+
next join while the entitlement does not, and the two then disagree about money. Read the entitlement where
|
|
208
|
+
you need it. (`self.tier` above is a derived *label* for the HUD, never the authority for access.)
|
|
209
|
+
- **`of` is REQUIRED on `hasPass`/`balanceOf`** — there is no implicit `self`, and an entity ref is a publish
|
|
210
|
+
error.
|
|
211
|
+
- **Deny by default:** an unheld or unregistered `passKey` reads `false`, an unknown `code` reads `0`, and a
|
|
212
|
+
guest seat reads the same. **Publish does not check the product registry** — a typo in `passKey` publishes
|
|
213
|
+
clean and silently reads empty forever.
|
|
214
|
+
- **A guest seat owns no purchase**, so `{on:purchase}` never fires for one.
|
|
215
|
+
- **The purchase rule fires exactly once per sale, platform-enforced** — a re-forward, a rejoin, a tab that died
|
|
216
|
+
mid-sale and a second instance of your world all collapse to ONE firing. Anything it grants into
|
|
217
|
+
session-scoped state is gone forever, because the rule will not fire again to rebuild it.
|
|
218
|
+
- **`priceLix: 0` is a Claim, not a bypass** — the popup still confirms, and the status is `Claimed`, not
|
|
219
|
+
`Granted`. Branch on `completed`, not on `Granted` alone.
|
|
220
|
+
- **`AlreadyOwned` is `completed` but nothing was charged and nothing new arrived.** Treat it as "they have it",
|
|
221
|
+
never as "a sale happened".
|
|
222
|
+
- **A currency `code` and a `passKey` are lowercase slugs** (`^[a-z0-9][a-z0-9_-]{0,63}$`). A world may mint at
|
|
223
|
+
most **16 distinct currency codes**, and a `grants` array holds at most **8** effects.
|
|
224
|
+
- **`consume` is per-call, not per-key.** Retries of the SAME call are replay-safe; calling it twice is two
|
|
225
|
+
spends. Do not "retry" by calling again.
|
|
226
|
+
- **Registration does not need a published world; buying does.** A product on a draft world exists, lists, and
|
|
227
|
+
refuses to sell.
|
|
228
|
+
|
|
229
|
+
## 7. Build, validate, publish
|
|
230
|
+
|
|
231
|
+
`npm install` → **`install_world_packages`** (MCP tool) → **`create_world`** (first time only — the product
|
|
232
|
+
registry hangs off the world row, which nothing has minted yet) → **register the products** (§2) → `npm run build` →
|
|
233
|
+
**`helix dev`** — drive the whole shop under the simulated shell BEFORE anything real: seed the same product
|
|
234
|
+
rows in `.helix/dev-products.json`, buy, consume, and force InsufficientFunds / PriceChanged / Pending from the
|
|
235
|
+
debug menu (nothing is charged; see the sdk doc's *Local development*) → `validate_world` on `dist/` →
|
|
236
|
+
**`helix verify-subpath dist`** (REQUIRED — the only local gate that serves the
|
|
237
|
+
bundle from a nested path, the way a published world is actually served) → `whoami` → `publish_world`.
|
|
238
|
+
|
|
239
|
+
To see it work, buy the coin pack and walk into the lounge with a second client watching: `room.sales` moves on
|
|
240
|
+
their screen, which is proof the receipt reached the room and not just the buyer.
|