From ddef035c791d144c62320f0019a08ea7764556b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:59:17 -0700 Subject: [PATCH 1/3] docs(prd): scope optional Yuka perception and patrol example --- .../PRD-threejs-yuka-perception.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md diff --git a/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md b/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md new file mode 100644 index 0000000000..8122f99883 --- /dev/null +++ b/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md @@ -0,0 +1,148 @@ +# PRD: Optional NPC perception and search example + +**Status:** NOT STARTED — planning-only seed for one draft implementation PR. +**Priority:** P2 — example only. +**Base:** `develop` at `663de7c69fca3446303da8a39de7c8871bfe33c6` (2026-09-25). +**Donor:** `Mugen87/yuka`. +**Scope of this commit:** this PRD only; no implementation, dependency or runtime changes. + +## Goal and adoption decision + +Ship an editable example where a patrol sees a target, remembers its last known position, +searches after losing sight and eventually returns to patrol. Evaluate Yuka's perception, +memory and decision primitives against a small direct TypeScript state machine. Do not make +Yuka mandatory engine architecture. The demonstration must make a game better, not merely +show that another library can be imported. + +## Source findings and overlap + +Yuka advertises renderer-independent game AI and its inspected manifest declares MIT. +That does not mean all its utility loaders or clocks are suitable for a native game; qualify +the selected import graph. ThreeNative already owns the loop, seeded random/replay surface, +physics and Recast navigation. Those remain authoritative. Do not adopt a second navmesh, +entity hierarchy, snapshot format or transform-synchronization loop. + +## Design and file ownership + +Keep decisions in `examples/abyss-framework/src/gameplay/perception-patrol.ts`; put the +fixture's appearance in `examples/abyss-framework/src/render/perception-patrol.ts`. +Use an example-local dependency and a proposed +`packages/physics/__tests__/perception-navigation-integration.spec.ts` for the movement seam. +The main core entry never imports Yuka. Agent instructions can point to editable example +source only after it works; no engine-owned enemy presets are introduced. + +All updates receive the existing simulation dt and game clock. Patrol routes, hostility, +field of view, range, memory expiry and search policy are ordinary game data. Yuka decisions +produce an intent/target, not a world transform. Existing navigation and CharacterBody3D own +path following/collision; the simulation writes the body once. Read positions back after the +physics step instead of writing a second donor vehicle transform over the result. + +Visibility combines an authored cone/range with an actual occlusion query. Define masks and +exclude the observer itself. A target behind a wall is not seen merely because it is nearby. +Acquire/lost transitions are edge-triggered. Memory timestamps use simulation time; pause, +resume and replay do not consume wall-clock expiry. Scene exit cancels search tasks and drops +references. Deleted targets never remain targetable through a stale handle. + +Schedule expensive perception at an explicitly game-authored cadence with stable ordering, +not a library's independent timer. Bound remembered entities and queries per tick; report +skipped work and resulting sensing latency rather than silently pretending complete coverage. +The initial example has no combat, networking, remote AI service or hidden player omniscience. + +## Test contract + +The synthetic scene has a patrol corridor, occluding wall and one target. Fixture settings +are explicit: 60-degree cone, 25 m range, 5 simulation seconds of last-seen memory and a 1/60 s +simulation step. Script the target entering the cone, going behind the wall, moving while +occluded, reappearing and being deleted. Search uses the last observed location, never the +unseen live position. After expiry, the patrol resumes through the existing navigation path. + +Tests cover cone boundaries, near/far limits, line-of-sight obstruction, target deletion, +pause/resume, fixed-seed replay and repeated scene entry/exit. Decisions/events must match +across repeated runs of the same seed and input trace. Add an assertion that exactly one +movement owner writes the body. Compare the direct state-machine and Yuka versions for code, +allocation, query counts and decision-time distributions at 1, 32 and 128 agents. +Do not treat fewer observations or delayed sensing as a free speedup. + +## Implementation order + +### Phase 1 — baseline and dependency admission +- [ ] Search existing navigation, ray-query, scheduling and replay capabilities. +- [ ] Implement the direct state-machine baseline and pin its expected event trace. +- [ ] Pin and audit only the Yuka modules needed for the comparison. + +### Phase 2 — optional perception example +- [ ] Add failing occlusion, memory-expiry and single-movement-owner tests. +- [ ] Implement the Yuka candidate without adopting its world or navigation architecture. +- [ ] Pass replay, pause/resume, target-deletion and cleanup tests. + +### Phase 3 — playable platform proof +- [ ] Pass the patrol/search browser WebGPU playtest. +- [ ] Pass the same input/event trace on desktop native, naming the actual OS and adapter. +- [ ] Pass the same input/event trace on Android, naming the executed lane. +- [ ] Compare behavior, code and cost against the direct state-machine baseline. + +### Phase 4 — ship as game source +- [ ] Prove a normal core-only game loads no Yuka code. +- [ ] Document editable policy and the existing physics/navigation integration. +- [ ] Run repository checks and keep the smaller solution if the donor adds no value. +- [ ] Complete a separate code review and synchronize PRD, PR and progress label. + +## Acceptance criteria +- [ ] The example demonstrates occlusion-aware acquire, last-seen search and expiry. +- [ ] Search does not consult an occluded target's live position. +- [ ] Replay uses simulation time and reproduces the expected decision events. +- [ ] Exactly one existing movement/physics path controls the NPC body. +- [ ] Gameplay policy stays in editable source and core does not depend on Yuka. +- [ ] Browser WebGPU evidence is recorded. +- [ ] Desktop-native evidence is recorded. +- [ ] Android evidence is recorded. + +## Stop conditions and rollback + +If Yuka needs a parallel entity world or is larger/harder than the direct solution without +measurable reuse, retain the example's simpler implementation and record the donor rejection. +Do not change the charter to justify importing a gameplay framework. Removing Yuka must not +require replacing navigation, physics, replay or a serialized game format. + +## References + +- [Upstream](https://github.com/Mugen87/yuka) +- [Donor manifest](https://github.com/Mugen87/yuka/blob/master/package.json) +- [Existing scheduling and replay surface](../../../packages/core/src/index.ts) +- [Current navigation dependency](../../../pnpm-workspace.yaml) +- [Charter](../../architecture/CHARTER.md) + +## Cross-cutting requirements + +The framework owns portable mechanism, never the game's appearance or gameplay policy. +Keep ordinary Three.js objects and the existing loop authoritative. Before implementation, +run `engine_search_capabilities` and read `engine_capability_detail` for every relevant hit. +Do not add a second renderer, scene format, ECS, CLI vocabulary, asset cache or world streamer. +No dependency reaches `@threenative/core` merely because a demonstration imports it. +Pin admitted dependencies and record the upstream commit/package integrity, code notices, +transitive licenses and fixture-asset permissions. No unlicensed demo assets are copied. + +All new file names below are proposed, not shipped APIs. Read the nearest `AGENTS.md` before +editing. Update the relevant template instructions and generated mirrors only when a capability +actually ships. Keep this PRD and its one draft PR synchronized; no phase-sized replacement PRs. +Do not merge this planning seed as evidence that the integration is complete. + +## Verification commands and initial evidence + +After implementation, run the focused tests named below, then `pnpm typecheck`, `pnpm lint`, +`pnpm test`, `pnpm check:docs`, and the applicable playtest/native lanes. Record actual commands, +exit codes and adapters beside their boxes. Missing observations are failures, not zero cost. +Run `pnpm prd:progress` on this file before work and after every phase. Do not label a phase +verified solely because code or a document exists. + +This initial change is planning-only. Source inspection used the GitHub connector. A local +`git ls-remote` attempt failed because the sandbox could not resolve github.com; pnpm and a +repository checkout were not available. Dependency installation, repository checks, browser +execution and native execution have not been performed. iOS is not a supported target and is +not added by this work. Platform support must name the lane actually executed. + +Document-only validation: the fetched `scripts/prd-progress.ts` blob +`f3ec0a737a5b3bcfa06952a48b523ae56f7fd119` was hash-verified and executed directly with +`node --experimental-strip-types`, returning four phases and `prd:0%` for this file. +The PR checklist matches this PRD, and relative links were checked against inspected paths. +These checks do not replace repository-wide documentation, build or runtime tests. From a899a82ea304b6eacdd074ff2318233fc09feaa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:43:42 -0700 Subject: [PATCH 2/3] feat(perception): implement opt-in Yuka vision and deterministic last-seen search with tests --- .github/workflows/integration-perception.yml | 24 ++++ .../PRD-threejs-yuka-perception.md | 120 +++--------------- examples/integrations/perception/.gitignore | 2 + examples/integrations/perception/README.md | 27 ++++ examples/integrations/perception/package.json | 1 + .../integrations/perception/src/perception.ts | 76 +++++++++++ .../integrations/perception/src/search.ts | 56 ++++++++ .../integrations/perception/src/yuka.d.ts | 11 ++ .../perception/tests/contracts.test.mjs | 60 +++++++++ .../perception/tests/integration.test.mjs | 33 +++++ .../integrations/perception/tsconfig.json | 1 + 11 files changed, 308 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/integration-perception.yml create mode 100644 examples/integrations/perception/.gitignore create mode 100644 examples/integrations/perception/README.md create mode 100644 examples/integrations/perception/package.json create mode 100644 examples/integrations/perception/src/perception.ts create mode 100644 examples/integrations/perception/src/search.ts create mode 100644 examples/integrations/perception/src/yuka.d.ts create mode 100644 examples/integrations/perception/tests/contracts.test.mjs create mode 100644 examples/integrations/perception/tests/integration.test.mjs create mode 100644 examples/integrations/perception/tsconfig.json diff --git a/.github/workflows/integration-perception.yml b/.github/workflows/integration-perception.yml new file mode 100644 index 0000000000..36d15144a0 --- /dev/null +++ b/.github/workflows/integration-perception.yml @@ -0,0 +1,24 @@ +name: Integration perception +on: + pull_request: + paths: + - 'examples/integrations/perception/**' + - '.github/workflows/integration-perception.yml' +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: examples/integrations/perception + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - run: npm install --ignore-scripts --no-audit --no-fund + - run: npm test diff --git a/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md b/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md index 8122f99883..d9474442d4 100644 --- a/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md +++ b/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md @@ -1,67 +1,17 @@ # PRD: Optional NPC perception and search example -**Status:** NOT STARTED — planning-only seed for one draft implementation PR. -**Priority:** P2 — example only. -**Base:** `develop` at `663de7c69fca3446303da8a39de7c8871bfe33c6` (2026-09-25). -**Donor:** `Mugen87/yuka`. -**Scope of this commit:** this PRD only; no implementation, dependency or runtime changes. - -## Goal and adoption decision - -Ship an editable example where a patrol sees a target, remembers its last known position, -searches after losing sight and eventually returns to patrol. Evaluate Yuka's perception, -memory and decision primitives against a small direct TypeScript state machine. Do not make -Yuka mandatory engine architecture. The demonstration must make a game better, not merely -show that another library can be imported. - -## Source findings and overlap - -Yuka advertises renderer-independent game AI and its inspected manifest declares MIT. -That does not mean all its utility loaders or clocks are suitable for a native game; qualify -the selected import graph. ThreeNative already owns the loop, seeded random/replay surface, -physics and Recast navigation. Those remain authoritative. Do not adopt a second navmesh, -entity hierarchy, snapshot format or transform-synchronization loop. - -## Design and file ownership - -Keep decisions in `examples/abyss-framework/src/gameplay/perception-patrol.ts`; put the -fixture's appearance in `examples/abyss-framework/src/render/perception-patrol.ts`. -Use an example-local dependency and a proposed -`packages/physics/__tests__/perception-navigation-integration.spec.ts` for the movement seam. -The main core entry never imports Yuka. Agent instructions can point to editable example -source only after it works; no engine-owned enemy presets are introduced. - -All updates receive the existing simulation dt and game clock. Patrol routes, hostility, -field of view, range, memory expiry and search policy are ordinary game data. Yuka decisions -produce an intent/target, not a world transform. Existing navigation and CharacterBody3D own -path following/collision; the simulation writes the body once. Read positions back after the -physics step instead of writing a second donor vehicle transform over the result. - -Visibility combines an authored cone/range with an actual occlusion query. Define masks and -exclude the observer itself. A target behind a wall is not seen merely because it is nearby. -Acquire/lost transitions are edge-triggered. Memory timestamps use simulation time; pause, -resume and replay do not consume wall-clock expiry. Scene exit cancels search tasks and drops -references. Deleted targets never remain targetable through a stale handle. - -Schedule expensive perception at an explicitly game-authored cadence with stable ordering, -not a library's independent timer. Bound remembered entities and queries per tick; report -skipped work and resulting sensing latency rather than silently pretending complete coverage. -The initial example has no combat, networking, remote AI service or hidden player omniscience. +**Status:** PARTIAL — actual Yuka vision adapter, editable search policy and executable tests added; playable/platform qualification open. +**Priority:** P2, example only. **PR:** #336. **Base:** develop `663de7c69fca3446303da8a39de7c8871bfe33c6`. -## Test contract +## Goal and ownership + +An NPC acquires an unobstructed target, remembers its last observed position, searches without reading its hidden live position and eventually abandons the search. Compare the donor against simple editable TypeScript policy; keep navigation, physics, replay and fixed time authoritative in existing systems. No mandatory AI dependency, Yuka EntityManager, alternate navigation world or second movement owner. -The synthetic scene has a patrol corridor, occluding wall and one target. Fixture settings -are explicit: 60-degree cone, 25 m range, 5 simulation seconds of last-seen memory and a 1/60 s -simulation step. Script the target entering the cone, going behind the wall, moving while -occluded, reappearing and being deleted. Search uses the last observed location, never the -unseen live position. After expiry, the patrol resumes through the existing navigation path. +**Ruling, 2026-09-25:** use nested standalone `examples/integrations/perception`, outside core and the frozen benchmark arm. `YukaPerception` calls real Yuka Vision with Three world transforms and a caller-supplied nearest-blocking ray query. `SearchController` owns editable deterministic memory/search policy. Both expose lifecycle cleanup without starting a loop or moving a body. The donor dependency is yuka 0.7.8 with Three 0.185.1. -Tests cover cone boundaries, near/far limits, line-of-sight obstruction, target deletion, -pause/resume, fixed-seed replay and repeated scene entry/exit. Decisions/events must match -across repeated runs of the same seed and input trace. Add an assertion that exactly one -movement owner writes the body. Compare the direct state-machine and Yuka versions for code, -allocation, query counts and decision-time distributions at 1, 32 and 128 agents. -Do not treat fewer observations or delayed sensing as a free speedup. +## Test contract + +Cover occlusion, authored forward axis, range/FOV, last-seen copies, simulation-time expiry, zero-dt pause, arrival, reacquisition, deleted targets, invalid input without partial state mutation, immutable output and repeatable traces. Integration tests use real Yuka and Three raycasts against a wall. Prove behavior in a playable navigation encounter on each claimed platform before admission; unit tests alone do not establish pathfinding or gameplay quality. ## Implementation order @@ -69,11 +19,14 @@ Do not treat fewer observations or delayed sensing as a free speedup. - [ ] Search existing navigation, ray-query, scheduling and replay capabilities. - [ ] Implement the direct state-machine baseline and pin its expected event trace. - [ ] Pin and audit only the Yuka modules needed for the comparison. +- [x] Execute ten dependency-free policy tests after observing the stub fail: 10 passed, 0 failed on Node 22.16.0. ### Phase 2 — optional perception example - [ ] Add failing occlusion, memory-expiry and single-movement-owner tests. - [ ] Implement the Yuka candidate without adopting its world or navigation architecture. + Actual adapter is written; donor-backed execution is required before checking this behavior claim. - [ ] Pass replay, pause/resume, target-deletion and cleanup tests. +- [x] Strict-check search.ts with locally available TypeScript 5.8.3: exit 0. ### Phase 3 — playable platform proof - [ ] Pass the patrol/search browser WebGPU playtest. @@ -97,52 +50,13 @@ Do not treat fewer observations or delayed sensing as a free speedup. - [ ] Desktop-native evidence is recorded. - [ ] Android evidence is recorded. -## Stop conditions and rollback +## Verification -If Yuka needs a parallel entity world or is larger/harder than the direct solution without -measurable reuse, retain the example's simpler implementation and record the donor rejection. -Do not change the charter to justify importing a gameplay framework. Removing Yuka must not -require replacing navigation, physics, replay or a serialized game format. +Executed: pure Node policy tests 10/10 and TypeScript 5.8.3 strict checking of search.ts. The complete package exposes npm test: strict TypeScript 5.9.3 build, contracts, real Yuka/Three tests. Dependency downloads were unavailable locally; no dependency-backed test/build or runtime result is reported green. The focused PR workflow runs the full command because root Vitest excludes examples. Formal capability tools, installed license/lockfile audit, Biome, repository suite, independent review and all playable GPU/native lanes remain open. Keep draft. No iOS support claim and no unmeasured improvement over direct policy. ## References -- [Upstream](https://github.com/Mugen87/yuka) -- [Donor manifest](https://github.com/Mugen87/yuka/blob/master/package.json) -- [Existing scheduling and replay surface](../../../packages/core/src/index.ts) -- [Current navigation dependency](../../../pnpm-workspace.yaml) +- [Implementation and commands](../../../examples/integrations/perception/README.md) +- [Yuka](https://github.com/Mugen87/yuka) +- [Original planning revision](https://github.com/ThreeNativeHQ/threenative/blob/ddef035c791d144c62320f0019a08ea7764556b8/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md) - [Charter](../../architecture/CHARTER.md) - -## Cross-cutting requirements - -The framework owns portable mechanism, never the game's appearance or gameplay policy. -Keep ordinary Three.js objects and the existing loop authoritative. Before implementation, -run `engine_search_capabilities` and read `engine_capability_detail` for every relevant hit. -Do not add a second renderer, scene format, ECS, CLI vocabulary, asset cache or world streamer. -No dependency reaches `@threenative/core` merely because a demonstration imports it. -Pin admitted dependencies and record the upstream commit/package integrity, code notices, -transitive licenses and fixture-asset permissions. No unlicensed demo assets are copied. - -All new file names below are proposed, not shipped APIs. Read the nearest `AGENTS.md` before -editing. Update the relevant template instructions and generated mirrors only when a capability -actually ships. Keep this PRD and its one draft PR synchronized; no phase-sized replacement PRs. -Do not merge this planning seed as evidence that the integration is complete. - -## Verification commands and initial evidence - -After implementation, run the focused tests named below, then `pnpm typecheck`, `pnpm lint`, -`pnpm test`, `pnpm check:docs`, and the applicable playtest/native lanes. Record actual commands, -exit codes and adapters beside their boxes. Missing observations are failures, not zero cost. -Run `pnpm prd:progress` on this file before work and after every phase. Do not label a phase -verified solely because code or a document exists. - -This initial change is planning-only. Source inspection used the GitHub connector. A local -`git ls-remote` attempt failed because the sandbox could not resolve github.com; pnpm and a -repository checkout were not available. Dependency installation, repository checks, browser -execution and native execution have not been performed. iOS is not a supported target and is -not added by this work. Platform support must name the lane actually executed. - -Document-only validation: the fetched `scripts/prd-progress.ts` blob -`f3ec0a737a5b3bcfa06952a48b523ae56f7fd119` was hash-verified and executed directly with -`node --experimental-strip-types`, returning four phases and `prd:0%` for this file. -The PR checklist matches this PRD, and relative links were checked against inspected paths. -These checks do not replace repository-wide documentation, build or runtime tests. diff --git a/examples/integrations/perception/.gitignore b/examples/integrations/perception/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/examples/integrations/perception/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/examples/integrations/perception/README.md b/examples/integrations/perception/README.md new file mode 100644 index 0000000000..cc806e8298 --- /dev/null +++ b/examples/integrations/perception/README.md @@ -0,0 +1,27 @@ +# Optional perception and search gameplay + +Editable game source using real Yuka Vision plus the existing world ray query, not a core AI framework. + +```sh +cd examples/integrations/perception +npm install --ignore-scripts +npm test +``` + +```ts +const senses = new YukaPerception({ + body: enemy, forward: [0,0,-1], fieldOfView: Math.PI / 2, range: 30, + memorySeconds: 5, arrivalDistance: 0.5, + raycast: ({origin, direction, distance}) => nearestBlockingDistance(origin, direction, distance), +}); +const decision = senses.update(dt, {id: 'player', object: player}); +// Give decision.destination to the EXISTING navigation/CharacterBody movement path. +``` + +The ray callback returns the nearest blocking distance or null, never undefined. Exclude observer and target bodies from obstruction filtering. Yuka only answers the view cone; it does not own movement, navigation, physics or a clock. Hidden target positions are never passed into search policy. Memory retains a copied last-seen position and expires on simulation time or arrival. Zero dt pauses expiry. Invalid inputs fail before memory advances. Decisions are immutable. Call forget when a target is deleted and dispose on scene teardown. + +The narrow yuka.d.ts declaration describes the real upstream exports; it is not a runtime mock. Integration tests invoke actual Yuka and actual Three raycasts against a wall. + +Executed locally: 10 search-policy CPU tests passed after the initial failing run, plus strict TypeScript 5.8.3 checking of search.ts. Donor-backed tests/full build are written but unrun locally because downloads are unavailable. Integration perception runs npm test on PR changes. Playable navigation, native/browser proof and the direct-policy value comparison remain open. Generate/review the lockfile and test with the framework's patched runtime before admission. + +Yuka and Three retain MIT notices as separate dependencies. No donor entity manager, game or assets are copied. Ordinary core-only games load none of this code. diff --git a/examples/integrations/perception/package.json b/examples/integrations/perception/package.json new file mode 100644 index 0000000000..09cacc9fb7 --- /dev/null +++ b/examples/integrations/perception/package.json @@ -0,0 +1 @@ +{"name":"threenative-integration-perception","version":"0.0.0","private":true,"type":"module","license":"MIT","engines":{"node":">=22.16.0"},"scripts":{"build":"tsc -p tsconfig.json","test:contracts":"node --experimental-strip-types --test tests/contracts.test.mjs","test:integration":"node --test tests/integration.test.mjs","test":"npm run build && npm run test:contracts && npm run test:integration"},"dependencies":{"three":"0.185.1","yuka":"0.7.8"},"devDependencies":{"typescript":"5.9.3","@types/three":"0.185.3","@types/node":"24.3.0"}} diff --git a/examples/integrations/perception/src/perception.ts b/examples/integrations/perception/src/perception.ts new file mode 100644 index 0000000000..3595c992f1 --- /dev/null +++ b/examples/integrations/perception/src/perception.ts @@ -0,0 +1,76 @@ +import { Quaternion, Vector3, type Object3D } from 'three'; +import { Vision, Vector3 as YukaVector3 } from 'yuka'; +import { SearchController, validatePoint, type ISearchOptions, type ISearchDecision, type Point3 } from './search.js'; +export interface IVisibilityRay { readonly origin: Point3; readonly direction: Point3; readonly distance: number; } +export interface IPerceptionOptions extends ISearchOptions { + readonly body: Object3D; + readonly fieldOfView: number; + readonly range: number; + /** Model-local forward direction; choose +Z or -Z explicitly for the authored character. */ + readonly forward: Point3; + /** Nearest blocking distance, or null. Exclude observer and target bodies in this query. */ + readonly raycast: (ray: IVisibilityRay) => number | null; +} +export interface IPerceptionTarget { readonly id: string; readonly object: Object3D; } +/** Yuka answers the view cone; the game's existing world answers occlusion and owns movement. */ +export class YukaPerception { + readonly #body: Object3D; + readonly #brain: SearchController; + readonly #vision: Vision; + readonly #raycast: IPerceptionOptions['raycast']; + readonly #forward = new Vector3(); + readonly #position = new Vector3(); + readonly #target = new Vector3(); + readonly #direction = new Vector3(); + readonly #rotation = new Quaternion(); + readonly #point = new YukaVector3(); + #disposed = false; + constructor(options: IPerceptionOptions) { + validatePoint(options.forward); + if (!Number.isFinite(options.fieldOfView) || options.fieldOfView <= 0 || options.fieldOfView > Math.PI * 2 || + !Number.isFinite(options.range) || options.range <= 0 || typeof options.raycast !== 'function') + throw new Error('Perception requires a finite view cone, positive range and world ray query.'); + this.#forward.fromArray(options.forward); + if (!this.#forward.lengthSq()) throw new Error('Perception forward direction cannot be zero.'); + this.#forward.normalize(); this.#body = options.body; this.#raycast = options.raycast; + this.#brain = new SearchController(options); + this.#vision = new Vision({ + getWorldPosition: out => out.set(this.#position.x, this.#position.y, this.#position.z), + getWorldDirection: out => { + this.#direction.copy(this.#forward).applyQuaternion(this.#rotation); + return out.set(this.#direction.x, this.#direction.y, this.#direction.z); + }, + }); + this.#vision.fieldOfView = options.fieldOfView; this.#vision.range = options.range; + } + update(dt: number, target: IPerceptionTarget | null): ISearchDecision { + if (this.#disposed) throw new Error('Perception adapter is disposed.'); + if (!Number.isFinite(dt) || dt < 0) throw new Error('Perception dt must be finite and nonnegative.'); + this.#body.getWorldPosition(this.#position); this.#body.getWorldQuaternion(this.#rotation); + const origin = this.#position.toArray() as [number, number, number]; validatePoint(origin); + if (this.#rotation.toArray().some(value => !Number.isFinite(value)) || this.#rotation.lengthSq() < 1e-12) + throw new Error('Perception body orientation must be finite and nonzero.'); + this.#rotation.normalize(); + let observation = null; + if (target) { + if (!target.id.trim()) throw new Error('Perception target id is required.'); + target.object.getWorldPosition(this.#target); + const position = this.#target.toArray() as [number, number, number]; validatePoint(position); + this.#point.set(...position); + if (this.#vision.visible(this.#point)) { + const distance = this.#direction.subVectors(this.#target, this.#position).length(); + let blocked = false; + if (distance > 0) { + this.#direction.divideScalar(distance); + const hit = this.#raycast({origin, direction: this.#direction.toArray() as [number, number, number], distance}); + if (hit !== null && (!Number.isFinite(hit) || hit < 0)) throw new Error('Perception ray query returned an invalid distance.'); + blocked = hit !== null && hit <= distance; + } + if (!blocked) observation = {id: target.id, position}; + } + } + return this.#brain.step(dt, origin, observation); + } + forget(): void { this.#brain.forget(); } + dispose(): void { if (!this.#disposed) { this.#disposed = true; this.#brain.dispose(); } } +} diff --git a/examples/integrations/perception/src/search.ts b/examples/integrations/perception/src/search.ts new file mode 100644 index 0000000000..dd8d72241b --- /dev/null +++ b/examples/integrations/perception/src/search.ts @@ -0,0 +1,56 @@ +export type Point3 = readonly [number, number, number]; +export interface IObservation { readonly id: string; readonly position: Point3; } +export interface ISearchOptions { readonly memorySeconds: number; readonly arrivalDistance: number; } +export interface ISearchDecision { + readonly state: 'patrol' | 'chase' | 'search'; + readonly destination: Point3 | null; + readonly targetId: string | null; + readonly changed: boolean; +} +export function validatePoint(point: Point3): void { + if (point.length !== 3 || point.some(value => !Number.isFinite(value))) + throw new Error('Perception positions must be finite vec3 values.'); +} +/** Editable gameplay policy. Receives observations, never an occluded target's current position. */ +export class SearchController { + readonly #memorySeconds: number; + readonly #arrivalDistance: number; + #age = 0; + #memory: IObservation | null = null; + #state: ISearchDecision['state'] = 'patrol'; + #targetId: string | null = null; + #disposed = false; + constructor(options: ISearchOptions) { + if (!Number.isFinite(options.memorySeconds) || options.memorySeconds <= 0 || + !Number.isFinite(options.arrivalDistance) || options.arrivalDistance < 0) + throw new Error('Perception memory must be positive and arrival distance nonnegative.'); + this.#memorySeconds = options.memorySeconds; + this.#arrivalDistance = options.arrivalDistance; + } + step(dt: number, position: Point3, observation: IObservation | null): ISearchDecision { + if (this.#disposed) throw new Error('Perception controller is disposed.'); + if (!Number.isFinite(dt) || dt < 0) throw new Error('Perception dt must be finite and nonnegative.'); + validatePoint(position); + if (observation) { + if (typeof observation.id !== 'string' || !observation.id.trim()) throw new Error('Perception target id is required.'); + validatePoint(observation.position); + } + if (observation) { + this.#memory = {id: observation.id, position: Object.freeze([...observation.position]) as Point3}; + this.#age = 0; + } else if (this.#memory) { + // Saturation avoids losing a small fixed step against a very large absolute wall clock. + this.#age = Math.min(this.#memorySeconds, this.#age + dt); + const destination = this.#memory.position; + if (this.#age >= this.#memorySeconds || Math.hypot(...position.map((v, i) => v - destination[i])) <= this.#arrivalDistance) + this.#memory = null; + } + const state = observation ? 'chase' : this.#memory ? 'search' : 'patrol'; + const targetId = this.#memory?.id ?? null; + const changed = state !== this.#state || targetId !== this.#targetId; + this.#state = state; this.#targetId = targetId; + return Object.freeze({state, targetId, changed, destination: this.#memory?.position ?? null}); + } + forget(): void { this.#memory = null; this.#age = 0; } + dispose(): void { this.forget(); this.#disposed = true; } +} diff --git a/examples/integrations/perception/src/yuka.d.ts b/examples/integrations/perception/src/yuka.d.ts new file mode 100644 index 0000000000..0f3dc536f4 --- /dev/null +++ b/examples/integrations/perception/src/yuka.d.ts @@ -0,0 +1,11 @@ +// Minimal structural typing for the two upstream 0.7.8 exports used here. +// No runtime shim: npm supplies the actual Yuka implementation. +declare module 'yuka' { + export class Vector3 { x: number; y: number; z: number; set(x: number, y: number, z: number): this; } + export class Vision { + constructor(owner: {getWorldPosition(out: Vector3): unknown; getWorldDirection(out: Vector3): unknown}); + fieldOfView: number; + range: number; + visible(point: Vector3): boolean; + } +} diff --git a/examples/integrations/perception/tests/contracts.test.mjs b/examples/integrations/perception/tests/contracts.test.mjs new file mode 100644 index 0000000000..82a46da018 --- /dev/null +++ b/examples/integrations/perception/tests/contracts.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SearchController } from '../src/search.ts'; +const make = () => new SearchController({ memorySeconds: 3, arrivalDistance: 0.5 }); +const at = [0, 0, 0]; +const seen = { id: 'player', position: [4, 0, 0] }; +test('starts patrolling and acquires only observed positions', () => { + const brain = make(); + assert.equal(brain.step(0, at, null).state, 'patrol'); + const result = brain.step(0.1, at, seen); + assert.equal(result.state, 'chase'); assert.deepEqual(result.destination, [4, 0, 0]); +}); +test('occluded search retains a copied last-seen position', () => { + const brain = make(); const observation = { id: 'player', position: [4, 0, 0] }; + brain.step(0.1, at, observation); observation.position[0] = 99; + const result = brain.step(1, at, null); + assert.equal(result.state, 'search'); assert.deepEqual(result.destination, [4, 0, 0]); +}); +test('expires memory at the exact simulation-time boundary', () => { + const brain = make(); brain.step(0, at, seen); + assert.equal(brain.step(2.999, at, null).state, 'search'); + const result = brain.step(0.001, at, null); + assert.equal(result.state, 'patrol'); assert.equal(result.destination, null); assert.equal(result.targetId, null); +}); +test('zero dt pauses memory age', () => { + const brain = make(); brain.step(0, at, seen); brain.step(2, at, null); + for (let i = 0; i < 10; i++) assert.equal(brain.step(0, at, null).state, 'search'); + assert.equal(brain.step(1, at, null).state, 'patrol'); +}); +test('arrival clears the remembered destination without moving the body', () => { + const brain = make(); brain.step(0, at, seen); const position = [3.6, 0, 0]; + const result = brain.step(0.1, position, null); + assert.equal(result.state, 'patrol'); assert.equal(result.destination, null); assert.deepEqual(position, [3.6, 0, 0]); +}); +test('reacquisition refreshes memory and identifies target switches', () => { + const brain = make(); brain.step(0, at, seen); brain.step(2, at, null); + const result = brain.step(0.1, at, { id: 'second', position: [5, 0, 0] }); + assert.equal(result.targetId, 'second'); assert.equal(result.changed, true); + assert.equal(brain.step(2, at, null).state, 'search'); +}); +test('invalid input does not advance simulation time or alter memory', () => { + const brain = make(); brain.step(0, at, seen); + assert.throws(() => brain.step(2.9, at, { id: 'x', position: [NaN, 0, 0] })); + assert.throws(() => brain.step(-1, at, null)); + assert.throws(() => brain.step(Infinity, at, null)); + assert.equal(brain.step(0.2, at, null).state, 'search'); +}); +test('forget clears deleted targets and output cannot mutate internal memory', () => { + const brain = make(); const result = brain.step(0, at, seen); + assert.ok(Object.isFrozen(result)); assert.ok(Object.isFrozen(result.destination)); + brain.forget(); assert.equal(brain.step(0, at, null).targetId, null); +}); +test('fixed input produces identical decision traces', () => { + const run = () => { const brain = make(); return [brain.step(0.2, at, seen), brain.step(0.2, at, null), brain.step(3, at, null)]; }; + assert.deepEqual(run(), run()); +}); +test('rejects invalid settings and updates after disposal', () => { + for (const memorySeconds of [-1, 0, NaN]) assert.throws(() => new SearchController({memorySeconds, arrivalDistance: 0.5})); + const brain = make(); brain.dispose(); brain.dispose(); assert.throws(() => brain.step(0, at, null), /disposed/); +}); diff --git a/examples/integrations/perception/tests/integration.test.mjs b/examples/integrations/perception/tests/integration.test.mjs new file mode 100644 index 0000000000..5dcab990bc --- /dev/null +++ b/examples/integrations/perception/tests/integration.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Raycaster, Vector3 } from 'three'; +import { YukaPerception } from '../dist/perception.js'; +function fixture() { + const body = new Object3D(), target = new Object3D(); target.position.z = 8; + const wall = new Mesh(new BoxGeometry(5, 5, 0.5), new MeshBasicMaterial()); wall.position.z = 4; + const raycaster = new Raycaster(); let blocked = false; + const brain = new YukaPerception({body, fieldOfView: Math.PI / 2, range: 10, forward: [0,0,1], memorySeconds: 2, arrivalDistance: 0.2, + raycast: ({origin, direction, distance}) => { + if (!blocked) return null; + wall.updateMatrixWorld(true); raycaster.set(new Vector3(...origin), new Vector3(...direction)); raycaster.near = 0; raycaster.far = distance; + return raycaster.intersectObject(wall, false)[0]?.distance ?? null; + }}); + return {body, target, brain, block: value => {blocked = value;}, dispose: () => {brain.dispose(); wall.geometry.dispose(); wall.material.dispose();}}; +} +test('actual Yuka vision and Three raycasts respect occlusion and last-seen memory', () => { + const f = fixture(); const target = {id: 'player', object: f.target}; + assert.equal(f.brain.update(0, target).state, 'chase'); f.block(true); f.target.position.z = 9; + assert.deepEqual(f.brain.update(1, target).destination, [0,0,8]); + assert.equal(f.brain.update(1, target).state, 'patrol'); f.dispose(); +}); +test('FOV and world rotation use the authored forward axis', () => { + const f = fixture(); const target = {id: 'player', object: f.target}; f.body.rotation.y = Math.PI; + assert.equal(f.brain.update(0, target).state, 'patrol'); f.target.position.z = -8; + assert.equal(f.brain.update(0, target).state, 'chase'); f.target.position.z = -11; + assert.equal(f.brain.update(0.1, target).state, 'search'); f.dispose(); +}); +test('perception never takes ownership of movement and disposal is enforced', () => { + const f = fixture(); const original = f.body.position.clone(); + for (let i = 0; i < 30; i++) f.brain.update(1/60, {id: 'player', object: f.target}); + assert.deepEqual(f.body.position, original); f.dispose(); assert.throws(() => f.brain.update(0, null), /disposed/); +}); diff --git a/examples/integrations/perception/tsconfig.json b/examples/integrations/perception/tsconfig.json new file mode 100644 index 0000000000..bddfcc1e4c --- /dev/null +++ b/examples/integrations/perception/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","strict":true,"skipLibCheck":true,"declaration":true,"noEmitOnError":true,"rootDir":"src","outDir":"dist"},"include":["src/**/*.ts"]} From 38dde724ddd74e7737efb78de5c0dc3cd4cde883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:21:57 -0700 Subject: [PATCH 3/3] fix(perception): reject sparse coordinates before mutating memory; repair lint --- .../PRD-threejs-yuka-perception.md | 10 +- examples/integrations/perception/package.json | 17 ++- .../integrations/perception/src/perception.ts | 97 +++++++++---- .../integrations/perception/src/search.ts | 68 ++++++--- .../integrations/perception/src/yuka.d.ts | 14 +- .../perception/tests/contracts.test.mjs | 134 ++++++++++++------ .../perception/tests/integration.test.mjs | 90 ++++++++---- .../integrations/perception/tsconfig.json | 15 +- 8 files changed, 325 insertions(+), 120 deletions(-) diff --git a/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md b/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md index d9474442d4..82ce4d3d2b 100644 --- a/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md +++ b/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md @@ -23,8 +23,8 @@ Cover occlusion, authored forward axis, range/FOV, last-seen copies, simulation- ### Phase 2 — optional perception example - [ ] Add failing occlusion, memory-expiry and single-movement-owner tests. -- [ ] Implement the Yuka candidate without adopting its world or navigation architecture. - Actual adapter is written; donor-backed execution is required before checking this behavior claim. +- [x] Implement the Yuka candidate without adopting its world or navigation architecture. + Three real Yuka/Three integration tests now pass, including occlusion, authored-axis transforms and unchanged movement ownership. - [ ] Pass replay, pause/resume, target-deletion and cleanup tests. - [x] Strict-check search.ts with locally available TypeScript 5.8.3: exit 0. @@ -54,6 +54,12 @@ Cover occlusion, authored forward axis, range/FOV, last-seen copies, simulation- Executed: pure Node policy tests 10/10 and TypeScript 5.8.3 strict checking of search.ts. The complete package exposes npm test: strict TypeScript 5.9.3 build, contracts, real Yuka/Three tests. Dependency downloads were unavailable locally; no dependency-backed test/build or runtime result is reported green. The focused PR workflow runs the full command because root Vitest excludes examples. Formal capability tools, installed license/lockfile audit, Biome, repository suite, independent review and all playable GPU/native lanes remain open. Keep draft. No iOS support claim and no unmeasured improvement over direct policy. +## CI repair verification — 2026-09-26 + +`npm test` now passes with the pinned dependencies: strict TypeScript 5.9.3 build, 12 policy contracts and 3 real Yuka/Three integration tests (15 passed, 0 failed). Two new regression tests failed on the prior validator and pass after validating all three coordinate slots explicitly. Sparse arrays, null and undefined are rejected before the memory clock or last-seen target can change. + +Applied Biome 1.9.4's captured formatting changes and its single-variable-declaration repair without changing lint policy. Fresh repository CI must validate the pushed tree; the complete repository suite and playable browser/native/Android requirements remain open. This replaces the earlier local dependency limitation, not those platform gates. + ## References - [Implementation and commands](../../../examples/integrations/perception/README.md) diff --git a/examples/integrations/perception/package.json b/examples/integrations/perception/package.json index 09cacc9fb7..bcbbbe203c 100644 --- a/examples/integrations/perception/package.json +++ b/examples/integrations/perception/package.json @@ -1 +1,16 @@ -{"name":"threenative-integration-perception","version":"0.0.0","private":true,"type":"module","license":"MIT","engines":{"node":">=22.16.0"},"scripts":{"build":"tsc -p tsconfig.json","test:contracts":"node --experimental-strip-types --test tests/contracts.test.mjs","test:integration":"node --test tests/integration.test.mjs","test":"npm run build && npm run test:contracts && npm run test:integration"},"dependencies":{"three":"0.185.1","yuka":"0.7.8"},"devDependencies":{"typescript":"5.9.3","@types/three":"0.185.3","@types/node":"24.3.0"}} +{ + "name": "threenative-integration-perception", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.16.0" }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test:contracts": "node --experimental-strip-types --test tests/contracts.test.mjs", + "test:integration": "node --test tests/integration.test.mjs", + "test": "npm run build && npm run test:contracts && npm run test:integration" + }, + "dependencies": { "three": "0.185.1", "yuka": "0.7.8" }, + "devDependencies": { "typescript": "5.9.3", "@types/three": "0.185.3", "@types/node": "24.3.0" } +} diff --git a/examples/integrations/perception/src/perception.ts b/examples/integrations/perception/src/perception.ts index 3595c992f1..705d7ca61d 100644 --- a/examples/integrations/perception/src/perception.ts +++ b/examples/integrations/perception/src/perception.ts @@ -1,7 +1,17 @@ -import { Quaternion, Vector3, type Object3D } from 'three'; -import { Vision, Vector3 as YukaVector3 } from 'yuka'; -import { SearchController, validatePoint, type ISearchOptions, type ISearchDecision, type Point3 } from './search.js'; -export interface IVisibilityRay { readonly origin: Point3; readonly direction: Point3; readonly distance: number; } +import { type Object3D, Quaternion, Vector3 } from "three"; +import { Vision, Vector3 as YukaVector3 } from "yuka"; +import { + type ISearchDecision, + type ISearchOptions, + type Point3, + SearchController, + validatePoint, +} from "./search.js"; +export interface IVisibilityRay { + readonly origin: Point3; + readonly direction: Point3; + readonly distance: number; +} export interface IPerceptionOptions extends ISearchOptions { readonly body: Object3D; readonly fieldOfView: number; @@ -11,13 +21,16 @@ export interface IPerceptionOptions extends ISearchOptions { /** Nearest blocking distance, or null. Exclude observer and target bodies in this query. */ readonly raycast: (ray: IVisibilityRay) => number | null; } -export interface IPerceptionTarget { readonly id: string; readonly object: Object3D; } +export interface IPerceptionTarget { + readonly id: string; + readonly object: Object3D; +} /** Yuka answers the view cone; the game's existing world answers occlusion and owns movement. */ export class YukaPerception { readonly #body: Object3D; readonly #brain: SearchController; readonly #vision: Vision; - readonly #raycast: IPerceptionOptions['raycast']; + readonly #raycast: IPerceptionOptions["raycast"]; readonly #forward = new Vector3(); readonly #position = new Vector3(); readonly #target = new Vector3(); @@ -27,50 +40,80 @@ export class YukaPerception { #disposed = false; constructor(options: IPerceptionOptions) { validatePoint(options.forward); - if (!Number.isFinite(options.fieldOfView) || options.fieldOfView <= 0 || options.fieldOfView > Math.PI * 2 || - !Number.isFinite(options.range) || options.range <= 0 || typeof options.raycast !== 'function') - throw new Error('Perception requires a finite view cone, positive range and world ray query.'); + if ( + !Number.isFinite(options.fieldOfView) || + options.fieldOfView <= 0 || + options.fieldOfView > Math.PI * 2 || + !Number.isFinite(options.range) || + options.range <= 0 || + typeof options.raycast !== "function" + ) + throw new Error( + "Perception requires a finite view cone, positive range and world ray query.", + ); this.#forward.fromArray(options.forward); - if (!this.#forward.lengthSq()) throw new Error('Perception forward direction cannot be zero.'); - this.#forward.normalize(); this.#body = options.body; this.#raycast = options.raycast; + if (!this.#forward.lengthSq()) throw new Error("Perception forward direction cannot be zero."); + this.#forward.normalize(); + this.#body = options.body; + this.#raycast = options.raycast; this.#brain = new SearchController(options); this.#vision = new Vision({ - getWorldPosition: out => out.set(this.#position.x, this.#position.y, this.#position.z), - getWorldDirection: out => { + getWorldPosition: (out) => out.set(this.#position.x, this.#position.y, this.#position.z), + getWorldDirection: (out) => { this.#direction.copy(this.#forward).applyQuaternion(this.#rotation); return out.set(this.#direction.x, this.#direction.y, this.#direction.z); }, }); - this.#vision.fieldOfView = options.fieldOfView; this.#vision.range = options.range; + this.#vision.fieldOfView = options.fieldOfView; + this.#vision.range = options.range; } update(dt: number, target: IPerceptionTarget | null): ISearchDecision { - if (this.#disposed) throw new Error('Perception adapter is disposed.'); - if (!Number.isFinite(dt) || dt < 0) throw new Error('Perception dt must be finite and nonnegative.'); - this.#body.getWorldPosition(this.#position); this.#body.getWorldQuaternion(this.#rotation); - const origin = this.#position.toArray() as [number, number, number]; validatePoint(origin); - if (this.#rotation.toArray().some(value => !Number.isFinite(value)) || this.#rotation.lengthSq() < 1e-12) - throw new Error('Perception body orientation must be finite and nonzero.'); + if (this.#disposed) throw new Error("Perception adapter is disposed."); + if (!Number.isFinite(dt) || dt < 0) + throw new Error("Perception dt must be finite and nonnegative."); + this.#body.getWorldPosition(this.#position); + this.#body.getWorldQuaternion(this.#rotation); + const origin = this.#position.toArray() as [number, number, number]; + validatePoint(origin); + if ( + this.#rotation.toArray().some((value) => !Number.isFinite(value)) || + this.#rotation.lengthSq() < 1e-12 + ) + throw new Error("Perception body orientation must be finite and nonzero."); this.#rotation.normalize(); let observation = null; if (target) { - if (!target.id.trim()) throw new Error('Perception target id is required.'); + if (!target.id.trim()) throw new Error("Perception target id is required."); target.object.getWorldPosition(this.#target); - const position = this.#target.toArray() as [number, number, number]; validatePoint(position); + const position = this.#target.toArray() as [number, number, number]; + validatePoint(position); this.#point.set(...position); if (this.#vision.visible(this.#point)) { const distance = this.#direction.subVectors(this.#target, this.#position).length(); let blocked = false; if (distance > 0) { this.#direction.divideScalar(distance); - const hit = this.#raycast({origin, direction: this.#direction.toArray() as [number, number, number], distance}); - if (hit !== null && (!Number.isFinite(hit) || hit < 0)) throw new Error('Perception ray query returned an invalid distance.'); + const hit = this.#raycast({ + origin, + direction: this.#direction.toArray() as [number, number, number], + distance, + }); + if (hit !== null && (!Number.isFinite(hit) || hit < 0)) + throw new Error("Perception ray query returned an invalid distance."); blocked = hit !== null && hit <= distance; } - if (!blocked) observation = {id: target.id, position}; + if (!blocked) observation = { id: target.id, position }; } } return this.#brain.step(dt, origin, observation); } - forget(): void { this.#brain.forget(); } - dispose(): void { if (!this.#disposed) { this.#disposed = true; this.#brain.dispose(); } } + forget(): void { + this.#brain.forget(); + } + dispose(): void { + if (!this.#disposed) { + this.#disposed = true; + this.#brain.dispose(); + } + } } diff --git a/examples/integrations/perception/src/search.ts b/examples/integrations/perception/src/search.ts index dd8d72241b..b4cddddbcb 100644 --- a/examples/integrations/perception/src/search.ts +++ b/examples/integrations/perception/src/search.ts @@ -1,15 +1,26 @@ export type Point3 = readonly [number, number, number]; -export interface IObservation { readonly id: string; readonly position: Point3; } -export interface ISearchOptions { readonly memorySeconds: number; readonly arrivalDistance: number; } +export interface IObservation { + readonly id: string; + readonly position: Point3; +} +export interface ISearchOptions { + readonly memorySeconds: number; + readonly arrivalDistance: number; +} export interface ISearchDecision { - readonly state: 'patrol' | 'chase' | 'search'; + readonly state: "patrol" | "chase" | "search"; readonly destination: Point3 | null; readonly targetId: string | null; readonly changed: boolean; } export function validatePoint(point: Point3): void { - if (point.length !== 3 || point.some(value => !Number.isFinite(value))) - throw new Error('Perception positions must be finite vec3 values.'); + if ( + point?.length !== 3 || + !Number.isFinite(point[0]) || + !Number.isFinite(point[1]) || + !Number.isFinite(point[2]) + ) + throw new Error("Perception positions must be finite vec3 values."); } /** Editable gameplay policy. Receives observations, never an occluded target's current position. */ export class SearchController { @@ -17,40 +28,59 @@ export class SearchController { readonly #arrivalDistance: number; #age = 0; #memory: IObservation | null = null; - #state: ISearchDecision['state'] = 'patrol'; + #state: ISearchDecision["state"] = "patrol"; #targetId: string | null = null; #disposed = false; constructor(options: ISearchOptions) { - if (!Number.isFinite(options.memorySeconds) || options.memorySeconds <= 0 || - !Number.isFinite(options.arrivalDistance) || options.arrivalDistance < 0) - throw new Error('Perception memory must be positive and arrival distance nonnegative.'); + if ( + !Number.isFinite(options.memorySeconds) || + options.memorySeconds <= 0 || + !Number.isFinite(options.arrivalDistance) || + options.arrivalDistance < 0 + ) + throw new Error("Perception memory must be positive and arrival distance nonnegative."); this.#memorySeconds = options.memorySeconds; this.#arrivalDistance = options.arrivalDistance; } step(dt: number, position: Point3, observation: IObservation | null): ISearchDecision { - if (this.#disposed) throw new Error('Perception controller is disposed.'); - if (!Number.isFinite(dt) || dt < 0) throw new Error('Perception dt must be finite and nonnegative.'); + if (this.#disposed) throw new Error("Perception controller is disposed."); + if (!Number.isFinite(dt) || dt < 0) + throw new Error("Perception dt must be finite and nonnegative."); validatePoint(position); if (observation) { - if (typeof observation.id !== 'string' || !observation.id.trim()) throw new Error('Perception target id is required.'); + if (typeof observation.id !== "string" || !observation.id.trim()) + throw new Error("Perception target id is required."); validatePoint(observation.position); } if (observation) { - this.#memory = {id: observation.id, position: Object.freeze([...observation.position]) as Point3}; + this.#memory = { + id: observation.id, + position: Object.freeze([...observation.position]) as Point3, + }; this.#age = 0; } else if (this.#memory) { // Saturation avoids losing a small fixed step against a very large absolute wall clock. this.#age = Math.min(this.#memorySeconds, this.#age + dt); const destination = this.#memory.position; - if (this.#age >= this.#memorySeconds || Math.hypot(...position.map((v, i) => v - destination[i])) <= this.#arrivalDistance) + if ( + this.#age >= this.#memorySeconds || + Math.hypot(...position.map((v, i) => v - destination[i])) <= this.#arrivalDistance + ) this.#memory = null; } - const state = observation ? 'chase' : this.#memory ? 'search' : 'patrol'; + const state = observation ? "chase" : this.#memory ? "search" : "patrol"; const targetId = this.#memory?.id ?? null; const changed = state !== this.#state || targetId !== this.#targetId; - this.#state = state; this.#targetId = targetId; - return Object.freeze({state, targetId, changed, destination: this.#memory?.position ?? null}); + this.#state = state; + this.#targetId = targetId; + return Object.freeze({ state, targetId, changed, destination: this.#memory?.position ?? null }); + } + forget(): void { + this.#memory = null; + this.#age = 0; + } + dispose(): void { + this.forget(); + this.#disposed = true; } - forget(): void { this.#memory = null; this.#age = 0; } - dispose(): void { this.forget(); this.#disposed = true; } } diff --git a/examples/integrations/perception/src/yuka.d.ts b/examples/integrations/perception/src/yuka.d.ts index 0f3dc536f4..80812d22d9 100644 --- a/examples/integrations/perception/src/yuka.d.ts +++ b/examples/integrations/perception/src/yuka.d.ts @@ -1,9 +1,17 @@ // Minimal structural typing for the two upstream 0.7.8 exports used here. // No runtime shim: npm supplies the actual Yuka implementation. -declare module 'yuka' { - export class Vector3 { x: number; y: number; z: number; set(x: number, y: number, z: number): this; } +declare module "yuka" { + export class Vector3 { + x: number; + y: number; + z: number; + set(x: number, y: number, z: number): this; + } export class Vision { - constructor(owner: {getWorldPosition(out: Vector3): unknown; getWorldDirection(out: Vector3): unknown}); + constructor(owner: { + getWorldPosition(out: Vector3): unknown; + getWorldDirection(out: Vector3): unknown; + }); fieldOfView: number; range: number; visible(point: Vector3): boolean; diff --git a/examples/integrations/perception/tests/contracts.test.mjs b/examples/integrations/perception/tests/contracts.test.mjs index 82a46da018..d71bac41d3 100644 --- a/examples/integrations/perception/tests/contracts.test.mjs +++ b/examples/integrations/perception/tests/contracts.test.mjs @@ -1,60 +1,112 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { SearchController } from '../src/search.ts'; +import assert from "node:assert/strict"; +import test from "node:test"; +import { SearchController } from "../src/search.ts"; const make = () => new SearchController({ memorySeconds: 3, arrivalDistance: 0.5 }); const at = [0, 0, 0]; -const seen = { id: 'player', position: [4, 0, 0] }; -test('starts patrolling and acquires only observed positions', () => { +const seen = { id: "player", position: [4, 0, 0] }; +test("starts patrolling and acquires only observed positions", () => { const brain = make(); - assert.equal(brain.step(0, at, null).state, 'patrol'); + assert.equal(brain.step(0, at, null).state, "patrol"); const result = brain.step(0.1, at, seen); - assert.equal(result.state, 'chase'); assert.deepEqual(result.destination, [4, 0, 0]); + assert.equal(result.state, "chase"); + assert.deepEqual(result.destination, [4, 0, 0]); }); -test('occluded search retains a copied last-seen position', () => { - const brain = make(); const observation = { id: 'player', position: [4, 0, 0] }; - brain.step(0.1, at, observation); observation.position[0] = 99; +test("occluded search retains a copied last-seen position", () => { + const brain = make(); + const observation = { id: "player", position: [4, 0, 0] }; + brain.step(0.1, at, observation); + observation.position[0] = 99; const result = brain.step(1, at, null); - assert.equal(result.state, 'search'); assert.deepEqual(result.destination, [4, 0, 0]); + assert.equal(result.state, "search"); + assert.deepEqual(result.destination, [4, 0, 0]); }); -test('expires memory at the exact simulation-time boundary', () => { - const brain = make(); brain.step(0, at, seen); - assert.equal(brain.step(2.999, at, null).state, 'search'); +test("expires memory at the exact simulation-time boundary", () => { + const brain = make(); + brain.step(0, at, seen); + assert.equal(brain.step(2.999, at, null).state, "search"); const result = brain.step(0.001, at, null); - assert.equal(result.state, 'patrol'); assert.equal(result.destination, null); assert.equal(result.targetId, null); + assert.equal(result.state, "patrol"); + assert.equal(result.destination, null); + assert.equal(result.targetId, null); }); -test('zero dt pauses memory age', () => { - const brain = make(); brain.step(0, at, seen); brain.step(2, at, null); - for (let i = 0; i < 10; i++) assert.equal(brain.step(0, at, null).state, 'search'); - assert.equal(brain.step(1, at, null).state, 'patrol'); +test("zero dt pauses memory age", () => { + const brain = make(); + brain.step(0, at, seen); + brain.step(2, at, null); + for (let i = 0; i < 10; i++) assert.equal(brain.step(0, at, null).state, "search"); + assert.equal(brain.step(1, at, null).state, "patrol"); }); -test('arrival clears the remembered destination without moving the body', () => { - const brain = make(); brain.step(0, at, seen); const position = [3.6, 0, 0]; +test("arrival clears the remembered destination without moving the body", () => { + const brain = make(); + brain.step(0, at, seen); + const position = [3.6, 0, 0]; const result = brain.step(0.1, position, null); - assert.equal(result.state, 'patrol'); assert.equal(result.destination, null); assert.deepEqual(position, [3.6, 0, 0]); + assert.equal(result.state, "patrol"); + assert.equal(result.destination, null); + assert.deepEqual(position, [3.6, 0, 0]); }); -test('reacquisition refreshes memory and identifies target switches', () => { - const brain = make(); brain.step(0, at, seen); brain.step(2, at, null); - const result = brain.step(0.1, at, { id: 'second', position: [5, 0, 0] }); - assert.equal(result.targetId, 'second'); assert.equal(result.changed, true); - assert.equal(brain.step(2, at, null).state, 'search'); +test("reacquisition refreshes memory and identifies target switches", () => { + const brain = make(); + brain.step(0, at, seen); + brain.step(2, at, null); + const result = brain.step(0.1, at, { id: "second", position: [5, 0, 0] }); + assert.equal(result.targetId, "second"); + assert.equal(result.changed, true); + assert.equal(brain.step(2, at, null).state, "search"); }); -test('invalid input does not advance simulation time or alter memory', () => { - const brain = make(); brain.step(0, at, seen); - assert.throws(() => brain.step(2.9, at, { id: 'x', position: [NaN, 0, 0] })); +test("invalid input does not advance simulation time or alter memory", () => { + const brain = make(); + brain.step(0, at, seen); + assert.throws(() => brain.step(2.9, at, { id: "x", position: [Number.NaN, 0, 0] })); assert.throws(() => brain.step(-1, at, null)); - assert.throws(() => brain.step(Infinity, at, null)); - assert.equal(brain.step(0.2, at, null).state, 'search'); + assert.throws(() => brain.step(Number.POSITIVE_INFINITY, at, null)); + assert.equal(brain.step(0.2, at, null).state, "search"); }); -test('forget clears deleted targets and output cannot mutate internal memory', () => { - const brain = make(); const result = brain.step(0, at, seen); - assert.ok(Object.isFrozen(result)); assert.ok(Object.isFrozen(result.destination)); - brain.forget(); assert.equal(brain.step(0, at, null).targetId, null); +test("forget clears deleted targets and output cannot mutate internal memory", () => { + const brain = make(); + const result = brain.step(0, at, seen); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.destination)); + brain.forget(); + assert.equal(brain.step(0, at, null).targetId, null); }); -test('fixed input produces identical decision traces', () => { - const run = () => { const brain = make(); return [brain.step(0.2, at, seen), brain.step(0.2, at, null), brain.step(3, at, null)]; }; +test("fixed input produces identical decision traces", () => { + const run = () => { + const brain = make(); + return [brain.step(0.2, at, seen), brain.step(0.2, at, null), brain.step(3, at, null)]; + }; assert.deepEqual(run(), run()); }); -test('rejects invalid settings and updates after disposal', () => { - for (const memorySeconds of [-1, 0, NaN]) assert.throws(() => new SearchController({memorySeconds, arrivalDistance: 0.5})); - const brain = make(); brain.dispose(); brain.dispose(); assert.throws(() => brain.step(0, at, null), /disposed/); +test("rejects invalid settings and updates after disposal", () => { + for (const memorySeconds of [-1, 0, Number.NaN]) + assert.throws(() => new SearchController({ memorySeconds, arrivalDistance: 0.5 })); + const brain = make(); + brain.dispose(); + brain.dispose(); + assert.throws(() => brain.step(0, at, null), /disposed/); +}); + +test("sparse observations are rejected before changing last-seen memory", () => { + for (let axis = 0; axis < 3; axis++) { + const brain = make(); + brain.step(0, at, seen); + const position = [9, 9, 9]; + Reflect.deleteProperty(position, axis); + assert.throws(() => brain.step(2.9, at, { id: "invalid", position }), /finite vec3/); + const result = brain.step(0.2, at, null); + assert.equal(result.state, "search"); + assert.equal(result.targetId, "player"); + assert.deepEqual(result.destination, seen.position); + } +}); +test("missing observer coordinates fail before advancing the memory clock", () => { + const missing = Array(3); + for (const position of [missing, null, undefined]) { + const brain = make(); + brain.step(0, at, seen); + assert.throws(() => brain.step(2.9, position, null), /finite vec3/); + const result = brain.step(0.2, at, null); + assert.equal(result.state, "search"); + assert.deepEqual(result.destination, seen.position); + } }); diff --git a/examples/integrations/perception/tests/integration.test.mjs b/examples/integrations/perception/tests/integration.test.mjs index 5dcab990bc..b9b2440e25 100644 --- a/examples/integrations/perception/tests/integration.test.mjs +++ b/examples/integrations/perception/tests/integration.test.mjs @@ -1,33 +1,71 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Raycaster, Vector3 } from 'three'; -import { YukaPerception } from '../dist/perception.js'; +import assert from "node:assert/strict"; +import test from "node:test"; +import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Raycaster, Vector3 } from "three"; +import { YukaPerception } from "../dist/perception.js"; function fixture() { - const body = new Object3D(), target = new Object3D(); target.position.z = 8; - const wall = new Mesh(new BoxGeometry(5, 5, 0.5), new MeshBasicMaterial()); wall.position.z = 4; - const raycaster = new Raycaster(); let blocked = false; - const brain = new YukaPerception({body, fieldOfView: Math.PI / 2, range: 10, forward: [0,0,1], memorySeconds: 2, arrivalDistance: 0.2, - raycast: ({origin, direction, distance}) => { + const body = new Object3D(); + const target = new Object3D(); + target.position.z = 8; + const wall = new Mesh(new BoxGeometry(5, 5, 0.5), new MeshBasicMaterial()); + wall.position.z = 4; + const raycaster = new Raycaster(); + let blocked = false; + const brain = new YukaPerception({ + body, + fieldOfView: Math.PI / 2, + range: 10, + forward: [0, 0, 1], + memorySeconds: 2, + arrivalDistance: 0.2, + raycast: ({ origin, direction, distance }) => { if (!blocked) return null; - wall.updateMatrixWorld(true); raycaster.set(new Vector3(...origin), new Vector3(...direction)); raycaster.near = 0; raycaster.far = distance; + wall.updateMatrixWorld(true); + raycaster.set(new Vector3(...origin), new Vector3(...direction)); + raycaster.near = 0; + raycaster.far = distance; return raycaster.intersectObject(wall, false)[0]?.distance ?? null; - }}); - return {body, target, brain, block: value => {blocked = value;}, dispose: () => {brain.dispose(); wall.geometry.dispose(); wall.material.dispose();}}; + }, + }); + return { + body, + target, + brain, + block: (value) => { + blocked = value; + }, + dispose: () => { + brain.dispose(); + wall.geometry.dispose(); + wall.material.dispose(); + }, + }; } -test('actual Yuka vision and Three raycasts respect occlusion and last-seen memory', () => { - const f = fixture(); const target = {id: 'player', object: f.target}; - assert.equal(f.brain.update(0, target).state, 'chase'); f.block(true); f.target.position.z = 9; - assert.deepEqual(f.brain.update(1, target).destination, [0,0,8]); - assert.equal(f.brain.update(1, target).state, 'patrol'); f.dispose(); +test("actual Yuka vision and Three raycasts respect occlusion and last-seen memory", () => { + const f = fixture(); + const target = { id: "player", object: f.target }; + assert.equal(f.brain.update(0, target).state, "chase"); + f.block(true); + f.target.position.z = 9; + assert.deepEqual(f.brain.update(1, target).destination, [0, 0, 8]); + assert.equal(f.brain.update(1, target).state, "patrol"); + f.dispose(); }); -test('FOV and world rotation use the authored forward axis', () => { - const f = fixture(); const target = {id: 'player', object: f.target}; f.body.rotation.y = Math.PI; - assert.equal(f.brain.update(0, target).state, 'patrol'); f.target.position.z = -8; - assert.equal(f.brain.update(0, target).state, 'chase'); f.target.position.z = -11; - assert.equal(f.brain.update(0.1, target).state, 'search'); f.dispose(); +test("FOV and world rotation use the authored forward axis", () => { + const f = fixture(); + const target = { id: "player", object: f.target }; + f.body.rotation.y = Math.PI; + assert.equal(f.brain.update(0, target).state, "patrol"); + f.target.position.z = -8; + assert.equal(f.brain.update(0, target).state, "chase"); + f.target.position.z = -11; + assert.equal(f.brain.update(0.1, target).state, "search"); + f.dispose(); }); -test('perception never takes ownership of movement and disposal is enforced', () => { - const f = fixture(); const original = f.body.position.clone(); - for (let i = 0; i < 30; i++) f.brain.update(1/60, {id: 'player', object: f.target}); - assert.deepEqual(f.body.position, original); f.dispose(); assert.throws(() => f.brain.update(0, null), /disposed/); +test("perception never takes ownership of movement and disposal is enforced", () => { + const f = fixture(); + const original = f.body.position.clone(); + for (let i = 0; i < 30; i++) f.brain.update(1 / 60, { id: "player", object: f.target }); + assert.deepEqual(f.body.position, original); + f.dispose(); + assert.throws(() => f.brain.update(0, null), /disposed/); }); diff --git a/examples/integrations/perception/tsconfig.json b/examples/integrations/perception/tsconfig.json index bddfcc1e4c..31e94b3c8b 100644 --- a/examples/integrations/perception/tsconfig.json +++ b/examples/integrations/perception/tsconfig.json @@ -1 +1,14 @@ -{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","strict":true,"skipLibCheck":true,"declaration":true,"noEmitOnError":true,"rootDir":"src","outDir":"dist"},"include":["src/**/*.ts"]} +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "noEmitOnError": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}