openvibers/openvibe.games Commits

Source-style triggers and entity I/O: trigger_multiple/once/push/teleport, relays, timers, outputs (#198)

OP OpenVibers committed 16 days ago · 52a2bb919a · 28 files, +1295 −58

* Source-style triggers and entity I/O: trigger_multiple/once/push/teleport, relays, timers, outputs

* Fix typecheck on triggers branch

- Widen OutputSpec optional fields to accept zod-inferred undefined under exactOptionalPropertyTypes
- Import LogicDef in gameplay trigger entity
- Add required points/logic arrays to Scrap Island v2 map document

Co-authored-by: CommandCodeBot <[email protected]>

---------

Co-authored-by: CommandCodeBot <[email protected]>

Changes

ddiff --git a/apps/client/src/editor/catalog.ts b/apps/client/src/editor/catalog.ts
iindex 0e56653..82d2063 100644
---- a/apps/client/src/editor/catalog.ts
++++ b/apps/client/src/editor/catalog.ts
@@@ -21,7 +21,7 @@ export const newId = (prefix: string): string =>
export interface Placeable {
name: string
-- kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc'
++ kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc' | 'trigger'
shape?: StaticBody['shape']
color?: string
tex?: string
@@@ -69,6 +69,7 @@ export const ENTITY_DEFS: Placeable[] = [
{ name: '🧑 Townsfolk (NPC)', kind: 'npc', npc: 'townsfolk' },
{ name: '🐇 Dust hare (NPC)', kind: 'npc', npc: 'dust_hare' },
{ name: '☠ Rustjaw thug (NPC, hostile)', kind: 'npc', npc: 'rustjaw_thug' },
++ { name: '🔘 Trigger volume', kind: 'trigger' },
]
export const TEXTURES = [
ddiff --git a/apps/client/src/editor/editorApp.ts b/apps/client/src/editor/editorApp.ts
iindex 4f31bec..1687cc5 100644
---- a/apps/client/src/editor/editorApp.ts
++++ b/apps/client/src/editor/editorApp.ts
@@@ -58,7 +58,7 @@ import { ToolManager } from './tools/toolManager.js'
import { PlacementTool, objectForPlacement } from './tools/placementTool.js'
import { sculptDab, toTerrainLocal, type SculptMode } from './tools/terrainTool.js'
import { lightForPlacement, type LightType } from './tools/lightTool.js'
--import { zoneForPlacement } from './tools/zoneTool.js'
++import { triggerForPlacement, zoneForPlacement } from './tools/zoneTool.js'
import { EditorCameraController } from './viewport/editorCameraController.js'
import { EditorViewRegistry } from './viewport/editorViewRegistry.js'
import { GizmoController, type GizmoMode } from './viewport/gizmoController.js'
@@@ -662,16 +662,20 @@ export async function bootEditor(): Promise<void> {
const placeZone = (): void => {
const { point } = creationPoint()
-- const zone = zoneForPlacement({
-- point,
-- size: Number((document.getElementById('zone-size') as HTMLInputElement).value),
-- height: Number((document.getElementById('zone-height') as HTMLInputElement).value),
-- taken: (id) => doc.has(id),
-- })
-- history.apply(addObject('zone', zone as never, 'create zone'))
++ const isTrigger = (document.getElementById('zone-trigger') as HTMLInputElement | null)?.checked
++ const size = Number((document.getElementById('zone-size') as HTMLInputElement).value)
++ const height = Number((document.getElementById('zone-height') as HTMLInputElement).value)
++ const zone = isTrigger
++ ? triggerForPlacement({ point, size, height, taken: (id) => doc.has(id) })
++ : zoneForPlacement({ point, size, height, taken: (id) => doc.has(id) })
++ history.apply(addObject('zone', zone as never, isTrigger ? 'create trigger' : 'create zone'))
selection.replace(zone.id)
ui.refreshAll()
-- ui.setMessage(`🟦 ${zone.name} created — scale it with the gizmo, set rules in the Inspector`)
++ ui.setMessage(
++ isTrigger
++ ? `🔘 ${zone.name} created — set outputs in the Inspector`
++ : `🟦 ${zone.name} created — scale it with the gizmo, set rules in the Inspector`,
++ )
}
// ── Face tool ───────────────────────────────────────────────────────
ddiff --git a/apps/client/src/editor/tools/lightZoneTool.test.ts b/apps/client/src/editor/tools/lightZoneTool.test.ts
iindex a1fbe92..59f9442 100644
---- a/apps/client/src/editor/tools/lightZoneTool.test.ts
++++ b/apps/client/src/editor/tools/lightZoneTool.test.ts
@@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { MapZoneSchemaV2, MapLightSchemaV2 } from '@openvibe/content'
import { lightForPlacement, type LightType } from './lightTool.js'
--import { nextZoneId, zoneForPlacement } from './zoneTool.js'
++import { nextZoneId, triggerForPlacement, zoneForPlacement } from './zoneTool.js'
const place = (type: LightType) =>
lightForPlacement({
@@@ -96,3 +96,12 @@ describe('zoneForPlacement', () => {
expect(nextZoneId((id) => taken.has(id))).toBe('zone-3')
})
})
++
++describe('triggerForPlacement', () => {
++ it('produces a trigger_multiple zone the schema accepts', () => {
++ const z = triggerForPlacement({ point: [0, 1, 0], size: 4, height: 3, taken: () => false })
++ const parsed = MapZoneSchemaV2.safeParse(z)
++ expect(parsed.success, parsed.success ? '' : parsed.error.message).toBe(true)
++ if (parsed.success) expect(parsed.data.trigger).toMatchObject({ wait: 0.2, outputs: [] })
++ })
++})
ddiff --git a/apps/client/src/editor/tools/placementTool.ts b/apps/client/src/editor/tools/placementTool.ts
iindex c8d7884..257c534 100644
---- a/apps/client/src/editor/tools/placementTool.ts
++++ b/apps/client/src/editor/tools/placementTool.ts
@@@ -54,6 +54,7 @@ export function placeableShape(
const b = models.find((m) => m.id === def.modelId)?.bounds ?? [1, 1, 1]
return { type: 'box', size: [b[0]!, b[1]!, b[2]!] }
}
++ if (def.kind === 'trigger') return { type: 'box', size: [2, 2, 2] }
return { type: 'box', size: [0.3, 3, 0.3] } // spawn flag pole
}
@@@ -234,6 +235,18 @@ export function objectForPlacement(
kind: 'spawn',
object: { id: 'spawn', pos: [pose.pos.x, pose.pos.y, pose.pos.z], yaw: e.y },
}
++ case 'trigger':
++ return {
++ kind: 'zone',
++ object: {
++ id: newId('trigger'),
++ name: 'Trigger',
++ min: [pose.pos.x - 1, pose.pos.y, pose.pos.z - 1],
++ max: [pose.pos.x + 1, pose.pos.y + 2, pose.pos.z + 1],
++ rules: { pvp: true, build: true, physgun: true },
++ trigger: { wait: 0.2, outputs: [] },
++ },
++ }
case 'patch': {
const sub = 32
return {
ddiff --git a/apps/client/src/editor/tools/zoneTool.ts b/apps/client/src/editor/tools/zoneTool.ts
iindex 6b41585..cd2a8ca 100644
---- a/apps/client/src/editor/tools/zoneTool.ts
++++ b/apps/client/src/editor/tools/zoneTool.ts
@@@ -51,3 +51,27 @@ export function zoneForPlacement(p: ZonePlacement): MapZoneV2 {
rules: { pvp: true, build: true, physgun: true },
}
}
++
++/** Next readable trigger id (`trigger-1`, `trigger-2`, …). */
++export function nextTriggerId(taken: (id: string) => boolean): string {
++ for (let n = 1; n < 10_000; n++) {
++ const id = `trigger-${n}`
++ if (!taken(id)) return id
++ }
++ return `trigger-${Date.now().toString(36)}`
++}
++
++/** A `trigger_multiple` zone, ready for the Inspector's outputs field. */
++export function triggerForPlacement(p: ZonePlacement): MapZoneV2 {
++ const id = nextTriggerId(p.taken)
++ const half = Math.max(0.5, p.size / 2)
++ const height = Math.max(0.5, p.height)
++ return {
++ id,
++ name: `Trigger ${id.replace('trigger-', '')}`,
++ min: [p.point[0] - half, p.point[1], p.point[2] - half],
++ max: [p.point[0] + half, p.point[1] + height, p.point[2] + half],
++ rules: { pvp: true, build: true, physgun: true },
++ trigger: { wait: 0.2, outputs: [] },
++ }
++}
ddiff --git a/apps/client/src/editor/ui/editorShell.ts b/apps/client/src/editor/ui/editorShell.ts
iindex 871fe7e..40492f3 100644
---- a/apps/client/src/editor/ui/editorShell.ts
++++ b/apps/client/src/editor/ui/editorShell.ts
@@@ -243,6 +243,7 @@ export function buildShell(
zone.append(
labelled('Size', slider('zone-size', 2, 80, 1, 16)),
labelled('Height', slider('zone-height', 2, 60, 1, 12)),
++ labelled('Trigger', checkbox('zone-trigger')),
)
const face = el('div', 'strip', 'face-strip')
ddiff --git a/apps/client/src/editor/ui/inspector.test.ts b/apps/client/src/editor/ui/inspector.test.ts
iindex 0614f3c..d9fda20 100644
---- a/apps/client/src/editor/ui/inspector.test.ts
++++ b/apps/client/src/editor/ui/inspector.test.ts
@@@ -59,6 +59,20 @@ describe('fieldsFor', () => {
)
})
++ it('gives a zone the trigger, push and teleport authoring fields', () => {
++ const f = keys(fieldsFor('zone', null))
++ expect(f).toEqual(
++ expect.arrayContaining([
++ 'trigger.once',
++ 'trigger.wait',
++ 'trigger.outputs',
++ 'push.dir',
++ 'push.speed',
++ 'teleport.to',
++ ]),
++ )
++ })
++
it('omits rotation for a resource node, which has no facing', () => {
expect(keys(fieldsFor('node', null))).not.toContain('rot')
})
@@@ -224,6 +238,27 @@ describe('Inspector DOM', () => {
expect(doc.get('s1', 'static')!.pos[0]).toBe(1)
})
++ it('parses the JSON outputs field before committing', () => {
++ const doc = new EditorDocument()
++ doc.add('zone', {
++ id: 'z1',
++ name: 'Z',
++ min: [0, 0, 0],
++ max: [1, 1, 1],
++ rules: { pvp: true, build: true, physgun: true },
++ trigger: { wait: 0.2, outputs: [] },
++ } as never)
++ new Inspector(root, doc, callbacks).setState({ ids: ['z1'], lockedBy: null })
++ const el = root.querySelector('[data-field="trigger.outputs"]') as HTMLInputElement
++ el.value = '[{"event":"OnStartTouch","target":"r1","input":"Trigger"}]'
++ el.dispatchEvent(new Event('change'))
++ expect(callbacks.setProperty).toHaveBeenCalledWith(
++ ['z1'],
++ 'trigger.outputs',
++ [{ event: 'OnStartTouch', target: 'r1', input: 'Trigger' }],
++ )
++ })
++
it('ignores unparseable numeric input and restores the true value', () => {
withStatic()
const x = root.querySelector('[data-field="pos"][data-component="0"]') as HTMLInputElement
ddiff --git a/apps/client/src/editor/ui/inspector.ts b/apps/client/src/editor/ui/inspector.ts
iindex f169f5e..e6863a1 100644
---- a/apps/client/src/editor/ui/inspector.ts
++++ b/apps/client/src/editor/ui/inspector.ts
@@@ -18,7 +18,14 @@ import type { EditorDocument, EditorObject, EditorObjectKind } from '../document
import { KIND_INFO } from '../document/editorObject.js'
import { commonValue, formatNumber } from './numberField.js'
--export type FieldType = 'number' | 'text' | 'color' | 'checkbox' | 'select' | 'readonly'
++export type FieldType =
++ | 'number'
++ | 'text'
++ | 'color'
++ | 'checkbox'
++ | 'select'
++ | 'readonly'
++ | 'json'
export interface FieldSpec {
key: string
@@@ -230,6 +237,14 @@ export function fieldsFor(kind: EditorObjectKind, object: EditorObject | null):
{ key: 'rules.pvp', label: 'PVP', type: 'checkbox', section: 'Rules' },
{ key: 'rules.build', label: 'Building', type: 'checkbox', section: 'Rules' },
{ key: 'rules.physgun', label: 'Physgun', type: 'checkbox', section: 'Rules' },
++ { key: 'trigger.once', label: 'Fire once', type: 'checkbox', section: 'Trigger' },
++ { key: 'trigger.wait', label: 'Wait (s)', type: 'number', step: 0.05, min: 0, section: 'Trigger' },
++ { key: 'trigger.outputs', label: 'Outputs (JSON)', type: 'json', section: 'Trigger' },
++ { key: 'push.dir', label: 'Push dir', type: 'number', vector: 3, section: 'Push' },
++ { key: 'push.speed', label: 'Push speed (u/s)', type: 'number', step: 1, min: 0, section: 'Push' },
++ { key: 'push.onlyFalling', label: 'Only falling', type: 'checkbox', section: 'Push' },
++ { key: 'teleport.to', label: 'Teleport target', type: 'text', section: 'Teleport' },
++ { key: 'teleport.keepAngles', label: 'Keep angles', type: 'checkbox', section: 'Teleport' },
]
}
}
@@@ -278,6 +293,10 @@ export function displayValue(
const bools = raw.map((v) => v === true)
return bools.every((b) => b === bools[0]) ? bools[0]! : null
}
++ if (field.type === 'json') {
++ const strings = raw.map((v) => (v === undefined || v === null ? '' : JSON.stringify(v)))
++ return strings.every((s) => s === strings[0]) ? strings[0]! : null
++ }
const strings = raw.map((v) => (v === undefined || v === null ? '' : String(v)))
return strings.every((s) => s === strings[0]) ? strings[0]! : null
}
@@@ -449,6 +468,16 @@ export class Inspector {
this.refreshValues()
return
}
++ if (field.type === 'json') {
++ try {
++ const value = JSON.parse(input.value)
++ if (!Array.isArray(value)) throw new Error('not an array')
++ this.commit(field, value)
++ } catch {
++ this.refreshValues()
++ }
++ return
++ }
if (field.type !== 'number') {
this.commit(field, input.value)
return
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex f915be9..120bb82 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -27,6 +27,7 @@ import {
SettingsSystem,
SocialSystem,
SurfSystem,
++ TriggersSystem,
VehicleSystem,
WorldEventSystem,
buildHandlerTable,
@@@ -90,6 +91,9 @@ export class GameServer {
// Runs see this tick's authoritative positions: construct after movement,
// insert in the ordered list before replication.
const surf = new SurfSystem(ctx)
++ // Triggers read the same authoritative segments and apply push/teleport
++ // before the next movement step.
++ const triggers = new TriggersSystem(ctx)
// The wire surface every system sends through.
const replication = new ReplicationSystem(ctx)
ctx.net = replication
@@@ -115,6 +119,7 @@ export class GameServer {
chat,
movement,
surf,
++ triggers,
replication,
manipulation,
combat,
@@@ -141,6 +146,7 @@ export class GameServer {
chat,
movement,
surf,
++ triggers,
replication,
manipulation,
combat,
@@@ -301,6 +307,9 @@ export class GameServer {
// 1b. Surf run state: timer, checkpoints, fails, finish (after movement,
// before replication so surf_state travels in this tick's snapshot).
s.surf.tick(ctx, dt)
++ // 1c. Trigger/I/O: derive touch events from the same segments, then apply
++ // push/teleport so they take effect before the next movement step.
++ s.triggers.tick(ctx, dt)
if (this.rush) {
const now = Date.now()
const results = this.rush.tick(
ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex 213eb7d..cdd1314 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -107,6 +107,10 @@ export interface PlayerSession {
surfPbSplits: number[]
/** This tick's authoritative movement segments (cleared by MovementSystem). */
moveSegments: { from: Vec3; to: Vec3; yaw: number }[]
++ /** Per-trigger inside state, so OnStartTouch/OnEndTouch fire once per crossing. */
++ triggerTouches: Map<string, boolean>
++ /** Last tick a trigger fired OnStartTouch, for `trigger_multiple`'s `wait`. */
++ triggerStartTicks: Map<string, number>
/** Tick of the last accepted use/swing (server-side swing cooldown). */
lastUseTick: number
/** Stance the kinematic physics body was last built for. */
@@@ -196,6 +200,8 @@ export function createSession(init: SessionInit): PlayerSession {
surfWr: null,
surfPbSplits: [],
moveSegments: [],
++ triggerTouches: new Map(),
++ triggerStartTicks: new Map(),
lastUseTick: 0,
bodyStance: 0,
content: init.content,
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex e2da564..c440896 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -30,6 +30,7 @@ import type { ReplicationSystem } from './replication.js'
import type { SessionsSystem } from './sessions.js'
import type { SettingsSystem } from './settings.js'
import type { SurfSystem } from './surf.js'
++import type { TriggersSystem } from './triggers.js'
import type { SurfRecords } from '../surfRecords.js'
import type { SocialSystem } from './social.js'
import type { VehicleSystem } from './vehicles.js'
@@@ -197,6 +198,7 @@ export interface ServerSystems {
chat: ChatSystem
movement: MovementSystem
surf: SurfSystem
++ triggers: TriggersSystem
replication: ReplicationSystem
manipulation: ManipulationSystem
combat: CombatSystem
ddiff --git a/apps/server/src/game/systems/index.ts b/apps/server/src/game/systems/index.ts
iindex 4ad6003..64f9eb4 100644
---- a/apps/server/src/game/systems/index.ts
++++ b/apps/server/src/game/systems/index.ts
@@@ -20,6 +20,7 @@ export { SettingsSystem, canChangeServerCvars, SERVER_CVAR_ALLOWLIST } from './s
export { ChatSystem } from '../chatCommands.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { SurfSystem } from './surf.js'
++export { TriggersSystem } from './triggers.js'
export { ReplicationSystem } from './replication.js'
export { ManipulationSystem } from './manipulation.js'
export { CombatSystem } from './combat.js'
ddiff --git a/apps/server/src/game/systems/movement.ts b/apps/server/src/game/systems/movement.ts
iindex 3fc4f69..aa079e8 100644
---- a/apps/server/src/game/systems/movement.ts
++++ b/apps/server/src/game/systems/movement.ts
@@@ -158,12 +158,14 @@ export class MovementSystem implements System {
params: MovementParams,
surfPlace: boolean,
): void {
++ // Always clear, even while driving: TriggersSystem reads these segments
++ // for every room, and a stale previous-tick segment must never re-fire.
++ session.moveSegments.length = 0
if (session.driving) {
ctx.systems.vehicles.driveVehicle(session)
return
}
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
-- session.moveSegments.length = 0
let simulated = 0
for (const input of takeInputs(session, ctx.config.tickRate)) {
session.lastInput = input
@@@ -171,16 +173,13 @@ export class MovementSystem implements System {
session.lastProcessedSeq = input.seq
applyLookInput(session, input)
session.buttons = input.intents
-- const from = surfPlace
-- ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
-- : null
++ const from = vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
stepMovement(session.move, toMoveInput(session, input), params, this.moveQueries, dt)
-- if (from)
-- session.moveSegments.push({
-- from,
-- to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
-- yaw: session.yaw,
-- })
++ session.moveSegments.push({
++ from,
++ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
++ yaw: session.yaw,
++ })
simulated++
}
if (simulated === 0 && session.lastInput) {
@@@ -191,9 +190,7 @@ export class MovementSystem implements System {
if (session.starvedTicks > ctx.config.tickRate / 2) {
// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys
// (prone/crouch), and the server flapped stances.
-- const from = surfPlace
-- ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
-- : null
++ const from = vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
stepMovement(
session.move,
toMoveInput(session, { ...session.lastInput, moveX: 0, moveZ: 0 }),
@@@ -201,12 +198,11 @@ export class MovementSystem implements System {
this.moveQueries,
dt,
)
-- if (from)
-- session.moveSegments.push({
-- from,
-- to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
-- yaw: session.yaw,
-- })
++ session.moveSegments.push({
++ from,
++ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
++ yaw: session.yaw,
++ })
}
}
let bodyId = ctx.sessions.bodyOf(session.playerId)
ddiff --git a/apps/server/src/game/systems/triggers.test.ts b/apps/server/src/game/systems/triggers.test.ts
nnew file mode 100644
iindex 0000000..3dccddd
---- /dev/null
++++ b/apps/server/src/game/systems/triggers.test.ts
@@@ -0,0 +1,140 @@
++/* eslint-disable @typescript-eslint/no-explicit-any */
++import { setMapOverride, type MapOverride, type ZoneDef } from '@openvibe/content'
++import { createMoveState } from '@openvibe/gameplay'
++import { vec3 } from '@openvibe/shared'
++import { describe, expect, it } from 'vitest'
++import { SOURCE_UNIT_TO_METRES, TriggersSystem } from './triggers.js'
++
++const world = { id: 'test-triggers', movement: 'default', zones: [] }
++
++function zone(id: string, over: Record<string, unknown> = {}): ZoneDef {
++ return {
++ id,
++ name: id,
++ min: [0, 0, 0],
++ max: [2, 2, 2],
++ rules: { pvp: true, build: true, physgun: true },
++ ...over,
++ } as unknown as ZoneDef
++}
++
++function override(): MapOverride {
++ return {
++ terrains: [],
++ zones: [
++ zone('start', {
++ min: [0, 0, 0],
++ max: [2, 2, 2],
++ trigger: {
++ wait: 0.2,
++ outputs: [
++ { event: 'OnStartTouch', target: 'flag', input: 'Disable' },
++ { event: 'OnEndTouch', target: 'flag', input: 'Enable' },
++ ],
++ },
++ }),
++ zone('flag', { min: [10, 0, 0], max: [12, 2, 2], trigger: { outputs: [] } }),
++ zone('tp', {
++ min: [20, 0, 0],
++ max: [22, 2, 2],
++ teleport: { to: 'dest', keepAngles: false },
++ }),
++ zone('boost', {
++ min: [30, 0, 0],
++ max: [32, 2, 2],
++ push: { dir: [0, 1, 0], speed: 800, onlyFalling: false },
++ }),
++ ],
++ points: [{ id: 'dest', pos: [5, 6, 7], yaw: 1.2, class: 'teleport_destination' }],
++ logic: [],
++ }
++}
++
++function sessionFor() {
++ const session = {
++ playerId: 'p1',
++ entityId: 'e1',
++ name: 'Tester',
++ yaw: 0,
++ pitch: 0,
++ move: createMoveState(vec3(0, 1, 0)),
++ moveSegments: [] as { from: { x: number; y: number; z: number }; to: { x: number; y: number; z: number }; yaw: number }[],
++ triggerTouches: new Map<string, boolean>(),
++ triggerStartTicks: new Map<string, number>(),
++ }
++ return session
++}
++
++function ctxFor(session: any, tick: number) {
++ return {
++ clock: { tick },
++ config: { tickRate: 100, placeId: 'test-triggers' },
++ world: {
++ content: { world },
++ bodyOf: () => undefined,
++ physics: { setTransform: () => {} },
++ entities: { get: () => undefined },
++ },
++ sessions: { values: () => [session][Symbol.iterator]() },
++ }
++}
++
++describe('TriggersSystem', () => {
++ it('fires OnStartTouch and OnEndTouch exactly once across a crossing', () => {
++ setMapOverride(override(), world.id)
++ try {
++ const session = sessionFor()
++ const system = new TriggersSystem(ctxFor(session, 0) as any)
++ const flag = (system as any).graph.triggers.find((t: any) => t.id === 'flag')
++
++ session.move.pos = vec3(0, 1, -2)
++ session.moveSegments.push({ from: vec3(0, 1, -2), to: vec3(0, 1, 0), yaw: 0 })
++ system.tick(ctxFor(session, 0) as any, 0.01)
++ expect(flag.enabled).toBe(false)
++
++ session.moveSegments.length = 0
++ session.move.pos = vec3(0, 1, 3)
++ session.moveSegments.push({ from: vec3(0, 1, 0), to: vec3(0, 1, 3), yaw: 0 })
++ system.tick(ctxFor(session, 1) as any, 0.01)
++ expect(flag.enabled).toBe(true)
++ } finally {
++ setMapOverride(null, world.id)
++ }
++ })
++
++ it('teleports to a destination, zeroing velocity and setting yaw', () => {
++ setMapOverride(override(), world.id)
++ try {
++ const session = sessionFor()
++ const system = new TriggersSystem(ctxFor(session, 0) as any)
++ session.move.pos = vec3(19, 1, 1)
++ session.move.vel.x = 12
++ session.move.vel.y = -3
++ session.moveSegments.push({ from: vec3(19, 1, 1), to: vec3(21, 1, 1), yaw: 0.4 })
++ system.tick(ctxFor(session, 0) as any, 0.01)
++ expect(session.move.pos.x).toBeCloseTo(5)
++ expect(session.move.pos.y).toBeCloseTo(6)
++ expect(session.move.pos.z).toBeCloseTo(7)
++ expect(session.move.vel).toEqual(vec3(0, 0, 0))
++ expect(session.yaw).toBeCloseTo(1.2)
++ } finally {
++ setMapOverride(null, world.id)
++ }
++ })
++
++ it('applies a push velocity of speed × 0.0254 m/s', () => {
++ setMapOverride(override(), world.id)
++ try {
++ const session = sessionFor()
++ const system = new TriggersSystem(ctxFor(session, 0) as any)
++ session.move.pos = vec3(29, 1, 1)
++ session.moveSegments.push({ from: vec3(29, 1, 1), to: vec3(31, 1, 1), yaw: 0 })
++ system.tick(ctxFor(session, 0) as any, 0.01)
++ expect(session.move.vel.y).toBeCloseTo(800 * SOURCE_UNIT_TO_METRES)
++ expect(session.move.vel.x).toBe(0)
++ expect(session.move.vel.z).toBe(0)
++ } finally {
++ setMapOverride(null, world.id)
++ }
++ })
++})
ddiff --git a/apps/server/src/game/systems/triggers.ts b/apps/server/src/game/systems/triggers.ts
nnew file mode 100644
iindex 0000000..488e04c
---- /dev/null
++++ b/apps/server/src/game/systems/triggers.ts
@@@ -0,0 +1,109 @@
++import { getMapOverride } from '@openvibe/content'
++import {
++ placeMoveStateAt,
++ touchTransition,
++ TriggerGraph,
++ type TriggerZoneRuntime,
++} from '@openvibe/gameplay'
++import { qfromYaw, vec3 } from '@openvibe/shared'
++import type { PlayerSession } from '../playerSession.js'
++import type { ServerContext } from './context.js'
++import type { System } from './system.js'
++
++/**
++ * Source unit → metre conversion for authored movement values. 1 gameplay
++ * unit = 0.0254 m (see the movement research; do not use the 16 u/ft
++ * architecture scale here).
++ */
++export const SOURCE_UNIT_TO_METRES = 0.0254
++
++/**
++ * Deterministic trigger/I/O execution. It runs AFTER MovementSystem (so it
++ * sees this tick's authoritative segments) and AFTER SurfSystem (so surf's
++ * own fail/checkpoint teleports stay authoritative for a surf place). Push
++ * and teleport are applied here, so they land before the next movement step.
++ */
++export class TriggersSystem implements System {
++ readonly name = 'triggers'
++ private readonly graph: TriggerGraph
++
++ constructor(private readonly ctx: ServerContext) {
++ const override = getMapOverride(ctx.world.content.world)
++ this.graph = new TriggerGraph({
++ zones: override?.zones ?? [],
++ points: override?.points ?? [],
++ logic: override?.logic ?? [],
++ tickRate: ctx.config.tickRate,
++ })
++ }
++
++ tick(ctx: ServerContext, _dt: number): void {
++ const now = ctx.clock.tick
++ this.graph.tick(now)
++ for (const session of ctx.sessions.values()) this.stepSession(ctx, session, now)
++ // Delay-0 edges scheduled by a touch event fire in this same tick.
++ this.graph.drain(now)
++ }
++
++ private stepSession(ctx: ServerContext, session: PlayerSession, now: number): void {
++ for (const segment of session.moveSegments) {
++ for (const trigger of this.graph.triggers) {
++ if (!trigger.enabled) continue
++ const wasInside = session.triggerTouches.get(trigger.id) ?? false
++ const transition = touchTransition(trigger, segment.from, segment.to, wasInside)
++ session.triggerTouches.set(trigger.id, transition.nowInside)
++ if (transition.kind === 'start') {
++ this.fireStart(session, trigger, now)
++ this.applyTeleport(ctx, session, trigger)
++ } else if (transition.kind === 'end') {
++ this.graph.fireTouchOutputs(trigger, 'OnEndTouch', now)
++ }
++ if (transition.nowInside) this.applyPush(session, trigger)
++ }
++ }
++ }
++
++ private fireStart(session: PlayerSession, trigger: TriggerZoneRuntime, now: number): void {
++ if (!trigger.trigger) return
++ const waitTicks = Math.round(trigger.trigger.wait * this.ctx.config.tickRate)
++ const last = session.triggerStartTicks.get(trigger.id) ?? Number.NEGATIVE_INFINITY
++ if (waitTicks > 0 && now - last < waitTicks) return
++ session.triggerStartTicks.set(trigger.id, now)
++ this.graph.fireTouchOutputs(trigger, 'OnStartTouch', now)
++ if (trigger.trigger.once) trigger.enabled = false
++ }
++
++ private applyTeleport(ctx: ServerContext, session: PlayerSession, trigger: TriggerZoneRuntime): void {
++ if (trigger.kind !== 'trigger_teleport' || !trigger.teleport) return
++ const dest = this.graph.points.get(trigger.teleport.to)
++ if (!dest) return
++ const yaw = trigger.teleport.keepAngles ? session.yaw : dest.yaw
++ this.teleport(ctx, session, dest.pos, yaw)
++ }
++
++ private teleport(
++ ctx: ServerContext,
++ session: PlayerSession,
++ pos: { x: number; y: number; z: number },
++ yaw: number,
++ ): void {
++ placeMoveStateAt(session.move, pos)
++ session.yaw = yaw
++ const body = ctx.world.bodyOf(session.entityId)
++ if (body !== undefined)
++ ctx.world.physics.setTransform(body, vec3(pos.x, pos.y + 0.15, pos.z))
++ const entity = ctx.world.entities.get(session.entityId)
++ if (entity) qfromYaw(entity.transform.rot, yaw)
++ }
++
++ private applyPush(session: PlayerSession, trigger: TriggerZoneRuntime): void {
++ if (trigger.kind !== 'trigger_push' || !trigger.push) return
++ if (trigger.push.onlyFalling && session.move.grounded) return
++ const [dx, dy, dz] = trigger.push.dir
++ const len = Math.hypot(dx, dy, dz) || 1
++ const m = trigger.push.speed * SOURCE_UNIT_TO_METRES
++ session.move.vel.x = (dx / len) * m
++ session.move.vel.y = (dy / len) * m
++ session.move.vel.z = (dz / len) * m
++ }
++}
ddiff --git a/apps/server/src/maps/scrapIsland.ts b/apps/server/src/maps/scrapIsland.ts
iindex 3875f4b..48aca4f 100644
---- a/apps/server/src/maps/scrapIsland.ts
++++ b/apps/server/src/maps/scrapIsland.ts
@@@ -1060,6 +1060,8 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
],
models: [],
textures: [],
++ points: [],
++ logic: [],
spawn: [ISLAND.spawn[0], round(top(ISLAND.spawn[0], ISLAND.spawn[2])), ISLAND.spawn[2]],
spawnYaw: 0,
}
ddiff --git a/packages/content/src/mapFileV2.test.ts b/packages/content/src/mapFileV2.test.ts
iindex 3c2d4a4..d72e337 100644
---- a/packages/content/src/mapFileV2.test.ts
++++ b/packages/content/src/mapFileV2.test.ts
@@@ -449,6 +449,86 @@ describe('map-authored zones', () => {
})
})
++describe('trigger/I/O round trip', () => {
++ const mapWithIo = (): MapFileV2 => ({
++ ...emptyMapV2(),
++ zones: [
++ {
++ id: 'start',
++ name: 'Start',
++ min: [-2, 0, -2],
++ max: [2, 3, 2],
++ rules: { pvp: true, build: true, physgun: true },
++ trigger: {
++ wait: 0.2,
++ outputs: [{ event: 'OnStartTouch', target: 'relay', input: 'Trigger' }],
++ },
++ },
++ {
++ id: 'boost',
++ name: 'Boost',
++ min: [10, 0, 10],
++ max: [12, 3, 12],
++ rules: { pvp: true, build: true, physgun: true },
++ push: { dir: [0, 1, 0], speed: 800 },
++ },
++ {
++ id: 'tp',
++ name: 'Teleport',
++ min: [20, 0, 20],
++ max: [22, 3, 22],
++ rules: { pvp: true, build: true, physgun: true },
++ teleport: { to: 'dest', keepAngles: false },
++ },
++ ],
++ points: [{ id: 'dest', pos: [0, 1, 0], yaw: 1.5, class: 'teleport_destination' }],
++ logic: [
++ { id: 'relay', class: 'relay', outputs: [{ event: 'OnTrigger', target: 'boost', input: 'Enable' }] },
++ { id: 'auto1', class: 'auto', outputs: [{ event: 'OnMapSpawn', target: 'relay', input: 'Trigger' }] },
++ ],
++ })
++
++ it('parses, validates and compiles points/logic/trigger/push/teleport', () => {
++ const r = parseMapFile(mapWithIo())
++ expect(r.ok, r.ok ? '' : r.issues.join(', ')).toBe(true)
++ if (!r.ok) return
++ expect(validateMapFile(r.map)).toEqual([])
++ const override = compileMapFileV2(r.map)
++ expect(override.points).toEqual([
++ { id: 'dest', pos: [0, 1, 0], yaw: 1.5, class: 'teleport_destination' },
++ ])
++ expect(override.logic).toHaveLength(2)
++ expect(override.zones?.[0]?.trigger?.outputs).toHaveLength(1)
++ expect(override.zones?.[1]?.push?.speed).toBe(800)
++ })
++
++ it('survives a save/reload round trip', () => {
++ const r = parseMapFile(mapWithIo())
++ expect(r.ok).toBe(true)
++ if (!r.ok) return
++ const again = parseMapFile(JSON.parse(canonicalizeMapFile(r.map)))
++ expect(again.ok).toBe(true)
++ if (again.ok) expect(again.map).toEqual(r.map)
++ })
++
++ it('rejects a missing output target and a missing teleport destination', () => {
++ const m = mapWithIo()
++ m.zones[0]!.trigger!.outputs[0]!.target = 'ghost'
++ const missingTarget = validateMapFile(m).join(' ')
++ expect(missingTarget).toContain('missing entity "ghost"')
++
++ const m2 = mapWithIo()
++ m2.zones[2]!.teleport!.to = 'nope'
++ expect(validateMapFile(m2).join(' ')).toContain('is not a point')
++ })
++
++ it('rejects an unknown input through the zod enum', () => {
++ const m = mapWithIo()
++ m.zones[0]!.trigger!.outputs[0]!.input = 'DoSomething' as never
++ expect(parseMapFile(m).ok).toBe(false)
++ })
++})
++
describe('map-authored NPCs', () => {
it('an older map with no npcs reads as none, and a new one starts with none', () => {
const raw = JSON.parse(JSON.stringify(emptyMapV2())) as Record<string, unknown>
ddiff --git a/packages/content/src/mapFileV2.ts b/packages/content/src/mapFileV2.ts
iindex 3ca7002..a762fb3 100644
---- a/packages/content/src/mapFileV2.ts
++++ b/packages/content/src/mapFileV2.ts
@@@ -15,13 +15,17 @@ import { z } from 'zod'
import {
FaceStyleSchema,
HexColorSchema,
++ LogicDefSchema,
MapLightSchema,
++ PointDefSchema,
StaticBodySchema,
ZoneDefSchema,
MovementStyleSchema,
SurfMapMetaSchema,
type FaceStyle,
++ type LogicDef,
type MovementStyle,
++ type PointDef,
type SurfMapMeta,
type ZoneDef,
} from './schema/world.js'
@@@ -171,6 +175,10 @@ export const MapFileV2Schema = z.object({
lights: z.array(MapLightSchemaV2).max(256).default([]),
/** Map-authored zones; they augment the base world's content zones. */
zones: z.array(MapZoneSchemaV2).max(512).default([]),
++ /** Named point entities (`info_target`, `info_teleport_destination`, cameras). */
++ points: z.array(PointDefSchema).max(4096).default([]),
++ /** Logic entities: relay, auto and timer. */
++ logic: z.array(LogicDefSchema).max(4096).default([]),
models: z
.array(z.object({ id: z.string(), name: z.string(), glb: z.string(), bounds: vec3 }))
.max(512)
@@@ -199,6 +207,8 @@ export type TerrainObjectV2 = z.infer<typeof TerrainObjectSchemaV2>
export type StaticObjectV2 = z.infer<typeof StaticObjectSchemaV2>
export type MapLightV2 = z.infer<typeof MapLightSchemaV2>
export type MapZoneV2 = z.infer<typeof MapZoneSchemaV2>
++export type MapPointV2 = PointDef
++export type MapLogicV2 = LogicDef
export type MapNodeV2 = MapFileV2['nodes'][number]
export type MapPropV2 = MapFileV2['props'][number]
export type MapNpcV2 = MapFileV2['npcs'][number]
@@@ -220,6 +230,8 @@ export function emptyMapV2(): MapFileV2 {
npcs: [],
lights: [],
zones: [],
++ points: [],
++ logic: [],
models: [],
textures: [],
}
@@@ -233,6 +245,8 @@ const ID_KINDS = [
['props', 'pr'],
['lights', 'light'],
['zones', 'zone'],
++ ['points', 'point'],
++ ['logic', 'logic'],
['npcs', 'npc'],
] as const
@@@ -365,36 +379,97 @@ export function validateMapFile(map: MapFileV2): string[] {
for (const n of map.npcs) claim(n.id, 'npc')
for (const l of map.lights) claim(l.id, 'light')
for (const z0 of map.zones) claim(z0.id, 'zone')
++ for (const p of map.points) claim(p.id, 'point')
++ for (const l of map.logic) claim(l.id, 'logic')
if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)
++ issues.push(...ioIssues(map))
if (map.surf) issues.push(...surfIssues(map))
return issues
}
++/**
++ * Cross-field I/O checks the schema cannot express: every output target must
++ * resolve to an entity id, and a teleport destination must be a point.
++ * Inputs are already type-checked by `IoInputSchema`; here we check the edges.
++ */
++function ioIssues(map: MapFileV2): string[] {
++ const issues: string[] = []
++ const points = new Set(map.points.map((p) => p.id))
++ const targets = new Set<string>([
++ ...map.points.map((p) => p.id),
++ ...map.logic.map((l) => l.id),
++ ...map.zones.map((z) => z.id),
++ ...map.statics.map((s) => s.brushEntity?.targetname ?? s.id),
++ ])
++
++ const checkOutputs = (owner: string, outputs: readonly { target: string }[]): void => {
++ for (const o of outputs)
++ if (!targets.has(o.target)) issues.push(`${owner}: output targets missing entity "${o.target}"`)
++ }
++
++ for (const z of map.zones) {
++ if (z.teleport && !points.has(z.teleport.to))
++ issues.push(`zone "${z.id}": teleport destination "${z.teleport.to}" is not a point`)
++ if (z.push) {
++ const [x, y, zz] = z.push.dir
++ if (Math.hypot(x, y, zz) < 1e-9)
++ issues.push(`zone "${z.id}": push dir must not be the zero vector`)
++ }
++ if (z.trigger) checkOutputs(`zone "${z.id}"`, z.trigger.outputs)
++ }
++ for (const l of map.logic) checkOutputs(`logic "${l.id}"`, l.outputs)
++ for (const s of map.statics)
++ if (s.brushEntity) checkOutputs(`static "${s.id}"`, s.brushEntity.outputs)
++ return issues
++}
++
++/** `surf.kind` plus the Source timer zone-name aliases. */
++function surfMetaFor(
++ zone: MapZoneV2,
++ stages: number,
++): { kind: 'start' | 'checkpoint' | 'finish' | 'kill'; stage: number; spawn?: [number, number, number] } | null {
++ if (zone.surf) {
++ return {
++ kind: zone.surf.kind,
++ stage: zone.surf.stage,
++ ...(zone.surf.spawn ? { spawn: zone.surf.spawn } : {}),
++ }
++ }
++ const id = zone.id
++ if (id === 'map_start' || zone.name === 'map_start') return { kind: 'start', stage: 0 }
++ if (id === 'map_end' || zone.name === 'map_end') return { kind: 'finish', stage: stages }
++ if (id === 'map_kill' || zone.name === 'map_kill') return { kind: 'kill', stage: 0 }
++ const cp = /^map_cp(\d+)$/.exec(id) ?? /^map_cp(\d+)$/.exec(zone.name)
++ if (cp) return { kind: 'checkpoint', stage: Number(cp[1]) }
++ return null
++}
++
/** A surf map's zones must describe exactly one ordered run. */
function surfIssues(map: MapFileV2): string[] {
const issues: string[] = []
const surf = map.surf
if (!surf) return issues
-- const zones = map.zones.filter((z) => z.surf)
-- const starts = zones.filter((z) => z.surf!.kind === 'start')
-- const finishes = zones.filter((z) => z.surf!.kind === 'finish')
++ const zones = map.zones
++ .map((z) => ({ zone: z, meta: surfMetaFor(z, surf.stages) }))
++ .filter((x) => x.meta !== null)
++ const starts = zones.filter((z) => z.meta!.kind === 'start')
++ const finishes = zones.filter((z) => z.meta!.kind === 'finish')
if (starts.length !== 1) issues.push(`surf: expected one start zone, found ${starts.length}`)
if (finishes.length !== 1) issues.push(`surf: expected one finish zone, found ${finishes.length}`)
if (map.movement !== 'surf') issues.push('surf: a surf map must set movement to "surf"')
-- for (const zone of zones) {
-- const meta = zone.surf!
-- const maxStage = meta.kind === 'finish' ? surf.stages : surf.stages - 1
-- if (meta.stage > maxStage)
-- issues.push(`surf zone "${zone.id}": stage ${meta.stage} exceeds ${maxStage}`)
-- if (meta.kind === 'checkpoint' && !meta.spawn)
++ for (const { zone, meta } of zones) {
++ const maxStage = meta!.kind === 'finish' ? surf.stages : surf.stages - 1
++ if (meta!.stage > maxStage)
++ issues.push(`surf zone "${zone.id}": stage ${meta!.stage} exceeds ${maxStage}`)
++ if (meta!.kind === 'checkpoint' && !meta!.spawn)
issues.push(`surf zone "${zone.id}": a checkpoint needs a respawn point`)
}
-- const checkpoints = zones.filter((z) => z.surf!.kind === 'checkpoint')
++ const checkpoints = zones.filter((z) => z.meta!.kind === 'checkpoint')
if (checkpoints.length !== surf.stages - 1)
issues.push(
`surf: ${checkpoints.length} checkpoints for ${surf.stages} stages (expected ${surf.stages - 1})`,
)
-- const stages = checkpoints.map((z) => z.surf!.stage).sort((a, b) => a - b)
++ const stages = checkpoints.map((z) => z.meta!.stage).sort((a, b) => a - b)
for (let i = 0; i < stages.length; i++)
if (stages[i] !== i + 1) {
issues.push(`surf: checkpoint stages must be 1..${surf.stages - 1}, got ${stages.join(',')}`)
@@@ -471,6 +546,8 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {
})),
statics: map.statics.map((b) => ({ ...b })),
zones: map.zones.map((z0): ZoneDef => ({ ...z0, min: [...z0.min], max: [...z0.max] })),
++ points: map.points.map((p) => ({ ...p, pos: [...p.pos] })),
++ logic: map.logic.map((l) => ({ ...l })),
...(map.movement ? { movement: map.movement } : {}),
...(map.surf ? { surf: map.surf } : {}),
...(map.spawn ? { spawn: map.spawn } : {}),
ddiff --git a/packages/content/src/schema/world.ts b/packages/content/src/schema/world.ts
iindex 583d5b3..253f54b 100644
---- a/packages/content/src/schema/world.ts
++++ b/packages/content/src/schema/world.ts
@@@ -91,6 +91,104 @@ export const FaceStyleSchema = z.object({
})
export type FaceStyle = z.infer<typeof FaceStyleSchema>
++/**
++ * Inputs an entity accepts through the deterministic I/O graph. Source has a
++ * much larger vocabulary; this is the subset the surf trigger core needs plus
++ * the door/button inputs the next tier will drive. An unknown input is a
++ * parse error, so a typo in the editor cannot silently do nothing.
++ */
++export const IoInputSchema = z.enum([
++ 'Enable',
++ 'Disable',
++ 'Toggle',
++ 'Trigger',
++ 'Open',
++ 'Close',
++ 'Press',
++ 'Teleport',
++ 'SetSpeed',
++ 'ResetTimer',
++ 'FireTimer',
++ 'CancelPending',
++ 'TouchTest',
++])
++export type IoInput = z.infer<typeof IoInputSchema>
++
++/**
++ * One edge in the event graph: "when `event` happens, send `input` to
++ * `target` after `delay` seconds". `once` means the edge fires at most once
++ * for the lifetime of the map.
++ */
++export const OutputSchema = z.object({
++ event: z.string().min(1).max(64),
++ target: z.string().min(1).max(64),
++ input: IoInputSchema,
++ param: z.string().optional(),
++ delay: z.number().finite().nonnegative().optional(),
++ once: z.boolean().optional(),
++})
++export type Output = z.infer<typeof OutputSchema>
++
++/** `trigger_multiple` / `trigger_once`: a volume that fires touch outputs. */
++export const TriggerZoneSchema = z.object({
++ wait: z.number().finite().nonnegative().optional(),
++ once: z.boolean().optional(),
++ outputs: z.array(OutputSchema).default([]),
++})
++export type TriggerZone = z.infer<typeof TriggerZoneSchema>
++
++/** `trigger_push`: a volume that applies a velocity to players inside it. */
++export const PushZoneSchema = z.object({
++ dir: finiteVec3,
++ /** Magnitude in Source units/s; the engine converts to m/s at runtime. */
++ speed: z.number().finite().min(0),
++ onlyFalling: z.boolean().optional(),
++})
++export type PushZone = z.infer<typeof PushZoneSchema>
++
++/** `trigger_teleport`: a volume that moves a player to a destination point. */
++export const TeleportZoneSchema = z.object({
++ to: z.string().min(1).max(64),
++ landmark: z.string().min(1).max(64).optional(),
++ keepAngles: z.boolean().optional(),
++})
++export type TeleportZone = z.infer<typeof TeleportZoneSchema>
++
++/**
++ * A point entity (`info_target`, `info_teleport_destination`, camera marker):
++ * a named position and optional facing, referenced by trigger outputs.
++ */
++export const PointDefSchema = z.object({
++ id: z.string().regex(/^[a-z0-9_-]+$/),
++ pos: finiteVec3,
++ yaw: z.number().optional(),
++ class: z.enum(['target', 'teleport_destination', 'camera']).default('target'),
++})
++export type PointDef = z.infer<typeof PointDefSchema>
++
++/**
++ * A logic entity: `logic_relay` forwards inputs, `logic_auto` fires on map
++ * spawn, and `logic_timer` fires on an interval.
++ */
++export const LogicDefSchema = z.object({
++ id: z.string().regex(/^[a-z0-9_-]+$/),
++ class: z.enum(['relay', 'auto', 'timer']),
++ wait: z.number().finite().nonnegative().optional(),
++ outputs: z.array(OutputSchema).default([]),
++})
++export type LogicDef = z.infer<typeof LogicDefSchema>
++
++/** A brush entity (`func_door`, `func_button`, `func_brush`) attached to a static. */
++export const BrushEntitySchema = z.object({
++ class: z.enum(['door', 'button', 'brush']),
++ targetname: z.string().min(1).max(64).optional(),
++ speed: z.number().finite().nonnegative().optional(),
++ lip: z.number().finite().nonnegative().optional(),
++ wait: z.number().finite().nonnegative().optional(),
++ outputs: z.array(OutputSchema).default([]),
++})
++export type BrushEntity = z.infer<typeof BrushEntitySchema>
++
export const StaticBodySchema = z.object({
/** Stable editor/document id (selection, collab locks). */
id: z.string().optional(),
@@@ -119,6 +217,8 @@ export const StaticBodySchema = z.object({
uv: FaceStyleSchema.optional(),
/** Per-face overrides for box statics (face index '0'..'5'). */
faces: z.record(z.string(), FaceStyleSchema).optional(),
++ /** Optional brush-entity behaviour (`func_door`/`func_button`/`func_brush`). */
++ brushEntity: BrushEntitySchema.optional(),
})
export const ResourceNodeSpawnSchema = z.object({
@@@ -175,6 +275,12 @@ export const ZoneDefSchema = z.object({
}),
/** Surf trigger metadata; absent on ordinary rule zones. */
surf: SurfZoneMetaSchema.optional(),
++ /** `trigger_multiple` / `trigger_once` touch outputs. */
++ trigger: TriggerZoneSchema.optional(),
++ /** `trigger_push` velocity volume. */
++ push: PushZoneSchema.optional(),
++ /** `trigger_teleport` destination volume. */
++ teleport: TeleportZoneSchema.optional(),
})
export const WorldDefSchema = z.object({
ddiff --git a/packages/content/src/terrain.ts b/packages/content/src/terrain.ts
iindex c2913bd..f878e31 100644
---- a/packages/content/src/terrain.ts
++++ b/packages/content/src/terrain.ts
@@@ -1,5 +1,5 @@
import type { SurfaceMaterialData } from './surface.js'
--import type { FaceStyle, StaticBody, WorldDef, ZoneDef } from './schema/world.js'
++import type { FaceStyle, LogicDef, PointDef, StaticBody, WorldDef, ZoneDef } from './schema/world.js'
/**
* Deterministic terrain heightfield shared by server physics, client
@@@ -189,6 +189,10 @@ export interface MapOverride {
* The base list is never mutated — see `ZoneIndex.setMapZones`.
*/
zones?: ZoneDef[]
++ /** Named point entities (`info_target`, `info_teleport_destination`, cameras). */
++ points?: PointDef[]
++ /** Logic entities: relay, auto and timer. */
++ logic?: LogicDef[]
/** Editor-placed spawn point (overrides the world def). */
spawn?: [number, number, number]
spawnYaw?: number
ddiff --git a/packages/gameplay/src/entities/io.ts b/packages/gameplay/src/entities/io.ts
nnew file mode 100644
iindex 0000000..ed78366
---- /dev/null
++++ b/packages/gameplay/src/entities/io.ts
@@@ -0,0 +1,74 @@
++/**
++ * The deterministic I/O queue. One event edge is an output: when `event`
++ * fires, send `input` to `target` after `delay` seconds. Seconds become
++ * whole ticks with `Math.round`, so the same map and the same tick rate
++ * always produce the same ordering — server and prediction cannot disagree
++ * about when something fires.
++ */
++
++export interface OutputSpec {
++ event: string
++ target: string
++ input: string
++ param?: string | undefined
++ delay?: number | undefined
++ once?: boolean | undefined
++}
++
++export interface QueuedOutput {
++ dueTick: number
++ /** Monotonic insertion order, the tie-breaker when two edges share a tick. */
++ seq: number
++ event: string
++ target: string
++ input: string
++ param?: string
++ once?: boolean
++}
++
++export function delayTicks(delaySeconds: number | undefined, tickRate: number): number {
++ if (delaySeconds === undefined || delaySeconds <= 0) return 0
++ return Math.round(delaySeconds * tickRate)
++}
++
++/** A stable identity for an output, used to honour `once`. */
++export function outputKey(output: OutputSpec): string {
++ return `${output.event}\u0000${output.target}\u0000${output.input}\u0000${output.param ?? ''}`
++}
++
++export class OutputQueue {
++ private items: QueuedOutput[] = []
++ private nextSeq = 0
++
++ get length(): number {
++ return this.items.length
++ }
++
++ schedule(outputs: readonly OutputSpec[], nowTick: number, tickRate: number): void {
++ for (const output of outputs) {
++ this.items.push({
++ dueTick: nowTick + delayTicks(output.delay, tickRate),
++ seq: this.nextSeq++,
++ event: output.event,
++ target: output.target,
++ input: output.input,
++ ...(output.param !== undefined ? { param: output.param } : {}),
++ ...(output.once !== undefined ? { once: output.once } : {}),
++ })
++ }
++ this.items.sort((a, b) => a.dueTick - b.dueTick || a.seq - b.seq)
++ }
++
++ /** Pop every edge due at or before `nowTick`, in (dueTick, seq) order. */
++ due(nowTick: number): QueuedOutput[] {
++ const out: QueuedOutput[] = []
++ while (this.items.length > 0 && this.items[0]!.dueTick <= nowTick) {
++ out.push(this.items.shift()!)
++ }
++ return out
++ }
++
++ clear(): void {
++ this.items = []
++ }
++}
ddiff --git a/packages/gameplay/src/entities/logic.ts b/packages/gameplay/src/entities/logic.ts
nnew file mode 100644
iindex 0000000..fe2141c
---- /dev/null
++++ b/packages/gameplay/src/entities/logic.ts
@@@ -0,0 +1,102 @@
++import type { LogicDef } from '@openvibe/content'
++import { delayTicks, type OutputSpec } from './io.js'
++
++/**
++ * Runtime logic entities: relay forwards inputs, auto fires on map spawn,
++ * and timer fires on an interval. All are pure data plus a small state
++ * machine — no wall clock, only ticks.
++ */
++
++export type LogicClass = 'relay' | 'auto' | 'timer'
++
++export interface LogicEntity {
++ id: string
++ class: LogicClass
++ enabled: boolean
++ wait: number
++ outputs: OutputSpec[]
++ /** Timer only: the tick at which the next OnTimer is due. */
++ nextFireTick: number | null
++}
++
++export function buildLogicEntity(def: LogicDef): LogicEntity {
++ return {
++ id: def.id,
++ class: def.class,
++ enabled: def.class !== 'auto',
++ wait: def.wait ?? 0,
++ outputs: def.outputs.map((o) => ({ ...o })),
++ nextFireTick: null,
++ }
++}
++
++export function timerIntervalTicks(entity: LogicEntity, tickRate: number): number {
++ return Math.max(1, delayTicks(entity.wait, tickRate))
++}
++
++/**
++ * Fire an input on a logic entity. Returns the outputs it wants scheduled.
++ * Timer scheduling lives here so `Enable`/`ResetTimer` and the graph's tick
++ * cannot disagree about the next due tick.
++ */
++export function fireLogicInput(
++ entity: LogicEntity,
++ input: string,
++ nowTick: number,
++ tickRate: number,
++): OutputSpec[] {
++ switch (entity.class) {
++ case 'relay': {
++ if (input === 'Trigger') {
++ if (!entity.enabled) return []
++ return entity.outputs
++ }
++ if (input === 'Enable') {
++ entity.enabled = true
++ return []
++ }
++ if (input === 'Disable') {
++ entity.enabled = false
++ return []
++ }
++ if (input === 'Toggle') {
++ entity.enabled = !entity.enabled
++ return []
++ }
++ return []
++ }
++ case 'auto': {
++ // Auto has no runtime inputs; its OnMapSpawn is seeded by the graph.
++ return []
++ }
++ case 'timer': {
++ if (input === 'Enable') {
++ entity.enabled = true
++ entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)
++ return []
++ }
++ if (input === 'Disable') {
++ entity.enabled = false
++ entity.nextFireTick = null
++ return []
++ }
++ if (input === 'Toggle') {
++ entity.enabled = !entity.enabled
++ entity.nextFireTick = entity.enabled
++ ? nowTick + timerIntervalTicks(entity, tickRate)
++ : null
++ return []
++ }
++ if (input === 'ResetTimer') {
++ if (entity.enabled) entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)
++ return []
++ }
++ if (input === 'FireTimer') {
++ const outputs = entity.outputs
++ if (entity.enabled) entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)
++ return outputs
++ }
++ return []
++ }
++ }
++}
ddiff --git a/packages/gameplay/src/entities/trigger.test.ts b/packages/gameplay/src/entities/trigger.test.ts
nnew file mode 100644
iindex 0000000..bf3cba6
---- /dev/null
++++ b/packages/gameplay/src/entities/trigger.test.ts
@@@ -0,0 +1,111 @@
++import { describe, expect, it } from 'vitest'
++import type { ZoneDef } from '@openvibe/content'
++import { delayTicks, OutputQueue } from './io.js'
++import {
++ buildTriggerZone,
++ classifyZone,
++ touchTransition,
++ TriggerGraph,
++} from './trigger.js'
++
++const zone = (over: Record<string, unknown> = {}): ZoneDef =>
++ ({
++ id: 't1',
++ name: 'Trigger',
++ min: [-1, 0, -1],
++ max: [1, 2, 1],
++ rules: { pvp: true, build: true, physgun: true },
++ ...over,
++ }) as unknown as ZoneDef
++
++const p = (x = 0, y = 1, z = 0): { x: number; y: number; z: number } => ({ x, y, z })
++
++describe('OutputQueue', () => {
++ it('orders by due tick, then insertion sequence', () => {
++ const q = new OutputQueue()
++ q.schedule([{ event: 'a', target: 'x', input: 'Trigger', delay: 2 }], 0, 10)
++ q.schedule([{ event: 'b', target: 'x', input: 'Trigger', delay: 1 }], 0, 10)
++ q.schedule([{ event: 'c', target: 'x', input: 'Trigger', delay: 1 }], 0, 10)
++ expect(delayTicks(2, 10)).toBe(20)
++ expect(delayTicks(1, 10)).toBe(10)
++ expect(q.due(9)).toEqual([])
++ expect(q.due(10).map((e) => e.event)).toEqual(['b', 'c'])
++ expect(q.due(20).map((e) => e.event)).toEqual(['a'])
++ })
++})
++
++describe('classifyZone / buildTriggerZone', () => {
++ it('recognises teleport, push, once and multiple volumes', () => {
++ expect(classifyZone(zone({ teleport: { to: 'dest' } }))).toBe('trigger_teleport')
++ expect(classifyZone(zone({ push: { dir: [0, 1, 0], speed: 100 } }))).toBe('trigger_push')
++ expect(classifyZone(zone({ trigger: { once: true, outputs: [] } }))).toBe('trigger_once')
++ expect(classifyZone(zone({ trigger: { outputs: [] } }))).toBe('trigger_multiple')
++ expect(classifyZone(zone())).toBeNull()
++ })
++
++ it('defaults trigger_multiple wait to the Source-like 0.2s', () => {
++ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!
++ expect(trigger.trigger!.wait).toBe(0.2)
++ })
++})
++
++describe('touchTransition', () => {
++ it('fires start and end once per crossing, and reports through-crossing starts', () => {
++ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!
++ const start = touchTransition(trigger, p(0, 1, -2), p(0, 1, 0), false)
++ expect(start.kind).toBe('start')
++ expect(start.nowInside).toBe(true)
++
++ const stay = touchTransition(trigger, p(0, 1, 0), p(0.5, 1, 0), true)
++ expect(stay.kind).toBe('none')
++ expect(stay.nowInside).toBe(true)
++
++ const end = touchTransition(trigger, p(0, 1, 0), p(0, 1, 2), true)
++ expect(end.kind).toBe('end')
++ expect(end.nowInside).toBe(false)
++ })
++
++ it('reports a start for a catch-up segment that passes all the way through', () => {
++ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!
++ const through = touchTransition(trigger, p(0, 1, -2), p(0, 1, 2), false)
++ expect(through.kind).toBe('start')
++ })
++})
++
++describe('TriggerGraph', () => {
++ it('fires logic_auto OnMapSpawn edges on the first tick', () => {
++ const t = zone({ id: 'vol', trigger: { outputs: [] } })
++ const graph = new TriggerGraph({
++ zones: [t],
++ points: [],
++ logic: [
++ { id: 'auto1', class: 'auto', outputs: [{ event: 'OnMapSpawn', target: 'vol', input: 'Disable' }] },
++ ],
++ tickRate: 100,
++ })
++ expect(graph.triggers[0]!.enabled).toBe(true)
++ graph.tick(0)
++ expect(graph.triggers[0]!.enabled).toBe(false)
++ })
++
++ it('makes logic_relay delays tick-deterministic', () => {
++ const t = zone({ id: 'vol', trigger: { outputs: [] } })
++ const graph = new TriggerGraph({
++ zones: [t],
++ points: [],
++ logic: [
++ {
++ id: 'relay',
++ class: 'relay',
++ outputs: [{ event: 'OnTrigger', target: 'vol', input: 'Disable', delay: 0.2 }],
++ },
++ ],
++ tickRate: 100,
++ })
++ graph.fireInput('relay', 'Trigger', undefined, 0)
++ graph.drain(19)
++ expect(graph.triggers[0]!.enabled).toBe(true)
++ graph.drain(20)
++ expect(graph.triggers[0]!.enabled).toBe(false)
++ })
++})
ddiff --git a/packages/gameplay/src/entities/trigger.ts b/packages/gameplay/src/entities/trigger.ts
nnew file mode 100644
iindex 0000000..3cb76a4
---- /dev/null
++++ b/packages/gameplay/src/entities/trigger.ts
@@@ -0,0 +1,263 @@
++import type { LogicDef, PointDef, ZoneDef } from '@openvibe/content'
++import { insideBox, segmentAabbEntry, segmentAabbExit } from '../surf/run.js'
++import { OutputQueue, outputKey, type OutputSpec } from './io.js'
++import {
++ buildLogicEntity,
++ fireLogicInput,
++ timerIntervalTicks,
++ type LogicEntity,
++} from './logic.js'
++
++/**
++ * Runtime trigger entities. These are engine-free: the server feeds them
++ * authoritative movement segments, and the same shape can be consumed by a
++ * 2D/top-down presentation without a physics body anywhere in the model.
++ */
++
++export type TriggerKind =
++ | 'trigger_multiple'
++ | 'trigger_once'
++ | 'trigger_teleport'
++ | 'trigger_push'
++
++export interface Vec3 {
++ x: number
++ y: number
++ z: number
++}
++
++export interface Aabb {
++ min: readonly [number, number, number]
++ max: readonly [number, number, number]
++}
++
++export interface TriggerZoneRuntime {
++ id: string
++ kind: TriggerKind
++ min: [number, number, number]
++ max: [number, number, number]
++ trigger?: { wait: number; once: boolean; outputs: OutputSpec[] }
++ push?: { dir: [number, number, number]; speed: number; onlyFalling: boolean }
++ teleport?: { to: string; landmark?: string; keepAngles: boolean }
++ enabled: boolean
++}
++
++export interface PointEntity {
++ id: string
++ pos: Vec3
++ yaw: number
++ class: 'target' | 'teleport_destination' | 'camera'
++}
++
++export const DEFAULT_MULTIPLE_WAIT = 0.2
++
++export function classifyZone(zone: ZoneDef): TriggerKind | null {
++ if (zone.teleport) return 'trigger_teleport'
++ if (zone.push) return 'trigger_push'
++ if (zone.trigger) return zone.trigger.once ? 'trigger_once' : 'trigger_multiple'
++ return null
++}
++
++export function buildTriggerZone(zone: ZoneDef): TriggerZoneRuntime | null {
++ const kind = classifyZone(zone)
++ if (!kind) return null
++ return {
++ id: zone.id,
++ kind,
++ min: [zone.min[0], zone.min[1], zone.min[2]],
++ max: [zone.max[0], zone.max[1], zone.max[2]],
++ ...(zone.trigger
++ ? {
++ trigger: {
++ wait: zone.trigger.wait ?? (zone.trigger.once ? 0 : DEFAULT_MULTIPLE_WAIT),
++ once: zone.trigger.once ?? false,
++ outputs: zone.trigger.outputs.map((o) => ({ ...o })),
++ },
++ }
++ : {}),
++ ...(zone.push
++ ? {
++ push: {
++ dir: [zone.push.dir[0], zone.push.dir[1], zone.push.dir[2]],
++ speed: zone.push.speed,
++ onlyFalling: zone.push.onlyFalling ?? false,
++ },
++ }
++ : {}),
++ ...(zone.teleport
++ ? {
++ teleport: {
++ to: zone.teleport.to,
++ ...(zone.teleport.landmark !== undefined
++ ? { landmark: zone.teleport.landmark }
++ : {}),
++ keepAngles: zone.teleport.keepAngles ?? false,
++ },
++ }
++ : {}),
++ enabled: true,
++ }
++}
++
++export function buildPointEntity(point: PointDef): PointEntity {
++ return {
++ id: point.id,
++ pos: { x: point.pos[0], y: point.pos[1], z: point.pos[2] },
++ yaw: point.yaw ?? 0,
++ class: point.class,
++ }
++}
++
++/** Outputs whose `event` matches a touch/trigger event name. */
++export function outputsFor(trigger: TriggerZoneRuntime, event: string): OutputSpec[] {
++ return (trigger.trigger?.outputs ?? []).filter((o) => o.event === event)
++}
++
++export type TouchTransition =
++ | { kind: 'start'; frac: number; nowInside: boolean }
++ | { kind: 'end'; frac: number; nowInside: boolean }
++ | { kind: 'none'; nowInside: boolean }
++
++/**
++ * What one movement segment did to one trigger volume. Entry uses the slab
++ * test, so a segment that starts outside and ends outside still reports a
++ * start if it passed through the box — the catch-up guarantee.
++ */
++export function touchTransition(
++ trigger: TriggerZoneRuntime,
++ from: Vec3,
++ to: Vec3,
++ wasInside: boolean,
++): TouchTransition {
++ const box: Aabb = { min: trigger.min, max: trigger.max }
++ if (wasInside) {
++ if (!insideBox(box, to)) {
++ const frac = segmentAabbExit(from, to, box) ?? 1
++ return { kind: 'end', frac, nowInside: false }
++ }
++ return { kind: 'none', nowInside: true }
++ }
++ const entry = segmentAabbEntry(from, to, box)
++ if (entry !== null) return { kind: 'start', frac: entry, nowInside: insideBox(box, to) }
++ return { kind: 'none', nowInside: insideBox(box, to) }
++}
++
++export interface TriggerGraphInput {
++ zones: readonly ZoneDef[]
++ points: readonly PointDef[]
++ logic: readonly LogicDef[]
++ tickRate: number
++}
++
++const MAX_DRAIN_ITERATIONS = 10_000
++
++/**
++ * The whole map's trigger graph: trigger volumes, point entities, logic
++ * entities and the one deterministic queue that connects them.
++ *
++ * `drain` fires every edge due at the current tick, including delay-0 edges
++ * scheduled by an earlier edge in the same tick, so a relay chain resolves
++ * immediately and deterministically.
++ */
++export class TriggerGraph {
++ readonly triggers: TriggerZoneRuntime[]
++ readonly points = new Map<string, PointEntity>()
++ private readonly logic = new Map<string, LogicEntity>()
++ private readonly queue = new OutputQueue()
++ private readonly tickRate: number
++ private readonly firedOnce = new Set<string>()
++ private started = false
++
++ constructor(input: TriggerGraphInput) {
++ this.tickRate = input.tickRate
++ this.triggers = input.zones
++ .map(buildTriggerZone)
++ .filter((t): t is TriggerZoneRuntime => t !== null)
++ for (const point of input.points) {
++ const p = buildPointEntity(point)
++ this.points.set(p.id, p)
++ }
++ for (const def of input.logic) {
++ const l = buildLogicEntity(def)
++ this.logic.set(l.id, l)
++ }
++ }
++
++ /** Seed `logic_auto` OnMapSpawn edges and initial timers, once. */
++ begin(nowTick: number): void {
++ if (this.started) return
++ this.started = true
++ for (const l of this.logic.values()) {
++ if (l.class !== 'auto') continue
++ this.schedule(l.outputs, nowTick)
++ }
++ }
++
++ /** Fire every edge due at `nowTick`, following delay-0 chains. */
++ drain(nowTick: number): void {
++ for (let i = 0; i < MAX_DRAIN_ITERATIONS; i++) {
++ const due = this.queue.due(nowTick)
++ if (due.length === 0) return
++ for (const edge of due) this.dispatch(edge.target, edge.input, edge.param, nowTick)
++ }
++ }
++
++ /** One tick: seed auto, fire due timers, then drain. */
++ tick(nowTick: number): void {
++ this.begin(nowTick)
++ for (const l of this.logic.values()) {
++ if (l.class !== 'timer' || !l.enabled) continue
++ if (l.nextFireTick === null || l.nextFireTick > nowTick) continue
++ this.schedule(l.outputs, nowTick)
++ l.nextFireTick = nowTick + timerIntervalTicks(l, this.tickRate)
++ }
++ this.drain(nowTick)
++ }
++
++ /** Schedule outputs from a touch event (does not drain; call drain after). */
++ fireTouchOutputs(trigger: TriggerZoneRuntime, event: string, nowTick: number): void {
++ this.schedule(outputsFor(trigger, event), nowTick)
++ }
++
++ /** Send an input to a named entity; schedules whatever it returns. */
++ fireInput(target: string, input: string, param: string | undefined, nowTick: number): void {
++ this.dispatch(target, input, param, nowTick)
++ }
++
++ /** Schedule edges, honouring `once` and dropping unknown targets. */
++ private schedule(outputs: readonly OutputSpec[], nowTick: number): void {
++ const pending: OutputSpec[] = []
++ for (const output of outputs) {
++ const key = outputKey(output)
++ if (output.once && this.firedOnce.has(key)) continue
++ if (output.once) this.firedOnce.add(key)
++ pending.push(output)
++ }
++ this.queue.schedule(pending, nowTick, this.tickRate)
++ }
++
++ private dispatch(target: string, input: string, param: string | undefined, nowTick: number): void {
++ const logic = this.logic.get(target)
++ if (logic) {
++ this.schedule(fireLogicInput(logic, input, nowTick, this.tickRate), nowTick)
++ return
++ }
++ const trigger = this.triggers.find((t) => t.id === target)
++ if (!trigger) return
++ if (input === 'Enable') {
++ trigger.enabled = true
++ return
++ }
++ if (input === 'Disable') {
++ trigger.enabled = false
++ return
++ }
++ if (input === 'Toggle') {
++ trigger.enabled = !trigger.enabled
++ return
++ }
++ if (input === 'Trigger') {
++ this.schedule(outputsFor(trigger, 'OnTrigger'), nowTick)
++ }
++ }
++}
ddiff --git a/packages/gameplay/src/index.ts b/packages/gameplay/src/index.ts
iindex fb8858d..ebe269e 100644
---- a/packages/gameplay/src/index.ts
++++ b/packages/gameplay/src/index.ts
@@@ -9,6 +9,9 @@ export * from './movement/simulate.js'
export * from './movement/styles.js'
export * from './surf/run.js'
export * from './surf/ghost.js'
++export * from './entities/io.js'
++export * from './entities/trigger.js'
++export * from './entities/logic.js'
export * from './skills/skills.js'
export * from './zones/zones.js'
export * from './entity/entity.js'
ddiff --git a/packages/gameplay/src/surf/run.ts b/packages/gameplay/src/surf/run.ts
iindex d175653..764c165 100644
---- a/packages/gameplay/src/surf/run.ts
++++ b/packages/gameplay/src/surf/run.ts
@@@ -151,6 +151,22 @@ export function segmentAabbExit(a: SurfVec3, b: SurfVec3, box: SurfBox): number
return tExit >= 0 && tExit <= 1 ? tExit : null
}
++/**
++ * Source surf timer zone aliases. `surf.kind` stays the canonical form; a
++ * map authored with the interoperable trigger names (`map_start`, `map_cpN`,
++ * `map_end`, `map_kill`) still builds a run without duplicating the kind.
++ */
++export function surfAliasFor(
++ idOrName: string,
++): { kind: SurfZoneKind; stage?: number } | null {
++ if (idOrName === 'map_start') return { kind: 'start', stage: 0 }
++ if (idOrName === 'map_end') return { kind: 'finish' }
++ if (idOrName === 'map_kill') return { kind: 'kill', stage: 0 }
++ const cp = /^map_cp(\d+)$/.exec(idOrName)
++ if (cp) return { kind: 'checkpoint', stage: Number(cp[1]) }
++ return null
++}
++
/** Build the run description from a compiled map's surf metadata and zones. */
export function surfRunMapOf(
meta: SurfMapMeta | undefined,
@@@ -159,25 +175,39 @@ export function surfRunMapOf(
spawnYaw = 0,
id = meta?.title ?? 'surf',
): SurfRunMap {
-- const tagged = zones.filter((z) => z.surf)
-- const start = tagged.find((z) => z.surf!.kind === 'start')
-- const finish = tagged.find((z) => z.surf!.kind === 'finish')
++ const tagged: { zone: ZoneDef; meta: NonNullable<ZoneDef['surf']> }[] = []
++ for (const zone of zones) {
++ if (zone.surf) {
++ tagged.push({ zone, meta: zone.surf })
++ continue
++ }
++ const alias = surfAliasFor(zone.id) ?? surfAliasFor(zone.name)
++ if (!alias) continue
++ const stage = alias.kind === 'finish'
++ ? (meta?.stages ?? 1)
++ : alias.kind === 'checkpoint'
++ ? (alias.stage ?? 0)
++ : 0
++ tagged.push({ zone, meta: { kind: alias.kind, stage } })
++ }
++ const start = tagged.find((t) => t.meta.kind === 'start')
++ const finish = tagged.find((t) => t.meta.kind === 'finish')
if (!start || !finish)
throw new Error('surf map needs a start zone and a finish zone')
-- const toZone = (z: ZoneDef): SurfRunZone => ({
-- id: z.id,
-- kind: z.surf!.kind,
-- stage: z.surf!.stage,
-- min: [z.min[0], z.min[1], z.min[2]],
-- max: [z.max[0], z.max[1], z.max[2]],
-- ...(z.surf!.spawn
-- ? { spawn: { x: z.surf!.spawn[0], y: z.surf!.spawn[1], z: z.surf!.spawn[2] } }
++ const toZone = (t: (typeof tagged)[number]): SurfRunZone => ({
++ id: t.zone.id,
++ kind: t.meta.kind,
++ stage: t.meta.stage,
++ min: [t.zone.min[0], t.zone.min[1], t.zone.min[2]],
++ max: [t.zone.max[0], t.zone.max[1], t.zone.max[2]],
++ ...(t.meta.spawn
++ ? { spawn: { x: t.meta.spawn[0], y: t.meta.spawn[1], z: t.meta.spawn[2] } }
: {}),
-- ...(z.surf!.yaw !== undefined ? { yaw: z.surf!.yaw } : {}),
++ ...(t.meta.yaw !== undefined ? { yaw: t.meta.yaw } : {}),
})
const checkpoints = tagged
-- .filter((z) => z.surf!.kind === 'checkpoint')
-- .sort((a, b) => a.surf!.stage - b.surf!.stage)
++ .filter((t) => t.meta.kind === 'checkpoint')
++ .sort((a, b) => a.meta.stage - b.meta.stage)
.map(toZone)
return {
id,
@@@ -186,7 +216,7 @@ export function surfRunMapOf(
start: toZone(start),
checkpoints,
finish: toZone(finish),
-- kills: tagged.filter((z) => z.surf!.kind === 'kill').map(toZone),
++ kills: tagged.filter((t) => t.meta.kind === 'kill').map(toZone),
spawn: { x: spawn[0], y: spawn[1], z: spawn[2] },
spawnYaw,
}
ddiff --git a/test/apps__server__src__game__systems__triggers.test.js b/test/apps__server__src__game__systems__triggers.test.js
nnew file mode 100644
iindex 0000000..1a5c860
---- /dev/null
++++ b/test/apps__server__src__game__systems__triggers.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/game/systems/triggers.test.ts'
++// covers: 'apps/server/src/game/systems'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/packages__gameplay__src__entities__trigger.test.js b/test/packages__gameplay__src__entities__trigger.test.js
nnew file mode 100644
iindex 0000000..34db310
---- /dev/null
++++ b/test/packages__gameplay__src__entities__trigger.test.js
@@@ -0,0 +1,3 @@
++// file: 'packages/gameplay/src/entities/trigger.test.ts'
++// covers: 'packages/gameplay/src/entities'
++// Generated by node test/run.js --index; do not edit.