From a40a8c5b12ab6983b8e82978f0107c8a20059a79 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Wed, 18 Mar 2026 23:10:02 +0200 Subject: [PATCH 1/4] Docs: sync parser API and task status --- Parser.md | 8 +++++++- tasks.md | 22 ++++++++++------------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Parser.md b/Parser.md index 3ed4520..eda2d89 100644 --- a/Parser.md +++ b/Parser.md @@ -450,9 +450,13 @@ Parser сначала проверяет: - `lookEntity(entity)` - `examineEntity(entity)` - `takeEntity(entity)` +- `removeInventoryEntity(entity)` - `showInventory()` +- `goToSceneTarget(rawTarget)` - `goToScene(sceneId)` - `goToEntity(entity)` +- `getSeeMessage(target)` +- `describeSpatialRelation(anchorNodeId, relation)` Принцип: - parser — один из клиентов `Game API`, а не его единственный владелец; @@ -482,8 +486,10 @@ Parser сначала проверяет: Например: - `takeEntity(entity)` проверяет дистанцию и возможность взять предмет; - `examineEntity(entity)` проверяет доступность examine; +- `goToSceneTarget(rawTarget)` оставляет `Game` знание о registry сцен и валидности перехода; +- `describeSpatialRelation(anchorNodeId, relation)` формирует player-facing spatial response на основе runtime world model; - `goToEntity(entity)` запускает movement; -- `lookEntity(entity)` возвращает краткое описание. +- `lookEntity(entity)` возвращает краткое описание с учётом spatial parent context, если он есть. То есть: - parser отвечает за язык и выбор цели; diff --git a/tasks.md b/tasks.md index 890824f..f9ebe0d 100644 --- a/tasks.md +++ b/tasks.md @@ -54,7 +54,7 @@ Architecture rules for this initiative: - scope data - unified envelope data - Core decision data -- [ ] Verify that UI, scripts, and game logic continue using the same shared `Game API`. +- [x] Verify that UI, scripts, and game logic continue using the same shared `Game API`. - [ ] Add regression tests / smoke checks for: - `#STAGE1-ON/OFF` - `#STAGE2-ON/OFF` @@ -65,7 +65,7 @@ Architecture rules for this initiative: - post-API escalation - linear plan execution without LLM - manual checklist drafted in `ParserSmoke.md` -- [ ] Formalize runtime spatial hierarchy in `Game` so relation-aware parser queries (`under`, `in`, `behind`, `near`) can execute against real world data instead of returning the current fallback message. +- [ ] Extend runtime spatial hierarchy so remaining relation-aware parser queries like `near` can execute against real world data. ## Spatial Hierarchy Plan @@ -81,14 +81,13 @@ Architecture rules for this initiative: - title - optional description - optional spatial parent link -- [ ] Decide where subscene spatial metadata lives in scene JSON: - - preferably on the `Subscene` component / triggerbox data so migration stays incremental. - [x] Build a scene-level spatial index in runtime: - node lookup by id - children by parent id - children grouped by relation - [ ] Keep this index separate from render hierarchy and separate from visibility/accessibility logic. -- [x] Treat `Subscene` as a virtual spatial node while keeping its authored spatial metadata on the `Subscene` component / triggerbox path. +- [x] Treat `Subscene` as a virtual spatial node. +- [x] Simplify `Subscene` authored data so spatial identity and nesting come from the owning `Triggerbox`, not duplicate fields on the component. - [x] Auto-activate direct spatial children when opening a `Subscene`: - direct `Entity` children - direct `Triggerbox` children @@ -121,11 +120,10 @@ Architecture rules for this initiative: - parent object / node - relation type - [ ] Limit parent candidates to valid nodes in the current scene. -- [x] Add editor UI for `Subscene` to edit: +- [x] Keep `Subscene` editor UI focused on behavior-facing fields only: - title - - node id - - optional parent node - - relation type + - description + - target group id - [x] Add editor UI for `Triggerbox` spatial authoring: - parent object / node - relation type @@ -143,7 +141,7 @@ Architecture rules for this initiative: - [x] Keep existing scenes valid with all spatial fields optional. - [x] Preserve current `activeSubscene` / `subsceneEntities` behavior during migration. -- [ ] Make parser relation grammar continue to work even before a scene defines any spatial metadata. +- [x] Make parser relation grammar continue to work even before a scene defines any spatial metadata. - [ ] Add smoke checks for scenes mixing: - direct object nesting - object inside subscene @@ -152,8 +150,8 @@ Architecture rules for this initiative: ### 5. Documentation -- [ ] Update `Parser.md` so it clearly states spatial hierarchy is owned by `Game`, not parser. -- [ ] Add or update documentation for scene spatial schema and subscene-as-node behavior. +- [x] Update `Parser.md` so it clearly states spatial hierarchy is owned by `Game`, not parser. +- [x] Add or update documentation for scene spatial schema and subscene-as-node behavior. - [ ] Document the editor workflow for assigning parent object and relation type. ## Suggested Order From 6538c6e7b8e07eabeb7d92a2368792a24b9ee7ba Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Wed, 18 Mar 2026 23:34:03 +0200 Subject: [PATCH 2/4] Test: add first autotest iteration --- Autotests.md | 344 ++++++++++++++++++++++ package-lock.json | 376 +++++++++++++++++++++++- package.json | 4 +- tasks.md | 328 ++++++++++----------- tests/fixtures/gameFactory.ts | 130 ++++++++ tests/fixtures/parserFactory.ts | 188 ++++++++++++ tests/fixtures/sceneFactory.ts | 107 +++++++ tests/fixtures/textAssetFactory.ts | 229 +++++++++++++++ tests/integration/parser-game.test.ts | 43 +++ tests/parser/commands.test.ts | 91 ++++++ tests/parser/core.test.ts | 54 ++++ tests/parser/resolution.test.ts | 97 ++++++ tests/scene/spatial-index.test.ts | 53 ++++ tests/scene/subscene-activation.test.ts | 58 ++++ tests/scene/subscene-cleanup.test.ts | 44 +++ vitest.config.ts | 9 + 16 files changed, 1975 insertions(+), 180 deletions(-) create mode 100644 Autotests.md create mode 100644 tests/fixtures/gameFactory.ts create mode 100644 tests/fixtures/parserFactory.ts create mode 100644 tests/fixtures/sceneFactory.ts create mode 100644 tests/fixtures/textAssetFactory.ts create mode 100644 tests/integration/parser-game.test.ts create mode 100644 tests/parser/commands.test.ts create mode 100644 tests/parser/core.test.ts create mode 100644 tests/parser/resolution.test.ts create mode 100644 tests/scene/spatial-index.test.ts create mode 100644 tests/scene/subscene-activation.test.ts create mode 100644 tests/scene/subscene-cleanup.test.ts create mode 100644 vitest.config.ts diff --git a/Autotests.md b/Autotests.md new file mode 100644 index 0000000..3706a5f --- /dev/null +++ b/Autotests.md @@ -0,0 +1,344 @@ +# Autotests + +## Purpose + +This document describes the current automated test setup on the `autotests` branch. + +The first iteration is intentionally narrow: +- deterministic parser behavior; +- parser core contracts; +- scene runtime behavior around spatial hierarchy and subscenes; +- one thin parser + game integration layer. + +This setup is meant to protect the most fragile gameplay contracts without introducing heavy browser or UI end-to-end coverage. + +## Current Stack + +- Test runner: `vitest` +- Environment: `node` +- Command: + +```bash +npm run test +``` + +Type safety check: + +```bash +npm run typecheck +``` + +## Design Principles + +The current autotest system is built around a few constraints: + +- Tests should not depend on large mutable game-content scenes. +- Tests should use small deterministic fixtures. +- Tests should target architecture layers directly: + - parser; + - parser core; + - scene runtime; + - subscene behavior. +- Tests should be readable enough to act as executable architecture documentation. + +Out of scope for this iteration: +- full browser Playwright coverage; +- full UI/canvas assertions; +- LLM-stage testing; +- using live content scenes as the main source of truth. + +## File Layout + +```text +tests/ + fixtures/ + gameFactory.ts + parserFactory.ts + sceneFactory.ts + textAssetFactory.ts + parser/ + commands.test.ts + core.test.ts + resolution.test.ts + scene/ + spatial-index.test.ts + subscene-activation.test.ts + subscene-cleanup.test.ts + integration/ + parser-game.test.ts +vitest.config.ts +``` + +## Fixture System + +The tests use programmatic fixtures instead of real scene files. + +### `tests/fixtures/textAssetFactory.ts` + +Provides a minimal in-memory text layer for tests: +- object titles, descriptions, details, synonyms; +- scene title and description; +- parser service strings; +- parser lexicon; +- parser training data; +- parser command specs. + +Use this when a test needs stable text assets without relying on `public/text/...`. + +### `tests/fixtures/gameFactory.ts` + +Provides a minimal `IGame`-compatible harness: +- captured player-facing messages; +- captured logs; +- captured played sounds; +- minimal `sceneManager`; +- minimal `textAssets`. + +This is the base semantic harness used by scene and parser tests. + +### `tests/fixtures/sceneFactory.ts` + +Builds a tiny `Scene` on top of the test game harness. + +Helpers include: +- `addEntity(...)` +- `addPlayer(...)` +- `addTriggerbox(...)` +- `addWalkbox(...)` + +This is the preferred way to build small deterministic runtime worlds for tests. + +### `tests/fixtures/parserFactory.ts` + +Builds a real `Parser` instance on top of the fixture game and scene. + +It wires the parser to a small semantic gameplay harness for: +- `lookScene` +- `lookEntity` +- `examineEntity` +- `takeEntity` +- `showInventory` +- `goToSceneTarget` +- `goToScene` +- `goToEntity` +- `removeInventoryEntity` +- `describeSpatialRelation` + +It also exposes: + +```ts +await fixture.run('look under chair') +``` + +which returns captured: +- `messages` +- `logs` +- `pendingIntent` + +This is the preferred entry point for parser-side tests. + +## Current Test Coverage + +### Scene Runtime + +#### `tests/scene/spatial-index.test.ts` + +Covers: +- parent/child spatial indexing; +- grouping by relation: + - `in` + - `on` + - `under` + - `behind` +- direct-child lookup staying non-recursive; +- legacy fallback: + - `parentNodeId + relation:null` behaves as `in` + +#### `tests/scene/subscene-activation.test.ts` + +Covers: +- direct entity child activation; +- direct triggerbox child activation; +- nested subscene becoming available; +- grandchildren not auto-activating; +- coexistence of: + - `targetGroupId` + - direct spatial children + +#### `tests/scene/subscene-cleanup.test.ts` + +Covers: +- `Switch` reset on subscene close; +- `sound1` playback path; +- cleanup for spatially included objects, not only group-based ones. + +### Parser + +#### `tests/parser/resolution.test.ts` + +Covers: +- exact resolution; +- synonym match; +- partial match; +- ambiguity clarification; +- deterministic tie-break: + - inventory first; + - nearest scene object when titles are indistinguishable. + +#### `tests/parser/commands.test.ts` + +Covers: +- `teleport` +- `teleport with id` +- wrong item for teleport -> no effect; +- `use id on boombox` +- missing-argument prompts for custom commands. + +#### `tests/parser/core.test.ts` + +Covers: +- pre-API handoff path; +- post-API escalation path; +- linear plan stopping after failure; +- core behavior independent of UI. + +### Thin Integration + +#### `tests/integration/parser-game.test.ts` + +Covers a small end-to-end slice on tiny fixtures: +- `look under chair` +- far-but-visible `examine` + +This layer is intentionally small. + +## How To Run + +Run all tests: + +```bash +npm run test +``` + +Run typecheck: + +```bash +npm run typecheck +``` + +Run a specific test file with Vitest directly: + +```bash +npx vitest run tests/parser/commands.test.ts +``` + +Run tests in watch mode: + +```bash +npx vitest +``` + +## How To Add A New Test + +### Add a parser test + +If the behavior belongs to parser resolution, parser commands, or parser core: +- use `createParserFixture()` +- build the smallest world needed +- run parser input through `fixture.run(...)` +- assert on: + - player-facing messages; + - pending intent; + - scene/inventory side effects. + +Example: + +```ts +const fixture = createParserFixture(); +fixture.addPlayer(); +fixture.addEntity('chair', { title: 'Chair', description: 'A chair.' }); + +const result = await fixture.run('look chair'); + +expect(result.messages.at(-1)).toBe('A chair.'); +``` + +### Add a scene runtime test + +If the behavior belongs to scene/spatial/subscene runtime: +- use `createSceneFixture()` +- build the smallest spatial structure possible +- call runtime helpers or component activation directly +- assert on: + - enabled/disabled state; + - `activeSubscene`; + - `subsceneEntities`; + - switch state; + - played sounds. + +### Add a new parser command fixture + +If a test needs custom command data: +- reuse the default command fixtures already provided; +- or override command assets through: + +```ts +fixture.textAssets.setParserCommands([...]); +``` + +This keeps tests independent from `public/text/system/commands/*.json`. + +## Why Programmatic Fixtures Instead Of Real Scenes + +The current system intentionally avoids large real content scenes because they: +- change frequently during content work; +- contain noise unrelated to the tested contract; +- make failures harder to localize. + +Programmatic fixtures keep failures small and readable. + +Real JSON scene fixtures may still be useful later for: +- serialization tests; +- loader tests; +- migration tests. + +They are not necessary for the first iteration. + +## Current Limitations + +- No browser/UI/canvas assertions yet. +- No Playwright layer yet. +- No direct tests for console preprocessor behavior yet. +- No LLM-stage tests yet. +- Parser NLP stage is not the focus of the current suite. + +## Recommended Next Iteration + +The next useful expansions would be: + +1. Add tests for console-preprocessor behavior: + - `I` + - `X` + - `L` + - `#STAGE1-ON/OFF` + - `#STAGE2-ON/OFF` + +2. Add more parser-core scenarios: + - clarification continuation loops; + - more plan-state transitions; + - more validation branches. + +3. Add tiny serialization/load fixtures if scene loading itself needs coverage. + +4. Add a very small browser smoke layer only if a runtime contract cannot be tested elsewhere. + +## Practical Rule + +When adding a test, prefer this order: + +1. scene/runtime test +2. parser test +3. thin integration test +4. browser/UI test + +If a lower layer can prove the contract, do not jump to a higher one. diff --git a/package-lock.json b/package-lock.json index cdea905..9b10881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "prettier": "^3.3.3", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.0" } }, "node_modules/@babel/code-frame": { @@ -1482,6 +1483,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1527,6 +1535,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1866,6 +1892,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1959,6 +2098,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2066,6 +2215,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2241,6 +2400,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2526,6 +2692,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2567,6 +2743,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3201,6 +3387,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3340,6 +3536,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -3439,6 +3646,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3770,6 +3984,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3823,6 +4044,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -3922,6 +4157,23 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3971,6 +4223,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4205,6 +4467,101 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4221,6 +4578,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 892776d..50fcdc9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.node.json --noEmit", "prepare": "husky", "format": "prettier --write .", @@ -39,7 +40,8 @@ "prettier": "^3.3.3", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.0" }, "lint-staged": { "*.{ts,tsx,js,jsx}": [ diff --git a/tasks.md b/tasks.md index f9ebe0d..2591a25 100644 --- a/tasks.md +++ b/tasks.md @@ -1,178 +1,150 @@ -# Parser Tasks - -## Current Scope - -These tasks cover the parser roadmap described in `Parser.md`, excluding the future LLM cascade. - -## Current Focus - -- [x] Introduce parser custom command assets (`Commands.md`, command TA loading, shared command spec format). -- [x] Expand parser DSL/Core so lower layers can mock richer Stage-2-style plans. -- [x] Implement `TELEPORT WITH` as the first custom command scenario driven by command TA. -- [x] Extend command assets to support multi-argument parsing for flows like `USE X ON Y`. -- [x] Add parser-side relation grammar recognition for queries like `LOOK UNDER TABLE` and `EXAMINE IN DRAWER`. - -## Next Initiative: Spatial Hierarchy In Game - -Goal: -- move spatial world structure into `Game` / scene runtime instead of keeping it as parser-only semantics; -- keep `visibility` and `accessibility` explicitly out of scope for this step; -- let parser consume spatial data as part of world context rather than owning it; -- add editor support so scene authors can assign parent object and relation type. - -Architecture rules for this initiative: -- `spatial` belongs to the world model, not to the parser; -- parser should only read spatial structure through `ParserWorldModelBuilder`; -- visibility/accessibility remain separate concerns and are not part of this task; -- both direct object-to-object nesting and object/subscene nesting must be supported; -- subscene should act as a virtual spatial node as well as a focus/interaction mechanism. - -## Backlog - -- [x] Replace the separate `ParserContextBuilder` / `ParserScopeBuilder` idea with one `ParserWorldModelBuilder` that returns both `context` and `scope`. -- [x] Define explicit scope slices: - - `visible` - - `held` - - `takable` - - `reachable` - - `examinable` - - `subscene` - - `sceneTargets` -- [x] Replace ad-hoc resolution helpers with scope-driven resolution. -- [x] Unify stage outputs so `Stage 1.1` and `Stage 1.2` emit the same Core-facing envelope. -- [x] Refactor `Parser Core` around the unified envelope/protocol. -- [x] Separate pre-API escalation from post-API escalation in `Parser Core`. -- [x] Support linear plan execution in `Parser Core` without requiring LLM. -- [x] Add optional `synonyms` to object TA schema. -- [x] Include `synonyms` in the default object TA template. -- [x] Extend parser target resolution to use: - - `title` - - `synonyms` - - partial matching - - clarification on ambiguity -- [x] Expand `#PEEK` debug output with: - - scope data - - unified envelope data - - Core decision data -- [x] Verify that UI, scripts, and game logic continue using the same shared `Game API`. -- [ ] Add regression tests / smoke checks for: - - `#STAGE1-ON/OFF` - - `#STAGE2-ON/OFF` - - clarification flows - - inventory-aware resolution - - `synonyms` - - pre-API escalation - - post-API escalation - - linear plan execution without LLM - - manual checklist drafted in `ParserSmoke.md` -- [ ] Extend runtime spatial hierarchy so remaining relation-aware parser queries like `near` can execute against real world data. - -## Spatial Hierarchy Plan - -### 1. Runtime / Scene Model - -- [x] Define shared runtime types for spatial placement: - - `parentNodeId` - - `relation` - - relation enum: `in`, `on`, `under`, `behind` -- [x] Add optional spatial metadata to regular scene entities. -- [x] Extend subscene data so a subscene can act as a virtual spatial node: - - stable node id - - title - - optional description - - optional spatial parent link -- [x] Build a scene-level spatial index in runtime: - - node lookup by id - - children by parent id - - children grouped by relation -- [ ] Keep this index separate from render hierarchy and separate from visibility/accessibility logic. -- [x] Treat `Subscene` as a virtual spatial node. -- [x] Simplify `Subscene` authored data so spatial identity and nesting come from the owning `Triggerbox`, not duplicate fields on the component. -- [x] Auto-activate direct spatial children when opening a `Subscene`: - - direct `Entity` children - - direct `Triggerbox` children - - direct nested `Subscene` children -- [x] Keep `Subscene` activation non-recursive: - - opening parent `Subscene A` reveals only direct children - - children of nested `Subscene B` remain inactive until `B` itself is opened - -### 2. Parser Integration - -- [x] Extend `ParserWorldModelBuilder` so parser context includes spatial data projected from runtime. -- [x] Define parser-facing relation projection: - - anchor node id - - relation type - - child node ids -- [x] Replace the current relation-query fallback path with real lookup against runtime spatial data. -- [x] Support first real execution cases: - - `LOOK UNDER X` - - `LOOK IN X` - - `LOOK BEHIND X` -- [ ] Keep `near` out of execution until its runtime semantics are clearly defined. -- [x] Preserve current clarification behavior: - - resolve anchor - - ambiguity handling - - tie-break rules for non-usable ambiguity - -### 3. Editor / UI Authoring - -- [x] Add editor UI for every scene `Entity` to choose: - - parent object / node - - relation type -- [ ] Limit parent candidates to valid nodes in the current scene. -- [x] Keep `Subscene` editor UI focused on behavior-facing fields only: - - title - - description - - target group id -- [x] Add editor UI for `Triggerbox` spatial authoring: - - parent object / node - - relation type -- [ ] Ensure authoring UI does not imply visibility/accessibility behavior that is not implemented yet. -- [x] Add serialization/deserialization support for the new spatial fields. -- [x] Show spatial nesting visually in `HierarchyPanel` for scene entities: - - child entities render below their parent - - nested entities are indented to the right - - flat list order remains stable for roots and fallback cases -- [x] Extend `HierarchyPanel` spatial nesting display to polygon-based scene objects: - - `Triggerbox` - - `Walkbox` - -### 4. Migration / Compatibility - -- [x] Keep existing scenes valid with all spatial fields optional. -- [x] Preserve current `activeSubscene` / `subsceneEntities` behavior during migration. -- [x] Make parser relation grammar continue to work even before a scene defines any spatial metadata. -- [ ] Add smoke checks for scenes mixing: - - direct object nesting - - object inside subscene - - subscene inside object - - nested subscene chains - -### 5. Documentation - -- [x] Update `Parser.md` so it clearly states spatial hierarchy is owned by `Game`, not parser. -- [x] Add or update documentation for scene spatial schema and subscene-as-node behavior. -- [ ] Document the editor workflow for assigning parent object and relation type. - -## Suggested Order - -1. Extract a single world-model builder that produces context and scope together. -2. Unify cascade envelopes. -3. Refactor `Parser Core` around the unified protocol. -4. Add `synonyms` support to TA and target resolution. -5. Improve `#PEEK`. -6. Run regression checks and clean up boundaries with `Game API`. -7. Introduce runtime spatial hierarchy and then reconnect parser relation queries to it. - -## Plan For Step 3 - -- [x] Define a single `CascadeEnvelope` shape that both `Stage 1.1` and `Stage 1.2` emit. -- [x] Replace the current action/handoff JSON split with the unified envelope. -- [x] Make `Parser Core` consume the unified envelope directly instead of inferring behavior from ad-hoc action types. -- [x] Split `Parser Core` flow into explicit phases: - - envelope intake - - pre-API validation/resolution - - API plan execution - - post-API outcome analysis -- [x] Introduce a minimal linear plan execution path in `Core` for non-LLM producers. -- [x] Expose enough debug data in `#PEEK` to inspect envelope and Core decisions while refactoring. +# Autotests Plan + +## Goal + +Introduce the first iteration of automated tests for `Scanline` / `Blue Signal` with focus on deterministic parser, core, and scene-runtime behavior. + +This iteration should: +- cover the most fragile gameplay contracts; +- avoid heavy browser/UI end-to-end coverage; +- use small dedicated fixtures instead of live content scenes; +- be cheap to maintain while the architecture is still evolving. + +Out of scope for this iteration: +- full Playwright coverage; +- LLM-stage testing; +- testing against large real content scenes as the main source of truth. + +## Target Stack + +- [x] Add `vitest` as the test runner. +- [x] Add `npm run test` script. +- [x] Keep the first iteration in a lightweight test environment: + - prefer `node` environment; + - use `jsdom` only if a specific test truly needs it. + +## Test Architecture + +The first iteration should use three layers: + +1. Unit tests for parser and helpers. +2. Runtime tests for scene/spatial/subscene behavior. +3. Thin integration tests for parser + game on tiny fixtures. + +Avoid starting with canvas/UI/browser assertions. + +## Fixtures and Helpers + +- [x] Create `tests/fixtures/sceneFactory.ts` + - helpers for minimal `Scene` setup; + - helpers for entities, triggerboxes, subscenes, switches, and spatial links. + +- [x] Create `tests/fixtures/gameFactory.ts` + - minimal `Game`/`IGame` test harness; + - controllable logging, messages, sounds, and inventory. + +- [x] Create `tests/fixtures/parserFactory.ts` + - build parser with small fixture world; + - helpers for running parser input and reading outcomes. + +- [x] Create `tests/fixtures/textAssetFactory.ts` + - minimal parser/engine text assets for tests; + - keep messages stable and deterministic. + +- [x] Decide fixture style for first iteration: + - start with programmatic fixtures; + - add tiny JSON fixture scenes later only if load/serialization tests need them. + +## First Test Files + +### Parser + +- [x] `tests/parser/resolution.test.ts` + Cover: + - exact title match; + - synonym match; + - partial match; + - ambiguity clarification; + - deterministic tie-break: + - inventory first; + - nearest scene object when needed. + +- [x] `tests/parser/commands.test.ts` + Cover: + - `teleport with id`; + - wrong item -> no effect; + - `use id on boombox`; + - multi-argument parsing for `USE X ON Y`; + - missing-argument prompt cases. + +- [x] `tests/parser/core.test.ts` + Cover: + - unified envelope intake; + - pre-API escalation; + - post-API escalation; + - linear plan execution; + - custom command validation path. + +### Scene / Runtime + +- [x] `tests/scene/spatial-index.test.ts` + Cover: + - direct parent/child lookup; + - relation grouping (`in`, `on`, `under`, `behind`); + - direct-child helper stays non-recursive. + +- [x] `tests/scene/subscene-activation.test.ts` + Cover: + - direct entity child activates; + - direct triggerbox child activates; + - nested subscene becomes available; + - grandchildren do not activate automatically. + +- [x] `tests/scene/subscene-cleanup.test.ts` + Cover: + - switch reset on subscene close; + - `sound1` path fires correctly; + - spatially included switch resets too, not only group-based targets. + +### Thin Integration + +- [x] `tests/integration/parser-game.test.ts` + Cover only a few end-to-end flows on tiny fixtures: + - `look under chair`; + - `teleport with your id card`; + - one far-but-visible `examine` case. + +## Recommended Implementation Order + +1. [x] Add `vitest` infrastructure. +2. [x] Add factories/helpers. +3. [x] Implement spatial runtime tests first: + - `spatial-index.test.ts` + - `subscene-activation.test.ts` + - `subscene-cleanup.test.ts` +4. [x] Implement parser command/resolution tests. +5. [x] Add one thin integration test file. + +## Success Criteria For Iteration 1 + +- [x] `npm run test` works locally. +- [x] Tests do not depend on large mutable content scenes. +- [x] The most fragile parser/runtime contracts are covered. +- [x] Failing tests point to a specific layer: + - parser; + - core; + - scene runtime; + - subscene behavior. + +## Notes + +- Keep UI click behavior out of the first iteration unless a contract cannot be tested elsewhere. +- Prefer deterministic fixtures over browser automation. +- Keep tests readable enough that they double as executable architecture documentation. +- `Autotests.md` is the current developer-facing description of the test system, fixtures, coverage, and usage workflow. +- Current progress: + - `vitest` bootstrap is in place; + - runtime spatial/subscene tests are green; + - parser resolution, commands, and core tests are green; + - one thin integration smoke file is green; + - current status: first autotest iteration is functionally complete. diff --git a/tests/fixtures/gameFactory.ts b/tests/fixtures/gameFactory.ts new file mode 100644 index 0000000..de77c9b --- /dev/null +++ b/tests/fixtures/gameFactory.ts @@ -0,0 +1,130 @@ +import type { IGame } from '../../src/core/IGame'; +import type { Scene } from '../../src/scene/Scene'; +import type { SceneObject } from '../../src/entities/SceneObject'; +import type { SpatialRelationType } from '../../src/scene/spatialTypes'; +import type { Entity } from '../../src/entities/Entity'; +import type { GameActionOutcome } from '../../src/core/GameActionTypes'; +import { createTestTextAssets } from './textAssetFactory'; + +export type TestGameHarness = { + game: IGame; + messages: string[]; + logs: string[]; + sounds: string[]; + notifications: string[]; + textAssets: ReturnType; +}; + +function notImplementedOutcome(code: string): GameActionOutcome { + return { + status: 'failed', + code, + recoverable: false, + }; +} + +export function createTestGame(): TestGameHarness { + const messages: string[] = []; + const logs: string[] = []; + const sounds: string[] = []; + const notifications: string[] = []; + const textAssets = createTestTextAssets(); + + const game: IGame = { + assets: { + setImageCacheBudget() {}, + markSceneSpriteRefs() {}, + syncSceneCacheState() {}, + renameSceneSpriteRefs() {}, + releaseSceneSpriteRefs() {}, + getImageCacheStats() { + return { budgetBytes: 0, estimatedBytes: 0 }; + }, + estimateSpritesTextureBytes: async () => ({ bytes: 0 }), + } as any, + audio: {} as any, + textAssets: textAssets as any, + sceneManager: { + currentScene: null, + scenes: new Map(), + sceneRegistry: new Map(), + switchTo() {}, + } as any, + editor: { + enabled: false, + selectionManager: { + notifyObjectChanged() {}, + }, + } as any, + inventory: [], + showMessage(text: string) { + messages.push(text); + }, + log(text: string) { + logs.push(text); + messages.push(text); + }, + text(key: string, params?: Record) { + return textAssets.getServiceText(key, params); + }, + getSeeMessage(_target: SceneObject) { + return null; + }, + lookScene(_scene?: Scene | null) { + return notImplementedOutcome('not_implemented_look_scene'); + }, + lookEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_look_entity'); + }, + describeSpatialRelation(_anchorNodeId: string, _relation: SpatialRelationType) { + return notImplementedOutcome('not_implemented_describe_spatial_relation'); + }, + examineEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_examine_entity'); + }, + takeEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_take_entity'); + }, + removeInventoryEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_remove_inventory_entity'); + }, + showInventory() { + return notImplementedOutcome('not_implemented_show_inventory'); + }, + goToSceneTarget(_target: string) { + return notImplementedOutcome('not_implemented_go_to_scene_target'); + }, + goToScene(_sceneId: string) { + return notImplementedOutcome('not_implemented_go_to_scene'); + }, + goToEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_go_to_entity'); + }, + showNotification(text: string) { + notifications.push(text); + }, + playSound(name: string) { + sounds.push(name); + }, + openFileBrowser() {}, + setCommandInput() {}, + getCommandInput() { + return null; + }, + focusCommandInput() {}, + input: {}, + isMouseOverUI: false, + canvas: {} as HTMLCanvasElement, + ctx: null, + bufferCanvas: {} as HTMLCanvasElement, + }; + + return { + game, + messages, + logs, + sounds, + notifications, + textAssets, + }; +} diff --git a/tests/fixtures/parserFactory.ts b/tests/fixtures/parserFactory.ts new file mode 100644 index 0000000..91d9dd9 --- /dev/null +++ b/tests/fixtures/parserFactory.ts @@ -0,0 +1,188 @@ +import type { GameActionOutcome } from '../../src/core/GameActionTypes'; +import { Parser } from '../../src/mechanics/Parser'; +import { ComponentSystem } from '../../src/systems/ComponentSystem'; +import { createSceneFixture, type SceneFixture } from './sceneFactory'; +import { Entity } from '../../src/entities/Entity'; + +export type ParserFixture = SceneFixture & { + parser: Parser; + run(input: string): Promise<{ + messages: string[]; + logs: string[]; + pendingIntent: string | null; + }>; +}; + +function okOutcome(code: string, message?: string, data?: Record): GameActionOutcome { + return { status: 'ok', code, message, data }; +} + +export function createParserFixture(): ParserFixture { + const fixture = createSceneFixture(); + + fixture.game.console = { + parserStage1Enabled: true, + parserStage2Enabled: false, + parserPeekEnabled: false, + log() {}, + }; + + fixture.game.lookScene = (scene = fixture.game.sceneManager.currentScene) => { + const targetScene = (scene as any) || fixture.scene; + const description = + fixture.textAssets.getResolvedSceneField(targetScene as any, 'description') || + targetScene?.description || + `You are in ${targetScene?.name || 'Unknown Scene'}.`; + return okOutcome('scene_description', description, { + targetType: 'scene', + sceneId: targetScene?.id, + }); + }; + + fixture.game.lookEntity = (entity: Entity) => { + const description = + fixture.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + if (description?.trim()) { + return okOutcome('entity_description', description, { + targetType: 'entity', + entityId: entity.name, + }); + } + return { status: 'escalate', code: 'missing_description', recoverable: true }; + }; + + fixture.game.examineEntity = (entity: Entity) => { + const distanceError = ComponentSystem.getInteractionDistanceError( + entity as any, + fixture.scene.player + ); + if (distanceError && !fixture.game.inventory.includes(entity)) { + return { + status: 'failed', + code: 'too_far_to_examine', + message: distanceError, + recoverable: true, + }; + } + const details = fixture.textAssets.getResolvedObjectField(entity, 'details'); + if (details?.trim()) { + return okOutcome('entity_details', details, { entityId: entity.name }); + } + const description = + fixture.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + if (description?.trim()) { + return okOutcome('entity_description_fallback', description, { entityId: entity.name }); + } + return { status: 'escalate', code: 'missing_details', recoverable: true }; + }; + + fixture.game.takeEntity = (entity: Entity) => { + const error = ComponentSystem.canTakeItem(entity as any, fixture.scene.player); + if (error) { + return { status: 'failed', code: 'cannot_take', message: error, recoverable: true }; + } + fixture.scene.removeEntity(entity); + fixture.game.inventory.push(entity); + const title = fixture.textAssets.getResolvedObjectField(entity, 'title') || entity.name; + return okOutcome('item_taken', fixture.game.text('parser.take_pickup_success', { item: title }), { + entityId: entity.name, + }); + }; + + fixture.game.removeInventoryEntity = (entity: Entity) => { + const index = fixture.game.inventory.indexOf(entity); + if (index === -1) { + return { status: 'failed', code: 'inventory_item_not_found', recoverable: true }; + } + fixture.game.inventory.splice(index, 1); + return okOutcome('inventory_item_removed', undefined, { entityId: entity.name }); + }; + + fixture.game.showInventory = () => { + const items = fixture.game.inventory + .map((entity) => fixture.textAssets.getResolvedObjectField(entity, 'title')) + .filter((title): title is string => !!title); + if (!items.length) { + return okOutcome('inventory_list', fixture.game.text('parser.inventory_empty')); + } + return okOutcome( + 'inventory_list', + fixture.game.text('parser.inventory_items', { items: items.join(', ') }) + ); + }; + + fixture.game.goToSceneTarget = (target: string) => { + const normalized = String(target || '').trim().toUpperCase(); + for (const descriptor of fixture.game.sceneManager.sceneRegistry.values()) { + if ( + descriptor.id.toUpperCase() === normalized || + descriptor.name.toUpperCase() === normalized || + (!!descriptor.title && descriptor.title.toUpperCase() === normalized) + ) { + return fixture.game.goToScene(descriptor.id); + } + } + return { status: 'failed', code: 'destination_not_found', recoverable: true }; + }; + + fixture.game.goToScene = (sceneId: string) => { + const scene = fixture.game.sceneManager.scenes.get(sceneId); + if (!scene) { + return { status: 'failed', code: 'destination_not_found', recoverable: true }; + } + fixture.game.sceneManager.currentScene = scene; + return okOutcome('scene_switched', scene.description, { sceneId }); + }; + + fixture.game.goToEntity = (entity: Entity) => { + fixture.scene.player?.moveTo(entity.x, entity.y); + const title = fixture.textAssets.getResolvedObjectField(entity, 'title') || entity.name; + return okOutcome('player_moving', fixture.game.text('parser.go_to_success', { target: title }), { + entityId: entity.name, + }); + }; + + fixture.game.describeSpatialRelation = (anchorNodeId, relation) => { + const anchorNode = fixture.scene.getSpatialNode(anchorNodeId); + const anchorTitle = anchorNode?.title?.trim(); + if (!anchorTitle) { + return { status: 'escalate', code: 'spatial_node_missing_title', recoverable: true }; + } + const childTitles = fixture.scene + .getDirectSpatialChildren(anchorNodeId, relation) + .map((child) => fixture.textAssets.getResolvedObjectField(child, 'title')) + .filter((title): title is string => !!title); + if (!childTitles.length) { + return okOutcome( + 'relation_empty', + fixture.game.text('parser.relation_empty', { relation, target: anchorTitle }) + ); + } + return okOutcome( + 'relation_contents', + fixture.game.text('parser.relation_contents', { + Relation: relation.charAt(0).toUpperCase() + relation.slice(1), + relation, + target: anchorTitle, + items: childTitles.join(', '), + }) + ); + }; + + const parser = new Parser(fixture.game); + + return { + ...fixture, + parser, + async run(input: string) { + fixture.messages.length = 0; + fixture.logs.length = 0; + await parser.parse(input); + return { + messages: [...fixture.messages], + logs: [...fixture.logs], + pendingIntent: parser.pendingState?.intent || null, + }; + }, + }; +} diff --git a/tests/fixtures/sceneFactory.ts b/tests/fixtures/sceneFactory.ts new file mode 100644 index 0000000..4ef698e --- /dev/null +++ b/tests/fixtures/sceneFactory.ts @@ -0,0 +1,107 @@ +import { Entity } from '../../src/entities/Entity'; +import { Actor } from '../../src/entities/Actor'; +import { Triggerbox } from '../../src/entities/Triggerbox'; +import { Walkbox } from '../../src/entities/Walkbox'; +import { Scene } from '../../src/scene/Scene'; +import type { SpatialPlacement, SpatialRelationType } from '../../src/scene/spatialTypes'; +import { createTestGame, type TestGameHarness } from './gameFactory'; + +type EntityOptions = { + title?: string; + description?: string; + disabled?: boolean; + groupID?: string | null; + components?: any[]; + spatial?: SpatialPlacement; +}; + +type TriggerboxOptions = { + disabled?: boolean; + groupID?: string | null; + components?: any[]; + spatial?: SpatialPlacement; +}; + +export type SceneFixture = TestGameHarness & { + scene: Scene; + addEntity(name: string, options?: EntityOptions): Entity; + addPlayer(name?: string, x?: number, y?: number): Actor; + addTriggerbox(name: string, options?: TriggerboxOptions): Triggerbox; + addWalkbox(name: string, relation?: SpatialRelationType): Walkbox; +}; + +const DEFAULT_POLY = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, +]; + +export function createSceneFixture(sceneId: string = 'test_scene'): SceneFixture { + const harness = createTestGame(); + const scene = new Scene(harness.game, sceneId, 'Test Scene'); + harness.game.sceneManager.currentScene = scene; + harness.game.sceneManager.scenes.set(sceneId, scene); + harness.game.sceneManager.sceneRegistry.set(sceneId, { + id: sceneId, + path: `${sceneId}.json`, + name: scene.name, + title: scene.name, + sourceData: null, + lastIndexed: Date.now(), + }); + harness.textAssets.setScene(scene.id, { + title: scene.name, + description: scene.description, + }); + + return { + ...harness, + scene, + addEntity(name, options = {}) { + const entity = new Entity(harness.game, 0, 0, 10, 10, name); + entity.description = options.description || `Description for ${name}`; + entity.disabled = options.disabled ?? false; + entity.groupID = options.groupID ?? null; + entity.components = options.components || []; + entity.spatial = options.spatial || {}; + scene.addEntity(entity); + harness.textAssets.setObject(name, { + title: options.title || name, + description: entity.description, + }); + return entity; + }, + addPlayer(name = 'Hero', x = 0, y = 0) { + const player = new Actor(harness.game, x, y, 10, 10, name); + player.isPlayer = true; + scene.addEntity(player); + harness.textAssets.setObject(name, { + title: name, + description: `${name} player`, + }); + return player; + }, + addTriggerbox(name, options = {}) { + const triggerbox = new Triggerbox(DEFAULT_POLY, name, ''); + triggerbox.disabled = options.disabled ?? false; + triggerbox.groupID = options.groupID ?? null; + triggerbox.components = options.components || []; + triggerbox.spatial = options.spatial || {}; + scene.triggerboxes.push(triggerbox); + harness.textAssets.setObject(name, { + title: name, + description: `${name} triggerbox`, + }); + return triggerbox; + }, + addWalkbox(name, relation) { + const walkbox = new Walkbox(DEFAULT_POLY, name); + if (relation) { + walkbox.spatial = { parentNodeId: scene.id, relation }; + } + scene.walkbox.push(walkbox); + return walkbox; + }, + }; +} diff --git a/tests/fixtures/textAssetFactory.ts b/tests/fixtures/textAssetFactory.ts new file mode 100644 index 0000000..43c4860 --- /dev/null +++ b/tests/fixtures/textAssetFactory.ts @@ -0,0 +1,229 @@ +import type { Scene } from '../../src/scene/Scene'; +import type { SceneObject } from '../../src/entities/SceneObject'; +import type { + ObjectTextAssetData, + SceneTextAssetData, +} from '../../src/core/TextAssetManager'; +import type { ParserLexiconAsset, ParserTrainingAsset } from '../../src/mechanics/parserLanguage'; +import type { ParserCommandSpec } from '../../src/mechanics/parserTypes'; + +type TextAssetLike = { + getResolvedObjectField(obj: SceneObject, field: string): string | null; + getResolvedObjectListField(obj: SceneObject, field: string): string[]; + getResolvedSceneField(scene: Scene, field: string): string | null; + getServiceText(key: string, params?: Record): string; + getParserLexicon(): ParserLexiconAsset; + getParserTraining(): ParserTrainingAsset; + getParserCommands(): ParserCommandSpec[]; + readParserTrainingAsset(): Promise; +}; + +export type TestTextAssets = TextAssetLike & { + setObject(id: string, data: ObjectTextAssetData): void; + setScene(id: string, data: SceneTextAssetData): void; + setParserCommands(commands: ParserCommandSpec[]): void; +}; + +const DEFAULT_SERVICE_TEXT: Record = { + 'engine.too_far_generic': 'You are too far away.', + 'engine.too_far_from_entity': 'You are too far away from the {target}.', + 'engine.locked_needs': 'Locked. Needs {item}', + 'engine.locked_generic': 'Locked.', + 'parser.look_default_scene': 'You are in {scene}.', + 'parser.look_default_object': 'You see nothing special about the {target}.', + 'parser.look_not_found': "You don't see any {target} here.", + 'parser.look_which_one': 'Which one do you mean: {options}?', + 'parser.examine_prompt': 'Examine what?', + 'parser.examine_which_one': 'Which one do you want to examine: {options}?', + 'parser.take_prompt': 'Take what?', + 'parser.take_which_one': 'Which item do you mean: {options}?', + 'parser.take_pickup_success': 'You picked up the {item}.', + 'parser.take_cannot': 'You cannot take that.', + 'parser.inventory_empty': 'You are not carrying anything.', + 'parser.inventory_items': 'You are carrying: {items}', + 'parser.go_to_prompt': 'Where do you want to go?', + 'parser.go_to_which_one': 'Where exactly do you want to go: {options}?', + 'parser.go_to_not_found': "You can't get to {target} from here.", + 'parser.go_to_success': 'You go to {target}.', + 'parser.command_no_effect': "That doesn't work.", + 'parser.parse_unknown': "I don't understand.", + 'parser.relation_empty': 'You see nothing {relation} the {target}.', + 'parser.relation_contents': '{Relation} the {target} you see: {items}.', +}; + +const DEFAULT_PARSER_LEXICON: ParserLexiconAsset = { + stage1Aliases: { + look: ['look'], + examine: ['examine', 'inspect', 'check', 'x'], + take: ['take', 'get', 'pickup', 'pick up'], + goTo: ['go', 'walk', 'move'], + showInventory: ['inventory', 'inv'], + }, + normalizationPrefixes: { + look: ['look at', 'look', 'tell me about', 'what is that', 'what is', 'describe'], + examine: ['look closely at', 'take a closer look at', 'examine', 'inspect', 'check'], + take: ['pick up', 'take', 'get', 'grab'], + goTo: ['go to', 'walk to', 'move to', 'go', 'walk', 'move'], + showInventory: [], + }, + politePrefixes: ['please', 'i want to', "i'd like to", 'i would like to'], + articles: ['the', 'a', 'an', 'my'], + lookSceneWords: ['around', 'here', 'scene'], + relationMarkers: { + on: ['on'], + under: ['under', 'beneath'], + in: ['in', 'inside'], + behind: ['behind'], + near: ['near', 'next to', 'by'], + }, +}; + +const DEFAULT_PARSER_TRAINING: ParserTrainingAsset = { + look: ['look chair', 'look at the chair', 'describe the chair'], + examine: ['examine chair', 'inspect the chair', 'check the card'], + take: ['take key', 'pick up key'], + goTo: ['go to office', 'walk office'], + showInventory: ['inventory', 'show inventory', 'what do i have'], +}; + +const DEFAULT_PARSER_COMMANDS: ParserCommandSpec[] = [ + { + id: 'teleport_with', + phrases: ['teleport with', 'teleport'], + arguments: [ + { + name: 'item', + kind: 'entity', + required: true, + scopes: ['held', 'takable'], + messages: { + missing: 'Teleport with what?', + ambiguous: 'Which item do you want to teleport with: {options}?', + notFound: "You don't have anything like that.", + noEffect: "That doesn't work.", + }, + validation: { + allowedTitles: ['your ID card'], + }, + }, + ], + plan: [ + { type: 'resolveArgumentEntity', arg: 'item', saveAs: 'teleport_item' }, + { type: 'ensureHeldEntity', ref: 'teleport_item', noEffectMessageId: 'no_effect' }, + { type: 'goToSceneById', sceneId: 'test1' }, + { type: 'removeInventoryEntity', ref: 'teleport_item' }, + { type: 'showText', messageId: 'success' }, + ], + messages: { + success: 'You vanish in a flash and arrive somewhere else.', + }, + }, + { + id: 'use_on', + phrases: ['use'], + arguments: [ + { + name: 'item', + kind: 'entity', + required: true, + scopes: ['held', 'reachable'], + messages: { + missing: 'Use what on what?', + ambiguous: 'Which item do you mean: {options}?', + notFound: "You don't see anything like that here.", + noEffect: "That doesn't work.", + }, + }, + { + name: 'target', + kind: 'entity', + required: true, + scopes: ['held', 'reachable'], + separatorsBefore: ['on', 'with'], + messages: { + missing: 'Use it on what?', + ambiguous: 'Which target do you mean: {options}?', + notFound: "You don't see anything like that here.", + noEffect: "That doesn't work.", + }, + }, + ], + plan: [ + { type: 'resolveArgumentEntity', arg: 'item', saveAs: 'use_item' }, + { type: 'resolveArgumentEntity', arg: 'target', saveAs: 'use_target' }, + { + type: 'showText', + messageId: 'no_effect_pair', + paramsFromRefs: { + item: 'use_item', + target: 'use_target', + }, + }, + ], + messages: { + no_effect_pair: 'Using the {item} on the {target} does nothing.', + }, + }, +]; + +function interpolate(template: string, params?: Record): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match, token: string) => { + const value = params[token]; + return value === undefined || value === null ? `{${token}}` : String(value); + }); +} + +export function createTestTextAssets(): TestTextAssets { + const objectAssets = new Map(); + const sceneAssets = new Map(); + let parserCommands = structuredClone(DEFAULT_PARSER_COMMANDS); + + return { + setObject(id, data) { + objectAssets.set(String(id), data); + }, + setScene(id, data) { + sceneAssets.set(String(id), data); + }, + getResolvedObjectField(obj, field) { + const asset = objectAssets.get(obj.name); + const value = asset?.[field]; + if (typeof value === 'string') return value; + if (field === 'description' && typeof (obj as { description?: unknown }).description === 'string') { + return (obj as { description?: string }).description || null; + } + return null; + }, + getResolvedObjectListField(obj, field) { + const asset = objectAssets.get(obj.name); + const value = asset?.[field]; + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + }, + getResolvedSceneField(scene, field) { + const asset = sceneAssets.get(scene.id); + const value = asset?.[field]; + if (typeof value === 'string') return value; + if (field === 'description' && typeof scene.description === 'string') return scene.description || null; + return null; + }, + getParserLexicon() { + return structuredClone(DEFAULT_PARSER_LEXICON); + }, + getParserTraining() { + return structuredClone(DEFAULT_PARSER_TRAINING); + }, + getParserCommands() { + return structuredClone(parserCommands); + }, + async readParserTrainingAsset() { + return structuredClone(DEFAULT_PARSER_TRAINING); + }, + setParserCommands(commands) { + parserCommands = structuredClone(commands); + }, + getServiceText(key, params) { + return interpolate(DEFAULT_SERVICE_TEXT[key] || key, params); + }, + }; +} diff --git a/tests/integration/parser-game.test.ts b/tests/integration/parser-game.test.ts new file mode 100644 index 0000000..c8fffcb --- /dev/null +++ b/tests/integration/parser-game.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { createParserFixture } from '../fixtures/parserFactory'; + +describe('Parser + game integration smoke', () => { + it('describes direct spatial contents with LOOK UNDER', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('Chair', { + title: 'Chair', + description: 'A wooden chair.', + }); + fixture.addEntity('note', { + title: 'Piece of paper', + description: 'A folded note.', + spatial: { parentNodeId: 'Chair', relation: 'under' }, + }); + + const result = await fixture.run('look under chair'); + + expect(result.messages.at(-1)).toBe('Under the Chair you see: Piece of paper.'); + }); + + it('surfaces the distance error for a far but visible EXAMINE target', async () => { + const fixture = createParserFixture(); + fixture.addPlayer('Hero', 0, 0); + const boombox = fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + details: 'A detailed boombox description.', + } as any); + boombox.x = 200; + fixture.textAssets.setObject('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + details: 'A detailed boombox description.', + synonyms: ['recorder'], + }); + + const result = await fixture.run('examine boombox'); + + expect(result.messages.at(-1)).toBe('You are too far away from the Boombox.'); + }); +}); diff --git a/tests/parser/commands.test.ts b/tests/parser/commands.test.ts new file mode 100644 index 0000000..2198cfb --- /dev/null +++ b/tests/parser/commands.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { createParserFixture } from '../fixtures/parserFactory'; + +describe('Parser custom commands', () => { + it('prompts when TELEPORT is missing its item', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + + const result = await fixture.run('teleport'); + + expect(result.messages.at(-1)).toBe('Teleport with what?'); + expect(result.pendingIntent).toBe('custom'); + }); + + it('teleports with the allowed ID card and consumes it', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const yourId = fixture.addEntity('miles_id', { + title: 'your ID card', + description: 'Your card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + fixture.game.sceneManager.scenes.set('test1', fixture.scene); + fixture.game.sceneManager.sceneRegistry.set('test1', { + id: 'test1', + path: 'test1.json', + name: 'Test Destination', + title: 'Test Destination', + sourceData: null, + lastIndexed: Date.now(), + }); + + const result = await fixture.run('teleport with id'); + + expect(result.messages.at(-1)).toBe('You vanish in a flash and arrive somewhere else.'); + expect(fixture.game.inventory).not.toContain(yourId); + }); + + it('rejects TELEPORT with the wrong matching item', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('wrong_id', { + title: 'Someone ID card', + description: 'Wrong card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + fixture.game.sceneManager.scenes.set('test1', fixture.scene); + fixture.game.sceneManager.sceneRegistry.set('test1', { + id: 'test1', + path: 'test1.json', + name: 'Test Destination', + title: 'Test Destination', + sourceData: null, + lastIndexed: Date.now(), + }); + + const result = await fixture.run('teleport with id'); + + expect(result.messages.at(-1)).toBe("That doesn't work."); + }); + + it('parses USE X ON Y and renders the no-effect pair message', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const idCard = fixture.addEntity('test_id', { + title: 'Someone ID card', + description: 'Card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + fixture.scene.removeEntity(idCard); + fixture.game.inventory.push(idCard); + fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Recorder.', + }); + + const result = await fixture.run('use id on boombox'); + + expect(result.messages.at(-1)).toBe('Using the Someone ID card on the Boombox does nothing.'); + }); + + it('prompts when USE is missing required arguments', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + + const result = await fixture.run('use'); + + expect(result.messages.at(-1)).toBe('Use what on what?'); + expect(result.pendingIntent).toBe('custom'); + }); +}); diff --git a/tests/parser/core.test.ts b/tests/parser/core.test.ts new file mode 100644 index 0000000..5ac2d67 --- /dev/null +++ b/tests/parser/core.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { createParserFixture } from '../fixtures/parserFactory'; + +describe('Parser core contracts', () => { + it('returns the generic unknown response on pre-API handoff when stage2 is disabled', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + + const result = await fixture.run('sing a song'); + + expect(result.messages.at(-1)).toBe("I don't understand."); + }); + + it('returns the generic unknown response on post-API escalation', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const mystery = fixture.addEntity('mystery_box', { + title: 'Mystery Box', + }); + mystery.description = ''; + fixture.textAssets.setObject('mystery_box', { + title: 'Mystery Box', + }); + + const result = await fixture.run('examine mystery'); + + expect(result.messages.at(-1)).toBe("I don't understand."); + }); + + it('stops a linear plan after a failed validation step', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('wrong_id', { + title: 'Someone ID card', + description: 'Wrong card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + fixture.game.sceneManager.scenes.set('test1', fixture.scene); + fixture.game.sceneManager.sceneRegistry.set('test1', { + id: 'test1', + path: 'test1.json', + name: 'Test Destination', + title: 'Test Destination', + sourceData: null, + lastIndexed: Date.now(), + }); + + const result = await fixture.run('teleport with id'); + + expect(result.messages.at(-1)).toBe("That doesn't work."); + expect(fixture.game.inventory).toHaveLength(0); + expect(fixture.game.sceneManager.currentScene).toBe(fixture.scene); + }); +}); diff --git a/tests/parser/resolution.test.ts b/tests/parser/resolution.test.ts new file mode 100644 index 0000000..3e08fcb --- /dev/null +++ b/tests/parser/resolution.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { createParserFixture } from '../fixtures/parserFactory'; + +describe('Parser resolution', () => { + it('matches an entity by synonym', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const boombox = fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + }); + fixture.textAssets.setObject(boombox.name, { + title: 'Boombox', + description: 'Cassette recorder.', + synonyms: ['recorder', 'radio', 'GF-7'], + }); + + const result = await fixture.run('look recorder'); + + expect(result.messages.at(-1)).toBe('Cassette recorder.'); + }); + + it('matches an entity by partial title', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + }); + + const result = await fixture.run('look boom'); + + expect(result.messages.at(-1)).toBe('Cassette recorder.'); + }); + + it('asks for clarification when multiple distinct targets match', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('your_id', { + title: 'your ID card', + description: 'Your card.', + }); + fixture.addEntity('other_id', { + title: 'Someone ID card', + description: 'Another card.', + }); + + const result = await fixture.run('look id'); + + expect(result.messages.at(-1)).toBe('Which one do you mean: your ID card, Someone ID card?'); + expect(result.pendingIntent).toBe('look'); + }); + + it('prefers the inventory copy when duplicate titles are indistinguishable', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const inventoryCoin = fixture.addEntity('coin_inventory', { + title: 'Coin', + description: 'Inventory coin.', + components: [{ type: 'Item' }], + }); + const sceneCoin = fixture.addEntity('coin_scene', { + title: 'Coin', + description: 'Scene coin.', + components: [{ type: 'Item' }], + }); + fixture.scene.removeEntity(inventoryCoin); + fixture.game.inventory.push(inventoryCoin); + + const result = await fixture.run('look coin'); + + expect(result.messages.at(-1)).toBe('Inventory coin.'); + expect(fixture.game.inventory).toContain(inventoryCoin); + expect(fixture.scene.entities).toContain(sceneCoin); + }); + + it('prefers the nearest scene object when duplicate titles are both in scene', async () => { + const fixture = createParserFixture(); + fixture.addPlayer('Hero', 0, 0); + const nearCoin = fixture.addEntity('near_coin', { + title: 'Coin', + description: 'Near coin.', + components: [{ type: 'Item' }], + }); + nearCoin.x = 5; + const farCoin = fixture.addEntity('far_coin', { + title: 'Coin', + description: 'Far coin.', + components: [{ type: 'Item' }], + }); + farCoin.x = 80; + + const result = await fixture.run('look coin'); + + expect(result.messages.at(-1)).toBe('Near coin.'); + }); +}); diff --git a/tests/scene/spatial-index.test.ts b/tests/scene/spatial-index.test.ts new file mode 100644 index 0000000..e5e360b --- /dev/null +++ b/tests/scene/spatial-index.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Scene spatial index', () => { + it('groups direct children by parent and relation', () => { + const fixture = createSceneFixture(); + fixture.addEntity('Table', { title: 'Table' }); + fixture.addEntity('Key', { + title: 'Key', + spatial: { parentNodeId: 'Table', relation: 'under' }, + }); + fixture.addEntity('Note', { + title: 'Note', + spatial: { parentNodeId: 'Table', relation: 'on' }, + }); + + const index = fixture.scene.getSpatialIndex(); + + expect(index.childrenByParentId.get('Table')?.map((node) => node.id)).toEqual(['Key', 'Note']); + expect(index.childrenByParentAndRelation.get('Table')?.get('under')?.map((node) => node.id)).toEqual(['Key']); + expect(index.childrenByParentAndRelation.get('Table')?.get('on')?.map((node) => node.id)).toEqual(['Note']); + }); + + it('returns only direct children from the helper', () => { + const fixture = createSceneFixture(); + fixture.addEntity('Desk', { title: 'Desk' }); + fixture.addEntity('Drawer', { + title: 'Drawer', + spatial: { parentNodeId: 'Desk', relation: 'in' }, + }); + fixture.addEntity('Paper', { + title: 'Paper', + spatial: { parentNodeId: 'Drawer', relation: 'in' }, + }); + + const directChildren = fixture.scene.getDirectSpatialChildren('Desk'); + + expect(directChildren.map((child) => child.name)).toEqual(['Drawer']); + }); + + it('treats legacy null relation with a parent as "in"', () => { + const fixture = createSceneFixture(); + fixture.addEntity('Cabinet', { title: 'Cabinet' }); + fixture.addEntity('Folder', { + title: 'Folder', + spatial: { parentNodeId: 'Cabinet', relation: null }, + }); + + const index = fixture.scene.getSpatialIndex(); + + expect(index.childrenByParentAndRelation.get('Cabinet')?.get('in')?.map((node) => node.id)).toEqual(['Folder']); + }); +}); diff --git a/tests/scene/subscene-activation.test.ts b/tests/scene/subscene-activation.test.ts new file mode 100644 index 0000000..efc9f01 --- /dev/null +++ b/tests/scene/subscene-activation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { ComponentSystem } from '../../src/systems/ComponentSystem'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Subscene activation', () => { + it('enables direct spatial children and leaves grandchildren disabled', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '' }], + }); + const directEntity = fixture.addEntity('Lamp', { + disabled: true, + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + }); + const nestedSubscene = fixture.addTriggerbox('Trig_B', { + disabled: true, + components: [{ type: 'Subscene', targetGroupId: '' }], + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + }); + const grandchild = fixture.addEntity('HiddenNote', { + disabled: true, + spatial: { parentNodeId: 'Trig_B', relation: 'in' }, + }); + + const handled = ComponentSystem.handleActivation(rootSubscene, fixture.scene); + + expect(handled).toBe(true); + expect(fixture.scene.activeSubscene).toBe('Trig_A'); + expect(directEntity.disabled).toBe(false); + expect(nestedSubscene.disabled).toBe(false); + expect(grandchild.disabled).toBe(true); + expect([...fixture.scene.subsceneEntities].map((item) => item.name).sort()).toEqual(['Lamp', 'Trig_B']); + }); + + it('still includes group targets together with direct spatial children', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '#A' }], + }); + const groupEntity = fixture.addEntity('ByGroup', { + disabled: true, + groupID: '#A', + }); + const spatialEntity = fixture.addEntity('BySpatial', { + disabled: true, + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + }); + + ComponentSystem.handleActivation(rootSubscene, fixture.scene); + + expect(groupEntity.disabled).toBe(false); + expect(spatialEntity.disabled).toBe(false); + expect([...fixture.scene.subsceneEntities].map((item) => item.name).sort()).toEqual([ + 'ByGroup', + 'BySpatial', + ]); + }); +}); diff --git a/tests/scene/subscene-cleanup.test.ts b/tests/scene/subscene-cleanup.test.ts new file mode 100644 index 0000000..44d2f23 --- /dev/null +++ b/tests/scene/subscene-cleanup.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { ComponentSystem } from '../../src/systems/ComponentSystem'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Subscene cleanup', () => { + it('resets switches included via spatial hierarchy when the subscene closes', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '' }], + }); + const spatialSwitch = fixture.addEntity('SwitchEntity', { + disabled: true, + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + components: [{ type: 'Switch', state: 2, sound1: 'switch-close' }], + }); + + ComponentSystem.handleActivation(rootSubscene, fixture.scene); + fixture.scene.activeSubscene = null; + + expect((spatialSwitch.components[0] as { state: number }).state).toBe(1); + expect(fixture.sounds).toEqual(['switch-close']); + expect(spatialSwitch.disabled).toBe(true); + expect(fixture.scene.subsceneEntities.size).toBe(0); + }); + + it('resets switches included via targetGroupId on close as well', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '#A' }], + }); + const groupSwitch = fixture.addEntity('GroupSwitch', { + disabled: true, + groupID: '#A', + components: [{ type: 'Switch', state: 2, sound1: 'group-close' }], + }); + + ComponentSystem.handleActivation(rootSubscene, fixture.scene); + fixture.scene.activeSubscene = null; + + expect((groupSwitch.components[0] as { state: number }).state).toBe(1); + expect(fixture.sounds).toEqual(['group-close']); + expect(groupSwitch.disabled).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..424ff97 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + reporters: 'default', + }, +}); From 7476c8a225537a868be0988082b4b09fb7eec5e6 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Wed, 18 Mar 2026 23:43:41 +0200 Subject: [PATCH 3/4] Test: add Game semantic API coverage --- Autotests.md | 59 +++++++++++++- tasks.md | 25 ++++++ tests/fixtures/gameSemanticFactory.ts | 59 ++++++++++++++ tests/game/navigation-and-spatial.test.ts | 66 ++++++++++++++++ tests/game/semantic-api.test.ts | 94 +++++++++++++++++++++++ 5 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/gameSemanticFactory.ts create mode 100644 tests/game/navigation-and-spatial.test.ts create mode 100644 tests/game/semantic-api.test.ts diff --git a/Autotests.md b/Autotests.md index 3706a5f..831d99b 100644 --- a/Autotests.md +++ b/Autotests.md @@ -7,6 +7,7 @@ This document describes the current automated test setup on the `autotests` bran The first iteration is intentionally narrow: - deterministic parser behavior; - parser core contracts; +- direct `Game` semantic API behavior; - scene runtime behavior around spatial hierarchy and subscenes; - one thin parser + game integration layer. @@ -52,10 +53,14 @@ Out of scope for this iteration: ```text tests/ fixtures/ + gameSemanticFactory.ts gameFactory.ts parserFactory.ts sceneFactory.ts textAssetFactory.ts + game/ + navigation-and-spatial.test.ts + semantic-api.test.ts parser/ commands.test.ts core.test.ts @@ -96,6 +101,25 @@ Provides a minimal `IGame`-compatible harness: This is the base semantic harness used by scene and parser tests. +### `tests/fixtures/gameSemanticFactory.ts` + +Builds on top of `gameFactory.ts` and exposes the real `Game` semantic API methods through `Game.prototype`, while still avoiding full `Game` construction and UI bootstrap. + +This fixture exists specifically for direct `Game`-layer contract tests. + +Use it when the goal is to test: +- `lookScene` +- `lookEntity` +- `examineEntity` +- `showInventory` +- `removeInventoryEntity` +- `goToSceneTarget` +- `goToScene` +- `goToEntity` +- `describeSpatialRelation` + +without pulling parser behavior into the assertion. + ### `tests/fixtures/sceneFactory.ts` Builds a tiny `Scene` on top of the test game harness. @@ -202,6 +226,29 @@ Covers: - linear plan stopping after failure; - core behavior independent of UI. +### Game + +#### `tests/game/semantic-api.test.ts` + +Covers: +- `lookScene`; +- `lookEntity`; +- `examineEntity`; +- `showInventory`; +- `removeInventoryEntity`. + +This layer verifies `Game` as the shared semantic gameplay API, separate from parser parsing. + +#### `tests/game/navigation-and-spatial.test.ts` + +Covers: +- `goToSceneTarget`; +- `goToScene`; +- `goToEntity`; +- `describeSpatialRelation`. + +This layer is especially useful for validating the shared boundary between parser and world/game semantics. + ### Thin Integration #### `tests/integration/parser-game.test.ts` @@ -311,6 +358,7 @@ They are not necessary for the first iteration. - No direct tests for console preprocessor behavior yet. - No LLM-stage tests yet. - Parser NLP stage is not the focus of the current suite. +- The direct `Game` tests use a semantic fixture layered on `Game.prototype`, not full `Game` construction. ## Recommended Next Iteration @@ -328,9 +376,16 @@ The next useful expansions would be: - more plan-state transitions; - more validation branches. -3. Add tiny serialization/load fixtures if scene loading itself needs coverage. +3. Add tests for console/preprocessor behavior: + - `I` + - `X` + - `L` + - `#STAGE1-ON/OFF` + - `#STAGE2-ON/OFF` + +4. Add tiny serialization/load fixtures if scene loading itself needs coverage. -4. Add a very small browser smoke layer only if a runtime contract cannot be tested elsewhere. +5. Add a very small browser smoke layer only if a runtime contract cannot be tested elsewhere. ## Practical Rule diff --git a/tasks.md b/tasks.md index 2591a25..3b6e299 100644 --- a/tasks.md +++ b/tasks.md @@ -125,6 +125,29 @@ Avoid starting with canvas/UI/browser assertions. 4. [x] Implement parser command/resolution tests. 5. [x] Add one thin integration test file. +## Next Iteration Candidate: Game Semantic API Tests + +- [x] Add `tests/game/semantic-api.test.ts` + Cover: + - `lookScene`; + - `lookEntity`; + - `examineEntity`; + - `showInventory`; + - `removeInventoryEntity`. + +- [x] Add `tests/game/navigation-and-spatial.test.ts` + Cover: + - `goToSceneTarget`; + - `goToScene`; + - `goToEntity`; + - `describeSpatialRelation`. + +- [x] Decide whether the current fixture `gameFactory.ts` is sufficient for direct `Game`-layer tests + or if a dedicated semantic `Game` harness should be introduced. + Result: + - keep `gameFactory.ts` as the minimal base harness; + - add `tests/fixtures/gameSemanticFactory.ts` for direct `Game` API tests through `Game.prototype`. + ## Success Criteria For Iteration 1 - [x] `npm run test` works locally. @@ -148,3 +171,5 @@ Avoid starting with canvas/UI/browser assertions. - parser resolution, commands, and core tests are green; - one thin integration smoke file is green; - current status: first autotest iteration is functionally complete. + - next logical slice: direct tests for `Game` as the shared semantic gameplay API. + - `Game` semantic API tests are now green as well. diff --git a/tests/fixtures/gameSemanticFactory.ts b/tests/fixtures/gameSemanticFactory.ts new file mode 100644 index 0000000..4442578 --- /dev/null +++ b/tests/fixtures/gameSemanticFactory.ts @@ -0,0 +1,59 @@ +import { Game } from '../../src/core/Game'; +import { Scene } from '../../src/scene/Scene'; +import { createSceneFixture, type SceneFixture } from './sceneFactory'; + +export type GameSemanticFixture = SceneFixture & { + addScene(id: string, name?: string, description?: string): Scene; +}; + +export function createGameSemanticFixture(sceneId: string = 'test_scene'): GameSemanticFixture { + const fixture = createSceneFixture(sceneId); + + Object.setPrototypeOf(fixture.game, Game.prototype); + for (const methodName of [ + 'lookScene', + 'lookEntity', + 'examineEntity', + 'showInventory', + 'removeInventoryEntity', + 'goToSceneTarget', + 'goToScene', + 'goToEntity', + 'describeSpatialRelation', + 'getSeeMessage', + ] as const) { + delete (fixture.game as Record)[methodName]; + } + + fixture.game.sceneManager.switchTo = (id: string) => { + const scene = fixture.game.sceneManager.scenes.get(id); + if (scene) { + fixture.game.sceneManager.currentScene = scene; + if (fixture.game.onSceneChange) { + fixture.game.onSceneChange(scene.name); + } + } + }; + + return { + ...fixture, + addScene(id: string, name = 'Extra Scene', description = `You are in ${name}.`) { + const scene = new Scene(fixture.game, id, name); + scene.description = description; + fixture.game.sceneManager.scenes.set(id, scene); + fixture.game.sceneManager.sceneRegistry.set(id, { + id, + path: `${id}.json`, + name, + title: name, + sourceData: null, + lastIndexed: Date.now(), + }); + fixture.textAssets.setScene(id, { + title: name, + description, + }); + return scene; + }, + }; +} diff --git a/tests/game/navigation-and-spatial.test.ts b/tests/game/navigation-and-spatial.test.ts new file mode 100644 index 0000000..dc2d2ff --- /dev/null +++ b/tests/game/navigation-and-spatial.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { createGameSemanticFixture } from '../fixtures/gameSemanticFactory'; + +describe('Game navigation and spatial API', () => { + it('goToSceneTarget resolves scene by id and title', () => { + const fixture = createGameSemanticFixture('start'); + const target = fixture.addScene('test1', 'New Scene', 'You are in New Scene.'); + + const byId = fixture.game.goToSceneTarget('test1'); + expect(byId.status).toBe('ok'); + expect(fixture.game.sceneManager.currentScene).toBe(target); + + fixture.game.sceneManager.currentScene = fixture.scene; + + const byTitle = fixture.game.goToSceneTarget('New Scene'); + expect(byTitle.status).toBe('ok'); + expect(fixture.game.sceneManager.currentScene).toBe(target); + }); + + it('goToSceneTarget fails for an unknown destination', () => { + const fixture = createGameSemanticFixture(); + + const outcome = fixture.game.goToSceneTarget('nowhere'); + + expect(outcome.status).toBe('failed'); + expect(outcome.code).toBe('destination_not_found'); + }); + + it('goToEntity starts player movement and returns the player-facing title', () => { + const fixture = createGameSemanticFixture(); + const player = fixture.addPlayer('Hero', 0, 0); + const chair = fixture.addEntity('Chair', { + title: 'Chair', + description: 'A wooden chair.', + }); + chair.x = 42; + chair.y = 84; + + const outcome = fixture.game.goToEntity(chair); + + expect(outcome.status).toBe('ok'); + expect(outcome.message).toBe('You go to Chair.'); + expect(player.target).toEqual({ x: 42, y: 84 }); + }); + + it('describeSpatialRelation returns populated and empty relation messages', () => { + const fixture = createGameSemanticFixture(); + fixture.addEntity('Desk', { + title: 'Desk', + description: 'An office desk.', + }); + fixture.addEntity('note', { + title: 'Piece of paper', + description: 'A folded note.', + spatial: { parentNodeId: 'Desk', relation: 'in' }, + }); + + const populated = fixture.game.describeSpatialRelation('Desk', 'in'); + expect(populated.status).toBe('ok'); + expect(populated.message).toBe('In the Desk you see: Piece of paper.'); + + const empty = fixture.game.describeSpatialRelation('Desk', 'under'); + expect(empty.status).toBe('ok'); + expect(empty.message).toBe('You see nothing under the Desk.'); + }); +}); diff --git a/tests/game/semantic-api.test.ts b/tests/game/semantic-api.test.ts new file mode 100644 index 0000000..4a7ffe0 --- /dev/null +++ b/tests/game/semantic-api.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { createGameSemanticFixture } from '../fixtures/gameSemanticFactory'; + +describe('Game semantic API', () => { + it('lookScene returns the scene description', () => { + const fixture = createGameSemanticFixture(); + + const outcome = fixture.game.lookScene(); + + expect(outcome.status).toBe('ok'); + expect(outcome.message).toBe('You are in Test Scene.'); + }); + + it('lookEntity appends spatial parent context when present', () => { + const fixture = createGameSemanticFixture(); + fixture.addEntity('Table', { + title: 'Table', + description: 'A sturdy table.', + }); + const note = fixture.addEntity('note', { + title: 'Piece of paper', + description: 'A folded note.', + spatial: { parentNodeId: 'Table', relation: 'under' }, + }); + + const outcome = fixture.game.lookEntity(note); + + expect(outcome.status).toBe('ok'); + expect(outcome.message).toBe('A folded note. Under the Table you see: Piece of paper.'); + }); + + it('examineEntity prefers details and falls back to description', () => { + const fixture = createGameSemanticFixture(); + fixture.addPlayer(); + const boombox = fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + }); + fixture.textAssets.setObject('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + details: 'Detailed boombox text.', + }); + + const detailed = fixture.game.examineEntity(boombox); + + expect(detailed.status).toBe('ok'); + expect(detailed.message).toBe('Detailed boombox text.'); + + fixture.textAssets.setObject('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + }); + + const fallback = fixture.game.examineEntity(boombox); + + expect(fallback.status).toBe('ok'); + expect(fallback.message).toBe('Cassette recorder.'); + }); + + it('showInventory returns empty and filled inventory messages', () => { + const fixture = createGameSemanticFixture(); + const emptyOutcome = fixture.game.showInventory(); + expect(emptyOutcome.message).toBe('You are not carrying anything.'); + + const idCard = fixture.addEntity('miles_id', { + title: 'your ID card', + description: 'Your ID.', + }); + fixture.scene.removeEntity(idCard); + fixture.game.inventory.push(idCard); + + const filledOutcome = fixture.game.showInventory(); + expect(filledOutcome.message).toBe('You are carrying: your ID card'); + }); + + it('removeInventoryEntity succeeds only for held items', () => { + const fixture = createGameSemanticFixture(); + const idCard = fixture.addEntity('miles_id', { + title: 'your ID card', + description: 'Your ID.', + }); + fixture.scene.removeEntity(idCard); + fixture.game.inventory.push(idCard); + + const removed = fixture.game.removeInventoryEntity(idCard); + expect(removed.status).toBe('ok'); + expect(fixture.game.inventory).toHaveLength(0); + + const missing = fixture.game.removeInventoryEntity(idCard); + expect(missing.status).toBe('failed'); + expect(missing.code).toBe('inventory_item_not_found'); + }); +}); From 79b0423db8cd7fc8f47e382b39fa6e0e279c1ad2 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 19 Mar 2026 00:00:18 +0200 Subject: [PATCH 4/4] Docs: add project autotests guidance --- AGENTS.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3bbb85b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Project Instructions + +## Autotests Recall Rule + +When working on mechanics/runtime-related code or architecture-sensitive changes in: +- `src/mechanics` +- `src/scene` +- `src/systems` +- `src/core` + +and especially on: +- parser behavior; +- `Game` semantic API behavior; +- spatial hierarchy; +- subscene behavior; + +recall that this project has an autotest system on branch `autotests`. + +Before proceeding with substantial changes in those areas: +- remember that autotests may already cover the contract you are touching; +- consult memory for the current autotest workflow and coverage; +- use `Autotests.md` for the current developer-facing description of: + - when to run autotests; + - how to run them; + - what is currently covered; + - how fixtures and test harnesses are structured. + +## Autotests Maintenance Rule + +When making significant functional changes or adding important new behavior in mechanics/runtime code: +- check whether existing autotests still describe the intended behavior; +- update affected tests if the contract changed; +- add new tests when a new important gameplay/runtime/parser contract is introduced; +- update `Autotests.md` if the test system, fixtures, or coverage model changes in a meaningful way.