From 2613f659f0e1ef6385e952ccf73f80ddf7d208ed Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sat, 25 Apr 2026 18:18:24 +0300 Subject: [PATCH 01/50] Fix inventory item selection coordinates --- src/entities/Entity.ts | 26 ++++++++++++++++++++++++ src/systems/InventoryManager.ts | 2 ++ src/tools/SceneEditor.ts | 5 +++++ tests/parser/world-model-context.test.ts | 12 ++++++++++- 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/entities/Entity.ts b/src/entities/Entity.ts index 7020027..68eec45 100644 --- a/src/entities/Entity.ts +++ b/src/entities/Entity.ts @@ -42,7 +42,10 @@ export interface EntityData { export class Entity extends SceneObject { private _x: number = 0; + private inventoryPositionOwner: Entity | null = null; + get x(): number { + if (this.inventoryPositionOwner) return this.inventoryPositionOwner.x; return this._x; } set x(val: number) { @@ -55,6 +58,7 @@ export class Entity extends SceneObject { private _y: number = 0; get y(): number { + if (this.inventoryPositionOwner) return this.inventoryPositionOwner.y; return this._y; } set y(val: number) { @@ -149,6 +153,28 @@ export class Entity extends SceneObject { animationSpeed: number; // Added game: IGame; + getInventoryPositionOwner(): Entity | null { + return this.inventoryPositionOwner; + } + + setInventoryPositionOwner(owner: Entity | null): void { + if (owner === this) owner = null; + let current = owner; + while (current) { + if (current === this) { + owner = null; + break; + } + current = current.getInventoryPositionOwner(); + } + + if (this.inventoryPositionOwner === owner) return; + this.inventoryPositionOwner = owner; + if (this.game.editor && this.game.editor.enabled) { + this.game.editor.selectionManager.notifyObjectChanged(this); + } + } + /** * List of properties to be serialized to/from JSON. * Extends SceneObject.SERIALIZABLE_PROPS. diff --git a/src/systems/InventoryManager.ts b/src/systems/InventoryManager.ts index 7e8aaa8..d9d77e8 100644 --- a/src/systems/InventoryManager.ts +++ b/src/systems/InventoryManager.ts @@ -305,6 +305,7 @@ export class InventoryManager { scene.addEntity(entity); } (entity as any).__inventoryRelation = relation; + entity.setInventoryPositionOwner(owner); entity.visible = false; entity.spatial = { parentNodeId: owner.name, @@ -316,6 +317,7 @@ export class InventoryManager { private releaseInventoryEntitySceneState(entity: Entity): void { if (this.getInventorySlotForEntity(entity)) return; delete (entity as any).__inventoryRelation; + entity.setInventoryPositionOwner(null); entity.visible = true; } diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index 98df5c1..dec188c 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -1027,6 +1027,11 @@ export class SceneEditor { const zoom = scene && scene.camera ? scene.camera.zoom : 1.0; if (selected instanceof Entity) { + if (this.game.inventoryManager?.getInventorySlotForEntity(selected)) { + ctx.restore(); + continue; + } + if ((selected as any).type === 'Quad') { // ** QUAD SELECTION RENDERING ** const quad = selected as QuadObject; diff --git a/tests/parser/world-model-context.test.ts b/tests/parser/world-model-context.test.ts index 5f3204b..43943fb 100644 --- a/tests/parser/world-model-context.test.ts +++ b/tests/parser/world-model-context.test.ts @@ -370,12 +370,14 @@ describe('Parser world model context', () => { it('omits player inventory items from scene text layer but projects external inventory items by slot relation', () => { const fixture = createSceneFixture(); - const player = fixture.addPlayer('Hero', 0, 0); + const player = fixture.addPlayer('Hero', 12, 34); const heldKey = fixture.addEntity('held_key', { title: 'Held key', description: 'A held key.', components: [{ type: 'Item' }], }); + heldKey.x = 100; + heldKey.y = 200; fixture.game.inventory.push(heldKey); fixture.game.inventoryManager.syncPlayerInventoryComponent(); @@ -393,17 +395,25 @@ describe('Parser world model context', () => { }, ], }); + cabinet.x = 56; + cabinet.y = 78; const book = fixture.addEntity('book', { title: 'Book', description: 'A book.', components: [{ type: 'Item' }], }); + book.x = 300; + book.y = 400; fixture.game.inventoryManager.addInventoryEntity(cabinet as any, book as any, 'behind'); const builder = new ParserWorldModelBuilder(fixture.game as any); const model = builder.build('look cabinet', null); expect((heldKey as any).spatial).toEqual({ parentNodeId: player.name, relation: 'in' }); + expect(heldKey.x).toBe(player.x); + expect(heldKey.y).toBe(player.y); + expect(book.x).toBe(cabinet.x); + expect(book.y).toBe(cabinet.y); expect(model.context.entities?.some((entity) => entity.id === 'held_key')).toBe(false); expect(model.context.entities?.some((entity) => entity.id === 'book')).toBe(true); expect(model.context.spatialRelations).toEqual( From 4aaa3a85369d0459077296ff4959a25b4d71d24a Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sat, 25 Apr 2026 18:47:30 +0300 Subject: [PATCH 02/50] Improve take source success messages --- src/core/TextAssetManager.ts | 1 + src/systems/GameSemanticAPI.ts | 30 ++++++++++++++++++++++++--- tests/fixtures/textAssetFactory.ts | 1 + tests/game/semantic-api.test.ts | 26 +++++++++++++++++++++++ tests/integration/parser-game.test.ts | 5 ++++- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 9eb3edb..4cd1f60 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -40,6 +40,7 @@ const DEFAULT_SERVICE_ASSETS: Record = { take_prompt: 'Take what?', take_which_one: 'Which item do you mean: {options}?', take_pickup_success: 'You picked up the {item}.', + take_pickup_success_from: 'You picked up the {item} from the {source}.', take_already_held: 'You are already carrying the {item}.', take_cannot: 'You cannot take that.', put_prompt: 'Put what?', diff --git a/src/systems/GameSemanticAPI.ts b/src/systems/GameSemanticAPI.ts index 7fcce57..a5265a5 100644 --- a/src/systems/GameSemanticAPI.ts +++ b/src/systems/GameSemanticAPI.ts @@ -104,6 +104,31 @@ export class GameSemanticAPI { return this.getPutTargetDescriptor(target)?.title || null; } + private getTakeSourceTitle(entity: Entity): string | null { + const scene = this.game.sceneManager.currentScene; + if (!scene) return null; + + const sourceState = getSceneTextLayerAccessState(scene, this.game, entity); + if (!sourceState.effectiveParentId) return null; + + const sourceObject = scene.getObjectByName(sourceState.effectiveParentId); + if (sourceObject?.type === 'Walkbox') return null; + return sourceObject ? this.getPlayerFacingObjectTitle(sourceObject) : null; + } + + private getTakeSuccessMessage(itemTitle: string, sourceTitle: string | null): string { + if (sourceTitle) { + return this.game.text('parser.take_pickup_success_from', { + item: itemTitle, + source: sourceTitle, + }); + } + + return this.game.text('parser.take_pickup_success', { + item: itemTitle, + }); + } + private getPutDistanceFailure( storageObject: SceneObject, anchor?: SceneObject | null @@ -1145,6 +1170,7 @@ export class GameSemanticAPI { } scene.finishDropAnimation(entity); + const takeSourceTitle = this.getTakeSourceTitle(entity); const containingSubsceneRootIds = this.game.inventoryManager.getContainingSubsceneRootIds(entity); @@ -1180,9 +1206,7 @@ export class GameSemanticAPI { return { status: 'ok', code: 'item_taken', - message: this.game.text('parser.take_pickup_success', { - item: itemTitle, - }), + message: this.getTakeSuccessMessage(itemTitle, takeSourceTitle), data: { entityId: entity.name }, effects: ['moved_to_inventory'], }; diff --git a/tests/fixtures/textAssetFactory.ts b/tests/fixtures/textAssetFactory.ts index 55e040e..c8ff984 100644 --- a/tests/fixtures/textAssetFactory.ts +++ b/tests/fixtures/textAssetFactory.ts @@ -42,6 +42,7 @@ const DEFAULT_SERVICE_TEXT: Record = { 'parser.take_which_target': 'Which container do you mean: {options}?', 'parser.take_target_not_found': "You don't see any suitable container near {target}.", 'parser.take_pickup_success': 'You picked up the {item}.', + 'parser.take_pickup_success_from': 'You picked up the {item} from the {source}.', 'parser.take_already_held': 'You are already carrying the {item}.', 'parser.take_cannot': 'You cannot take that.', 'parser.put_prompt': 'Put what?', diff --git a/tests/game/semantic-api.test.ts b/tests/game/semantic-api.test.ts index fff3a40..c0c01f4 100644 --- a/tests/game/semantic-api.test.ts +++ b/tests/game/semantic-api.test.ts @@ -1426,6 +1426,12 @@ describe('Game semantic API', () => { const taken = fixture.game.takeEntity(cassette); expect(taken.status).toBe('ok'); + expect(taken.message).toBe( + fixture.game.text('parser.take_pickup_success_from', { + item: 'Cassette', + source: 'Tape recorder', + }) + ); expect(fixture.game.inventory).toContain(cassette); expect(fixture.game.getInventoryEntities(recorder)).not.toContain(cassette); expect(fixture.scene.entities).toContain(cassette); @@ -1433,6 +1439,26 @@ describe('Game semantic API', () => { expect((cassette as any).spatial).toEqual({ parentNodeId: player.name, relation: 'in' }); }); + it('takeEntity keeps the old success message for untitled technical parents', () => { + const fixture = createGameSemanticFixture(); + fixture.addPlayer('Hero', 0, 0); + fixture.addEntity('storage_pocket', { + title: null, + description: 'A technical holder.', + }); + const note = fixture.addEntity('note', { + title: 'Note', + description: 'A note.', + components: [{ type: 'Item', ignoreDistance: true }], + spatial: { parentNodeId: 'storage_pocket', relation: 'in' }, + }); + + const taken = fixture.game.takeEntity(note); + + expect(taken.status).toBe('ok'); + expect(taken.message).toBe(fixture.game.text('parser.take_pickup_success', { item: 'Note' })); + }); + it('takeEntity checks distance to an external inventory owner, not stale item coordinates', () => { const fixture = createGameSemanticFixture(); const player = fixture.addPlayer('Hero', 0, 0); diff --git a/tests/integration/parser-game.test.ts b/tests/integration/parser-game.test.ts index 7e5a760..09dcf38 100644 --- a/tests/integration/parser-game.test.ts +++ b/tests/integration/parser-game.test.ts @@ -120,7 +120,10 @@ describe('Parser + game integration smoke', () => { const messages = await runSemanticParser(fixture, 'take cassete from boombox'); expect(messages.at(-1)).toBe( - fixture.game.text('parser.take_pickup_success', { item: 'Compact cassette' }) + fixture.game.text('parser.take_pickup_success_from', { + item: 'Compact cassette', + source: 'Tape recorder', + }) ); expect(fixture.game.inventory).toContain(cassette); }); From 380fd80614370f89707ab9143f0aa4950ac5364e Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sat, 25 Apr 2026 19:03:02 +0300 Subject: [PATCH 03/50] Hydrate surface items on scene change --- src/systems/InventoryManager.ts | 72 +++++++++++++++ tests/game/navigation-and-spatial.test.ts | 108 ++++++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/src/systems/InventoryManager.ts b/src/systems/InventoryManager.ts index d9d77e8..f2fca54 100644 --- a/src/systems/InventoryManager.ts +++ b/src/systems/InventoryManager.ts @@ -321,6 +321,50 @@ export class InventoryManager { entity.visible = true; } + private hydrateSurfaceEntitySceneState( + surface: SceneObject, + component: SurfaceComponent, + entity: Entity, + placement: SurfaceItemPlacement + ): void { + const scene = this.sceneManager.currentScene; + if (!scene) return; + if (!scene.entities.includes(entity)) { + scene.addEntity(entity); + } + + delete (entity as any).__inventoryRelation; + entity.setInventoryPositionOwner(null); + entity.visible = true; + entity.x = placement.x; + entity.y = placement.y; + entity.layer = Number.isFinite(surface.layer) ? surface.layer : 0; + entity.spatial = { + parentNodeId: surface.name, + relation: this.getSurfaceSlotPlacementRelation({ + surface, + component, + relation: ComponentSystem.normalizeSurfaceRelation(component), + }), + }; + + if (scene.activeSubscene) { + if (this.isObjectInsideActiveSubscene(surface)) { + entity.subsceneItemScale = this.getSurfacePlacementSubsceneItemScale(surface); + entity.disabled = false; + scene.subsceneEntities.add(entity); + this.clearEntityDetachedSubsceneRoot(entity, scene.activeSubscene); + } else { + scene.subsceneEntities.delete(entity); + entity.subsceneItemScale = 1; + } + } else { + scene.subsceneEntities.delete(entity); + entity.subsceneItemScale = 1; + } + entity.update(0); + } + handleSceneChange(): void { this.inventoryEntityStore.clear(); this.inventory = []; @@ -354,6 +398,34 @@ export class InventoryManager { } } + for (const surface of scene.getAllSceneObjects()) { + if (surface.disabled) continue; + + for (const component of ComponentSystem.getSurfaceComponents(surface)) { + if (!Array.isArray(component.items)) component.items = []; + if (typeof component.capacity !== 'number' || !Number.isFinite(component.capacity)) { + component.capacity = Number.MAX_SAFE_INTEGER; + } + component.relation = ComponentSystem.normalizeSurfaceRelation(component); + + component.items = component.items.filter((placement) => { + const entityId = typeof placement?.id === 'string' ? placement.id.trim() : ''; + const entity = entityId ? scene.getObjectByName(entityId) : null; + if ( + !(entity instanceof Entity) || + !Number.isFinite(placement?.x) || + !Number.isFinite(placement?.y) + ) { + return false; + } + + placement.id = entity.name; + this.hydrateSurfaceEntitySceneState(surface, component, entity, placement); + return true; + }); + } + } + this.reconcileInventoryPreview(); this.notifyInventoryUiChange(); } diff --git a/tests/game/navigation-and-spatial.test.ts b/tests/game/navigation-and-spatial.test.ts index ffa8082..820456d 100644 --- a/tests/game/navigation-and-spatial.test.ts +++ b/tests/game/navigation-and-spatial.test.ts @@ -261,6 +261,114 @@ describe('Game navigation and spatial API', () => { ); }); + it('switchTo hydrates surface item placements into entity scene state', () => { + const fixture = createGameSemanticFixture('start'); + const target = fixture.addScene('gallery', 'Gallery', 'You are in Gallery.'); + + const player = new Actor(fixture.game as any, 0, 0, 10, 10, 'Hero'); + player.isPlayer = true; + target.addEntity(player); + fixture.textAssets.setObject('Hero', { + title: 'Hero', + description: 'Hero player', + }); + + const table = new Entity(fixture.game as any, 0, 0, 10, 10, 'table'); + table.layer = 5; + table.components = [ + { + type: 'Surface', + relation: 'on', + capacity: 2, + groups: [], + items: [{ id: 'coin', x: 42, y: 24 }], + }, + ]; + target.addEntity(table); + fixture.textAssets.setObject('table', { + title: 'Table', + description: 'A table.', + }); + + const coin = new Entity(fixture.game as any, 999, 999, 10, 10, 'coin'); + coin.components = [{ type: 'Item' }]; + target.addEntity(coin); + fixture.textAssets.setObject('coin', { + title: 'Coin', + description: 'A coin.', + }); + + fixture.game.sceneManager.switchTo(target.id); + + expect(fixture.game.getSurfaceEntities(table, 'on')).toContain(coin); + expect(coin.visible).toBe(true); + expect(coin.x).toBe(42); + expect(coin.y).toBe(24); + expect(coin.layer).toBe(5); + expect((coin as any).spatial).toEqual({ parentNodeId: 'table', relation: 'on' }); + }); + + it('switchTo hydrates untitled nested surface extensions and projects them through the titled anchor', () => { + const fixture = createGameSemanticFixture('start'); + const target = fixture.addScene('library', 'Library', 'You are in Library.'); + + const player = new Actor(fixture.game as any, 0, 0, 10, 10, 'Hero'); + player.isPlayer = true; + target.addEntity(player); + fixture.textAssets.setObject('Hero', { + title: 'Hero', + description: 'Hero player', + }); + + const desk = new Entity(fixture.game as any, 0, 0, 10, 10, 'desk'); + target.addEntity(desk); + fixture.textAssets.setObject('desk', { + title: 'Desk', + description: 'A desk.', + }); + + const hiddenShelf = new Entity(fixture.game as any, 0, 0, 10, 10, 'hidden_shelf'); + hiddenShelf.spatial = { parentNodeId: 'desk', relation: 'behind' }; + hiddenShelf.components = [ + { + type: 'Surface', + relation: 'on', + capacity: 2, + groups: [], + items: [{ id: 'note', x: 11, y: 12 }], + }, + ]; + target.addEntity(hiddenShelf); + fixture.textAssets.setObject('hidden_shelf', { + description: 'Untitled hidden shelf.', + }); + + const note = new Entity(fixture.game as any, 400, 400, 10, 10, 'note'); + note.components = [{ type: 'Item' }]; + target.addEntity(note); + fixture.textAssets.setObject('note', { + title: 'Note', + description: 'A note.', + }); + + fixture.game.sceneManager.switchTo(target.id); + + expect(fixture.game.getSurfaceEntities(hiddenShelf, 'on')).toContain(note); + expect(note.x).toBe(11); + expect(note.y).toBe(12); + expect((note as any).spatial).toEqual({ parentNodeId: 'hidden_shelf', relation: 'on' }); + + const outcome = fixture.game.describeSpatialRelation('desk', 'behind'); + expect(outcome.status).toBe('ok'); + expect(outcome.message).toBe( + fixture.game.text('parser.relation_contents', { + Relation: 'Behind', + target: 'Desk', + items: 'Note', + }) + ); + }); + it('switchTo hydrates untitled nested inventory extensions and projects them through the titled anchor', () => { const fixture = createGameSemanticFixture('start'); const target = fixture.addScene('workshop', 'Workshop', 'You are in Workshop.'); From e2371b432c6fc2d1106c21826f8b66608b72db04 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sat, 25 Apr 2026 19:26:57 +0300 Subject: [PATCH 04/50] Fix Exit collision with parallax quads --- src/systems/ComponentSystem.ts | 109 ++++++++++++++++++++++++++- tests/scene/scene-transition.test.ts | 52 +++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 1914768..a84491c 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -1,7 +1,11 @@ import { SceneObject } from '../entities/SceneObject'; +import { Entity } from '../entities/Entity'; +import { PolygonObject } from '../entities/PolygonObject'; import { QuadObject } from '../entities/QuadObject'; import type { SpatialRelationType } from '../scene/spatialTypes'; import { Actor } from '../entities/Actor'; +import { Geometry } from '../utils/Geometry'; +import { toVisualPosition } from '../utils/Parallax'; import { ShadowSystem, type ShadowComponent } from './ShadowSystem'; @@ -325,13 +329,116 @@ export class ComponentSystem { for (const actor of actors) { for (const exitObject of exitObjects) { - if (!exitObject.containsPoint(actor.x, actor.y)) continue; + if (!this.exitObjectIntersectsActor(exitObject, actor, scene)) continue; scene.activateObject(exitObject, 0, actor); return; } } } + private static exitObjectIntersectsActor( + exitObject: SceneObject, + actor: Actor, + scene: ActivationSceneContext + ): boolean { + const actorRect = this.getActorVisualColliderRect(actor, scene); + if (!actorRect) { + const point = this.getActorVisualPoint(actor, scene); + return exitObject.containsPoint(point.x, point.y); + } + + if (exitObject instanceof QuadObject) { + const poly = this.getQuadVisualPolygon(exitObject, scene); + return Geometry.rectIntersectsPolygon(actorRect, poly); + } + + if (exitObject instanceof PolygonObject) { + const poly = this.getPolygonVisualPolygon(exitObject, scene); + return Geometry.rectIntersectsPolygon(actorRect, poly); + } + + if (exitObject instanceof Entity) { + const targetRect = this.getEntityVisualRect(exitObject, scene); + return Geometry.rectIntersectsRect(actorRect, targetRect); + } + + return this.getRectProbePoints(actorRect).some((point) => + exitObject.containsPoint(point.x, point.y) + ); + } + + private static getActorVisualPoint( + actor: Actor, + scene: ActivationSceneContext + ): { x: number; y: number } { + const p = actor.parallax !== undefined ? actor.parallax : 1.0; + const visualOffset = (actor as any).visualOffset || { x: 0, y: 0 }; + return toVisualPosition({ x: actor.x, y: actor.y }, scene.camera, p, visualOffset); + } + + private static getActorVisualColliderRect( + actor: Actor, + scene: ActivationSceneContext + ): { x: number; y: number; w: number; h: number } | null { + if (actor.colliderWidth <= 0 || actor.colliderHeight <= 0) return null; + + const visual = this.getActorVisualPoint(actor, scene); + return { + x: visual.x - actor.colliderWidth / 2, + y: visual.y - actor.colliderHeight, + w: actor.colliderWidth, + h: actor.colliderHeight, + }; + } + + private static getQuadVisualPolygon( + quad: QuadObject, + scene: ActivationSceneContext + ): { x: number; y: number }[] { + return quad.vertices.map((v) => { + const p = v.p !== undefined ? v.p : quad.parallax !== undefined ? quad.parallax : 1.0; + return toVisualPosition({ x: v.x, y: v.y }, scene.camera, p); + }); + } + + private static getPolygonVisualPolygon( + polygon: PolygonObject, + scene: ActivationSceneContext + ): { x: number; y: number }[] { + const p = polygon.parallax !== undefined ? polygon.parallax : 1.0; + return polygon.poly.map((v) => toVisualPosition(v, scene.camera, p)); + } + + private static getEntityVisualRect( + entity: Entity, + scene: ActivationSceneContext + ): { x: number; y: number; w: number; h: number } { + const p = entity.parallax !== undefined ? entity.parallax : 1.0; + const visualOffset = (entity as any).visualOffset || { x: 0, y: 0 }; + const visual = toVisualPosition({ x: entity.x, y: entity.y }, scene.camera, p, visualOffset); + return { + x: visual.x - entity.width / 2, + y: visual.y - entity.height, + w: entity.width, + h: entity.height, + }; + } + + private static getRectProbePoints(rect: { + x: number; + y: number; + w: number; + h: number; + }): { x: number; y: number }[] { + return [ + { x: rect.x, y: rect.y }, + { x: rect.x + rect.w, y: rect.y }, + { x: rect.x + rect.w, y: rect.y + rect.h }, + { x: rect.x, y: rect.y + rect.h }, + { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }, + ]; + } + // Called when trying to TAKE an item // Returns string (error message) or null (success) static getInteractionDistanceError( diff --git a/tests/scene/scene-transition.test.ts b/tests/scene/scene-transition.test.ts index 3e50935..8ada65b 100644 --- a/tests/scene/scene-transition.test.ts +++ b/tests/scene/scene-transition.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { createSceneFixture } from '../fixtures/sceneFactory'; import { Actor } from '../../src/entities/Actor'; +import { QuadObject } from '../../src/entities/QuadObject'; describe('Scene Transitions (Exit/Entry)', () => { it('transitions an actor to another scene and places it at the Entry point', async () => { @@ -141,4 +142,55 @@ describe('Scene Transitions (Exit/Entry)', () => { expect(sceneB.entities).not.toContain(door); expect(sceneA.entities).toContain(door); }); + + it('checks an Exit Quad against the actor visual collider under parallax', () => { + const fixture = createSceneFixture(); + const sceneA = fixture.scene; + const game = fixture.game; + const sceneManager = game.sceneManager; + + sceneA.camera.x = 100; + sceneA.camera.y = 0; + + const sceneB = fixture.addScene('scene-b', 'Scene B'); + sceneManager.currentScene = sceneB; + const entryObj = fixture.addTriggerbox('entry-1', { + components: [{ type: 'Entry' }], + }); + entryObj.poly = [ + { x: 100, y: 100 }, + { x: 110, y: 100 }, + { x: 110, y: 110 }, + { x: 100, y: 110 }, + ]; + + sceneManager.currentScene = sceneA; + const exitQuad = new QuadObject(game, 'exit-quad'); + exitQuad.vertices = [ + { x: 0, y: 0, p: 1.5 }, + { x: 20, y: 0, p: 1.5 }, + { x: 20, y: 20, p: 1.5 }, + { x: 0, y: 20, p: 1.5 }, + ]; + exitQuad.components = [{ type: 'Exit', targetSceneId: 'scene-b', targetEntryId: 'entry-1' }]; + sceneA.addEntity(exitQuad); + + const actor = new Actor(game, -40, 10); + actor.name = 'hero'; + actor.parallax = 1.5; + actor.colliderWidth = 4; + actor.colliderHeight = 4; + sceneA.addEntity(actor); + + sceneA.update(16); + expect(sceneManager.currentScene?.id).toBe(sceneA.id); + + actor.x = 10; + actor.y = 10; + sceneA.update(16); + + expect(sceneManager.currentScene?.id).toBe('scene-b'); + expect(actor.x).toBe(105); + expect(actor.y).toBe(105); + }); }); From 8070fdb433a09c207b4626abf7a8834d62c99b0d Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sun, 26 Apr 2026 13:05:16 +0200 Subject: [PATCH 05/50] Fix: correctly parse empty object names for 'all' and 'both' group queries (e.g. TAKE ALL) --- src/mechanics/Parser.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 2d5e45c..0ebbba2 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -436,19 +436,23 @@ export class Parser { })); if (groupQuery.kind !== 'list') { - const matches = this.findPluralAwareMatchesInCandidates(groupQuery.query, candidates); + const matches = !groupQuery.query + ? candidates + : this.findPluralAwareMatchesInCandidates(groupQuery.query, candidates); if (!matches.length) { - const diagnosticMatches = this.findPluralAwareMatchesInCandidates( - groupQuery.query, - this.getVisibleTakeGroupDiagnosticCandidates(rawAnchor, relation) - ); + const diagnosticMatches = !groupQuery.query + ? [] + : this.findPluralAwareMatchesInCandidates( + groupQuery.query, + this.getVisibleTakeGroupDiagnosticCandidates(rawAnchor, relation) + ); if (diagnosticMatches.length) { return buildTakeActions(diagnosticMatches); } return [ { type: 'takeTarget', - target: groupQuery.query, + target: groupQuery.query || 'all', anchor: rawAnchor, relation, }, @@ -458,7 +462,7 @@ export class Parser { if (matches.length > 1) { return [ this.buildTakeTargetAction( - this.singularizeSimplePluralQuery(groupQuery.query), + groupQuery.query ? this.singularizeSimplePluralQuery(groupQuery.query) : 'all', rawAnchor, relation ), @@ -595,14 +599,16 @@ export class Parser { ); if (groupQuery.kind !== 'list') { - const matches = this.findPluralAwareMatchesInCandidates(groupQuery.query, candidates); + const matches = !groupQuery.query + ? candidates + : this.findPluralAwareMatchesInCandidates(groupQuery.query, candidates); if (!matches.length) { - return [this.buildPutTargetAction(groupQuery.query, rawTarget, relation)]; + return [this.buildPutTargetAction(groupQuery.query || 'all', rawTarget, relation)]; } if (groupQuery.kind === 'both' && matches.length !== 2) { return [ this.buildPutTargetAction( - this.singularizeSimplePluralQuery(groupQuery.query), + groupQuery.query ? this.singularizeSimplePluralQuery(groupQuery.query) : 'all', rawTarget, relation ), @@ -665,7 +671,9 @@ export class Parser { ? groupQuery.queries.flatMap((query) => this.findPluralAwareMatchesInCandidates(query, candidates) ) - : this.findPluralAwareMatchesInCandidates(groupQuery.query, candidates); + : !groupQuery.query + ? candidates + : this.findPluralAwareMatchesInCandidates(groupQuery.query, candidates); return Array.from(new Set(matches)); } @@ -720,7 +728,6 @@ export class Parser { const quantifierMatch = /^(all|both)\b\s*(.*)$/i.exec(item); if (quantifierMatch) { const query = this.stripLeadingArticles(quantifierMatch[2]); - if (!query) return null; return { kind: quantifierMatch[1].toLowerCase() === 'all' ? 'all' : 'both', query, From 98cc02b662bd15a42189f7faf8693f7e5260067a Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sun, 26 Apr 2026 14:15:18 +0200 Subject: [PATCH 06/50] Add LLM prompt peek console commands --- GDD.md | 2 ++ src/core/Console.ts | 11 ++++++++++ src/mechanics/LlmCascade.ts | 11 +++++++++- src/mechanics/Parser.ts | 36 +++++++++++++++++++++++++++----- src/mechanics/parserTypes.ts | 7 +++++++ tests/fixtures/parserFactory.ts | 1 + tests/parser/llm-cascade.test.ts | 17 +++++++++++++++ tests/parser/llm-parser.test.ts | 20 ++++++++++++++++++ 8 files changed, 99 insertions(+), 6 deletions(-) diff --git a/GDD.md b/GDD.md index d8b03ec..756ff39 100644 --- a/GDD.md +++ b/GDD.md @@ -48,6 +48,8 @@ - `#CLS` : очищает буфер консоли; - `#PEEK-ON` : включает режим отладки parser-mediator, при котором после каждой игровой команды в консоль выводятся `Context JSON`, `Action JSON` и `Result JSON`; - `#PEEK-OFF` : отключает этот режим. +- `#PEEKLLM-ON` : включает режим отладки LLM-каскада, при котором после LLM-вызова в консоль выводятся полный prompt (`system` + `messages`) и полный raw response/error; +- `#PEEKLLM-OFF` : отключает этот режим. - `#VALIDATE-SPATIAL` : запускает проверку текущей сцены через `SceneSpatialValidator` и выводит в консоль список spatial/container ошибок и предупреждений. Проверяются, в частности, циклы и битые spatial-ссылки, конфликтующие контейнеры с одинаковым relation, некорректные storage-ссылки `Inventory`/`Surface`, `hidden` у безымянных объектов и main inventory персонажа. В режиме редактора `SceneSpatialValidator` также автоматически запускается при загрузке и сохранении сцены. Эта проверка не блокирует работу и не исправляет сцену автоматически: если найдены проблемы, редактор показывает краткое уведомление, а подробный список issues выводится в browser console. Полный ручной отчёт можно получить командой `#VALIDATE-SPATIAL`. diff --git a/src/core/Console.ts b/src/core/Console.ts index 762480d..d969ce2 100644 --- a/src/core/Console.ts +++ b/src/core/Console.ts @@ -20,6 +20,7 @@ export class Console { history: string[] = []; isOpen: boolean = false; parserPeekEnabled: boolean = false; + parserPeekLlmEnabled: boolean = false; parserStage1Enabled: boolean = true; parserStage2Enabled: boolean = true; parserLlmEnabled: boolean = false; @@ -148,6 +149,16 @@ export class Console { this.log('Parser peek disabled.', 'info'); }); + this.registerCommand('#PEEKLLM-ON', () => { + this.parserPeekLlmEnabled = true; + this.log('LLM prompt/response peek enabled.', 'info'); + }); + + this.registerCommand('#PEEKLLM-OFF', () => { + this.parserPeekLlmEnabled = false; + this.log('LLM prompt/response peek disabled.', 'info'); + }); + this.registerCommand('#STAGE1-OFF', () => { this.parserStage1Enabled = false; this.log( diff --git a/src/mechanics/LlmCascade.ts b/src/mechanics/LlmCascade.ts index 27ae3a4..e382a95 100644 --- a/src/mechanics/LlmCascade.ts +++ b/src/mechanics/LlmCascade.ts @@ -112,9 +112,15 @@ export class LlmCascade { 'Respond with a single JSON object. Do not add any text outside the JSON.', ].join('\n'); + const messages = [{ role: 'user' as const, content: userMessage }]; + const prompt = { + system: systemPrompt, + messages, + }; + const response = await this.provider.sendMessageStream( systemPrompt, - [{ role: 'user', content: userMessage }], + messages, (delta, accumulated) => { onThinkingDelta?.(delta, accumulated); } @@ -123,6 +129,7 @@ export class LlmCascade { if (!response.ok) { this.lastDebugInfo = { ...baseDebug, + prompt, durationMs: response.durationMs, tokensGenerated: response.tokensGenerated, rawResponse: response.text, @@ -138,6 +145,7 @@ export class LlmCascade { if (!parsed) { this.lastDebugInfo = { ...baseDebug, + prompt, durationMs: response.durationMs, tokensGenerated: response.tokensGenerated, rawResponse, @@ -151,6 +159,7 @@ export class LlmCascade { const normalized = this.normalizeResponse(parsed); this.lastDebugInfo = { ...baseDebug, + prompt, matched: normalized.actions.length > 0, durationMs: response.durationMs, tokensGenerated: response.tokensGenerated, diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 0ebbba2..b89de17 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -3536,6 +3536,11 @@ export class Parser { return `--- ${title.toUpperCase()} ---\n${this.formatPeekObject(obj)}`; }; + const formatFullSection = (title: string, value: unknown) => { + const body = typeof value === 'string' ? value : JSON.stringify(value, null, 2); + return `--- ${title.toUpperCase()} ---\n${body}`; + }; + const peekMessages = this.game.console?.parserPeekEnabled ? [ formatSection('context', contextJson), @@ -3548,11 +3553,32 @@ export class Parser { ] : undefined; + const peekLlmMessages = + this.game.console?.parserPeekLlmEnabled && llmDebug + ? [ + formatFullSection('llm prompt', llmDebug.prompt || null), + formatFullSection('llm response', { + rawResponse: llmDebug.rawResponse || '', + error: llmDebug.error, + reason: llmDebug.reason, + provider: llmDebug.provider, + model: llmDebug.model, + durationMs: llmDebug.durationMs, + tokensGenerated: llmDebug.tokensGenerated, + }), + ] + : undefined; + + const debugMessages = + peekMessages || peekLlmMessages + ? [...(peekMessages || []), ...(peekLlmMessages || [])] + : undefined; + if (result.type === 'handoff') { return { playerMessage: this.game.text('parser.parse_unknown'), nextPendingState: null, - debugMessages: peekMessages || [ + debugMessages: debugMessages || [ formatSection('handoff context', contextJson), formatSection('handoff scope', scopeJson), formatSection('handoff envelope', envelopeJson), @@ -3607,7 +3633,7 @@ export class Parser { return { playerMessage: clarification.message || this.game.text('parser.parse_unknown'), nextPendingState, - debugMessages: peekMessages, + debugMessages, }; } @@ -3616,7 +3642,7 @@ export class Parser { return { playerMessage: escalation.message || this.game.text('parser.parse_unknown'), nextPendingState: null, - debugMessages: peekMessages || [ + debugMessages: debugMessages || [ `[Parser handoff] context=${contextJson}`, `[Parser handoff] scope=${scopeJson}`, `[Parser handoff] envelope=${envelopeJson}`, @@ -3637,7 +3663,7 @@ export class Parser { playerMessage: playerMessages.length === 1 ? playerMessages[0] : undefined, playerMessages: playerMessages.length > 1 ? playerMessages : undefined, nextPendingState: null, - debugMessages: peekMessages, + debugMessages, }; } @@ -3645,7 +3671,7 @@ export class Parser { playerMessage: outcomeMessages.length === 1 ? outcomeMessages[0] : undefined, playerMessages: outcomeMessages.length > 1 ? outcomeMessages : undefined, nextPendingState: null, - debugMessages: peekMessages, + debugMessages, }; } diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index f7bf6eb..afc0c48 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -321,6 +321,13 @@ export type LlmCascadeDebugInfo = { matched: boolean; provider: string; model?: string; + prompt?: { + system: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + }>; + }; durationMs?: number; tokensGenerated?: number; rawResponse?: string; diff --git a/tests/fixtures/parserFactory.ts b/tests/fixtures/parserFactory.ts index e995f40..ee29be1 100644 --- a/tests/fixtures/parserFactory.ts +++ b/tests/fixtures/parserFactory.ts @@ -92,6 +92,7 @@ export function createParserFixture(): ParserFixture { parserStage1Enabled: true, parserStage2Enabled: false, parserPeekEnabled: false, + parserPeekLlmEnabled: false, log() {}, }; diff --git a/tests/parser/llm-cascade.test.ts b/tests/parser/llm-cascade.test.ts index a557a43..1401d3a 100644 --- a/tests/parser/llm-cascade.test.ts +++ b/tests/parser/llm-cascade.test.ts @@ -78,6 +78,23 @@ describe('LlmCascade', () => { expect(debug?.acceptedActions).toHaveLength(1); }); + it('stores the full prompt and raw response in debug info', async () => { + provider.response.text = JSON.stringify({ + kind: 'final_response', + message: 'Full response text.', + }); + + await cascade.parse('speak to the terminal', mockContext); + + const debug = cascade.getLastDebugInfo(); + expect(debug?.prompt?.system).toContain('Respond with exactly one JSON object'); + expect(debug?.prompt?.messages[0]?.role).toBe('user'); + expect(debug?.prompt?.messages[0]?.content).toContain( + 'Player command: "speak to the terminal"' + ); + expect(debug?.rawResponse).toBe(provider.response.text); + }); + it('converts final_response to a showText action', async () => { provider.response.text = JSON.stringify({ kind: 'final_response', diff --git a/tests/parser/llm-parser.test.ts b/tests/parser/llm-parser.test.ts index 472953c..c6c6d32 100644 --- a/tests/parser/llm-parser.test.ts +++ b/tests/parser/llm-parser.test.ts @@ -23,6 +23,26 @@ describe('Parser LLM Integration', () => { expect(console.parserLlmEnabled).toBe(false); }); + it('#PEEKLLM-ON/#PEEKLLM-OFF toggle parserPeekLlmEnabled on a real Console instance', () => { + const game = { + log: vi.fn(), + textAssets: { + getParserCommands: () => ({}), + getParserLexicon: () => ({}), + }, + sceneManager: { currentScene: null }, + }; + const console = new Console(game); + + expect(console.parserPeekLlmEnabled).toBe(false); + + console.processCommand('#peekllm-on'); + expect(console.parserPeekLlmEnabled).toBe(true); + + console.processCommand('#peekllm-off'); + expect(console.parserPeekLlmEnabled).toBe(false); + }); + it('#C1-OFF/#C1-ON toggle forced LLM handoff mode', () => { const game = { log: vi.fn(), From 4dde4d34a83c3b30ab7e9ac51492eb8d15340787 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sun, 26 Apr 2026 23:31:57 +0200 Subject: [PATCH 07/50] Refactoring & Fixes --- Sessions.md | 8 +- SpatialSys.md | 20 +- public/scenes/test_room.json | 258 +++++++++--------- public/text/objects/Drawer1.json | 2 +- public/text/objects/Drawer2.json | 6 + public/text/objects/Trig_sub_D.json | 6 + public/text/objects/miles_id.json | 4 +- src/components/editor/HierarchyPanel.tsx | 76 ++++-- .../editor/properties/FolderProperties.tsx | 32 ++- src/mechanics/Parser.ts | 50 +++- src/systems/GameSemanticAPI.ts | 4 +- src/systems/InventoryManager.ts | 54 ++-- src/tools/editor/EditorPersistenceManager.ts | 15 +- tests/fixtures/gameFactory.ts | 4 +- tests/fixtures/parserFactory.ts | 15 +- tests/game/semantic-api.test.ts | 46 ++++ tests/integration/parser-game.test.ts | 109 ++++++++ tests/parser/world-model-context.test.ts | 22 ++ tests/scene/scene-spatial-validator.test.ts | 26 ++ 19 files changed, 539 insertions(+), 218 deletions(-) create mode 100644 public/text/objects/Drawer2.json create mode 100644 public/text/objects/Trig_sub_D.json diff --git a/Sessions.md b/Sessions.md index cf406a3..c8aac8c 100644 --- a/Sessions.md +++ b/Sessions.md @@ -596,7 +596,7 @@ During the session the following checks were run successfully: ## Session Entry - 2026-04-19 12:13 +02:00 - 1. What was completed +1. What was completed - Decomposition of Game.ts: Successfully refactored the monolith from ~80KB to ~40KB by delegating responsibilities to specialized systems. @@ -609,20 +609,20 @@ During the session the following checks were run successfully: - Autotests & CI Consistency: Updated test fixtures and ensured the entire suite (202 tests) passes, confirming no regressions in parser or runtime logic. - 1. Current state +2. Current state - The core architecture is now modular and more scalable. - The IGame interface is fully updated to reflect the new delegation pattern. - The workspace is clean, and changes are committed to the scene-refact2 branch. - Browser runtime errors (SyntaxErrors due to improper type imports) have been fully resolved and verified. - 1. Next steps +3. Next steps - Feature Sprint: Resume development of gameplay features as defined in GDD.md. - Cleanup: Conduct a final audit of any remaining any casts in ComponentSystem.ts that can now be replaced with AnyComponent. - Tauri Prep: Proceed with the explicit workspace model for the desktop build as outlined in Tauri.md. - 1. Risks & Caveats +4. Risks & Caveats - Import Precision: Developers must use import type when bringing in IGame or GameActionOutcome in new files to avoid Vite build failures. diff --git a/SpatialSys.md b/SpatialSys.md index 41b8413..3e59925 100644 --- a/SpatialSys.md +++ b/SpatialSys.md @@ -23,9 +23,15 @@ Raw hierarchy остаётся правдой сцены. Но player-facing т - `on` - на поверхности. - `under` - под. - `behind` - за. -- `near` - распознаётся parser-ом как relation word, но не является полноценным storage relation и не используется как container relation. +- `near` - пространственное отношение (proximity). Допускается в `spatial.relation` для визуальной/семантической группировки, но **запрещено** использовать в качестве storage relation. -В данных сцены relation обычно задаётся в нижнем регистре. В user-facing тексте relation форматируется человекочитаемо: `in`, `on`, `under`, `behind`. +### Правила использования `near` + +- **(1) Validity в данных сцены (Scene Data):** `near` является полностью валидным значением для `spatial.relation` при расстановке объектов на сцене, когда нужно указать, что один объект находится "около" или "рядом" с другим (anchor). +- **(2) Parser / Runtime handling:** Парсер распознает токен `near` (например, `LOOK NEAR DESK`), а рантайм использует это значение исключительно для расчета пространственной близости (proximity-only relation). В отличие от `in` или `on`, `near` не дает доступа к вложенному хранилищу. +- **(3) Validation rules:** Валидатор (validator) **обязан отклонять** (reject) значение `near`, если оно указано внутри конфигурации любых storage containers (например, у компонентов `Inventory` или `Surface`), так как "рядом" не может быть слотом для хранения предметов. + +В данных сцены relation обычно задаётся в нижнем регистре. В user-facing тексте relation форматируется человекочитаемо: `in`, `on`, `under`, `behind`, `near`. ## Raw Spatial Hierarchy @@ -34,6 +40,7 @@ Raw hierarchy остаётся правдой сцены. Но player-facing т ```ts spatial?: { parentNodeId: string; + // 'near' используется только для proximity и отклоняется в storage components relation: 'in' | 'on' | 'under' | 'behind' | 'near'; } ``` @@ -272,7 +279,14 @@ Chair (Title, Surface relation under) - `PUT book ON SHELF` ищет storage с relation `on` на `Shelf`. - `PUT cassette UNDER CHAIR` ищет storage с relation `under` на `Chair`. -У одного titled object не должно быть двух storage components одного kind/relation, создающих конфликт. Валидатор должен подсвечивать duplicate relation slots. +У одного titled object не должно быть двух storage components с одинаковым relation — даже если это разные типы компонентов (например, один `Inventory`, а другой `Surface`). Валидатор должен подсвечивать такие duplicate relation slots как ошибку. + +Пример конфликта (invalid): +- `Box` (Title) + - `Inventory` (relation: `in`) + - `Surface` (relation: `in`) + +В таком случае команды типа `PUT key IN BOX` должны быть отклонены валидатором из-за неоднозначности (неясно, в какой именно из `in`-слотов класть предмет). ## External / Technical Containers diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index b2ae474..d5eb211 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -18,6 +18,7 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "poly": [ { "x": -79, @@ -65,6 +66,7 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "poly": [ { "x": 82, @@ -132,7 +134,7 @@ { "type": "Subscene", "targetGroupId": "#D", - "itemScale": 9.5 + "itemScale": 8 } ], "layer": 0, @@ -142,6 +144,7 @@ "parentNodeId": "Desk", "relation": "in" }, + "parallax": 1, "poly": [ { "x": 27, @@ -176,8 +179,7 @@ "y": 194 } ], - "script": "", - "parallax": 1 + "script": "" }, { "name": "Drawer1", @@ -200,13 +202,14 @@ "clearlyOpenable": true } ], - "layer": 0, + "layer": 1, "visible": true, "hidden": false, "spatial": { "parentNodeId": "Trig_sub_D", "relation": "in" }, + "parallax": 1, "poly": [ { "x": -156.29411764705887, @@ -225,8 +228,7 @@ "y": -95 } ], - "script": "", - "parallax": 1 + "script": "" }, { "name": "Trig_834", @@ -241,12 +243,12 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "poly": [], - "script": "", - "parallax": 1 + "script": "" }, { - "name": "sub_sw_d2", + "name": "Drawer2", "type": "Triggerbox", "locked": false, "disabled": true, @@ -268,6 +270,11 @@ "layer": 0, "visible": true, "hidden": false, + "spatial": { + "parentNodeId": "Trig_sub_D", + "relation": "in" + }, + "parallax": 1, "poly": [ { "x": -156.29411764705878, @@ -286,8 +293,7 @@ "y": -10.68627450980393 } ], - "script": "", - "parallax": 1 + "script": "" }, { "name": "Desk", @@ -302,6 +308,7 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "poly": [ { "x": -209.1508060212592, @@ -380,8 +387,7 @@ "y": 211 } ], - "script": "", - "parallax": 1 + "script": "" }, { "name": "d1_surface", @@ -408,6 +414,7 @@ "parentNodeId": "Drawer1", "relation": "in" }, + "parallax": 1, "poly": [ { "x": -145, @@ -426,25 +433,7 @@ "y": -23 } ], - "script": "", - "parallax": 1 - }, - { - "name": "Trig_338", - "type": "Triggerbox", - "locked": false, - "disabled": false, - "groupID": null, - "customName": "", - "textRedirects": {}, - "interactions": {}, - "components": [], - "layer": 0, - "visible": true, - "hidden": false, - "poly": [], - "script": "", - "parallax": 1 + "script": "" }, { "name": "desk_surface", @@ -471,6 +460,7 @@ "parentNodeId": "Desk", "relation": "on" }, + "parallax": 1, "poly": [ { "x": -29, @@ -509,8 +499,7 @@ "y": 107 } ], - "script": "", - "parallax": 1 + "script": "" }, { "name": "wall", @@ -525,6 +514,7 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "poly": [ { "x": -238, @@ -555,30 +545,53 @@ "y": 12 } ], - "script": "", - "parallax": 1 + "script": "" }, { - "name": "entry", + "name": "d2_surface", "type": "Triggerbox", "locked": false, - "disabled": false, - "groupID": null, + "disabled": true, + "groupID": "#D2", "customName": "", "textRedirects": {}, "interactions": {}, "components": [ { - "type": "Entry", - "direction": "left" + "type": "Surface", + "relation": "in", + "capacity": 5, + "groups": [], + "items": [] } ], - "layer": 0, + "layer": 4, "visible": true, "hidden": false, - "poly": [], - "script": "", - "parallax": 1 + "spatial": { + "parentNodeId": "Drawer2", + "relation": "in" + }, + "parallax": 1, + "poly": [ + { + "x": -80, + "y": 66 + }, + { + "x": 346, + "y": 66 + }, + { + "x": 326, + "y": -19 + }, + { + "x": -60, + "y": -20 + } + ], + "script": "" } ], "scaling": { @@ -615,6 +628,7 @@ "parentNodeId": "window1", "relation": "in" }, + "parallax": 0.4, "x": 119.67896209456934, "y": 234, "width": 821.6, @@ -627,7 +641,6 @@ "color": "#00ff00", "scale": 0.65, "modelScale": 0.65, - "parallax": 0.4, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -656,6 +669,7 @@ "layer": -1, "visible": true, "hidden": false, + "parallax": 1, "x": 199, "y": 297, "width": 884.8, @@ -668,7 +682,6 @@ "color": "#888888", "scale": 0.7, "modelScale": 0.7, - "parallax": 1, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -696,6 +709,7 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "x": 100.562, "y": 249.75294117647059, "width": 116.89999999999999, @@ -708,7 +722,6 @@ "color": "#00ff00", "scale": 0.7, "modelScale": 0.7, - "parallax": 1, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -744,8 +757,9 @@ "layer": 0, "visible": true, "hidden": false, - "x": 195.15987568672656, - "y": 251.6724017789711, + "parallax": 1.0572798870396043, + "x": 194.30236238320973, + "y": 273.63822717059463, "width": 71.03999999999999, "height": 290.08, "baseWidth": 96, @@ -756,7 +770,6 @@ "color": "#00ffff", "scale": 0.74, "modelScale": 0.74, - "parallax": 1.0429510848780243, "ignoreScaling": true, "animationSpeed": 30, "opacity": 1, @@ -804,6 +817,7 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "x": -328, "y": 307, "width": 171.2340644206598, @@ -816,7 +830,6 @@ "color": "#000000", "scale": 1.1, "modelScale": 1.1, - "parallax": 1, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -840,6 +853,7 @@ "parentNodeId": "Trig_sub_D", "relation": "in" }, + "parallax": 1, "x": 135, "y": 310, "width": 614.4, @@ -852,7 +866,6 @@ "color": "#00ff00", "scale": 0.6, "modelScale": 0.6, - "parallax": 1, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -871,12 +884,17 @@ "components": [ { "type": "Subtrigger", - "target": "sub_sw_d2" + "target": "Drawer2" } ], "layer": 3, "visible": true, "hidden": false, + "spatial": { + "parentNodeId": "Drawer2", + "relation": "in" + }, + "parallax": 1, "x": 134, "y": 311, "width": 614.4, @@ -889,7 +907,6 @@ "color": "#00ff00", "scale": 0.6, "modelScale": 0.6, - "parallax": 1, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -913,6 +930,7 @@ "parentNodeId": "Drawer1", "relation": "in" }, + "parallax": 1, "x": 136, "y": -4, "width": 614.4, @@ -925,7 +943,6 @@ "color": "#00ff00", "scale": 0.6, "modelScale": 0.6, - "parallax": 1, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -949,6 +966,7 @@ "parentNodeId": "Drawer1", "relation": "in" }, + "parallax": 1, "x": 320, "y": 184, "width": 979, @@ -961,7 +979,6 @@ "color": "#00ff00", "scale": 1, "modelScale": 1, - "parallax": 1, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -990,6 +1007,11 @@ "layer": 6, "visible": true, "hidden": false, + "spatial": { + "parentNodeId": "sub_D_main", + "relation": "in" + }, + "parallax": 1, "x": 135, "y": -176, "width": 614.4, @@ -1002,7 +1024,6 @@ "color": "#00ff00", "scale": 0.6, "modelScale": 0.6, - "parallax": 1, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -1031,6 +1052,7 @@ "parentNodeId": "Drawer1", "relation": "in" }, + "parallax": 1, "x": 135, "y": 66, "width": 614.4, @@ -1043,7 +1065,6 @@ "color": "#00ff00", "scale": 0.6, "modelScale": 0.6, - "parallax": 1, "ignoreScaling": true, "animationSpeed": 150, "opacity": 1, @@ -1067,9 +1088,9 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "x": -146.13725490196006, "y": 289.60784313725503, - "parallax": 1, "ignoreScaling": false, "vertices": [ { @@ -1127,8 +1148,9 @@ "layer": 0, "visible": true, "hidden": false, - "x": 222.9043373174799, - "y": 306.92845155295464, + "parallax": 1.0790382036293824, + "x": 222.90433731747981, + "y": 306.92845155295527, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -1139,7 +1161,6 @@ "color": "#36d87fff", "scale": 0.8, "modelScale": 0.8, - "parallax": 1.0790382036293824, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -1172,6 +1193,7 @@ "layer": 0, "visible": true, "hidden": false, + "parallax": 1, "x": -152, "y": -11, "width": 112, @@ -1184,49 +1206,12 @@ "color": "#AAAAAA", "scale": 0.91, "modelScale": 1, - "parallax": 1, "ignoreScaling": false, "animationSpeed": 150, "opacity": 0, "blendMode": "source-over", "blur": 0 }, - { - "name": "Static_649", - "type": "Entity", - "locked": false, - "disabled": true, - "groupID": null, - "customName": "", - "textRedirects": {}, - "interactions": {}, - "components": [], - "layer": 15, - "visible": true, - "hidden": false, - "spatial": { - "parentNodeId": "Drawer3", - "relation": "in" - }, - "x": 138, - "y": 101, - "width": 492, - "height": 58, - "baseWidth": 540.6593406593406, - "baseHeight": 63.73626373626374, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": null, - "color": "#b83ed0", - "scale": 0.91, - "modelScale": 1, - "parallax": 1, - "ignoreScaling": false, - "animationSpeed": 150, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, { "name": "window1", "type": "Entity", @@ -1249,6 +1234,7 @@ "layer": -2, "visible": true, "hidden": false, + "parallax": 1, "x": 56, "y": -192, "width": 27.3, @@ -1261,7 +1247,6 @@ "color": "#AAAAAA", "scale": 0.91, "modelScale": 1, - "parallax": 1, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -1290,30 +1275,30 @@ "layer": 0, "visible": true, "hidden": false, - "x": 140.53391040317646, - "y": 232.51675433854237, "parallax": 1, + "x": 139.68693090675706, + "y": 254.5786519356021, "ignoreScaling": false, "vertices": [ { - "x": 140.53391040317646, - "y": 232.51675433854237, - "p": 1.0304695546570963 + "x": 139.68693090675706, + "y": 254.5786519356021, + "p": 1.0448448818175478 }, { - "x": 237.8050038950988, - "y": 231.11410685583928, - "p": 1.0295328678986841 + "x": 237.03683022735026, + "y": 253.21082877980723, + "p": 1.0439129162701501 }, { - "x": 184.23616712419644, - "y": 272.9067130971723, - "p": 1.0568050023114235 + "x": 181.07715375429132, + "y": 293.97438989495856, + "p": 1.070637230610974 }, { - "x": 136.21505230962873, - "y": 271.1391630350319, - "p": 1.0556628312582463 + "x": 133.15407933325008, + "y": 292.24977718295406, + "p": 1.0695044581782378 } ], "color": "#000975", @@ -1350,19 +1335,19 @@ "parentNodeId": "Drawer1", "relation": "in" }, + "parallax": 1, "x": 82, "y": -2, - "width": 15.044362633477071, - "height": 9.32343137254902, + "width": 19.55767142352019, + "height": 12.120460784313726, "baseWidth": 165.32266630194582, "baseHeight": 102.45528980823099, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "sub_drawers_d1_id.json", "color": "#AAAAAA", - "scale": 0.09100000000000001, - "modelScale": 0.1, - "parallax": 1, + "scale": 0.1183, + "modelScale": 0.13, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -1383,26 +1368,26 @@ "type": "Item" } ], - "layer": 0, + "layer": 4, "visible": false, "hidden": false, "spatial": { "parentNodeId": "Hero_1", "relation": "in" }, - "x": 339, - "y": 223, - "width": 18.62673942701228, - "height": 26.46957708049113, + "parallax": 1, + "x": 194.30236238320973, + "y": 273.63822717059463, + "width": 15.759890859481585, + "height": 22.395634379263303, "baseWidth": 19.69986357435198, "baseHeight": 27.994542974079128, "colliderWidth": 0, "colliderHeight": 0, "spriteName": null, "color": "#AAAAAA", - "scale": 0.9455263157894737, - "modelScale": 1, - "parallax": 1, + "scale": 0.8, + "modelScale": 0.8, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -1430,19 +1415,19 @@ "parentNodeId": "Walk_main", "relation": "in" }, + "parallax": 1, "x": 555, "y": 214, - "width": 18.416780354706685, - "height": 26.171214188267395, + "width": 14.73342428376535, + "height": 20.936971350613916, "baseWidth": 19.69986357435198, "baseHeight": 27.994542974079128, "colliderWidth": 0, "colliderHeight": 0, "spriteName": null, "color": "#86cece", - "scale": 0.9348684210526316, - "modelScale": 1, - "parallax": 1, + "scale": 0.7478947368421053, + "modelScale": 0.8, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -1478,6 +1463,7 @@ "parentNodeId": "wall", "relation": "on" }, + "parallax": 1, "x": -193, "y": 57, "width": 14.999999999999998, @@ -1490,7 +1476,6 @@ "color": "#d66e29", "scale": 0.91, "modelScale": 1, - "parallax": 1, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -1526,6 +1511,7 @@ "parentNodeId": "wall", "relation": "on" }, + "parallax": 1, "x": -200, "y": 46, "width": 14.999999999999998, @@ -1538,7 +1524,6 @@ "color": "#d0cb39", "scale": 0.91, "modelScale": 1, - "parallax": 1, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -1546,12 +1531,13 @@ "blur": 0 } ], + "folders": [], "camera": { "x": 226.41176470588238, "y": 52.529411764705884, "zoom": 0.51 }, - "autoCenter": false, + "autoCenter": true, "cameraSpeed": 1.5, "camDeadzoneX": 200, "camDeadzoneY": -21, diff --git a/public/text/objects/Drawer1.json b/public/text/objects/Drawer1.json index 31bfa38..0a99f45 100644 --- a/public/text/objects/Drawer1.json +++ b/public/text/objects/Drawer1.json @@ -1,6 +1,6 @@ { "title": "upper drawer", - "description": "", + "description": "It is an upper drawer of the desk. Actually, you can open it.", "details": "", "synonyms": [] } diff --git a/public/text/objects/Drawer2.json b/public/text/objects/Drawer2.json new file mode 100644 index 0000000..38f7d66 --- /dev/null +++ b/public/text/objects/Drawer2.json @@ -0,0 +1,6 @@ +{ + "title": "middle drawer", + "description": "It is a middle drawer of the desk. Actually, you can open it.", + "details": "", + "synonyms": [] +} diff --git a/public/text/objects/Trig_sub_D.json b/public/text/objects/Trig_sub_D.json new file mode 100644 index 0000000..fdc58f5 --- /dev/null +++ b/public/text/objects/Trig_sub_D.json @@ -0,0 +1,6 @@ +{ + "title": "", + "description": "You see nothing special.", + "details": "", + "synonyms": [] +} diff --git a/public/text/objects/miles_id.json b/public/text/objects/miles_id.json index 442b305..627c0f4 100644 --- a/public/text/objects/miles_id.json +++ b/public/text/objects/miles_id.json @@ -1,4 +1,4 @@ { - "title": "your ID card", - "description": "You see nothing special." + "title": "ID card", + "description": "You s nothing special." } diff --git a/src/components/editor/HierarchyPanel.tsx b/src/components/editor/HierarchyPanel.tsx index 0560db2..ec09f17 100644 --- a/src/components/editor/HierarchyPanel.tsx +++ b/src/components/editor/HierarchyPanel.tsx @@ -25,6 +25,15 @@ export const HierarchyPanel: React.FC = () => { startY: 0, }); const dragPending = React.useRef<{ startY: number; item: any } | null>(null); + const pendingListenersRef = React.useRef<(() => void) | null>(null); + + React.useEffect(() => { + return () => { + if (pendingListenersRef.current) { + pendingListenersRef.current(); + } + }; + }, []); // Force re-render on hierarchy version change (subscription) React.useEffect(() => { @@ -347,6 +356,7 @@ export const HierarchyPanel: React.FC = () => { dragPending.current = null; window.removeEventListener('mousemove', armDragIfMoved); window.removeEventListener('mouseup', cancelPending); + pendingListenersRef.current = null; setDragState({ dragging: true, draggedNames: names, @@ -358,7 +368,9 @@ export const HierarchyPanel: React.FC = () => { dragPending.current = null; window.removeEventListener('mousemove', armDragIfMoved); window.removeEventListener('mouseup', cancelPending); + pendingListenersRef.current = null; }; + pendingListenersRef.current = cancelPending; window.addEventListener('mousemove', armDragIfMoved); window.addEventListener('mouseup', cancelPending); }, @@ -390,34 +402,42 @@ export const HierarchyPanel: React.FC = () => { const draggedWalkboxes: any[] = []; const draggedTriggers: any[] = []; - scene.entities = scene.entities.filter((e: any) => { - if (draggedNames.has(e.name)) { - draggedEntities.push(e); - return false; - } - return true; - }); - scene.folders = scene.folders.filter((f: any) => { - if (draggedNames.has(f.name)) { - draggedFolders.push(f); - return false; - } - return true; - }); - scene.walkbox = scene.walkbox.filter((w: any) => { - if (draggedNames.has(w.name)) { - draggedWalkboxes.push(w); - return false; - } - return true; - }); - scene.triggerboxes = scene.triggerboxes.filter((t: any) => { - if (draggedNames.has(t.name)) { - draggedTriggers.push(t); - return false; - } - return true; - }); + scene.entities = Array.isArray(scene.entities) + ? scene.entities.filter((e: any) => { + if (draggedNames.has(e.name)) { + draggedEntities.push(e); + return false; + } + return true; + }) + : []; + scene.folders = Array.isArray(scene.folders) + ? scene.folders.filter((f: any) => { + if (draggedNames.has(f.name)) { + draggedFolders.push(f); + return false; + } + return true; + }) + : []; + scene.walkbox = Array.isArray(scene.walkbox) + ? scene.walkbox.filter((w: any) => { + if (draggedNames.has(w.name)) { + draggedWalkboxes.push(w); + return false; + } + return true; + }) + : []; + scene.triggerboxes = Array.isArray(scene.triggerboxes) + ? scene.triggerboxes.filter((t: any) => { + if (draggedNames.has(t.name)) { + draggedTriggers.push(t); + return false; + } + return true; + }) + : []; let newFolder: string | null = null; if (targetName !== '__end__') { diff --git a/src/components/editor/properties/FolderProperties.tsx b/src/components/editor/properties/FolderProperties.tsx index a289bff..a1ca9bc 100644 --- a/src/components/editor/properties/FolderProperties.tsx +++ b/src/components/editor/properties/FolderProperties.tsx @@ -22,7 +22,12 @@ export const FolderProperties: React.FC = () => { const scene = game?.sceneManager?.currentScene; if (!scene) return; const fid = (folder as any).folderId; - const allObjects = [...scene.entities, ...(scene.walkbox || []), ...(scene.triggerboxes || [])]; + const allObjects = [ + ...scene.entities, + ...(scene.folders || []), + ...(scene.walkbox || []), + ...(scene.triggerboxes || []), + ]; for (const child of allObjects as any[]) { if ((child as any).folder !== fid) continue; if (!((child as any).inheritedProps instanceof Set)) { @@ -78,17 +83,19 @@ export const FolderProperties: React.FC = () => { )} @@ -184,6 +196,7 @@ export const FolderProperties: React.FC = () => { {hasDefault('parallax') && ( )} @@ -248,6 +265,7 @@ export const FolderProperties: React.FC = () => { {hasDefault('blendMode') && ( + {comp.type === 'Actor' && ( +
+ Enables Actor movement, direction, player mode, and animation sets. +
+ )} + {comp.type === 'Backface' && ( <>
{ )}
- ))} + ); + })} ); }; diff --git a/src/entities/Actor.ts b/src/entities/Actor.ts index f4dde7d..15f598e 100644 --- a/src/entities/Actor.ts +++ b/src/entities/Actor.ts @@ -364,6 +364,10 @@ export class Actor extends Entity { toJSON() { const data = super.toJSON(); data.type = 'Actor'; + const components = Array.isArray(data.components) ? data.components : []; + if (!components.some((component: any) => component?.type === 'Actor')) { + data.components = [{ type: 'Actor' }, ...components]; + } return data; } diff --git a/src/entities/EntityPrefabs.ts b/src/entities/EntityPrefabs.ts index e9be46b..87804f6 100644 --- a/src/entities/EntityPrefabs.ts +++ b/src/entities/EntityPrefabs.ts @@ -27,6 +27,7 @@ export const DefaultActorData: ActorData = { speed: 0.1, animSets: {}, isPlayer: false, + components: [{ type: 'Actor' }], }; export const DefaultQuadData: any = { diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index f6e84f9..023de28 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -535,10 +535,14 @@ export class SceneManager { let entity: Entity; + const hasActorComponent = Array.isArray(entityData.components) + ? entityData.components.some((component: any) => component?.type === 'Actor') + : false; + if (entityData.type === 'Player') { entity = Actor.fromJSON(this.game, { ...entityData, type: 'Actor', isPlayer: true }); - } else if (entityData.type === 'Actor') { - entity = Actor.fromJSON(this.game, entityData); + } else if (entityData.type === 'Actor' || hasActorComponent) { + entity = Actor.fromJSON(this.game, { ...entityData, type: 'Actor' }); } else if (entityData.type === 'Quad' || entityData.type === 'Rect') { entity = QuadObject.fromJSON(this.game, entityData); } else { diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index a84491c..99b06b9 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -60,6 +60,10 @@ export interface ItemComponent { ignoreDistance?: boolean; } +export interface ActorComponent { + type: 'Actor'; +} + export interface InventoryComponent { type: 'Inventory'; relation?: Exclude; @@ -99,6 +103,7 @@ export type AnyComponent = ( | ExitComponent | EntryComponent | ItemComponent + | ActorComponent | InventoryComponent | SurfaceComponent | WalkBoxComponent diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index dec188c..735cd0e 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -595,6 +595,86 @@ export class SceneEditor { this.selectionManager.selectObject(obj); } + convertEntityToActor(entity: Entity): Actor | null { + if (!entity || entity instanceof Actor || entity.type === 'Quad') return null; + const scene = this.game.sceneManager.currentScene; + if (!scene) return null; + + const index = scene.entities.indexOf(entity); + if (index < 0) return null; + + this.saveUndoState(); + + const inventoryOwner = entity.getInventoryPositionOwner?.() || null; + const data = entity.toJSON(); + const components = Array.isArray(data.components) ? data.components : []; + const actor = Actor.fromJSON(this.game, { + ...data, + type: 'Actor', + direction: data.direction || 'down', + speed: typeof data.speed === 'number' ? data.speed : 0.1, + animSets: data.animSets || {}, + isPlayer: !!data.isPlayer, + components: components.some((component: any) => component?.type === 'Actor') + ? components + : [{ type: 'Actor' }, ...components], + }); + + actor.scene = scene; + actor.setInventoryPositionOwner?.(inventoryOwner); + scene.entities[index] = actor; + if (actor.isPlayer) scene.player = actor; + if (scene.subsceneEntities.has(entity)) { + scene.subsceneEntities.delete(entity); + scene.subsceneEntities.add(actor); + } + + this.game.sceneManager.exposeEntitiesToWindow(); + this.selectObject(actor); + useEditorStore.getState().incrementObjectVersion(); + this.refreshHierarchy(); + return actor; + } + + convertActorToEntity(actor: Actor): Entity | null { + if (!actor || !(actor instanceof Actor)) return null; + const scene = this.game.sceneManager.currentScene; + if (!scene) return null; + + const index = scene.entities.indexOf(actor); + if (index < 0) return null; + + this.saveUndoState(); + + const inventoryOwner = actor.getInventoryPositionOwner?.() || null; + const data = actor.toJSON(); + const components = Array.isArray(data.components) + ? data.components.filter( + (component: any) => component?.type !== 'Actor' && component?.type !== 'Shadow' + ) + : []; + const entity = Entity.fromJSON(this.game, { + ...data, + type: 'Entity', + components, + }); + + entity.scene = scene; + entity.setInventoryPositionOwner?.(inventoryOwner); + scene.entities[index] = entity; + if (scene.player === actor) scene.player = null; + if (scene.subsceneEntities.has(actor)) { + scene.subsceneEntities.delete(actor); + scene.subsceneEntities.add(entity); + } + + this.game.sceneManager.exposeEntitiesToWindow(); + this.selectObject(entity); + useEditorStore.getState().incrementObjectVersion(); + this.refreshHierarchy(); + return entity; + } + toggleObjectSelection(obj: SceneObject): void { this.selectionManager.toggleObjectSelection(obj); } @@ -835,7 +915,12 @@ export class SceneEditor { }); } } - } else if (type === 'Actor') { + } else if ( + type === 'Actor' || + (Array.isArray(data.components) && + data.components.some((component: any) => component?.type === 'Actor')) + ) { + data.type = 'Actor'; newObj = Actor.fromJSON(this.game, data); } else if (type === 'Player') { newObj = Actor.fromJSON(this.game, { ...data, type: 'Actor', isPlayer: true }); diff --git a/tests/editor/scene-editor-object-creation.test.ts b/tests/editor/scene-editor-object-creation.test.ts index f932e86..d28a0b9 100644 --- a/tests/editor/scene-editor-object-creation.test.ts +++ b/tests/editor/scene-editor-object-creation.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { Actor } from '../../src/entities/Actor'; +import { Entity } from '../../src/entities/Entity'; import { SceneEditor } from '../../src/tools/SceneEditor'; import { createSceneFixture } from '../fixtures/sceneFactory'; @@ -6,6 +8,11 @@ function createHeadlessEditor(fixture: ReturnType): S (fixture.game.sceneManager as any).exposeEntitiesToWindow = () => {}; return { game: fixture.game, + saveUndoState: () => {}, + selectObject(obj: any) { + (this as any).selectedObject = obj; + }, + refreshHierarchy: () => {}, } as SceneEditor; } @@ -53,4 +60,59 @@ describe('SceneEditor object creation', () => { ]); expect(restoredTriggerbox.hidden).toBe('lookable'); }); + + it('converts an Entity to an Actor through the Actor component marker', () => { + const fixture = createSceneFixture(); + const editor = createHeadlessEditor(fixture); + const entity = new Entity(fixture.game as any, 12, 34, 56, 78, 'Lamp'); + entity.color = '#123456'; + entity.components = [{ type: 'Item' }]; + entity.interactions = { LOOK: 'lamp.look' }; + fixture.scene.addEntity(entity); + + const actor = SceneEditor.prototype.convertEntityToActor.call(editor, entity); + + expect(actor).toBeInstanceOf(Actor); + expect(fixture.scene.entities[0]).toBe(actor); + expect(actor?.name).toBe('Lamp'); + expect(actor?.x).toBe(12); + expect(actor?.y).toBe(34); + expect(actor?.color).toBe('#123456'); + expect(actor?.interactions).toEqual({ LOOK: 'lamp.look' }); + expect(actor?.components).toEqual([{ type: 'Actor' }, { type: 'Item' }]); + expect(actor?.direction).toBe('down'); + expect(actor?.speed).toBe(0.1); + }); + + it('converts an Actor back to an Entity and drops Actor-only data', () => { + const fixture = createSceneFixture(); + const editor = createHeadlessEditor(fixture); + const actor = new Actor(fixture.game as any, 10, 20, 30, 40, 'Npc'); + actor.direction = 'left'; + actor.speed = 0.25; + actor.animSets = { + idle: { id: 'idle', up: null, down: 'npc_down', left: null, right: null }, + }; + actor.components = [ + { type: 'Actor' }, + { type: 'Shadow', shadowQuadId: 'shadow', offsetX: 1, offsetY: 2, triggerId: '' }, + { type: 'Item' }, + ]; + actor.isPlayer = true; + fixture.scene.addEntity(actor); + + const entity = SceneEditor.prototype.convertActorToEntity.call(editor, actor); + + expect(entity).toBeInstanceOf(Entity); + expect(entity).not.toBeInstanceOf(Actor); + expect(fixture.scene.entities[0]).toBe(entity); + expect(fixture.scene.player).toBeNull(); + expect(entity?.components).toEqual([{ type: 'Item' }]); + const serialized = entity?.toJSON(); + expect(serialized.type).toBe('Entity'); + expect(serialized.direction).toBeUndefined(); + expect(serialized.speed).toBeUndefined(); + expect(serialized.animSets).toBeUndefined(); + expect(serialized.isPlayer).toBeUndefined(); + }); }); From 91147552db9714d5eba65755862f111752583cb0 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Mon, 27 Apr 2026 01:33:47 +0200 Subject: [PATCH 11/50] Refactoring & Fixes --- Sessions.md | 105 ++++++++++++++++++ public/scenes/home/room.json | 4 +- public/scenes/home/room_backup.json | 4 +- public/scenes/test_room (10).json | 2 +- public/scenes/test_room.json | 2 +- public/scenes/test_room1.json | 16 +-- public/text/objects/Sofa.json | 6 + .../properties/MultiSelectionProperties.tsx | 12 +- .../editor/properties/PropertiesPanel.tsx | 4 + .../editor/properties/SectionComponents.tsx | 4 +- .../editor/properties/SectionIdentity.tsx | 21 ++-- src/entities/SceneObject.ts | 9 ++ src/systems/GameSemanticAPI.ts | 5 +- src/systems/InventoryManager.ts | 16 +++ src/tools/editor/EditorPersistenceManager.ts | 4 +- src/utils/GroupIds.ts | 15 +++ tests/editor/group-id-normalization.test.ts | 23 ++++ tests/fixtures/gameFactory.ts | 2 +- tests/game/semantic-api.test.ts | 21 ++++ 19 files changed, 232 insertions(+), 43 deletions(-) create mode 100644 public/text/objects/Sofa.json create mode 100644 src/utils/GroupIds.ts create mode 100644 tests/editor/group-id-normalization.test.ts diff --git a/Sessions.md b/Sessions.md index c8aac8c..19425d3 100644 --- a/Sessions.md +++ b/Sessions.md @@ -752,3 +752,108 @@ During the session the following checks were run successfully: - The local `gemini-cli-agent` skill lives outside the repo under the Codex skills directory; the repo only records the usage rule in `AGENTS.md`. - `npm install` was run to restore missing local `.bin` scripts after a temporary worktree/junction test setup disrupted the local dependency executable links. `package.json` and lockfiles remained unchanged. - NotebookLM source replacement still depends on CLI auth and may require the standard readiness flow if auth has expired. + +## Session Entry - 2026-04-27 01:27 +02:00 + +### Session Goals + +- Continue work on Scanline Engine after verifying NotebookLM access. +- Evaluate and implement the idea that Static/Entity objects can become Actors by adding an Actor component, and Actors can become Static again by removing it. +- Add a confirmation popup for removing the Actor component because that operation discards Actor-only data. +- Commit the completed improvement and verify follow-up review findings against the actual current code. + +### What Was Implemented + +- Verified NotebookLM CLI authorization using the project readiness flow: + - `python -m notebooklm auth check --json` succeeded. + - `python -m notebooklm list --json` succeeded and showed the `Scanline Engine` notebook. + - Targeted notebook smoke test succeeded for notebook `9f146be7-7c4a-4bb0-b7b4-7f20079e85b0`. +- Implemented Actor component conversion: + - Static/Entity objects can add an `Actor` component from the Components section. + - Adding the component replaces the scene object with an `Actor` instance at the same `scene.entities` index. + - Actor objects display an `Actor` marker component in the Components section. + - Removing the `Actor` component replaces the object with a normal `Entity`. + - Actor serialization now emits `{ type: 'Actor' }` in `components` for editor consistency. + - Static/Entity JSON that contains an Actor component marker now loads as an Actor, preserving compatibility with the new authoring model. +- Added a destructive confirmation dialog when removing the Actor component: + - Title: `Remove Actor Component`. + - Buttons: `Cancel` and `Proceed`. + - Proceed warns that the object becomes Static and loses Actor settings, including direction, player mode, move speed, visual states, animation sets, and Actor-only components. +- Added tests for: + - Entity -> Actor conversion preserving common properties and adding the Actor marker. + - Actor -> Entity conversion dropping Actor-only serialized data and removing Actor/Shadow components. + +### Important Architecture / Runtime Decisions + +- Actor component is currently a UI/editor conversion handle, not a full component-first runtime rewrite. +- Runtime continues to use the existing class split where `Actor extends Entity`, and systems that rely on `instanceof Actor`, `entity.type === 'Actor'`, Actor movement methods, player state, direction, and animation sets remain valid. +- Conversion helpers live on `SceneEditor` and perform the undo snapshot internally before mutating the scene: + - `convertEntityToActor()` + - `convertActorToEntity()` +- Review findings asking for additional `saveUndoState()` calls in `SectionComponents.tsx` were checked against current code and intentionally not applied: + - Both conversion helpers already call `this.saveUndoState()` before mutation. + - Adding UI-level undo snapshots would create duplicate undo entries for one conversion action. +- Removing Actor strips Actor-only state and also removes `Shadow`, which remains Actor-only for this slice. + +### Parser / Mechanics / Scene / Inventory Changes + +- No parser, command-resolution, inventory, subscene, or semantic API behavior was intentionally changed. +- Scene loading changed only insofar as Entity/Static JSON carrying the Actor marker is instantiated as `Actor`. +- Existing runtime Actor behavior is preserved rather than moved into a component system. + +### Tests Run and Outcomes + +- `npm run typecheck` + - Passed. +- `npm test` + - Passed. + - 21 test files passed. + - 244 tests passed. +- `npm run build` + - Passed during implementation. + - Vite emitted only existing-style warnings about chunk size and dynamic/static imports of `fileApi`. +- During wrap-up, `npm run typecheck` and `npm test` were re-run and passed again. + +### Commits Created + +- `ec18c3e2f9dc8102ea2f5483caad926328411a2c` - `Add Actor component conversion` + +### Current State + +- Branch: `scene-refact3`. +- Last commit: `ec18c3e Add Actor component conversion`. +- After the commit, additional uncommitted changes are present in the worktree. They were not made as part of the committed Actor conversion wrap-up and were intentionally left untouched: + - `public/scenes/home/room.json` + - `public/scenes/home/room_backup.json` + - `public/scenes/test_room (10).json` + - `public/scenes/test_room.json` + - `public/scenes/test_room1.json` + - `src/components/editor/properties/MultiSelectionProperties.tsx` + - `src/components/editor/properties/PropertiesPanel.tsx` + - `src/components/editor/properties/SectionComponents.tsx` + - `src/components/editor/properties/SectionIdentity.tsx` + - `src/entities/SceneObject.ts` + - `src/systems/GameSemanticAPI.ts` + - `public/text/objects/Sofa.json` + - `src/utils/GroupIds.ts` + - `tests/editor/group-id-normalization.test.ts` +- This wrap-up appends a new entry to `Sessions.md`, which is expected to remain as an additional documentation change unless separately committed. + +### Remaining Work / Next Recommended Steps + +- Manually QA the editor flow in the running app: + - create Static; + - add Actor component; + - verify Actor Properties appear; + - undo/redo the conversion; + - remove Actor component; + - verify Cancel does nothing and Proceed converts to Static; + - verify Actor-only fields and Shadow are removed after Proceed. +- Decide whether `GDD.md` should be updated to describe Actor as an editor-visible component marker while preserving the current runtime class split. +- Review the unrelated dirty files before any future commit so the Actor conversion commit remains isolated from group-id or scene-content work. + +### Risks / Caveats / Open Questions + +- Actor component is not yet a pure runtime component architecture. It is intentionally an authoring/conversion affordance over existing classes. +- If future work moves Actor behavior into a true component system, the current conversion helpers should become a migration bridge rather than the final architecture. +- The confirmation dialog prevents accidental loss, but once the user chooses Proceed, Actor-only settings are removed from the object data by design. diff --git a/public/scenes/home/room.json b/public/scenes/home/room.json index 086ae22..bf2aeb4 100644 --- a/public/scenes/home/room.json +++ b/public/scenes/home/room.json @@ -162,7 +162,7 @@ "type": "Triggerbox", "locked": false, "disabled": true, - "groupID": "#D ", + "groupID": "#D", "customName": "", "textRedirects": {}, "interactions": {}, @@ -219,7 +219,7 @@ "type": "Triggerbox", "locked": false, "disabled": true, - "groupID": "#D ", + "groupID": "#D", "customName": "", "textRedirects": {}, "interactions": {}, diff --git a/public/scenes/home/room_backup.json b/public/scenes/home/room_backup.json index a37a3dc..cd6e59f 100644 --- a/public/scenes/home/room_backup.json +++ b/public/scenes/home/room_backup.json @@ -162,7 +162,7 @@ "type": "Triggerbox", "locked": false, "disabled": true, - "groupID": "#D ", + "groupID": "#D", "customName": "", "textRedirects": {}, "interactions": {}, @@ -219,7 +219,7 @@ "type": "Triggerbox", "locked": false, "disabled": true, - "groupID": "#D ", + "groupID": "#D", "customName": "", "textRedirects": {}, "interactions": {}, diff --git a/public/scenes/test_room (10).json b/public/scenes/test_room (10).json index 3b38524..6adabb9 100644 --- a/public/scenes/test_room (10).json +++ b/public/scenes/test_room (10).json @@ -250,7 +250,7 @@ "type": "Triggerbox", "locked": false, "disabled": true, - "groupID": "#D ", + "groupID": "#D", "customName": "", "textRedirects": {}, "interactions": {}, diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index 12a6e3c..12a79d6 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -252,7 +252,7 @@ "type": "Triggerbox", "locked": false, "disabled": true, - "groupID": "#D ", + "groupID": "#D", "customName": "", "textRedirects": {}, "interactions": {}, diff --git a/public/scenes/test_room1.json b/public/scenes/test_room1.json index 4530210..b4c5d52 100644 --- a/public/scenes/test_room1.json +++ b/public/scenes/test_room1.json @@ -158,7 +158,7 @@ "locked": false, "disabled": true, "layer": 0, - "groupID": "#D ", + "groupID": "#D", "customName": "", "interactions": {}, "components": [ @@ -213,7 +213,7 @@ "locked": false, "disabled": true, "layer": 0, - "groupID": "#D ", + "groupID": "#D", "customName": "", "interactions": {}, "components": [ @@ -454,7 +454,7 @@ "locked": true, "disabled": true, "customName": "", - "groupID": "#D ", + "groupID": "#D", "components": [], "interactions": {} }, @@ -480,7 +480,7 @@ "locked": false, "disabled": true, "customName": "", - "groupID": "#D2 ", + "groupID": "#D2", "components": [], "interactions": {} }, @@ -506,7 +506,7 @@ "locked": false, "disabled": true, "customName": "", - "groupID": "#D1 ", + "groupID": "#D1", "components": [], "interactions": {} }, @@ -532,7 +532,7 @@ "locked": false, "disabled": true, "customName": "id", - "groupID": "#D1 ", + "groupID": "#D1", "components": [ { "type": "Item", @@ -563,7 +563,7 @@ "locked": false, "disabled": true, "customName": "", - "groupID": "#D1 ", + "groupID": "#D1", "components": [], "interactions": {} }, @@ -589,7 +589,7 @@ "locked": true, "disabled": true, "customName": "", - "groupID": "#D ", + "groupID": "#D", "components": [], "interactions": {} }, diff --git a/public/text/objects/Sofa.json b/public/text/objects/Sofa.json new file mode 100644 index 0000000..b8c7891 --- /dev/null +++ b/public/text/objects/Sofa.json @@ -0,0 +1,6 @@ +{ + "title": "Sofa", + "description": "An old, but comfortable sofa.", + "details": "Perfect for a nap, but maybe not in this situation.", + "synonyms": [] +} diff --git a/src/components/editor/properties/MultiSelectionProperties.tsx b/src/components/editor/properties/MultiSelectionProperties.tsx index fc2f57a..b7a84c8 100644 --- a/src/components/editor/properties/MultiSelectionProperties.tsx +++ b/src/components/editor/properties/MultiSelectionProperties.tsx @@ -4,6 +4,7 @@ import { Select } from '../../common/Select'; import { Entity } from '../../../entities/Entity'; import { Triggerbox } from '../../../entities/Triggerbox'; import { useEditorStore } from '../../../store/editorStore'; +import { normalizeGroupIdList } from '../../../utils/GroupIds'; import { getSharedValue, @@ -132,18 +133,11 @@ export const MultiSelectionProperties: React.FC = e.preventDefault(); const raw = groupIdDraft.trim(); if (!raw) return; - const prepared = raw - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - .map((x) => (x.startsWith('#') ? x : `#${x}`)); + const prepared = normalizeGroupIdList(raw).split(',').filter(Boolean); let changedCount = 0; multiObjects.forEach((o: any) => { - const existing = (o.groupID || '') - .split(',') - .map((x: string) => x.trim()) - .filter(Boolean); + const existing = normalizeGroupIdList(o.groupID).split(',').filter(Boolean); if (e.ctrlKey) { const filtered = existing.filter((x: string) => !prepared.includes(x)); diff --git a/src/components/editor/properties/PropertiesPanel.tsx b/src/components/editor/properties/PropertiesPanel.tsx index 8356420..85574ed 100644 --- a/src/components/editor/properties/PropertiesPanel.tsx +++ b/src/components/editor/properties/PropertiesPanel.tsx @@ -17,6 +17,7 @@ import { scalePolyByFactor, scaleQuadVerticesByFactor, } from './propertiesUtils'; +import { normalizeGroupIdList } from '../../../utils/GroupIds'; import { MultiSelectionProperties } from './MultiSelectionProperties'; import { SectionIdentity } from './SectionIdentity'; @@ -542,6 +543,9 @@ export const PropertiesPanel: React.FC = () => { finalVal = parseFloat(String(value)); if (isNaN(finalVal)) finalVal = 0; } + if (field === 'groupID') { + finalVal = normalizeGroupIdList(finalVal, { preserveEmptyTokens: true }); + } obj[field] = finalVal; diff --git a/src/components/editor/properties/SectionComponents.tsx b/src/components/editor/properties/SectionComponents.tsx index dfde7bf..2f77466 100644 --- a/src/components/editor/properties/SectionComponents.tsx +++ b/src/components/editor/properties/SectionComponents.tsx @@ -893,9 +893,9 @@ export const SectionComponents: React.FC = () => { type="text" className="e-input" style={{ width: '100%' }} - value={comp.idKey || ''} + value={comp.keyId || ''} onChange={(e) => { - comp.idKey = e.target.value; + comp.keyId = e.target.value; incrementObjectVersion(); }} /> diff --git a/src/components/editor/properties/SectionIdentity.tsx b/src/components/editor/properties/SectionIdentity.tsx index 83d666c..aad2c2e 100644 --- a/src/components/editor/properties/SectionIdentity.tsx +++ b/src/components/editor/properties/SectionIdentity.tsx @@ -4,6 +4,7 @@ import { Select } from '../../common/Select'; import { Entity } from '../../../entities/Entity'; import { SceneObject } from '../../../entities/SceneObject'; import { Triggerbox } from '../../../entities/Triggerbox'; +import { normalizeGroupIdList } from '../../../utils/GroupIds'; interface SectionIdentityData { id?: string; @@ -153,19 +154,13 @@ export const SectionIdentity: React.FC = ({ className="e-input" value={o.groupID || ''} onChange={(e) => { - const val = e.target.value; - const tokens = val.split(','); - const newTokens = tokens.map((t) => { - if (t.length === 0) return ''; - let clean = t; - const trimmed = t.trimStart(); - if (trimmed.length > 0 && !trimmed.startsWith('#')) { - const firstCharIdx = t.length - trimmed.length; - clean = t.substring(0, firstCharIdx) + '#' + trimmed; - } - return clean; - }); - handleChange('groupID', newTokens.join(',')); + handleChange( + 'groupID', + normalizeGroupIdList(e.target.value, { preserveEmptyTokens: true }) + ); + }} + onBlur={(e) => { + handleChange('groupID', normalizeGroupIdList(e.target.value)); }} /> diff --git a/src/entities/SceneObject.ts b/src/entities/SceneObject.ts index b2ad4a3..7f1c2b7 100644 --- a/src/entities/SceneObject.ts +++ b/src/entities/SceneObject.ts @@ -1,4 +1,5 @@ import type { AnyComponent } from '../systems/ComponentSystem'; +import { normalizeGroupIdList } from '../utils/GroupIds'; export class SceneObject { name: string; @@ -103,6 +104,10 @@ export class SceneObject { } return; } + if (prop === 'groupID' && typeof value === 'string') { + json[prop] = normalizeGroupIdList(value); + return; + } if ( prop === 'spatial' && value && @@ -140,6 +145,10 @@ export class SceneObject { } return; } + if (prop === 'groupID' && typeof value === 'string') { + (this as any)[prop] = normalizeGroupIdList(value); + return; + } // Deep clone objects and arrays if (typeof value === 'object' && value !== null) { (this as any)[prop] = JSON.parse(JSON.stringify(value)); diff --git a/src/systems/GameSemanticAPI.ts b/src/systems/GameSemanticAPI.ts index 91922d3..f501169 100644 --- a/src/systems/GameSemanticAPI.ts +++ b/src/systems/GameSemanticAPI.ts @@ -1278,12 +1278,13 @@ export class GameSemanticAPI { }; } - if (!this.game.inventoryManager.hasMainInventory(scene.player)) { + const player = scene.player instanceof Entity ? scene.player : null; + if (!this.game.inventoryManager.hasMainInventory(player)) { return { status: 'failed', code: 'player_inventory_missing', message: this.game.text('parser.inventory_missing'), - data: { entityId: entity.name, ownerId: scene.player?.name }, + data: { entityId: entity.name, ownerId: player?.name }, recoverable: false, }; } diff --git a/src/systems/InventoryManager.ts b/src/systems/InventoryManager.ts index 41f11cd..4c650f9 100644 --- a/src/systems/InventoryManager.ts +++ b/src/systems/InventoryManager.ts @@ -137,8 +137,24 @@ export class InventoryManager { return this.inventory.includes(entity); } + /** + * Checks whether this exact entity, or a serialized duplicate of it, is already held. + * Prefer a stable explicit `id` when present; older scene data uses `name` as the + * item identity, so name matching remains the fallback for legacy entities. + */ hasEntityIdInInventory(entity: Entity): boolean { if (this.isEntityInInventory(entity)) return true; + const entityId = + typeof (entity as unknown as { id?: unknown })?.id === 'string' + ? (entity as unknown as { id: string }).id.trim() + : ''; + if (entityId) { + return this.inventory.some( + (held) => + typeof (held as unknown as { id?: unknown })?.id === 'string' && + (held as unknown as { id: string }).id.trim() === entityId + ); + } const entityName = String(entity?.name || '').trim(); if (!entityName) return false; return this.inventory.some((held) => String(held?.name || '').trim() === entityName); diff --git a/src/tools/editor/EditorPersistenceManager.ts b/src/tools/editor/EditorPersistenceManager.ts index 411338d..3d4886e 100644 --- a/src/tools/editor/EditorPersistenceManager.ts +++ b/src/tools/editor/EditorPersistenceManager.ts @@ -263,10 +263,10 @@ export class EditorPersistenceManager { try { await saveProjectFile(filePath, json); - this.editor.game.showNotification(`Prefab Saved: ${filename} `); + this.editor.game.showNotification(`Prefab Saved: ${filename}`); } catch (e) { console.error('Failed to save prefab:', e); - this.editor.game.showNotification(`Error: ${e} `); + this.editor.game.showNotification(`Error: ${e}`); } } diff --git a/src/utils/GroupIds.ts b/src/utils/GroupIds.ts new file mode 100644 index 0000000..0c8ffe1 --- /dev/null +++ b/src/utils/GroupIds.ts @@ -0,0 +1,15 @@ +export function normalizeGroupIdList( + value: unknown, + options: { preserveEmptyTokens?: boolean } = {} +): string { + const raw = String(value ?? ''); + const tokens = raw + .split(',') + .map((token) => token.trim()) + .map((token) => { + if (!token) return ''; + return token.startsWith('#') ? token : `#${token}`; + }); + + return (options.preserveEmptyTokens ? tokens : tokens.filter(Boolean)).join(','); +} diff --git a/tests/editor/group-id-normalization.test.ts b/tests/editor/group-id-normalization.test.ts new file mode 100644 index 0000000..4c10e42 --- /dev/null +++ b/tests/editor/group-id-normalization.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { SceneObject } from '../../src/entities/SceneObject'; +import { normalizeGroupIdList } from '../../src/utils/GroupIds'; + +describe('group ID normalization', () => { + it('trims group ids and adds missing # prefixes', () => { + expect(normalizeGroupIdList(' D , #D1 ,#D2 ')).toBe('#D,#D1,#D2'); + }); + + it('can preserve empty tokens while the editor input is mid-edit', () => { + expect(normalizeGroupIdList('D, ', { preserveEmptyTokens: true })).toBe('#D,'); + }); + + it('normalizes SceneObject groupID values on load and serialization', () => { + const obj = new SceneObject('Drawer2', 'Triggerbox'); + + obj.load({ groupID: '#D ' }); + expect(obj.groupID).toBe('#D'); + + obj.groupID = ' D1 , #D2 '; + expect(obj.toJSON().groupID).toBe('#D1,#D2'); + }); +}); diff --git a/tests/fixtures/gameFactory.ts b/tests/fixtures/gameFactory.ts index 8603d68..546b8dd 100644 --- a/tests/fixtures/gameFactory.ts +++ b/tests/fixtures/gameFactory.ts @@ -274,7 +274,7 @@ export function createTestGame(): TestGameHarness { }; } - if (game.inventoryManager.hasEntityIdInInventory(entity)) { + if (game.inventoryManager.isEntityInInventory(entity)) { return { status: 'failed', code: 'item_already_held', diff --git a/tests/game/semantic-api.test.ts b/tests/game/semantic-api.test.ts index 9eac690..b583a8d 100644 --- a/tests/game/semantic-api.test.ts +++ b/tests/game/semantic-api.test.ts @@ -273,6 +273,27 @@ describe('Game semantic API', () => { expect(fixture.game.inventory).toEqual([heldCassette]); }); + it('does not treat same-name entities with different stable ids as the same held item', () => { + const fixture = createGameSemanticFixture(); + fixture.addPlayer('Hero', 0, 0); + const heldCassette = fixture.addEntity('cassette', { + title: 'Compact cassette', + description: 'A held cassette.', + components: [{ type: 'Item' }], + }); + (heldCassette as any).id = 'cassette-held'; + fixture.scene.removeEntity(heldCassette); + fixture.game.inventory.push(heldCassette); + const sceneCassette = fixture.addEntity('cassette', { + title: 'Compact cassette', + description: 'A different cassette.', + components: [{ type: 'Item' }], + }); + (sceneCassette as any).id = 'cassette-scene'; + + expect(fixture.game.inventoryManager.hasEntityIdInInventory(sceneCassette)).toBe(false); + }); + it('does not treat a scene duplicate as the held item for DROP', () => { const fixture = createGameSemanticFixture(); fixture.addPlayer('Hero', 0, 0); From dfe2f70147e865a53523de79a80b87eaea17fd4c Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Mon, 27 Apr 2026 21:22:11 +0200 Subject: [PATCH 12/50] documentation update --- AGENTS.md | 12 ++++++++++++ GDD.md | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9a59094..30ea7a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,18 @@ Use the project knowledge sources in this order, depending on the question: 3. `local_rag` is the local fallback/sidecar for fuzzy recall: semantic search, related-document discovery, and cases where the exact memory title, file name, or subsystem name is unknown. 4. The repository itself is the source of truth for current code. Use `rg`, file reads, and tests to verify behavior before editing. +## Kairo TaskOps + +Use Kairo as the shared task/action layer when MCP or CLI access is available. Kairo is for actionable work with an owner, status, and next step; `agent_memory` remains the durable knowledge layer. + +- Create or update Kairo tasks for work that continues beyond the current response, needs user acceptance/manual action, is delegated to another agent, or comes from review/test follow-up. +- Do not create Kairo tasks for trivial internal steps, raw notes, architecture facts, or temporary debugging thoughts. +- Use `proj:quest` for this repository. Prefer tags: `owner:`, `type:`, `area:`, `source:`, `status-meta:`, and `session:`. +- Priority convention: `0` blocker/urgent user action/regression risk, `1` important current-session work, `2` normal follow-up, `3` low-priority cleanup or someday. +- Title convention: start with `[Quest]`, use an action phrase, and mention the owner only when delegated or user-facing. +- Description convention: include owner, context, expected outcome, acceptance criteria, relevant files/links, and source when useful. +- Lifecycle: set active/delegated work to `doing`, completed work to `done` after validation or required acceptance, and store durable conclusions from completed tasks in `agent_memory`. + ## Gemini CLI Worker Rule When Gemini CLI is installed, use it as an external helper for technical tasks wherever this is practical and safe. This is intended to increase throughput and reduce Codex token use. diff --git a/GDD.md b/GDD.md index 756ff39..c87ab3f 100644 --- a/GDD.md +++ b/GDD.md @@ -249,6 +249,8 @@ Parser обрабатывает пользовательский ввод кас - _Actor_: объект, который помимо свойств Static имеет направление, в котором он повёрнут и, опционально, спрайты/анимации состояний (idle, walk, talk, etc), причём для каждого направления свой набор. Обычно Actor это NPC и анимированные объекты. Персонаж игрока также является разновидностью Actor. + В редакторе Actor может быть создан напрямую через меню/горячую клавишу, либо получен из обычного Static/Entity добавлением компонента `Actor` в панели Components. В текущей архитектуре этот компонент является authoring-маркером: при добавлении редактор превращает объект в Actor, а при удалении превращает его обратно в Static. + - _Quad_ : четырёхугольный объект, каждая вершина которого обладает отдельным параллаксом. Используется для создания псевдо 3d поверхностей и эффектов типа лучей света и теней. ### ID @@ -450,6 +452,19 @@ _Sort Mode_ (v0, v1, v2, v3, ignore) Кроме простых свойств, которые есть всегда, объекты сцены способны содержать _компоненты_ (структуры данных), которые могут быть добавлены и удалены в редакторе. Каждый объект может иметь один или несколько компонентов разных типов. Но не любой объект может содержать любой компонент. +#### Компонент Actor + +Есть у Actor и может быть добавлен к Static/Entity. + +`Actor` -- особый компонент-маркер редактора. В отличие от обычных компонентов, он меняет тип объекта: + +- если добавить `Actor` к Static/Entity, объект становится Actor и получает Actor-only свойства: направление, скорость движения, режим Player, визуальные состояния и группы анимаций; +- если удалить `Actor` у Actor, объект становится Static/Entity и теряет Actor-only свойства, визуальные состояния, группы анимаций и Actor-only компоненты вроде `Shadow`; +- перед удалением компонента `Actor` редактор показывает подтверждение с кнопками `Cancel` и `Proceed`, поскольку операция необратимо очищает Actor-настройки объекта; +- Static и Actor по-прежнему можно создавать напрямую из меню или горячими клавишами. Разница в том, что созданный напрямую Actor уже имеет компонент-маркер `Actor`. + +Важно: на уровне текущей runtime-архитектуры Actor всё ещё является расширением Entity (`Actor extends Entity`). Компонент `Actor` описывает поведение редактора и сериализации, а не означает, что вся Actor-логика уже перенесена в чистую компонентную систему. + #### Компоненты групп анимаций Есть только у Actor. @@ -1105,6 +1120,8 @@ Prefab можно загрузить в текущую сцену из файл Также есть кнопка "Add Anim. Set" для добавления новой (пустой) группы анимаций. Если у Actor вообще нет групп анимаций, первая добавленная автоматически получает id 'idle' а вторая 'walk' (но это пользователь потом может отредактировать) +В разделе Components у Static/Entity доступен компонент `Actor`. Добавление этого компонента превращает выбранный объект в Actor и открывает Actor-свойства. У Actor этот компонент отображается как маркер типа. Удаление компонента `Actor` вызывает поп-ап подтверждения; при выборе `Proceed` объект становится Static и теряет Actor-only настройки, включая визуальные состояния, группы анимаций, направление, скорость движения, режим Player и Actor-only компоненты. + Если текущмй спрайт не задан, то Actor будет выглядеть как Static без спрайта, то есть как прямоугольник с заливкой цветом. Для Actor и Static можно задать размер их прямоугольника (ширина и высота) а также масштаб (умножитель размера). Например, можно уменьшать масштаб предмета, чтобы использовать для него спрайты более высокого разрешения без увеличения размера на экране. From 11d990dc42676f3f6cbd361080fe15c2042e282d Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Tue, 5 May 2026 21:28:00 +0200 Subject: [PATCH 13/50] Add Actor route pathfinding --- GDD.md | 3 + src/entities/Actor.ts | 471 +++++++++++++++++++++++++- tests/entities/actor-movement.test.ts | 154 +++++++++ tests/scene/scene-interaction.test.ts | 33 +- 4 files changed, 649 insertions(+), 12 deletions(-) create mode 100644 tests/entities/actor-movement.test.ts diff --git a/GDD.md b/GDD.md index c87ab3f..7b864a3 100644 --- a/GDD.md +++ b/GDD.md @@ -964,11 +964,14 @@ ScriptRegistry.register('interaction.lamp.use', ({ entity }) => { - `actor.setDirection(dir: 'up' | 'down' | 'left' | 'right')` - `actor.walkTo(x, y)` - `actor.moveTo(x, y)` +- `actor.getMoveResult()` - `actor.stop()` - `actor.setState(state)` - `actor.playAnimSet(id: string)` - `actor.resetAnimSet()` +`actor.moveTo(x, y)` строит маршрут до указанной точки через текущие правила проходимости сцены. Если точка недостижима, метод сразу возвращает результат со статусом `unreachable`. Если маршрут построен, метод возвращает `started`, а Actor начинает идти по точкам маршрута. После прибытия `actor.getMoveResult()` возвращает `arrived`; если во время движения маршрут внезапно оказался заблокирован из-за другого объекта или изменения сцены, Actor останавливается, а `actor.getMoveResult()` возвращает `blocked` с сообщением, что маршрут нужно пересмотреть. + Пример: ```typescript diff --git a/src/entities/Actor.ts b/src/entities/Actor.ts index 15f598e..00c15e9 100644 --- a/src/entities/Actor.ts +++ b/src/entities/Actor.ts @@ -7,6 +7,22 @@ import { toWorldPosition } from '../utils/Parallax'; export type ActorState = 'idle' | 'walk' | 'talk' | 'interact' | string; export type ActorDirection = 'up' | 'down' | 'left' | 'right'; +export type ActorMoveStatus = 'idle' | 'started' | 'arrived' | 'unreachable' | 'blocked'; +export type ActorMoveCode = + | 'idle' + | 'route_started' + | 'arrived' + | 'route_unreachable' + | 'route_blocked'; + +export interface ActorMoveResult { + status: ActorMoveStatus; + code: ActorMoveCode; + message: string; + target: { x: number; y: number } | null; + route: { x: number; y: number }[]; +} + export interface AnimationSet { id: string; // e.g. 'idle', 'walk' up: string | null; // Sprite Name @@ -36,6 +52,9 @@ export class Actor extends Entity { speed: number; target: { x: number; y: number } | null; visualTarget: { x: number; y: number } | null; + route: { x: number; y: number }[]; + routeIndex: number; + lastMoveResult: ActorMoveResult; readonly type: string = 'Actor'; isPlayer: boolean = false; @@ -66,6 +85,9 @@ export class Actor extends Entity { this.speed = 0.1; this.target = null; this.visualTarget = null; + this.route = []; + this.routeIndex = 0; + this.lastMoveResult = this.createMoveResult('idle', 'idle', null, []); this.isPlayer = false; this.animSets = {}; @@ -120,37 +142,78 @@ export class Actor extends Entity { return this.animSets[id]; } - walkTo(x: number, y: number): void { + walkTo(x: number, y: number): ActorMoveResult { // Validation: Check if destination is walkable using the Scene's logic if (this.scene && typeof this.scene.isWalkable === 'function') { if (!this.scene.isWalkable(x, y, this)) { console.warn(`[Actor] walkTo destination ${x},${y} is not walkable.`); - return; + this.stopWithMoveResult( + this.createMoveResult('unreachable', 'route_unreachable', { x, y }, []) + ); + return this.lastMoveResult; } } - this.moveTo(x, y); + return this.moveTo(x, y); } - moveTo(x: number, y: number): void { - this.target = { x, y }; + moveTo(x: number, y: number): ActorMoveResult { + const target = { x, y }; + const route = this.planRouteTo(target); + + if (!route) { + this.stopWithMoveResult( + this.createMoveResult('unreachable', 'route_unreachable', target, []) + ); + return this.lastMoveResult; + } + + this.route = route; + this.routeIndex = 0; + this.target = route[0] || target; this.visualTarget = null; this.setState('walk'); this.overrideAnimSet = null; + this.lastMoveResult = this.createMoveResult('started', 'route_started', target, route); + return this.lastMoveResult; } - moveToVisual(x: number, y: number): void { - this.visualTarget = { x, y }; - this.target = null; + moveToVisual(x: number, y: number): ActorMoveResult { + const worldTarget = toWorldPosition( + { x, y }, + this.scene?.camera || { x: 0, y: 0 }, + this.parallax !== undefined ? this.parallax : 1.0 + ); + const route = this.planRouteTo(worldTarget); + + if (!route) { + this.stopWithMoveResult( + this.createMoveResult('unreachable', 'route_unreachable', worldTarget, []) + ); + return this.lastMoveResult; + } + + this.route = route; + this.routeIndex = 0; + this.target = route[0] || worldTarget; + this.visualTarget = null; this.setState('walk'); this.overrideAnimSet = null; + this.lastMoveResult = this.createMoveResult('started', 'route_started', worldTarget, route); + return this.lastMoveResult; } stop(): void { this.target = null; this.visualTarget = null; + this.route = []; + this.routeIndex = 0; this.setState('idle'); } + getMoveResult(): ActorMoveResult { + return this.lastMoveResult; + } + setState(state: ActorState) { if (this.state === state) return; this.state = state; @@ -197,7 +260,15 @@ export class Actor extends Entity { if (dist <= step) { this.x = currentTarget.x; this.y = currentTarget.y; - this.stop(); + if (this.route.length > 0 && this.routeIndex < this.route.length - 1) { + this.routeIndex += 1; + this.target = this.route[this.routeIndex]; + } else { + const completedTarget = this.visualTarget || this.target; + this.stopWithMoveResult( + this.createMoveResult('arrived', 'arrived', completedTarget, this.route) + ); + } } else { const moveX = (dx / dist) * step; const moveY = (dy / dist) * step; @@ -215,8 +286,12 @@ export class Actor extends Entity { this.x = nextX; this.y = nextY; if (this.overrideAnimSet) this.overrideAnimSet = null; + } else if (isWalkable && this.trySlideTowardTarget(nextX, nextY, isWalkable)) { + if (this.overrideAnimSet) this.overrideAnimSet = null; } else { - this.stop(); + this.stopWithMoveResult( + this.createMoveResult('blocked', 'route_blocked', this.target, this.route) + ); } } } @@ -249,6 +324,8 @@ export class Actor extends Entity { if (dx !== 0 || dy !== 0) { this.target = null; + this.route = []; + this.routeIndex = 0; this.setState('walk'); if (this.overrideAnimSet) this.overrideAnimSet = null; @@ -361,6 +438,380 @@ export class Actor extends Entity { this.resetAnimSet(); } + private createMoveResult( + status: ActorMoveStatus, + code: ActorMoveCode, + target: { x: number; y: number } | null, + route: { x: number; y: number }[] + ): ActorMoveResult { + return { + status, + code, + message: this.getMoveMessage(code), + target: target ? { ...target } : null, + route: route.map((point) => ({ ...point })), + }; + } + + private getMoveMessage(code: ActorMoveCode): string { + switch (code) { + case 'route_started': + return 'Route started.'; + case 'arrived': + return 'Arrived.'; + case 'route_unreachable': + return 'Destination is unreachable.'; + case 'route_blocked': + return 'Route is blocked and should be reconsidered.'; + case 'idle': + default: + return 'Idle.'; + } + } + + private stopWithMoveResult(result: ActorMoveResult): void { + this.stop(); + this.lastMoveResult = result; + } + + private planRouteTo(target: { x: number; y: number }): { x: number; y: number }[] | null { + const isWalkable = (x: number, y: number) => + !this.scene || typeof this.scene.isWalkable !== 'function' + ? true + : this.scene.isWalkable(x, y, this); + + if (!isWalkable(target.x, target.y)) return null; + if (this.isRouteSegmentClear({ x: this.x, y: this.y }, target, isWalkable)) return [target]; + + return this.findGridRoute(target, isWalkable); + } + + private isRouteSegmentClear( + from: { x: number; y: number }, + to: { x: number; y: number }, + isWalkable: (x: number, y: number) => boolean + ): boolean { + const dx = to.x - from.x; + const dy = to.y - from.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const stepSize = Math.max(2, this.getRouteGridSize() / 2); + const steps = Math.max(1, Math.ceil(dist / stepSize)); + + for (let i = 1; i <= steps; i++) { + const t = i / steps; + if (!isWalkable(from.x + dx * t, from.y + dy * t)) return false; + } + + return true; + } + + private getRouteGridSize(): number { + const colliderSize = + this.colliderWidth > 0 && this.colliderHeight > 0 + ? Math.min(this.colliderWidth, this.colliderHeight) + : 12; + return Math.max(4, Math.min(24, colliderSize)); + } + + private trySlideTowardTarget( + nextX: number, + nextY: number, + isWalkable: (x: number, y: number) => boolean + ): boolean { + const canChangeX = Math.abs(nextX - this.x) > 0.0001; + const canChangeY = Math.abs(nextY - this.y) > 0.0001; + const canMoveX = canChangeX && isWalkable(nextX, this.y); + const canMoveY = canChangeY && isWalkable(this.x, nextY); + + if (canMoveX && canMoveY) { + const target = this.target || { x: nextX, y: nextY }; + const xFirstDistance = this.cellDistance({ x: nextX, y: this.y }, target); + const yFirstDistance = this.cellDistance({ x: this.x, y: nextY }, target); + + if (xFirstDistance <= yFirstDistance) { + this.x = nextX; + } else { + this.y = nextY; + } + return true; + } + + if (canMoveX) { + this.x = nextX; + return true; + } + + if (canMoveY) { + this.y = nextY; + return true; + } + + return false; + } + + private findGridRoute( + target: { x: number; y: number }, + isWalkable: (x: number, y: number) => boolean + ): { x: number; y: number }[] | null { + const gridSize = this.getRouteGridSize(); + const start = { x: this.x, y: this.y }; + const bounds = this.getRouteSearchBounds(start, target, gridSize); + const startCell = this.pointToCell(start, bounds, gridSize); + const targetCell = this.pointToCell(target, bounds, gridSize); + const startKey = this.cellKey(startCell); + const targetKey = this.cellKey(targetCell); + const open = new Set([startKey]); + const cameFrom = new Map(); + const gScore = new Map([[startKey, 0]]); + const fScore = new Map([[startKey, this.cellDistance(startCell, targetCell)]]); + const cells = new Map([[startKey, startCell]]); + const directions = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 }, + { x: 1, y: 1 }, + { x: 1, y: -1 }, + { x: -1, y: 1 }, + { x: -1, y: -1 }, + ]; + const maxIterations = (bounds.cols + 1) * (bounds.rows + 1); + let iterations = 0; + + while (open.size > 0 && iterations < maxIterations) { + iterations += 1; + const currentKey = this.getBestOpenCell(open, fScore); + const current = cells.get(currentKey); + if (!current) return null; + + if (currentKey === targetKey) { + return this.smoothRoute( + this.reconstructRoute( + cameFrom, + cells, + currentKey, + target, + bounds, + gridSize, + startKey, + targetKey, + start + ), + isWalkable + ); + } + + open.delete(currentKey); + + for (const direction of directions) { + const neighbor = { x: current.x + direction.x, y: current.y + direction.y }; + if ( + neighbor.x < 0 || + neighbor.y < 0 || + neighbor.x > bounds.cols || + neighbor.y > bounds.rows + ) { + continue; + } + + const neighborKey = this.cellKey(neighbor); + const currentPoint = this.cellToRoutePoint( + current, + currentKey, + bounds, + gridSize, + startKey, + targetKey, + start, + target + ); + const point = this.cellToRoutePoint( + neighbor, + neighborKey, + bounds, + gridSize, + startKey, + targetKey, + start, + target + ); + if (!isWalkable(point.x, point.y)) continue; + if (!this.isRouteSegmentClear(currentPoint, point, isWalkable)) continue; + + const tentativeG = + (gScore.get(currentKey) ?? Number.POSITIVE_INFINITY) + + this.cellDistance(current, neighbor); + + if (tentativeG < (gScore.get(neighborKey) ?? Number.POSITIVE_INFINITY)) { + cameFrom.set(neighborKey, currentKey); + cells.set(neighborKey, neighbor); + gScore.set(neighborKey, tentativeG); + fScore.set(neighborKey, tentativeG + this.cellDistance(neighbor, targetCell)); + open.add(neighborKey); + } + } + } + + return null; + } + + private getRouteSearchBounds( + start: { x: number; y: number }, + target: { x: number; y: number }, + gridSize: number + ): { minX: number; minY: number; cols: number; rows: number } { + let minX = Math.min(start.x, target.x); + let maxX = Math.max(start.x, target.x); + let minY = Math.min(start.y, target.y); + let maxY = Math.max(start.y, target.y); + + for (const entity of this.scene?.entities || []) { + if (entity.disabled) continue; + minX = Math.min(minX, entity.x - entity.colliderWidth - gridSize); + maxX = Math.max(maxX, entity.x + entity.colliderWidth + gridSize); + minY = Math.min(minY, entity.y - entity.colliderHeight - gridSize); + maxY = Math.max(maxY, entity.y + gridSize); + } + + for (const walkbox of this.scene?.walkbox || []) { + if (walkbox.disabled) continue; + for (const point of walkbox.poly || []) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + } + + const padding = gridSize * 4; + minX -= padding; + maxX += padding; + minY -= padding; + maxY += padding; + + return { + minX, + minY, + cols: Math.max(1, Math.ceil((maxX - minX) / gridSize)), + rows: Math.max(1, Math.ceil((maxY - minY) / gridSize)), + }; + } + + private pointToCell( + point: { x: number; y: number }, + bounds: { minX: number; minY: number }, + gridSize: number + ): { x: number; y: number } { + return { + x: Math.round((point.x - bounds.minX) / gridSize), + y: Math.round((point.y - bounds.minY) / gridSize), + }; + } + + private cellToPoint( + cell: { x: number; y: number }, + bounds: { minX: number; minY: number }, + gridSize: number + ): { x: number; y: number } { + return { + x: bounds.minX + cell.x * gridSize, + y: bounds.minY + cell.y * gridSize, + }; + } + + private cellToRoutePoint( + cell: { x: number; y: number }, + key: string, + bounds: { minX: number; minY: number }, + gridSize: number, + startKey: string, + targetKey: string, + start: { x: number; y: number }, + target: { x: number; y: number } + ): { x: number; y: number } { + if (key === startKey) return start; + if (key === targetKey) return target; + return this.cellToPoint(cell, bounds, gridSize); + } + + private cellKey(cell: { x: number; y: number }): string { + return `${cell.x},${cell.y}`; + } + + private cellDistance(a: { x: number; y: number }, b: { x: number; y: number }): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.sqrt(dx * dx + dy * dy); + } + + private getBestOpenCell(open: Set, fScore: Map): string { + let best = ''; + let bestScore = Number.POSITIVE_INFINITY; + + for (const key of open) { + const score = fScore.get(key) ?? Number.POSITIVE_INFINITY; + if (score < bestScore) { + best = key; + bestScore = score; + } + } + + return best; + } + + private reconstructRoute( + cameFrom: Map, + cells: Map, + currentKey: string, + target: { x: number; y: number }, + bounds: { minX: number; minY: number }, + gridSize: number, + startKey: string, + targetKey: string, + start: { x: number; y: number } + ): { x: number; y: number }[] { + const route: { x: number; y: number }[] = []; + let key = currentKey; + + while (cameFrom.has(key)) { + const cell = cells.get(key); + if (cell) { + route.unshift( + this.cellToRoutePoint(cell, key, bounds, gridSize, startKey, targetKey, start, target) + ); + } + key = cameFrom.get(key) || key; + } + + if (route.length === 0) return [target]; + route[route.length - 1] = target; + return route; + } + + private smoothRoute( + route: { x: number; y: number }[], + isWalkable: (x: number, y: number) => boolean + ): { x: number; y: number }[] { + if (route.length <= 2) return route; + + const smoothed: { x: number; y: number }[] = []; + let anchor = { x: this.x, y: this.y }; + let index = 0; + + while (index < route.length) { + let nextIndex = route.length - 1; + while (nextIndex > index && !this.isRouteSegmentClear(anchor, route[nextIndex], isWalkable)) { + nextIndex -= 1; + } + + smoothed.push(route[nextIndex]); + anchor = route[nextIndex]; + index = nextIndex + 1; + } + + return smoothed; + } + toJSON() { const data = super.toJSON(); data.type = 'Actor'; diff --git a/tests/entities/actor-movement.test.ts b/tests/entities/actor-movement.test.ts new file mode 100644 index 0000000..eb94ce0 --- /dev/null +++ b/tests/entities/actor-movement.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { Entity } from '../../src/entities/Entity'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +function updateActorUntilIdle(actor: any, maxFrames = 300): void { + for (let i = 0; i < maxFrames && actor.state === 'walk'; i++) { + actor.update(10, (x: number, y: number) => actor.scene.isWalkable(x, y, actor)); + } +} + +describe('Actor route movement', () => { + it('starts a direct route when the target is reachable in a straight line', () => { + const fixture = createSceneFixture(); + const floor = fixture.addWalkbox('Floor'); + floor.poly = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ]; + const actor = fixture.addPlayer('Hero', 10, 50); + actor.colliderWidth = 4; + actor.colliderHeight = 4; + + const result = actor.moveTo(90, 50); + + expect(result.status).toBe('started'); + expect(result.code).toBe('route_started'); + expect(result.route).toEqual([{ x: 90, y: 50 }]); + }); + + it('builds a multi-point route around a blocking collider', () => { + const fixture = createSceneFixture(); + const floor = fixture.addWalkbox('Floor'); + floor.poly = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ]; + const actor = fixture.addPlayer('Hero', 10, 30); + actor.colliderWidth = 4; + actor.colliderHeight = 4; + actor.speed = 1; + + const obstacle = new Entity(fixture.game as any, 50, 50, 10, 10, 'Blocker'); + obstacle.colliderWidth = 20; + obstacle.colliderHeight = 40; + fixture.scene.addEntity(obstacle); + + const result = actor.moveTo(90, 30); + + expect(result.status).toBe('started'); + expect(result.route.length).toBeGreaterThan(1); + + updateActorUntilIdle(actor); + + expect(actor.getMoveResult().status).toBe('arrived'); + expect(actor.x).toBeCloseTo(90); + expect(actor.y).toBeCloseTo(30); + }); + + it('returns unreachable immediately when no route can reach the destination', () => { + const fixture = createSceneFixture(); + const floor = fixture.addWalkbox('Floor'); + floor.poly = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ]; + const actor = fixture.addPlayer('Hero', 10, 50); + actor.colliderWidth = 4; + actor.colliderHeight = 4; + + const result = actor.moveTo(150, 50); + + expect(result.status).toBe('unreachable'); + expect(result.code).toBe('route_unreachable'); + expect(actor.state).toBe('idle'); + expect(actor.target).toBeNull(); + }); + + it('stops with a blocked result when the planned route becomes obstructed', () => { + const fixture = createSceneFixture(); + const floor = fixture.addWalkbox('Floor'); + floor.poly = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ]; + const actor = fixture.addPlayer('Hero', 10, 50); + actor.colliderWidth = 4; + actor.colliderHeight = 4; + actor.speed = 1; + + const result = actor.moveTo(90, 50); + expect(result.status).toBe('started'); + + const blocker = new Entity(fixture.game as any, 50, 50, 10, 10, 'DynamicBlocker'); + blocker.colliderWidth = 20; + blocker.colliderHeight = 20; + fixture.scene.addEntity(blocker); + + updateActorUntilIdle(actor); + + expect(actor.getMoveResult().status).toBe('blocked'); + expect(actor.getMoveResult().code).toBe('route_blocked'); + expect(actor.x).toBeLessThan(90); + }); + + it('slides along one axis when a diagonal route step is blocked', () => { + const fixture = createSceneFixture(); + const actor = fixture.addPlayer('Hero', 0, 0); + actor.colliderWidth = 4; + actor.colliderHeight = 4; + actor.speed = 1; + + const result = actor.moveTo(10, 10); + expect(result.status).toBe('started'); + + actor.update(10, (x: number, y: number) => x === actor.x || y === actor.y); + + expect(actor.getMoveResult().status).toBe('started'); + expect(actor.state).toBe('walk'); + expect(actor.x !== 0 || actor.y !== 0).toBe(true); + expect(actor.x === 0 || actor.y === 0).toBe(true); + }); + + it('plans routes across large walkboxes without exhausting the search cap', () => { + const fixture = createSceneFixture(); + const floor = fixture.addWalkbox('LargeFloor'); + floor.poly = [ + { x: -1000, y: -120 }, + { x: 1000, y: -120 }, + { x: 1000, y: 120 }, + { x: -1000, y: 120 }, + ]; + const actor = fixture.addPlayer('Hero', -900, 0); + actor.colliderWidth = 4; + actor.colliderHeight = 4; + + const obstacle = new Entity(fixture.game as any, 0, 80, 10, 10, 'WideBlocker'); + obstacle.colliderWidth = 40; + obstacle.colliderHeight = 160; + fixture.scene.addEntity(obstacle); + + const result = actor.moveTo(900, 0); + + expect(result.status).toBe('started'); + expect(result.route.length).toBeGreaterThan(1); + }); +}); diff --git a/tests/scene/scene-interaction.test.ts b/tests/scene/scene-interaction.test.ts index 549cfc3..14b67f8 100644 --- a/tests/scene/scene-interaction.test.ts +++ b/tests/scene/scene-interaction.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { Entity } from '../../src/entities/Entity'; import { handleSceneClick } from '../../src/scene/SceneInteraction'; import { createSceneFixture } from '../fixtures/sceneFactory'; @@ -141,7 +142,35 @@ describe('Scene interaction text layer', () => { handleSceneClick(fixture.scene, 320, 180); expect(fixture.messages).toHaveLength(0); - expect(fixture.scene.player?.visualTarget).not.toBeNull(); - expect(fixture.scene.player?.target).toBeNull(); + expect(fixture.scene.player?.getMoveResult().status).toBe('started'); + expect(fixture.scene.player?.target).not.toBeNull(); + expect(fixture.scene.player?.visualTarget).toBeNull(); + }); + + it('click-to-move uses route planning around blocking colliders', () => { + const fixture = createSceneFixture(); + fixture.game.canvas.width = 640; + fixture.game.canvas.height = 360; + const player = fixture.addPlayer('Hero', -80, 0); + player.colliderWidth = 4; + player.colliderHeight = 4; + player.speed = 1; + const floor = fixture.addWalkbox('wb_floor'); + floor.poly = [ + { x: -100, y: -50 }, + { x: 100, y: -50 }, + { x: 100, y: 50 }, + { x: -100, y: 50 }, + ]; + + const obstacle = new Entity(fixture.game as any, 0, 20, 10, 10, 'Blocker'); + obstacle.colliderWidth = 20; + obstacle.colliderHeight = 40; + fixture.scene.addEntity(obstacle); + + handleSceneClick(fixture.scene, 400, 180); + + expect(player.getMoveResult().status).toBe('started'); + expect(player.getMoveResult().route.length).toBeGreaterThan(1); }); }); From fbea8ffdacd1a5410f5b29424150a4c8ea79a1a7 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Tue, 5 May 2026 21:54:27 +0200 Subject: [PATCH 14/50] AI settings update --- AGENTS.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 30ea7a8..c46f0db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,11 +18,73 @@ Use the project knowledge sources in this order, depending on the question: 3. `local_rag` is the local fallback/sidecar for fuzzy recall: semantic search, related-document discovery, and cases where the exact memory title, file name, or subsystem name is unknown. 4. The repository itself is the source of truth for current code. Use `rg`, file reads, and tests to verify behavior before editing. +## NotebookLM Architecture Recall Workflow + +NotebookLM is not just a passive documentation search tool. Treat it as a free, high-context analysis assistant over the full project knowledge base: project documentation, important exported session history (`Sessions.md`), memory exports, GDD/autotest docs, and other curated sources in the Scanline Engine notebook. + +Before architecture-sensitive, subsystem-level, gameplay/runtime, parser, scene, or troubleshooting-heavy work: + +1. Complete the NotebookLM CLI readiness flow below. +2. Ask NotebookLM for a structured subsystem overview before scanning large parts of the repository. +3. Use `agent_memory` after NotebookLM for precise fresh facts, decisions, incidents, commit hashes, and runbooks. +4. Use `local_rag` as fallback or sidecar when NotebookLM is unavailable, noisy, or when fuzzy related-document discovery is needed. +5. Verify all conclusions against the current repository before editing. + +Preferred NotebookLM query template: + +```text +For Scanline Engine, give a subsystem overview for ``. + +Return: +- Current contract and intended behavior +- Key runtime/editor/parser files +- Relevant tests/autotests and how to run them +- Recent decisions, incidents, sessions, and commits +- Known caveats, regressions, or gotchas +- Recommended implementation checklist + +Keep it concise, actionable, and prefer exact file paths when known. +``` + +Use more specific variants when useful: + +```text +For Scanline Engine, analyze ``. + +Return: +- Most likely subsystems involved +- Known similar incidents or prior fixes +- Files and functions to inspect first +- Tests that should reproduce or protect this behavior +- Risks and rollback considerations +``` + +```text +For Scanline Engine, prepare an implementation brief for ``. + +Return: +- Existing architecture to reuse +- Contract changes needed +- Minimal file/test plan +- Documentation/GDD updates needed +- Open questions for the user +``` + +Important NotebookLM usage rules: + +- Prefer asking for a specific output shape over broad "summarize this" prompts. +- NotebookLM analysis is cheap relative to Codex context. Use it to synthesize prior knowledge before spending tokens re-reading large code or docs. +- The notebook may include `Sessions.md`, which is a curated history of important chat sessions. Ask about prior sessions explicitly when chronology or "why was this done?" matters. +- NotebookLM answers are guidance, not source of truth. Validate file paths, contracts, and behavior in the repo before editing. +- If NotebookLM is unavailable, use `local_rag` with `context: "Quest"` plus direct `agent_memory` recall, then verify in code. + ## Kairo TaskOps Use Kairo as the shared task/action layer when MCP or CLI access is available. Kairo is for actionable work with an owner, status, and next step; `agent_memory` remains the durable knowledge layer. +- Kairo task sync uses the private GitHub repo `z-hunter/kairo-tasks-sync` (`git@github.com:z-hunter/kairo-tasks-sync.git`) with local repo path `C:\Users\Professional\AppData\Roaming\kairo\tasks-sync`. - Create or update Kairo tasks for work that continues beyond the current response, needs user acceptance/manual action, is delegated to another agent, or comes from review/test follow-up. +- Gemini can also access Kairo and `agent_memory`; create `owner:gemini` tasks when useful, but include explicit instructions because Gemini's startup prompt may not require using these systems by default. - Do not create Kairo tasks for trivial internal steps, raw notes, architecture facts, or temporary debugging thoughts. - Use `proj:quest` for this repository. Prefer tags: `owner:`, `type:`, `area:`, `source:`, `status-meta:`, and `session:`. - Priority convention: `0` blocker/urgent user action/regression risk, `1` important current-session work, `2` normal follow-up, `3` low-priority cleanup or someday. @@ -37,6 +99,7 @@ When Gemini CLI is installed, use it as an external helper for technical tasks w - Prefer the `gemini-cli-agent` skill for this workflow. - Use Gemini for bounded implementation chores, mechanical edits, small test-writing tasks, focused bug fixes, and independent read-only reviews. - Run multiple Gemini CLI processes in parallel when tasks are independent and have disjoint file ownership. +- Local Gemini has access to `agent_memory` and Kairo. When relevant, Codex may create Kairo tasks for Gemini and tell Gemini exactly which memory/task context to consult or update. - Codex remains responsible for project memory/NotebookLM/RAG recall, architecture decisions, prompt scoping, diff review, test selection, and final integration. - Give Gemini strict prompts with allowed write scope, forbidden files, allowed commands, validation expectations, and an instruction to stop if the task exceeds scope. - Do not delegate broad architecture/design decisions, project-knowledge recall, GDD interpretation, or open-ended refactors to Gemini. From af53e2f05c5dfa7f2092c9cc09eba1fdc0f3aa5e Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Tue, 5 May 2026 22:11:52 +0200 Subject: [PATCH 15/50] AI settings update --- AGENTS.md | 275 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 182 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c46f0db..68b4868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,36 +1,54 @@ -# Project Instructions +# Scanline Engine Agent Protocol -- To get answers to previous session logs and project documentation, you can use your NotebookLM skill (Scanline Engine's notebook, URL: `https://notebooklm.google.com/notebook/9f146be7-7c4a-4bb0-b7b4-7f20079e85b0`). You can ask NotebookLM questions, and it will answer based on the project's entire knowledge base. -- Always consult NotebookLM for architecture/codebase recall first (if possible), instead of having to re-analyze the codebase each time. This saves tokens and allows us to do more. +## Mission -- If NotebookLM is not available, use local RAG as fallback. +Be a senior, autonomous engineering collaborator for Scanline Engine. Use project memory and knowledge synthesis before broad code scans, keep task state explicit, verify behavior in the repository and tests, and preserve durable knowledge for future agents. -- You ALWAYS record all important points, decisions, and insights you, and other developers, might need in the future in your memory (agent-memory-MCP) +## Startup Protocol -- Before implementing anything, check the contents of your memory for all related information. +At the start of a new session, before resuming nontrivial work, or before architecture-sensitive troubleshooting: -## Knowledge Recall Model +1. Read the user's newest request and classify it: quick answer, implementation, architecture-sensitive work, troubleshooting, continuation, review, or planning. +2. Recall `agent_memory` for relevant fresh facts, decisions, incidents, commits, runbooks, and known caveats. +3. If the task needs whole-project context, complete the NotebookLM readiness flow and ask for a structured subsystem overview before scanning large code areas. +4. Use `local_rag` as fallback or sidecar for fuzzy related-document discovery and indexed docs. +5. Check Kairo for active/high-priority `proj:quest` tasks when starting a new session, resuming work, or when the request may already have a follow-up. Do not check Kairo for tiny one-shot questions unless task status may matter. +6. Verify conclusions against the current repository before editing. +7. For substantial work, keep Kairo updated while working and store durable conclusions in `agent_memory` after validation. + +## Responsibility Model + +- Codex owns architecture judgment, risk assessment, code review, test selection, final integration, and user-facing recommendations. +- NotebookLM and `local_rag` provide recall and synthesis, not final truth. +- Gemini may perform bounded technical work only under explicit scope. +- The repository and tests are the current source of truth. + +## Knowledge Sources Use the project knowledge sources in this order, depending on the question: -1. `agent_memory` is the primary durable memory layer for precise facts, decisions, runbooks, incidents, commit context, and fresh conclusions from prior work. -2. NotebookLM is the broad architecture/documentation synthesis layer. Use it when a question needs whole-project context, but only after the NotebookLM readiness flow below succeeds. -3. `local_rag` is the local fallback/sidecar for fuzzy recall: semantic search, related-document discovery, and cases where the exact memory title, file name, or subsystem name is unknown. -4. The repository itself is the source of truth for current code. Use `rg`, file reads, and tests to verify behavior before editing. +1. `agent_memory`: primary durable memory for precise facts, decisions, runbooks, incidents, commit context, fresh conclusions, and known failures. +2. NotebookLM: broad architecture/documentation/session synthesis, after the CLI readiness flow succeeds. +3. `local_rag`: local fallback/sidecar for semantic search, related-document discovery, indexed memory exports, and mirrored project docs. +4. Repository: source of truth for current code and behavior. Use `rg`, file reads, and tests before editing. + +The Scanline Engine NotebookLM notebook is: -## NotebookLM Architecture Recall Workflow +`https://notebooklm.google.com/notebook/9f146be7-7c4a-4bb0-b7b4-7f20079e85b0` -NotebookLM is not just a passive documentation search tool. Treat it as a free, high-context analysis assistant over the full project knowledge base: project documentation, important exported session history (`Sessions.md`), memory exports, GDD/autotest docs, and other curated sources in the Scanline Engine notebook. +## NotebookLM Architecture Recall + +NotebookLM is not just passive documentation search. Treat it as a free, high-context analysis assistant over the full project knowledge base: project documentation, exported session history (`Sessions.md`), memory exports, GDD/autotest docs, and other curated sources in the Scanline Engine notebook. Before architecture-sensitive, subsystem-level, gameplay/runtime, parser, scene, or troubleshooting-heavy work: -1. Complete the NotebookLM CLI readiness flow below. -2. Ask NotebookLM for a structured subsystem overview before scanning large parts of the repository. -3. Use `agent_memory` after NotebookLM for precise fresh facts, decisions, incidents, commit hashes, and runbooks. -4. Use `local_rag` as fallback or sidecar when NotebookLM is unavailable, noisy, or when fuzzy related-document discovery is needed. +1. Complete the NotebookLM CLI readiness flow. +2. Ask NotebookLM for a structured brief in the shape you need. +3. Use `agent_memory` after NotebookLM for precise fresh facts, incidents, commit hashes, decisions, and runbooks. +4. Use `local_rag` if NotebookLM is unavailable, noisy, or you need fuzzy related-document discovery. 5. Verify all conclusions against the current repository before editing. -Preferred NotebookLM query template: +Preferred subsystem overview prompt: ```text For Scanline Engine, give a subsystem overview for ``. @@ -43,10 +61,12 @@ Return: - Known caveats, regressions, or gotchas - Recommended implementation checklist +Do not give a generic explanation. Produce an engineering brief for implementation. Keep it concise, actionable, and prefer exact file paths when known. +If evidence is uncertain, mark it as uncertain and say what repo files should verify it. ``` -Use more specific variants when useful: +Bug analysis prompt: ```text For Scanline Engine, analyze ``. @@ -59,6 +79,8 @@ Return: - Risks and rollback considerations ``` +Implementation brief prompt: + ```text For Scanline Engine, prepare an implementation brief for ``. @@ -70,89 +92,163 @@ Return: - Open questions for the user ``` -Important NotebookLM usage rules: +NotebookLM usage rules: -- Prefer asking for a specific output shape over broad "summarize this" prompts. -- NotebookLM analysis is cheap relative to Codex context. Use it to synthesize prior knowledge before spending tokens re-reading large code or docs. -- The notebook may include `Sessions.md`, which is a curated history of important chat sessions. Ask about prior sessions explicitly when chronology or "why was this done?" matters. -- NotebookLM answers are guidance, not source of truth. Validate file paths, contracts, and behavior in the repo before editing. -- If NotebookLM is unavailable, use `local_rag` with `context: "Quest"` plus direct `agent_memory` recall, then verify in code. +- Prefer specific output shapes over broad "summarize this" prompts. +- Use NotebookLM to synthesize prior knowledge before spending Codex context on large code or docs. +- Ask about `Sessions.md` explicitly when chronology, previous chat context, or "why was this done?" matters. +- NotebookLM answers are guidance; validate file paths, contracts, and behavior in the repo. -## Kairo TaskOps +## NotebookLM CLI Readiness -Use Kairo as the shared task/action layer when MCP or CLI access is available. Kairo is for actionable work with an owner, status, and next step; `agent_memory` remains the durable knowledge layer. +Use NotebookLM through the CLI first. Do not start with the NotebookLM MCP for normal project recall on this machine. -- Kairo task sync uses the private GitHub repo `z-hunter/kairo-tasks-sync` (`git@github.com:z-hunter/kairo-tasks-sync.git`) with local repo path `C:\Users\Professional\AppData\Roaming\kairo\tasks-sync`. -- Create or update Kairo tasks for work that continues beyond the current response, needs user acceptance/manual action, is delegated to another agent, or comes from review/test follow-up. -- Gemini can also access Kairo and `agent_memory`; create `owner:gemini` tasks when useful, but include explicit instructions because Gemini's startup prompt may not require using these systems by default. -- Do not create Kairo tasks for trivial internal steps, raw notes, architecture facts, or temporary debugging thoughts. -- Use `proj:quest` for this repository. Prefer tags: `owner:`, `type:`, `area:`, `source:`, `status-meta:`, and `session:`. -- Priority convention: `0` blocker/urgent user action/regression risk, `1` important current-session work, `2` normal follow-up, `3` low-priority cleanup or someday. -- Title convention: start with `[Quest]`, use an action phrase, and mention the owner only when delegated or user-facing. -- Description convention: include owner, context, expected outcome, acceptance criteria, relevant files/links, and source when useful. -- Lifecycle: set active/delegated work to `doing`, completed work to `done` after validation or required acceptance, and store durable conclusions from completed tasks in `agent_memory`. +Required readiness flow: -## Gemini CLI Worker Rule +1. Run `python -m notebooklm auth check --json` only as a storage/cookie diagnostic. +2. Run `python -m notebooklm list --json`; this is the real auth check. +3. Run the project notebook smoke test: + `python -m notebooklm ask "ping: reply with one short sentence confirming access" --notebook 9f146be7-7c4a-4bb0-b7b4-7f20079e85b0 --json` +4. If `list` and the smoke-test `ask` work, reuse the current CLI auth and do not re-authorize. +5. If a real CLI command returns `Authentication expired or invalid` or redirects to Google sign-in, organize CLI re-auth with the user: + `Start-Process powershell -ArgumentList @('-NoExit','-Command','Set-Location -LiteralPath "D:\GAMES\New folder\Quest"; python -m notebooklm login')` + Ask the user to complete Google login in the opened browser, wait for the NotebookLM homepage, then press Enter in that terminal. Re-run `list` and the project smoke test. -When Gemini CLI is installed, use it as an external helper for technical tasks wherever this is practical and safe. This is intended to increase throughput and reduce Codex token use. +Important caveats: -- Prefer the `gemini-cli-agent` skill for this workflow. -- Use Gemini for bounded implementation chores, mechanical edits, small test-writing tasks, focused bug fixes, and independent read-only reviews. -- Run multiple Gemini CLI processes in parallel when tasks are independent and have disjoint file ownership. -- Local Gemini has access to `agent_memory` and Kairo. When relevant, Codex may create Kairo tasks for Gemini and tell Gemini exactly which memory/task context to consult or update. -- Codex remains responsible for project memory/NotebookLM/RAG recall, architecture decisions, prompt scoping, diff review, test selection, and final integration. -- Give Gemini strict prompts with allowed write scope, forbidden files, allowed commands, validation expectations, and an instruction to stop if the task exceeds scope. -- Do not delegate broad architecture/design decisions, project-knowledge recall, GDD interpretation, or open-ended refactors to Gemini. -- After Gemini edits, inspect `git status`/`git diff`, reject out-of-scope changes, and run relevant tests before considering the work complete. +- `auth check` can report `status: ok` while the server-side session is expired or revoked. Trust `list`/`ask`, not `auth check` alone. +- Use explicit notebook IDs (`--notebook 9f146be7-7c4a-4bb0-b7b4-7f20079e85b0`) instead of `notebooklm use`, so separate agent sessions do not overwrite shared CLI context. +- MCP may fail with `browserType.launchPersistentContext: Target page, context or browser has been closed`; treat that as an MCP/Chrome profile launch issue, not NotebookLM auth. Only troubleshoot MCP if the user asks. +- If CLI auth is repaired and MCP state must be refreshed later, back up and copy `C:\Users\Professional\.notebooklm\storage_state.json` to `C:\Users\Professional\AppData\Local\notebooklm-mcp\Data\browser_state\state.json`. This does not fix MCP browser launch failures. + +## Local RAG `local_rag` does not query live `agent_memory` directly. It indexes a file mirror: - exported durable memory docs under `docs/memory`; - mirrored Quest root documentation under `docs/projects/Quest`. -Important `local_rag` caveats: +Rules and caveats: -- For `mcp__local_rag__summarize_project_context`, use `context: "Quest"`, not the full Windows path like `D:\GAMES\New folder\Quest`. Earlier misses happened because indexed memory docs use the `Quest` context label. -- Use `mcp__local_rag__semantic_search` when you are unsure what to ask for; it searches across indexed memory and project documentation. +- Use `context: "Quest"` for `mcp__local_rag__summarize_project_context`, not the full Windows path. +- Use `mcp__local_rag__semantic_search` when you are unsure what to ask for. - Use `mcp__local_rag__repo_list` with `path: "docs/projects/Quest"` to confirm the project documentation mirror is visible. -- Fresh `agent_memory` entries may not appear in `local_rag` until the memory export/mirror and RAG index are refreshed. For fresh facts, query `agent_memory` directly first. -- Project documentation is mirrored into `local_rag` by the local startup script at `C:\Users\Professional\.codex\tools\agent-memory-mcp\start-local-rag.ps1`; the mirrored files live at `C:\Users\Professional\.codex\tools\agent-memory-mcp\local-rag-data\docs\projects\Quest`. +- Fresh `agent_memory` entries may not appear in `local_rag` until the memory export/mirror and RAG index are refreshed. Query `agent_memory` directly for fresh facts. +- Project documentation is mirrored by `C:\Users\Professional\.codex\tools\agent-memory-mcp\start-local-rag.ps1`. +- Mirrored project docs live at `C:\Users\Professional\.codex\tools\agent-memory-mcp\local-rag-data\docs\projects\Quest`. -## NotebookLM CLI Connectivity Rule +## Memory Policy -Use NotebookLM through the CLI first. Do not start with the NotebookLM MCP for normal project recall on this machine. +Always record important points, decisions, and insights that future developers or agents may need. Store durable knowledge, not transient chatter. -Required readiness flow: +Store memory when: -1. Run `python -m notebooklm auth check --json` only as a storage/cookie diagnostic. -2. Run `python -m notebooklm list --json`. This is the real auth check. -3. Run a project notebook smoke test: - - `python -m notebooklm ask "ping: reply with one short sentence confirming access" --notebook 9f146be7-7c4a-4bb0-b7b4-7f20079e85b0 --json` -4. If `list` and the smoke-test `ask` work, reuse the current CLI auth and do not re-authorize. -5. If a real CLI command returns `Authentication expired or invalid` or redirects to Google sign-in, organize CLI re-auth with the user: - - start `python -m notebooklm login` in a visible terminal, preferably via: - `Start-Process powershell -ArgumentList @('-NoExit','-Command','Set-Location -LiteralPath "D:\GAMES\New folder\Quest"; python -m notebooklm login')` - - ask the user to complete Google login in the opened browser, wait for the NotebookLM homepage, then press Enter in that terminal; - - re-run `list` and the project smoke-test `ask`. +- a runtime/parser/gameplay/editor contract changes; +- a bug root cause or durable workaround is found; +- a repeatable workflow/runbook is discovered; +- a commit has lasting architectural or operational value; +- a test failure is known, reproduced, and tracked; +- a user accepts or rejects important behavior. -Important caveats: +Do not store: -- `auth check` can report `status: ok` while the server-side session is expired or revoked. Trust `list`/`ask`, not `auth check` alone. -- Use explicit notebook IDs (`--notebook 9f146be7-7c4a-4bb0-b7b4-7f20079e85b0`) instead of `notebooklm use`, so separate agent sessions do not overwrite shared CLI context. -- MCP may still fail with `browserType.launchPersistentContext: Target page, context or browser has been closed`; treat that as an MCP/Chrome profile launch issue, not as a NotebookLM auth issue. Only troubleshoot MCP if the user explicitly asks for MCP repair. -- If CLI auth is repaired and MCP state must be refreshed later, back up and copy `C:\Users\Professional\.notebooklm\storage_state.json` to `C:\Users\Professional\AppData\Local\notebooklm-mcp\Data\browser_state\state.json`. This does not fix MCP browser launch failures. +- raw logs without conclusions; +- temporary guesses that did not produce durable lessons; +- facts obvious from nearby code unless they connect to decisions, caveats, or tests. + +Use the right type: + +- `working`: short-lived current-task context. +- `episodic`: important events, chronology, commits, validations, incidents. +- `semantic`: stable facts about architecture, contracts, configuration, files, and environment. +- `procedural`: repeatable workflows, setup, troubleshooting, validation steps. +- `store_decision`, `store_runbook`, `store_incident`: use these structured APIs when the record fits. + +After major work, clean up or supersede stale temporary context when useful. + +## Kairo TaskOps + +Use Kairo as the shared task/action layer for work with an owner, status, and next step. `agent_memory` remains the durable knowledge layer. + +At session start or before substantial work: + +- Check Kairo for active/high-priority `proj:quest` tasks, especially `doing`, delegated, blocked, needs-acceptance, waiting, and high-priority tasks. +- Reconcile the user's current request with existing Kairo tasks before creating duplicates. +- If resuming work, update the relevant task instead of creating a new one. + +During and after work: + +- Create or update Kairo tasks for work that continues beyond the current response, needs user acceptance/manual action, is delegated, or comes from review/test follow-up. +- Keep active work in `doing`, delegated work clearly tagged, and blocked work marked with status metadata. +- Close your own completed tasks yourself: after validation or required acceptance, set them to `done`. +- When a task is completed, store durable conclusions in `agent_memory`. +- Do not create Kairo tasks for trivial internal steps, raw notes, architecture facts, or temporary debugging thoughts. + +Kairo conventions: + +- Sync repo: private GitHub repo `z-hunter/kairo-tasks-sync` (`git@github.com:z-hunter/kairo-tasks-sync.git`), local path `C:\Users\Professional\AppData\Roaming\kairo\tasks-sync`. +- Use `proj:quest`. +- Prefer tags: `owner:`, `type:`, `area:`, `source:`, `status-meta:`, `session:`. +- Priority: `0` blocker/urgent user action/regression risk, `1` important current-session work, `2` normal follow-up, `3` low-priority cleanup or someday. +- Title: start with `[Quest]`, use an action phrase, and mention owner only when delegated or user-facing. +- Description: include owner, context, expected outcome, acceptance criteria, relevant files/links, and source when useful. + +## Gemini CLI Worker Rule + +When Gemini CLI is installed, use it as an external helper for bounded technical tasks where practical and safe. Prefer the `gemini-cli-agent` skill. + +Good Gemini tasks: + +- bounded implementation chores; +- mechanical edits; +- small test-writing tasks; +- focused bug fixes; +- independent read-only reviews; +- multiple independent tasks with disjoint file ownership. + +Do not delegate: + +- architecture or product decisions; +- project-knowledge recall, GDD interpretation, or broad design; +- open-ended refactors; +- final integration, test selection, or user-facing recommendations. + +Gemini rules: + +- Local Gemini has access to `agent_memory` and Kairo. When relevant, create `owner:gemini` tasks and tell Gemini exactly which memory/task context to consult or update. +- Codex remains responsible for prompt scope, architecture judgment, diff review, test selection, and final integration. +- Give Gemini strict prompts with allowed write scope, forbidden files, allowed commands, validation expectations, and an instruction to stop if scope is exceeded. +- After Gemini edits, inspect `git status`/`git diff`, reject out-of-scope changes, and run relevant tests. + +## Implementation Discipline + +- Prefer existing repo patterns, helpers, and architecture over new abstractions. +- Keep edits scoped to the requested behavior and related contracts. +- Update `GDD.md` if gameplay/design behavior changes. +- Use structured APIs/parsers where available instead of ad hoc string manipulation. +- For runtime/scene/gameplay bugs, prefer diagnostic helpers or temporary probes that explain engine decisions, such as why `isWalkable` returned false, which object blocked a path, or which semantic rule selected a parser target. +- Do not revert user changes. Work with dirty files unless the user explicitly asks to revert them. + +## Validation Ladder + +Use the narrowest meaningful checks first, then broaden based on risk: + +1. Focused tests for touched files or newly added behavior. +2. Adjacent subsystem tests. +3. Integration/parser/autotests when contracts cross runtime/parser/scene/system boundaries. +4. `npm run typecheck` for TypeScript changes. +5. Full `npm test` when risk is broad or before major commits. + +If the full suite fails on an unrelated existing issue, reproduce the failing test if useful, create/update a Kairo follow-up, and report it clearly. ## Project Standards -- **What is?**: A 2.5D retro-style adventure game engine, "Scanline Engine" (previously "Quest"), with AI-powered text parser. -- **Tech Stack**: React, Vite, Vanilla CSS. -- **Files**: - - Use `src/` for source code. - - `GDD.md` is the source of truth for game design. -- **Workflow**: - - Consult NotebookLM/RAG/GDD (in order of priority) before implementing gameplay features. - - Update `GDD.md` if design decisions change during implementation. - - Once the new functionality has been tested and accepted by the user, commit to memory all the most useful facts obtained during implementation that may be useful to developers in the future. +- Scanline Engine is a 2.5D retro-style adventure game engine with an AI-powered text parser. +- Tech stack: React, Vite, Vanilla CSS. +- Use `src/` for source code. +- `GDD.md` is the source of truth for game design. +- Consult NotebookLM/RAG/GDD before implementing gameplay features. +- Once new functionality is tested and accepted, store useful implementation facts in `agent_memory`. ## Autotests Recall Rule @@ -170,23 +266,16 @@ and especially on: - 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 this project has an autotest system on branch `autotests`. -- 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. +Before substantial changes in those areas: -## Autotests Maintenance Rule +- consult memory for current autotest workflow and coverage; +- use `Autotests.md` for when/how to run autotests, current coverage, fixtures, and harness structure; +- check whether existing autotests already cover the contract you are touching. -When making significant functional changes or adding important new behavior in mechanics/runtime code: +When making significant functional changes: -- 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. +- add tests for new important gameplay/runtime/parser contracts; +- update `Autotests.md` if the test system, fixtures, or coverage model changes meaningfully. From f9673c27ea73796cd048b5025683352eef3aa424 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Tue, 5 May 2026 22:47:33 +0200 Subject: [PATCH 16/50] AI setings --- Sessions.md | 125 +++++++++++++++++++++++ src/mechanics/ParserWorldModelBuilder.ts | 22 +++- tests/fixtures/gameFactory.ts | 2 +- 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/Sessions.md b/Sessions.md index 19425d3..87679e1 100644 --- a/Sessions.md +++ b/Sessions.md @@ -857,3 +857,128 @@ During the session the following checks were run successfully: - Actor component is not yet a pure runtime component architecture. It is intentionally an authoring/conversion affordance over existing classes. - If future work moves Actor behavior into a true component system, the current conversion helpers should become a migration bridge rather than the final architecture. - The confirmation dialog prevents accidental loss, but once the user chooses Proceed, Actor-only settings are removed from the object data by design. + +## Session Entry - 2026-05-05 22:22 +02:00 + +### Session Goals + +- Improve `Actor.moveTo` so Actors can route around obstacles instead of moving only in a direct line. +- Make route outcomes usable by future AI/NPC logic: immediate unreachable result, arrival result, and route-blocked/replan-needed result. +- Ensure click-to-move uses the same route planning as scripted `moveTo`. +- Tune path following until it matches keyboard movement better in narrow passages. +- Improve the agent setup by documenting a stronger NotebookLM/Kairo/memory startup workflow in `AGENTS.md`. +- Preserve durable conclusions in `agent_memory`, Kairo, and this session log. + +### What Was Implemented + +- Added route planning to `src/entities/Actor.ts`. + - `Actor.moveTo(x, y)` now returns an `ActorMoveResult`. + - `Actor.moveToVisual(x, y)` also routes after converting the click/visual target into the Actor's parallax-corrected world target. + - `Actor.getMoveResult()` exposes the latest movement outcome for future AI/NPC polling. + - Movement results include statuses/codes such as `started`, `arrived`, `unreachable`, and `blocked` / `route_blocked`. +- Implemented path planning using `Scene.isWalkable` as the single source of truth for current collision and Walkbox semantics. + - Direct segment sampling is tried first. + - If direct movement is blocked, bounded grid A* builds a waypoint route. + - Route smoothing removes unnecessary intermediate waypoints when a segment is clear. + - Search cap is based on the generated grid area rather than a fixed 4000-iteration cap, which matters for large/complex Walkbox areas. + - Segment-clear checks were tightened to sample at `gridSize / 2` with a minimum step of 2. +- Added route-following axis-slide fallback. + - If a diagonal route step is blocked, Actor tries X-only or Y-only movement before reporting `route_blocked`. + - This mirrors keyboard movement and helps narrow passages where manual control can already pass. + - A zero-displacement axis fallback is not treated as progress, so true blocks still report `blocked`. +- Updated click-to-move tests so Walkbox clicks assert route planning rather than the old `visualTarget` behavior. +- Updated `GDD.md` to document `actor.getMoveResult()` and the new `moveTo` route/outcome contract. +- Added `tests/entities/actor-movement.test.ts`. + - Direct route. + - Route around blocking collider. + - Immediate unreachable destination. + - Dynamic blocker causing `route_blocked`. + - Axis-slide behavior for narrow/diagonal blocked steps. + - Large Walkbox route that would exceed the old fixed search cap. + +### Important Architecture / Runtime Decisions + +- `Scene.isWalkable` remains the single authoritative movement oracle. The path planner does not duplicate collision, Walkbox Add/Subtract/Invert, parallax, or dynamic-scene rules. +- `moveToVisual` must be kept in sync with `moveTo`, because `SceneInteraction.movePlayerToClick` uses `moveToVisual` for mouse click movement. +- AI/NPC movement should poll `actor.getMoveResult()` for `arrived`, `unreachable`, or `route_blocked` rather than inferring from `target`/`state` alone. +- Route movement should preserve keyboard parity in narrow spaces by attempting axis-separated progress before failing. +- NotebookLM should be used as a structured architecture-analysis assistant, not just a broad summarizer. `AGENTS.md` now includes explicit NotebookLM query templates and workflow. +- Kairo is now explicitly part of session startup/resume flow; agents should check active/high-priority `proj:quest` tasks and close their own completed tasks after validation/acceptance. + +### Parser / Mechanics / Scene / Inventory Changes + +- Runtime movement changed in `Actor`. +- Scene interaction changed only through test expectations and the use of routed `moveToVisual`; no parser behavior was intentionally changed. +- `GameSemanticAPI`, inventory, spatial text semantics, and parser command resolution were not intentionally changed. +- Existing `Scene.isWalkable` collision/Walkbox behavior was reused rather than modified. + +### Tests Run and Outcomes + +- `npm test -- tests/entities/actor-movement.test.ts` + - Passed during focused implementation. +- `npm test -- tests/entities/actor-movement.test.ts tests/scene/scene-interaction.test.ts` + - Passed. +- `npm test -- tests/game/navigation-and-spatial.test.ts tests/entities/actor-movement.test.ts tests/scene/scene-interaction.test.ts` + - Passed. + - Final relevant run: 3 files passed, 26 tests passed. +- `npm run typecheck` + - Passed. +- Full `npm test` + - Run during the session and failed on an unrelated existing parser world-model test: + - `tests/parser/world-model-context.test.ts` + - case: `omits scene duplicates whose stable id is already held from takable scope` + - actual issue: `compact_cassette` still appears in takable scope. + - The failure was reproduced with the single parser test file and tracked separately in Kairo. + +### Commits Created + +- `11d990d Add Actor route pathfinding` + - Adds routed `moveTo` / `moveToVisual`, movement result API, pathfinding tests, click-to-move regression coverage, and GDD documentation. +- `fbea8ff AI settings update` + - Adds NotebookLM structured recall workflow to `AGENTS.md`. +- `af53e2f AI settings update` + - Refactors `AGENTS.md` into a more useful operational startup protocol, including Startup Protocol, Responsibility Model, NotebookLM workflow, Kairo lifecycle, memory policy, validation ladder, and autotest rules. + +### Kairo / Memory Updates + +- Kairo task `[Quest] Implement Actor MoveTo route planning` was completed and marked `done`. +- Kairo follow-up created for unrelated parser duplicate held-item failure: + - `aaaaaaabtx4yd3f45sovk3pbwhktwukd` + - `[Quest] Fix duplicate held item leaking into parser takable scope` +- Durable `agent_memory` entries were stored for: + - final MoveTo pathfinding contract and caveats; + - click-to-move using `moveToVisual` route planning; + - large Walkbox search cap behavior; + - narrow passage axis-slide parity with keyboard movement; + - commit `11d990d`; + - NotebookLM structured recall workflow; + - `AGENTS.md` operational startup protocol refactor. + +### Current State + +- Branch: `scene-refact3`. +- Latest commit: `af53e2f AI settings update`. +- Worktree was clean before this wrap-up entry was appended. +- This wrap-up adds a new `Sessions.md` documentation change that should remain uncommitted unless the user wants to commit the session log. + +### Remaining Work / Next Recommended Steps + +- Investigate and fix the unrelated parser world-model duplicate held-item failure: + - `tests/parser/world-model-context.test.ts` + - `compact_cassette` appears in takable scope when the stable id is already held. +- Continue manual QA of click-to-move and scripted `moveTo` in real scenes with: + - large Walkbox polygons; + - foreground occluders such as the sofa; + - narrow passages; + - dynamic blockers. +- Consider adding an engine diagnostic helper such as `scene.explainWalkable(x, y, actor)` or route debug output that reports which object/Walkbox caused a blocked point. +- If route planning performance becomes an issue in large scenes, consider caching sampled walkability grids per route request, using a binary heap for A*, or coarser/finer adaptive grids. +- If future NPC AI relies heavily on `ActorMoveResult`, consider adding event/callback hooks in addition to polling. + +### Risks / Caveats / Open Questions + +- The path planner is intentionally conservative and depends on `Scene.isWalkable`; any existing `isWalkable` quirks will be inherited by pathfinding. +- `Actor.moveTo` now returns a result where old code ignored a `void` return. TypeScript accepted this, but scripts may need to start checking results for AI/NPC behavior. +- `moveToVisual` now clears `visualTarget` and stores world-route waypoints in `target`/`route`; tests were updated to reflect this. +- Large Walkbox pathfinding works after removing the fixed cap, but the new regression test shows a nontrivial runtime cost. Keep an eye on route planning latency in very large scenes. +- Full `npm test` is not green because of the unrelated parser duplicate held-item test. Focused movement/navigation/typecheck validation is green. diff --git a/src/mechanics/ParserWorldModelBuilder.ts b/src/mechanics/ParserWorldModelBuilder.ts index aabfdd2..fac4a27 100644 --- a/src/mechanics/ParserWorldModelBuilder.ts +++ b/src/mechanics/ParserWorldModelBuilder.ts @@ -246,7 +246,9 @@ export class ParserWorldModelBuilder { const entry = textLayer?.entryById.get(entity.name); return (!!isItem || !!entity.isTakeable) && !entry?.blocked; }); - const takable = visibleItems.filter((entity) => !(this.game as any).canTakeEntity?.(entity)); + const takable = visibleItems + .filter((entity) => !this.isEntityHeldForTake(entity)) + .filter((entity) => !(this.game as any).canTakeEntity?.(entity)); const putSource = visibleItems .filter((entity) => reachableSet.has(entity) || subsceneSet.has(entity)) .filter((entity) => !(this.game as any).canPutSourceEntity?.(entity)); @@ -256,7 +258,9 @@ export class ParserWorldModelBuilder { held, takable: this.uniqueObjects([ ...takable, - ...externalTakable.filter((entity: Entity) => !(this.game as any).canTakeEntity?.(entity)), + ...externalTakable + .filter((entity: Entity) => !this.isEntityHeldForTake(entity)) + .filter((entity: Entity) => !(this.game as any).canTakeEntity?.(entity)), ]), putSource: this.uniqueObjects([ ...putSource, @@ -286,6 +290,20 @@ export class ParserWorldModelBuilder { .map((entry) => entry.object); } + private isEntityHeldForTake(entity: Entity): boolean { + const inventoryManager = (this.game as any).inventoryManager; + const stableIdCheck = inventoryManager?.hasEntityIdInInventory; + if (typeof stableIdCheck === 'function') { + return !!stableIdCheck.call(inventoryManager, entity); + } + if ((this.game.inventory || []).includes(entity)) return true; + const entityName = String(entity?.name || '').trim(); + if (!entityName) return false; + return (this.game.inventory || []).some( + (held: Entity) => String(held?.name || '').trim() === entityName + ); + } + private getPlayerFacingObjectTitle(sceneObject: SceneObject): string | null { const title = this.game.textAssets.getResolvedObjectField(sceneObject as any, 'title'); return title && title.trim() ? title.trim() : null; diff --git a/tests/fixtures/gameFactory.ts b/tests/fixtures/gameFactory.ts index 546b8dd..8603d68 100644 --- a/tests/fixtures/gameFactory.ts +++ b/tests/fixtures/gameFactory.ts @@ -274,7 +274,7 @@ export function createTestGame(): TestGameHarness { }; } - if (game.inventoryManager.isEntityInInventory(entity)) { + if (game.inventoryManager.hasEntityIdInInventory(entity)) { return { status: 'failed', code: 'item_already_held', From bde13adda019a39d6b943f2dbbf9a6717d7d5ffe Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Wed, 6 May 2026 01:43:24 +0200 Subject: [PATCH 17/50] Improvement: using LLM to improve Parser cascade 1 intention recognizig and also generate less generic answers. --- public/text/objects/miles_id.json | 2 +- public/text/system/parser-llm-system.md | 38 +++++++++ public/text/system/parser-llm.json | 33 ++++++++ src/mechanics/LlmCascade.ts | 102 +++++++++++++++++------- src/mechanics/Parser.ts | 39 ++++++++- src/mechanics/parserTypes.ts | 8 +- tests/parser/llm-cascade.test.ts | 78 +++++++++++++++++- tests/parser/llm-parser.test.ts | 62 ++++++++++++++ 8 files changed, 327 insertions(+), 35 deletions(-) create mode 100644 public/text/system/parser-llm.json diff --git a/public/text/objects/miles_id.json b/public/text/objects/miles_id.json index 627c0f4..c78544d 100644 --- a/public/text/objects/miles_id.json +++ b/public/text/objects/miles_id.json @@ -1,4 +1,4 @@ { "title": "ID card", - "description": "You s nothing special." + "description": "You see nothing special." } diff --git a/public/text/system/parser-llm-system.md b/public/text/system/parser-llm-system.md index df00420..07cf906 100644 --- a/public/text/system/parser-llm-system.md +++ b/public/text/system/parser-llm-system.md @@ -22,6 +22,37 @@ You are not just a command parser. You bring the world to life. You interpret wh - Match the player's language. - Do not mention implementation details, JSON, APIs, parser layers, or the model. +## Narrator Personality + +You are not neutral. + +You comment on the player's impulses, motives, fears, bad decisions, exhaustion, curiosity, loneliness, and occasional stupidity. +You often frame interactions through emotional or psychological interpretation instead of literal physical description. + +When the player attempts something pointless, awkward, suspicious, desperate, self-destructive, or absurd, you may dryly acknowledge it. + +The humor is subtle, cynical, deadpan, occasionally self-destructive, and sometimes slightly mean. +Avoid cheerful humor, sitcom energy, random jokes, or meme-style punchlines. +Prefer observations that reveal character or mood. + +## Failure Responses + +When an action fails, avoid defaulting to physical explanations. + +Do not primarily explain failure through collision, or object attachment unless necessary. + +Instead, prefer: + +- cynical observations +- emotional framing +- implication +- social awkwardness +- noir-style inner commentary +- suspicious interpretation of the player's behavior +- existential or self-deprecating undertones + +The player should feel narrated, not mechanically rejected. + ## Context You receive the player's command and a JSON snapshot of the current game world: @@ -71,6 +102,12 @@ For a direct question back to the player: { "kind": "clarification", "question": "Short question." } ``` +When the standard parser response is already safer, clearer, or more grounded than anything you can add: + +```json +{ "kind": "fallback" } +``` + ## Rules 1. Use only real objects from the context. A `target`, `item`, or `anchor` must match a visible entity or inventory title. @@ -81,3 +118,4 @@ For a direct question back to the player: 6. If uncertain, return `final_response` in character instead of inventing an unsafe action. 7. Never return JavaScript, TypeScript, shell commands, or executable code. 8. If an action cannot be performed, prefer a concise in-world reason through `final_response` or `showText`. +9. If you cannot safely improve a previous parser attempt, return `fallback`. diff --git a/public/text/system/parser-llm.json b/public/text/system/parser-llm.json new file mode 100644 index 0000000..95b1b1d --- /dev/null +++ b/public/text/system/parser-llm.json @@ -0,0 +1,33 @@ +{ + "previous_attempt_label": "Previous parser attempt:", + "forced_handoff_label": "Lower cascade interpretation:", + "forced_handoff_instructions": [ + "Cascade 1 test mode asks you to handle this command yourself.", + "Use the lower cascade interpretation as a hint for what the dry machine parser would do.", + "If you can give a richer, more atmospheric, and still grounded response, prefer final_response or showText.", + "If the lower cascade action is genuinely the best answer, you may return that action plan.", + "If you cannot improve the lower cascade result safely, return fallback." + ], + "post_api_escalation_instructions": [ + "The previous parser/game attempt escalated instead of completing.", + "First try to correct the player intent, target, relation, or action plan if the lower parser likely misunderstood the command.", + "Do not repeat the same failing action unless you intentionally corrected the target, relation, or intent.", + "If the requested action is impossible in the current world, return final_response or a showText action with a short in-world reason.", + "If you cannot improve the previous attempt safely, return fallback." + ], + "post_api_not_found_instructions": [ + "The previous parser/game attempt reported that it could not see the target. This often means the lower cascade misread a verb, adjective, or phrase fragment as the noun.", + "First try to correct the player intent or target by using only real visible or inventory titles from the context.", + "Do not repeat the same failing action unless you intentionally corrected the target, relation, or intent.", + "If no safe correction exists, return final_response with a short grounded in-world reason, or fallback if the standard parser response is better." + ], + "post_api_recovery_instructions": [ + "The previous parser/game attempt recognized a command but ended in a recoverable failed outcome.", + "First decide whether the lower parser likely misunderstood the player intent, target, relation, or action. If so, return a corrected safe action plan using only real context titles.", + "If the intent and target are correct but the game outcome says the action is impossible, do not override game state. Return final_response or showText with a short atmospheric in-world reason.", + "Do not claim that an action succeeded unless the returned plan can actually execute it.", + "If you cannot improve the previous attempt safely or interestingly, return fallback.", + "If the requested action is impossible in the current world, return final_response or showText with a short noir narrator response that explains the failure through tone, implication, sarcasm, or cynical observation rather than plain technical description." + ], + "response_reminder": "Respond with a single JSON object. Do not add any text outside the JSON." +} diff --git a/src/mechanics/LlmCascade.ts b/src/mechanics/LlmCascade.ts index e382a95..a832913 100644 --- a/src/mechanics/LlmCascade.ts +++ b/src/mechanics/LlmCascade.ts @@ -9,11 +9,12 @@ import type { } from './parserTypes'; const SYSTEM_PROMPT_URL = '/text/system/parser-llm-system.md'; +const PROMPT_ASSET_DOMAIN = 'parser-llm'; const FALLBACK_SYSTEM_PROMPT = [ 'You are a command-line parser and Game Master for a retro adventure game.', 'Respond with exactly one JSON object and no extra text.', - 'Return either {"kind":"plan","actions":[...]}, {"kind":"final_response","message":"..."}, or {"kind":"clarification","question":"..."}.', + 'Return either {"kind":"plan","actions":[...]}, {"kind":"final_response","message":"..."}, {"kind":"clarification","question":"..."}, or {"kind":"fallback"}.', 'Use only real titles from the provided context and only safe parser action types.', ].join('\n'); @@ -39,7 +40,11 @@ type ConsoleLike = { }; export type LlmCascadePreviousAttempt = { - kind?: 'post_api_escalation' | 'post_api_not_found' | 'forced_cascade_handoff'; + kind?: + | 'post_api_escalation' + | 'post_api_not_found' + | 'post_api_recovery' + | 'forced_cascade_handoff'; envelope: ParserCascadeEnvelope; result: unknown; }; @@ -78,7 +83,10 @@ export class LlmCascade { return null; } - const systemPrompt = await this.loadSystemPrompt(); + const [systemPrompt, promptAssets] = await Promise.all([ + this.loadSystemPrompt(), + this.loadPromptAssets(), + ]); const userMessage = [ `Player command: "${input}"`, '', @@ -87,29 +95,28 @@ export class LlmCascade { ...(previousAttempt ? [ '', - previousAttempt.kind === 'forced_cascade_handoff' - ? 'Lower cascade interpretation:' - : 'Previous parser attempt:', + this.promptText( + promptAssets, + previousAttempt.kind === 'forced_cascade_handoff' + ? 'forced_handoff_label' + : 'previous_attempt_label' + ), JSON.stringify(previousAttempt, null, 2), '', ...(previousAttempt.kind === 'forced_cascade_handoff' - ? [ - 'Cascade 1 test mode asks you to handle this command yourself.', - 'Use the lower cascade interpretation as a hint for what the dry machine parser would do.', - 'If you can give a richer, more atmospheric, and still grounded response, prefer final_response or showText.', - 'If the lower cascade action is genuinely the best answer, you may return that action plan.', - ] - : [ + ? this.promptList(promptAssets, 'forced_handoff_instructions') + : this.promptList( + promptAssets, previousAttempt.kind === 'post_api_not_found' - ? 'The previous parser/game attempt reported that it could not see the target. This often means the lower cascade misread a verb, adjective, or phrase fragment as the noun.' - : 'The previous parser/game attempt escalated instead of completing.', - 'Do not repeat the same failing action unless you intentionally corrected the target, relation, or intent.', - 'If the requested action is impossible in the current world, return final_response or a showText action with a short in-world reason.', - ]), + ? 'post_api_not_found_instructions' + : previousAttempt.kind === 'post_api_recovery' + ? 'post_api_recovery_instructions' + : 'post_api_escalation_instructions' + )), ] : []), '', - 'Respond with a single JSON object. Do not add any text outside the JSON.', + this.promptText(promptAssets, 'response_reminder'), ].join('\n'); const messages = [{ role: 'user' as const, content: userMessage }]; @@ -167,9 +174,16 @@ export class LlmCascade { extractedJson, acceptedActions: normalized.actions, filteredActions: normalized.filteredActions, - reason: normalized.actions.length > 0 ? undefined : 'invalid_response', + reason: + normalized.actions.length > 0 + ? undefined + : normalized.fallback + ? 'fallback' + : 'invalid_response', error: - normalized.actions.length > 0 ? undefined : 'LLM response did not contain valid actions', + normalized.actions.length > 0 || normalized.fallback + ? undefined + : 'LLM response did not contain valid actions', }; if (!normalized.actions.length) { @@ -232,6 +246,29 @@ export class LlmCascade { return this.systemPromptCache; } + private async loadPromptAssets(): Promise> { + const textAssets = this.getTextAssets(); + if (!textAssets?.readServiceAsset) return {}; + try { + return await textAssets.readServiceAsset(PROMPT_ASSET_DOMAIN); + } catch (error) { + this.getConsole()?.log(`[LLM prompt asset fallback] ${String(error)}`, 'info'); + return {}; + } + } + + private promptText(assets: Record, key: string): string { + const value = assets[key]; + return typeof value === 'string' ? value : ''; + } + + private promptList(assets: Record, key: string): string[] { + const value = assets[key]; + if (Array.isArray(value)) return value.filter((item) => typeof item === 'string'); + if (typeof value === 'string') return [value]; + return []; + } + private extractJson(text: string): string { const fenceMatch = /```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/.exec(text); if (fenceMatch) return fenceMatch[1].trim(); @@ -251,27 +288,36 @@ export class LlmCascade { private normalizeResponse(parsed: unknown): { actions: ParserToolAction[]; filteredActions: unknown[]; + fallback: boolean; } { if (!this.isRecord(parsed)) { - return { actions: [], filteredActions: [parsed] }; + return { actions: [], filteredActions: [parsed], fallback: false }; + } + + if (parsed.kind === 'fallback') { + return { actions: [], filteredActions: [], fallback: true }; } if (parsed.kind === 'final_response') { const message = typeof parsed.message === 'string' ? parsed.message.trim() : ''; return message - ? { actions: [{ type: 'showText', message }], filteredActions: [] } - : { actions: [], filteredActions: [parsed] }; + ? { actions: [{ type: 'showText', message }], filteredActions: [], fallback: false } + : { actions: [], filteredActions: [parsed], fallback: false }; } if (parsed.kind === 'clarification') { const question = typeof parsed.question === 'string' ? parsed.question.trim() : ''; return question - ? { actions: [{ type: 'showText', message: question }], filteredActions: [] } - : { actions: [], filteredActions: [parsed] }; + ? { + actions: [{ type: 'showText', message: question }], + filteredActions: [], + fallback: false, + } + : { actions: [], filteredActions: [parsed], fallback: false }; } if (parsed.kind !== 'plan' || !Array.isArray(parsed.actions)) { - return { actions: [], filteredActions: [parsed] }; + return { actions: [], filteredActions: [parsed], fallback: false }; } const actions: ParserToolAction[] = []; @@ -286,7 +332,7 @@ export class LlmCascade { } } - return { actions, filteredActions }; + return { actions, filteredActions, fallback: false }; } private validateAction(action: unknown): ParserToolAction | null { diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index e6a9e6f..15e33cd 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -152,9 +152,7 @@ export class Parser { llmAttempted = true; const parsedResult = this.safeParseJson(resultJson); const llmEnvelope = await this.runLlmCascade(trimmed, context, { - kind: this.resultHasEscalation(parsedResult) - ? 'post_api_escalation' - : 'post_api_not_found', + kind: this.getPostApiLlmRetryKind(parsedResult), envelope, result: parsedResult, }); @@ -1254,7 +1252,11 @@ export class Parser { private resultShouldRetryWithLlm(resultJson: string): boolean { try { const result = JSON.parse(resultJson) as ParserResult; - return this.resultHasEscalation(result) || this.resultHasSoftNotFoundFailure(result); + return ( + this.resultHasEscalation(result) || + this.resultHasSoftNotFoundFailure(result) || + this.resultHasRecoverableFailureForLlm(result) + ); } catch { return false; } @@ -1267,6 +1269,12 @@ export class Parser { ); } + private getPostApiLlmRetryKind(result: unknown): LlmCascadePreviousAttempt['kind'] { + if (this.resultHasEscalation(result)) return 'post_api_escalation'; + if (this.resultHasSoftNotFoundFailure(result)) return 'post_api_not_found'; + return 'post_api_recovery'; + } + private resultHasSoftNotFoundFailure(result: unknown): boolean { if (!this.isParserOutcomeResult(result)) return false; return result.outcomes.some((outcome) => { @@ -1284,6 +1292,29 @@ export class Parser { }); } + private resultHasRecoverableFailureForLlm(result: unknown): boolean { + if (!this.isParserOutcomeResult(result)) return false; + const recoveryCodes = new Set([ + 'cannot_take', + 'not_takeable', + 'inventory_not_accessible', + 'put_target_is_source', + 'put_item_not_held', + 'put_target_not_accessible', + 'put_target_not_found', + 'relation_not_supported', + 'destination_not_found', + 'custom_command_invalid_argument', + 'custom_command_target_too_far', + 'take_group_invalid_both', + ]); + + return result.outcomes.some((outcome) => { + if (outcome.status !== 'failed' || outcome.recoverable === false) return false; + return recoveryCodes.has(String(outcome.code || '')); + }); + } + private isParserOutcomeResult( result: unknown ): result is Extract { diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index afc0c48..9d7ef52 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -335,5 +335,11 @@ export type LlmCascadeDebugInfo = { acceptedActions?: ParserToolAction[]; filteredActions?: unknown[]; error?: string; - reason?: 'provider_unavailable' | 'api_error' | 'invalid_response' | 'timeout' | 'disabled'; + reason?: + | 'provider_unavailable' + | 'api_error' + | 'invalid_response' + | 'timeout' + | 'disabled' + | 'fallback'; }; diff --git a/tests/parser/llm-cascade.test.ts b/tests/parser/llm-cascade.test.ts index 1401d3a..fac1682 100644 --- a/tests/parser/llm-cascade.test.ts +++ b/tests/parser/llm-cascade.test.ts @@ -44,6 +44,33 @@ class MockProvider implements ILlmProvider { describe('LlmCascade', () => { let provider: MockProvider; let cascade: LlmCascade; + const mockPromptAssets = { + previous_attempt_label: 'Previous parser attempt:', + forced_handoff_label: 'Lower cascade interpretation:', + forced_handoff_instructions: [ + 'Cascade 1 test mode asks you to handle this command yourself.', + 'Use the lower cascade interpretation as a hint for what the dry machine parser would do.', + 'If you can give a richer, more atmospheric, and still grounded response, prefer final_response or showText.', + 'If the lower cascade action is genuinely the best answer, you may return that action plan.', + 'If you cannot improve the lower cascade result safely, return fallback.', + ], + post_api_escalation_instructions: [ + 'The previous parser/game attempt escalated instead of completing.', + 'Do not repeat the same failing action unless you intentionally corrected the target, relation, or intent.', + 'If the requested action is impossible in the current world, return final_response or a showText action with a short in-world reason.', + ], + post_api_not_found_instructions: [ + 'The previous parser/game attempt reported that it could not see the target. This often means the lower cascade misread a verb, adjective, or phrase fragment as the noun.', + 'Do not repeat the same failing action unless you intentionally corrected the target, relation, or intent.', + ], + post_api_recovery_instructions: [ + 'The previous parser/game attempt recognized a command but ended in a recoverable failed outcome.', + 'First decide whether the lower parser likely misunderstood the player intent, target, relation, or action.', + 'If the intent and target are correct but the game outcome says the action is impossible, do not override game state.', + 'If you cannot improve the previous attempt safely or interestingly, return fallback.', + ], + response_reminder: 'Respond with a single JSON object. Do not add any text outside the JSON.', + }; const mockContext: ParserContext = { rawInput: '', normalizedInput: '', @@ -56,7 +83,10 @@ describe('LlmCascade', () => { provider = new MockProvider(); cascade = new LlmCascade( provider, - () => undefined, + () => + ({ + readServiceAsset: vi.fn().mockResolvedValue(mockPromptAssets), + }) as any, () => undefined ); }); @@ -121,6 +151,19 @@ describe('LlmCascade', () => { ]); }); + it('treats explicit fallback as no LLM envelope', async () => { + provider.response.text = JSON.stringify({ kind: 'fallback' }); + + const result = await cascade.parse('take book', mockContext); + + expect(result).toBeNull(); + const debug = cascade.getLastDebugInfo(); + expect(debug?.matched).toBe(false); + expect(debug?.reason).toBe('fallback'); + expect(debug?.error).toBeUndefined(); + expect(debug?.filteredActions).toEqual([]); + }); + it('returns null and populates debug reason invalid_response for invalid JSON', async () => { provider.response.text = 'Not a JSON'; @@ -265,6 +308,39 @@ describe('LlmCascade', () => { expect(userMessage).toContain('richer, more atmospheric'); expect(userMessage).toContain('you may return that action plan'); }); + + it('includes recovery instructions for recoverable failed parser attempts', async () => { + provider.response.text = JSON.stringify({ + kind: 'fallback', + }); + + await cascade.parse('take book', mockContext, undefined, { + kind: 'post_api_recovery', + envelope: { + stage: 'regex-v1', + output: { + kind: 'plan', + actions: [{ type: 'takeTarget', target: 'Book' }], + }, + debug: { + rawInput: 'take book', + normalizedInput: 'TAKE BOOK', + verb: 'TAKE', + noun: 'book', + }, + }, + result: { + type: 'outcomes', + outcomes: [{ status: 'failed', code: 'cannot_take', message: 'You cannot take that.' }], + }, + }); + + const userMessage = provider.messages[0]?.content || ''; + expect(userMessage).toContain('Previous parser attempt'); + expect(userMessage).toContain('cannot_take'); + expect(userMessage).toContain('recoverable failed outcome'); + expect(userMessage).toContain('return fallback'); + }); }); describe('AnthropicProvider', () => { diff --git a/tests/parser/llm-parser.test.ts b/tests/parser/llm-parser.test.ts index c6c6d32..b82c87c 100644 --- a/tests/parser/llm-parser.test.ts +++ b/tests/parser/llm-parser.test.ts @@ -185,4 +185,66 @@ describe('Parser LLM Integration', () => { expect(previousAttempt?.result?.outcomes?.[0]?.code).toBe('entity_not_found'); expect(fixture.messages).toContain('A small red key.'); }); + + it('Parser calls llmCascade after a recoverable standard command failure', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.game.console.parserLlmEnabled = true; + fixture.addEntity('book', { + title: 'Book', + description: 'A heavy reference book.', + }); + + const mockLlmParse = vi.fn().mockResolvedValue({ + stage: 'llm-v3', + output: { + kind: 'plan', + actions: [ + { + type: 'showText', + message: 'The book has done its talking. Now it is your turn to move.', + }, + ], + }, + debug: { + rawInput: 'take book', + normalizedInput: 'TAKE BOOK', + verb: 'LLM', + noun: '', + }, + }); + fixture.parser.llmCascade.parse = mockLlmParse; + + await fixture.parser.parse('take book'); + + expect(mockLlmParse).toHaveBeenCalled(); + const previousAttempt = mockLlmParse.mock.calls[0]?.[3]; + expect(previousAttempt?.kind).toBe('post_api_recovery'); + expect(previousAttempt?.result?.outcomes?.[0]?.code).toBe('not_takeable'); + expect(fixture.messages).toContain( + 'The book has done its talking. Now it is your turn to move.' + ); + expect(fixture.messages).not.toContain(fixture.game.text('parser.take_cannot')); + }); + + it('keeps the standard parser failure when LLM recovery returns no envelope', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.game.console.parserLlmEnabled = true; + fixture.addEntity('book', { + title: 'Book', + description: 'A heavy reference book.', + }); + + const mockLlmParse = vi.fn().mockResolvedValue(null); + fixture.parser.llmCascade.parse = mockLlmParse; + + await fixture.parser.parse('take book'); + + expect(mockLlmParse).toHaveBeenCalled(); + const previousAttempt = mockLlmParse.mock.calls[0]?.[3]; + expect(previousAttempt?.kind).toBe('post_api_recovery'); + expect(previousAttempt?.result?.outcomes?.[0]?.code).toBe('not_takeable'); + expect(fixture.messages).toContain(fixture.game.text('parser.take_cannot')); + }); }); From 51e64b61fbacc68271d6ca7a629fce51faafc4b6 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Wed, 6 May 2026 02:17:22 +0200 Subject: [PATCH 18/50] Add TA-driven semantic facts for LLM parser context --- Parser.md | 78 ++++++- TextAssets.md | 132 ++++++++++++ public/text/objects/boombox.json | 10 +- public/text/objects/test.json | 3 +- public/text/objects/test_1.json | 3 +- public/text/system/parser-llm-system.md | 1 + public/text/system/parser-llm.json | 7 + src/core/TextAssetManager.ts | 39 +++- src/mechanics/LlmCascade.ts | 7 +- src/mechanics/ParserWorldModelBuilder.ts | 255 +++++++++++++++++++++++ src/mechanics/parserTypes.ts | 16 ++ tests/fixtures/sceneFactory.ts | 4 + tests/fixtures/textAssetFactory.ts | 11 + tests/parser/llm-cascade.test.ts | 5 + tests/parser/world-model-context.test.ts | 92 ++++++++ 15 files changed, 650 insertions(+), 13 deletions(-) diff --git a/Parser.md b/Parser.md index f392d16..fe779f5 100644 --- a/Parser.md +++ b/Parser.md @@ -131,6 +131,7 @@ flowchart TD - список текстово значимых объектов сцены; - отдельный список `knownEntities` для объектов, известных движку, но не раскрытых player-facing текстовому слою; - инвентарь игрока; +- `worldFacts`: краткие авторитетные факты о переноске, containment/location и TA-driven semantic relations; - spatial nodes and relation projection, derived from the runtime scene hierarchy; - `pending state`, если parser уже ждёт уточнение. @@ -153,6 +154,14 @@ flowchart TD - он не определяет target; - он лишь даёт parser-у картину мира. +`worldFacts` deliberately combine generic runtime facts and authored semantic facts: + +- generic: `Player carries ID card.`, `Boombox contains Compact cassette.`, `Compact cassette is inside Boombox.`; +- authored semantic: `Boombox already has Compact cassette loaded.`, `Car has Gasoline in the tank.`; +- semantic facts come from object Text Assets, not from hardcoded parser heuristics. + +For v1, semantic facts only improve LLM context. They do not create real command mechanics or runtime effects. + Scope slices intentionally separate knowledge from actionability: - `visible` means the player-facing text layer may refer to the object; @@ -656,6 +665,8 @@ Parser: Но и новое опциональное поле: - `synonyms` +- `semanticTags` +- `relationFacts` Пример: @@ -664,7 +675,8 @@ Parser: "title": "logo", "description": "You see Scanline Engine logo.", "details": "Extended description here.", - "synonyms": ["logotype", "emblem", "scanline symbol"] + "synonyms": ["logotype", "emblem", "scanline symbol"], + "semanticTags": ["signage", "brand_marker"] } ``` @@ -680,6 +692,65 @@ Parser: - используется действием `EXAMINE`; - тоже входит в стандартный шаблон нового object TA. +Поля `semanticTags` и `relationFacts`: + +- являются authoring metadata для `ParserWorldModelBuilder`; +- генерируют `worldFacts` для Stage 2 LLM context; +- не являются player-facing текстом; +- не добавляют новые команды или runtime effects сами по себе. + +`semanticTags` задаются на объекте-ребёнке или любом объекте, которому нужен смысловой маркер: + +```json +{ + "title": "Compact cassette", + "synonyms": ["tape", "cassette"], + "semanticTags": ["media", "audio_media", "cassette"] +} +``` + +`relationFacts` задаются на parent/container/device объекте: + +```json +{ + "title": "Boombox", + "relationFacts": [ + { + "relation": "in", + "childTags": ["media", "audio_media"], + "fact": "{self} already has {child} loaded." + } + ] +} +``` + +Если объект с тегом `media` или `audio_media` находится `in` Boombox, в LLM context появится semantic world fact: + +```text +Boombox already has Compact cassette loaded. +``` + +Та же модель должна использоваться для других доменов: + +```json +{ + "title": "Car", + "relationFacts": [ + { + "relation": "in", + "childTags": ["fuel"], + "fact": "{self} has {child} in the tank." + } + ] +} +``` + +Supported placeholders in `fact`: + +- `{self}` - resolved title parent-объекта; +- `{child}` - resolved title child-объекта; +- `{relation}` - matched relation. + Стандартный шаблон нового object TA: ```json @@ -687,7 +758,9 @@ Parser: "title": "Object", "description": "You see nothing special.", "details": "", - "synonyms": [] + "synonyms": [], + "semanticTags": [], + "relationFacts": [] } ``` @@ -1121,6 +1194,7 @@ Parser должен быть локализуемым без переписыв - `public/text/system/parser-lexicon.json` — stage1 lexicon и normalization vocabulary; - `public/text/system/parser-training.json` — training phrases для NLP-слоя. - `public/text/system/commands/*.json` — custom command assets; +- `public/text/objects/*.json` — object title/description/details/synonyms plus optional semantic tags and relation facts for LLM context; - `Commands.md` — формат и принципы command TA. Текущее применение: diff --git a/TextAssets.md b/TextAssets.md index 6705b38..6b973fc 100644 --- a/TextAssets.md +++ b/TextAssets.md @@ -32,6 +32,12 @@ Object asset: - `title` - `description` +- `details` +- `synonyms` +- `semanticTags` +- `relationFacts` + +Minimal object assets may still contain only `title` and `description`. Missing optional fields are treated as empty. ## Custom text variants @@ -69,6 +75,132 @@ Scripts do not generate text themselves. They only change which named text field - `title` maps to the user-facing object or scene name. - `description` maps to the basic text used by parser/runtime for `look` or `look around`. +- `details` maps to the richer text used by parser/runtime for `examine`. +- `synonyms` helps the parser and LLM map player wording to this object. - Existing runtime fields remain as fallback and for backward compatibility. - Parser and UI should read only the resolved standard fields, not custom variant names directly. - The LLM parser cascade receives resolved parser/world context plus the system prompt asset; it should not read arbitrary scene files directly. + +## Object semantic fields for LLM context + +Object Text Assets can describe lightweight semantic knowledge for the Stage 2 LLM cascade. + +This is authoring metadata for context generation only. It does not add real runtime mechanics, command verbs, scripts, state changes, or game effects. + +### `semanticTags` + +`semanticTags` is an optional string array on an object asset. + +It describes what the object is in a reusable way: + +```json +{ + "title": "Compact cassette", + "description": "You see a compact cassette.", + "synonyms": ["tape", "cassette"], + "semanticTags": ["media", "audio_media", "cassette"] +} +``` + +Tags are used by `ParserWorldModelBuilder` when generating `worldFacts` for the LLM prompt. They are not player-facing text and should be short, stable, lowercase identifiers. + +Recommended style: + +- use broad tags and specific tags together, for example `media`, `audio_media`, `cassette`; +- reuse the same tags across scenes for the same concept; +- prefer semantic role over implementation detail, for example `fuel`, `keycard`, `power_cell`, `light_source`; +- avoid putting prose in tags. + +### `relationFacts` + +`relationFacts` is an optional array on a parent/container/device object asset. + +Each rule says: when this object has a child in a relation, and the child has one of the required tags, add a concise authoritative world fact for the LLM. + +Shape: + +```json +{ + "relation": "in", + "childTags": ["media", "audio_media"], + "fact": "{self} already has {child} loaded." +} +``` + +Supported `relation` values: + +- `in` +- `on` +- `under` +- `behind` + +`childTags` matches if the child has at least one listed tag. If `childTags` is empty or missing, the rule applies to every child in that relation. + +`fact` supports these placeholders: + +- `{self}` - the parent object's resolved `title`; +- `{child}` - the child object's resolved `title`; +- `{relation}` - the matched relation. + +Example: media player + +```json +{ + "title": "Boombox", + "synonyms": ["recorder", "radio", "tape recorder"], + "semanticTags": ["device", "audio_device", "media_player"], + "relationFacts": [ + { + "relation": "in", + "childTags": ["media", "audio_media"], + "fact": "{self} already has {child} loaded." + } + ] +} +``` + +If `Compact cassette` is inside `Boombox`, the LLM context gets: + +```text +Boombox contains Compact cassette. +Compact cassette is inside Boombox. +Boombox already has Compact cassette loaded. +``` + +Example: vehicle fuel + +```json +{ + "title": "Car", + "semanticTags": ["vehicle"], + "relationFacts": [ + { + "relation": "in", + "childTags": ["fuel"], + "fact": "{self} has {child} in the tank." + } + ] +} +``` + +```json +{ + "title": "Gasoline", + "semanticTags": ["fuel", "liquid"] +} +``` + +If `Gasoline` is inside `Car`, the LLM context gets: + +```text +Car contains Gasoline. +Gasoline is inside Car. +Car has Gasoline in the tank. +``` + +### Important limitations + +- These facts are for LLM context only. +- They do not make unsupported commands executable. For example, `PLAY`, `DRIVE`, or `FUEL` still need real command/runtime support if they should change game state. +- They help the LLM avoid contradicting the world. For example, it should not say a cassette is missing if `worldFacts` says it is already loaded. +- Keep facts concise and factual. Tone, sarcasm, and atmospheric refusal text belong in LLM responses, not in semantic facts. diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json index 9adb742..6918e30 100644 --- a/public/text/objects/boombox.json +++ b/public/text/objects/boombox.json @@ -2,5 +2,13 @@ "title": "Boombox", "description": "The Sharp - small but toothy radio and cassette recorder, connected to the computer.", "details": "The Sharp GF-7 boombox is connected to the computer using regular audio cables. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. And with the ability to record songs from the radio or capture live audio using the external microphones, the possibilities are endless. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape.", - "synonyms": ["recorder", "radio", "tape recorder", "Sharp", "GF-7"] + "synonyms": ["recorder", "radio", "tape recorder", "Sharp", "GF-7"], + "semanticTags": ["device", "audio_device", "media_player"], + "relationFacts": [ + { + "relation": "in", + "childTags": ["media", "audio_media"], + "fact": "{self} already has {child} loaded." + } + ] } diff --git a/public/text/objects/test.json b/public/text/objects/test.json index 045f5d3..d2e784a 100644 --- a/public/text/objects/test.json +++ b/public/text/objects/test.json @@ -1,5 +1,6 @@ { "title": "Compact cassette", "description": "You see a compact cassette", - "synonyms": ["tape", "cassette", "cassete", "record"] + "synonyms": ["tape", "cassette", "cassete", "record"], + "semanticTags": ["media", "audio_media", "cassette"] } diff --git a/public/text/objects/test_1.json b/public/text/objects/test_1.json index 4f3889f..551ab11 100644 --- a/public/text/objects/test_1.json +++ b/public/text/objects/test_1.json @@ -1,5 +1,6 @@ { "title": "Cassette 'Music'", "description": "You see a compact cassette labeled 'Music'", - "synonyms": ["tape", "cassette", "cassete", "casete", "record", "music"] + "synonyms": ["tape", "cassette", "cassete", "casete", "record", "music"], + "semanticTags": ["media", "audio_media", "cassette"] } diff --git a/public/text/system/parser-llm-system.md b/public/text/system/parser-llm-system.md index 07cf906..a90d70b 100644 --- a/public/text/system/parser-llm-system.md +++ b/public/text/system/parser-llm-system.md @@ -60,6 +60,7 @@ You receive the player's command and a JSON snapshot of the current game world: - Current scene title and description. - Visible entities with titles, descriptions, details, and synonyms. - Player inventory. +- World facts: concise authoritative facts about current locations, containment, and Text Asset semantic relations. Check these before saying a required thing is missing, not loaded, not inserted, not fueled, empty, or unavailable. - Spatial nodes and relations. - Pending parser state, if any. diff --git a/public/text/system/parser-llm.json b/public/text/system/parser-llm.json index 95b1b1d..f917045 100644 --- a/public/text/system/parser-llm.json +++ b/public/text/system/parser-llm.json @@ -29,5 +29,12 @@ "If you cannot improve the previous attempt safely or interestingly, return fallback.", "If the requested action is impossible in the current world, return final_response or showText with a short noir narrator response that explains the failure through tone, implication, sarcasm, or cynical observation rather than plain technical description." ], + "world_fact_instructions": [ + "World facts are authoritative, including semantic facts generated from Text Assets.", + "Semantic world facts describe current game state, not suggestions, guesses, or flavor text.", + "Before saying that a required object is missing, not loaded, not inserted, not fueled, empty, or unavailable, check worldFacts and entity contents/location.", + "If a world fact says an object already has, contains, holds, carries, or is otherwise supplied with the needed thing, do not redirect the player to fetch a different object just because names or synonyms partially match.", + "If the requested action is unsupported by the available parser actions, respond as a narrator about the attempted action or limitation while still respecting worldFacts." + ], "response_reminder": "Respond with a single JSON object. Do not add any text outside the JSON." } diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 4cd1f60..5c77de9 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -9,7 +9,19 @@ import { saveProjectFile, } from '../platform/fileApi'; -type TextAssetValue = string | string[]; +export type TextAssetStructuredValue = + | string + | number + | boolean + | null + | TextAssetStructuredValue[] + | { [key: string]: TextAssetStructuredValue }; + +type TextAssetValue = + | string + | string[] + | TextAssetStructuredValue[] + | Record; type TextAssetData = Record; export type SceneTextAssetData = TextAssetData & { title?: string; @@ -20,6 +32,8 @@ export type ObjectTextAssetData = TextAssetData & { description?: string; details?: string; synonyms?: string[]; + semanticTags?: string[]; + relationFacts?: TextAssetStructuredValue[]; }; const DEFAULT_SERVICE_ASSETS: Record = { @@ -669,6 +683,18 @@ export class TextAssetManager { return this.resolveListField(asset, field); } + getResolvedObjectStructuredListField( + obj: SceneObject, + field: string, + normalize: (value: unknown) => T | null + ): T[] { + const objectId = this.normalizeId(obj?.name || ''); + const asset = objectId ? this.objectCache.get(objectId) : null; + const raw = asset?.[field]; + if (!Array.isArray(raw)) return []; + return raw.map((item) => normalize(item)).filter((item): item is T => item !== null); + } + getServiceText(key: string, params?: Record, fallback?: string): string { const rawKey = String(key || '').trim(); if (!rawKey) return fallback || ''; @@ -724,10 +750,13 @@ export class TextAssetManager { private resolveListField(asset: TextAssetData | null | undefined, field: string): string[] { const raw = asset?.[field]; if (!Array.isArray(raw)) return []; - return raw - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean); + const values: string[] = []; + for (const item of raw) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) values.push(trimmed); + } + return values; } private async fetchJson(url: string): Promise { diff --git a/src/mechanics/LlmCascade.ts b/src/mechanics/LlmCascade.ts index a832913..45d0621 100644 --- a/src/mechanics/LlmCascade.ts +++ b/src/mechanics/LlmCascade.ts @@ -92,6 +92,7 @@ export class LlmCascade { '', 'Game world context:', JSON.stringify(context, null, 2), + ...this.promptList(promptAssets, 'world_fact_instructions'), ...(previousAttempt ? [ '', @@ -246,7 +247,7 @@ export class LlmCascade { return this.systemPromptCache; } - private async loadPromptAssets(): Promise> { + private async loadPromptAssets(): Promise> { const textAssets = this.getTextAssets(); if (!textAssets?.readServiceAsset) return {}; try { @@ -257,12 +258,12 @@ export class LlmCascade { } } - private promptText(assets: Record, key: string): string { + private promptText(assets: Record, key: string): string { const value = assets[key]; return typeof value === 'string' ? value : ''; } - private promptList(assets: Record, key: string): string[] { + private promptList(assets: Record, key: string): string[] { const value = assets[key]; if (Array.isArray(value)) return value.filter((item) => typeof item === 'string'); if (typeof value === 'string') return [value]; diff --git a/src/mechanics/ParserWorldModelBuilder.ts b/src/mechanics/ParserWorldModelBuilder.ts index fac4a27..05ce85c 100644 --- a/src/mechanics/ParserWorldModelBuilder.ts +++ b/src/mechanics/ParserWorldModelBuilder.ts @@ -6,15 +6,31 @@ import { createSceneTextLayerQuery, getSceneTextLayerAccessState } from '../scen import { ComponentSystem } from '../systems/ComponentSystem'; import type { ParserContext, + ParserEntityContentContext, ParserEntityContext, + ParserEntityLocationContext, ParserInventoryItemContext, ParserPendingState, + ParserRelationType, ParserScope, ParserSpatialNodeContext, ParserSpatialRelationContext, ParserWorldModel, } from './parserTypes'; +type ParserSemanticRelationFact = { + relation: Exclude; + childTags: string[]; + fact: string; +}; + +const SEMANTIC_RELATIONS: Array = [ + 'on', + 'under', + 'in', + 'behind', +]; + export class ParserWorldModelBuilder { private readonly game: Game; @@ -42,6 +58,7 @@ export class ParserWorldModelBuilder { const entities = scene ? this.buildEntityContexts(scene) : []; const knownEntities = scene ? this.buildKnownEntityContexts(scene) : []; const inventory = this.buildInventoryContexts(); + const worldFacts = scene ? this.buildWorldFacts(scene, entities, inventory) : []; const spatialRelations = scene ? this.buildSpatialRelations(scene) : []; const spatialNodes = scene ? this.buildSpatialNodes(scene) : []; const pending = pendingState @@ -60,6 +77,7 @@ export class ParserWorldModelBuilder { entities, knownEntities, inventory, + worldFacts, spatialNodes, spatialRelations, pending, @@ -104,9 +122,15 @@ export class ParserWorldModelBuilder { id: sceneObject.name, title: entry.title, item: isItem || undefined, + location: this.buildEntityLocationContext(entry, textLayer), + contents: this.buildEntityContentsContext(sceneObject.name, textLayer), reachable, ...coordinates, synonyms, + semanticTags: this.game.textAssets.getResolvedObjectListField( + sceneObject as any, + 'semanticTags' + ), description: this.game.textAssets.getResolvedObjectField(sceneObject as any, 'description') || undefined, @@ -119,6 +143,7 @@ export class ParserWorldModelBuilder { } private buildKnownEntityContexts(scene: Scene): ParserEntityContext[] { + const textLayer = createSceneTextLayerQuery(scene, this.game); const visibleIds = new Set(this.buildEntityContexts(scene).map((entity) => entity.id)); return scene .getAllSceneObjects() @@ -136,6 +161,12 @@ export class ParserWorldModelBuilder { id: sceneObject.name, title, item: isItem || undefined, + location: this.buildLocationContext( + accessState.effectiveParentId, + accessState.effectiveRelation, + textLayer + ), + contents: this.buildEntityContentsContext(sceneObject.name, textLayer), visibility: accessState.hidden ? 'hidden' : 'visible', accessibility: accessState.blocked ? 'blocked' @@ -144,6 +175,10 @@ export class ParserWorldModelBuilder { : undefined, hiddenReason: accessState.hiddenReason || undefined, synonyms: this.game.textAssets.getResolvedObjectListField(sceneObject as any, 'synonyms'), + semanticTags: this.game.textAssets.getResolvedObjectListField( + sceneObject as any, + 'semanticTags' + ), description: this.game.textAssets.getResolvedObjectField(sceneObject as any, 'description') || undefined, @@ -172,6 +207,226 @@ export class ParserWorldModelBuilder { .filter((entity): entity is ParserInventoryItemContext => !!entity); } + private buildEntityLocationContext( + entry: ReturnType['entries'][number], + textLayer: ReturnType + ): ParserEntityLocationContext | undefined { + return this.buildLocationContext(entry.effectiveParentId, entry.effectiveRelation, textLayer); + } + + private buildLocationContext( + parentId: string | null, + relation: ParserEntityLocationContext['relation'] | null, + textLayer: ReturnType + ): ParserEntityLocationContext | undefined { + if (!parentId || !relation) return undefined; + const parentEntry = textLayer.entryById.get(parentId); + return this.compactRecord({ + relation, + parentId, + parentTitle: parentEntry?.title || undefined, + }); + } + + private buildEntityContentsContext( + entityId: string, + textLayer: ReturnType + ): ParserEntityContentContext[] { + const relationMap = textLayer.childrenByParentAndRelation.get(entityId); + if (!relationMap) return []; + + const contents: ParserEntityContentContext[] = []; + for (const [relation] of relationMap.entries()) { + for (const child of textLayer.getRelationDescendants(entityId, relation)) { + contents.push( + this.compactRecord({ + relation, + id: child.object.name, + title: child.title, + }) + ); + } + } + return contents; + } + + private buildWorldFacts( + scene: Scene, + entities: ParserEntityContext[], + inventory: ParserInventoryItemContext[] + ): string[] { + const facts: string[] = []; + const titleById = new Map(entities.map((entity) => [entity.id, entity.title] as const)); + const semanticTagsById = new Map( + entities.map((entity) => [entity.id, entity.semanticTags || []] as const) + ); + + for (const item of inventory) { + facts.push(`Player carries ${item.title}.`); + } + + for (const entity of entities) { + if (entity.location?.parentTitle) { + facts.push( + this.formatLocationFact( + entity.title, + entity.location.relation, + entity.location.parentTitle + ) + ); + } + + if (entity.contents?.length) { + const contentsByRelation = new Map(); + for (const content of entity.contents) { + const title = titleById.get(content.id) || content.title; + const titles = contentsByRelation.get(content.relation) || []; + titles.push(title); + contentsByRelation.set(content.relation, titles); + } + + for (const [relation, titles] of contentsByRelation.entries()) { + facts.push(this.formatContentsFact(entity.title, relation, titles)); + } + + for (const semanticFact of this.buildSemanticRelationFacts( + scene, + entity, + entity.contents, + titleById, + semanticTagsById + )) { + facts.push(semanticFact); + } + } + } + + if (scene.activeSubscene) { + facts.push(`Active subscene is ${scene.activeSubscene}.`); + } + + return Array.from(new Set(facts)); + } + + private buildSemanticRelationFacts( + scene: Scene, + entity: ParserEntityContext, + contents: ParserEntityContentContext[], + titleById: Map, + semanticTagsById: Map + ): string[] { + const sceneObject = scene.getObjectByName(entity.id); + if (!sceneObject) return []; + + const relationFacts = this.getObjectRelationFacts(sceneObject); + if (!relationFacts.length) return []; + + const facts: string[] = []; + for (const relationFact of relationFacts) { + for (const content of contents) { + if (content.relation !== relationFact.relation) continue; + const childTags = semanticTagsById.get(content.id) || []; + if (!this.semanticTagsMatch(childTags, relationFact.childTags)) continue; + facts.push( + this.interpolateSemanticFact(relationFact.fact, { + self: entity.title, + child: titleById.get(content.id) || content.title, + relation: content.relation, + }) + ); + } + } + + return facts; + } + + private getObjectRelationFacts(sceneObject: SceneObject): ParserSemanticRelationFact[] { + const accessor = (this.game.textAssets as any).getResolvedObjectStructuredListField; + if (typeof accessor !== 'function') return []; + return accessor.call(this.game.textAssets, sceneObject, 'relationFacts', (value: unknown) => + this.normalizeSemanticRelationFact(value) + ); + } + + private normalizeSemanticRelationFact(value: unknown): ParserSemanticRelationFact | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const relation = String(record.relation || '').trim(); + const fact = typeof record.fact === 'string' ? record.fact.trim() : ''; + if (!SEMANTIC_RELATIONS.includes(relation as ParserSemanticRelationFact['relation']) || !fact) { + return null; + } + const childTags = Array.isArray(record.childTags) + ? record.childTags + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean) + : []; + return { + relation: relation as ParserSemanticRelationFact['relation'], + childTags, + fact, + }; + } + + private semanticTagsMatch(childTags: string[], requiredTags: string[]): boolean { + if (!requiredTags.length) return true; + const normalizedChildTags = new Set(childTags.map((tag) => tag.trim().toLowerCase())); + return requiredTags.some((tag) => normalizedChildTags.has(tag)); + } + + private interpolateSemanticFact( + template: string, + params: Record<'self' | 'child' | 'relation', string> + ): string { + return template.replace(/\{(self|child|relation)\}/g, (_match, token: keyof typeof params) => { + return params[token]; + }); + } + + private formatLocationFact( + title: string, + relation: ParserEntityLocationContext['relation'], + parentTitle: string + ): string { + if (relation === 'in' && this.isFloorTitle(parentTitle)) { + return `${title} is on ${parentTitle}.`; + } + switch (relation) { + case 'in': + return `${title} is inside ${parentTitle}.`; + case 'on': + return `${title} is on ${parentTitle}.`; + case 'under': + return `${title} is under ${parentTitle}.`; + case 'behind': + return `${title} is behind ${parentTitle}.`; + } + } + + private formatContentsFact(title: string, relation: string, contents: string[]): string { + const listed = contents.join(', '); + if (relation === 'in' && this.isFloorTitle(title)) { + return `${listed} is on ${title}.`; + } + switch (relation) { + case 'in': + return `${title} contains ${listed}.`; + case 'on': + return `${listed} is on ${title}.`; + case 'under': + return `${listed} is under ${title}.`; + case 'behind': + return `${listed} is behind ${title}.`; + default: + return `${title} is related to ${listed}.`; + } + } + + private isFloorTitle(title: string): boolean { + return title.trim().toLowerCase() === this.game.textAssets.getServiceText('engine.floor_label'); + } + private buildSpatialNodes(scene: Scene): ParserSpatialNodeContext[] { const textLayer = createSceneTextLayerQuery(scene, this.game); const connectedNodeIds = new Set(); diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index 9d7ef52..73836a2 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -2,10 +2,24 @@ import type { GameActionOutcome } from '../core/GameActionTypes'; import type { Entity } from '../entities/Entity'; import type { SceneObject } from '../entities/SceneObject'; +export type ParserEntityLocationContext = { + relation: Exclude; + parentId: string; + parentTitle?: string; +}; + +export type ParserEntityContentContext = { + relation: Exclude; + id: string; + title: string; +}; + export type ParserEntityContext = { id: string; title: string; item?: true; + location?: ParserEntityLocationContext; + contents?: ParserEntityContentContext[]; reachable?: true; visibility?: 'visible' | 'hidden'; accessibility?: 'reachable' | 'blocked' | 'inaccessible'; @@ -13,6 +27,7 @@ export type ParserEntityContext = { x?: number; y?: number; synonyms?: string[]; + semanticTags?: string[]; description?: string; details?: string; interactions?: string[]; @@ -138,6 +153,7 @@ export type ParserContext = { entities?: ParserEntityContext[]; knownEntities?: ParserEntityContext[]; inventory?: ParserInventoryItemContext[]; + worldFacts?: string[]; spatialNodes?: ParserSpatialNodeContext[]; spatialRelations?: ParserSpatialRelationContext[]; pending?: ParserPendingState; diff --git a/tests/fixtures/sceneFactory.ts b/tests/fixtures/sceneFactory.ts index 984c51b..ae02334 100644 --- a/tests/fixtures/sceneFactory.ts +++ b/tests/fixtures/sceneFactory.ts @@ -13,6 +13,8 @@ type EntityOptions = { groupID?: string | null; components?: any[]; spatial?: SpatialPlacement; + semanticTags?: string[]; + relationFacts?: Array>; }; type TriggerboxOptions = { @@ -93,6 +95,8 @@ export function createSceneFixture(sceneId: string = 'test_scene'): SceneFixture ? {} : { title: options.title !== undefined ? options.title : name }), description: entity.description, + semanticTags: options.semanticTags, + relationFacts: options.relationFacts as any, }); return entity; }, diff --git a/tests/fixtures/textAssetFactory.ts b/tests/fixtures/textAssetFactory.ts index c8ff984..5c1f5d6 100644 --- a/tests/fixtures/textAssetFactory.ts +++ b/tests/fixtures/textAssetFactory.ts @@ -7,6 +7,11 @@ import type { ParserCommandSpec } from '../../src/mechanics/parserTypes'; type TextAssetLike = { getResolvedObjectField(obj: SceneObject, field: string): string | null; getResolvedObjectListField(obj: SceneObject, field: string): string[]; + getResolvedObjectStructuredListField( + obj: SceneObject, + field: string, + normalize: (value: unknown) => T | null + ): T[]; getResolvedSceneField(scene: Scene, field: string): string | null; getServiceText(key: string, params?: Record): string; getParserLexicon(): ParserLexiconAsset; @@ -250,6 +255,12 @@ export function createTestTextAssets(): TestTextAssets { ? value.filter((item): item is string => typeof item === 'string') : []; }, + getResolvedObjectStructuredListField(obj, field, normalize) { + const asset = objectAssets.get(obj.name); + const value = asset?.[field]; + if (!Array.isArray(value)) return []; + return value.map((item) => normalize(item)).filter((item): item is T => item !== null); + }, getResolvedSceneField(scene, field) { const asset = sceneAssets.get(scene.id); const value = asset?.[field]; diff --git a/tests/parser/llm-cascade.test.ts b/tests/parser/llm-cascade.test.ts index fac1682..a857ab7 100644 --- a/tests/parser/llm-cascade.test.ts +++ b/tests/parser/llm-cascade.test.ts @@ -69,6 +69,10 @@ describe('LlmCascade', () => { 'If the intent and target are correct but the game outcome says the action is impossible, do not override game state.', 'If you cannot improve the previous attempt safely or interestingly, return fallback.', ], + world_fact_instructions: [ + 'World facts are authoritative, including semantic facts generated from Text Assets.', + 'Before saying that a required object is missing, not loaded, not inserted, not fueled, empty, or unavailable, check worldFacts and entity contents/location.', + ], response_reminder: 'Respond with a single JSON object. Do not add any text outside the JSON.', }; const mockContext: ParserContext = { @@ -122,6 +126,7 @@ describe('LlmCascade', () => { expect(debug?.prompt?.messages[0]?.content).toContain( 'Player command: "speak to the terminal"' ); + expect(debug?.prompt?.messages[0]?.content).toContain('World facts are authoritative'); expect(debug?.rawResponse).toBe(provider.response.text); }); diff --git a/tests/parser/world-model-context.test.ts b/tests/parser/world-model-context.test.ts index 82c9958..85239c4 100644 --- a/tests/parser/world-model-context.test.ts +++ b/tests/parser/world-model-context.test.ts @@ -149,6 +149,98 @@ describe('Parser world model context', () => { ); }); + it('exposes explicit entity location and contents for LLM context', () => { + const fixture = createSceneFixture(); + fixture.addPlayer('Hero', 0, 0); + fixture.addEntity('boombox', { + title: 'Boombox', + description: 'A tape recorder.', + semanticTags: ['device', 'audio_device', 'media_player'], + relationFacts: [ + { + relation: 'in', + childTags: ['media', 'audio_media'], + fact: '{self} already has {child} loaded.', + }, + ], + components: [ + { type: 'Inventory', relation: 'in', capacity: 2, groups: [], protected: false, items: [] }, + ], + }); + fixture.addEntity('cassette', { + title: 'Compact cassette', + description: 'A cassette.', + semanticTags: ['media', 'audio_media', 'cassette'], + components: [{ type: 'Item' }], + spatial: { parentNodeId: 'boombox', relation: 'in' }, + }); + + const builder = new ParserWorldModelBuilder(fixture.game as any); + const model = builder.build('play cassette', null); + const boombox = model.context.entities?.find((entity) => entity.id === 'boombox'); + const cassette = model.context.entities?.find((entity) => entity.id === 'cassette'); + + expect(boombox?.contents).toEqual( + expect.arrayContaining([ + { + relation: 'in', + id: 'cassette', + title: 'Compact cassette', + }, + ]) + ); + expect(cassette?.location).toEqual({ + relation: 'in', + parentId: 'boombox', + parentTitle: 'Boombox', + }); + expect(model.context.worldFacts).toEqual( + expect.arrayContaining([ + 'Boombox contains Compact cassette.', + 'Compact cassette is inside Boombox.', + 'Boombox already has Compact cassette loaded.', + ]) + ); + }); + + it('generates semantic relation facts from text assets without media-specific rules', () => { + const fixture = createSceneFixture(); + fixture.addPlayer('Hero', 0, 0); + fixture.addEntity('car', { + title: 'Car', + description: 'A tired sedan.', + semanticTags: ['vehicle'], + relationFacts: [ + { + relation: 'in', + childTags: ['fuel'], + fact: '{self} has {child} in the tank.', + }, + ], + components: [ + { type: 'Inventory', relation: 'in', capacity: 2, groups: [], protected: false, items: [] }, + ], + }); + fixture.addEntity('gasoline', { + title: 'Gasoline', + description: 'A mean little promise of motion.', + semanticTags: ['fuel', 'liquid'], + components: [{ type: 'Item' }], + spatial: { parentNodeId: 'car', relation: 'in' }, + }); + + const builder = new ParserWorldModelBuilder(fixture.game as any); + const model = builder.build('drive car', null); + + expect(model.context.worldFacts).toEqual( + expect.arrayContaining([ + 'Car contains Gasoline.', + 'Gasoline is inside Car.', + 'Car has Gasoline in the tank.', + ]) + ); + }); + it('projects nested titled descendants relative to each semantic anchor', () => { const fixture = createSceneFixture(); fixture.addPlayer('Hero', 0, 0); From 7b4534462438f0683ee7e9212b09c38e86833e0d Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Wed, 6 May 2026 02:21:41 +0200 Subject: [PATCH 19/50] Add session wrap-up for semantic facts work --- Sessions.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/Sessions.md b/Sessions.md index 87679e1..fc2354d 100644 --- a/Sessions.md +++ b/Sessions.md @@ -982,3 +982,109 @@ During the session the following checks were run successfully: - `moveToVisual` now clears `visualTarget` and stores world-route waypoints in `target`/`route`; tests were updated to reflect this. - Large Walkbox pathfinding works after removing the fixed cap, but the new regression test shows a nontrivial runtime cost. Keep an eye on route planning latency in very large scenes. - Full `npm test` is not green because of the unrelated parser duplicate held-item test. Focused movement/navigation/typecheck validation is green. + +## Session Entry - 2026-05-06 02:17 +02:00 + +### Session Goals + +- Improve the Stage 2 LLM parser context so it understands authored scene semantics such as "the cassette is already loaded in the boombox". +- Replace the temporary media-specific heuristic with a generic Text Asset driven model. +- Keep v1 scoped to LLM context only: no new runtime command verbs such as `PLAY`, `DRIVE`, or `FUEL`. +- Document the new TA authoring contract and commit the implementation. + +### What Was Implemented + +- Added structured object Text Asset support in `TextAssetManager`. + - Object TA can now contain `semanticTags: string[]`. + - Object TA can now contain `relationFacts: Array<{ relation, childTags, fact }>`. + - Added `getResolvedObjectStructuredListField` as a safe accessor for structured list fields without breaking existing string/list text fields. +- Extended parser world model context. + - `ParserEntityContext` now includes optional `semanticTags`. + - `ParserWorldModelBuilder` still emits generic facts such as `Boombox contains Compact cassette.` and `Compact cassette is inside Boombox.` + - It now additionally emits TA-driven semantic facts when a parent object's `relationFacts` match a child object's `semanticTags`. + - Supported semantic relation matching is currently `in`, `on`, `under`, and `behind`. + - `fact` templates support `{self}`, `{child}`, and `{relation}`. +- Removed the previous hardcoded media heuristic. + - The previous `boombox/recorder/cassette/disk` inference is gone. + - Loaded-media knowledge is now authored in object TA. +- Updated current scene Text Assets. + - `public/text/objects/boombox.json` now defines audio/media semantic tags and a relation fact for loaded media. + - `public/text/objects/test.json` and `test_1.json` now tag cassettes as `media`, `audio_media`, and `cassette`. +- Updated LLM prompt assets. + - `parser-llm.json` now gives generic `worldFacts` authority instructions. + - Media-specific prompt wording for PLAY/MUSIC/CASSETTE/RECORDER was removed. + - `parser-llm-system.md` now describes world facts as current location, containment, and Text Asset semantic relation facts. +- Updated documentation. + - `TextAssets.md` now documents `semanticTags`, `relationFacts`, examples, placeholders, supported relations, and v1 limitations. + - `Parser.md` now documents `worldFacts` as a mix of generic runtime facts and authored semantic facts, plus the object TA template changes. + +### Important Architecture Decisions + +- Semantic facts for the LLM are authored in Text Assets, not runtime components, parser code, or prompt-specific hacks. +- `worldFacts` are treated as concise authoritative state facts for the LLM. +- Semantic relation facts are context only in v1. They help the LLM avoid contradicting the scene but do not execute commands or mutate state. +- The same mechanism should be used for future domains: + - boombox + cassette -> loaded media; + - disk drive + floppy -> inserted disk; + - car + gasoline -> fueled vehicle; + - lamp + bulb -> installed component. +- The LLM prompt should remain generic and trust `worldFacts`; it should not contain per-domain rules such as "if recorder contains cassette...". + +### Parser / Mechanics / Scene / Inventory Changes + +- Parser mechanics changed only in world model context construction and LLM prompt preparation. +- Runtime gameplay effects, inventory rules, spatial placement behavior, scene transitions, and command execution were not changed. +- No real `PLAY`, `DRIVE`, `FUEL`, or similar command mechanic was added. +- `LlmCascade` prompt asset typing was widened to tolerate structured service/object text data while still reading only string and string-list prompt fields. + +### Tests Run and Outcomes + +- `npm test -- tests/parser/world-model-context.test.ts tests/parser/llm-cascade.test.ts` + - Passed: 2 files, 27 tests. +- `npm test -- tests/parser tests/integration/parser-game.test.ts` + - Passed: 9 files, 125 tests. +- `npm run typecheck` + - Passed. +- `git diff --check` + - Passed. +- Full `npm test` + - Passed: 23 files, 261 tests. + +### Commits Created + +- `51e64b6 Add TA-driven semantic facts for LLM parser context` + - Implements structured TA semantic fields, TA-driven semantic world facts, generic LLM world fact instructions, current boombox/cassette TA metadata, tests, and documentation. + +### Kairo / Memory Updates + +- Kairo task completed: + - `aaaaaaabtx5ixylx5tpg5nq3qygv5tss` + - `[Quest] Make parser LLM context expose explicit containment` +- Durable `agent_memory` entries stored for: + - the TA-driven semantic facts architecture; + - the documentation update; + - commit `51e64b6`. + +### Current State + +- Branch: `scene-refact3`. +- Latest code commit: `51e64b6 Add TA-driven semantic facts for LLM parser context`. +- Worktree was clean immediately after the commit. +- This wrap-up appends a new `Sessions.md` entry after the code commit. + +### Remaining Work / Next Recommended Steps + +- Manually smoke-test `#LLM-ON` with commands around: + - `play cassette`; + - `play music`; + - future non-media examples once authored, such as fuel/vehicle or disk/drive. +- Consider editor support for authoring `semanticTags` and `relationFacts` directly in the TA UI. +- Consider adding schema validation or linting for malformed `relationFacts`. +- If semantic facts become gameplay-critical later, design a separate runtime component/command contract instead of overloading LLM context facts. + +### Risks / Caveats / Open Questions + +- Semantic facts are only as correct as the TA authoring. A wrong tag or relation fact can mislead the LLM even though runtime state is unchanged. +- Facts should stay concise and factual; atmospheric sarcasm belongs in LLM responses, not TA semantic facts. +- Empty or missing `childTags` currently means the relation rule applies to any child in that relation. +- NotebookLM source replacement completed after this entry was written: fresh `Sessions.md`, `GDD.md`, `AgentMemory.md`, `Parser.md`, and `TextAssets.md` sources were uploaded and reached `ready` status in the Scanline Engine notebook. From 89fe62b545f298cf7a4f01158a460d579f3202ee Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 7 May 2026 00:52:08 +0200 Subject: [PATCH 20/50] Various Fixes --- SpatialSys.md | 6 +- Tauri.md | 2 +- public/scenes/quad4.json | 126 +++++++++++++++-------------- src/entities/Actor.ts | 8 +- src/entities/Folder.ts | 14 ++-- src/entities/TriggerComponents.ts | 2 +- src/scene/SceneSpatialValidator.ts | 34 ++++++-- src/scene/spatialTypes.ts | 2 +- src/systems/ShadowSystem.ts | 8 +- tests/scene/shadow-system.test.ts | 42 ++++++++++ 10 files changed, 165 insertions(+), 79 deletions(-) diff --git a/SpatialSys.md b/SpatialSys.md index 3e59925..d19ba7d 100644 --- a/SpatialSys.md +++ b/SpatialSys.md @@ -174,7 +174,9 @@ Cabinet (Title) Runtime нормализует relations: - `Inventory.relation` по умолчанию считается `in`. -- `Surface.relation` в runtime по умолчанию считается `on`, если relation отсутствует. Для authored built-in surfaces лучше задавать relation явно. +- `Surface.relation` в runtime по умолчанию считается `on`, если relation отсутствует. + +Несмотря на то, что отсутствие `Inventory.relation` и `Surface.relation` является валидным с точки зрения runtime, **SceneSpatialValidator** должен выдавать non-blocking warning, если relation опущен (чтобы явно подсветить implicit defaults). Кроме того, валидатор должен строго требовать (enforce) явного указания `Surface.relation` для authored built-in surfaces. ## Inventory @@ -632,6 +634,8 @@ Active subscene: - missing spatial parents; - spatial cycles; - invalid container relations; +- `near` relation used in storage container configuration (must be rejected per Rule (3)); +- missing container relations (выдавать non-blocking warning для отсутствующего `Inventory.relation` или `Surface.relation`, чтобы подсветить implicit runtime defaults, и требовать явного `Surface.relation` для authored built-in surfaces); - duplicate storage slots for same relation; - конфликт built-in и untitled external container extensions; - inventory/surface items referencing missing objects; diff --git a/Tauri.md b/Tauri.md index 142587b..4bebb29 100644 --- a/Tauri.md +++ b/Tauri.md @@ -51,4 +51,4 @@ npm run tauri:build 1. [x] Add the official Tauri npm dependency and lockfile update. 2. [x] Smoke-test `tauri dev`. 3. [x] Replace the temporary working-directory project-root assumption with an explicit project/workspace model. -4. [ ] Decide whether the integrated editor is supported in packaged builds or only in desktop-dev/editor builds. +4. [x] Decide whether the integrated editor is supported in packaged builds or only in desktop-dev/editor builds. diff --git a/public/scenes/quad4.json b/public/scenes/quad4.json index 7c60056..e587dd5 100644 --- a/public/scenes/quad4.json +++ b/public/scenes/quad4.json @@ -230,11 +230,11 @@ "p": 0.65 }, { - "x": 149.65358299671635, - "y": 63.03569717109512, + "x": 165, + "y": 62, "p": 0.699, "binding": { - "targetName": "Quad_275", + "targetName": "exit1", "type": "vertex", "index": 1 } @@ -337,9 +337,14 @@ "p": 0.65 }, { - "x": 150.1, - "y": 61.8, - "p": 0.65 + "x": 150, + "y": 62, + "p": 0.65, + "binding": { + "targetName": "exit1", + "type": "vertex", + "index": 0 + } }, { "x": 199, @@ -522,11 +527,11 @@ "ignoreScaling": false, "vertices": [ { - "x": 134.88141317106655, - "y": 91.89878093528154, + "x": 169, + "y": 89, "p": 0.845, "binding": { - "targetName": "Quad", + "targetName": "exit1", "type": "vertex", "index": 3 } @@ -713,13 +718,13 @@ "p": 1 }, { - "x": -447, - "y": 124, + "x": -449, + "y": 128.05718906574896, "p": 1, "binding": { - "targetName": "Quad", + "targetName": "floor", "type": "vertex", - "index": 2 + "index": 3 } } ], @@ -868,12 +873,7 @@ { "x": 172.88141317106655, "y": 91.89878093528154, - "p": 0.845, - "binding": { - "targetName": "Quad_275", - "type": "vertex", - "index": 2 - } + "p": 0.845 }, { "x": 154.98999999999998, @@ -1278,39 +1278,39 @@ "name": "shadow_hero", "type": "Quad", "locked": false, - "disabled": true, + "disabled": false, "groupID": null, "customName": "", "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, - "visible": false, + "visible": true, "hidden": false, "parallax": 1, - "x": -28.475100830614515, - "y": 83.60041599119995, + "x": -97.57017864354819, + "y": 88.54366671904958, "ignoreScaling": false, "vertices": [ { - "x": -28.475100830614515, - "y": 83.60041599119995, - "p": 0.767375066864736 + "x": -97.57017864354819, + "y": 88.54366671904958, + "p": 0.7927593974333667 }, { - "x": -4.809789438288288, - "y": 83.81263375657423, - "p": 0.7690152216643636 + "x": -73.17073234552487, + "y": 88.7637495524327, + "p": 0.7944469628409929 }, { - "x": -106.59812605338605, - "y": 134.7769337747976, - "p": 0.9965357150707409 + "x": -197.7707913007259, + "y": 141.18539967775646, + "p": 0.9910943320051704 }, { - "x": -107.99586538390915, - "y": 122.93980115610466, - "p": 0.9794088283518501 + "x": -199.80700236822938, + "y": 129.18590369879365, + "p": 0.9971687824531933 } ], "color": "#535246", @@ -1952,6 +1952,9 @@ "textRedirects": {}, "interactions": {}, "components": [ + { + "type": "Actor" + }, { "type": "Shadow", "shadowQuadId": "shadow_hero", @@ -1963,18 +1966,18 @@ "layer": 1, "visible": true, "hidden": false, - "parallax": 0.7710044397854223, - "x": -25.66797394584988, - "y": 84.25601705784521, - "width": 24.388970071630826, - "height": 99.5882944591592, + "parallax": 0.7973542641647748, + "x": -94.64497210664521, + "y": 89.37605822772225, + "width": 24.87209945266463, + "height": 101.56107276504724, "baseWidth": 96, "baseHeight": 392, "colliderWidth": 32, "colliderHeight": 6, "spriteName": "miles_ds-idle-right.json", "color": "#00ffff", - "scale": 0.2540517715794878, + "scale": 0.2590843692985899, "modelScale": 0.33, "ignoreScaling": false, "animationSpeed": 30, @@ -2392,6 +2395,10 @@ "type": "Exit", "targetSceneId": "", "targetEntryId": "entry" + }, + { + "type": "WalkBox", + "mode": "Add" } ], "layer": 0, @@ -2403,34 +2410,34 @@ "ignoreScaling": false, "vertices": [ { - "x": 118.65358299671635, - "y": 63.03569717109512, - "p": 0.699 - }, - { - "x": 149.65358299671635, - "y": 63.03569717109512, - "p": 0.699, + "x": 150, + "y": 62, + "p": 0.65, "binding": { - "targetName": "glass", + "targetName": "wall_2", "type": "vertex", "index": 2 } }, { - "x": 172.88141317106655, - "y": 91.89878093528154, + "x": 165, + "y": 62, + "p": 0.699 + }, + { + "x": 172, + "y": 70, + "p": 0.845 + }, + { + "x": 169, + "y": 89, "p": 0.845, "binding": { - "targetName": "Quad_303", + "targetName": "Quad_1", "type": "vertex", - "index": 2 + "index": 0 } - }, - { - "x": 134.88141317106655, - "y": 91.89878093528154, - "p": 0.845 } ], "color": "#52779aff", @@ -2446,6 +2453,7 @@ "blur": 0 } ], + "folders": [], "camera": { "x": 0, "y": 0, diff --git a/src/entities/Actor.ts b/src/entities/Actor.ts index 00c15e9..8939d15 100644 --- a/src/entities/Actor.ts +++ b/src/entities/Actor.ts @@ -773,14 +773,20 @@ export class Actor extends Entity { const route: { x: number; y: number }[] = []; let key = currentKey; + const visited = new Set(); while (cameFrom.has(key)) { + if (visited.has(key)) break; + visited.add(key); + const cell = cells.get(key); if (cell) { route.unshift( this.cellToRoutePoint(cell, key, bounds, gridSize, startKey, targetKey, start, target) ); } - key = cameFrom.get(key) || key; + const nextKey = cameFrom.get(key); + if (nextKey === undefined || nextKey === key) break; + key = nextKey; } if (route.length === 0) return [target]; diff --git a/src/entities/Folder.ts b/src/entities/Folder.ts index 4270007..2e92fb2 100644 --- a/src/entities/Folder.ts +++ b/src/entities/Folder.ts @@ -85,13 +85,13 @@ export class Folder extends Entity { static fromData(game: IGame, data: FolderData): Folder { const folder = new Folder(game, data.name); - if (data.folderId) folder.folderId = data.folderId; - if (data.collapsed) folder.collapsed = data.collapsed; - if (data.groupID) folder.groupID = data.groupID; - if (data.locked) folder.locked = data.locked; - if (data.disabled) folder.disabled = data.disabled; - if (data.spatial) folder.spatial = JSON.parse(JSON.stringify(data.spatial)); - if (data.defaults) folder.defaults = JSON.parse(JSON.stringify(data.defaults)); + if (data.folderId !== undefined) folder.folderId = data.folderId; + if (data.collapsed !== undefined) folder.collapsed = data.collapsed; + if (data.groupID !== undefined) folder.groupID = data.groupID; + if (data.locked !== undefined) folder.locked = data.locked; + if (data.disabled !== undefined) folder.disabled = data.disabled; + if (data.spatial !== undefined) folder.spatial = JSON.parse(JSON.stringify(data.spatial)); + if (data.defaults !== undefined) folder.defaults = JSON.parse(JSON.stringify(data.defaults)); return folder; } } diff --git a/src/entities/TriggerComponents.ts b/src/entities/TriggerComponents.ts index 0cce22f..3d2a599 100644 --- a/src/entities/TriggerComponents.ts +++ b/src/entities/TriggerComponents.ts @@ -118,8 +118,8 @@ export function normalizeTriggerComponent(component: any): AnyTriggerComponent | groupId1: typeof component.groupId1 === 'string' ? component.groupId1 : '', groupId2: typeof component.groupId2 === 'string' ? component.groupId2 : '', state: component.state === 2 ? 2 : 1, - ...(typeof component.keyId === 'string' ? { keyId: component.keyId } : {}), ...(typeof component.idKey === 'string' ? { keyId: component.idKey } : {}), + ...(typeof component.keyId === 'string' ? { keyId: component.keyId } : {}), ...(typeof component.sound1 === 'string' ? { sound1: component.sound1 } : {}), ...(typeof component.sound2 === 'string' ? { sound2: component.sound2 } : {}), ...(component.locked === true ? { locked: true } : {}), diff --git a/src/scene/SceneSpatialValidator.ts b/src/scene/SceneSpatialValidator.ts index 4263874..46e98c8 100644 --- a/src/scene/SceneSpatialValidator.ts +++ b/src/scene/SceneSpatialValidator.ts @@ -35,12 +35,18 @@ type ContainerSlot = { external: boolean; }; -function isValidRelation(value: unknown): value is ContainerRelation { +function isSpatialRelation(value: unknown): value is SpatialRelationType { + return ( + value === 'in' || value === 'on' || value === 'under' || value === 'behind' || value === 'near' + ); +} + +function isStorageRelation(value: unknown): value is ContainerRelation { return value === 'in' || value === 'on' || value === 'under' || value === 'behind'; } function isValidBlockedRelation(value: unknown): value is ContainerRelation | 'none' { - return value === 'none' || isValidRelation(value); + return value === 'none' || isStorageRelation(value); } function hasItemComponent(object: SceneObject | null | undefined): boolean { @@ -125,7 +131,7 @@ export class SceneSpatialValidator { } private normalizeSpatialRelation(value: unknown): ContainerRelation | null { - return isValidRelation(value) ? value : null; + return isStorageRelation(value) ? value : null; } private getParent(object: SceneObject): SceneObject | null { @@ -164,7 +170,7 @@ export class SceneSpatialValidator { : ''; const rawRelation = (object as any).spatial?.relation; - if (rawRelation != null && !isValidRelation(rawRelation)) { + if (rawRelation != null && !isSpatialRelation(rawRelation)) { this.addIssue( 'error', 'invalid_spatial_relation', @@ -189,7 +195,7 @@ export class SceneSpatialValidator { ); } - if (!isValidRelation(rawRelation)) { + if (!isSpatialRelation(rawRelation)) { this.addIssue( 'warning', 'spatial_parent_without_relation', @@ -238,7 +244,14 @@ export class SceneSpatialValidator { const type = getComponentLabel(component); if (type === 'Inventory' && (component as InventoryComponent).relation != null) { const relation = (component as InventoryComponent).relation; - if (!isValidRelation(relation)) { + if (relation === 'near') { + this.addIssue( + 'error', + 'invalid_inventory_relation', + `${object.name} has Inventory with forbidden relation 'near' (Rule (3)).`, + { objectId: object.name } + ); + } else if (!isStorageRelation(relation)) { this.addIssue( 'error', 'invalid_inventory_relation', @@ -250,7 +263,14 @@ export class SceneSpatialValidator { if (type === 'Surface' && (component as SurfaceComponent).relation != null) { const relation = (component as SurfaceComponent).relation; - if (!isValidRelation(relation)) { + if (relation === 'near') { + this.addIssue( + 'error', + 'invalid_surface_relation', + `${object.name} has Surface with forbidden relation 'near' (Rule (3)).`, + { objectId: object.name } + ); + } else if (!isStorageRelation(relation)) { this.addIssue( 'error', 'invalid_surface_relation', diff --git a/src/scene/spatialTypes.ts b/src/scene/spatialTypes.ts index bf63b8a..6afd486 100644 --- a/src/scene/spatialTypes.ts +++ b/src/scene/spatialTypes.ts @@ -1,4 +1,4 @@ -export type SpatialRelationType = 'in' | 'on' | 'under' | 'behind'; +export type SpatialRelationType = 'in' | 'on' | 'under' | 'behind' | 'near'; export type SpatialPlacement = { parentNodeId?: string | null; diff --git a/src/systems/ShadowSystem.ts b/src/systems/ShadowSystem.ts index 30aa837..6e39e64 100644 --- a/src/systems/ShadowSystem.ts +++ b/src/systems/ShadowSystem.ts @@ -43,7 +43,13 @@ export class ShadowSystem { let inside = false; for (const t of targets) { - if (typeof t.containsPoint === 'function') { + if (t instanceof QuadObject) { + const hit = t.hitTest(checkX, checkY); + if (hit) { + inside = true; + break; + } + } else if (typeof t.containsPoint === 'function') { const hit = t.containsPoint(checkX, checkY); if (hit) { inside = true; diff --git a/tests/scene/shadow-system.test.ts b/tests/scene/shadow-system.test.ts index 23491c5..824e44d 100644 --- a/tests/scene/shadow-system.test.ts +++ b/tests/scene/shadow-system.test.ts @@ -36,4 +36,46 @@ describe('ShadowSystem', () => { expect(shadow.disabled).toBe(false); expect(shadow.visible).toBe(true); }); + + it('uses the actual Quad polygon, not the inherited Entity bounds, for shadow zones', () => { + const fixture = createSceneFixture('shadow_quad_scene'); + + const lightspot = new QuadObject(fixture.game, 'lightspot'); + lightspot.groupID = '#lightSpots'; + lightspot.locked = true; + lightspot.x = -94.35007280706921; + lightspot.y = 132.21254989539653; + lightspot.vertices = [ + { x: -139.12445128696197, y: 64.07837041744762, p: 0.64 }, + { x: -12.219765268971267, y: 63.76143450593196, p: 0.64 }, + { x: -106, y: 124, p: 1 }, + { x: -222.8963344900405, y: 123.83466797150467, p: 1 }, + ]; + fixture.scene.addEntity(lightspot); + + const player = fixture.addPlayer('Hero', -30, 70); + player.components = [ + { + type: 'Shadow', + id: 'shadow-comp', + shadowQuadId: 'shadow', + offsetX: 0, + offsetY: 0, + triggerId: '#lightSpots', + }, + ]; + + const shadow = new QuadObject(fixture.game, 'shadow'); + shadow.visible = false; + shadow.disabled = true; + fixture.scene.addEntity(shadow); + + expect(lightspot.hitTest(player.x, player.y)).toBe(true); + expect(lightspot.containsPoint(player.x, player.y)).toBe(false); + + ComponentSystem.update(player, 16); + + expect(shadow.disabled).toBe(false); + expect(shadow.visible).toBe(true); + }); }); From 069d3db72e2cd118361db24ec48a2609d069b2ed Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 7 May 2026 01:02:04 +0200 Subject: [PATCH 21/50] docs: replace hardcoded path in AGENTS.md with placeholder fix(trigger): ensure keyId takes precedence over idKey --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 68b4868..1723bcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ NotebookLM usage rules: ## NotebookLM CLI Readiness +*(Note: Substitute `` with your actual local checkout location.)* + Use NotebookLM through the CLI first. Do not start with the NotebookLM MCP for normal project recall on this machine. Required readiness flow: @@ -111,7 +113,7 @@ Required readiness flow: `python -m notebooklm ask "ping: reply with one short sentence confirming access" --notebook 9f146be7-7c4a-4bb0-b7b4-7f20079e85b0 --json` 4. If `list` and the smoke-test `ask` work, reuse the current CLI auth and do not re-authorize. 5. If a real CLI command returns `Authentication expired or invalid` or redirects to Google sign-in, organize CLI re-auth with the user: - `Start-Process powershell -ArgumentList @('-NoExit','-Command','Set-Location -LiteralPath "D:\GAMES\New folder\Quest"; python -m notebooklm login')` + `Start-Process powershell -ArgumentList @('-NoExit','-Command','Set-Location -LiteralPath ""; python -m notebooklm login')` Ask the user to complete Google login in the opened browser, wait for the NotebookLM homepage, then press Enter in that terminal. Re-run `list` and the project smoke test. Important caveats: From 2fcb17ef0aa7d0310d5bec6efbe3242d3dee2498 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 7 May 2026 01:07:44 +0200 Subject: [PATCH 22/50] Fix spatial relation type narrowing --- src/scene/SceneSpatialValidator.ts | 4 +- src/systems/GameSemanticAPI.ts | 62 ++++++++++++++---------------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/scene/SceneSpatialValidator.ts b/src/scene/SceneSpatialValidator.ts index 46e98c8..ba10e9b 100644 --- a/src/scene/SceneSpatialValidator.ts +++ b/src/scene/SceneSpatialValidator.ts @@ -243,7 +243,7 @@ export class SceneSpatialValidator { for (const component of object.components || []) { const type = getComponentLabel(component); if (type === 'Inventory' && (component as InventoryComponent).relation != null) { - const relation = (component as InventoryComponent).relation; + const relation = (component as any).relation; if (relation === 'near') { this.addIssue( 'error', @@ -262,7 +262,7 @@ export class SceneSpatialValidator { } if (type === 'Surface' && (component as SurfaceComponent).relation != null) { - const relation = (component as SurfaceComponent).relation; + const relation = (component as any).relation; if (relation === 'near') { this.addIssue( 'error', diff --git a/src/systems/GameSemanticAPI.ts b/src/systems/GameSemanticAPI.ts index f501169..abf91bd 100644 --- a/src/systems/GameSemanticAPI.ts +++ b/src/systems/GameSemanticAPI.ts @@ -19,6 +19,8 @@ import { import { ScriptRegistry } from '../core/ScriptRegistry'; import { Geometry } from '../utils/Geometry'; +type EffectiveRelation = Exclude; + export type PutTargetTextDescriptor = { title: string; relation: SpatialRelationType; @@ -37,6 +39,12 @@ export class GameSemanticAPI { // --- Helper Methods --- + private isEffectiveRelation( + relation: SpatialRelationType | null | undefined + ): relation is EffectiveRelation { + return relation === 'in' || relation === 'on' || relation === 'under' || relation === 'behind'; + } + private getPlayerFacingObjectTitle(target: SceneObject): string | null { const title = this.game.textAssets.getResolvedObjectField(target as any, 'title'); return title && title.trim() ? title.trim() : null; @@ -856,8 +864,9 @@ export class GameSemanticAPI { } const anchorObject = scene.getObjectByName(anchorNodeId); + const effectiveRelation = this.isEffectiveRelation(relation) ? relation : null; const blockingComponent = anchorObject - ? getActiveBlockingComponentState(anchorObject, relation) + ? getActiveBlockingComponentState(anchorObject, effectiveRelation) : null; if (blockingComponent && !blockingComponent.transparent) { if (blockingComponent.clearlyOpenable) { @@ -871,31 +880,22 @@ export class GameSemanticAPI { } } - let childTitles = - getSceneTextRelationDescendants( - textLayer, - anchorNodeId, - relation as Exclude - ) - ?.map((entry) => entry.title) - .filter((title): title is string => !!title) || []; - - const revealableLookables = getSceneTextRelationAccessStates( - scene, - this.game, - anchorNodeId, - relation as Exclude, - { includeHidden: true } - ).filter((accessState) => accessState.hiddenReason === 'lookable'); - if (revealableLookables.length) { + let childTitles = effectiveRelation + ? getSceneTextRelationDescendants(textLayer, anchorNodeId, effectiveRelation) + ?.map((entry) => entry.title) + .filter((title): title is string => !!title) || [] + : []; + + const revealableLookables = effectiveRelation + ? getSceneTextRelationAccessStates(scene, this.game, anchorNodeId, effectiveRelation, { + includeHidden: true, + }).filter((accessState) => accessState.hiddenReason === 'lookable') + : []; + if (effectiveRelation && revealableLookables.length) { revealableLookables.forEach((accessState) => scene.revealHiddenEntity(accessState.object)); const revealedTextLayer = buildSceneTextLayerSnapshot(scene, this.game); childTitles = - getSceneTextRelationDescendants( - revealedTextLayer, - anchorNodeId, - relation as Exclude - ) + getSceneTextRelationDescendants(revealedTextLayer, anchorNodeId, effectiveRelation) ?.map((entry) => entry.title) .filter((title): title is string => !!title) || []; } @@ -966,7 +966,7 @@ export class GameSemanticAPI { ); const textLayer = buildSceneTextLayerSnapshot(scene, this.game); - const getRelationCandidates = (candidateRelation: SpatialRelationType) => + const getRelationCandidates = (candidateRelation: EffectiveRelation) => getSceneTextRelationDescendants(textLayer, anchor.name, candidateRelation) .map((entry) => entry.object) .filter((candidate): candidate is Entity => candidate instanceof Entity) @@ -984,20 +984,16 @@ export class GameSemanticAPI { return relationOutcome; } - directCandidates = ['on', 'under', 'behind'].flatMap((candidateRelation) => - getRelationCandidates(candidateRelation as SpatialRelationType) + directCandidates = (['on', 'under', 'behind'] as EffectiveRelation[]).flatMap( + (candidateRelation) => getRelationCandidates(candidateRelation) ); } const semanticContents = getSceneTextRelationDescendants(textLayer, anchor.name, relation); const fallbackSemanticContents = relation === 'in' && !semanticContents.length - ? ['on', 'under', 'behind'].flatMap((candidateRelation) => - getSceneTextRelationDescendants( - textLayer, - anchor.name, - candidateRelation as SpatialRelationType - ) + ? (['on', 'under', 'behind'] as EffectiveRelation[]).flatMap((candidateRelation) => + getSceneTextRelationDescendants(textLayer, anchor.name, candidateRelation) ) : []; @@ -1084,7 +1080,7 @@ export class GameSemanticAPI { relation === 'in' || relation === 'on' || relation === 'under' || relation === 'behind' ? relation : null; - const relations: SpatialRelationType[] = semanticRelation + const relations: EffectiveRelation[] = semanticRelation ? [semanticRelation] : ['in', 'on', 'under', 'behind']; From 8f7b3f0bed7d36d12c0c0427a175f8e337608663 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 7 May 2026 18:06:49 +0200 Subject: [PATCH 23/50] Feature: closed-console modal state for parser responses that exceed the two visible closed-console lines (according the GDD). --- GDD.md | 2 +- Sessions.md | 144 +++++++++++++++++++++++++ public/scenes/test_room.json | 60 ++++++----- src/components/UIOverlay.tsx | 24 ++++- src/core/Console.ts | 171 +++++++++++++++++++++++++++++- src/core/Game.ts | 54 ++++++++-- src/core/IGame.ts | 1 + src/core/Input.ts | 10 +- src/mechanics/Parser.ts | 19 ++-- tests/parser/preprocessor.test.ts | 126 ++++++++++++++++++++++ 10 files changed, 559 insertions(+), 52 deletions(-) diff --git a/GDD.md b/GDD.md index 7b864a3..59c3eca 100644 --- a/GDD.md +++ b/GDD.md @@ -50,7 +50,7 @@ - `#PEEK-OFF` : отключает этот режим. - `#PEEKLLM-ON` : включает режим отладки LLM-каскада, при котором после LLM-вызова в консоль выводятся полный prompt (`system` + `messages`) и полный raw response/error; - `#PEEKLLM-OFF` : отключает этот режим. -- `#VALIDATE-SPATIAL` : запускает проверку текущей сцены через `SceneSpatialValidator` и выводит в консоль список spatial/container ошибок и предупреждений. Проверяются, в частности, циклы и битые spatial-ссылки, конфликтующие контейнеры с одинаковым relation, некорректные storage-ссылки `Inventory`/`Surface`, `hidden` у безымянных объектов и main inventory персонажа. +- `#VALIDATE-SPATIAL` : запускает проверку текущей сцены через `SceneSpatialValidator` и выводит в консоль список spatial/container ошибок и предупреждений. Проверяются, в частности, циклы и битые spatial-ссылки, конфликтующие контейнеры с одинаковым relation, некорректные storage-ссылки `Inventory`/`Surface`, `hidden` у безымянных объектов и main inventory персонажа. Эти детали выводятся в консоль браузера. В режиме редактора `SceneSpatialValidator` также автоматически запускается при загрузке и сохранении сцены. Эта проверка не блокирует работу и не исправляет сцену автоматически: если найдены проблемы, редактор показывает краткое уведомление, а подробный список issues выводится в browser console. Полный ручной отчёт можно получить командой `#VALIDATE-SPATIAL`. diff --git a/Sessions.md b/Sessions.md index fc2354d..4f40711 100644 --- a/Sessions.md +++ b/Sessions.md @@ -1088,3 +1088,147 @@ During the session the following checks were run successfully: - Facts should stay concise and factual; atmospheric sarcasm belongs in LLM responses, not TA semantic facts. - Empty or missing `childTags` currently means the relation rule applies to any child in that relation. - NotebookLM source replacement completed after this entry was written: fresh `Sessions.md`, `GDD.md`, `AgentMemory.md`, `Parser.md`, and `TextAssets.md` sources were uploaded and reached `ready` status in the Scanline Engine notebook. +## Session Entry - 2026-05-07 18:01 Europe/Warsaw + +### Session Goals + +- Implement the GDD-described closed-console modal state for parser responses that exceed the two visible closed-console lines. +- Add word wrapping in the closed low-res console so long lines are not clipped at the right screen edge. +- Preserve forced line breaks (`\n`, CRLF/CR) from Text Assets and parser responses so TA descriptions can use paragraphs. +- Iterate on modal-console UX until it matches real gameplay behavior in `test_room`, especially `LOOK CITY`. + +### What Was Implemented + +- Added closed-console word wrapping and forced-newline preservation in `src/core/Console.ts`. + - Closed-console display lines are derived from buffer text by splitting CRLF/CR/`\n` into explicit paragraphs and wrapping words to the low-res console width. + - Very long unbroken words are split so they cannot overflow the canvas. +- Added closed modal state handling. + - `Console.isClosedModal` marks the continue-waiting state. + - Parser/player output that wraps beyond two closed-console lines enters modal state while the console is closed. + - Any normal key press or canvas click dismisses the modal. + - Backquote/tilde is an exception: it opens the full high-res console instead of merely dismissing the modal. +- Added parser-response batching. + - `Console.logResponse()` evaluates the modal threshold over the full player-facing parser response batch, not one physical buffer entry at a time. + - `Game.logResponse()` funnels real parser responses into that batch path, with a fallback for tests/stubs. + - `Parser.parse()` now sends player-facing response output through `game.logResponse(...)` when available. +- Fixed Enter propagation. + - The hidden parser input now stops `Enter` propagation after submitting a command, preventing the same key event from bubbling to global input and instantly dismissing a newly opened modal. +- Scoped modal rendering to the latest parser response. + - `Console` now stores a separate `closedModalDisplayLines` snapshot when a response triggers modal state. + - `Game.renderUI()` renders only `getClosedModalDisplayLines()` while modal, rather than expanding the whole closed-console history. + - Dismissing modal, opening the full console, clearing, or loading from JSON clears that modal snapshot. +- Kept technical parser logs out of the closed console. + - `ConsoleLine` now supports optional `showInClosed`. + - Parser debug/peek messages are written with `{ showInClosed: false }`, so they remain visible in the open console buffer but do not occupy the low-res gameplay screen. + - Command confirmations such as `Parser peek enabled.` from `#PEEK-ON` and `LLM prompt/response peek enabled.` from `#PEEKLLM-ON` remain normal visible output. +- Polished the continue prompt. + - `[Continue]` is right-aligned on the modal prompt row. + - Its blink cadence now uses the same `cursorBlink / 500` logic as the normal closed-console text cursor. + +### Important Architecture / Runtime Decisions + +- Closed modal is a transient view over the latest parser response, not a resized history viewer. +- The full console buffer remains the authoritative history for the open console; the modal snapshot is only for the low-res modal display. +- Parser/player responses and technical diagnostic logs now have different closed-console visibility semantics: + - player-facing parser responses can trigger and populate modal state; + - debug/peek logs are retained for the open console but hidden from the closed console. +- Tilde/backquote has higher priority than generic modal dismissal because it is the user's established gesture for opening the console. +- Forced text newlines are handled at display wrapping time, so existing JSON string escape behavior (`\n`, CRLF/CR) works without changing Text Asset schemas. + +### Parser / Mechanics / Scene / UI Changes + +- Parser: + - `src/mechanics/Parser.ts` now sends player-facing response arrays through `game.logResponse(...)`. + - Parser debug messages are logged with `showInClosed: false`. +- Console/runtime: + - `src/core/Console.ts` gained display-line wrapping, modal state, modal response snapshots, `logResponse`, `getClosedModalDisplayLines`, and `showInClosed` filtering. + - `src/core/Game.ts` renders dynamic-height closed modal output, right-aligned blinking `[Continue]`, and `logResponse`. + - `src/core/Input.ts` handles Backquote before generic modal dismissal. + - `src/core/IGame.ts` exposes optional `logResponse`. +- React UI: + - `src/components/UIOverlay.tsx` disables hidden parser input during modal state, stops submit `Enter` propagation, and mirrors the Backquote modal-open fallback. +- Tests: + - `tests/parser/preprocessor.test.ts` now covers closed-console wrapping, forced newlines, modal dismissal, multi-message parser response modal triggering, Backquote opening the full console, technical log filtering, and latest-response-only modal snapshots. + +### Validation / Tests Run + +- `codex-doctor -Fast` + - Passed: 17 checks, 0 warnings, 0 failures. +- NotebookLM CLI readiness: + - Initial `list` / smoke `ask` failed because auth had expired. + - `notebooklm-ready.ps1 -AutoLogin` repaired CLI auth and the project smoke test passed. +- Focused tests during implementation: + - `npm test -- tests/parser/preprocessor.test.ts` + - `npm test -- tests/parser/preprocessor.test.ts tests/parser/commands.test.ts tests/integration/parser-game.test.ts` + - `npm test -- tests/parser/preprocessor.test.ts tests/parser/llm-parser.test.ts` + - All passed after fixes. +- Typecheck: + - `npm run typecheck` + - Passed. +- Full suite: + - `npm test` + - Passed: 23 files, 268 tests. +- Whitespace: + - `git diff --check` + - Passed. Git reported expected LF-to-CRLF working-copy warnings only. +- Browser smoke checks with Playwright: + - `LOOK CITY` in `test_room` enters closed modal, disables input, preserves forced CR/newline, wraps text, and dismisses to the last two wrapped lines. + - Backquote from modal opens the full high-res console overlay. + - `#PEEK-ON` followed by `LOOK CITY` keeps `--- CONTEXT ---` in the full buffer but hides it from closed display, while `Parser peek enabled.` remains visible. + - Repeating `LOOK CITY` leaves history in the full buffer but modal renders only the latest response snapshot. + +### Commits Created During This Session + +- No git commit was created during this session. +- Latest commit at wrap-up time: + - `2fcb17e Fix spatial relation type narrowing` + +### Kairo / Memory Updates + +- Kairo task updated and closed: + - `aaaaaaabtybovqouo5bfcrh3gru6asfb` + - `[Quest] Implement closed-console modal multiline output` +- Durable `agent_memory` entries stored for: + - closed console modal multiline output implementation; + - parser response batching and Enter propagation fix; + - Backquote opening the full console from modal state; + - right-aligned `[Continue]` prompt and blink cadence; + - technical parser logs hidden from closed console; + - latest-response-only modal snapshot behavior. + +### Current State + +- Branch: `scene-refact3`. +- Worktree has uncommitted changes from this session in: + - `src/components/UIOverlay.tsx` + - `src/core/Console.ts` + - `src/core/Game.ts` + - `src/core/IGame.ts` + - `src/core/Input.ts` + - `src/mechanics/Parser.ts` + - `tests/parser/preprocessor.test.ts` +- There is also a pre-existing/user-owned dirty file not edited as part of this implementation: + - `public/scenes/test_room.json` +- `Sessions.md` is updated by this wrap-up entry. + +### Remaining Work / Next Recommended Steps + +- Commit the feature after user acceptance, including the source/test changes and this `Sessions.md` entry if desired. +- Consider adding a higher-level integration/UI test for closed-modal rendering if the project later gains browser-driven test infrastructure. +- Consider documenting the closed-console modal contract in `GDD.md` or a console/UI architecture doc if this behavior becomes a stable public editing/design rule. + +### Risks / Caveats / Open Questions + +- Closed modal currently caps visible modal output to available low-res screen height. Extremely long responses still show the tail of that response rather than true pagination. +- Technical logs are hidden only when logged with `showInClosed: false`; future debug producers should use the same flag if they should stay out of the closed console. +- The modal snapshot is intentionally transient and is not serialized into save/load state. +- The dirty `public/scenes/test_room.json` was left untouched because it appears unrelated/user-owned. + +### NotebookLM / RAG Refresh + +- NotebookLM source replacement completed after this entry was appended: + - `Sessions.md` + - `GDD.md` + - generated `AgentMemory.md` +- The local memory mirror was refreshed and the NotebookLM memory dump was regenerated as part of this wrap-up workflow. +- Fresh NotebookLM sources reached `ready` status in the Scanline Engine notebook. diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index 12a79d6..2e9d511 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -738,6 +738,9 @@ "textRedirects": {}, "interactions": {}, "components": [ + { + "type": "Actor" + }, { "type": "Shadow", "shadowQuadId": "shadow", @@ -757,9 +760,9 @@ "layer": 0, "visible": true, "hidden": false, - "parallax": 1.0572798870396043, - "x": 194.30236238320973, - "y": 273.6382271705948, + "parallax": 1.019456250499469, + "x": 314.7813052435627, + "y": 215.6934596932739, "width": 71.03999999999999, "height": 290.08, "baseWidth": 96, @@ -777,7 +780,7 @@ "blur": 0, "isPlayer": true, "speed": 0.24, - "direction": "left", + "direction": "right", "animSets": { "idle": { "id": "idle", @@ -1136,6 +1139,9 @@ "textRedirects": {}, "interactions": {}, "components": [ + { + "type": "Actor" + }, { "type": "Inventory", "relation": "in", @@ -1148,9 +1154,9 @@ "layer": 0, "visible": true, "hidden": false, - "parallax": 1.0790382036293824, - "x": 222.90433731747981, - "y": 306.92845155295504, + "parallax": 1.0790382036293826, + "x": 222.90433731747987, + "y": 306.92845155295527, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -1276,32 +1282,32 @@ "visible": true, "hidden": false, "parallax": 1, - "x": 139.68693090675706, - "y": 254.57865193560227, + "x": 259.89767226363784, + "y": 196.62446897813348, "ignoreScaling": false, "vertices": [ { - "x": 139.68693090675706, - "y": 254.57865193560227, - "p": 1.0448448818175478 + "x": 259.89767226363784, + "y": 196.62446897813348, + "p": 1.0070239381402728 }, { - "x": 237.03683022735026, - "y": 253.2178459321643, - "p": 1.0439129162701501 + "x": 357.36017660537453, + "y": 195.4075428410151, + "p": 1.006195344038248 }, { - "x": 181.0473938118461, - "y": 293.77427380496965, - "p": 1.0705057889322083 + "x": 298.1356403971205, + "y": 231.78779124240106, + "p": 1.0300043094580598 }, { - "x": 133.1256264157164, - "y": 292.0584499780684, - "p": 1.0693787892777253 + "x": 250.34953321830542, + "y": 230.24698779349282, + "p": 1.028995963519363 } ], - "color": "#000975", + "color": "#2b019d", "sortMode": "ignore", "opacity": 0.6, "blendMode": "multiply", @@ -1376,17 +1382,17 @@ "relation": "in" }, "parallax": 1, - "x": 194.30236238320973, - "y": 273.6382271705948, - "width": 15.759890859481585, - "height": 22.395634379263303, + "x": 314.7813052435627, + "y": 215.6934596932739, + "width": 14.765029370537503, + "height": 20.981883842342764, "baseWidth": 19.69986357435198, "baseHeight": 27.994542974079128, "colliderWidth": 0, "colliderHeight": 0, "spriteName": null, "color": "#AAAAAA", - "scale": 0.8, + "scale": 0.7494990670778385, "modelScale": 0.8, "ignoreScaling": false, "animationSpeed": 150, diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index 8f50543..c80aa4c 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -36,6 +36,7 @@ export const UIOverlay: React.FC = ({ game }) => { // Console State for Input Unlocking const [isConsoleOpen, setIsConsoleOpen] = useState(false); + const [isConsoleModal, setIsConsoleModal] = useState(false); const parserInputRef = React.useRef(null); useEffect(() => { @@ -69,6 +70,7 @@ export const UIOverlay: React.FC = ({ game }) => { if (game && game.console) { const unsubscribe = game.console.subscribe(() => { setIsConsoleOpen(game.console.isOpen); + setIsConsoleModal(game.console.isClosedModal); }); return unsubscribe; } @@ -83,7 +85,7 @@ export const UIOverlay: React.FC = ({ game }) => { useEffect(() => { if (!game) return; - if (editorEnabled || isConsoleOpen) return; + if (editorEnabled || isConsoleOpen || isConsoleModal) return; const timer = window.setTimeout(() => { const input = parserInputRef.current; @@ -94,7 +96,7 @@ export const UIOverlay: React.FC = ({ game }) => { }, 0); return () => window.clearTimeout(timer); - }, [game, editorEnabled, isConsoleOpen, previewEntity?.name]); + }, [game, editorEnabled, isConsoleOpen, isConsoleModal, previewEntity?.name]); useEffect(() => { if (message) { @@ -151,9 +153,20 @@ export const UIOverlay: React.FC = ({ game }) => { id="parser-input" ref={parserInputRef} autoComplete="off" - autoFocus={!editorEnabled || isConsoleOpen} - disabled={editorEnabled && !isConsoleOpen} + autoFocus={!isConsoleModal && (!editorEnabled || isConsoleOpen)} + disabled={isConsoleModal || (editorEnabled && !isConsoleOpen)} onKeyDown={(e) => { + if (e.code === 'Backquote' && game?.console.isClosedModal) { + e.preventDefault(); + game.console.toggle(); + return; + } + + if (game?.console.continueClosedModal()) { + e.preventDefault(); + return; + } + // Layer 2: React Event Fallback (fires if Global Capture misses or bubbles up) // F1: Toggle Scene Editor @@ -172,6 +185,9 @@ export const UIOverlay: React.FC = ({ game }) => { // Enter: Parse Command if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); const val = e.currentTarget.value.trim(); // Keep case for now, parser handles upper? // GDD: "Input command... displayed in buffer... then sent to parser" diff --git a/src/core/Console.ts b/src/core/Console.ts index d969ce2..23abccb 100644 --- a/src/core/Console.ts +++ b/src/core/Console.ts @@ -7,6 +7,16 @@ export interface ConsoleLine { text: string; type: ConsoleLineType; timestamp: number; + showInClosed?: boolean; +} + +export interface ConsoleDisplayLine { + text: string; + type: ConsoleLineType; +} + +export interface ConsoleLogOptions { + showInClosed?: boolean; } export interface ConsoleState { @@ -25,10 +35,14 @@ export class Console { parserStage2Enabled: boolean = true; parserLlmEnabled: boolean = false; parserCascade1ForceLlm: boolean = false; + isClosedModal: boolean = false; + private closedModalDisplayLines: ConsoleDisplayLine[] = []; // Configuration readonly MAX_BUFFER_LINES = 2000; // Approx 150KB of text depending on length readonly MAX_HISTORY = 50; + readonly CLOSED_CONSOLE_VISIBLE_LINES = 2; + readonly CLOSED_CONSOLE_WRAP_COLUMNS = 68; // Command Registry private commands: Map void> = new Map(); @@ -61,6 +75,10 @@ export class Console { } preprocessGameplayInput(input: string): string { + if (this.isClosedModal) { + return ''; + } + const trimmed = input.trim(); if (!trimmed) return trimmed; @@ -84,6 +102,8 @@ export class Console { } processCommand(input: string): void { + if (this.isClosedModal) return; + const trimmed = input.trim(); if (!trimmed) return; @@ -245,6 +265,14 @@ export class Console { } toggle(): void { + if (this.isClosedModal) { + this.isClosedModal = false; + this.closedModalDisplayLines = []; + this.isOpen = true; + this.notifyListeners(); + return; + } + this.isOpen = !this.isOpen; this.notifyListeners(); } @@ -254,11 +282,12 @@ export class Console { this.notifyListeners(); } - log(text: string, type: ConsoleLineType = 'output'): number { + log(text: string, type: ConsoleLineType = 'output', options: ConsoleLogOptions = {}): number { const line: ConsoleLine = { text, type, timestamp: Date.now(), + showInClosed: options.showInClosed, }; this.buffer.push(line); @@ -268,10 +297,63 @@ export class Console { this.buffer.shift(); } + if ( + this.lineShowsInClosed(line) && + !this.isOpen && + type === 'output' && + this.getWrappedLineCount(text) > 2 + ) { + this.isClosedModal = true; + this.closedModalDisplayLines = this.buildDisplayLines([{ text, type }]); + } + this.notifyListeners(); return this.buffer.length - 1; } + logResponse( + messages: string[], + type: ConsoleLineType = 'output', + options: ConsoleLogOptions = {} + ): number[] { + const lineIndexes: number[] = []; + const timestamp = Date.now(); + + for (const message of messages) { + const line: ConsoleLine = { + text: message, + type, + timestamp, + showInClosed: options.showInClosed, + }; + + this.buffer.push(line); + lineIndexes.push(this.buffer.length - 1); + + if (this.buffer.length > this.MAX_BUFFER_LINES) { + this.buffer.shift(); + for (let index = 0; index < lineIndexes.length; index += 1) { + lineIndexes[index] -= 1; + } + } + } + + if ( + options.showInClosed !== false && + !this.isOpen && + type === 'output' && + this.getWrappedLineCount(messages) > 2 + ) { + this.isClosedModal = true; + this.closedModalDisplayLines = this.buildDisplayLines( + messages.map((message) => ({ text: message, type })) + ); + } + + this.notifyListeners(); + return lineIndexes; + } + updateLine(index: number, text: string, type?: ConsoleLineType): void { const line = this.buffer[index]; if (!line) return; @@ -299,11 +381,96 @@ export class Console { clear(): void { this.buffer = []; + this.isClosedModal = false; + this.closedModalDisplayLines = []; this.log('Console cleared', 'info'); // log already notifies, but if we clear buffer directly first, we might want to ensure update. // actually log() calls notify, so we are good. } + continueClosedModal(): boolean { + if (!this.isClosedModal) return false; + this.isClosedModal = false; + this.closedModalDisplayLines = []; + this.notifyListeners(); + return true; + } + + getClosedModalDisplayLines(): ConsoleDisplayLine[] { + return this.closedModalDisplayLines; + } + + getClosedDisplayLines(): ConsoleDisplayLine[] { + return this.buildDisplayLines( + this.buffer + .filter((line) => this.lineShowsInClosed(line)) + .map((line) => ({ text: line.text, type: line.type })) + ); + } + + private lineShowsInClosed(line: ConsoleLine): boolean { + return line.showInClosed !== false; + } + + private getWrappedLineCount(text: string | string[]): number { + const messages = Array.isArray(text) ? text : [text]; + return messages.reduce((count, message) => count + this.wrapConsoleText(message).length, 0); + } + + private buildDisplayLines( + lines: Array<{ text: string; type: ConsoleLineType }> + ): ConsoleDisplayLine[] { + return lines.flatMap((line) => + this.wrapConsoleText(line.text).map((text) => ({ text, type: line.type })) + ); + } + + private wrapConsoleText(text: string): string[] { + const paragraphs = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + const lines: string[] = []; + + for (const paragraph of paragraphs) { + if (paragraph.length === 0) { + lines.push(''); + continue; + } + + lines.push(...this.wrapParagraph(paragraph)); + } + + return lines.length > 0 ? lines : ['']; + } + + private wrapParagraph(text: string): string[] { + const words = text.trim().split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + if (word.length > this.CLOSED_CONSOLE_WRAP_COLUMNS) { + if (current) { + lines.push(current); + current = ''; + } + for (let index = 0; index < word.length; index += this.CLOSED_CONSOLE_WRAP_COLUMNS) { + lines.push(word.slice(index, index + this.CLOSED_CONSOLE_WRAP_COLUMNS)); + } + continue; + } + + const next = current ? `${current} ${word}` : word; + if (next.length > this.CLOSED_CONSOLE_WRAP_COLUMNS) { + if (current) lines.push(current); + current = word; + } else { + current = next; + } + } + + if (current) lines.push(current); + return lines.length > 0 ? lines : ['']; + } + // Serialization for Save/Load toJSON(): ConsoleState { return { @@ -317,5 +484,7 @@ export class Console { if (state.buffer) this.buffer = state.buffer; if (state.history) this.history = state.history; if (state.isOpen !== undefined) this.isOpen = state.isOpen; + this.isClosedModal = false; + this.closedModalDisplayLines = []; } } diff --git a/src/core/Game.ts b/src/core/Game.ts index 75c6b11..375af36 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -441,7 +441,17 @@ export class Game implements IGame { const w = this.bufferCanvas.width; const h = this.bufferCanvas.height; const lineHeight = 10; - const consoleHeight = lineHeight * 3 + 4; + const isClosedModal = this.console.isClosedModal; + const closedDisplayLines = isClosedModal + ? this.console.getClosedModalDisplayLines() + : this.console.getClosedDisplayLines(); + const maxModalLines = Math.max(1, Math.floor((h - 4) / lineHeight) - 1); + const outputLineCount = isClosedModal + ? Math.min(Math.max(closedDisplayLines.length, 1), maxModalLines) + : this.console.CLOSED_CONSOLE_VISIBLE_LINES; + const continueLineCount = isClosedModal ? 1 : 0; + const inputLineCount = isClosedModal ? 0 : 1; + const consoleHeight = lineHeight * (outputLineCount + continueLineCount + inputLineCount) + 4; ctx.font = '10px monospace'; ctx.textBaseline = 'top'; @@ -451,18 +461,22 @@ export class Game implements IGame { ctx.fillRect(0, consoleY, w, consoleHeight); ctx.fillStyle = '#fff'; - const buffer = this.console.buffer; - const lastIndex = buffer.length - 1; + const visibleOutput = closedDisplayLines.slice(-outputLineCount); - if (lastIndex >= 1) { - const line = buffer[lastIndex - 1]; + for (let index = 0; index < visibleOutput.length; index += 1) { + const line = visibleOutput[index]; ctx.fillStyle = line.type === 'command' ? '#aaa' : '#fff'; - ctx.fillText(line.text, 2, consoleY + 2); + ctx.fillText(line.text, 2, consoleY + 2 + lineHeight * index); } - if (lastIndex >= 0) { - const line = buffer[lastIndex]; - ctx.fillStyle = line.type === 'command' ? '#aaa' : '#fff'; - ctx.fillText(line.text, 2, consoleY + 2 + lineHeight); + + if (isClosedModal) { + this.cursorBlink += 16; + const cursorVisible = Math.floor(this.cursorBlink / 500) % 2 === 0; + const continueText = '[Continue]'; + const continueX = Math.max(2, w - ctx.measureText(continueText).width - 2); + ctx.fillStyle = cursorVisible ? '#fff' : '#777'; + ctx.fillText(continueText, continueX, consoleY + 2 + lineHeight * outputLineCount); + return; } const inputText = this.consoleInput ? this.consoleInput.value : ''; @@ -477,7 +491,7 @@ export class Game implements IGame { } ctx.fillStyle = '#fff'; - ctx.fillText(`> ${inputText}${cursor}`, 2, consoleY + 2 + lineHeight * 2); + ctx.fillText(`> ${inputText}${cursor}`, 2, consoleY + 2 + lineHeight * outputLineCount); } disableCRT(): void { @@ -485,6 +499,10 @@ export class Game implements IGame { } onMouseClick(x: number, y: number): void { + if (this.console.continueClosedModal()) { + return; + } + if (!this.editor.enabled) { this.focusCommandInput(); } @@ -502,6 +520,20 @@ export class Game implements IGame { this.console.log(text); } + logResponse(messages: string[]): void { + if (typeof this.console?.logResponse !== 'function') { + for (const message of messages) { + this.log(message); + } + return; + } + + for (const message of messages) { + console.log(`[GAME LOG] ${message}`); + } + this.console.logResponse(messages); + } + text(key: string, params?: Record): string { return this.textAssets.getServiceText(key, params); } diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 8df848e..6f9550f 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -26,6 +26,7 @@ export interface IGame { isEntityInInventory(entity: Entity): boolean; showMessage(text: string): void; log(text: string): void; + logResponse?(messages: string[]): void; text(key: string, params?: Record): string; getSeeMessage(target: SceneObject): string | null; getBlockedAccessOutcome(entity: SceneObject): GameActionOutcome | null; diff --git a/src/core/Input.ts b/src/core/Input.ts index df28ab8..d6f0d28 100644 --- a/src/core/Input.ts +++ b/src/core/Input.ts @@ -36,15 +36,21 @@ export class Input { }; this.onKeyDown = (e: KeyboardEvent) => { - this.keys[e.key] = true; - // Toggle console by physical backquote key, independent of keyboard layout. if (e.code === 'Backquote') { e.preventDefault(); if (this.game.console) { this.game.console.toggle(); } + return; } + + if (this.game.console?.continueClosedModal()) { + e.preventDefault(); + return; + } + + this.keys[e.key] = true; }; this.onKeyUp = (e: KeyboardEvent) => { diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 15e33cd..5e6ac30 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -167,19 +167,26 @@ export class Parser { if (response.debugMessages?.length) { for (const message of response.debugMessages) { - this.game.console?.log(message, 'info'); + this.game.console?.log(message, 'info', { showInClosed: false }); } } this.pendingState = response.nextPendingState === undefined ? this.pendingState : response.nextPendingState; - if (response.playerMessages?.length) { - for (const message of response.playerMessages) { - this.game.log(message); + const playerMessages = response.playerMessages?.length + ? response.playerMessages + : response.playerMessage + ? [response.playerMessage] + : []; + if (playerMessages.length) { + if (typeof this.game.logResponse === 'function') { + this.game.logResponse(playerMessages); + } else { + for (const message of playerMessages) { + this.game.log(message); + } } - } else if (response.playerMessage) { - this.game.log(response.playerMessage); } } catch (error) { this.pendingState = null; diff --git a/tests/parser/preprocessor.test.ts b/tests/parser/preprocessor.test.ts index 484630c..223e55e 100644 --- a/tests/parser/preprocessor.test.ts +++ b/tests/parser/preprocessor.test.ts @@ -10,4 +10,130 @@ describe('Console gameplay preprocessor', () => { expect(consoleInstance.preprocessGameplayInput('i')).toBe('INVENTORY'); expect(consoleInstance.preprocessGameplayInput('q')).toBe('QUIT'); }); + + it('wraps closed-console output by words and preserves forced newlines', () => { + const consoleInstance = new Console({}); + (consoleInstance as { CLOSED_CONSOLE_WRAP_COLUMNS: number }).CLOSED_CONSOLE_WRAP_COLUMNS = 24; + + consoleInstance.log( + 'One two three four five six seven.\nSecond paragraph starts here and keeps going.' + ); + + const lines = consoleInstance.getClosedDisplayLines().map((line) => line.text); + + expect(lines).toEqual([ + 'One two three four five', + 'six seven.', + 'Second paragraph starts', + 'here and keeps going.', + ]); + }); + + it('enters and dismisses closed modal mode for output longer than two wrapped lines', () => { + const consoleInstance = new Console({}); + + consoleInstance.log( + [ + 'This is a deliberately long parser response that should occupy more than one closed-console line.', + 'It keeps adding enough words to pass the two visible lines threshold.', + 'The closed console should expand and wait for continue.', + ].join(' ') + ); + + expect(consoleInstance.isClosedModal).toBe(true); + expect(consoleInstance.preprocessGameplayInput('look')).toBe(''); + expect(consoleInstance.processCommand('#HELP')).toBeUndefined(); + + expect(consoleInstance.continueClosedModal()).toBe(true); + expect(consoleInstance.isClosedModal).toBe(false); + expect(consoleInstance.preprocessGameplayInput('l')).toBe('LOOK'); + }); + + it('enters closed modal mode when a parser response spans several short messages', () => { + const consoleInstance = new Console({}); + (consoleInstance as { CLOSED_CONSOLE_WRAP_COLUMNS: number }).CLOSED_CONSOLE_WRAP_COLUMNS = 80; + + consoleInstance.logResponse([ + 'First short response line.', + 'Second short response line.', + 'Third short response line.', + ]); + + expect(consoleInstance.isClosedModal).toBe(true); + expect( + consoleInstance + .getClosedDisplayLines() + .slice(-3) + .map((line) => line.text) + ).toEqual([ + 'First short response line.', + 'Second short response line.', + 'Third short response line.', + ]); + }); + + it('shows only the latest parser response in closed modal mode', () => { + const consoleInstance = new Console({}); + (consoleInstance as { CLOSED_CONSOLE_WRAP_COLUMNS: number }).CLOSED_CONSOLE_WRAP_COLUMNS = 80; + + consoleInstance.log('LOOK CITY', 'command'); + consoleInstance.logResponse([ + 'First city response line.', + 'Second city response line.', + 'Third city response line.', + ]); + consoleInstance.continueClosedModal(); + + consoleInstance.log('LOOK CITY', 'command'); + consoleInstance.logResponse([ + 'Latest city response line one.', + 'Latest city response line two.', + 'Latest city response line three.', + ]); + + expect(consoleInstance.getClosedDisplayLines().map((line) => line.text)).toContain( + 'First city response line.' + ); + expect(consoleInstance.getClosedModalDisplayLines().map((line) => line.text)).toEqual([ + 'Latest city response line one.', + 'Latest city response line two.', + 'Latest city response line three.', + ]); + }); + + it('opens the full console instead of continuing when toggled from closed modal mode', () => { + const consoleInstance = new Console({}); + + consoleInstance.logResponse([ + 'First short response line.', + 'Second short response line.', + 'Third short response line.', + ]); + + expect(consoleInstance.isClosedModal).toBe(true); + expect(consoleInstance.isOpen).toBe(false); + + consoleInstance.toggle(); + + expect(consoleInstance.isClosedModal).toBe(false); + expect(consoleInstance.isOpen).toBe(true); + }); + + it('keeps technical logs out of the closed console while preserving them in the full buffer', () => { + const consoleInstance = new Console({}); + + consoleInstance.log('Parser peek enabled.', 'info'); + consoleInstance.log('--- CONTEXT ---\n{"huge":true}', 'info', { showInClosed: false }); + consoleInstance.log('You see the city.', 'output'); + + expect(consoleInstance.buffer.map((line) => line.text)).toEqual([ + 'Parser peek enabled.', + '--- CONTEXT ---\n{"huge":true}', + 'You see the city.', + ]); + expect(consoleInstance.getClosedDisplayLines().map((line) => line.text)).toEqual([ + 'Parser peek enabled.', + 'You see the city.', + ]); + }); }); From ea38df4c2f97dea434e53816f61b53d381dfb162 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 7 May 2026 21:13:15 +0200 Subject: [PATCH 24/50] Add TA take refusal overrides --- GDD.md | 4 ++- Parser.md | 25 +++++++++++++- TextAssets.md | 23 ++++++++++++ public/text/objects/boombox.json | 13 +++++-- src/core/TextAssetManager.ts | 50 +++++++++++++++++++-------- src/systems/GameSemanticAPI.ts | 40 ++++++++++++++------- tests/core/text-asset-manager.test.ts | 26 ++++++++++++++ tests/fixtures/gameFactory.ts | 12 ++++--- tests/fixtures/parserFactory.ts | 24 ++++++++++++- tests/fixtures/sceneFactory.ts | 2 ++ tests/fixtures/textAssetFactory.ts | 13 +++++-- tests/game/semantic-api.test.ts | 42 ++++++++++++++++++++++ tests/parser/llm-parser.test.ts | 20 +++++++++++ 13 files changed, 256 insertions(+), 38 deletions(-) create mode 100644 tests/core/text-asset-manager.test.ts diff --git a/GDD.md b/GDD.md index 59c3eca..668882f 100644 --- a/GDD.md +++ b/GDD.md @@ -664,7 +664,9 @@ Static и Actor могут содержать скриптовые событи Текстовый asset содержит стандартные (используемые движком) поля, а также может содержать дополнительные (кастомные). -> сейчас стандартными текстовыми полями считаются `title` и `description`. +Для object TA стандартными player-facing полями считаются `title`, `description`, `details` и `takeFailure`. +`takeFailure` используется стандартным `TAKE`, когда объект распознан, но стандартная runtime-логика определила, что его нельзя взять и иначе вернула бы generic `parser.take_cannot`. Если поле задано непустой строкой, parser выводит этот текст напрямую и не запускает post-API LLM recovery для этого отказа. +Player-facing текстовые поля TA можно задавать строкой или массивом строк; массив строк склеивается через `\n`, а пустые элементы массива становятся пустыми строками в выводе. Кроме текстовых ассетов сцен и объектов, в проекте есть и **служебные TA** для строк самого движка, парсера, UI и скриптов. Они хранятся отдельно, в `public\text\system\`, разбиваются по доменам (`parser.json`, `engine.json`, `scripts.json`, etc) и адресуются по строковым ключам вида `parser.take_prompt` или `engine.click_you_see`. Служебные TA не имеют таблицы переадресации. Это просто словари строк, доступных по ключу. diff --git a/Parser.md b/Parser.md index fe779f5..2ab85e7 100644 --- a/Parser.md +++ b/Parser.md @@ -661,8 +661,9 @@ Parser: - `title` - `description` - `details` +- `takeFailure` -Но и новое опциональное поле: +Также важны опциональные поля: - `synonyms` - `semanticTags` @@ -692,6 +693,27 @@ Parser: - используется действием `EXAMINE`; - тоже входит в стандартный шаблон нового object TA. +Player-facing текстовые поля object/scene TA можно задавать строкой или массивом строк. Массив +склеивается через `\n`, пустая строка внутри массива даёт пустую строку в выводе: + +```json +{ + "details": [ + "First paragraph.", + "", + "Second paragraph." + ] +} +``` + +Поле `takeFailure`: + +- является стандартным player-facing полем object TA; +- используется стандартным `TAKE`, когда runtime определил, что объект не является + берущимся предметом и иначе вернул бы generic `parser.take_cannot`; +- если задано непустой строкой, выводится напрямую и делает failed outcome + terminal (`recoverable: false`), поэтому post-API LLM recovery не запускается. + Поля `semanticTags` и `relationFacts`: - являются authoring metadata для `ParserWorldModelBuilder`; @@ -758,6 +780,7 @@ Supported placeholders in `fact`: "title": "Object", "description": "You see nothing special.", "details": "", + "takeFailure": "", "synonyms": [], "semanticTags": [], "relationFacts": [] diff --git a/TextAssets.md b/TextAssets.md index 6b973fc..46b75a2 100644 --- a/TextAssets.md +++ b/TextAssets.md @@ -33,12 +33,31 @@ Object asset: - `title` - `description` - `details` +- `takeFailure` - `synonyms` - `semanticTags` - `relationFacts` Minimal object assets may still contain only `title` and `description`. Missing optional fields are treated as empty. +## Multiline text + +Any TA field resolved as player-facing text may be authored either as a normal JSON string or as +an array of strings. Arrays are joined with `\n`, and empty strings become blank lines: + +```json +{ + "details": [ + "Line one.", + "", + "Line three after a blank line." + ] +} +``` + +This is valid JSON and is preferred for long descriptions. List fields such as `synonyms`, +`semanticTags`, and `relationFacts.childTags` keep their existing list semantics. + ## Custom text variants Text assets may also contain custom named fields in the same JSON file, for example: @@ -76,6 +95,10 @@ Scripts do not generate text themselves. They only change which named text field - `title` maps to the user-facing object or scene name. - `description` maps to the basic text used by parser/runtime for `look` or `look around`. - `details` maps to the richer text used by parser/runtime for `examine`. +- `takeFailure` overrides the generic `parser.take_cannot` response when the player tries + to `TAKE` this object and standard runtime logic determines that it is not takeable. + Authored `takeFailure` responses are terminal: they are shown directly and do not trigger + post-API LLM recovery. - `synonyms` helps the parser and LLM map player wording to this object. - Existing runtime fields remain as fallback and for backward compatibility. - Parser and UI should read only the resolved standard fields, not custom variant names directly. diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json index 6918e30..a9d0317 100644 --- a/public/text/objects/boombox.json +++ b/public/text/objects/boombox.json @@ -1,8 +1,17 @@ { "title": "Boombox", "description": "The Sharp - small but toothy radio and cassette recorder, connected to the computer.", - "details": "The Sharp GF-7 boombox is connected to the computer using regular audio cables. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. And with the ability to record songs from the radio or capture live audio using the external microphones, the possibilities are endless. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape.", - "synonyms": ["recorder", "radio", "tape recorder", "Sharp", "GF-7"], + "details": [ + "The Sharp GF-7 boombox is a decent little machine.", + "It's hooked up to the computer through a tangle of audio cables.", + "You used to save programs to cassette tapes before you got the floppy drive. Now the tapes mostly hold backups, copied games, and things that probably shouldn't survive power outages.", + "", + "The Commodore can even push sound through it if you know the right tricks. And with the built-in radio and external microphones, the thing can record just about anything.", + "", + "Most days it works fine. Other days the tape head needs a screwdriver and a little faith." + ], + "takeFailure": "You consider hauling the little Sharp around on your shoulder like some street king with a ghetto blaster. \nBut let's be honest. It's not going to make you look cool.. \nBesides, that's what the pocket cassette player is for. Easier to carry. Easier to disappear into.", + "synonyms": ["recorder", "radio", "tape recorder", "player", "Sharp", "GF-7"], "semanticTags": ["device", "audio_device", "media_player"], "relationFacts": [ { diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 5c77de9..dbd12cb 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -23,14 +23,16 @@ type TextAssetValue = | TextAssetStructuredValue[] | Record; type TextAssetData = Record; +type TextAssetTextValue = string | string[]; export type SceneTextAssetData = TextAssetData & { - title?: string; - description?: string; + title?: TextAssetTextValue; + description?: TextAssetTextValue; }; export type ObjectTextAssetData = TextAssetData & { - title?: string; - description?: string; - details?: string; + title?: TextAssetTextValue; + description?: TextAssetTextValue; + details?: TextAssetTextValue; + takeFailure?: TextAssetTextValue; synonyms?: string[]; semanticTags?: string[]; relationFacts?: TextAssetStructuredValue[]; @@ -460,6 +462,7 @@ export class TextAssetManager { title: fallbackTitle, description: fallbackDescription, details: '', + takeFailure: '', synonyms: [], }; } @@ -719,12 +722,13 @@ export class TextAssetManager { const domainAsset = this.serviceCache.get(domain) || {}; const template = domainAsset[entryKey]; - if (typeof template !== 'string') { + const text = this.resolveTextValue(template); + if (text === null) { console.warn(`[TextAssetManager] Missing service text '${rawKey}'.`); return fallback || rawKey; } - return this.interpolate(template, params); + return this.interpolate(text, params); } private resolveField( @@ -737,16 +741,25 @@ export class TextAssetManager { const redirectTarget = redirects && redirects[field]; if (redirectTarget) { const redirected = asset[redirectTarget]; - if (typeof redirected === 'string') return redirected; + const redirectedText = this.resolveTextValue(redirected); + if (redirectedText !== null) return redirectedText; console.warn( `[TextAssetManager] Missing redirected field '${redirectTarget}' for '${field}'.` ); } const direct = asset[field]; - if (typeof direct === 'string') return direct; + const directText = this.resolveTextValue(direct); + if (directText !== null) return directText; return fallback; } + private resolveTextValue(value: TextAssetValue | undefined): string | null { + if (typeof value === 'string') return value; + if (!Array.isArray(value)) return null; + if (!value.every((item) => typeof item === 'string')) return null; + return value.join('\n'); + } + private resolveListField(asset: TextAssetData | null | undefined, field: string): string[] { const raw = asset?.[field]; if (!Array.isArray(raw)) return []; @@ -807,17 +820,26 @@ export class TextAssetManager { private normalizeSceneAssetData(asset: TextAssetData | null): SceneTextAssetData | null { if (!asset) return null; const normalized: SceneTextAssetData = { ...asset }; - if (typeof asset.title === 'string') normalized.title = asset.title; - if (typeof asset.description === 'string') normalized.description = asset.description; + if (this.resolveTextValue(asset.title) !== null) { + normalized.title = asset.title as TextAssetTextValue; + } + if (this.resolveTextValue(asset.description) !== null) + normalized.description = asset.description as TextAssetTextValue; return normalized; } private normalizeObjectAssetData(asset: TextAssetData | null): ObjectTextAssetData | null { if (!asset) return null; const normalized: ObjectTextAssetData = { ...asset }; - if (typeof asset.title === 'string') normalized.title = asset.title; - if (typeof asset.description === 'string') normalized.description = asset.description; - if (typeof asset.details === 'string') normalized.details = asset.details; + if (this.resolveTextValue(asset.title) !== null) { + normalized.title = asset.title as TextAssetTextValue; + } + if (this.resolveTextValue(asset.description) !== null) + normalized.description = asset.description as TextAssetTextValue; + if (this.resolveTextValue(asset.details) !== null) + normalized.details = asset.details as TextAssetTextValue; + if (this.resolveTextValue(asset.takeFailure) !== null) + normalized.takeFailure = asset.takeFailure as TextAssetTextValue; normalized.synonyms = this.resolveListField(asset, 'synonyms'); return normalized; } diff --git a/src/systems/GameSemanticAPI.ts b/src/systems/GameSemanticAPI.ts index abf91bd..178413d 100644 --- a/src/systems/GameSemanticAPI.ts +++ b/src/systems/GameSemanticAPI.ts @@ -50,6 +50,11 @@ export class GameSemanticAPI { return title && title.trim() ? title.trim() : null; } + private getAuthoredTakeFailure(target: SceneObject): string | null { + const message = this.game.textAssets.getResolvedObjectField(target as any, 'takeFailure'); + return message && message.trim() ? message.trim() : null; + } + private getRelationDisplayText(relation: SpatialRelationType): string { switch (relation) { case 'in': @@ -805,10 +810,15 @@ export class GameSemanticAPI { }; } + const objectDescription = this.game.textAssets.getResolvedObjectField(entity, 'description'); + const runtimeDescription = + typeof (entity as any).description === 'string' ? (entity as any).description : null; + const description = objectDescription || runtimeDescription; + const details = this.game.textAssets.getResolvedObjectField(entity, 'details'); if (details && details.trim()) { if (entity instanceof Entity && this.game.inventoryManager.isEntityInInventory(entity)) { - this.game.openInventoryPreview(entity, details); + this.game.openInventoryPreview(entity, description); } return { status: 'ok', @@ -818,10 +828,6 @@ export class GameSemanticAPI { }; } - const objectDescription = this.game.textAssets.getResolvedObjectField(entity, 'description'); - const runtimeDescription = - typeof (entity as any).description === 'string' ? (entity as any).description : null; - const description = objectDescription || runtimeDescription; if (description && description.trim()) { if (entity instanceof Entity && this.game.inventoryManager.isEntityInInventory(entity)) { this.game.openInventoryPreview(entity, description); @@ -1142,12 +1148,15 @@ export class GameSemanticAPI { if (!inventorySlot || this.game.inventoryManager.isPlayerInventoryOwner(inventoryOwner)) { const errorMsg = ComponentSystem.canTakeItem(entity, scene.player); if (errorMsg) { + const authoredTakeFailure = this.getAuthoredTakeFailure(entity); + const genericTakeFailure = this.game.text('parser.take_cannot'); + const useAuthoredFailure = !!authoredTakeFailure && errorMsg === genericTakeFailure; return { status: 'failed', code: 'cannot_take', - message: errorMsg, + message: useAuthoredFailure ? authoredTakeFailure : errorMsg, data: { entityId: entity.name }, - recoverable: true, + recoverable: useAuthoredFailure ? false : true, }; } } @@ -1208,12 +1217,13 @@ export class GameSemanticAPI { }; } + const authoredTakeFailure = this.getAuthoredTakeFailure(entity); return { status: 'failed', code: 'not_takeable', - message: this.game.text('parser.take_cannot'), + message: authoredTakeFailure || this.game.text('parser.take_cannot'), data: { entityId: entity.name }, - recoverable: true, + recoverable: authoredTakeFailure ? false : true, }; } @@ -1253,24 +1263,28 @@ export class GameSemanticAPI { if (!inventorySlot || this.game.inventoryManager.isPlayerInventoryOwner(inventoryOwner)) { const errorMsg = ComponentSystem.canTakeItem(entity, scene.player); if (errorMsg) { + const authoredTakeFailure = this.getAuthoredTakeFailure(entity); + const genericTakeFailure = this.game.text('parser.take_cannot'); + const useAuthoredFailure = !!authoredTakeFailure && errorMsg === genericTakeFailure; return { status: 'failed', code: 'cannot_take', - message: errorMsg, + message: useAuthoredFailure ? authoredTakeFailure : errorMsg, data: { entityId: entity.name }, - recoverable: true, + recoverable: useAuthoredFailure ? false : true, }; } } const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); if (!(isItem || entity.isTakeable)) { + const authoredTakeFailure = this.getAuthoredTakeFailure(entity); return { status: 'failed', code: 'not_takeable', - message: this.game.text('parser.take_cannot'), + message: authoredTakeFailure || this.game.text('parser.take_cannot'), data: { entityId: entity.name }, - recoverable: true, + recoverable: authoredTakeFailure ? false : true, }; } diff --git a/tests/core/text-asset-manager.test.ts b/tests/core/text-asset-manager.test.ts new file mode 100644 index 0000000..5b1e2b4 --- /dev/null +++ b/tests/core/text-asset-manager.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { TextAssetManager } from '../../src/core/TextAssetManager'; + +describe('TextAssetManager', () => { + it('resolves string arrays as multiline text fields', () => { + const textAssets = new TextAssetManager(); + (textAssets as any).objectCache.set('boombox', { + title: 'Boombox', + details: ['Line one.', '', 'Line three.'], + }); + (textAssets as any).sceneCache.set('test_room', { + description: ['Scene line one.', 'Scene line two.'], + }); + (textAssets as any).serviceCache.set('parser', { + parse_unknown: ['I do not', 'understand.'], + }); + + expect( + textAssets.getResolvedObjectField({ name: 'boombox', textRedirects: {} } as any, 'details') + ).toBe('Line one.\n\nLine three.'); + expect( + textAssets.getResolvedSceneField({ id: 'test_room', textRedirects: {} } as any, 'description') + ).toBe('Scene line one.\nScene line two.'); + expect(textAssets.getServiceText('parser.parse_unknown')).toBe('I do not\nunderstand.'); + }); +}); diff --git a/tests/fixtures/gameFactory.ts b/tests/fixtures/gameFactory.ts index 8603d68..b45e70d 100644 --- a/tests/fixtures/gameFactory.ts +++ b/tests/fixtures/gameFactory.ts @@ -289,23 +289,27 @@ export function createTestGame(): TestGameHarness { const inventoryOwner = (game.inventoryManager as any).findInventoryOwnerForEntity?.(entity); const errorMsg = ComponentSystem.canTakeItem(entity, scene.player); if (errorMsg) { + const authoredTakeFailure = textAssets.getResolvedObjectField(entity, 'takeFailure')?.trim(); + const genericTakeFailure = textAssets.getServiceText('parser.take_cannot'); + const useAuthoredFailure = !!authoredTakeFailure && errorMsg === genericTakeFailure; return { status: 'failed', code: 'cannot_take', - message: errorMsg, + message: useAuthoredFailure ? authoredTakeFailure : errorMsg, data: { entityId: entity.name }, - recoverable: true, + recoverable: useAuthoredFailure ? false : true, }; } const isItem = entity.components?.some((component: any) => component?.type === 'Item'); if (!isItem && !entity.isTakeable) { + const authoredTakeFailure = textAssets.getResolvedObjectField(entity, 'takeFailure')?.trim(); return { status: 'failed', code: 'not_takeable', - message: textAssets.getServiceText('parser.take_cannot'), + message: authoredTakeFailure || textAssets.getServiceText('parser.take_cannot'), data: { entityId: entity.name }, - recoverable: true, + recoverable: authoredTakeFailure ? false : true, }; } diff --git a/tests/fixtures/parserFactory.ts b/tests/fixtures/parserFactory.ts index f6a8cd2..8cc533a 100644 --- a/tests/fixtures/parserFactory.ts +++ b/tests/fixtures/parserFactory.ts @@ -218,7 +218,29 @@ export function createParserFixture(): ParserFixture { if (accessOutcome) return accessOutcome; const error = ComponentSystem.canTakeItem(entity as any, fixture.scene.player); if (error) { - return { status: 'failed', code: 'cannot_take', message: error, recoverable: true }; + const authoredTakeFailure = fixture.textAssets + .getResolvedObjectField(entity, 'takeFailure') + ?.trim(); + const genericTakeFailure = fixture.game.text('parser.take_cannot'); + const useAuthoredFailure = !!authoredTakeFailure && error === genericTakeFailure; + return { + status: 'failed', + code: 'cannot_take', + message: useAuthoredFailure ? authoredTakeFailure : error, + recoverable: useAuthoredFailure ? false : true, + }; + } + const isItem = entity.components?.some((component: any) => component?.type === 'Item'); + if (!isItem && !entity.isTakeable) { + const authoredTakeFailure = fixture.textAssets + .getResolvedObjectField(entity, 'takeFailure') + ?.trim(); + return { + status: 'failed', + code: 'not_takeable', + message: authoredTakeFailure || fixture.game.text('parser.take_cannot'), + recoverable: authoredTakeFailure ? false : true, + }; } fixture.scene.removeEntity(entity); (entity as any).spatial = null; diff --git a/tests/fixtures/sceneFactory.ts b/tests/fixtures/sceneFactory.ts index ae02334..e4479cd 100644 --- a/tests/fixtures/sceneFactory.ts +++ b/tests/fixtures/sceneFactory.ts @@ -9,6 +9,7 @@ import { createTestGame, type TestGameHarness } from './gameFactory'; type EntityOptions = { title?: string | null; description?: string; + takeFailure?: string; disabled?: boolean; groupID?: string | null; components?: any[]; @@ -95,6 +96,7 @@ export function createSceneFixture(sceneId: string = 'test_scene'): SceneFixture ? {} : { title: options.title !== undefined ? options.title : name }), description: entity.description, + takeFailure: options.takeFailure, semanticTags: options.semanticTags, relationFacts: options.relationFacts as any, }); diff --git a/tests/fixtures/textAssetFactory.ts b/tests/fixtures/textAssetFactory.ts index 5c1f5d6..0afb844 100644 --- a/tests/fixtures/textAssetFactory.ts +++ b/tests/fixtures/textAssetFactory.ts @@ -221,6 +221,13 @@ function interpolate(template: string, params?: Record) }); } +function resolveTextValue(value: unknown): string | null { + if (typeof value === 'string') return value; + if (!Array.isArray(value)) return null; + if (!value.every((item) => typeof item === 'string')) return null; + return value.join('\n'); +} + export function createTestTextAssets(): TestTextAssets { const objectAssets = new Map(); const sceneAssets = new Map(); @@ -239,7 +246,8 @@ export function createTestTextAssets(): TestTextAssets { } const asset = objectAssets.get(obj.name); const value = asset?.[field]; - if (typeof value === 'string') return value; + const text = resolveTextValue(value); + if (text !== null) return text; if ( field === 'description' && typeof (obj as { description?: unknown }).description === 'string' @@ -264,7 +272,8 @@ export function createTestTextAssets(): TestTextAssets { getResolvedSceneField(scene, field) { const asset = sceneAssets.get(scene.id); const value = asset?.[field]; - if (typeof value === 'string') return value; + const text = resolveTextValue(value); + if (text !== null) return text; if (field === 'description' && typeof scene.description === 'string') return scene.description || null; return null; diff --git a/tests/game/semantic-api.test.ts b/tests/game/semantic-api.test.ts index b583a8d..e5008ae 100644 --- a/tests/game/semantic-api.test.ts +++ b/tests/game/semantic-api.test.ts @@ -96,6 +96,25 @@ describe('Game semantic API', () => { expect(fallback.message).toBe('Cassette recorder.'); }); + it('resolves object text fields from arrays of lines', () => { + 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: ['Line one.', '', 'Line three.'], + }); + + const outcome = fixture.game.examineEntity(boombox); + + expect(outcome.status).toBe('ok'); + expect(outcome.message).toBe('Line one.\n\nLine three.'); + }); + it('examineEntity reveals an examinable hidden object', () => { const fixture = createGameSemanticFixture(); fixture.addPlayer('Hero', 0, 0); @@ -358,6 +377,29 @@ describe('Game semantic API', () => { }); }); + it('uses object TA takeFailure as a terminal not-takeable response', () => { + const fixture = createGameSemanticFixture(); + fixture.addPlayer('Hero', 0, 0); + const marbleColumn = fixture.addEntity('marble_column', { + title: 'Marble column', + description: 'A load-bearing marble column.', + takeFailure: 'The column is doing important architectural work.', + }); + + const outcome = fixture.game.takeEntity(marbleColumn); + const preflight = Game.prototype.canTakeEntity.call(fixture.game, marbleColumn); + + expect(outcome).toEqual({ + status: 'failed', + code: 'not_takeable', + message: 'The column is doing important architectural work.', + data: { entityId: 'marble_column' }, + recoverable: false, + }); + expect(preflight).toEqual(outcome); + expect(fixture.game.inventory).not.toContain(marbleColumn); + }); + it('requires an explicit player inventory for inventory commands', () => { const fixture = createGameSemanticFixture(); const player = fixture.addPlayer('Hero', 0, 0); diff --git a/tests/parser/llm-parser.test.ts b/tests/parser/llm-parser.test.ts index b82c87c..262c86e 100644 --- a/tests/parser/llm-parser.test.ts +++ b/tests/parser/llm-parser.test.ts @@ -227,6 +227,26 @@ describe('Parser LLM Integration', () => { expect(fixture.messages).not.toContain(fixture.game.text('parser.take_cannot')); }); + it('uses object TA takeFailure instead of LLM recovery for non-takeable objects', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.game.console.parserLlmEnabled = true; + fixture.addEntity('book', { + title: 'Book', + description: 'A heavy reference book.', + takeFailure: 'The book is bolted to the lectern.', + }); + + const mockLlmParse = vi.fn(); + fixture.parser.llmCascade.parse = mockLlmParse; + + await fixture.parser.parse('take book'); + + expect(mockLlmParse).not.toHaveBeenCalled(); + expect(fixture.messages).toContain('The book is bolted to the lectern.'); + expect(fixture.messages).not.toContain(fixture.game.text('parser.take_cannot')); + }); + it('keeps the standard parser failure when LLM recovery returns no envelope', async () => { const fixture = createParserFixture(); fixture.addPlayer(); From 1b43375ffb4d32a56023d0bf4aab6d92c2dd3e9b Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 7 May 2026 22:50:15 +0200 Subject: [PATCH 25/50] Imrovement: Click on Inventory Item now work as LOOK command instead of EXAMINE. --- public/text/objects/Chair.json | 3 +- public/text/objects/boombox.json | 2 +- public/text/objects/miles_id.json | 8 +++- src/components/UIOverlay.tsx | 17 +++++++- .../inventory/PlayerInventoryPanel.tsx | 9 ++-- src/mechanics/ParserWorldModelBuilder.ts | 5 ++- src/systems/GameSemanticAPI.ts | 10 ++++- src/systems/InventoryManager.ts | 3 -- tests/game/semantic-api.test.ts | 41 ++++++++++++++++++- 9 files changed, 83 insertions(+), 15 deletions(-) diff --git a/public/text/objects/Chair.json b/public/text/objects/Chair.json index 00bfe18..56c431d 100644 --- a/public/text/objects/Chair.json +++ b/public/text/objects/Chair.json @@ -1,4 +1,5 @@ { "title": "Chair", - "description": "Just an old ordinary office chair." + "description": "Just an old ordinary office chair.", + "takeFailure": "A man carrying his own desk chair out into the city is either moving, fleeing, or very close to a nervous breakdown." } diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json index a9d0317..c188a76 100644 --- a/public/text/objects/boombox.json +++ b/public/text/objects/boombox.json @@ -10,7 +10,7 @@ "", "Most days it works fine. Other days the tape head needs a screwdriver and a little faith." ], - "takeFailure": "You consider hauling the little Sharp around on your shoulder like some street king with a ghetto blaster. \nBut let's be honest. It's not going to make you look cool.. \nBesides, that's what the pocket cassette player is for. Easier to carry. Easier to disappear into.", + "takeFailure": "You consider hauling the little Sharp around on your shoulder like some street king with a ghetto blaster. \nShame carrying it around alone would make you look considerably less sharp. \nBesides, that's what the pocket cassette player is for. Easier to carry. Easier to disappear into.", "synonyms": ["recorder", "radio", "tape recorder", "player", "Sharp", "GF-7"], "semanticTags": ["device", "audio_device", "media_player"], "relationFacts": [ diff --git a/public/text/objects/miles_id.json b/public/text/objects/miles_id.json index c78544d..7839940 100644 --- a/public/text/objects/miles_id.json +++ b/public/text/objects/miles_id.json @@ -1,4 +1,10 @@ { "title": "ID card", - "description": "You see nothing special." + "description": "It's your employee ID badge. \nYou can put it on clothing.", + "details": [ + "This is a laminated card with a colorful print and a metal clip for attaching to clothing. It looks impressive and corporate.", + "It's immediately obvious that Syntrack doesn't skimp on the little things. You're pleased to see your name on this.", + "The photo came out surprisingly well. You look like someone who makes responsible decisions." + ], + "synonyms": ["id", "badge"] } diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index c80aa4c..b12fc85 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -144,6 +144,10 @@ export const UIOverlay: React.FC = ({ game }) => { return () => window.removeEventListener('keydown', handleKeyDown); }, [choiceDialog, handleChoiceResolve]); + const continueConsoleModalFirst = React.useCallback(() => { + return game?.console.continueClosedModal() || false; + }, [game]); + return ( <>
@@ -312,9 +316,18 @@ export const UIOverlay: React.FC = ({ game }) => {
game.closeInventoryPreview()} + onClick={() => { + if (continueConsoleModalFirst()) return; + game.closeInventoryPreview(); + }} > -
e.stopPropagation()}> +
{ + e.stopPropagation(); + continueConsoleModalFirst(); + }} + >
diff --git a/src/components/inventory/PlayerInventoryPanel.tsx b/src/components/inventory/PlayerInventoryPanel.tsx index 63571ef..e73369b 100644 --- a/src/components/inventory/PlayerInventoryPanel.tsx +++ b/src/components/inventory/PlayerInventoryPanel.tsx @@ -46,6 +46,9 @@ export const PlayerInventoryPanel: React.FC = ({ game
{inventoryItems.map((item: Entity) => { const title = game.textAssets.getResolvedObjectField(item, 'title') || item.name; + const objectDescription = game.textAssets.getResolvedObjectField(item, 'description'); + const runtimeDescription = typeof item.description === 'string' ? item.description : null; + const description = objectDescription || runtimeDescription; const isActive = previewedItem === item; return (
+ )} + {quads.length > 0 && (